[Fix] websocket connecting and patchDB connection monitoring (#1738)

* refactor how we handle rpc responses and patchdb connection monitoring

* websockets only

* remove unused global error handlers

* chore: clear storage inside auth service

* feat: convert all global toasts to declarative approach (#1754)

* no more reference to serverID

Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: waterplea <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2022-08-22 10:53:52 -06:00
committed by GitHub
parent 70baed88f4
commit 3ddeb5fa94
101 changed files with 1177 additions and 1298 deletions

View File

@@ -2,23 +2,22 @@ pub mod model;
pub mod package; pub mod package;
pub mod util; pub mod util;
use std::borrow::Cow;
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use color_eyre::eyre::eyre;
use futures::{FutureExt, SinkExt, StreamExt}; use futures::{FutureExt, SinkExt, StreamExt};
use patch_db::json_ptr::JsonPointer; use patch_db::json_ptr::JsonPointer;
use patch_db::{Dump, Revision}; use patch_db::{Dump, Revision};
use rpc_toolkit::command; use rpc_toolkit::command;
use rpc_toolkit::hyper::upgrade::Upgraded; use rpc_toolkit::hyper::upgrade::Upgraded;
use rpc_toolkit::hyper::{Body, Error as HyperError, Request, Response}; use rpc_toolkit::hyper::{Body, Error as HyperError, Request, Response};
use rpc_toolkit::yajrc::{GenericRpcMethod, RpcError, RpcResponse}; use rpc_toolkit::yajrc::RpcError;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tokio::sync::{broadcast, oneshot}; use tokio::sync::{broadcast, oneshot};
use tokio::task::JoinError; use tokio::task::JoinError;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tracing::instrument; use tracing::instrument;
@@ -30,11 +29,12 @@ use crate::middleware::auth::{HasValidSession, HashSessionToken};
use crate::util::serde::{display_serializable, IoFormat}; use crate::util::serde::{display_serializable, IoFormat};
use crate::{Error, ResultExt}; use crate::{Error, ResultExt};
#[instrument(skip(ctx, ws_fut))] #[instrument(skip(ctx, session, ws_fut))]
async fn ws_handler< async fn ws_handler<
WSFut: Future<Output = Result<Result<WebSocketStream<Upgraded>, HyperError>, JoinError>>, WSFut: Future<Output = Result<Result<WebSocketStream<Upgraded>, HyperError>, JoinError>>,
>( >(
ctx: RpcContext, ctx: RpcContext,
session: Option<(HasValidSession, HashSessionToken)>,
ws_fut: WSFut, ws_fut: WSFut,
) -> Result<(), Error> { ) -> Result<(), Error> {
let (dump, sub) = ctx.db.dump_and_sub().await; let (dump, sub) = ctx.db.dump_and_sub().await;
@@ -43,50 +43,21 @@ async fn ws_handler<
.with_kind(crate::ErrorKind::Network)? .with_kind(crate::ErrorKind::Network)?
.with_kind(crate::ErrorKind::Unknown)?; .with_kind(crate::ErrorKind::Unknown)?;
let (has_valid_session, token) = loop { if let Some((session, token)) = session {
if let Some(Message::Text(cookie)) = stream let kill = subscribe_to_session_kill(&ctx, token).await;
.next() send_dump(session, &mut stream, dump).await?;
deal_with_messages(session, kill, sub, stream).await?;
} else {
stream
.close(Some(CloseFrame {
code: CloseCode::Error,
reason: "UNAUTHORIZED".into(),
}))
.await .await
.transpose() .with_kind(crate::ErrorKind::Network)?;
.with_kind(crate::ErrorKind::Network)? }
{
let cookie_str = serde_json::from_str::<Cow<str>>(&cookie)
.with_kind(crate::ErrorKind::Deserialization)?;
let id = basic_cookies::Cookie::parse(&cookie_str)
.with_kind(crate::ErrorKind::Authorization)?
.into_iter()
.find(|c| c.get_name() == "session")
.ok_or_else(|| {
Error::new(eyre!("UNAUTHORIZED"), crate::ErrorKind::Authorization)
})?;
let authenticated_session = HashSessionToken::from_cookie(&id);
match HasValidSession::from_session(&authenticated_session, &ctx).await {
Err(e) => {
stream
.send(Message::Text(
serde_json::to_string(
&RpcResponse::<GenericRpcMethod<String>>::from_result(Err::<
_,
RpcError,
>(
e.into()
)),
)
.with_kind(crate::ErrorKind::Serialization)?,
))
.await
.with_kind(crate::ErrorKind::Network)?;
return Ok(());
}
Ok(has_validation) => break (has_validation, authenticated_session),
}
}
};
let kill = subscribe_to_session_kill(&ctx, token).await;
send_dump(has_valid_session, &mut stream, dump).await?;
deal_with_messages(has_valid_session, kill, sub, stream).await?;
Ok(()) Ok(())
} }
@@ -115,39 +86,25 @@ async fn deal_with_messages(
futures::select! { futures::select! {
_ = (&mut kill).fuse() => { _ = (&mut kill).fuse() => {
tracing::info!("Closing WebSocket: Reason: Session Terminated"); tracing::info!("Closing WebSocket: Reason: Session Terminated");
stream
.close(Some(CloseFrame {
code: CloseCode::Error,
reason: "UNAUTHORIZED".into(),
}))
.await
.with_kind(crate::ErrorKind::Network)?;
return Ok(()) return Ok(())
} }
new_rev = sub.recv().fuse() => { new_rev = sub.recv().fuse() => {
let rev = new_rev.with_kind(crate::ErrorKind::Database)?; let rev = new_rev.with_kind(crate::ErrorKind::Database)?;
stream stream
.send(Message::Text( .send(Message::Text(serde_json::to_string(&rev).with_kind(crate::ErrorKind::Serialization)?))
serde_json::to_string(
&RpcResponse::<GenericRpcMethod<String>>::from_result(Ok::<_, RpcError>(
serde_json::to_value(&rev).with_kind(crate::ErrorKind::Serialization)?,
)),
)
.with_kind(crate::ErrorKind::Serialization)?,
))
.await .await
.with_kind(crate::ErrorKind::Network)?; .with_kind(crate::ErrorKind::Network)?;
} }
message = stream.next().fuse() => { message = stream.next().fuse() => {
let message = message.transpose().with_kind(crate::ErrorKind::Network)?; let message = message.transpose().with_kind(crate::ErrorKind::Network)?;
match message { match message {
Some(Message::Ping(a)) => {
stream
.send(Message::Pong(a))
.await
.with_kind(crate::ErrorKind::Network)?;
}
Some(Message::Close(frame)) => {
if let Some(reason) = frame.as_ref() {
tracing::info!("Closing WebSocket: Reason: {} {}", reason.code, reason.reason);
} else {
tracing::info!("Closing WebSocket: Reason: Unknown");
}
return Ok(())
}
None => { None => {
tracing::info!("Closing WebSocket: Stream Finished"); tracing::info!("Closing WebSocket: Stream Finished");
return Ok(()) return Ok(())
@@ -155,12 +112,6 @@ async fn deal_with_messages(
_ => (), _ => (),
} }
} }
_ = tokio::time::sleep(Duration::from_secs(10)).fuse() => {
stream
.send(Message::Ping(Vec::new()))
.await
.with_kind(crate::ErrorKind::Network)?;
}
} }
} }
} }
@@ -172,13 +123,7 @@ async fn send_dump(
) -> Result<(), Error> { ) -> Result<(), Error> {
stream stream
.send(Message::Text( .send(Message::Text(
serde_json::to_string(&RpcResponse::<GenericRpcMethod<String>>::from_result(Ok::< serde_json::to_string(&dump).with_kind(crate::ErrorKind::Serialization)?,
_,
RpcError,
>(
serde_json::to_value(&dump).with_kind(crate::ErrorKind::Serialization)?,
)))
.with_kind(crate::ErrorKind::Serialization)?,
)) ))
.await .await
.with_kind(crate::ErrorKind::Network)?; .with_kind(crate::ErrorKind::Network)?;
@@ -187,11 +132,27 @@ async fn send_dump(
pub async fn subscribe(ctx: RpcContext, req: Request<Body>) -> Result<Response<Body>, Error> { pub async fn subscribe(ctx: RpcContext, req: Request<Body>) -> Result<Response<Body>, Error> {
let (parts, body) = req.into_parts(); let (parts, body) = req.into_parts();
let session = match async {
let token = HashSessionToken::from_request_parts(&parts)?;
let session = HasValidSession::from_session(&token, &ctx).await?;
Ok::<_, Error>((session, token))
}
.await
{
Ok(a) => Some(a),
Err(e) => {
if e.kind != crate::ErrorKind::Authorization {
tracing::error!("Error Authenticating Websocket: {}", e);
tracing::debug!("{:?}", e);
}
None
}
};
let req = Request::from_parts(parts, body); let req = Request::from_parts(parts, body);
let (res, ws_fut) = hyper_ws_listener::create_ws(req).with_kind(crate::ErrorKind::Network)?; let (res, ws_fut) = hyper_ws_listener::create_ws(req).with_kind(crate::ErrorKind::Network)?;
if let Some(ws_fut) = ws_fut { if let Some(ws_fut) = ws_fut {
tokio::task::spawn(async move { tokio::task::spawn(async move {
match ws_handler(ctx, ws_fut).await { match ws_handler(ctx, session, ws_fut).await {
Ok(()) => (), Ok(()) => (),
Err(e) => { Err(e) => {
tracing::error!("WebSocket Closed: {}", e); tracing::error!("WebSocket Closed: {}", e);

View File

@@ -1,15 +1,13 @@
use std::future::Future; use std::future::Future;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::ops::Deref; use std::ops::{Deref, DerefMut};
use std::ops::DerefMut;
use std::process::Stdio; use std::process::Stdio;
use std::time::{Duration, UNIX_EPOCH}; use std::time::{Duration, UNIX_EPOCH};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use color_eyre::eyre::eyre; use color_eyre::eyre::eyre;
use futures::stream::BoxStream; use futures::stream::BoxStream;
use futures::Stream; use futures::{FutureExt, SinkExt, Stream, StreamExt, TryStreamExt};
use futures::{FutureExt, SinkExt, StreamExt, TryStreamExt};
use hyper::upgrade::Upgraded; use hyper::upgrade::Upgraded;
use hyper::Error as HyperError; use hyper::Error as HyperError;
use rpc_toolkit::command; use rpc_toolkit::command;
@@ -30,7 +28,8 @@ use crate::core::rpc_continuations::{RequestGuid, RpcContinuation};
use crate::error::ResultExt; use crate::error::ResultExt;
use crate::procedure::docker::DockerProcedure; use crate::procedure::docker::DockerProcedure;
use crate::s9pk::manifest::PackageId; use crate::s9pk::manifest::PackageId;
use crate::util::{display_none, serde::Reversible}; use crate::util::display_none;
use crate::util::serde::Reversible;
use crate::{Error, ErrorKind}; use crate::{Error, ErrorKind};
#[pin_project::pin_project] #[pin_project::pin_project]

View File

@@ -12,7 +12,9 @@ use rpc_toolkit::command_helpers::prelude::RequestParts;
use rpc_toolkit::hyper::header::COOKIE; use rpc_toolkit::hyper::header::COOKIE;
use rpc_toolkit::hyper::http::Error as HttpError; use rpc_toolkit::hyper::http::Error as HttpError;
use rpc_toolkit::hyper::{Body, Request, Response}; use rpc_toolkit::hyper::{Body, Request, Response};
use rpc_toolkit::rpc_server_helpers::{noop3, to_response, DynMiddleware, DynMiddlewareStage2}; use rpc_toolkit::rpc_server_helpers::{
noop4, to_response, DynMiddleware, DynMiddlewareStage2, DynMiddlewareStage3,
};
use rpc_toolkit::yajrc::RpcMethod; use rpc_toolkit::yajrc::RpcMethod;
use rpc_toolkit::Metadata; use rpc_toolkit::Metadata;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -198,8 +200,7 @@ pub fn auth<M: Metadata>(ctx: RpcContext) -> DynMiddleware<M> {
|_| StatusCode::OK, |_| StatusCode::OK,
)?)); )?));
} else if rpc_req.method.as_str() == "auth.login" { } else if rpc_req.method.as_str() == "auth.login" {
let mut guard = rate_limiter.lock().await; let guard = rate_limiter.lock().await;
guard.0 += 1;
if guard.1.elapsed() < Duration::from_secs(20) { if guard.1.elapsed() < Duration::from_secs(20) {
if guard.0 >= 3 { if guard.0 >= 3 {
let (res_parts, _) = Response::new(()).into_parts(); let (res_parts, _) = Response::new(()).into_parts();
@@ -216,13 +217,25 @@ pub fn auth<M: Metadata>(ctx: RpcContext) -> DynMiddleware<M> {
|_| StatusCode::OK, |_| StatusCode::OK,
)?)); )?));
} }
}
}
}
let m3: DynMiddlewareStage3 = Box::new(move |_, res| {
async move {
let mut guard = rate_limiter.lock().await;
if guard.1.elapsed() < Duration::from_secs(20) {
if res.is_err() {
guard.0 += 1;
}
} else { } else {
guard.0 = 0; guard.0 = 0;
} }
guard.1 = Instant::now(); guard.1 = Instant::now();
Ok(Ok(noop4()))
} }
} .boxed()
Ok(Ok(noop3())) });
Ok(Ok(m3))
} }
.boxed() .boxed()
}); });

View File

@@ -2,11 +2,6 @@
"useMocks": true, "useMocks": true,
"targetArch": "aarch64", "targetArch": "aarch64",
"ui": { "ui": {
"patchDb": {
"poll": {
"cooldown": 10000
}
},
"api": { "api": {
"url": "rpc", "url": "rpc",
"version": "v1" "version": "v1"

View File

@@ -8,7 +8,6 @@ import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service' import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service' import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service' import { LiveApiService } from './services/api/live-api.service'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { WorkspaceConfig } from '@start9labs/shared' import { WorkspaceConfig } from '@start9labs/shared'
const { useMocks } = require('../../../../config.json') as WorkspaceConfig const { useMocks } = require('../../../../config.json') as WorkspaceConfig
@@ -29,7 +28,6 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
provide: ApiService, provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService, useClass: useMocks ? MockApiService : LiveApiService,
}, },
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
], ],
bootstrap: [AppComponent], bootstrap: [AppComponent],
}) })

View File

@@ -1,13 +0,0 @@
import { ErrorHandler, Injectable } from '@angular/core'
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError (error: any): void {
const chunkFailedMessage = /Loading chunk [\d]+ failed/
if (chunkFailedMessage.test(error.message)) {
window.location.reload()
}
}
}

View File

@@ -12,7 +12,6 @@ import {
} from '@ionic/angular' } from '@ionic/angular'
import { AppComponent } from './app.component' import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module' import { AppRoutingModule } from './app-routing.module'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { SuccessPageModule } from './pages/success/success.module' import { SuccessPageModule } from './pages/success/success.module'
import { HomePageModule } from './pages/home/home.module' import { HomePageModule } from './pages/home/home.module'
import { LoadingPageModule } from './pages/loading/loading.module' import { LoadingPageModule } from './pages/loading/loading.module'
@@ -46,7 +45,6 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
provide: ApiService, provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService, useClass: useMocks ? MockApiService : LiveApiService,
}, },
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
], ],
bootstrap: [AppComponent], bootstrap: [AppComponent],
}) })

View File

@@ -1,14 +0,0 @@
import { ErrorHandler, Injectable } from '@angular/core'
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError (e: any): void {
console.error(e)
const chunkFailedMessage = /Loading chunk [\d]+ failed/
if (chunkFailedMessage.test(e.message)) {
window.location.reload()
}
}
}

View File

@@ -0,0 +1,29 @@
import { Directive, ElementRef, Input } from '@angular/core'
import { AlertButton } from '@ionic/angular'
@Directive({
selector: `button[alertButton], a[alertButton]`,
})
export class AlertButtonDirective implements AlertButton {
@Input()
icon?: string
@Input()
role?: 'cancel' | 'destructive' | string
handler = () => {
this.elementRef.nativeElement.click()
return false
}
constructor(private readonly elementRef: ElementRef<HTMLElement>) {}
get text(): string {
return this.elementRef.nativeElement.textContent?.trim() || ''
}
get cssClass(): string[] {
return Array.from(this.elementRef.nativeElement.classList)
}
}

View File

@@ -0,0 +1,27 @@
import { Directive, ElementRef, Input } from '@angular/core'
import { AlertInput } from '@ionic/angular'
@Directive({
selector: `input[alertInput], textarea[alertInput]`,
})
export class AlertInputDirective<T> implements AlertInput {
@Input()
value?: T
@Input()
label?: string
constructor(private readonly elementRef: ElementRef<HTMLInputElement>) {}
get checked(): boolean {
return this.elementRef.nativeElement.checked
}
get name(): string {
return this.elementRef.nativeElement.name
}
get type(): AlertInput['type'] {
return this.elementRef.nativeElement.type as AlertInput['type']
}
}

View File

@@ -0,0 +1,99 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ContentChildren,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
QueryList,
ViewChild,
} from '@angular/core'
import { AlertController, AlertOptions, IonicSafeString } from '@ionic/angular'
import { OverlayEventDetail } from '@ionic/core'
import { AlertButtonDirective } from './alert-button.directive'
import { AlertInputDirective } from './alert-input.directive'
@Component({
selector: 'alert',
template: `
<div #message><ng-content></ng-content></div>
<ng-content select="[alertInput]"></ng-content>
<ng-content select="[alertButton]"></ng-content>
`,
styles: [':host { display: none !important; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlertComponent<T> implements AfterViewInit, OnDestroy {
@Output()
readonly dismiss = new EventEmitter<OverlayEventDetail<T>>()
@Input()
header = ''
@Input()
subHeader = ''
@Input()
backdropDismiss = true
@ViewChild('message', { static: true })
private readonly content?: ElementRef<HTMLElement>
@ContentChildren(AlertButtonDirective)
private readonly buttons: QueryList<AlertButtonDirective> = new QueryList()
@ContentChildren(AlertInputDirective)
private readonly inputs: QueryList<AlertInputDirective<any>> = new QueryList()
private alert?: HTMLIonAlertElement
constructor(
private readonly elementRef: ElementRef<HTMLElement>,
private readonly controller: AlertController,
) {}
get cssClass(): string[] {
return Array.from(this.elementRef.nativeElement.classList)
}
get message(): IonicSafeString {
return new IonicSafeString(this.content?.nativeElement.innerHTML || '')
}
async ngAfterViewInit() {
this.alert = await this.controller.create(this.getOptions())
this.alert.onDidDismiss().then(event => {
this.dismiss.emit(event)
})
await this.alert.present()
}
async ngOnDestroy() {
await this.alert?.dismiss()
}
private getOptions(): AlertOptions {
const {
header,
subHeader,
message,
cssClass,
buttons,
inputs,
backdropDismiss,
} = this
return {
header,
subHeader,
message,
cssClass,
backdropDismiss,
buttons: buttons.toArray(),
inputs: inputs.toArray(),
}
}
}

View File

@@ -0,0 +1,10 @@
import { NgModule } from '@angular/core'
import { AlertComponent } from './alert.component'
import { AlertButtonDirective } from './alert-button.directive'
import { AlertInputDirective } from './alert-input.directive'
@NgModule({
declarations: [AlertComponent, AlertButtonDirective, AlertInputDirective],
exports: [AlertComponent, AlertButtonDirective, AlertInputDirective],
})
export class AlertModule {}

View File

@@ -0,0 +1,32 @@
import { Directive, ElementRef, Input } from '@angular/core'
import { ToastButton } from '@ionic/angular'
@Directive({
selector: `button[toastButton], a[toastButton]`,
})
export class ToastButtonDirective implements ToastButton {
@Input()
icon?: string
@Input()
side?: 'start' | 'end'
@Input()
role?: 'cancel' | string
handler = () => {
this.elementRef.nativeElement.click()
return false
}
constructor(private readonly elementRef: ElementRef<HTMLElement>) {}
get text(): string | undefined {
return this.elementRef.nativeElement.textContent?.trim() || undefined
}
get cssClass(): string[] {
return Array.from(this.elementRef.nativeElement.classList)
}
}

View File

@@ -0,0 +1,85 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ContentChildren,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
QueryList,
ViewChild,
} from '@angular/core'
import { IonicSafeString, ToastController, ToastOptions } from '@ionic/angular'
import { OverlayEventDetail } from '@ionic/core'
import { ToastButtonDirective } from './toast-button.directive'
@Component({
selector: 'toast',
template: `
<div #message><ng-content></ng-content></div>
<ng-content select="[toastButton]"></ng-content>
`,
styles: [':host { display: none !important; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToastComponent<T> implements AfterViewInit, OnDestroy {
@Output()
readonly dismiss = new EventEmitter<OverlayEventDetail<T>>()
@Input()
header = ''
@Input()
duration = 0
@Input()
position: 'top' | 'bottom' | 'middle' = 'bottom'
@ViewChild('message', { static: true })
private readonly content?: ElementRef<HTMLElement>
@ContentChildren(ToastButtonDirective)
private readonly buttons: QueryList<ToastButtonDirective> = new QueryList()
private toast?: HTMLIonToastElement
constructor(
private readonly elementRef: ElementRef<HTMLElement>,
private readonly controller: ToastController,
) {}
get cssClass(): string[] {
return Array.from(this.elementRef.nativeElement.classList)
}
get message(): IonicSafeString {
return new IonicSafeString(this.content?.nativeElement.innerHTML || '')
}
async ngAfterViewInit() {
this.toast = await this.controller.create(this.getOptions())
this.toast.onDidDismiss().then(event => {
this.dismiss.emit(event)
})
await this.toast.present()
}
async ngOnDestroy() {
await this.toast?.dismiss()
}
private getOptions(): ToastOptions {
const { header, message, duration, position, cssClass, buttons } = this
return {
header,
message,
duration,
position,
cssClass,
buttons: buttons.toArray(),
}
}
}

View File

@@ -0,0 +1,9 @@
import { NgModule } from '@angular/core'
import { ToastComponent } from './toast.component'
import { ToastButtonDirective } from './toast-button.directive'
@NgModule({
declarations: [ToastComponent, ToastButtonDirective],
exports: [ToastComponent, ToastButtonDirective],
})
export class ToastModule {}

View File

@@ -5,10 +5,17 @@
export * from './classes/http-error' export * from './classes/http-error'
export * from './classes/rpc-error' export * from './classes/rpc-error'
export * from './components/alert/alert.component'
export * from './components/alert/alert.module'
export * from './components/alert/alert-button.directive'
export * from './components/alert/alert-input.directive'
export * from './components/markdown/markdown.component' export * from './components/markdown/markdown.component'
export * from './components/markdown/markdown.component.module' export * from './components/markdown/markdown.component.module'
export * from './components/text-spinner/text-spinner.component' export * from './components/text-spinner/text-spinner.component'
export * from './components/text-spinner/text-spinner.component.module' export * from './components/text-spinner/text-spinner.component.module'
export * from './components/toast/toast.component'
export * from './components/toast/toast.module'
export * from './components/toast/toast-button.directive'
export * from './directives/element/element.directive' export * from './directives/element/element.directive'
export * from './directives/element/element.module' export * from './directives/element/element.module'

View File

@@ -4,11 +4,6 @@ export type WorkspaceConfig = {
useMocks: boolean useMocks: boolean
// each key corresponds to a project and values adjust settings for that project, eg: ui, setup-wizard, diagnostic-ui // each key corresponds to a project and values adjust settings for that project, eg: ui, setup-wizard, diagnostic-ui
ui: { ui: {
patchDb: {
poll: {
cooldown: number /* in ms */
}
}
api: { api: {
url: string url: string
version: string version: string

View File

@@ -18,4 +18,5 @@
<ion-footer> <ion-footer>
<footer appFooter></footer> <footer appFooter></footer>
</ion-footer> </ion-footer>
<toast-container></toast-container>
</ion-app> </ion-app>

View File

@@ -1,8 +1,9 @@
import { Component, Inject, OnDestroy } from '@angular/core' import { Component, OnDestroy } from '@angular/core'
import { merge } from 'rxjs'
import { AuthService } from './services/auth.service' import { AuthService } from './services/auth.service'
import { SplitPaneTracker } from './services/split-pane.service' import { SplitPaneTracker } from './services/split-pane.service'
import { merge, Observable } from 'rxjs' import { PatchDataService } from './services/patch-data.service'
import { GLOBAL_SERVICE } from './app/global/global.module' import { PatchMonitorService } from './services/patch-monitor.service'
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -10,13 +11,13 @@ import { GLOBAL_SERVICE } from './app/global/global.module'
styleUrls: ['app.component.scss'], styleUrls: ['app.component.scss'],
}) })
export class AppComponent implements OnDestroy { export class AppComponent implements OnDestroy {
readonly subscription = merge(...this.services).subscribe() readonly subscription = merge(this.patchData, this.patchMonitor).subscribe()
constructor( constructor(
@Inject(GLOBAL_SERVICE) private readonly patchData: PatchDataService,
private readonly services: readonly Observable<unknown>[], private readonly patchMonitor: PatchMonitorService,
readonly authService: AuthService,
private readonly splitPane: SplitPaneTracker, private readonly splitPane: SplitPaneTracker,
readonly authService: AuthService,
) {} ) {}
splitPaneVisible({ detail }: any) { splitPaneVisible({ detail }: any) {

View File

@@ -17,8 +17,8 @@ import { FooterModule } from './app/footer/footer.module'
import { MenuModule } from './app/menu/menu.module' import { MenuModule } from './app/menu/menu.module'
import { EnterModule } from './app/enter/enter.module' import { EnterModule } from './app/enter/enter.module'
import { APP_PROVIDERS } from './app.providers' import { APP_PROVIDERS } from './app.providers'
import { GlobalModule } from './app/global/global.module'
import { PatchDbModule } from './services/patch-db/patch-db.module' import { PatchDbModule } from './services/patch-db/patch-db.module'
import { ToastContainerModule } from './components/toast-container/toast-container.module'
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
@@ -45,8 +45,8 @@ import { PatchDbModule } from './services/patch-db/patch-db.module'
MonacoEditorModule, MonacoEditorModule,
SharedPipesModule, SharedPipesModule,
MarketplaceModule, MarketplaceModule,
GlobalModule,
PatchDbModule, PatchDbModule,
ToastContainerModule,
], ],
providers: APP_PROVIDERS, providers: APP_PROVIDERS,
bootstrap: [AppComponent], bootstrap: [AppComponent],

View File

@@ -5,12 +5,10 @@ import { Router, RouteReuseStrategy } from '@angular/router'
import { IonicRouteStrategy, IonNav } from '@ionic/angular' import { IonicRouteStrategy, IonNav } from '@ionic/angular'
import { Storage } from '@ionic/storage-angular' import { Storage } from '@ionic/storage-angular'
import { WorkspaceConfig } from '@start9labs/shared' import { WorkspaceConfig } from '@start9labs/shared'
import { ApiService } from './services/api/embassy-api.service' import { ApiService } from './services/api/embassy-api.service'
import { MockApiService } from './services/api/embassy-mock-api.service' import { MockApiService } from './services/api/embassy-mock-api.service'
import { LiveApiService } from './services/api/embassy-live-api.service' import { LiveApiService } from './services/api/embassy-live-api.service'
import { BOOTSTRAPPER, PATCH_CACHE } from './services/patch-db/patch-db.factory' import { BOOTSTRAPPER, PATCH_CACHE } from './services/patch-db/patch-db.factory'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { AuthService } from './services/auth.service' import { AuthService } from './services/auth.service'
import { LocalStorageService } from './services/local-storage.service' import { LocalStorageService } from './services/local-storage.service'
import { DataModel } from './services/patch-db/data-model' import { DataModel } from './services/patch-db/data-model'
@@ -30,10 +28,6 @@ export const APP_PROVIDERS: Provider[] = [
provide: ApiService, provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService, useClass: useMocks ? MockApiService : LiveApiService,
}, },
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
},
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
deps: [ deps: [

View File

@@ -1,5 +1,4 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { heightCollapse } from '../../util/animations' import { heightCollapse } from '../../util/animations'
import { PatchDbService } from '../../services/patch-db/patch-db.service' import { PatchDbService } from '../../services/patch-db/patch-db.service'
import { map } from 'rxjs/operators' import { map } from 'rxjs/operators'

View File

@@ -1,54 +0,0 @@
import {
ClassProvider,
ExistingProvider,
Inject,
InjectionToken,
NgModule,
OnDestroy,
Type,
} from '@angular/core'
import { merge, Observable } from 'rxjs'
import { OfflineService } from './services/offline.service'
import { LogoutService } from './services/logout.service'
import { PatchMonitorService } from './services/patch-monitor.service'
import { PatchDataService } from './services/patch-data.service'
import { ConnectionMonitorService } from './services/connection-monitor.service'
import { UnreadToastService } from './services/unread-toast.service'
import { RefreshToastService } from './services/refresh-toast.service'
import { UpdateToastService } from './services/update-toast.service'
export const GLOBAL_SERVICE = new InjectionToken<
readonly Observable<unknown>[]
>('A multi token of global Observable services')
// This module is purely for providers organization purposes
@NgModule({
providers: [
[
ConnectionMonitorService,
LogoutService,
OfflineService,
RefreshToastService,
UnreadToastService,
UpdateToastService,
].map(useClass),
[PatchDataService, PatchMonitorService].map(useExisting),
],
})
export class GlobalModule {}
function useClass(useClass: Type<unknown>): ClassProvider {
return {
provide: GLOBAL_SERVICE,
multi: true,
useClass,
}
}
function useExisting(useExisting: Type<unknown>): ExistingProvider {
return {
provide: GLOBAL_SERVICE,
multi: true,
useExisting,
}
}

View File

@@ -1,21 +0,0 @@
import { Injectable } from '@angular/core'
import { EMPTY, Observable } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { PatchMonitorService } from './patch-monitor.service'
import { ConnectionService } from 'src/app/services/connection.service'
// Start connection monitor upon PatchDb start
@Injectable()
export class ConnectionMonitorService extends Observable<unknown> {
private readonly stream$ = this.patchMonitor.pipe(
switchMap(started => (started ? this.connectionService.start() : EMPTY)),
)
constructor(
private readonly patchMonitor: PatchMonitorService,
private readonly connectionService: ConnectionService,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -1,27 +0,0 @@
import { Injectable, NgZone } from '@angular/core'
import { Router } from '@angular/router'
import { filter, tap } from 'rxjs/operators'
import { Observable } from 'rxjs'
import { AuthService } from 'src/app/services/auth.service'
// Redirect to login page upon broken authorization
@Injectable()
export class LogoutService extends Observable<unknown> {
private readonly stream$ = this.authService.isVerified$.pipe(
filter(verified => !verified),
tap(() => {
this.zone.run(() => {
this.router.navigate(['/login'], { replaceUrl: true })
})
}),
)
constructor(
private readonly authService: AuthService,
private readonly zone: NgZone,
private readonly router: Router,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -1,117 +0,0 @@
import { Injectable } from '@angular/core'
import { ToastController, ToastOptions } from '@ionic/angular'
import { ToastButton } from '@ionic/core'
import { EMPTY, from, Observable } from 'rxjs'
import {
debounceTime,
distinctUntilChanged,
filter,
map,
switchMap,
tap,
} from 'rxjs/operators'
import { AuthService } from 'src/app/services/auth.service'
import {
ConnectionFailure,
ConnectionService,
} from 'src/app/services/connection.service'
// Watch for connection status
@Injectable()
export class OfflineService extends Observable<unknown> {
private current?: HTMLIonToastElement
private readonly connection$ = this.connectionService
.watchFailure$()
.pipe(distinctUntilChanged(), debounceTime(500))
private readonly stream$ = this.authService.isVerified$.pipe(
// Close on logout
tap(() => this.current?.dismiss()),
switchMap(verified => (verified ? this.connection$ : EMPTY)),
// Close on change to connection state
tap(() => this.current?.dismiss()),
filter(connection => connection !== ConnectionFailure.None),
map(getMessage),
switchMap(({ message, link }) =>
this.getToast().pipe(
tap(toast => {
this.current = toast
toast.message = message
toast.buttons = getButtons(link)
toast.present()
}),
),
),
)
constructor(
private readonly authService: AuthService,
private readonly connectionService: ConnectionService,
private readonly toastCtrl: ToastController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private getToast(): Observable<HTMLIonToastElement> {
return from(this.toastCtrl.create(TOAST))
}
}
const TOAST: ToastOptions = {
header: 'Unable to Connect',
cssClass: 'warning-toast',
message: '',
position: 'bottom',
duration: 0,
buttons: [],
}
function getMessage(failure: ConnectionFailure): OfflineMessage {
switch (failure) {
case ConnectionFailure.Network:
return { message: 'Phone or computer has no network connection.' }
case ConnectionFailure.Tor:
return {
message: 'Browser unable to connect over Tor.',
link: 'https://start9.com/latest/support/common-issues',
}
case ConnectionFailure.Lan:
return {
message: 'Embassy not found on Local Area Network.',
link: 'https://start9.com/latest/support/common-issues',
}
default:
return { message: '' }
}
}
function getButtons(link?: string): ToastButton[] {
const buttons: ToastButton[] = [
{
side: 'start',
icon: 'close',
handler: () => true,
},
]
if (link) {
buttons.push({
side: 'end',
text: 'View solutions',
handler: () => {
window.open(link, '_blank', 'noreferrer')
return false
},
})
}
return buttons
}
interface OfflineMessage {
readonly message: string
readonly link?: string
}

View File

@@ -1,52 +0,0 @@
import { Injectable } from '@angular/core'
import { AlertController, AlertOptions } from '@ionic/angular'
import { EMPTY, from, Observable } from 'rxjs'
import { filter, switchMap } from 'rxjs/operators'
import { Emver } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ConfigService } from 'src/app/services/config.service'
import { PatchDataService } from './patch-data.service'
// Watch version to refresh browser window
@Injectable()
export class RefreshToastService extends Observable<unknown> {
private readonly stream$ = this.patchData.pipe(
switchMap(data =>
data ? this.patch.watch$('server-info', 'version') : EMPTY,
),
filter(version => !!this.emver.compare(this.config.version, version)),
switchMap(() => this.getAlert()),
switchMap(alert => alert.present()),
)
constructor(
private readonly patchData: PatchDataService,
private readonly patch: PatchDbService,
private readonly emver: Emver,
private readonly config: ConfigService,
private readonly alertCtrl: AlertController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private getAlert(): Observable<HTMLIonAlertElement> {
return from(this.alertCtrl.create(ALERT))
}
}
const ALERT: AlertOptions = {
backdropDismiss: true,
header: 'Refresh Needed',
message:
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.',
buttons: [
{
text: 'Refresh Page',
cssClass: 'enter-click',
handler: () => {
location.reload()
},
},
],
}

View File

@@ -1,72 +0,0 @@
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { ToastController, ToastOptions } from '@ionic/angular'
import { EMPTY, Observable, ObservableInput } from 'rxjs'
import { filter, pairwise, switchMap, tap } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PatchDataService } from './patch-data.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
// Watch unread notification count to display toast
@Injectable()
export class UnreadToastService extends Observable<unknown> {
private unreadToast?: HTMLIonToastElement
private readonly stream$ = this.patchData.pipe(
switchMap<DataModel | null, ObservableInput<number>>(data => {
if (data) {
return this.patch.watch$('server-info', 'unread-notification-count')
}
this.unreadToast?.dismiss()
return EMPTY
}),
pairwise(),
filter(([prev, cur]) => cur > prev),
tap(() => {
this.showToast()
}),
)
TOAST: ToastOptions = {
header: 'Embassy',
message: `New notifications`,
position: 'bottom',
duration: 4000,
buttons: [
{
side: 'start',
icon: 'close',
handler: () => true,
},
{
side: 'end',
text: 'View',
handler: () => {
this.router.navigate(['/notifications'], {
queryParams: { toast: true },
})
},
},
],
}
constructor(
private readonly router: Router,
private readonly patchData: PatchDataService,
private readonly patch: PatchDbService,
private readonly toastCtrl: ToastController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private async showToast() {
await this.unreadToast?.dismiss()
this.unreadToast = await this.toastCtrl.create(this.TOAST)
this.unreadToast.buttons?.push()
await this.unreadToast.present()
}
}

View File

@@ -1,95 +0,0 @@
import { Injectable } from '@angular/core'
import {
LoadingController,
LoadingOptions,
ToastController,
ToastOptions,
} from '@ionic/angular'
import { EMPTY, Observable } from 'rxjs'
import { distinctUntilChanged, filter, switchMap } from 'rxjs/operators'
import { ErrorToastService } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDataService } from './patch-data.service'
// Watch status to present toast for updated state
@Injectable()
export class UpdateToastService extends Observable<unknown> {
private updateToast?: HTMLIonToastElement
private readonly stream$ = this.patchData.pipe(
switchMap(data => {
if (data) {
return this.patch.watch$('server-info', 'status-info', 'updated')
}
this.errToast.dismiss()
this.updateToast?.dismiss()
return EMPTY
}),
distinctUntilChanged(),
filter(Boolean),
switchMap(() => this.showToast()),
)
constructor(
private readonly patchData: PatchDataService,
private readonly patch: PatchDbService,
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
LOADER: LoadingOptions = {
message: 'Restarting...',
}
TOAST: ToastOptions = {
header: 'EOS download complete!',
message:
'Restart your Embassy for these updates to take effect. It can take several minutes to come back online.',
position: 'bottom',
duration: 0,
cssClass: 'success-toast',
buttons: [
{
side: 'start',
icon: 'close',
handler: () => true,
},
{
side: 'end',
text: 'Restart',
handler: () => {
this.restart()
},
},
],
}
private async showToast() {
await this.updateToast?.dismiss()
this.updateToast = await this.toastCtrl.create(this.TOAST)
await this.updateToast.present()
}
private async restart(): Promise<void> {
const loader = await this.loadingCtrl.create(this.LOADER)
await loader.present()
try {
await this.embassyApi.restartServer({})
} catch (e: any) {
await this.errToast.present(e)
} finally {
await loader.dismiss()
}
}
}

View File

@@ -94,7 +94,7 @@ export class MenuComponent {
// should wipe cache independent of actual BE logout // should wipe cache independent of actual BE logout
private logout() { private logout() {
this.embassyApi.logout({}) this.embassyApi.logout({}).catch(e => console.error('Failed to log out', e))
this.authService.setUnverified() this.authService.setUnverified()
} }
} }

View File

@@ -1,6 +1,6 @@
<div class="wrapper"> <div class="wrapper">
<ion-badge <ion-badge
*ngIf="unreadCount && !sidebarOpen" *ngIf="!(sidebarOpen$ | async) && (unreadCount$ | async) as unreadCount"
mode="md" mode="md"
class="md-badge" class="md-badge"
color="danger" color="danger"

View File

@@ -1,37 +1,19 @@
import { Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { SplitPaneTracker } from 'src/app/services/split-pane.service' import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { combineLatest, Subscription } from 'rxjs'
@Component({ @Component({
selector: 'badge-menu-button', selector: 'badge-menu-button',
templateUrl: './badge-menu.component.html', templateUrl: './badge-menu.component.html',
styleUrls: ['./badge-menu.component.scss'], styleUrls: ['./badge-menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BadgeMenuComponent { export class BadgeMenuComponent {
unreadCount = 0 unreadCount$ = this.patch.watch$('server-info', 'unread-notification-count')
sidebarOpen = false sidebarOpen$ = this.splitPane.sidebarOpen$
subs: Subscription[] = []
constructor( constructor(
private readonly splitPane: SplitPaneTracker, private readonly splitPane: SplitPaneTracker,
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
) {} ) {}
ngOnInit() {
this.subs = [
combineLatest([
this.patch.watch$('server-info', 'unread-notification-count'),
this.splitPane.sidebarOpen$,
]).subscribe(([unread, menu]) => {
this.unreadCount = unread
this.sidebarOpen = menu
}),
]
}
ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe())
}
} }

View File

@@ -17,7 +17,7 @@
id="scroller" id="scroller"
*ngIf="!loading && needInfinite" *ngIf="!loading && needInfinite"
position="top" position="top"
threshold="0" threshold="1000"
(ionInfinite)="doInfinite($event)" (ionInfinite)="doInfinite($event)"
> >
<ion-infinite-scroll-content <ion-infinite-scroll-content

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from '@angular/common' import { Component, Input, ViewChild } from '@angular/core'
import { Component, Inject, Input, ViewChild } from '@angular/core'
import { IonContent, LoadingController } from '@ionic/angular' import { IonContent, LoadingController } from '@ionic/angular'
import { map, takeUntil, timer } from 'rxjs' import { map, takeUntil, timer } from 'rxjs'
import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { WebSocketSubjectConfig } from 'rxjs/webSocket'
@@ -48,11 +47,10 @@ export class LogsComponent {
isOnBottom = true isOnBottom = true
autoScroll = true autoScroll = true
websocketFail = false websocketFail = false
limit = 200 limit = 400
toProcess: Log[] = [] toProcess: Log[] = []
constructor( constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly errToast: ErrorToastService, private readonly errToast: ErrorToastService,
private readonly destroy$: DestroyService, private readonly destroy$: DestroyService,
private readonly api: ApiService, private readonly api: ApiService,
@@ -63,17 +61,13 @@ export class LogsComponent {
async ngOnInit() { async ngOnInit() {
try { try {
const { 'start-cursor': startCursor, guid } = await this.followLogs({ const { 'start-cursor': startCursor, guid } = await this.followLogs({
limit: 100, limit: this.limit,
}) })
this.startCursor = startCursor this.startCursor = startCursor
const host = this.document.location.host
const protocol =
this.document.location.protocol === 'http:' ? 'ws' : 'wss'
const config: WebSocketSubjectConfig<Log> = { const config: WebSocketSubjectConfig<Log> = {
url: `${protocol}://${host}/ws/rpc/${guid}`, url: `/rpc/${guid}`,
openObserver: { openObserver: {
next: () => { next: () => {
console.log('**** LOGS WEBSOCKET OPEN ****') console.log('**** LOGS WEBSOCKET OPEN ****')
@@ -159,7 +153,7 @@ export class LogsComponent {
} }
private processJob() { private processJob() {
timer(0, 500) timer(100, 500)
.pipe( .pipe(
map((_, index) => index), map((_, index) => index),
takeUntil(this.destroy$), takeUntil(this.destroy$),

View File

@@ -1,15 +1,13 @@
<p <p
[style.color]=" [style.color]="
(disconnected$ | async) (connected$ | async) ? 'var(--ion-color-' + rendering.color + ')' : 'gray'
? 'gray'
: 'var(--ion-color-' + rendering.color + ')'
" "
[style.font-size]="size" [style.font-size]="size"
[style.font-style]="style" [style.font-style]="style"
[style.font-weight]="weight" [style.font-weight]="weight"
> >
<span *ngIf="!installProgress"> <span *ngIf="!installProgress">
{{ (disconnected$ | async) ? 'Unknown' : rendering.display }} {{ (connected$ | async) ? rendering.display : 'Unknown' }}
<span *ngIf="rendering.showDots" class="loading-dots"></span> <span *ngIf="rendering.showDots" class="loading-dots"></span>
<span <span
*ngIf=" *ngIf="

View File

@@ -23,7 +23,7 @@ export class StatusComponent {
@Input() installProgress?: InstallProgress @Input() installProgress?: InstallProgress
@Input() sigtermTimeout?: string | null = null @Input() sigtermTimeout?: string | null = null
disconnected$ = this.connectionService.watchDisconnected$() readonly connected$ = this.connectionService.connected$
constructor(private readonly connectionService: ConnectionService) {} constructor(private readonly connectionService: ConnectionService) {}
} }

View File

@@ -0,0 +1,17 @@
<toast
*ngIf="visible$ | async as message"
header="Embassy"
[duration]="4000"
(dismiss)="onDismiss()"
>
New notifications
<button toastButton icon="close" side="start" (click)="onDismiss()"></button>
<a
toastButton
side="end"
routerLink="/notifications"
[queryParams]="{ toast: true }"
>
View
</a>
</toast>

View File

@@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { Observable, Subject, merge } from 'rxjs'
import { NotificationsToastService } from './notifications-toast.service'
@Component({
selector: 'notifications-toast',
templateUrl: './notifications-toast.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationsToastComponent {
private readonly dismiss$ = new Subject<boolean>()
readonly visible$: Observable<boolean> = merge(
this.dismiss$,
this.notifications$,
)
constructor(
@Inject(NotificationsToastService)
private readonly notifications$: Observable<boolean>,
) {}
onDismiss() {
this.dismiss$.next(false)
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core'
import { endWith, Observable } from 'rxjs'
import { filter, map, pairwise } from 'rxjs/operators'
import { exists } from '@start9labs/shared'
import { PatchDbService } from '../../../services/patch-db/patch-db.service'
@Injectable({ providedIn: 'root' })
export class NotificationsToastService extends Observable<boolean> {
private readonly stream$ = this.patch
.watch$('server-info', 'unread-notification-count')
.pipe(
filter(exists),
pairwise(),
map(([prev, cur]) => cur > prev),
endWith(false),
)
constructor(private readonly patch: PatchDbService) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,19 @@
<toast
*ngIf="content$ | async as content"
class="warning-toast"
header="Unable to Connect"
(dismiss)="onDismiss()"
>
{{ content.message }}
<button toastButton icon="close" side="start" (click)="onDismiss()"></button>
<a
*ngIf="content.link"
toastButton
side="end"
target="_blank"
rel="noreferrer"
[href]="content.link"
>
View solutions
</a>
</toast>

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { Observable, Subject, merge, tap, map } from 'rxjs'
import { OfflineMessage, OfflineToastService } from './offline-toast.service'
@Component({
selector: 'offline-toast',
templateUrl: './offline-toast.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OfflineToastComponent {
private readonly dismiss$ = new Subject<null>()
readonly content$ = merge(this.dismiss$, this.failure$)
constructor(
@Inject(OfflineToastService)
private readonly failure$: Observable<OfflineMessage | null>,
) {}
onDismiss() {
this.dismiss$.next(null)
}
}

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@angular/core'
import { combineLatest, Observable, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { AuthService } from 'src/app/services/auth.service'
import { ConnectionService } from 'src/app/services/connection.service'
export interface OfflineMessage {
readonly message: string
readonly link?: string
}
// Watch for connection status
@Injectable({ providedIn: 'root' })
export class OfflineToastService extends Observable<OfflineMessage | null> {
private readonly stream$ = this.authService.isVerified$.pipe(
switchMap(verified => (verified ? this.failure$ : of(null))),
)
private readonly failure$ = combineLatest([
this.connectionService.networkConnected$,
this.connectionService.websocketConnected$,
]).pipe(
map(([network, websocket]) => {
if (!network) return { message: 'No Internet' }
if (!websocket)
return {
message: 'Connecting to Embassy...',
link: 'https://start9.com/latest/support/common-issues',
}
return null
}),
)
constructor(
private readonly authService: AuthService,
private readonly connectionService: ConnectionService,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,5 @@
<alert *ngIf="show$ | async" header="Refresh needed" (dismiss)="onDismiss()">
Your user interface is cached and out of date. Hard refresh the page to get
the latest UI.
<a alertButton class="enter-click" href=".">Refresh Page</a>
</alert>

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { Observable, Subject, merge } from 'rxjs'
import { RefreshAlertService } from './refresh-alert.service'
@Component({
selector: 'refresh-alert',
templateUrl: './refresh-alert.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RefreshAlertComponent {
private readonly dismiss$ = new Subject<boolean>()
readonly show$ = merge(this.dismiss$, this.refresh$)
constructor(
@Inject(RefreshAlertService) private readonly refresh$: Observable<boolean>,
) {}
onDismiss() {
this.dismiss$.next(false)
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core'
import { endWith, Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { Emver } from '@start9labs/shared'
import { PatchDbService } from '../../../services/patch-db/patch-db.service'
import { ConfigService } from '../../../services/config.service'
// Watch for connection status
@Injectable({ providedIn: 'root' })
export class RefreshAlertService extends Observable<boolean> {
private readonly stream$ = this.patch.watch$('server-info', 'version').pipe(
map(version => !!this.emver.compare(this.config.version, version)),
endWith(false),
)
constructor(
private readonly patch: PatchDbService,
private readonly emver: Emver,
private readonly config: ConfigService,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,4 @@
<notifications-toast></notifications-toast>
<offline-toast></offline-toast>
<refresh-alert></refresh-alert>
<update-toast></update-toast>

View File

@@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'toast-container',
templateUrl: './toast-container.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToastContainerComponent {}

View File

@@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { AlertModule, ToastModule } from '@start9labs/shared'
import { ToastContainerComponent } from './toast-container.component'
import { NotificationsToastComponent } from './notifications-toast/notifications-toast.component'
import { OfflineToastComponent } from './offline-toast/offline-toast.component'
import { RefreshAlertComponent } from './refresh-alert/refresh-alert.component'
import { UpdateToastComponent } from './update-toast/update-toast.component'
@NgModule({
imports: [CommonModule, ToastModule, AlertModule, RouterModule],
declarations: [
ToastContainerComponent,
NotificationsToastComponent,
OfflineToastComponent,
RefreshAlertComponent,
UpdateToastComponent,
],
exports: [ToastContainerComponent],
})
export class ToastContainerModule {}

View File

@@ -0,0 +1,11 @@
<toast
*ngIf="visible$ | async as message"
class="success-toast"
header="EOS download complete!"
(dismiss)="onDismiss()"
>
Restart your Embassy for these updates to take effect. It can take several
minutes to come back online.
<button toastButton icon="close" side="start" (click)="onDismiss()"></button>
<button toastButton side="end" (click)="restart()">Restart</button>
</toast>

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { LoadingController } from '@ionic/angular'
import { ErrorToastService } from '@start9labs/shared'
import { Observable, Subject, merge } from 'rxjs'
import { UpdateToastService } from './update-toast.service'
import { ApiService } from '../../../services/api/embassy-api.service'
@Component({
selector: 'update-toast',
templateUrl: './update-toast.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UpdateToastComponent {
private readonly dismiss$ = new Subject<boolean>()
readonly visible$: Observable<boolean> = merge(this.dismiss$, this.update$)
constructor(
@Inject(UpdateToastService) private readonly update$: Observable<boolean>,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
) {}
onDismiss() {
this.dismiss$.next(false)
}
async restart(): Promise<void> {
this.onDismiss()
const loader = await this.loadingCtrl.create({
message: 'Restarting...',
})
await loader.present()
try {
await this.embassyApi.restartServer({})
} catch (e: any) {
await this.errToast.present(e)
} finally {
await loader.dismiss()
}
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core'
import { endWith, Observable } from 'rxjs'
import { distinctUntilChanged, filter } from 'rxjs/operators'
import { PatchDbService } from '../../../services/patch-db/patch-db.service'
@Injectable({ providedIn: 'root' })
export class UpdateToastService extends Observable<boolean> {
private readonly stream$ = this.patch
.watch$('server-info', 'status-info', 'updated')
.pipe(distinctUntilChanged(), filter(Boolean), endWith(false))
constructor(private readonly patch: PatchDbService) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { map, take } from 'rxjs/operators' import { filter, map, take } from 'rxjs/operators'
import { PackageState } from 'src/app/services/patch-db/data-model' import { PackageState } from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@@ -29,6 +29,7 @@ export class BackupSelectPage {
this.patch this.patch
.watch$('package-data') .watch$('package-data')
.pipe( .pipe(
filter(Boolean),
map(pkgs => { map(pkgs => {
return Object.values(pkgs).map(pkg => { return Object.values(pkgs).map(pkg => {
const { id, title } = pkg.manifest const { id, title } = pkg.manifest

View File

@@ -1,11 +1,11 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-buttons slot="start"> <ion-title>{{ title }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()"> <ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon> <ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button> </ion-button>
</ion-buttons> </ion-buttons>
<ion-title>{{ title }}</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>

View File

@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { import {
@@ -22,6 +22,7 @@ import { hasCurrentDeps } from 'src/app/util/has-deps'
selector: 'app-actions', selector: 'app-actions',
templateUrl: './app-actions.page.html', templateUrl: './app-actions.page.html',
styleUrls: ['./app-actions.page.scss'], styleUrls: ['./app-actions.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppActionsPage { export class AppActionsPage {
readonly pkgId = getPkgId(this.route) readonly pkgId = getPkgId(this.route)
@@ -103,7 +104,7 @@ export class AppActionsPage {
} else if (last) { } else if (last) {
statusesStr = `${last}` statusesStr = `${last}`
} else { } else {
error = `There is state for which this action may be run. This is a bug. Please file an issue with the service maintainer.` error = `There is no status for which this action may be run. This is a bug. Please file an issue with the service maintainer.`
} }
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Forbidden', header: 'Forbidden',
@@ -158,10 +159,12 @@ export class AppActionsPage {
try { try {
await this.embassyApi.uninstallPackage({ id: this.pkgId }) await this.embassyApi.uninstallPackage({ id: this.pkgId })
this.embassyApi.setDbValue({ this.embassyApi
pointer: `/ack-instructions/${this.pkgId}`, .setDbValue({
value: false, pointer: `/ack-instructions/${this.pkgId}`,
}) value: false,
})
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navCtrl.navigateRoot('/services') this.navCtrl.navigateRoot('/services')
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)
@@ -185,7 +188,7 @@ export class AppActionsPage {
'action-id': actionId, 'action-id': actionId,
input, input,
}) })
this.modalCtrl.dismiss()
const successModal = await this.modalCtrl.create({ const successModal = await this.modalCtrl.create({
component: ActionSuccessPage, component: ActionSuccessPage,
componentProps: { componentProps: {
@@ -193,8 +196,8 @@ export class AppActionsPage {
}, },
}) })
setTimeout(() => successModal.present(), 400) setTimeout(() => successModal.present(), 500)
return true return false
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)
return false return false
@@ -218,6 +221,7 @@ interface LocalAction {
selector: 'app-actions-item', selector: 'app-actions-item',
templateUrl: './app-actions-item.component.html', templateUrl: './app-actions-item.component.html',
styleUrls: ['./app-actions.page.scss'], styleUrls: ['./app-actions.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppActionsItemComponent { export class AppActionsItemComponent {
@Input() action!: LocalAction @Input() action!: LocalAction

View File

@@ -1,9 +1,4 @@
<div <ng-container *ngIf="connected$ | async; else disconnected">
*ngIf="disconnected$ | async; else connected"
class="bulb"
[style.background-color]="'var(--ion-color-dark)'"
></div>
<ng-template #connected>
<ion-icon <ion-icon
*ngIf="pkg.error; else noError" *ngIf="pkg.error; else noError"
class="warning-icon" class="warning-icon"
@@ -28,4 +23,8 @@
></div> ></div>
</ng-template> </ng-template>
</ng-template> </ng-template>
</ng-container>
<ng-template #disconnected>
<div class="bulb" [style.background-color]="'var(--ion-color-dark)'"></div>
</ng-template> </ng-template>

View File

@@ -12,7 +12,7 @@ export class AppListIconComponent {
@Input() @Input()
pkg!: PkgInfo pkg!: PkgInfo
disconnected$ = this.connectionService.watchDisconnected$() readonly connected$ = this.connectionService.connected$
constructor(private readonly connectionService: ConnectionService) {} constructor(private readonly connectionService: ConnectionService) {}
} }

View File

@@ -11,7 +11,7 @@ import { ErrorToastService } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace' import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, merge, OperatorFunction, pipe, Subject } from 'rxjs' import { from, merge, OperatorFunction, pipe, Subject } from 'rxjs'
import { catchError, mapTo, startWith, switchMap, tap } from 'rxjs/operators' import { catchError, map, startWith, switchMap, tap } from 'rxjs/operators'
import { RecoveredInfo } from 'src/app/util/parse-data-model' import { RecoveredInfo } from 'src/app/util/parse-data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
@@ -103,7 +103,7 @@ function loading(
// Show notification on error // Show notification on error
catchError(e => from(errToast.present(e))), catchError(e => from(errToast.present(e))),
// Map any result to false to stop loading indicator // Map any result to false to stop loading indicator
mapTo(false), map(() => false),
// Start operation with true // Start operation with true
startWith(true), startWith(true),
) )

View File

@@ -9,12 +9,12 @@
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<!-- loading --> <!-- loading -->
<ng-template #loading> <ng-container *ngIf="loading else loaded">
<text-spinner text="Connecting to Embassy"></text-spinner> <text-spinner text="Connecting to Embassy"></text-spinner>
</ng-template> </ng-container>
<!-- not loading --> <!-- loaded -->
<ng-container *ngIf="connected$ | async else loading"> <ng-template #loaded>
<app-list-empty <app-list-empty
*ngIf="empty; else list" *ngIf="empty; else list"
class="ion-text-center ion-padding" class="ion-text-center ion-padding"
@@ -39,5 +39,5 @@
</ion-item-group> </ion-item-group>
</ng-container> </ng-container>
</ng-template> </ng-template>
</ng-container> </ng-template>
</ion-content> </ion-content>

View File

@@ -14,12 +14,11 @@ import { parseDataModel, RecoveredInfo } from 'src/app/util/parse-data-model'
providers: [DestroyService], providers: [DestroyService],
}) })
export class AppListPage { export class AppListPage {
loading = true
pkgs: readonly PackageDataEntry[] = [] pkgs: readonly PackageDataEntry[] = []
recoveredPkgs: readonly RecoveredInfo[] = [] recoveredPkgs: readonly RecoveredInfo[] = []
reordering = false reordering = false
readonly connected$ = this.patch.connected$
constructor( constructor(
private readonly api: ApiService, private readonly api: ApiService,
private readonly destroy$: DestroyService, private readonly destroy$: DestroyService,
@@ -38,6 +37,7 @@ export class AppListPage {
take(1), take(1),
map(parseDataModel), map(parseDataModel),
tap(({ pkgs, recoveredPkgs }) => { tap(({ pkgs, recoveredPkgs }) => {
this.loading = false
this.pkgs = pkgs this.pkgs = pkgs
this.recoveredPkgs = recoveredPkgs this.recoveredPkgs = recoveredPkgs
}), }),

View File

@@ -56,8 +56,8 @@ export class AppShowPage {
readonly currentMarketplace$: Observable<Marketplace> = readonly currentMarketplace$: Observable<Marketplace> =
this.marketplaceService.getMarketplace() this.marketplaceService.getMarketplace()
readonly altMarketplaceData$: Observable<UIMarketplaceData | undefined> = readonly altMarketplaceData$: Observable<UIMarketplaceData> =
this.marketplaceService.getAltMarketplace() this.marketplaceService.getAltMarketplaceData()
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,

View File

@@ -3,19 +3,10 @@
> >
<ng-container *ngIf="checks.length"> <ng-container *ngIf="checks.length">
<ion-item-divider>Health Checks</ion-item-divider> <ion-item-divider>Health Checks</ion-item-divider>
<ng-container *ngIf="disconnected$ | async; else connected"> <!-- connected -->
<ion-item *ngFor="let health of checks"> <ng-container *ngIf="connected$ | async; else disconnected">
<ion-avatar slot="start">
<ion-skeleton-text class="avatar"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text class="label"></ion-skeleton-text>
<ion-skeleton-text class="description"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<ng-template #connected>
<ion-item *ngFor="let health of checks"> <ion-item *ngFor="let health of checks">
<!-- result -->
<ng-container *ngIf="health.value?.result as result; else noResult"> <ng-container *ngIf="health.value?.result as result; else noResult">
<ion-spinner <ion-spinner
*ngIf="isLoading(result)" *ngIf="isLoading(result)"
@@ -69,6 +60,7 @@
</ion-text> </ion-text>
</ion-label> </ion-label>
</ng-container> </ng-container>
<!-- no result -->
<ng-template #noResult> <ng-template #noResult>
<ion-spinner <ion-spinner
class="icon-spinner" class="icon-spinner"
@@ -83,6 +75,18 @@
</ion-label> </ion-label>
</ng-template> </ng-template>
</ion-item> </ion-item>
</ng-container>
<!-- disconnected -->
<ng-template #disconnected>
<ion-item *ngFor="let health of checks">
<ion-avatar slot="start">
<ion-skeleton-text class="avatar"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text class="label"></ion-skeleton-text>
<ion-skeleton-text class="description"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-template> </ng-template>
</ng-container> </ng-container>
</ng-container> </ng-container>

View File

@@ -17,7 +17,7 @@ export class AppShowHealthChecksComponent {
HealthResult = HealthResult HealthResult = HealthResult
readonly disconnected$ = this.connectionService.watchDisconnected$() readonly connected$ = this.connectionService.connected$
constructor(private readonly connectionService: ConnectionService) {} constructor(private readonly connectionService: ConnectionService) {}

View File

@@ -11,7 +11,7 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ng-container *ngIf="isInstalled && !(disconnected$ | async)"> <ng-container *ngIf="isInstalled && (connected$ | async)">
<ion-grid> <ion-grid>
<ion-row style="padding-left: 12px"> <ion-row style="padding-left: 12px">
<ion-col> <ion-col>

View File

@@ -37,7 +37,7 @@ export class AppShowStatusComponent {
PR = PrimaryRendering PR = PrimaryRendering
disconnected$ = this.connectionService.watchDisconnected$() readonly connected$ = this.connectionService.connected$
constructor( constructor(
private readonly alertCtrl: AlertController, private readonly alertCtrl: AlertController,

View File

@@ -117,10 +117,12 @@ export class ToButtonsPipe implements PipeTransform {
} }
private async presentModalInstructions(pkg: PackageDataEntry) { private async presentModalInstructions(pkg: PackageDataEntry) {
this.apiService.setDbValue({ this.apiService
pointer: `/ack-instructions/${pkg.manifest.id}`, .setDbValue({
value: true, pointer: `/ack-instructions/${pkg.manifest.id}`,
}) value: true,
})
.catch(e => console.error('Failed to mark instructions as seen', e))
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
componentProps: { componentProps: {

View File

@@ -8,7 +8,6 @@ import {
DependencyErrorType, DependencyErrorType,
PackageDataEntry, PackageDataEntry,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
import { exists } from '@start9labs/shared'
import { DependentInfo } from 'src/app/types/dependent-info' import { DependentInfo } from 'src/app/types/dependent-info'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ModalService } from 'src/app/services/modal.service' import { ModalService } from 'src/app/services/modal.service'
@@ -49,7 +48,7 @@ export class ToDependenciesPipe implements PipeTransform {
'dependency-errors', 'dependency-errors',
), ),
]).pipe( ]).pipe(
filter(deps => deps.every(exists) && !!pkg.installed), filter(deps => deps.every(Boolean) && !!pkg.installed),
map(([currentDeps, depErrors]) => map(([currentDeps, depErrors]) =>
Object.keys(currentDeps) Object.keys(currentDeps)
.filter(id => !!pkg.manifest.dependencies[id]) .filter(id => !!pkg.manifest.dependencies[id])

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { debounce, exists, ErrorToastService } from '@start9labs/shared' import { debounce, ErrorToastService } from '@start9labs/shared'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import { filter, take } from 'rxjs/operators' import { filter, take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -31,7 +31,7 @@ export class DevConfigPage {
ngOnInit() { ngOnInit() {
this.patchDb this.patchDb
.watch$('ui', 'dev', this.projectId, 'config') .watch$('ui', 'dev', this.projectId, 'config')
.pipe(filter(exists), take(1)) .pipe(filter(Boolean), take(1))
.subscribe(config => { .subscribe(config => {
this.code = config this.code = config
}) })

View File

@@ -5,7 +5,6 @@ import { filter, take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { import {
debounce, debounce,
exists,
ErrorToastService, ErrorToastService,
MarkdownComponent, MarkdownComponent,
} from '@start9labs/shared' } from '@start9labs/shared'
@@ -20,8 +19,8 @@ import { getProjectId } from 'src/app/util/get-project-id'
export class DevInstructionsPage { export class DevInstructionsPage {
readonly projectId = getProjectId(this.route) readonly projectId = getProjectId(this.route)
editorOptions = { theme: 'vs-dark', language: 'markdown' } editorOptions = { theme: 'vs-dark', language: 'markdown' }
code: string = '' code = ''
saving: boolean = false saving = false
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
@@ -34,7 +33,7 @@ export class DevInstructionsPage {
ngOnInit() { ngOnInit() {
this.patchDb this.patchDb
.watch$('ui', 'dev', this.projectId, 'instructions') .watch$('ui', 'dev', this.projectId, 'instructions')
.pipe(filter(exists), take(1)) .pipe(filter(Boolean), take(1))
.subscribe(config => { .subscribe(config => {
this.code = config this.code = config
}) })

View File

@@ -5,7 +5,6 @@ import {
AlertController, AlertController,
LoadingController, LoadingController,
ModalController, ModalController,
NavController,
} from '@ionic/angular' } from '@ionic/angular'
import { import {
GenericInputComponent, GenericInputComponent,
@@ -17,7 +16,6 @@ import { ConfigSpec } from 'src/app/pkg-config/config-types'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { DevData } from 'src/app/services/patch-db/data-model' import { DevData } from 'src/app/services/patch-db/data-model'
import { ActivatedRoute } from '@angular/router'
import { DestroyService, ErrorToastService } from '@start9labs/shared' import { DestroyService, ErrorToastService } from '@start9labs/shared'
import { takeUntil } from 'rxjs/operators' import { takeUntil } from 'rxjs/operators'
@@ -36,8 +34,6 @@ export class DeveloperListPage {
private readonly loadingCtrl: LoadingController, private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService, private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController, private readonly alertCtrl: AlertController,
private readonly navCtrl: NavController,
private readonly route: ActivatedRoute,
private readonly destroy$: DestroyService, private readonly destroy$: DestroyService,
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
private readonly actionCtrl: ActionSheetController, private readonly actionCtrl: ActionSheetController,

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { LoadingController, ModalController } from '@ionic/angular' import { LoadingController, ModalController } from '@ionic/angular'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page' import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
@@ -13,6 +13,7 @@ import { DevProjectData } from 'src/app/services/patch-db/data-model'
selector: 'developer-menu', selector: 'developer-menu',
templateUrl: 'developer-menu.page.html', templateUrl: 'developer-menu.page.html',
styleUrls: ['developer-menu.page.scss'], styleUrls: ['developer-menu.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DeveloperMenuPage { export class DeveloperMenuPage {
readonly projectId = getProjectId(this.route) readonly projectId = getProjectId(this.route)

View File

@@ -3,11 +3,7 @@ import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { Routes, RouterModule } from '@angular/router' import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { import { SharedPipesModule, EmverPipesModule } from '@start9labs/shared'
SharedPipesModule,
EmverPipesModule,
TextSpinnerComponentModule,
} from '@start9labs/shared'
import { import {
FilterPackagesPipeModule, FilterPackagesPipeModule,
CategoriesModule, CategoriesModule,
@@ -16,7 +12,6 @@ import {
SkeletonModule, SkeletonModule,
} from '@start9labs/marketplace' } from '@start9labs/marketplace'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module' import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { MarketplaceStatusModule } from '../marketplace-status/marketplace-status.module' import { MarketplaceStatusModule } from '../marketplace-status/marketplace-status.module'
import { MarketplaceListPage } from './marketplace-list.page' import { MarketplaceListPage } from './marketplace-list.page'
import { MarketplaceListContentComponent } from './marketplace-list-content/marketplace-list-content.component' import { MarketplaceListContentComponent } from './marketplace-list-content/marketplace-list-content.component'
@@ -34,7 +29,6 @@ const routes: Routes = [
IonicModule, IonicModule,
FormsModule, FormsModule,
RouterModule.forChild(routes), RouterModule.forChild(routes),
TextSpinnerComponentModule,
SharedPipesModule, SharedPipesModule,
EmverPipesModule, EmverPipesModule,
FilterPackagesPipeModule, FilterPackagesPipeModule,

View File

@@ -8,14 +8,9 @@
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<marketplace-list-content <marketplace-list-content
*ngIf="connected$ | async else loading"
[localPkgs]="(localPkgs$ | async) || {}" [localPkgs]="(localPkgs$ | async) || {}"
[pkgs]="pkgs$ | async" [pkgs]="pkgs$ | async"
[categories]="categories$ | async" [categories]="categories$ | async"
[name]="(name$ | async) || ''" [name]="(name$ | async) || ''"
></marketplace-list-content> ></marketplace-list-content>
<ng-template #loading>
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-template>
</ion-content> </ion-content>

View File

@@ -1,45 +1,30 @@
import { Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { Observable } from 'rxjs' import { map } from 'rxjs/operators'
import { filter, first, map, startWith, switchMapTo } from 'rxjs/operators' import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { exists, isEmptyObject } from '@start9labs/shared'
import {
AbstractMarketplaceService,
MarketplacePkg,
} from '@start9labs/marketplace'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { ConnectionService } from 'src/app/services/connection.service'
@Component({ @Component({
selector: 'marketplace-list', selector: 'marketplace-list',
templateUrl: './marketplace-list.page.html', templateUrl: './marketplace-list.page.html',
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MarketplaceListPage { export class MarketplaceListPage {
readonly connected$ = this.patch.connected$ readonly connected$ = this.connectionService.connected$
readonly localPkgs$: Observable<Record<string, PackageDataEntry>> = this.patch readonly localPkgs$ = this.patch.watch$('package-data')
.watch$('package-data')
.pipe(
filter(data => exists(data) && !isEmptyObject(data)),
startWith({}),
)
readonly categories$ = this.marketplaceService.getCategories() readonly categories$ = this.marketplaceService.getCategories()
readonly pkgs$: Observable<MarketplacePkg[]> = this.patch readonly pkgs$ = this.marketplaceService.getPackages()
.watch$('server-info')
.pipe(
filter(data => exists(data) && !isEmptyObject(data)),
first(),
switchMapTo(this.marketplaceService.getPackages()),
)
readonly name$: Observable<string> = this.marketplaceService readonly name$ = this.marketplaceService
.getMarketplace() .getMarketplace()
.pipe(map(({ name }) => name)) .pipe(map(({ name }) => name))
constructor( constructor(
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
private readonly marketplaceService: AbstractMarketplaceService, private readonly marketplaceService: AbstractMarketplaceService,
private readonly connectionService: ConnectionService,
) {} ) {}
} }

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { import {
ServerNotifications, ServerNotifications,

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@@ -6,6 +6,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
selector: 'lan', selector: 'lan',
templateUrl: './lan.page.html', templateUrl: './lan.page.html',
styleUrls: ['./lan.page.scss'], styleUrls: ['./lan.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class LANPage { export class LANPage {
readonly downloadIsDisabled = !this.config.isTor() readonly downloadIsDisabled = !this.config.isTor()

View File

@@ -11,12 +11,9 @@
<ng-container *ngIf="ui$ | async as ui"> <ng-container *ngIf="ui$ | async as ui">
<ion-item-group *ngIf="server$ | async as server"> <ion-item-group *ngIf="server$ | async as server">
<ion-item-divider>General</ion-item-divider> <ion-item-divider>General</ion-item-divider>
<ion-item <ion-item button (click)="presentModalName('My Embassy', ui.name)">
button
(click)="presentModalName('Embassy-' + server.id, ui.name)"
>
<ion-label>Device Name</ion-label> <ion-label>Device Name</ion-label>
<ion-note slot="end">{{ ui.name || 'Embassy-' + server.id }}</ion-note> <ion-note slot="end">{{ ui.name || 'My Embassy' }}</ion-note>
</ion-item> </ion-item>
<ion-item-divider>Marketplace</ion-item-divider> <ion-item-divider>Marketplace</ion-item-divider>

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { import {
LoadingController, LoadingController,
@@ -17,6 +17,7 @@ import { LocalStorageService } from '../../../services/local-storage.service'
selector: 'preferences', selector: 'preferences',
templateUrl: './preferences.page.html', templateUrl: './preferences.page.html',
styleUrls: ['./preferences.page.scss'], styleUrls: ['./preferences.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PreferencesPage { export class PreferencesPage {
clicks = 0 clicks = 0

View File

@@ -5,7 +5,7 @@ import {
PipeTransform, PipeTransform,
} from '@angular/core' } from '@angular/core'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { take } from 'rxjs/operators' import { filter, take } from 'rxjs/operators'
import { PackageMainStatus } from 'src/app/services/patch-db/data-model' import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
@@ -15,7 +15,9 @@ import { Observable } from 'rxjs'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BackingUpComponent { export class BackingUpComponent {
readonly pkgs$ = this.patch.watch$('package-data').pipe(take(1)) readonly pkgs$ = this.patch
.watch$('package-data')
.pipe(filter(Boolean), take(1))
readonly backupProgress$ = this.patch.watch$( readonly backupProgress$ = this.patch.watch$(
'server-info', 'server-info',
'status-info', 'status-info',

View File

@@ -1,10 +1,12 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title *ngIf="connected$ | async else loading"> <ion-title *ngIf="ui$ | async as ui; else loadingTitle">
{{ (ui$ | async)?.name || "Embassy-" + (server$ | async)?.id }} {{ ui.name || "My Embassy" }}
</ion-title> </ion-title>
<ng-template #loading> <ng-template #loadingTitle>
<ion-title>Loading<span class="loading-dots"></span></ion-title> <ion-title>
<ion-title>Loading<span class="loading-dots"></span></ion-title>
</ion-title>
</ng-template> </ng-template>
<ion-buttons slot="end"> <ion-buttons slot="end">
<badge-menu-button></badge-menu-button> <badge-menu-button></badge-menu-button>
@@ -14,86 +16,80 @@
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<!-- loading --> <!-- loading -->
<ng-template #spinner> <ng-template #loading>
<text-spinner text="Connecting to Embassy"></text-spinner> <text-spinner text="Connecting to Embassy"></text-spinner>
</ng-template> </ng-template>
<!-- not loading --> <!-- loaded -->
<ng-container *ngIf="connected$ | async else spinner"> <ion-item-group *ngIf="server$ | async as server; else loading">
<ion-item-group *ngIf="server$ | async as server"> <div *ngFor="let cat of settings | keyvalue : asIsOrder">
<div *ngFor="let cat of settings | keyvalue : asIsOrder"> <ion-item-divider>
<ion-item-divider> <ion-text color="dark" *ngIf="cat.key !== 'Power'">
<ion-text color="dark" *ngIf="cat.key !== 'Power'"> {{ cat.key }}
{{ cat.key }} </ion-text>
</ion-text> <ion-text color="dark" *ngIf="cat.key === 'Power'" (click)="addClick()">
<ion-text {{ cat.key }}
color="dark" </ion-text>
*ngIf="cat.key === 'Power'" </ion-item-divider>
(click)="addClick()" <ng-container *ngFor="let button of cat.value">
> <ion-item
{{ cat.key }} button
</ion-text> [style.display]="(button.title === 'Repair Disk' && !(showDiskRepair$ | async)) ? 'none' : 'block'"
</ion-item-divider> [detail]="button.detail"
<ng-container *ngFor="let button of cat.value"> [disabled]="button.disabled$ | async"
<ion-item (click)="button.action()"
button >
[style.display]="(button.title === 'Repair Disk' && !(showDiskRepair$ | async)) ? 'none' : 'block'" <ion-icon slot="start" [name]="button.icon"></ion-icon>
[detail]="button.detail" <ion-label>
[disabled]="button.disabled | async" <h2>{{ button.title }}</h2>
(click)="button.action()" <p *ngIf="button.description">{{ button.description }}</p>
>
<ion-icon slot="start" [name]="button.icon"></ion-icon>
<ion-label>
<h2>{{ button.title }}</h2>
<p *ngIf="button.description">{{ button.description }}</p>
<!-- "Create Backup" button only --> <!-- "Create Backup" button only -->
<p *ngIf="button.title === 'Create Backup'"> <p *ngIf="button.title === 'Create Backup'">
<ng-container *ngIf="server['status-info'] as statusInfo"> <ng-container *ngIf="server['status-info'] as statusInfo">
<ion-text
color="warning"
*ngIf="!statusInfo['backup-progress'] && !statusInfo['update-progress']"
>
Last Backup: {{ server['last-backup'] ?
(server['last-backup'] | date: 'medium') : 'never' }}
</ion-text>
<span *ngIf="!!statusInfo['backup-progress']" class="inline">
<ion-spinner
color="success"
style="height: 12px; width: 12px; margin-right: 6px"
></ion-spinner>
<ion-text color="success">Backing up</ion-text>
</span>
</ng-container>
</p>
<!-- "Software Update" button only -->
<p *ngIf="button.title === 'Software Update'">
<ion-text <ion-text
*ngIf="server['status-info'].updated; else notUpdated"
class="inline"
color="warning" color="warning"
*ngIf="!statusInfo['backup-progress'] && !statusInfo['update-progress']"
> >
Update Complete. Restart to apply changes Last Backup: {{ server['last-backup'] ? (server['last-backup']
| date: 'medium') : 'never' }}
</ion-text> </ion-text>
<ng-template #notUpdated> <span *ngIf="!!statusInfo['backup-progress']" class="inline">
<ng-container *ngIf="showUpdate$ | async; else check"> <ion-spinner
<ion-text class="inline" color="success"> color="success"
<ion-icon name="rocket-outline"></ion-icon> style="height: 12px; width: 12px; margin-right: 6px"
Update Available ></ion-spinner>
</ion-text> <ion-text color="success">Backing up</ion-text>
</ng-container> </span>
<ng-template #check> </ng-container>
<ion-text class="inline" color="dark"> </p>
<ion-icon name="refresh"></ion-icon> <!-- "Software Update" button only -->
Check for updates <p *ngIf="button.title === 'Software Update'">
</ion-text> <ion-text
</ng-template> *ngIf="server['status-info'].updated; else notUpdated"
class="inline"
color="warning"
>
Update Complete. Restart to apply changes
</ion-text>
<ng-template #notUpdated>
<ng-container *ngIf="showUpdate$ | async; else check">
<ion-text class="inline" color="success">
<ion-icon name="rocket-outline"></ion-icon>
Update Available
</ion-text>
</ng-container>
<ng-template #check>
<ion-text class="inline" color="dark">
<ion-icon name="refresh"></ion-icon>
Check for updates
</ion-text>
</ng-template> </ng-template>
</p> </ng-template>
</ion-label> </p>
</ion-item> </ion-label>
</ng-container> </ion-item>
</div> </ng-container>
</ion-item-group> </div>
</ng-container> </ion-item-group>
</ion-content> </ion-content>

View File

@@ -9,11 +9,10 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { Observable, of } from 'rxjs' import { Observable, of } from 'rxjs'
import { filter, take } from 'rxjs/operators' import { filter, take, tap } from 'rxjs/operators'
import { exists, isEmptyObject, ErrorToastService } from '@start9labs/shared' import { isEmptyObject, ErrorToastService } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service' import { EOSService } from 'src/app/services/eos.service'
import { LocalStorageService } from 'src/app/services/local-storage.service' import { LocalStorageService } from 'src/app/services/local-storage.service'
import { RecoveredPackageDataEntry } from 'src/app/services/patch-db/data-model'
import { OSUpdatePage } from 'src/app/modals/os-update/os-update.page' import { OSUpdatePage } from 'src/app/modals/os-update/os-update.page'
import { getAllPackages } from '../../../util/get-package-data' import { getAllPackages } from '../../../util/get-package-data'
@@ -28,7 +27,6 @@ export class ServerShowPage {
readonly server$ = this.patch.watch$('server-info') readonly server$ = this.patch.watch$('server-info')
readonly ui$ = this.patch.watch$('ui') readonly ui$ = this.patch.watch$('ui')
readonly connected$ = this.patch.connected$
readonly showUpdate$ = this.eosService.showUpdate$ readonly showUpdate$ = this.eosService.showUpdate$
readonly showDiskRepair$ = this.localStorageService.showDiskRepair$ readonly showDiskRepair$ = this.localStorageService.showDiskRepair$
@@ -48,10 +46,12 @@ export class ServerShowPage {
ngOnInit() { ngOnInit() {
this.patch this.patch
.watch$('recovered-packages') .watch$('recovered-packages')
.pipe(filter(exists), take(1)) .pipe(
.subscribe((rps: { [id: string]: RecoveredPackageDataEntry }) => { filter(Boolean),
this.hasRecoveredPackage = !isEmptyObject(rps) take(1),
}) tap(data => (this.hasRecoveredPackage = !isEmptyObject(data))),
)
.subscribe()
} }
async updateEos(): Promise<void> { async updateEos(): Promise<void> {
@@ -290,7 +290,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }), this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Restore From Backup', title: 'Restore From Backup',
@@ -299,7 +299,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }), this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: this.eosService.updatingOrBackingUp$, disabled$: this.eosService.updatingOrBackingUp$,
}, },
], ],
Settings: [ Settings: [
@@ -312,7 +312,7 @@ export class ServerShowPage {
? this.updateEos() ? this.updateEos()
: this.checkForEosUpdate(), : this.checkForEosUpdate(),
detail: false, detail: false,
disabled: this.eosService.updatingOrBackingUp$, disabled$: this.eosService.updatingOrBackingUp$,
}, },
{ {
title: 'Preferences', title: 'Preferences',
@@ -323,7 +323,7 @@ export class ServerShowPage {
relativeTo: this.route, relativeTo: this.route,
}), }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'LAN', title: 'LAN',
@@ -332,7 +332,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['lan'], { relativeTo: this.route }), this.navCtrl.navigateForward(['lan'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'SSH', title: 'SSH',
@@ -341,7 +341,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['ssh'], { relativeTo: this.route }), this.navCtrl.navigateForward(['ssh'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'WiFi', title: 'WiFi',
@@ -350,7 +350,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['wifi'], { relativeTo: this.route }), this.navCtrl.navigateForward(['wifi'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Sideload Service', title: 'Sideload Service',
@@ -361,7 +361,7 @@ export class ServerShowPage {
relativeTo: this.route, relativeTo: this.route,
}), }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Marketplace Settings', title: 'Marketplace Settings',
@@ -372,7 +372,7 @@ export class ServerShowPage {
relativeTo: this.route, relativeTo: this.route,
}), }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
], ],
Insights: [ Insights: [
@@ -383,7 +383,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['specs'], { relativeTo: this.route }), this.navCtrl.navigateForward(['specs'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Monitor', title: 'Monitor',
@@ -392,7 +392,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['metrics'], { relativeTo: this.route }), this.navCtrl.navigateForward(['metrics'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Active Sessions', title: 'Active Sessions',
@@ -403,7 +403,7 @@ export class ServerShowPage {
relativeTo: this.route, relativeTo: this.route,
}), }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'OS Logs', title: 'OS Logs',
@@ -412,7 +412,7 @@ export class ServerShowPage {
action: () => action: () =>
this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }), this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Kernel Logs', title: 'Kernel Logs',
@@ -424,7 +424,7 @@ export class ServerShowPage {
relativeTo: this.route, relativeTo: this.route,
}), }),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
], ],
Support: [ Support: [
@@ -439,7 +439,7 @@ export class ServerShowPage {
'noreferrer', 'noreferrer',
), ),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Contact Support', title: 'Contact Support',
@@ -452,7 +452,7 @@ export class ServerShowPage {
'noreferrer', 'noreferrer',
), ),
detail: true, detail: true,
disabled: of(false), disabled$: of(false),
}, },
], ],
Power: [ Power: [
@@ -462,7 +462,7 @@ export class ServerShowPage {
icon: 'reload', icon: 'reload',
action: () => this.presentAlertRestart(), action: () => this.presentAlertRestart(),
detail: false, detail: false,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Shutdown', title: 'Shutdown',
@@ -470,7 +470,7 @@ export class ServerShowPage {
icon: 'power', icon: 'power',
action: () => this.presentAlertShutdown(), action: () => this.presentAlertShutdown(),
detail: false, detail: false,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'System Rebuild', title: 'System Rebuild',
@@ -478,7 +478,7 @@ export class ServerShowPage {
icon: 'construct-outline', icon: 'construct-outline',
action: () => this.presentAlertSystemRebuild(), action: () => this.presentAlertSystemRebuild(),
detail: false, detail: false,
disabled: of(false), disabled$: of(false),
}, },
{ {
title: 'Repair Disk', title: 'Repair Disk',
@@ -486,7 +486,7 @@ export class ServerShowPage {
icon: 'medkit-outline', icon: 'medkit-outline',
action: () => this.presentAlertRepairDisk(), action: () => this.presentAlertRepairDisk(),
detail: false, detail: false,
disabled: of(false), disabled$: of(false),
}, },
], ],
} }
@@ -517,5 +517,5 @@ interface SettingBtn {
icon: string icon: string
action: Function action: Function
detail: boolean detail: boolean
disabled: Observable<boolean> disabled$: Observable<boolean>
} }

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ToastController } from '@ionic/angular' import { ToastController } from '@ionic/angular'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
@@ -8,6 +8,7 @@ import { copyToClipboard } from '@start9labs/shared'
selector: 'server-specs', selector: 'server-specs',
templateUrl: './server-specs.page.html', templateUrl: './server-specs.page.html',
styleUrls: ['./server-specs.page.scss'], styleUrls: ['./server-specs.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ServerSpecsPage { export class ServerSpecsPage {
readonly server$ = this.patch.watch$('server-info') readonly server$ = this.patch.watch$('server-info')

View File

@@ -29,6 +29,9 @@ export module RR {
// server // server
export type EchoReq = { message: string } // server.echo
export type EchoRes = string
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
export type GetServerLogsRes = LogsRes export type GetServerLogsRes = LogsRes

View File

@@ -1,35 +1,12 @@
import { Subject, Observable } from 'rxjs' import { Subject, Observable } from 'rxjs'
import { import { Update, Operation, Revision } from 'patch-db-client'
Http,
Update,
Operation,
Revision,
Source,
Store,
RPCResponse,
} from 'patch-db-client'
import { RR } from './api.types' import { RR } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log, RequestError } from '@start9labs/shared' import { Log, RequestError } from '@start9labs/shared'
import { map } from 'rxjs/operators'
import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export abstract class ApiService implements Source<DataModel>, Http<DataModel> { export abstract class ApiService {
protected readonly sync$ = new Subject<Update<DataModel>>() readonly sync$ = new Subject<Update<DataModel>>()
/** PatchDb Source interface. Post/Patch requests provide a source of patches to the db. */
// sequenceStream '_' is not used by the live api, but is overridden by the mock
watch$(_?: Store<DataModel>): Observable<RPCResponse<Update<DataModel>>> {
return this.sync$
.asObservable()
.pipe(map(result => ({ result, jsonrpc: '2.0' })))
}
// websocket
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
// http // http
@@ -63,6 +40,14 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
// server // server
abstract echo(params: RR.EchoReq): Promise<RR.EchoRes>
abstract openPatchWebsocket$(): Observable<Update<DataModel>>
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
abstract getServerLogs( abstract getServerLogs(
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> ): Promise<RR.GetServerLogsRes>

View File

@@ -1,26 +1,34 @@
import { Injectable } from '@angular/core' import { Inject, Injectable } from '@angular/core'
import { HttpService, Log, LogsRes, Method } from '@start9labs/shared' import {
HttpService,
Log,
Method,
RPCError,
RPCOptions,
} from '@start9labs/shared'
import { ApiService } from './embassy-api.service' import { ApiService } from './embassy-api.service'
import { RR } from './api.types' import { RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util' import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service' import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket' import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs' import { Observable, timeout } from 'rxjs'
import { AuthService } from '../auth.service'
import { DOCUMENT } from '@angular/common'
import { DataModel } from '../patch-db/data-model'
import { Update } from 'patch-db-client'
@Injectable() @Injectable()
export class LiveApiService extends ApiService { export class LiveApiService extends ApiService {
constructor( constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly http: HttpService, private readonly http: HttpService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly auth: AuthService,
) { ) {
super() super()
; (window as any).rpcClient = this ; (window as any).rpcClient = this
} }
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return webSocket(config)
}
async getStatic(url: string): Promise<string> { async getStatic(url: string): Promise<string> {
return this.http.httpRequest({ return this.http.httpRequest({
method: Method.GET, method: Method.GET,
@@ -41,93 +49,114 @@ export class LiveApiService extends ApiService {
// db // db
async getRevisions(since: number): Promise<RR.GetRevisionsRes> { async getRevisions(since: number): Promise<RR.GetRevisionsRes> {
return this.http.rpcRequest({ method: 'db.revisions', params: { since } }) return this.rpcRequest({ method: 'db.revisions', params: { since } })
} }
async getDump(): Promise<RR.GetDumpRes> { async getDump(): Promise<RR.GetDumpRes> {
return this.http.rpcRequest({ method: 'db.dump', params: {} }) return this.rpcRequest({ method: 'db.dump', params: {} })
} }
async setDbValueRaw(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> { async setDbValueRaw(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
return this.http.rpcRequest({ method: 'db.put.ui', params }) return this.rpcRequest({ method: 'db.put.ui', params })
} }
// auth // auth
async login(params: RR.LoginReq): Promise<RR.loginRes> { async login(params: RR.LoginReq): Promise<RR.loginRes> {
return this.http.rpcRequest({ method: 'auth.login', params }) return this.rpcRequest({ method: 'auth.login', params })
} }
async logout(params: RR.LogoutReq): Promise<RR.LogoutRes> { async logout(params: RR.LogoutReq): Promise<RR.LogoutRes> {
return this.http.rpcRequest({ method: 'auth.logout', params }) return this.rpcRequest({ method: 'auth.logout', params })
} }
async getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes> { async getSessions(params: RR.GetSessionsReq): Promise<RR.GetSessionsRes> {
return this.http.rpcRequest({ method: 'auth.session.list', params }) return this.rpcRequest({ method: 'auth.session.list', params })
} }
async killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> { async killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> {
return this.http.rpcRequest({ method: 'auth.session.kill', params }) return this.rpcRequest({ method: 'auth.session.kill', params })
} }
// server // server
async echo(params: RR.EchoReq): Promise<RR.EchoRes> {
return this.rpcRequest({ method: 'echo', params })
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
const config: WebSocketSubjectConfig<Update<DataModel>> = {
url: `/db`,
closeObserver: {
next: val => {
if (val.reason === 'UNAUTHORIZED') this.auth.setUnverified()
},
},
}
return this.openWebsocket(config).pipe(timeout({ first: 21000 }))
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return this.openWebsocket(config)
}
async getServerLogs( async getServerLogs(
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> { ): Promise<RR.GetServerLogsRes> {
return this.http.rpcRequest({ method: 'server.logs', params }) return this.rpcRequest({ method: 'server.logs', params })
} }
async getKernelLogs( async getKernelLogs(
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> { ): Promise<RR.GetServerLogsRes> {
return this.http.rpcRequest({ method: 'server.kernel-logs', params }) return this.rpcRequest({ method: 'server.kernel-logs', params })
} }
async followServerLogs( async followServerLogs(
params: RR.FollowServerLogsReq, params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> { ): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'server.logs.follow', params }) return this.rpcRequest({ method: 'server.logs.follow', params })
} }
async followKernelLogs( async followKernelLogs(
params: RR.FollowServerLogsReq, params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> { ): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'server.kernel-logs.follow', params }) return this.rpcRequest({ method: 'server.kernel-logs.follow', params })
} }
async getServerMetrics( async getServerMetrics(
params: RR.GetServerMetricsReq, params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes> { ): Promise<RR.GetServerMetricsRes> {
return this.http.rpcRequest({ method: 'server.metrics', params }) return this.rpcRequest({ method: 'server.metrics', params })
} }
async updateServerRaw( async updateServerRaw(
params: RR.UpdateServerReq, params: RR.UpdateServerReq,
): Promise<RR.UpdateServerRes> { ): Promise<RR.UpdateServerRes> {
return this.http.rpcRequest({ method: 'server.update', params }) return this.rpcRequest({ method: 'server.update', params })
} }
async restartServer( async restartServer(
params: RR.RestartServerReq, params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> { ): Promise<RR.RestartServerRes> {
return this.http.rpcRequest({ method: 'server.restart', params }) return this.rpcRequest({ method: 'server.restart', params })
} }
async shutdownServer( async shutdownServer(
params: RR.ShutdownServerReq, params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes> { ): Promise<RR.ShutdownServerRes> {
return this.http.rpcRequest({ method: 'server.shutdown', params }) return this.rpcRequest({ method: 'server.shutdown', params })
} }
async systemRebuild( async systemRebuild(
params: RR.RestartServerReq, params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> { ): Promise<RR.RestartServerRes> {
return this.http.rpcRequest({ method: 'server.rebuild', params }) return this.rpcRequest({ method: 'server.rebuild', params })
} }
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> { async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
return this.http.rpcRequest({ method: 'disk.repair', params }) return this.rpcRequest({ method: 'disk.repair', params })
} }
// marketplace URLs // marketplace URLs
@@ -135,7 +164,7 @@ export class LiveApiService extends ApiService {
async marketplaceProxy<T>(path: string, qp: {}, url: string): Promise<T> { async marketplaceProxy<T>(path: string, qp: {}, url: string): Promise<T> {
Object.assign(qp, { arch: this.config.targetArch }) Object.assign(qp, { arch: this.config.targetArch })
const fullURL = `${url}${path}?${new URLSearchParams(qp).toString()}` const fullURL = `${url}${path}?${new URLSearchParams(qp).toString()}`
return this.http.rpcRequest({ return this.rpcRequest({
method: 'marketplace.get', method: 'marketplace.get',
params: { url: fullURL }, params: { url: fullURL },
}) })
@@ -156,19 +185,19 @@ export class LiveApiService extends ApiService {
async getNotificationsRaw( async getNotificationsRaw(
params: RR.GetNotificationsReq, params: RR.GetNotificationsReq,
): Promise<RR.GetNotificationsRes> { ): Promise<RR.GetNotificationsRes> {
return this.http.rpcRequest({ method: 'notification.list', params }) return this.rpcRequest({ method: 'notification.list', params })
} }
async deleteNotification( async deleteNotification(
params: RR.DeleteNotificationReq, params: RR.DeleteNotificationReq,
): Promise<RR.DeleteNotificationRes> { ): Promise<RR.DeleteNotificationRes> {
return this.http.rpcRequest({ method: 'notification.delete', params }) return this.rpcRequest({ method: 'notification.delete', params })
} }
async deleteAllNotifications( async deleteAllNotifications(
params: RR.DeleteAllNotificationsReq, params: RR.DeleteAllNotificationsReq,
): Promise<RR.DeleteAllNotificationsRes> { ): Promise<RR.DeleteAllNotificationsRes> {
return this.http.rpcRequest({ return this.rpcRequest({
method: 'notification.delete-before', method: 'notification.delete-before',
params, params,
}) })
@@ -180,39 +209,39 @@ export class LiveApiService extends ApiService {
params: RR.GetWifiReq, params: RR.GetWifiReq,
timeout?: number, timeout?: number,
): Promise<RR.GetWifiRes> { ): Promise<RR.GetWifiRes> {
return this.http.rpcRequest({ method: 'wifi.get', params, timeout }) return this.rpcRequest({ method: 'wifi.get', params, timeout })
} }
async setWifiCountry( async setWifiCountry(
params: RR.SetWifiCountryReq, params: RR.SetWifiCountryReq,
): Promise<RR.SetWifiCountryRes> { ): Promise<RR.SetWifiCountryRes> {
return this.http.rpcRequest({ method: 'wifi.country.set', params }) return this.rpcRequest({ method: 'wifi.country.set', params })
} }
async addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes> { async addWifi(params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
return this.http.rpcRequest({ method: 'wifi.add', params }) return this.rpcRequest({ method: 'wifi.add', params })
} }
async connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> { async connectWifi(params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
return this.http.rpcRequest({ method: 'wifi.connect', params }) return this.rpcRequest({ method: 'wifi.connect', params })
} }
async deleteWifi(params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> { async deleteWifi(params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
return this.http.rpcRequest({ method: 'wifi.delete', params }) return this.rpcRequest({ method: 'wifi.delete', params })
} }
// ssh // ssh
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> { async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
return this.http.rpcRequest({ method: 'ssh.list', params }) return this.rpcRequest({ method: 'ssh.list', params })
} }
async addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> { async addSshKey(params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.add', params }) return this.rpcRequest({ method: 'ssh.add', params })
} }
async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> { async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.delete', params }) return this.rpcRequest({ method: 'ssh.delete', params })
} }
// backup // backup
@@ -220,38 +249,38 @@ export class LiveApiService extends ApiService {
async getBackupTargets( async getBackupTargets(
params: RR.GetBackupTargetsReq, params: RR.GetBackupTargetsReq,
): Promise<RR.GetBackupTargetsRes> { ): Promise<RR.GetBackupTargetsRes> {
return this.http.rpcRequest({ method: 'backup.target.list', params }) return this.rpcRequest({ method: 'backup.target.list', params })
} }
async addBackupTarget( async addBackupTarget(
params: RR.AddBackupTargetReq, params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes> { ): Promise<RR.AddBackupTargetRes> {
params.path = params.path.replace('/\\/g', '/') params.path = params.path.replace('/\\/g', '/')
return this.http.rpcRequest({ method: 'backup.target.cifs.add', params }) return this.rpcRequest({ method: 'backup.target.cifs.add', params })
} }
async updateBackupTarget( async updateBackupTarget(
params: RR.UpdateBackupTargetReq, params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes> { ): Promise<RR.UpdateBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.update', params }) return this.rpcRequest({ method: 'backup.target.cifs.update', params })
} }
async removeBackupTarget( async removeBackupTarget(
params: RR.RemoveBackupTargetReq, params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes> { ): Promise<RR.RemoveBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.remove', params }) return this.rpcRequest({ method: 'backup.target.cifs.remove', params })
} }
async getBackupInfo( async getBackupInfo(
params: RR.GetBackupInfoReq, params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes> { ): Promise<RR.GetBackupInfoRes> {
return this.http.rpcRequest({ method: 'backup.target.info', params }) return this.rpcRequest({ method: 'backup.target.info', params })
} }
async createBackupRaw( async createBackupRaw(
params: RR.CreateBackupReq, params: RR.CreateBackupReq,
): Promise<RR.CreateBackupRes> { ): Promise<RR.CreateBackupRes> {
return this.http.rpcRequest({ method: 'backup.create', params }) return this.rpcRequest({ method: 'backup.create', params })
} }
// package // package
@@ -267,95 +296,95 @@ export class LiveApiService extends ApiService {
async getPackageLogs( async getPackageLogs(
params: RR.GetPackageLogsReq, params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> { ): Promise<RR.GetPackageLogsRes> {
return this.http.rpcRequest({ method: 'package.logs', params }) return this.rpcRequest({ method: 'package.logs', params })
} }
async followPackageLogs( async followPackageLogs(
params: RR.FollowServerLogsReq, params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> { ): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'package.logs.follow', params }) return this.rpcRequest({ method: 'package.logs.follow', params })
} }
async getPkgMetrics( async getPkgMetrics(
params: RR.GetPackageMetricsReq, params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes> { ): Promise<RR.GetPackageMetricsRes> {
return this.http.rpcRequest({ method: 'package.metrics', params }) return this.rpcRequest({ method: 'package.metrics', params })
} }
async installPackageRaw( async installPackageRaw(
params: RR.InstallPackageReq, params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> { ): Promise<RR.InstallPackageRes> {
return this.http.rpcRequest({ method: 'package.install', params }) return this.rpcRequest({ method: 'package.install', params })
} }
async dryUpdatePackage( async dryUpdatePackage(
params: RR.DryUpdatePackageReq, params: RR.DryUpdatePackageReq,
): Promise<RR.DryUpdatePackageRes> { ): Promise<RR.DryUpdatePackageRes> {
return this.http.rpcRequest({ method: 'package.update.dry', params }) return this.rpcRequest({ method: 'package.update.dry', params })
} }
async getPackageConfig( async getPackageConfig(
params: RR.GetPackageConfigReq, params: RR.GetPackageConfigReq,
): Promise<RR.GetPackageConfigRes> { ): Promise<RR.GetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.get', params }) return this.rpcRequest({ method: 'package.config.get', params })
} }
async drySetPackageConfig( async drySetPackageConfig(
params: RR.DrySetPackageConfigReq, params: RR.DrySetPackageConfigReq,
): Promise<RR.DrySetPackageConfigRes> { ): Promise<RR.DrySetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set.dry', params }) return this.rpcRequest({ method: 'package.config.set.dry', params })
} }
async setPackageConfigRaw( async setPackageConfigRaw(
params: RR.SetPackageConfigReq, params: RR.SetPackageConfigReq,
): Promise<RR.SetPackageConfigRes> { ): Promise<RR.SetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set', params }) return this.rpcRequest({ method: 'package.config.set', params })
} }
async restorePackagesRaw( async restorePackagesRaw(
params: RR.RestorePackagesReq, params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> { ): Promise<RR.RestorePackagesRes> {
return this.http.rpcRequest({ method: 'package.backup.restore', params }) return this.rpcRequest({ method: 'package.backup.restore', params })
} }
async executePackageAction( async executePackageAction(
params: RR.ExecutePackageActionReq, params: RR.ExecutePackageActionReq,
): Promise<RR.ExecutePackageActionRes> { ): Promise<RR.ExecutePackageActionRes> {
return this.http.rpcRequest({ method: 'package.action', params }) return this.rpcRequest({ method: 'package.action', params })
} }
async startPackageRaw( async startPackageRaw(
params: RR.StartPackageReq, params: RR.StartPackageReq,
): Promise<RR.StartPackageRes> { ): Promise<RR.StartPackageRes> {
return this.http.rpcRequest({ method: 'package.start', params }) return this.rpcRequest({ method: 'package.start', params })
} }
async restartPackageRaw( async restartPackageRaw(
params: RR.RestartPackageReq, params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> { ): Promise<RR.RestartPackageRes> {
return this.http.rpcRequest({ method: 'package.restart', params }) return this.rpcRequest({ method: 'package.restart', params })
} }
async stopPackageRaw(params: RR.StopPackageReq): Promise<RR.StopPackageRes> { async stopPackageRaw(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
return this.http.rpcRequest({ method: 'package.stop', params }) return this.rpcRequest({ method: 'package.stop', params })
} }
async deleteRecoveredPackageRaw( async deleteRecoveredPackageRaw(
params: RR.DeleteRecoveredPackageReq, params: RR.DeleteRecoveredPackageReq,
): Promise<RR.DeleteRecoveredPackageRes> { ): Promise<RR.DeleteRecoveredPackageRes> {
return this.http.rpcRequest({ method: 'package.delete-recovered', params }) return this.rpcRequest({ method: 'package.delete-recovered', params })
} }
async uninstallPackageRaw( async uninstallPackageRaw(
params: RR.UninstallPackageReq, params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> { ): Promise<RR.UninstallPackageRes> {
return this.http.rpcRequest({ method: 'package.uninstall', params }) return this.rpcRequest({ method: 'package.uninstall', params })
} }
async dryConfigureDependency( async dryConfigureDependency(
params: RR.DryConfigureDependencyReq, params: RR.DryConfigureDependencyReq,
): Promise<RR.DryConfigureDependencyRes> { ): Promise<RR.DryConfigureDependencyRes> {
return this.http.rpcRequest({ return this.rpcRequest({
method: 'package.dependency.configure.dry', method: 'package.dependency.configure.dry',
params, params,
}) })
@@ -364,9 +393,29 @@ export class LiveApiService extends ApiService {
async sideloadPackage( async sideloadPackage(
params: RR.SideloadPackageReq, params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes> { ): Promise<RR.SideloadPacakgeRes> {
return this.http.rpcRequest({ return this.rpcRequest({
method: 'package.sideload', method: 'package.sideload',
params, params,
}) })
} }
private openWebsocket<T>(config: WebSocketSubjectConfig<T>): Observable<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
const host = location.host
config.url = `${protocol}://${host}/ws${config.url}`
return webSocket(config)
}
private async rpcRequest<T>(options: RPCOptions): Promise<T> {
return this.http.rpcRequest<T>(options).catch(e => {
if ((e as RPCError).error.code === 34) {
console.error('Unauthenticated, logging out')
this.auth.setUnverified()
}
throw e
})
}
} }

View File

@@ -44,16 +44,6 @@ export class MockApiService extends ApiService {
super() super()
} }
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(100).pipe(
map((_, index) => {
// mock fire open observer
if (index === 0) config.openObserver?.next(new Event(''))
return Mock.ServerLogs[0]
}),
)
}
async getStatic(url: string): Promise<string> { async getStatic(url: string): Promise<string> {
await pauseFor(2000) await pauseFor(2000)
return markdown return markdown
@@ -120,6 +110,25 @@ export class MockApiService extends ApiService {
// server // server
async echo(params: RR.EchoReq): Promise<RR.EchoRes> {
await pauseFor(2000)
return params.message
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
return this.mockPatch$
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(100).pipe(
map((_, index) => {
// mock fire open observer
if (index === 0) config.openObserver?.next(new Event(''))
return Mock.ServerLogs[0]
}),
)
}
async getServerLogs( async getServerLogs(
params: RR.GetServerLogsReq, params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> { ): Promise<RR.GetServerLogsRes> {
@@ -291,7 +300,6 @@ export class MockApiService extends ApiService {
value: 0, value: 0,
}, },
] ]
return this.withRevision(patch, Mock.Notifications) return this.withRevision(patch, Mock.Notifications)
} }

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core' import { Injectable, NgZone } from '@angular/core'
import { Observable, ReplaySubject } from 'rxjs' import { ReplaySubject } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators' import { map } from 'rxjs/operators'
import { Storage } from '@ionic/storage-angular' import { Storage } from '@ionic/storage-angular'
import { Router } from '@angular/router'
export enum AuthState { export enum AuthState {
UNVERIFIED, UNVERIFIED,
@@ -14,19 +15,23 @@ export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey' private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly authState$ = new ReplaySubject<AuthState>(1) private readonly authState$ = new ReplaySubject<AuthState>(1)
readonly isVerified$ = this.watch$().pipe( readonly isVerified$ = this.authState$.pipe(
map(state => state === AuthState.VERIFIED), map(state => state === AuthState.VERIFIED),
) )
constructor(private readonly storage: Storage) {} constructor(
private readonly storage: Storage,
private readonly zone: NgZone,
private readonly router: Router,
) {}
async init(): Promise<void> { async init(): Promise<void> {
const loggedIn = await this.storage.get(this.LOGGED_IN_KEY) const loggedIn = await this.storage.get(this.LOGGED_IN_KEY)
this.authState$.next(loggedIn ? AuthState.VERIFIED : AuthState.UNVERIFIED) if (loggedIn) {
} this.setVerified()
} else {
watch$(): Observable<AuthState> { this.setUnverified()
return this.authState$.pipe(distinctUntilChanged()) }
} }
async setVerified(): Promise<void> { async setVerified(): Promise<void> {
@@ -34,7 +39,11 @@ export class AuthService {
this.authState$.next(AuthState.VERIFIED) this.authState$.next(AuthState.VERIFIED)
} }
async setUnverified(): Promise<void> { setUnverified(): void {
this.authState$.next(AuthState.UNVERIFIED) this.authState$.next(AuthState.UNVERIFIED)
this.storage.clear()
this.zone.run(() => {
this.router.navigate(['/login'], { replaceUrl: true })
})
} }
} }

View File

@@ -11,7 +11,7 @@ const {
targetArch, targetArch,
gitHash, gitHash,
useMocks, useMocks,
ui: { patchDb, api, mocks, marketplace }, ui: { api, mocks, marketplace },
} = require('../../../../../config.json') as WorkspaceConfig } = require('../../../../../config.json') as WorkspaceConfig
@Injectable({ @Injectable({
@@ -24,7 +24,6 @@ export class ConfigService {
mocks = mocks mocks = mocks
targetArch = targetArch targetArch = targetArch
gitHash = gitHash gitHash = gitHash
patchDb = patchDb
api = api api = api
marketplace = marketplace marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts skipStartupAlerts = useMocks && mocks.skipStartupAlerts

View File

@@ -1,83 +1,22 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { import { combineLatest, fromEvent, merge, ReplaySubject } from 'rxjs'
BehaviorSubject, import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
combineLatest,
fromEvent,
merge,
Observable,
} from 'rxjs'
import { PatchConnection, PatchDbService } from './patch-db/patch-db.service'
import {
distinctUntilChanged,
map,
mapTo,
startWith,
tap,
} from 'rxjs/operators'
import { ConfigService } from './config.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ConnectionService { export class ConnectionService {
private readonly networkState$ = merge( readonly networkConnected$ = merge(
fromEvent(window, 'online').pipe(mapTo(true)), fromEvent(window, 'online'),
fromEvent(window, 'offline').pipe(mapTo(false)), fromEvent(window, 'offline'),
).pipe( ).pipe(
startWith(null), startWith(null),
map(() => navigator.onLine), map(() => navigator.onLine),
distinctUntilChanged(),
) )
readonly websocketConnected$ = new ReplaySubject<boolean>(1)
private readonly connectionFailure$ = new BehaviorSubject<ConnectionFailure>( readonly connected$ = combineLatest([
ConnectionFailure.None, this.networkConnected$,
) this.websocketConnected$,
]).pipe(map(([network, websocket]) => network && websocket))
constructor(
private readonly configService: ConfigService,
private readonly patch: PatchDbService,
) {}
watchFailure$() {
return this.connectionFailure$.asObservable()
}
watchDisconnected$() {
return this.connectionFailure$.pipe(
map(failure => failure !== ConnectionFailure.None),
)
}
start(): Observable<unknown> {
return combineLatest([
// 1
this.networkState$.pipe(distinctUntilChanged()),
// 2
this.patch.watchPatchConnection$().pipe(distinctUntilChanged()),
// 3
this.patch
.watch$('server-info', 'status-info', 'update-progress')
.pipe(distinctUntilChanged()),
]).pipe(
tap(([network, patchConnection, progress]) => {
if (!network) {
this.connectionFailure$.next(ConnectionFailure.Network)
} else if (patchConnection !== PatchConnection.Disconnected) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!!progress && progress.downloaded === progress.size) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!this.configService.isTor()) {
this.connectionFailure$.next(ConnectionFailure.Lan)
} else {
this.connectionFailure$.next(ConnectionFailure.Tor)
}
}),
)
}
}
export enum ConnectionFailure {
None = 'none',
Network = 'network',
Tor = 'tor',
Lan = 'lan',
} }

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared' import { Emver } from '@start9labs/shared'
import { BehaviorSubject, combineLatest } from 'rxjs' import { BehaviorSubject, combineLatest } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators' import { distinctUntilChanged, filter, map } from 'rxjs/operators'
import { MarketplaceEOS } from 'src/app/services/api/api.types' import { MarketplaceEOS } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -16,6 +16,7 @@ export class EOSService {
updateAvailable$ = new BehaviorSubject<boolean>(false) updateAvailable$ = new BehaviorSubject<boolean>(false)
readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe( readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe(
filter(Boolean),
map(status => !!status['update-progress'] || status.updated), map(status => !!status['update-progress'] || status.updated),
distinctUntilChanged(), distinctUntilChanged(),
) )

View File

@@ -1,14 +0,0 @@
import { ErrorHandler, Injectable } from '@angular/core'
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError (e: any): void {
console.error(e)
const chunkFailedMessage = /Loading chunk [\d]+ failed/
if (chunkFailedMessage.test(e.message)) {
window.location.reload()
}
}
}

View File

@@ -18,6 +18,7 @@ import {
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { import {
catchError, catchError,
distinctUntilChanged,
filter, filter,
map, map,
shareReplay, shareReplay,
@@ -32,9 +33,14 @@ export class MarketplaceService extends AbstractMarketplaceService {
private readonly notes = new Map<string, Record<string, string>>() private readonly notes = new Map<string, Record<string, string>>()
private readonly hasPackages$ = new Subject<boolean>() private readonly hasPackages$ = new Subject<boolean>()
private readonly uiMarketplaceData$: Observable< private readonly uiMarketplaceData$: Observable<UIMarketplaceData> =
UIMarketplaceData | undefined this.patch.watch$('ui', 'marketplace').pipe(
> = this.patch.watch$('ui', 'marketplace').pipe(shareReplay(1)) filter(Boolean),
distinctUntilChanged(
(prev, curr) => prev['selected-id'] === curr['selected-id'],
),
shareReplay(1),
)
private readonly marketplace$ = this.uiMarketplaceData$.pipe( private readonly marketplace$ = this.uiMarketplaceData$.pipe(
map(data => this.toMarketplace(data)), map(data => this.toMarketplace(data)),
@@ -42,19 +48,19 @@ export class MarketplaceService extends AbstractMarketplaceService {
private readonly serverInfo$: Observable<ServerInfo> = this.patch private readonly serverInfo$: Observable<ServerInfo> = this.patch
.watch$('server-info') .watch$('server-info')
.pipe(take(1), shareReplay()) .pipe(filter(Boolean), take(1), shareReplay())
private readonly registryData$: Observable<MarketplaceData> = private readonly registryData$: Observable<MarketplaceData> =
this.uiMarketplaceData$.pipe( this.uiMarketplaceData$.pipe(
switchMap(uiMarketplaceData => switchMap(data =>
this.serverInfo$.pipe( this.serverInfo$.pipe(
switchMap(({ id }) => switchMap(({ id }) =>
from( from(
this.getMarketplaceData( this.getMarketplaceData(
{ 'server-id': id }, { 'server-id': id },
this.toMarketplace(uiMarketplaceData).url, this.toMarketplace(data).url,
), ),
).pipe(tap(({ name }) => this.updateName(uiMarketplaceData, name))), ).pipe(tap(({ name }) => this.updateName(data, name))),
), ),
), ),
), ),
@@ -126,7 +132,7 @@ export class MarketplaceService extends AbstractMarketplaceService {
return this.marketplace$ return this.marketplace$
} }
getAltMarketplace(): Observable<UIMarketplaceData | undefined> { getAltMarketplaceData(): Observable<UIMarketplaceData> {
return this.uiMarketplaceData$ return this.uiMarketplaceData$
} }

View File

@@ -1,44 +1,38 @@
import { Inject, Injectable } from '@angular/core' import { Inject, Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { Observable, of } from 'rxjs' import { Observable } from 'rxjs'
import { filter, share, switchMap, take, tap } from 'rxjs/operators' import { filter, share, switchMap, take, tap } from 'rxjs/operators'
import { isEmptyObject } from '@start9labs/shared' import { exists, isEmptyObject } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { DataModel, UIData } from 'src/app/services/patch-db/data-model' import { DataModel, UIData } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service' import { EOSService } from 'src/app/services/eos.service'
import { OSWelcomePage } from 'src/app/modals/os-welcome/os-welcome.page' import { OSWelcomePage } from 'src/app/modals/os-welcome/os-welcome.page'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchMonitorService } from './patch-monitor.service'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { AbstractMarketplaceService } from '../../../../../../marketplace/src/services/marketplace.service' import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ConnectionService } from 'src/app/services/connection.service'
// Get data from PatchDb after is starts and act upon it // Get data from PatchDb after is starts and act upon it
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class PatchDataService extends Observable<DataModel | null> { export class PatchDataService extends Observable<DataModel> {
private readonly stream$ = this.patchMonitor.pipe( private readonly stream$ = this.connectionService.connected$.pipe(
switchMap(started => filter(Boolean),
started switchMap(() => this.patch.watch$()),
? this.patch.watch$().pipe( filter(obj => exists(obj) && !isEmptyObject(obj)),
filter(obj => !isEmptyObject(obj)), take(1),
take(1), tap(({ ui }) => {
tap(({ ui }) => { // check for updates to EOS and services
// check for updates to EOS and services this.checkForUpdates(ui)
this.checkForUpdates(ui) // show eos welcome message
// show eos welcome message this.showEosWelcome(ui['ack-welcome'])
this.showEosWelcome(ui['ack-welcome']) }),
}),
)
: of(null),
),
share(), share(),
) )
constructor( constructor(
private readonly patchMonitor: PatchMonitorService,
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
private readonly eosService: EOSService, private readonly eosService: EOSService,
private readonly config: ConfigService, private readonly config: ConfigService,
@@ -46,6 +40,7 @@ export class PatchDataService extends Observable<DataModel | null> {
private readonly embassyApi: ApiService, private readonly embassyApi: ApiService,
@Inject(AbstractMarketplaceService) @Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService, private readonly marketplaceService: MarketplaceService,
private readonly connectionService: ConnectionService,
) { ) {
super(subscriber => this.stream$.subscribe(subscriber)) super(subscriber => this.stream$.subscribe(subscriber))
} }

View File

@@ -41,8 +41,8 @@ export interface DevData {
export interface DevProjectData { export interface DevProjectData {
name: string name: string
instructions?: string instructions: string
config?: string config: string
'basic-info'?: BasicInfo 'basic-info'?: BasicInfo
} }

View File

@@ -1,50 +1,41 @@
import { InjectionToken } from '@angular/core' import { InjectionToken } from '@angular/core'
import { exists } from '@start9labs/shared' import { catchError, switchMap, take, tap } from 'rxjs/operators'
import { filter } from 'rxjs/operators' import { Bootstrapper, DBCache, Update } from 'patch-db-client'
import {
Bootstrapper,
DBCache,
MockSource,
PollSource,
Source,
WebsocketSource,
} from 'patch-db-client'
import { ConfigService } from '../config.service'
import { ApiService } from '../api/embassy-api.service'
import { MockApiService } from '../api/embassy-mock-api.service'
import { DataModel } from './data-model' import { DataModel } from './data-model'
import { BehaviorSubject } from 'rxjs' import { EMPTY, from, interval, merge, Observable } from 'rxjs'
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
import { ApiService } from '../api/embassy-api.service'
// [wsSources, pollSources] export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>>>(
export const PATCH_SOURCE = new InjectionToken<Source<DataModel>[]>('') '',
export const PATCH_SOURCE$ = new InjectionToken< )
BehaviorSubject<Source<DataModel>[]>
>('')
export const PATCH_CACHE = new InjectionToken<DBCache<DataModel>>('', { export const PATCH_CACHE = new InjectionToken<DBCache<DataModel>>('', {
factory: () => ({} as any), factory: () => ({} as any),
}) })
export const BOOTSTRAPPER = new InjectionToken<Bootstrapper<DataModel>>('') export const BOOTSTRAPPER = new InjectionToken<Bootstrapper<DataModel>>('')
export function mockSourceFactory({ export function sourceFactory(
mockPatch$, api: ApiService,
}: MockApiService): Source<DataModel>[] { authService: AuthService,
return Array(2).fill( connectionService: ConnectionService,
new MockSource<DataModel>(mockPatch$.pipe(filter(exists))), ): Observable<Update<DataModel>> {
const websocket$ = api.openPatchWebsocket$().pipe(
catchError((_, watch$) => {
connectionService.websocketConnected$.next(false)
return interval(4000).pipe(
switchMap(() =>
from(api.echo({ message: 'ping' })).pipe(catchError(() => EMPTY)),
),
take(1),
switchMap(() => watch$),
)
}),
tap(() => connectionService.websocketConnected$.next(true)),
)
return authService.isVerified$.pipe(
switchMap(verified => (verified ? merge(websocket$, api.sync$) : EMPTY)),
) )
} }
export function realSourceFactory(
embassyApi: ApiService,
config: ConfigService,
{ defaultView }: Document,
): Source<DataModel>[] {
const { patchDb } = config
const host = defaultView?.location.host
const protocol = defaultView?.location.protocol === 'http:' ? 'ws' : 'wss'
return [
new WebsocketSource<DataModel>(`${protocol}://${host}/ws/db`),
new PollSource<DataModel>({ ...patchDb.poll }, embassyApi),
]
}

View File

@@ -1,22 +1,15 @@
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { WorkspaceConfig } from '@start9labs/shared'
import { import {
BOOTSTRAPPER, BOOTSTRAPPER,
mockSourceFactory,
PATCH_CACHE, PATCH_CACHE,
PATCH_SOURCE, PATCH_SOURCE,
PATCH_SOURCE$, sourceFactory,
realSourceFactory,
} from './patch-db.factory' } from './patch-db.factory'
import { LocalStorageBootstrap } from './local-storage-bootstrap' import { LocalStorageBootstrap } from './local-storage-bootstrap'
import { ApiService } from '../api/embassy-api.service' import { ApiService } from '../api/embassy-api.service'
import { ConfigService } from '../config.service' import { AuthService } from '../auth.service'
import { ReplaySubject } from 'rxjs' import { ConnectionService } from '../connection.service'
const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
// This module is purely for providers organization purposes // This module is purely for providers organization purposes
@NgModule({ @NgModule({
@@ -27,16 +20,12 @@ const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
}, },
{ {
provide: PATCH_SOURCE, provide: PATCH_SOURCE,
deps: [ApiService, ConfigService, DOCUMENT], deps: [ApiService, AuthService, ConnectionService],
useFactory: useMocks ? mockSourceFactory : realSourceFactory, useFactory: sourceFactory,
},
{
provide: PATCH_SOURCE$,
useValue: new ReplaySubject(1),
}, },
{ {
provide: PatchDB, provide: PatchDB,
deps: [PATCH_SOURCE$, ApiService, PATCH_CACHE], deps: [PATCH_SOURCE, PATCH_CACHE],
useClass: PatchDB, useClass: PatchDB,
}, },
], ],

View File

@@ -1,165 +1,49 @@
import { Inject, Injectable } from '@angular/core' import { Inject, Injectable } from '@angular/core'
import { Storage } from '@ionic/storage-angular' import { Bootstrapper, PatchDB, Store } from 'patch-db-client'
import { Bootstrapper, PatchDB, Source, Store } from 'patch-db-client' import { Observable, of, Subscription } from 'rxjs'
import { import { catchError, debounceTime, finalize, tap } from 'rxjs/operators'
BehaviorSubject,
Observable,
of,
ReplaySubject,
Subscription,
} from 'rxjs'
import {
catchError,
debounceTime,
filter,
finalize,
mergeMap,
shareReplay,
switchMap,
take,
tap,
withLatestFrom,
} from 'rxjs/operators'
import { pauseFor } from '@start9labs/shared'
import { DataModel } from './data-model' import { DataModel } from './data-model'
import { ApiService } from '../api/embassy-api.service' import { BOOTSTRAPPER } from './patch-db.factory'
import { AuthService } from '../auth.service'
import { BOOTSTRAPPER, PATCH_SOURCE, PATCH_SOURCE$ } from './patch-db.factory'
export enum PatchConnection {
Initializing = 'initializing',
Connected = 'connected',
Disconnected = 'disconnected',
}
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class PatchDbService { export class PatchDbService {
private readonly WS_SUCCESS = 'wsSuccess' private sub?: Subscription
private readonly patchConnection$ = new ReplaySubject<PatchConnection>(1)
private readonly wsSuccess$ = new BehaviorSubject(false)
private readonly polling$ = new BehaviorSubject(false)
private subs: Subscription[] = []
readonly connected$ = this.watchPatchConnection$().pipe(
filter(status => status === PatchConnection.Connected),
take(1),
shareReplay(),
)
constructor( constructor(
// [wsSources, pollSources]
@Inject(PATCH_SOURCE) private readonly sources: Source<DataModel>[],
@Inject(BOOTSTRAPPER) @Inject(BOOTSTRAPPER)
private readonly bootstrapper: Bootstrapper<DataModel>, private readonly bootstrapper: Bootstrapper<DataModel>,
@Inject(PATCH_SOURCE$)
private readonly sources$: BehaviorSubject<Source<DataModel>[]>,
private readonly http: ApiService,
private readonly auth: AuthService,
private readonly storage: Storage,
private readonly patchDb: PatchDB<DataModel>, private readonly patchDb: PatchDB<DataModel>,
) {} ) {}
init() { start(): void {
this.sources$.next([this.sources[0], this.http]) // Early return if already started
this.patchConnection$.next(PatchConnection.Initializing) if (this.sub) {
} return
}
async start(): Promise<void> { console.log('patchDB: STARTING')
this.init() this.sub = this.patchDb.cache$
.pipe(
this.subs.push( debounceTime(420),
// Connection Error tap(cache => {
this.patchDb.connectionError$ this.bootstrapper.update(cache)
.pipe(
debounceTime(420),
withLatestFrom(this.polling$),
mergeMap(async ([e, polling]) => {
if (polling) {
console.log('patchDB: POLLING FAILED', e)
this.patchConnection$.next(PatchConnection.Disconnected)
await pauseFor(2000)
this.sources$.next([this.sources[1], this.http])
return
}
console.log('patchDB: WEBSOCKET FAILED', e)
this.polling$.next(true)
this.sources$.next([this.sources[1], this.http])
}),
)
.subscribe({
complete: () => {
console.warn('patchDB: SYNC COMPLETE')
},
}), }),
)
// RPC ERROR .subscribe()
this.patchDb.rpcError$
.pipe(
tap(({ error }) => {
if (error.code === 34) {
console.log('patchDB: Unauthorized. Logging out.')
this.auth.setUnverified()
}
}),
)
.subscribe({
complete: () => {
console.warn('patchDB: SYNC COMPLETE')
},
}),
// GOOD CONNECTION
this.patchDb.cache$
.pipe(
debounceTime(420),
withLatestFrom(this.patchConnection$, this.wsSuccess$, this.polling$),
tap(async ([cache, connection, wsSuccess, polling]) => {
this.bootstrapper.update(cache)
if (connection === PatchConnection.Initializing) {
console.log(
polling
? 'patchDB: POLL CONNECTED'
: 'patchDB: WEBSOCKET CONNECTED',
)
this.patchConnection$.next(PatchConnection.Connected)
if (!wsSuccess && !polling) {
console.log('patchDB: WEBSOCKET SUCCESS')
this.storage.set(this.WS_SUCCESS, 'true')
this.wsSuccess$.next(true)
}
} else if (
connection === PatchConnection.Disconnected &&
wsSuccess
) {
console.log('patchDB: SWITCHING BACK TO WEBSOCKETS')
this.patchConnection$.next(PatchConnection.Initializing)
this.polling$.next(false)
this.sources$.next([this.sources[0], this.http])
}
}),
)
.subscribe({
complete: () => {
console.warn('patchDB: SYNC COMPLETE')
},
}),
)
} }
stop(): void { stop(): void {
console.log('patchDB: STOPPING') // Early return if already stopped
this.patchConnection$.next(PatchConnection.Initializing) if (!this.sub) {
this.patchDb.store.reset() return
this.subs.forEach(x => x.unsubscribe()) }
this.subs = []
}
watchPatchConnection$(): Observable<PatchConnection> { console.log('patchDB: STOPPING')
return this.patchConnection$.asObservable() this.patchDb.store.reset()
this.sub.unsubscribe()
this.sub = undefined
} }
// prettier-ignore // prettier-ignore
@@ -168,10 +52,7 @@ export class PatchDbService {
console.log('patchDB: WATCHING ', argsString) console.log('patchDB: WATCHING ', argsString)
return this.patchConnection$.pipe( return this.patchDb.store.watch$(...(args as [])).pipe(
filter(status => status === PatchConnection.Connected),
take(1),
switchMap(() => this.patchDb.store.watch$(...(args as []))),
tap(data => console.log('patchDB: NEW VALUE', argsString, data)), tap(data => console.log('patchDB: NEW VALUE', argsString, data)),
catchError(e => { catchError(e => {
console.error('patchDB: WATCH ERROR', e) console.error('patchDB: WATCH ERROR', e)

View File

@@ -1,8 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Storage } from '@ionic/storage-angular' import { Observable } from 'rxjs'
import { from, Observable, of } from 'rxjs' import { map } from 'rxjs/operators'
import { mapTo, share, switchMap } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { AuthService } from 'src/app/services/auth.service' import { AuthService } from 'src/app/services/auth.service'
@@ -12,23 +10,19 @@ import { AuthService } from 'src/app/services/auth.service'
}) })
export class PatchMonitorService extends Observable<boolean> { export class PatchMonitorService extends Observable<boolean> {
private readonly stream$ = this.authService.isVerified$.pipe( private readonly stream$ = this.authService.isVerified$.pipe(
switchMap(verified => { map(verified => {
if (verified) { if (verified) {
return from(this.patch.start()).pipe(mapTo(true)) this.patch.start()
return true
} }
this.patch.stop() this.patch.stop()
this.storage.clear() return false
return of(false)
}), }),
share(),
) )
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
private readonly storage: Storage,
) { ) {
super(subscriber => this.stream$.subscribe(subscriber)) super(subscriber => this.stream$.subscribe(subscriber))
} }

View File

@@ -1,10 +1,9 @@
import { first } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { UIMarketplaceData } from 'src/app/services/patch-db/data-model' import { UIMarketplaceData } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs' import { filter, firstValueFrom } from 'rxjs'
export function getMarketplace( export function getMarketplace(
patch: PatchDbService, patch: PatchDbService,
): Promise<UIMarketplaceData> { ): Promise<UIMarketplaceData> {
return firstValueFrom(patch.watch$('ui', 'marketplace')) return firstValueFrom(patch.watch$('ui', 'marketplace').pipe(filter(Boolean)))
} }

View File

@@ -1,7 +1,6 @@
import { first } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs' import { filter, firstValueFrom } from 'rxjs'
export function getPackage( export function getPackage(
patch: PatchDbService, patch: PatchDbService,
@@ -13,5 +12,5 @@ export function getPackage(
export function getAllPackages( export function getAllPackages(
patch: PatchDbService, patch: PatchDbService,
): Promise<Record<string, PackageDataEntry>> { ): Promise<Record<string, PackageDataEntry>> {
return firstValueFrom(patch.watch$('package-data')) return firstValueFrom(patch.watch$('package-data').pipe(filter(Boolean)))
} }

View File

@@ -1,7 +1,7 @@
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ServerInfo } from 'src/app/services/patch-db/data-model' import { ServerInfo } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs' import { filter, firstValueFrom } from 'rxjs'
export function getServerInfo(patch: PatchDbService): Promise<ServerInfo> { export function getServerInfo(patch: PatchDbService): Promise<ServerInfo> {
return firstValueFrom(patch.watch$('server-info')) return firstValueFrom(patch.watch$('server-info').pipe(filter(Boolean)))
} }

Some files were not shown because too many files have changed in this diff Show More