[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 util;
use std::borrow::Cow;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use color_eyre::eyre::eyre;
use futures::{FutureExt, SinkExt, StreamExt};
use patch_db::json_ptr::JsonPointer;
use patch_db::{Dump, Revision};
use rpc_toolkit::command;
use rpc_toolkit::hyper::upgrade::Upgraded;
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_json::Value;
use tokio::sync::{broadcast, oneshot};
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::WebSocketStream;
use tracing::instrument;
@@ -30,11 +29,12 @@ use crate::middleware::auth::{HasValidSession, HashSessionToken};
use crate::util::serde::{display_serializable, IoFormat};
use crate::{Error, ResultExt};
#[instrument(skip(ctx, ws_fut))]
#[instrument(skip(ctx, session, ws_fut))]
async fn ws_handler<
WSFut: Future<Output = Result<Result<WebSocketStream<Upgraded>, HyperError>, JoinError>>,
>(
ctx: RpcContext,
session: Option<(HasValidSession, HashSessionToken)>,
ws_fut: WSFut,
) -> Result<(), Error> {
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::Unknown)?;
let (has_valid_session, token) = loop {
if let Some(Message::Text(cookie)) = stream
.next()
if let Some((session, token)) = session {
let kill = subscribe_to_session_kill(&ctx, token).await;
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
.transpose()
.with_kind(crate::ErrorKind::Network)?
{
let cookie_str = serde_json::from_str::<Cow<str>>(&cookie)
.with_kind(crate::ErrorKind::Deserialization)?;
.with_kind(crate::ErrorKind::Network)?;
}
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(())
}
@@ -115,39 +86,25 @@ async fn deal_with_messages(
futures::select! {
_ = (&mut kill).fuse() => {
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(())
}
new_rev = sub.recv().fuse() => {
let rev = new_rev.with_kind(crate::ErrorKind::Database)?;
stream
.send(Message::Text(
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)?,
))
.send(Message::Text(serde_json::to_string(&rev).with_kind(crate::ErrorKind::Serialization)?))
.await
.with_kind(crate::ErrorKind::Network)?;
}
message = stream.next().fuse() => {
let message = message.transpose().with_kind(crate::ErrorKind::Network)?;
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 => {
tracing::info!("Closing WebSocket: Stream Finished");
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> {
stream
.send(Message::Text(
serde_json::to_string(&RpcResponse::<GenericRpcMethod<String>>::from_result(Ok::<
_,
RpcError,
>(
serde_json::to_value(&dump).with_kind(crate::ErrorKind::Serialization)?,
)))
.with_kind(crate::ErrorKind::Serialization)?,
serde_json::to_string(&dump).with_kind(crate::ErrorKind::Serialization)?,
))
.await
.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> {
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 (res, ws_fut) = hyper_ws_listener::create_ws(req).with_kind(crate::ErrorKind::Network)?;
if let Some(ws_fut) = ws_fut {
tokio::task::spawn(async move {
match ws_handler(ctx, ws_fut).await {
match ws_handler(ctx, session, ws_fut).await {
Ok(()) => (),
Err(e) => {
tracing::error!("WebSocket Closed: {}", e);

View File

@@ -1,15 +1,13 @@
use std::future::Future;
use std::marker::PhantomData;
use std::ops::Deref;
use std::ops::DerefMut;
use std::ops::{Deref, DerefMut};
use std::process::Stdio;
use std::time::{Duration, UNIX_EPOCH};
use chrono::{DateTime, Utc};
use color_eyre::eyre::eyre;
use futures::stream::BoxStream;
use futures::Stream;
use futures::{FutureExt, SinkExt, StreamExt, TryStreamExt};
use futures::{FutureExt, SinkExt, Stream, StreamExt, TryStreamExt};
use hyper::upgrade::Upgraded;
use hyper::Error as HyperError;
use rpc_toolkit::command;
@@ -30,7 +28,8 @@ use crate::core::rpc_continuations::{RequestGuid, RpcContinuation};
use crate::error::ResultExt;
use crate::procedure::docker::DockerProcedure;
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};
#[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::http::Error as HttpError;
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::Metadata;
use serde::{Deserialize, Serialize};
@@ -198,8 +200,7 @@ pub fn auth<M: Metadata>(ctx: RpcContext) -> DynMiddleware<M> {
|_| StatusCode::OK,
)?));
} else if rpc_req.method.as_str() == "auth.login" {
let mut guard = rate_limiter.lock().await;
guard.0 += 1;
let guard = rate_limiter.lock().await;
if guard.1.elapsed() < Duration::from_secs(20) {
if guard.0 >= 3 {
let (res_parts, _) = Response::new(()).into_parts();
@@ -216,13 +217,25 @@ pub fn auth<M: Metadata>(ctx: RpcContext) -> DynMiddleware<M> {
|_| 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 {
guard.0 = 0;
}
guard.1 = Instant::now();
Ok(Ok(noop4()))
}
}
Ok(Ok(noop3()))
.boxed()
});
Ok(Ok(m3))
}
.boxed()
});

View File

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

View File

@@ -8,7 +8,6 @@ import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { WorkspaceConfig } from '@start9labs/shared'
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
@@ -29,7 +28,6 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
],
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'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { SuccessPageModule } from './pages/success/success.module'
import { HomePageModule } from './pages/home/home.module'
import { LoadingPageModule } from './pages/loading/loading.module'
@@ -46,7 +45,6 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
],
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/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.module'
export * from './components/text-spinner/text-spinner.component'
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.module'

View File

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

View File

@@ -18,4 +18,5 @@
<ion-footer>
<footer appFooter></footer>
</ion-footer>
<toast-container></toast-container>
</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 { SplitPaneTracker } from './services/split-pane.service'
import { merge, Observable } from 'rxjs'
import { GLOBAL_SERVICE } from './app/global/global.module'
import { PatchDataService } from './services/patch-data.service'
import { PatchMonitorService } from './services/patch-monitor.service'
@Component({
selector: 'app-root',
@@ -10,13 +11,13 @@ import { GLOBAL_SERVICE } from './app/global/global.module'
styleUrls: ['app.component.scss'],
})
export class AppComponent implements OnDestroy {
readonly subscription = merge(...this.services).subscribe()
readonly subscription = merge(this.patchData, this.patchMonitor).subscribe()
constructor(
@Inject(GLOBAL_SERVICE)
private readonly services: readonly Observable<unknown>[],
readonly authService: AuthService,
private readonly patchData: PatchDataService,
private readonly patchMonitor: PatchMonitorService,
private readonly splitPane: SplitPaneTracker,
readonly authService: AuthService,
) {}
splitPaneVisible({ detail }: any) {

View File

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

View File

@@ -5,12 +5,10 @@ import { Router, RouteReuseStrategy } from '@angular/router'
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
import { Storage } from '@ionic/storage-angular'
import { WorkspaceConfig } from '@start9labs/shared'
import { ApiService } from './services/api/embassy-api.service'
import { MockApiService } from './services/api/embassy-mock-api.service'
import { LiveApiService } from './services/api/embassy-live-api.service'
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 { LocalStorageService } from './services/local-storage.service'
import { DataModel } from './services/patch-db/data-model'
@@ -30,10 +28,6 @@ export const APP_PROVIDERS: Provider[] = [
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
},
{
provide: APP_INITIALIZER,
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 { PatchDbService } from '../../services/patch-db/patch-db.service'
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
private logout() {
this.embassyApi.logout({})
this.embassyApi.logout({}).catch(e => console.error('Failed to log out', e))
this.authService.setUnverified()
}
}

View File

@@ -1,6 +1,6 @@
<div class="wrapper">
<ion-badge
*ngIf="unreadCount && !sidebarOpen"
*ngIf="!(sidebarOpen$ | async) && (unreadCount$ | async) as unreadCount"
mode="md"
class="md-badge"
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 { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { combineLatest, Subscription } from 'rxjs'
@Component({
selector: 'badge-menu-button',
templateUrl: './badge-menu.component.html',
styleUrls: ['./badge-menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BadgeMenuComponent {
unreadCount = 0
sidebarOpen = false
subs: Subscription[] = []
unreadCount$ = this.patch.watch$('server-info', 'unread-notification-count')
sidebarOpen$ = this.splitPane.sidebarOpen$
constructor(
private readonly splitPane: SplitPaneTracker,
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"
*ngIf="!loading && needInfinite"
position="top"
threshold="0"
threshold="1000"
(ionInfinite)="doInfinite($event)"
>
<ion-infinite-scroll-content

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export class StatusComponent {
@Input() installProgress?: InstallProgress
@Input() sigtermTimeout?: string | null = null
disconnected$ = this.connectionService.watchDisconnected$()
readonly connected$ = this.connectionService.connected$
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 { 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 { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@@ -29,6 +29,7 @@ export class BackupSelectPage {
this.patch
.watch$('package-data')
.pipe(
filter(Boolean),
map(pkgs => {
return Object.values(pkgs).map(pkg => {
const { id, title } = pkg.manifest

View File

@@ -1,11 +1,11 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-title>{{ title }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ title }}</ion-title>
</ion-toolbar>
</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 { ApiService } from 'src/app/services/api/embassy-api.service'
import {
@@ -22,6 +22,7 @@ import { hasCurrentDeps } from 'src/app/util/has-deps'
selector: 'app-actions',
templateUrl: './app-actions.page.html',
styleUrls: ['./app-actions.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppActionsPage {
readonly pkgId = getPkgId(this.route)
@@ -103,7 +104,7 @@ export class AppActionsPage {
} else if (last) {
statusesStr = `${last}`
} 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({
header: 'Forbidden',
@@ -158,10 +159,12 @@ export class AppActionsPage {
try {
await this.embassyApi.uninstallPackage({ id: this.pkgId })
this.embassyApi.setDbValue({
pointer: `/ack-instructions/${this.pkgId}`,
value: false,
})
this.embassyApi
.setDbValue({
pointer: `/ack-instructions/${this.pkgId}`,
value: false,
})
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navCtrl.navigateRoot('/services')
} catch (e: any) {
this.errToast.present(e)
@@ -185,7 +188,7 @@ export class AppActionsPage {
'action-id': actionId,
input,
})
this.modalCtrl.dismiss()
const successModal = await this.modalCtrl.create({
component: ActionSuccessPage,
componentProps: {
@@ -193,8 +196,8 @@ export class AppActionsPage {
},
})
setTimeout(() => successModal.present(), 400)
return true
setTimeout(() => successModal.present(), 500)
return false
} catch (e: any) {
this.errToast.present(e)
return false
@@ -218,6 +221,7 @@ interface LocalAction {
selector: 'app-actions-item',
templateUrl: './app-actions-item.component.html',
styleUrls: ['./app-actions.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppActionsItemComponent {
@Input() action!: LocalAction

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import { ErrorToastService } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service'
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 { MarketplaceService } from 'src/app/services/marketplace.service'
@@ -103,7 +103,7 @@ function loading(
// Show notification on error
catchError(e => from(errToast.present(e))),
// Map any result to false to stop loading indicator
mapTo(false),
map(() => false),
// Start operation with true
startWith(true),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -117,10 +117,12 @@ export class ToButtonsPipe implements PipeTransform {
}
private async presentModalInstructions(pkg: PackageDataEntry) {
this.apiService.setDbValue({
pointer: `/ack-instructions/${pkg.manifest.id}`,
value: true,
})
this.apiService
.setDbValue({
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({
componentProps: {

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import {
AlertController,
LoadingController,
ModalController,
NavController,
} from '@ionic/angular'
import {
GenericInputComponent,
@@ -17,7 +16,6 @@ import { ConfigSpec } from 'src/app/pkg-config/config-types'
import * as yaml from 'js-yaml'
import { v4 } from 'uuid'
import { DevData } from 'src/app/services/patch-db/data-model'
import { ActivatedRoute } from '@angular/router'
import { DestroyService, ErrorToastService } from '@start9labs/shared'
import { takeUntil } from 'rxjs/operators'
@@ -36,8 +34,6 @@ export class DeveloperListPage {
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly navCtrl: NavController,
private readonly route: ActivatedRoute,
private readonly destroy$: DestroyService,
private readonly patch: PatchDbService,
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 { LoadingController, ModalController } from '@ionic/angular'
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',
templateUrl: 'developer-menu.page.html',
styleUrls: ['developer-menu.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DeveloperMenuPage {
readonly projectId = getProjectId(this.route)

View File

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

View File

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

View File

@@ -1,45 +1,30 @@
import { Component } from '@angular/core'
import { Observable } from 'rxjs'
import { filter, first, map, startWith, switchMapTo } from 'rxjs/operators'
import { exists, isEmptyObject } from '@start9labs/shared'
import {
AbstractMarketplaceService,
MarketplacePkg,
} from '@start9labs/marketplace'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { map } from 'rxjs/operators'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
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({
selector: 'marketplace-list',
templateUrl: './marketplace-list.page.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceListPage {
readonly connected$ = this.patch.connected$
readonly connected$ = this.connectionService.connected$
readonly localPkgs$: Observable<Record<string, PackageDataEntry>> = this.patch
.watch$('package-data')
.pipe(
filter(data => exists(data) && !isEmptyObject(data)),
startWith({}),
)
readonly localPkgs$ = this.patch.watch$('package-data')
readonly categories$ = this.marketplaceService.getCategories()
readonly pkgs$: Observable<MarketplacePkg[]> = this.patch
.watch$('server-info')
.pipe(
filter(data => exists(data) && !isEmptyObject(data)),
first(),
switchMapTo(this.marketplaceService.getPackages()),
)
readonly pkgs$ = this.marketplaceService.getPackages()
readonly name$: Observable<string> = this.marketplaceService
readonly name$ = this.marketplaceService
.getMarketplace()
.pipe(map(({ name }) => name))
constructor(
private readonly patch: PatchDbService,
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 {
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 { 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',
templateUrl: './lan.page.html',
styleUrls: ['./lan.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LANPage {
readonly downloadIsDisabled = !this.config.isTor()

View File

@@ -11,12 +11,9 @@
<ng-container *ngIf="ui$ | async as ui">
<ion-item-group *ngIf="server$ | async as server">
<ion-item-divider>General</ion-item-divider>
<ion-item
button
(click)="presentModalName('Embassy-' + server.id, ui.name)"
>
<ion-item button (click)="presentModalName('My Embassy', ui.name)">
<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-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 {
LoadingController,
@@ -17,6 +17,7 @@ import { LocalStorageService } from '../../../services/local-storage.service'
selector: 'preferences',
templateUrl: './preferences.page.html',
styleUrls: ['./preferences.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PreferencesPage {
clicks = 0

View File

@@ -5,7 +5,7 @@ import {
PipeTransform,
} from '@angular/core'
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 { Observable } from 'rxjs'
@@ -15,7 +15,9 @@ import { Observable } from 'rxjs'
changeDetection: ChangeDetectionStrategy.OnPush,
})
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$(
'server-info',
'status-info',

View File

@@ -1,10 +1,12 @@
<ion-header>
<ion-toolbar>
<ion-title *ngIf="connected$ | async else loading">
{{ (ui$ | async)?.name || "Embassy-" + (server$ | async)?.id }}
<ion-title *ngIf="ui$ | async as ui; else loadingTitle">
{{ ui.name || "My Embassy" }}
</ion-title>
<ng-template #loading>
<ion-title>Loading<span class="loading-dots"></span></ion-title>
<ng-template #loadingTitle>
<ion-title>
<ion-title>Loading<span class="loading-dots"></span></ion-title>
</ion-title>
</ng-template>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
@@ -14,86 +16,80 @@
<ion-content class="ion-padding">
<!-- loading -->
<ng-template #spinner>
<ng-template #loading>
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-template>
<!-- not loading -->
<ng-container *ngIf="connected$ | async else spinner">
<ion-item-group *ngIf="server$ | async as server">
<div *ngFor="let cat of settings | keyvalue : asIsOrder">
<ion-item-divider>
<ion-text color="dark" *ngIf="cat.key !== 'Power'">
{{ cat.key }}
</ion-text>
<ion-text
color="dark"
*ngIf="cat.key === 'Power'"
(click)="addClick()"
>
{{ cat.key }}
</ion-text>
</ion-item-divider>
<ng-container *ngFor="let button of cat.value">
<ion-item
button
[style.display]="(button.title === 'Repair Disk' && !(showDiskRepair$ | async)) ? 'none' : 'block'"
[detail]="button.detail"
[disabled]="button.disabled | async"
(click)="button.action()"
>
<ion-icon slot="start" [name]="button.icon"></ion-icon>
<ion-label>
<h2>{{ button.title }}</h2>
<p *ngIf="button.description">{{ button.description }}</p>
<!-- loaded -->
<ion-item-group *ngIf="server$ | async as server; else loading">
<div *ngFor="let cat of settings | keyvalue : asIsOrder">
<ion-item-divider>
<ion-text color="dark" *ngIf="cat.key !== 'Power'">
{{ cat.key }}
</ion-text>
<ion-text color="dark" *ngIf="cat.key === 'Power'" (click)="addClick()">
{{ cat.key }}
</ion-text>
</ion-item-divider>
<ng-container *ngFor="let button of cat.value">
<ion-item
button
[style.display]="(button.title === 'Repair Disk' && !(showDiskRepair$ | async)) ? 'none' : 'block'"
[detail]="button.detail"
[disabled]="button.disabled$ | async"
(click)="button.action()"
>
<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 -->
<p *ngIf="button.title === 'Create Backup'">
<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'">
<!-- "Create Backup" button only -->
<p *ngIf="button.title === 'Create Backup'">
<ng-container *ngIf="server['status-info'] as statusInfo">
<ion-text
*ngIf="server['status-info'].updated; else notUpdated"
class="inline"
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>
<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>
<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
*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>
</p>
</ion-label>
</ion-item>
</ng-container>
</div>
</ion-item-group>
</ng-container>
</ng-template>
</p>
</ion-label>
</ion-item>
</ng-container>
</div>
</ion-item-group>
</ion-content>

View File

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

View File

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

View File

@@ -1,35 +1,12 @@
import { Subject, Observable } from 'rxjs'
import {
Http,
Update,
Operation,
Revision,
Source,
Store,
RPCResponse,
} from 'patch-db-client'
import { Update, Operation, Revision } from 'patch-db-client'
import { RR } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log, RequestError } from '@start9labs/shared'
import { map } from 'rxjs/operators'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
protected 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>
export abstract class ApiService {
readonly sync$ = new Subject<Update<DataModel>>()
// http
@@ -63,6 +40,14 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
// server
abstract echo(params: RR.EchoReq): Promise<RR.EchoRes>
abstract openPatchWebsocket$(): Observable<Update<DataModel>>
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
abstract getServerLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>

View File

@@ -1,26 +1,34 @@
import { Injectable } from '@angular/core'
import { HttpService, Log, LogsRes, Method } from '@start9labs/shared'
import { Inject, Injectable } from '@angular/core'
import {
HttpService,
Log,
Method,
RPCError,
RPCOptions,
} from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service'
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()
export class LiveApiService extends ApiService {
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly auth: AuthService,
) {
super()
; (window as any).rpcClient = this
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return webSocket(config)
}
async getStatic(url: string): Promise<string> {
return this.http.httpRequest({
method: Method.GET,
@@ -41,93 +49,114 @@ export class LiveApiService extends ApiService {
// db
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> {
return this.http.rpcRequest({ method: 'db.dump', params: {} })
return this.rpcRequest({ method: 'db.dump', params: {} })
}
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
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> {
return this.http.rpcRequest({ method: 'auth.logout', params })
return this.rpcRequest({ method: 'auth.logout', params })
}
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> {
return this.http.rpcRequest({ method: 'auth.session.kill', params })
return this.rpcRequest({ method: 'auth.session.kill', params })
}
// 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(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.http.rpcRequest({ method: 'server.logs', params })
return this.rpcRequest({ method: 'server.logs', params })
}
async getKernelLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.http.rpcRequest({ method: 'server.kernel-logs', params })
return this.rpcRequest({ method: 'server.kernel-logs', params })
}
async followServerLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'server.logs.follow', params })
return this.rpcRequest({ method: 'server.logs.follow', params })
}
async followKernelLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'server.kernel-logs.follow', params })
return this.rpcRequest({ method: 'server.kernel-logs.follow', params })
}
async getServerMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes> {
return this.http.rpcRequest({ method: 'server.metrics', params })
return this.rpcRequest({ method: 'server.metrics', params })
}
async updateServerRaw(
params: RR.UpdateServerReq,
): Promise<RR.UpdateServerRes> {
return this.http.rpcRequest({ method: 'server.update', params })
return this.rpcRequest({ method: 'server.update', params })
}
async restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
return this.http.rpcRequest({ method: 'server.restart', params })
return this.rpcRequest({ method: 'server.restart', params })
}
async shutdownServer(
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes> {
return this.http.rpcRequest({ method: 'server.shutdown', params })
return this.rpcRequest({ method: 'server.shutdown', params })
}
async systemRebuild(
params: RR.RestartServerReq,
): 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> {
return this.http.rpcRequest({ method: 'disk.repair', params })
return this.rpcRequest({ method: 'disk.repair', params })
}
// marketplace URLs
@@ -135,7 +164,7 @@ export class LiveApiService extends ApiService {
async marketplaceProxy<T>(path: string, qp: {}, url: string): Promise<T> {
Object.assign(qp, { arch: this.config.targetArch })
const fullURL = `${url}${path}?${new URLSearchParams(qp).toString()}`
return this.http.rpcRequest({
return this.rpcRequest({
method: 'marketplace.get',
params: { url: fullURL },
})
@@ -156,19 +185,19 @@ export class LiveApiService extends ApiService {
async getNotificationsRaw(
params: RR.GetNotificationsReq,
): Promise<RR.GetNotificationsRes> {
return this.http.rpcRequest({ method: 'notification.list', params })
return this.rpcRequest({ method: 'notification.list', params })
}
async deleteNotification(
params: RR.DeleteNotificationReq,
): Promise<RR.DeleteNotificationRes> {
return this.http.rpcRequest({ method: 'notification.delete', params })
return this.rpcRequest({ method: 'notification.delete', params })
}
async deleteAllNotifications(
params: RR.DeleteAllNotificationsReq,
): Promise<RR.DeleteAllNotificationsRes> {
return this.http.rpcRequest({
return this.rpcRequest({
method: 'notification.delete-before',
params,
})
@@ -180,39 +209,39 @@ export class LiveApiService extends ApiService {
params: RR.GetWifiReq,
timeout?: number,
): Promise<RR.GetWifiRes> {
return this.http.rpcRequest({ method: 'wifi.get', params, timeout })
return this.rpcRequest({ method: 'wifi.get', params, timeout })
}
async setWifiCountry(
params: RR.SetWifiCountryReq,
): 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> {
return this.http.rpcRequest({ method: 'wifi.add', params })
return this.rpcRequest({ method: 'wifi.add', params })
}
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> {
return this.http.rpcRequest({ method: 'wifi.delete', params })
return this.rpcRequest({ method: 'wifi.delete', params })
}
// ssh
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> {
return this.http.rpcRequest({ method: 'ssh.add', params })
return this.rpcRequest({ method: 'ssh.add', params })
}
async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.delete', params })
return this.rpcRequest({ method: 'ssh.delete', params })
}
// backup
@@ -220,38 +249,38 @@ export class LiveApiService extends ApiService {
async getBackupTargets(
params: RR.GetBackupTargetsReq,
): Promise<RR.GetBackupTargetsRes> {
return this.http.rpcRequest({ method: 'backup.target.list', params })
return this.rpcRequest({ method: 'backup.target.list', params })
}
async addBackupTarget(
params: RR.AddBackupTargetReq,
): Promise<RR.AddBackupTargetRes> {
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(
params: RR.UpdateBackupTargetReq,
): Promise<RR.UpdateBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.update', params })
return this.rpcRequest({ method: 'backup.target.cifs.update', params })
}
async removeBackupTarget(
params: RR.RemoveBackupTargetReq,
): Promise<RR.RemoveBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.remove', params })
return this.rpcRequest({ method: 'backup.target.cifs.remove', params })
}
async getBackupInfo(
params: RR.GetBackupInfoReq,
): Promise<RR.GetBackupInfoRes> {
return this.http.rpcRequest({ method: 'backup.target.info', params })
return this.rpcRequest({ method: 'backup.target.info', params })
}
async createBackupRaw(
params: RR.CreateBackupReq,
): Promise<RR.CreateBackupRes> {
return this.http.rpcRequest({ method: 'backup.create', params })
return this.rpcRequest({ method: 'backup.create', params })
}
// package
@@ -267,95 +296,95 @@ export class LiveApiService extends ApiService {
async getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> {
return this.http.rpcRequest({ method: 'package.logs', params })
return this.rpcRequest({ method: 'package.logs', params })
}
async followPackageLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.http.rpcRequest({ method: 'package.logs.follow', params })
return this.rpcRequest({ method: 'package.logs.follow', params })
}
async getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes> {
return this.http.rpcRequest({ method: 'package.metrics', params })
return this.rpcRequest({ method: 'package.metrics', params })
}
async installPackageRaw(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {
return this.http.rpcRequest({ method: 'package.install', params })
return this.rpcRequest({ method: 'package.install', params })
}
async dryUpdatePackage(
params: RR.DryUpdatePackageReq,
): Promise<RR.DryUpdatePackageRes> {
return this.http.rpcRequest({ method: 'package.update.dry', params })
return this.rpcRequest({ method: 'package.update.dry', params })
}
async getPackageConfig(
params: RR.GetPackageConfigReq,
): Promise<RR.GetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.get', params })
return this.rpcRequest({ method: 'package.config.get', params })
}
async drySetPackageConfig(
params: RR.DrySetPackageConfigReq,
): Promise<RR.DrySetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set.dry', params })
return this.rpcRequest({ method: 'package.config.set.dry', params })
}
async setPackageConfigRaw(
params: RR.SetPackageConfigReq,
): Promise<RR.SetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set', params })
return this.rpcRequest({ method: 'package.config.set', params })
}
async restorePackagesRaw(
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> {
return this.http.rpcRequest({ method: 'package.backup.restore', params })
return this.rpcRequest({ method: 'package.backup.restore', params })
}
async executePackageAction(
params: RR.ExecutePackageActionReq,
): Promise<RR.ExecutePackageActionRes> {
return this.http.rpcRequest({ method: 'package.action', params })
return this.rpcRequest({ method: 'package.action', params })
}
async startPackageRaw(
params: RR.StartPackageReq,
): Promise<RR.StartPackageRes> {
return this.http.rpcRequest({ method: 'package.start', params })
return this.rpcRequest({ method: 'package.start', params })
}
async restartPackageRaw(
params: RR.RestartPackageReq,
): 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> {
return this.http.rpcRequest({ method: 'package.stop', params })
return this.rpcRequest({ method: 'package.stop', params })
}
async deleteRecoveredPackageRaw(
params: RR.DeleteRecoveredPackageReq,
): Promise<RR.DeleteRecoveredPackageRes> {
return this.http.rpcRequest({ method: 'package.delete-recovered', params })
return this.rpcRequest({ method: 'package.delete-recovered', params })
}
async uninstallPackageRaw(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> {
return this.http.rpcRequest({ method: 'package.uninstall', params })
return this.rpcRequest({ method: 'package.uninstall', params })
}
async dryConfigureDependency(
params: RR.DryConfigureDependencyReq,
): Promise<RR.DryConfigureDependencyRes> {
return this.http.rpcRequest({
return this.rpcRequest({
method: 'package.dependency.configure.dry',
params,
})
@@ -364,9 +393,29 @@ export class LiveApiService extends ApiService {
async sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes> {
return this.http.rpcRequest({
return this.rpcRequest({
method: 'package.sideload',
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()
}
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> {
await pauseFor(2000)
return markdown
@@ -120,6 +110,25 @@ export class MockApiService extends ApiService {
// 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(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
@@ -291,7 +300,6 @@ export class MockApiService extends ApiService {
value: 0,
},
]
return this.withRevision(patch, Mock.Notifications)
}

View File

@@ -1,7 +1,8 @@
import { Injectable } from '@angular/core'
import { Observable, ReplaySubject } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { Injectable, NgZone } from '@angular/core'
import { ReplaySubject } from 'rxjs'
import { map } from 'rxjs/operators'
import { Storage } from '@ionic/storage-angular'
import { Router } from '@angular/router'
export enum AuthState {
UNVERIFIED,
@@ -14,19 +15,23 @@ export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly authState$ = new ReplaySubject<AuthState>(1)
readonly isVerified$ = this.watch$().pipe(
readonly isVerified$ = this.authState$.pipe(
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> {
const loggedIn = await this.storage.get(this.LOGGED_IN_KEY)
this.authState$.next(loggedIn ? AuthState.VERIFIED : AuthState.UNVERIFIED)
}
watch$(): Observable<AuthState> {
return this.authState$.pipe(distinctUntilChanged())
if (loggedIn) {
this.setVerified()
} else {
this.setUnverified()
}
}
async setVerified(): Promise<void> {
@@ -34,7 +39,11 @@ export class AuthService {
this.authState$.next(AuthState.VERIFIED)
}
async setUnverified(): Promise<void> {
setUnverified(): void {
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,
gitHash,
useMocks,
ui: { patchDb, api, mocks, marketplace },
ui: { api, mocks, marketplace },
} = require('../../../../../config.json') as WorkspaceConfig
@Injectable({
@@ -24,7 +24,6 @@ export class ConfigService {
mocks = mocks
targetArch = targetArch
gitHash = gitHash
patchDb = patchDb
api = api
marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts

View File

@@ -1,83 +1,22 @@
import { Injectable } from '@angular/core'
import {
BehaviorSubject,
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'
import { combineLatest, fromEvent, merge, ReplaySubject } from 'rxjs'
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
@Injectable({
providedIn: 'root',
})
export class ConnectionService {
private readonly networkState$ = merge(
fromEvent(window, 'online').pipe(mapTo(true)),
fromEvent(window, 'offline').pipe(mapTo(false)),
readonly networkConnected$ = merge(
fromEvent(window, 'online'),
fromEvent(window, 'offline'),
).pipe(
startWith(null),
map(() => navigator.onLine),
distinctUntilChanged(),
)
private readonly connectionFailure$ = new BehaviorSubject<ConnectionFailure>(
ConnectionFailure.None,
)
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',
readonly websocketConnected$ = new ReplaySubject<boolean>(1)
readonly connected$ = combineLatest([
this.networkConnected$,
this.websocketConnected$,
]).pipe(map(([network, websocket]) => network && websocket))
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
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 { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -16,6 +16,7 @@ export class EOSService {
updateAvailable$ = new BehaviorSubject<boolean>(false)
readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe(
filter(Boolean),
map(status => !!status['update-progress'] || status.updated),
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 {
catchError,
distinctUntilChanged,
filter,
map,
shareReplay,
@@ -32,9 +33,14 @@ export class MarketplaceService extends AbstractMarketplaceService {
private readonly notes = new Map<string, Record<string, string>>()
private readonly hasPackages$ = new Subject<boolean>()
private readonly uiMarketplaceData$: Observable<
UIMarketplaceData | undefined
> = this.patch.watch$('ui', 'marketplace').pipe(shareReplay(1))
private readonly uiMarketplaceData$: Observable<UIMarketplaceData> =
this.patch.watch$('ui', 'marketplace').pipe(
filter(Boolean),
distinctUntilChanged(
(prev, curr) => prev['selected-id'] === curr['selected-id'],
),
shareReplay(1),
)
private readonly marketplace$ = this.uiMarketplaceData$.pipe(
map(data => this.toMarketplace(data)),
@@ -42,19 +48,19 @@ export class MarketplaceService extends AbstractMarketplaceService {
private readonly serverInfo$: Observable<ServerInfo> = this.patch
.watch$('server-info')
.pipe(take(1), shareReplay())
.pipe(filter(Boolean), take(1), shareReplay())
private readonly registryData$: Observable<MarketplaceData> =
this.uiMarketplaceData$.pipe(
switchMap(uiMarketplaceData =>
switchMap(data =>
this.serverInfo$.pipe(
switchMap(({ id }) =>
from(
this.getMarketplaceData(
{ '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$
}
getAltMarketplace(): Observable<UIMarketplaceData | undefined> {
getAltMarketplaceData(): Observable<UIMarketplaceData> {
return this.uiMarketplaceData$
}

View File

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

View File

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

View File

@@ -1,50 +1,41 @@
import { InjectionToken } from '@angular/core'
import { exists } from '@start9labs/shared'
import { filter } from 'rxjs/operators'
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 { catchError, switchMap, take, tap } from 'rxjs/operators'
import { Bootstrapper, DBCache, Update } from 'patch-db-client'
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<Source<DataModel>[]>('')
export const PATCH_SOURCE$ = new InjectionToken<
BehaviorSubject<Source<DataModel>[]>
>('')
export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>>>(
'',
)
export const PATCH_CACHE = new InjectionToken<DBCache<DataModel>>('', {
factory: () => ({} as any),
})
export const BOOTSTRAPPER = new InjectionToken<Bootstrapper<DataModel>>('')
export function mockSourceFactory({
mockPatch$,
}: MockApiService): Source<DataModel>[] {
return Array(2).fill(
new MockSource<DataModel>(mockPatch$.pipe(filter(exists))),
export function sourceFactory(
api: ApiService,
authService: AuthService,
connectionService: ConnectionService,
): 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 { NgModule } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { WorkspaceConfig } from '@start9labs/shared'
import {
BOOTSTRAPPER,
mockSourceFactory,
PATCH_CACHE,
PATCH_SOURCE,
PATCH_SOURCE$,
realSourceFactory,
sourceFactory,
} from './patch-db.factory'
import { LocalStorageBootstrap } from './local-storage-bootstrap'
import { ApiService } from '../api/embassy-api.service'
import { ConfigService } from '../config.service'
import { ReplaySubject } from 'rxjs'
const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
// This module is purely for providers organization purposes
@NgModule({
@@ -27,16 +20,12 @@ const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
},
{
provide: PATCH_SOURCE,
deps: [ApiService, ConfigService, DOCUMENT],
useFactory: useMocks ? mockSourceFactory : realSourceFactory,
},
{
provide: PATCH_SOURCE$,
useValue: new ReplaySubject(1),
deps: [ApiService, AuthService, ConnectionService],
useFactory: sourceFactory,
},
{
provide: PatchDB,
deps: [PATCH_SOURCE$, ApiService, PATCH_CACHE],
deps: [PATCH_SOURCE, PATCH_CACHE],
useClass: PatchDB,
},
],

View File

@@ -1,165 +1,49 @@
import { Inject, Injectable } from '@angular/core'
import { Storage } from '@ionic/storage-angular'
import { Bootstrapper, PatchDB, Source, Store } from 'patch-db-client'
import {
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 { Bootstrapper, PatchDB, Store } from 'patch-db-client'
import { Observable, of, Subscription } from 'rxjs'
import { catchError, debounceTime, finalize, tap } from 'rxjs/operators'
import { DataModel } from './data-model'
import { ApiService } from '../api/embassy-api.service'
import { AuthService } from '../auth.service'
import { BOOTSTRAPPER, PATCH_SOURCE, PATCH_SOURCE$ } from './patch-db.factory'
export enum PatchConnection {
Initializing = 'initializing',
Connected = 'connected',
Disconnected = 'disconnected',
}
import { BOOTSTRAPPER } from './patch-db.factory'
@Injectable({
providedIn: 'root',
})
export class PatchDbService {
private readonly WS_SUCCESS = 'wsSuccess'
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(),
)
private sub?: Subscription
constructor(
// [wsSources, pollSources]
@Inject(PATCH_SOURCE) private readonly sources: Source<DataModel>[],
@Inject(BOOTSTRAPPER)
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>,
) {}
init() {
this.sources$.next([this.sources[0], this.http])
this.patchConnection$.next(PatchConnection.Initializing)
}
start(): void {
// Early return if already started
if (this.sub) {
return
}
async start(): Promise<void> {
this.init()
this.subs.push(
// Connection Error
this.patchDb.connectionError$
.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')
},
console.log('patchDB: STARTING')
this.sub = this.patchDb.cache$
.pipe(
debounceTime(420),
tap(cache => {
this.bootstrapper.update(cache)
}),
// RPC ERROR
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')
},
}),
)
)
.subscribe()
}
stop(): void {
console.log('patchDB: STOPPING')
this.patchConnection$.next(PatchConnection.Initializing)
this.patchDb.store.reset()
this.subs.forEach(x => x.unsubscribe())
this.subs = []
}
// Early return if already stopped
if (!this.sub) {
return
}
watchPatchConnection$(): Observable<PatchConnection> {
return this.patchConnection$.asObservable()
console.log('patchDB: STOPPING')
this.patchDb.store.reset()
this.sub.unsubscribe()
this.sub = undefined
}
// prettier-ignore
@@ -168,10 +52,7 @@ export class PatchDbService {
console.log('patchDB: WATCHING ', argsString)
return this.patchConnection$.pipe(
filter(status => status === PatchConnection.Connected),
take(1),
switchMap(() => this.patchDb.store.watch$(...(args as []))),
return this.patchDb.store.watch$(...(args as [])).pipe(
tap(data => console.log('patchDB: NEW VALUE', argsString, data)),
catchError(e => {
console.error('patchDB: WATCH ERROR', e)

View File

@@ -1,8 +1,6 @@
import { Injectable } from '@angular/core'
import { Storage } from '@ionic/storage-angular'
import { from, Observable, of } from 'rxjs'
import { mapTo, share, switchMap } from 'rxjs/operators'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.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> {
private readonly stream$ = this.authService.isVerified$.pipe(
switchMap(verified => {
map(verified => {
if (verified) {
return from(this.patch.start()).pipe(mapTo(true))
this.patch.start()
return true
}
this.patch.stop()
this.storage.clear()
return of(false)
return false
}),
share(),
)
constructor(
private readonly authService: AuthService,
private readonly patch: PatchDbService,
private readonly storage: Storage,
) {
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 { UIMarketplaceData } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs'
import { filter, firstValueFrom } from 'rxjs'
export function getMarketplace(
patch: PatchDbService,
): 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 { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs'
import { filter, firstValueFrom } from 'rxjs'
export function getPackage(
patch: PatchDbService,
@@ -13,5 +12,5 @@ export function getPackage(
export function getAllPackages(
patch: PatchDbService,
): 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 { 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> {
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