From 3ddeb5fa9461c9c257e0773037db76ac8457c503 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Mon, 22 Aug 2022 10:53:52 -0600 Subject: [PATCH] [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 Co-authored-by: waterplea --- backend/src/db/mod.rs | 127 +++++-------- backend/src/logs.rs | 9 +- backend/src/middleware/auth.rs | 23 ++- frontend/config-sample.json | 5 - .../diagnostic-ui/src/app/app.module.ts | 2 - .../services/global-error-handler.service.ts | 13 -- .../setup-wizard/src/app/app.module.ts | 2 - .../services/global-error-handler.service.ts | 14 -- .../alert/alert-button.directive.ts | 29 +++ .../components/alert/alert-input.directive.ts | 27 +++ .../src/components/alert/alert.component.ts | 99 ++++++++++ .../src/components/alert/alert.module.ts | 10 + .../toast/toast-button.directive.ts | 32 ++++ .../src/components/toast/toast.component.ts | 85 +++++++++ .../src/components/toast/toast.module.ts | 9 + frontend/projects/shared/src/public-api.ts | 7 + .../shared/src/types/workspace-config.ts | 5 - .../projects/ui/src/app/app.component.html | 1 + frontend/projects/ui/src/app/app.component.ts | 15 +- frontend/projects/ui/src/app/app.module.ts | 4 +- frontend/projects/ui/src/app/app.providers.ts | 6 - .../ui/src/app/app/footer/footer.component.ts | 3 +- .../ui/src/app/app/global/global.module.ts | 54 ------ .../services/connection-monitor.service.ts | 21 --- .../app/app/global/services/logout.service.ts | 27 --- .../app/global/services/offline.service.ts | 117 ------------ .../global/services/refresh-toast.service.ts | 52 ------ .../global/services/unread-toast.service.ts | 72 -------- .../global/services/update-toast.service.ts | 95 ---------- .../ui/src/app/app/menu/menu.component.ts | 2 +- .../badge-menu.component.html | 2 +- .../badge-menu-button/badge-menu.component.ts | 26 +-- .../app/components/logs/logs.component.html | 2 +- .../src/app/components/logs/logs.component.ts | 16 +- .../components/status/status.component.html | 6 +- .../app/components/status/status.component.ts | 2 +- .../notifications-toast.component.html | 17 ++ .../notifications-toast.component.ts | 27 +++ .../notifications-toast.service.ts | 21 +++ .../offline-toast.component.html | 19 ++ .../offline-toast/offline-toast.component.ts | 24 +++ .../offline-toast/offline-toast.service.ts | 40 ++++ .../refresh-alert.component.html | 5 + .../refresh-alert/refresh-alert.component.ts | 23 +++ .../refresh-alert/refresh-alert.service.ts | 24 +++ .../toast-container.component.html | 4 + .../toast-container.component.ts | 8 + .../toast-container/toast-container.module.ts | 23 +++ .../update-toast/update-toast.component.html | 11 ++ .../update-toast/update-toast.component.ts | 47 +++++ .../update-toast/update-toast.service.ts | 15 ++ .../backup-select/backup-select.page.ts | 3 +- .../generic-form/generic-form.page.html | 4 +- .../app-actions/app-actions.page.ts | 22 ++- .../app-list-icon.component.html | 11 +- .../app-list-icon/app-list-icon.component.ts | 2 +- .../app-list-rec/app-list-rec.component.ts | 4 +- .../apps-routes/app-list/app-list.page.html | 10 +- .../apps-routes/app-list/app-list.page.ts | 4 +- .../apps-routes/app-show/app-show.page.ts | 4 +- .../app-show-health-checks.component.html | 28 +-- .../app-show-health-checks.component.ts | 2 +- .../app-show-status.component.html | 2 +- .../app-show-status.component.ts | 2 +- .../app-show/pipes/to-buttons.pipe.ts | 10 +- .../app-show/pipes/to-dependencies.pipe.ts | 3 +- .../dev-config/dev-config.page.ts | 4 +- .../dev-instructions/dev-instructions.page.ts | 7 +- .../developer-list/developer-list.page.ts | 4 - .../developer-menu/developer-menu.page.ts | 3 +- .../marketplace-list.module.ts | 8 +- .../marketplace-list.page.html | 5 - .../marketplace-list/marketplace-list.page.ts | 35 +--- .../pages/notifications/notifications.page.ts | 2 +- .../app/pages/server-routes/lan/lan.page.ts | 3 +- .../preferences/preferences.page.html | 7 +- .../preferences/preferences.page.ts | 3 +- .../backing-up/backing-up.component.ts | 6 +- .../server-show/server-show.page.html | 150 ++++++++------- .../server-show/server-show.page.ts | 58 +++--- .../server-specs/server-specs.page.ts | 3 +- .../ui/src/app/services/api/api.types.ts | 3 + .../app/services/api/embassy-api.service.ts | 37 ++-- .../services/api/embassy-live-api.service.ts | 167 +++++++++++------ .../services/api/embassy-mock-api.service.ts | 30 +-- .../ui/src/app/services/auth.service.ts | 31 ++-- .../ui/src/app/services/config.service.ts | 3 +- .../ui/src/app/services/connection.service.ts | 83 ++------- .../ui/src/app/services/eos.service.ts | 3 +- .../services/global-error-handler.service.ts | 14 -- .../src/app/services/marketplace.service.ts | 22 ++- .../services/patch-data.service.ts | 39 ++-- .../src/app/services/patch-db/data-model.ts | 4 +- .../app/services/patch-db/patch-db.factory.ts | 71 ++++--- .../app/services/patch-db/patch-db.module.ts | 23 +-- .../app/services/patch-db/patch-db.service.ts | 173 +++--------------- .../services/patch-monitor.service.ts | 18 +- .../ui/src/app/util/get-marketplace.ts | 5 +- .../ui/src/app/util/get-package-data.ts | 5 +- .../ui/src/app/util/get-server-info.ts | 4 +- patch-db | 2 +- 101 files changed, 1177 insertions(+), 1298 deletions(-) delete mode 100644 frontend/projects/diagnostic-ui/src/app/services/global-error-handler.service.ts delete mode 100644 frontend/projects/setup-wizard/src/app/services/global-error-handler.service.ts create mode 100644 frontend/projects/shared/src/components/alert/alert-button.directive.ts create mode 100644 frontend/projects/shared/src/components/alert/alert-input.directive.ts create mode 100644 frontend/projects/shared/src/components/alert/alert.component.ts create mode 100644 frontend/projects/shared/src/components/alert/alert.module.ts create mode 100644 frontend/projects/shared/src/components/toast/toast-button.directive.ts create mode 100644 frontend/projects/shared/src/components/toast/toast.component.ts create mode 100644 frontend/projects/shared/src/components/toast/toast.module.ts delete mode 100644 frontend/projects/ui/src/app/app/global/global.module.ts delete mode 100644 frontend/projects/ui/src/app/app/global/services/connection-monitor.service.ts delete mode 100644 frontend/projects/ui/src/app/app/global/services/logout.service.ts delete mode 100644 frontend/projects/ui/src/app/app/global/services/offline.service.ts delete mode 100644 frontend/projects/ui/src/app/app/global/services/refresh-toast.service.ts delete mode 100644 frontend/projects/ui/src/app/app/global/services/unread-toast.service.ts delete mode 100644 frontend/projects/ui/src/app/app/global/services/update-toast.service.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.component.html create mode 100644 frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.component.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.service.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.html create mode 100644 frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.service.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.html create mode 100644 frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.service.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/toast-container.component.html create mode 100644 frontend/projects/ui/src/app/components/toast-container/toast-container.component.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/toast-container.module.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.html create mode 100644 frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.ts create mode 100644 frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.service.ts delete mode 100644 frontend/projects/ui/src/app/services/global-error-handler.service.ts rename frontend/projects/ui/src/app/{app/global => }/services/patch-data.service.ts (70%) rename frontend/projects/ui/src/app/{app/global => }/services/patch-monitor.service.ts (65%) diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 410f11c11..6bed11514 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -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, 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::>(&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::>::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::>::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::>::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) -> Result, 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); diff --git a/backend/src/logs.rs b/backend/src/logs.rs index 858f9e14e..ebd3fe376 100644 --- a/backend/src/logs.rs +++ b/backend/src/logs.rs @@ -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] diff --git a/backend/src/middleware/auth.rs b/backend/src/middleware/auth.rs index 3d0b177dd..c8c16635d 100644 --- a/backend/src/middleware/auth.rs +++ b/backend/src/middleware/auth.rs @@ -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(ctx: RpcContext) -> DynMiddleware { |_| 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(ctx: RpcContext) -> DynMiddleware { |_| 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() }); diff --git a/frontend/config-sample.json b/frontend/config-sample.json index 0622437d0..26699cc4b 100644 --- a/frontend/config-sample.json +++ b/frontend/config-sample.json @@ -2,11 +2,6 @@ "useMocks": true, "targetArch": "aarch64", "ui": { - "patchDb": { - "poll": { - "cooldown": 10000 - } - }, "api": { "url": "rpc", "version": "v1" diff --git a/frontend/projects/diagnostic-ui/src/app/app.module.ts b/frontend/projects/diagnostic-ui/src/app/app.module.ts index e120226d4..73e7b7bf7 100644 --- a/frontend/projects/diagnostic-ui/src/app/app.module.ts +++ b/frontend/projects/diagnostic-ui/src/app/app.module.ts @@ -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], }) diff --git a/frontend/projects/diagnostic-ui/src/app/services/global-error-handler.service.ts b/frontend/projects/diagnostic-ui/src/app/services/global-error-handler.service.ts deleted file mode 100644 index ac0bb9824..000000000 --- a/frontend/projects/diagnostic-ui/src/app/services/global-error-handler.service.ts +++ /dev/null @@ -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() - } - } -} \ No newline at end of file diff --git a/frontend/projects/setup-wizard/src/app/app.module.ts b/frontend/projects/setup-wizard/src/app/app.module.ts index dc888bad1..7aa804e78 100644 --- a/frontend/projects/setup-wizard/src/app/app.module.ts +++ b/frontend/projects/setup-wizard/src/app/app.module.ts @@ -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], }) diff --git a/frontend/projects/setup-wizard/src/app/services/global-error-handler.service.ts b/frontend/projects/setup-wizard/src/app/services/global-error-handler.service.ts deleted file mode 100644 index 1c3475d28..000000000 --- a/frontend/projects/setup-wizard/src/app/services/global-error-handler.service.ts +++ /dev/null @@ -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() - } - } -} \ No newline at end of file diff --git a/frontend/projects/shared/src/components/alert/alert-button.directive.ts b/frontend/projects/shared/src/components/alert/alert-button.directive.ts new file mode 100644 index 000000000..fc5320edb --- /dev/null +++ b/frontend/projects/shared/src/components/alert/alert-button.directive.ts @@ -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) {} + + get text(): string { + return this.elementRef.nativeElement.textContent?.trim() || '' + } + + get cssClass(): string[] { + return Array.from(this.elementRef.nativeElement.classList) + } +} diff --git a/frontend/projects/shared/src/components/alert/alert-input.directive.ts b/frontend/projects/shared/src/components/alert/alert-input.directive.ts new file mode 100644 index 000000000..af7879e37 --- /dev/null +++ b/frontend/projects/shared/src/components/alert/alert-input.directive.ts @@ -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 implements AlertInput { + @Input() + value?: T + + @Input() + label?: string + + constructor(private readonly elementRef: ElementRef) {} + + 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'] + } +} diff --git a/frontend/projects/shared/src/components/alert/alert.component.ts b/frontend/projects/shared/src/components/alert/alert.component.ts new file mode 100644 index 000000000..522ba93d3 --- /dev/null +++ b/frontend/projects/shared/src/components/alert/alert.component.ts @@ -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: ` +
+ + + `, + styles: [':host { display: none !important; }'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlertComponent implements AfterViewInit, OnDestroy { + @Output() + readonly dismiss = new EventEmitter>() + + @Input() + header = '' + + @Input() + subHeader = '' + + @Input() + backdropDismiss = true + + @ViewChild('message', { static: true }) + private readonly content?: ElementRef + + @ContentChildren(AlertButtonDirective) + private readonly buttons: QueryList = new QueryList() + + @ContentChildren(AlertInputDirective) + private readonly inputs: QueryList> = new QueryList() + + private alert?: HTMLIonAlertElement + + constructor( + private readonly elementRef: ElementRef, + 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(), + } + } +} diff --git a/frontend/projects/shared/src/components/alert/alert.module.ts b/frontend/projects/shared/src/components/alert/alert.module.ts new file mode 100644 index 000000000..45fa01f55 --- /dev/null +++ b/frontend/projects/shared/src/components/alert/alert.module.ts @@ -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 {} diff --git a/frontend/projects/shared/src/components/toast/toast-button.directive.ts b/frontend/projects/shared/src/components/toast/toast-button.directive.ts new file mode 100644 index 000000000..7c564961e --- /dev/null +++ b/frontend/projects/shared/src/components/toast/toast-button.directive.ts @@ -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) {} + + get text(): string | undefined { + return this.elementRef.nativeElement.textContent?.trim() || undefined + } + + get cssClass(): string[] { + return Array.from(this.elementRef.nativeElement.classList) + } +} diff --git a/frontend/projects/shared/src/components/toast/toast.component.ts b/frontend/projects/shared/src/components/toast/toast.component.ts new file mode 100644 index 000000000..b6431c532 --- /dev/null +++ b/frontend/projects/shared/src/components/toast/toast.component.ts @@ -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: ` +
+ + `, + styles: [':host { display: none !important; }'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ToastComponent implements AfterViewInit, OnDestroy { + @Output() + readonly dismiss = new EventEmitter>() + + @Input() + header = '' + + @Input() + duration = 0 + + @Input() + position: 'top' | 'bottom' | 'middle' = 'bottom' + + @ViewChild('message', { static: true }) + private readonly content?: ElementRef + + @ContentChildren(ToastButtonDirective) + private readonly buttons: QueryList = new QueryList() + + private toast?: HTMLIonToastElement + + constructor( + private readonly elementRef: ElementRef, + 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(), + } + } +} diff --git a/frontend/projects/shared/src/components/toast/toast.module.ts b/frontend/projects/shared/src/components/toast/toast.module.ts new file mode 100644 index 000000000..9f5304f5d --- /dev/null +++ b/frontend/projects/shared/src/components/toast/toast.module.ts @@ -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 {} diff --git a/frontend/projects/shared/src/public-api.ts b/frontend/projects/shared/src/public-api.ts index 003f5a8aa..c6df02a93 100644 --- a/frontend/projects/shared/src/public-api.ts +++ b/frontend/projects/shared/src/public-api.ts @@ -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' diff --git a/frontend/projects/shared/src/types/workspace-config.ts b/frontend/projects/shared/src/types/workspace-config.ts index 2bebcb6c1..ed3a1e269 100644 --- a/frontend/projects/shared/src/types/workspace-config.ts +++ b/frontend/projects/shared/src/types/workspace-config.ts @@ -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 diff --git a/frontend/projects/ui/src/app/app.component.html b/frontend/projects/ui/src/app/app.component.html index ed5cc0cf8..34c3353bb 100644 --- a/frontend/projects/ui/src/app/app.component.html +++ b/frontend/projects/ui/src/app/app.component.html @@ -18,4 +18,5 @@
+ diff --git a/frontend/projects/ui/src/app/app.component.ts b/frontend/projects/ui/src/app/app.component.ts index b19ac5d68..6789c4808 100644 --- a/frontend/projects/ui/src/app/app.component.ts +++ b/frontend/projects/ui/src/app/app.component.ts @@ -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[], - readonly authService: AuthService, + private readonly patchData: PatchDataService, + private readonly patchMonitor: PatchMonitorService, private readonly splitPane: SplitPaneTracker, + readonly authService: AuthService, ) {} splitPaneVisible({ detail }: any) { diff --git a/frontend/projects/ui/src/app/app.module.ts b/frontend/projects/ui/src/app/app.module.ts index 3c8ec71be..3e7fcc635 100644 --- a/frontend/projects/ui/src/app/app.module.ts +++ b/frontend/projects/ui/src/app/app.module.ts @@ -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], diff --git a/frontend/projects/ui/src/app/app.providers.ts b/frontend/projects/ui/src/app/app.providers.ts index 3e2d706f4..bf50889e5 100644 --- a/frontend/projects/ui/src/app/app.providers.ts +++ b/frontend/projects/ui/src/app/app.providers.ts @@ -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: [ diff --git a/frontend/projects/ui/src/app/app/footer/footer.component.ts b/frontend/projects/ui/src/app/app/footer/footer.component.ts index 2856aebb0..f91fa909f 100644 --- a/frontend/projects/ui/src/app/app/footer/footer.component.ts +++ b/frontend/projects/ui/src/app/app/footer/footer.component.ts @@ -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' diff --git a/frontend/projects/ui/src/app/app/global/global.module.ts b/frontend/projects/ui/src/app/app/global/global.module.ts deleted file mode 100644 index e5c3eadbf..000000000 --- a/frontend/projects/ui/src/app/app/global/global.module.ts +++ /dev/null @@ -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[] ->('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): ClassProvider { - return { - provide: GLOBAL_SERVICE, - multi: true, - useClass, - } -} - -function useExisting(useExisting: Type): ExistingProvider { - return { - provide: GLOBAL_SERVICE, - multi: true, - useExisting, - } -} diff --git a/frontend/projects/ui/src/app/app/global/services/connection-monitor.service.ts b/frontend/projects/ui/src/app/app/global/services/connection-monitor.service.ts deleted file mode 100644 index 94e739100..000000000 --- a/frontend/projects/ui/src/app/app/global/services/connection-monitor.service.ts +++ /dev/null @@ -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 { - 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)) - } -} diff --git a/frontend/projects/ui/src/app/app/global/services/logout.service.ts b/frontend/projects/ui/src/app/app/global/services/logout.service.ts deleted file mode 100644 index 18cd4d3d7..000000000 --- a/frontend/projects/ui/src/app/app/global/services/logout.service.ts +++ /dev/null @@ -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 { - 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)) - } -} diff --git a/frontend/projects/ui/src/app/app/global/services/offline.service.ts b/frontend/projects/ui/src/app/app/global/services/offline.service.ts deleted file mode 100644 index a26e1cac6..000000000 --- a/frontend/projects/ui/src/app/app/global/services/offline.service.ts +++ /dev/null @@ -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 { - 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 { - 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 -} diff --git a/frontend/projects/ui/src/app/app/global/services/refresh-toast.service.ts b/frontend/projects/ui/src/app/app/global/services/refresh-toast.service.ts deleted file mode 100644 index db871687d..000000000 --- a/frontend/projects/ui/src/app/app/global/services/refresh-toast.service.ts +++ /dev/null @@ -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 { - 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 { - 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() - }, - }, - ], -} diff --git a/frontend/projects/ui/src/app/app/global/services/unread-toast.service.ts b/frontend/projects/ui/src/app/app/global/services/unread-toast.service.ts deleted file mode 100644 index 2dad72542..000000000 --- a/frontend/projects/ui/src/app/app/global/services/unread-toast.service.ts +++ /dev/null @@ -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 { - private unreadToast?: HTMLIonToastElement - - private readonly stream$ = this.patchData.pipe( - switchMap>(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() - } -} diff --git a/frontend/projects/ui/src/app/app/global/services/update-toast.service.ts b/frontend/projects/ui/src/app/app/global/services/update-toast.service.ts deleted file mode 100644 index 252a13503..000000000 --- a/frontend/projects/ui/src/app/app/global/services/update-toast.service.ts +++ /dev/null @@ -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 { - 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 { - 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() - } - } -} diff --git a/frontend/projects/ui/src/app/app/menu/menu.component.ts b/frontend/projects/ui/src/app/app/menu/menu.component.ts index a16f872e5..4f6bc8c30 100644 --- a/frontend/projects/ui/src/app/app/menu/menu.component.ts +++ b/frontend/projects/ui/src/app/app/menu/menu.component.ts @@ -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() } } diff --git a/frontend/projects/ui/src/app/components/badge-menu-button/badge-menu.component.html b/frontend/projects/ui/src/app/components/badge-menu-button/badge-menu.component.html index f209c3605..612472dfd 100644 --- a/frontend/projects/ui/src/app/components/badge-menu-button/badge-menu.component.html +++ b/frontend/projects/ui/src/app/components/badge-menu-button/badge-menu.component.html @@ -1,6 +1,6 @@
{ - this.unreadCount = unread - this.sidebarOpen = menu - }), - ] - } - - ngOnDestroy() { - this.subs.forEach(sub => sub.unsubscribe()) - } } diff --git a/frontend/projects/ui/src/app/components/logs/logs.component.html b/frontend/projects/ui/src/app/components/logs/logs.component.html index 34a7bac9e..f65507534 100644 --- a/frontend/projects/ui/src/app/components/logs/logs.component.html +++ b/frontend/projects/ui/src/app/components/logs/logs.component.html @@ -17,7 +17,7 @@ id="scroller" *ngIf="!loading && needInfinite" position="top" - threshold="0" + threshold="1000" (ionInfinite)="doInfinite($event)" > = { - 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$), diff --git a/frontend/projects/ui/src/app/components/status/status.component.html b/frontend/projects/ui/src/app/components/status/status.component.html index f0df4c390..50ffa4860 100644 --- a/frontend/projects/ui/src/app/components/status/status.component.html +++ b/frontend/projects/ui/src/app/components/status/status.component.html @@ -1,15 +1,13 @@

- {{ (disconnected$ | async) ? 'Unknown' : rendering.display }} + {{ (connected$ | async) ? rendering.display : 'Unknown' }} + New notifications + + + View + + diff --git a/frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.component.ts b/frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.component.ts new file mode 100644 index 000000000..65d4241c2 --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.component.ts @@ -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() + + readonly visible$: Observable = merge( + this.dismiss$, + this.notifications$, + ) + + constructor( + @Inject(NotificationsToastService) + private readonly notifications$: Observable, + ) {} + + onDismiss() { + this.dismiss$.next(false) + } +} diff --git a/frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.service.ts b/frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.service.ts new file mode 100644 index 000000000..b6eb55325 --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/notifications-toast/notifications-toast.service.ts @@ -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 { + 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)) + } +} diff --git a/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.html b/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.html new file mode 100644 index 000000000..2da6d6039 --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.html @@ -0,0 +1,19 @@ + + {{ content.message }} + + + View solutions + + diff --git a/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.ts b/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.ts new file mode 100644 index 000000000..497e763bd --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.component.ts @@ -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() + + readonly content$ = merge(this.dismiss$, this.failure$) + + constructor( + @Inject(OfflineToastService) + private readonly failure$: Observable, + ) {} + + onDismiss() { + this.dismiss$.next(null) + } +} diff --git a/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.service.ts b/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.service.ts new file mode 100644 index 000000000..97178c339 --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/offline-toast/offline-toast.service.ts @@ -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 { + 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)) + } +} diff --git a/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.html b/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.html new file mode 100644 index 000000000..fe6dd640d --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.html @@ -0,0 +1,5 @@ + + Your user interface is cached and out of date. Hard refresh the page to get + the latest UI. + Refresh Page + diff --git a/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.ts b/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.ts new file mode 100644 index 000000000..e9a2e75ae --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.component.ts @@ -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() + + readonly show$ = merge(this.dismiss$, this.refresh$) + + constructor( + @Inject(RefreshAlertService) private readonly refresh$: Observable, + ) {} + + onDismiss() { + this.dismiss$.next(false) + } +} diff --git a/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.service.ts b/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.service.ts new file mode 100644 index 000000000..0855fc81f --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/refresh-alert/refresh-alert.service.ts @@ -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 { + 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)) + } +} diff --git a/frontend/projects/ui/src/app/components/toast-container/toast-container.component.html b/frontend/projects/ui/src/app/components/toast-container/toast-container.component.html new file mode 100644 index 000000000..d475b1947 --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/toast-container.component.html @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/projects/ui/src/app/components/toast-container/toast-container.component.ts b/frontend/projects/ui/src/app/components/toast-container/toast-container.component.ts new file mode 100644 index 000000000..161ddc076 --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/toast-container.component.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/components/toast-container/toast-container.module.ts b/frontend/projects/ui/src/app/components/toast-container/toast-container.module.ts new file mode 100644 index 000000000..0003cc51f --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/toast-container.module.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.html b/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.html new file mode 100644 index 000000000..4bc4c796d --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.html @@ -0,0 +1,11 @@ + + Restart your Embassy for these updates to take effect. It can take several + minutes to come back online. + + + diff --git a/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.ts b/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.ts new file mode 100644 index 000000000..72b6956ee --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.component.ts @@ -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() + + readonly visible$: Observable = merge(this.dismiss$, this.update$) + + constructor( + @Inject(UpdateToastService) private readonly update$: Observable, + private readonly embassyApi: ApiService, + private readonly errToast: ErrorToastService, + private readonly loadingCtrl: LoadingController, + ) {} + + onDismiss() { + this.dismiss$.next(false) + } + + async restart(): Promise { + 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() + } + } +} diff --git a/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.service.ts b/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.service.ts new file mode 100644 index 000000000..ff8389b7f --- /dev/null +++ b/frontend/projects/ui/src/app/components/toast-container/update-toast/update-toast.service.ts @@ -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 { + 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)) + } +} diff --git a/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.ts b/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.ts index a7173a67b..d09b39534 100644 --- a/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.ts +++ b/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.ts @@ -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 diff --git a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.html b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.html index 0dcd183fa..706c8487d 100644 --- a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.html +++ b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.html @@ -1,11 +1,11 @@ - + {{ title }} + - {{ title }} diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts index cc899d7d9..74d65a5a3 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts @@ -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 diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html index 085b8ee1b..8f1af1470 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html @@ -1,9 +1,4 @@ -

- +
+ + + +
diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.ts index 2148cea52..7ae537ad8 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.ts @@ -12,7 +12,7 @@ export class AppListIconComponent { @Input() pkg!: PkgInfo - disconnected$ = this.connectionService.watchDisconnected$() + readonly connected$ = this.connectionService.connected$ constructor(private readonly connectionService: ConnectionService) {} } diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-rec/app-list-rec.component.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-rec/app-list-rec.component.ts index 0122bc2d8..f7c2a397c 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-rec/app-list-rec.component.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-rec/app-list-rec.component.ts @@ -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), ) diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html index 430aa9710..24d3e52dd 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html @@ -9,12 +9,12 @@ - + - + - - + + - + diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.ts index 79d2d537c..86e4ef156 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.ts @@ -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 }), diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts index c6d85fcbe..a4f85a4ec 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts @@ -56,8 +56,8 @@ export class AppShowPage { readonly currentMarketplace$: Observable = this.marketplaceService.getMarketplace() - readonly altMarketplaceData$: Observable = - this.marketplaceService.getAltMarketplace() + readonly altMarketplaceData$: Observable = + this.marketplaceService.getAltMarketplaceData() constructor( private readonly route: ActivatedRoute, diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.html b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.html index d09b54eca..9b4585728 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.html @@ -3,19 +3,10 @@ > Health Checks - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.ts index bbddbffac..5db1ab1ca 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-health-checks/app-show-health-checks.component.ts @@ -17,7 +17,7 @@ export class AppShowHealthChecksComponent { HealthResult = HealthResult - readonly disconnected$ = this.connectionService.watchDisconnected$() + readonly connected$ = this.connectionService.connected$ constructor(private readonly connectionService: ConnectionService) {} diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html index e387084a2..b26bb45af 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html @@ -11,7 +11,7 @@ - + diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts index 7414f4115..cb57f487d 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts @@ -37,7 +37,7 @@ export class AppShowStatusComponent { PR = PrimaryRendering - disconnected$ = this.connectionService.watchDisconnected$() + readonly connected$ = this.connectionService.connected$ constructor( private readonly alertCtrl: AlertController, diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts index cd7d7296e..49f5b8d68 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts @@ -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: { diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts index 9f9fcfe12..8872e758f 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts @@ -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]) diff --git a/frontend/projects/ui/src/app/pages/developer-routes/dev-config/dev-config.page.ts b/frontend/projects/ui/src/app/pages/developer-routes/dev-config/dev-config.page.ts index 3feb5627b..39cdeb1c9 100644 --- a/frontend/projects/ui/src/app/pages/developer-routes/dev-config/dev-config.page.ts +++ b/frontend/projects/ui/src/app/pages/developer-routes/dev-config/dev-config.page.ts @@ -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 }) diff --git a/frontend/projects/ui/src/app/pages/developer-routes/dev-instructions/dev-instructions.page.ts b/frontend/projects/ui/src/app/pages/developer-routes/dev-instructions/dev-instructions.page.ts index ad650e1a0..fed1beb45 100644 --- a/frontend/projects/ui/src/app/pages/developer-routes/dev-instructions/dev-instructions.page.ts +++ b/frontend/projects/ui/src/app/pages/developer-routes/dev-instructions/dev-instructions.page.ts @@ -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 }) diff --git a/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts b/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts index 02dfb6344..0b419fc51 100644 --- a/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts +++ b/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts @@ -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, diff --git a/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.page.ts b/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.page.ts index 3331375ec..cad282a91 100644 --- a/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.page.ts +++ b/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.page.ts @@ -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) diff --git a/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.module.ts b/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.module.ts index c0ab34564..12a1e41e0 100644 --- a/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.module.ts +++ b/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.module.ts @@ -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, diff --git a/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.html b/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.html index 059ae68d7..6027ffc8e 100644 --- a/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.html +++ b/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.html @@ -8,14 +8,9 @@ - - - - diff --git a/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.ts b/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.ts index 8325fe1cb..41a4a7b95 100644 --- a/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.ts +++ b/frontend/projects/ui/src/app/pages/marketplace-routes/marketplace-list/marketplace-list.page.ts @@ -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> = 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 = 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 = this.marketplaceService + readonly name$ = this.marketplaceService .getMarketplace() .pipe(map(({ name }) => name)) constructor( private readonly patch: PatchDbService, private readonly marketplaceService: AbstractMarketplaceService, + private readonly connectionService: ConnectionService, ) {} } diff --git a/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts b/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts index 4ea359297..b731186c9 100644 --- a/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts +++ b/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts @@ -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, diff --git a/frontend/projects/ui/src/app/pages/server-routes/lan/lan.page.ts b/frontend/projects/ui/src/app/pages/server-routes/lan/lan.page.ts index a91ecf933..15ea137b0 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/lan/lan.page.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/lan/lan.page.ts @@ -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() diff --git a/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.html b/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.html index 911e8b722..f972fc398 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.html +++ b/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.html @@ -11,12 +11,9 @@ General - + Device Name - {{ ui.name || 'Embassy-' + server.id }} + {{ ui.name || 'My Embassy' }} Marketplace diff --git a/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.ts b/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.ts index 9a5830067..a0b58c6c5 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/preferences/preferences.page.ts @@ -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 diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.ts b/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.ts index c6a256f81..875ffeb36 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.ts @@ -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', diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html index 08b072dcb..21bf0a180 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html +++ b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html @@ -1,10 +1,12 @@ - - {{ (ui$ | async)?.name || "Embassy-" + (server$ | async)?.id }} + + {{ ui.name || "My Embassy" }} - - Loading + + + Loading + @@ -14,86 +16,80 @@ - + - - - -
- - - {{ cat.key }} - - - {{ cat.key }} - - - - - - -

{{ button.title }}

-

{{ button.description }}

+ + +
+ + + {{ cat.key }} + + + {{ cat.key }} + + + + + + +

{{ button.title }}

+

{{ button.description }}

- -

- - - Last Backup: {{ server['last-backup'] ? - (server['last-backup'] | date: 'medium') : 'never' }} - - - - Backing up - - -

- -

+ +

+ - Update Complete. Restart to apply changes + Last Backup: {{ server['last-backup'] ? (server['last-backup'] + | date: 'medium') : 'never' }} - - - - - Update Available - - - - - - Check for updates - - + + + Backing up + + +

+ +

+ + Update Complete. Restart to apply changes + + + + + + Update Available + + + + + + Check for updates + -

-
-
-
-
-
-
+ +

+ + + +
+
diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts index 116d83b3b..e08ef3836 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.ts @@ -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 { @@ -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 + disabled$: Observable } diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-specs/server-specs.page.ts b/frontend/projects/ui/src/app/pages/server-routes/server-specs/server-specs.page.ts index 3dd80968a..f77b8f7e1 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-specs/server-specs.page.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/server-specs/server-specs.page.ts @@ -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') diff --git a/frontend/projects/ui/src/app/services/api/api.types.ts b/frontend/projects/ui/src/app/services/api/api.types.ts index c0621c5f0..6a6f0c2ef 100644 --- a/frontend/projects/ui/src/app/services/api/api.types.ts +++ b/frontend/projects/ui/src/app/services/api/api.types.ts @@ -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 diff --git a/frontend/projects/ui/src/app/services/api/embassy-api.service.ts b/frontend/projects/ui/src/app/services/api/embassy-api.service.ts index ab37f7f06..be836929e 100644 --- a/frontend/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/frontend/projects/ui/src/app/services/api/embassy-api.service.ts @@ -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, Http { - protected readonly sync$ = new Subject>() - - /** 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): Observable>> { - return this.sync$ - .asObservable() - .pipe(map(result => ({ result, jsonrpc: '2.0' }))) - } - - // websocket - - abstract openLogsWebsocket$( - config: WebSocketSubjectConfig, - ): Observable +export abstract class ApiService { + readonly sync$ = new Subject>() // http @@ -63,6 +40,14 @@ export abstract class ApiService implements Source, Http { // server + abstract echo(params: RR.EchoReq): Promise + + abstract openPatchWebsocket$(): Observable> + + abstract openLogsWebsocket$( + config: WebSocketSubjectConfig, + ): Observable + abstract getServerLogs( params: RR.GetServerLogsReq, ): Promise diff --git a/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts b/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts index c1b153646..3e4847bfd 100644 --- a/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -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): Observable { - return webSocket(config) - } - async getStatic(url: string): Promise { return this.http.httpRequest({ method: Method.GET, @@ -41,93 +49,114 @@ export class LiveApiService extends ApiService { // db async getRevisions(since: number): Promise { - return this.http.rpcRequest({ method: 'db.revisions', params: { since } }) + return this.rpcRequest({ method: 'db.revisions', params: { since } }) } async getDump(): Promise { - return this.http.rpcRequest({ method: 'db.dump', params: {} }) + return this.rpcRequest({ method: 'db.dump', params: {} }) } async setDbValueRaw(params: RR.SetDBValueReq): Promise { - return this.http.rpcRequest({ method: 'db.put.ui', params }) + return this.rpcRequest({ method: 'db.put.ui', params }) } // auth async login(params: RR.LoginReq): Promise { - return this.http.rpcRequest({ method: 'auth.login', params }) + return this.rpcRequest({ method: 'auth.login', params }) } async logout(params: RR.LogoutReq): Promise { - return this.http.rpcRequest({ method: 'auth.logout', params }) + return this.rpcRequest({ method: 'auth.logout', params }) } async getSessions(params: RR.GetSessionsReq): Promise { - return this.http.rpcRequest({ method: 'auth.session.list', params }) + return this.rpcRequest({ method: 'auth.session.list', params }) } async killSessions(params: RR.KillSessionsReq): Promise { - return this.http.rpcRequest({ method: 'auth.session.kill', params }) + return this.rpcRequest({ method: 'auth.session.kill', params }) } // server + async echo(params: RR.EchoReq): Promise { + return this.rpcRequest({ method: 'echo', params }) + } + + openPatchWebsocket$(): Observable> { + const config: WebSocketSubjectConfig> = { + url: `/db`, + closeObserver: { + next: val => { + if (val.reason === 'UNAUTHORIZED') this.auth.setUnverified() + }, + }, + } + + return this.openWebsocket(config).pipe(timeout({ first: 21000 })) + } + + openLogsWebsocket$(config: WebSocketSubjectConfig): Observable { + return this.openWebsocket(config) + } + async getServerLogs( params: RR.GetServerLogsReq, ): Promise { - return this.http.rpcRequest({ method: 'server.logs', params }) + return this.rpcRequest({ method: 'server.logs', params }) } async getKernelLogs( params: RR.GetServerLogsReq, ): Promise { - return this.http.rpcRequest({ method: 'server.kernel-logs', params }) + return this.rpcRequest({ method: 'server.kernel-logs', params }) } async followServerLogs( params: RR.FollowServerLogsReq, ): Promise { - return this.http.rpcRequest({ method: 'server.logs.follow', params }) + return this.rpcRequest({ method: 'server.logs.follow', params }) } async followKernelLogs( params: RR.FollowServerLogsReq, ): Promise { - 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 { - return this.http.rpcRequest({ method: 'server.metrics', params }) + return this.rpcRequest({ method: 'server.metrics', params }) } async updateServerRaw( params: RR.UpdateServerReq, ): Promise { - return this.http.rpcRequest({ method: 'server.update', params }) + return this.rpcRequest({ method: 'server.update', params }) } async restartServer( params: RR.RestartServerReq, ): Promise { - return this.http.rpcRequest({ method: 'server.restart', params }) + return this.rpcRequest({ method: 'server.restart', params }) } async shutdownServer( params: RR.ShutdownServerReq, ): Promise { - return this.http.rpcRequest({ method: 'server.shutdown', params }) + return this.rpcRequest({ method: 'server.shutdown', params }) } async systemRebuild( params: RR.RestartServerReq, ): Promise { - return this.http.rpcRequest({ method: 'server.rebuild', params }) + return this.rpcRequest({ method: 'server.rebuild', params }) } async repairDisk(params: RR.RestartServerReq): Promise { - 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(path: string, qp: {}, url: string): Promise { 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 { - return this.http.rpcRequest({ method: 'notification.list', params }) + return this.rpcRequest({ method: 'notification.list', params }) } async deleteNotification( params: RR.DeleteNotificationReq, ): Promise { - return this.http.rpcRequest({ method: 'notification.delete', params }) + return this.rpcRequest({ method: 'notification.delete', params }) } async deleteAllNotifications( params: RR.DeleteAllNotificationsReq, ): Promise { - 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 { - return this.http.rpcRequest({ method: 'wifi.get', params, timeout }) + return this.rpcRequest({ method: 'wifi.get', params, timeout }) } async setWifiCountry( params: RR.SetWifiCountryReq, ): Promise { - return this.http.rpcRequest({ method: 'wifi.country.set', params }) + return this.rpcRequest({ method: 'wifi.country.set', params }) } async addWifi(params: RR.AddWifiReq): Promise { - return this.http.rpcRequest({ method: 'wifi.add', params }) + return this.rpcRequest({ method: 'wifi.add', params }) } async connectWifi(params: RR.ConnectWifiReq): Promise { - return this.http.rpcRequest({ method: 'wifi.connect', params }) + return this.rpcRequest({ method: 'wifi.connect', params }) } async deleteWifi(params: RR.DeleteWifiReq): Promise { - return this.http.rpcRequest({ method: 'wifi.delete', params }) + return this.rpcRequest({ method: 'wifi.delete', params }) } // ssh async getSshKeys(params: RR.GetSSHKeysReq): Promise { - return this.http.rpcRequest({ method: 'ssh.list', params }) + return this.rpcRequest({ method: 'ssh.list', params }) } async addSshKey(params: RR.AddSSHKeyReq): Promise { - return this.http.rpcRequest({ method: 'ssh.add', params }) + return this.rpcRequest({ method: 'ssh.add', params }) } async deleteSshKey(params: RR.DeleteSSHKeyReq): Promise { - 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 { - return this.http.rpcRequest({ method: 'backup.target.list', params }) + return this.rpcRequest({ method: 'backup.target.list', params }) } async addBackupTarget( params: RR.AddBackupTargetReq, ): Promise { 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 { - 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 { - 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 { - return this.http.rpcRequest({ method: 'backup.target.info', params }) + return this.rpcRequest({ method: 'backup.target.info', params }) } async createBackupRaw( params: RR.CreateBackupReq, ): Promise { - 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 { - return this.http.rpcRequest({ method: 'package.logs', params }) + return this.rpcRequest({ method: 'package.logs', params }) } async followPackageLogs( params: RR.FollowServerLogsReq, ): Promise { - return this.http.rpcRequest({ method: 'package.logs.follow', params }) + return this.rpcRequest({ method: 'package.logs.follow', params }) } async getPkgMetrics( params: RR.GetPackageMetricsReq, ): Promise { - return this.http.rpcRequest({ method: 'package.metrics', params }) + return this.rpcRequest({ method: 'package.metrics', params }) } async installPackageRaw( params: RR.InstallPackageReq, ): Promise { - return this.http.rpcRequest({ method: 'package.install', params }) + return this.rpcRequest({ method: 'package.install', params }) } async dryUpdatePackage( params: RR.DryUpdatePackageReq, ): Promise { - return this.http.rpcRequest({ method: 'package.update.dry', params }) + return this.rpcRequest({ method: 'package.update.dry', params }) } async getPackageConfig( params: RR.GetPackageConfigReq, ): Promise { - return this.http.rpcRequest({ method: 'package.config.get', params }) + return this.rpcRequest({ method: 'package.config.get', params }) } async drySetPackageConfig( params: RR.DrySetPackageConfigReq, ): Promise { - 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 { - return this.http.rpcRequest({ method: 'package.config.set', params }) + return this.rpcRequest({ method: 'package.config.set', params }) } async restorePackagesRaw( params: RR.RestorePackagesReq, ): Promise { - return this.http.rpcRequest({ method: 'package.backup.restore', params }) + return this.rpcRequest({ method: 'package.backup.restore', params }) } async executePackageAction( params: RR.ExecutePackageActionReq, ): Promise { - return this.http.rpcRequest({ method: 'package.action', params }) + return this.rpcRequest({ method: 'package.action', params }) } async startPackageRaw( params: RR.StartPackageReq, ): Promise { - return this.http.rpcRequest({ method: 'package.start', params }) + return this.rpcRequest({ method: 'package.start', params }) } async restartPackageRaw( params: RR.RestartPackageReq, ): Promise { - return this.http.rpcRequest({ method: 'package.restart', params }) + return this.rpcRequest({ method: 'package.restart', params }) } async stopPackageRaw(params: RR.StopPackageReq): Promise { - return this.http.rpcRequest({ method: 'package.stop', params }) + return this.rpcRequest({ method: 'package.stop', params }) } async deleteRecoveredPackageRaw( params: RR.DeleteRecoveredPackageReq, ): Promise { - return this.http.rpcRequest({ method: 'package.delete-recovered', params }) + return this.rpcRequest({ method: 'package.delete-recovered', params }) } async uninstallPackageRaw( params: RR.UninstallPackageReq, ): Promise { - return this.http.rpcRequest({ method: 'package.uninstall', params }) + return this.rpcRequest({ method: 'package.uninstall', params }) } async dryConfigureDependency( params: RR.DryConfigureDependencyReq, ): Promise { - 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 { - return this.http.rpcRequest({ + return this.rpcRequest({ method: 'package.sideload', params, }) } + + private openWebsocket(config: WebSocketSubjectConfig): Observable { + 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(options: RPCOptions): Promise { + return this.http.rpcRequest(options).catch(e => { + if ((e as RPCError).error.code === 34) { + console.error('Unauthenticated, logging out') + this.auth.setUnverified() + } + throw e + }) + } } diff --git a/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 18e50d2de..207e7b4f2 100644 --- a/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -44,16 +44,6 @@ export class MockApiService extends ApiService { super() } - openLogsWebsocket$(config: WebSocketSubjectConfig): Observable { - 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 { await pauseFor(2000) return markdown @@ -120,6 +110,25 @@ export class MockApiService extends ApiService { // server + async echo(params: RR.EchoReq): Promise { + await pauseFor(2000) + return params.message + } + + openPatchWebsocket$(): Observable> { + return this.mockPatch$ + } + + openLogsWebsocket$(config: WebSocketSubjectConfig): Observable { + 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 { @@ -291,7 +300,6 @@ export class MockApiService extends ApiService { value: 0, }, ] - return this.withRevision(patch, Mock.Notifications) } diff --git a/frontend/projects/ui/src/app/services/auth.service.ts b/frontend/projects/ui/src/app/services/auth.service.ts index 4e09627ca..e3729c7b6 100644 --- a/frontend/projects/ui/src/app/services/auth.service.ts +++ b/frontend/projects/ui/src/app/services/auth.service.ts @@ -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(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 { const loggedIn = await this.storage.get(this.LOGGED_IN_KEY) - this.authState$.next(loggedIn ? AuthState.VERIFIED : AuthState.UNVERIFIED) - } - - watch$(): Observable { - return this.authState$.pipe(distinctUntilChanged()) + if (loggedIn) { + this.setVerified() + } else { + this.setUnverified() + } } async setVerified(): Promise { @@ -34,7 +39,11 @@ export class AuthService { this.authState$.next(AuthState.VERIFIED) } - async setUnverified(): Promise { + setUnverified(): void { this.authState$.next(AuthState.UNVERIFIED) + this.storage.clear() + this.zone.run(() => { + this.router.navigate(['/login'], { replaceUrl: true }) + }) } } diff --git a/frontend/projects/ui/src/app/services/config.service.ts b/frontend/projects/ui/src/app/services/config.service.ts index dba1ffdbc..5769bd69d 100644 --- a/frontend/projects/ui/src/app/services/config.service.ts +++ b/frontend/projects/ui/src/app/services/config.service.ts @@ -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 diff --git a/frontend/projects/ui/src/app/services/connection.service.ts b/frontend/projects/ui/src/app/services/connection.service.ts index a83441b67..2f4967502 100644 --- a/frontend/projects/ui/src/app/services/connection.service.ts +++ b/frontend/projects/ui/src/app/services/connection.service.ts @@ -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.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 { - 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(1) + readonly connected$ = combineLatest([ + this.networkConnected$, + this.websocketConnected$, + ]).pipe(map(([network, websocket]) => network && websocket)) } diff --git a/frontend/projects/ui/src/app/services/eos.service.ts b/frontend/projects/ui/src/app/services/eos.service.ts index 3d19e3452..0e5887891 100644 --- a/frontend/projects/ui/src/app/services/eos.service.ts +++ b/frontend/projects/ui/src/app/services/eos.service.ts @@ -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(false) readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe( + filter(Boolean), map(status => !!status['update-progress'] || status.updated), distinctUntilChanged(), ) diff --git a/frontend/projects/ui/src/app/services/global-error-handler.service.ts b/frontend/projects/ui/src/app/services/global-error-handler.service.ts deleted file mode 100644 index 1c3475d28..000000000 --- a/frontend/projects/ui/src/app/services/global-error-handler.service.ts +++ /dev/null @@ -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() - } - } -} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/services/marketplace.service.ts b/frontend/projects/ui/src/app/services/marketplace.service.ts index 9b093890d..c7b8fb0cd 100644 --- a/frontend/projects/ui/src/app/services/marketplace.service.ts +++ b/frontend/projects/ui/src/app/services/marketplace.service.ts @@ -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>() private readonly hasPackages$ = new Subject() - private readonly uiMarketplaceData$: Observable< - UIMarketplaceData | undefined - > = this.patch.watch$('ui', 'marketplace').pipe(shareReplay(1)) + private readonly uiMarketplaceData$: Observable = + 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 = this.patch .watch$('server-info') - .pipe(take(1), shareReplay()) + .pipe(filter(Boolean), take(1), shareReplay()) private readonly registryData$: Observable = 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 { + getAltMarketplaceData(): Observable { return this.uiMarketplaceData$ } diff --git a/frontend/projects/ui/src/app/app/global/services/patch-data.service.ts b/frontend/projects/ui/src/app/services/patch-data.service.ts similarity index 70% rename from frontend/projects/ui/src/app/app/global/services/patch-data.service.ts rename to frontend/projects/ui/src/app/services/patch-data.service.ts index c7420525f..786a1ee6e 100644 --- a/frontend/projects/ui/src/app/app/global/services/patch-data.service.ts +++ b/frontend/projects/ui/src/app/services/patch-data.service.ts @@ -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 { - 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 { + 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 { private readonly embassyApi: ApiService, @Inject(AbstractMarketplaceService) private readonly marketplaceService: MarketplaceService, + private readonly connectionService: ConnectionService, ) { super(subscriber => this.stream$.subscribe(subscriber)) } diff --git a/frontend/projects/ui/src/app/services/patch-db/data-model.ts b/frontend/projects/ui/src/app/services/patch-db/data-model.ts index 9c728a65f..3b759c89b 100644 --- a/frontend/projects/ui/src/app/services/patch-db/data-model.ts +++ b/frontend/projects/ui/src/app/services/patch-db/data-model.ts @@ -41,8 +41,8 @@ export interface DevData { export interface DevProjectData { name: string - instructions?: string - config?: string + instructions: string + config: string 'basic-info'?: BasicInfo } diff --git a/frontend/projects/ui/src/app/services/patch-db/patch-db.factory.ts b/frontend/projects/ui/src/app/services/patch-db/patch-db.factory.ts index 5710b40f7..1c459e20e 100644 --- a/frontend/projects/ui/src/app/services/patch-db/patch-db.factory.ts +++ b/frontend/projects/ui/src/app/services/patch-db/patch-db.factory.ts @@ -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[]>('') -export const PATCH_SOURCE$ = new InjectionToken< - BehaviorSubject[]> ->('') +export const PATCH_SOURCE = new InjectionToken>>( + '', +) export const PATCH_CACHE = new InjectionToken>('', { factory: () => ({} as any), }) export const BOOTSTRAPPER = new InjectionToken>('') -export function mockSourceFactory({ - mockPatch$, -}: MockApiService): Source[] { - return Array(2).fill( - new MockSource(mockPatch$.pipe(filter(exists))), +export function sourceFactory( + api: ApiService, + authService: AuthService, + connectionService: ConnectionService, +): Observable> { + 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[] { - const { patchDb } = config - const host = defaultView?.location.host - const protocol = defaultView?.location.protocol === 'http:' ? 'ws' : 'wss' - - return [ - new WebsocketSource(`${protocol}://${host}/ws/db`), - new PollSource({ ...patchDb.poll }, embassyApi), - ] -} diff --git a/frontend/projects/ui/src/app/services/patch-db/patch-db.module.ts b/frontend/projects/ui/src/app/services/patch-db/patch-db.module.ts index 4a67975e6..7e990d178 100644 --- a/frontend/projects/ui/src/app/services/patch-db/patch-db.module.ts +++ b/frontend/projects/ui/src/app/services/patch-db/patch-db.module.ts @@ -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, }, ], diff --git a/frontend/projects/ui/src/app/services/patch-db/patch-db.service.ts b/frontend/projects/ui/src/app/services/patch-db/patch-db.service.ts index 429ce5825..f5e3f801a 100644 --- a/frontend/projects/ui/src/app/services/patch-db/patch-db.service.ts +++ b/frontend/projects/ui/src/app/services/patch-db/patch-db.service.ts @@ -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(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[], @Inject(BOOTSTRAPPER) private readonly bootstrapper: Bootstrapper, - @Inject(PATCH_SOURCE$) - private readonly sources$: BehaviorSubject[]>, - private readonly http: ApiService, - private readonly auth: AuthService, - private readonly storage: Storage, private readonly patchDb: PatchDB, ) {} - 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 { - 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 { - 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) diff --git a/frontend/projects/ui/src/app/app/global/services/patch-monitor.service.ts b/frontend/projects/ui/src/app/services/patch-monitor.service.ts similarity index 65% rename from frontend/projects/ui/src/app/app/global/services/patch-monitor.service.ts rename to frontend/projects/ui/src/app/services/patch-monitor.service.ts index efa33a800..aef9056e1 100644 --- a/frontend/projects/ui/src/app/app/global/services/patch-monitor.service.ts +++ b/frontend/projects/ui/src/app/services/patch-monitor.service.ts @@ -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 { 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)) } diff --git a/frontend/projects/ui/src/app/util/get-marketplace.ts b/frontend/projects/ui/src/app/util/get-marketplace.ts index ea32c4ad5..1f78d83a9 100644 --- a/frontend/projects/ui/src/app/util/get-marketplace.ts +++ b/frontend/projects/ui/src/app/util/get-marketplace.ts @@ -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 { - return firstValueFrom(patch.watch$('ui', 'marketplace')) + return firstValueFrom(patch.watch$('ui', 'marketplace').pipe(filter(Boolean))) } diff --git a/frontend/projects/ui/src/app/util/get-package-data.ts b/frontend/projects/ui/src/app/util/get-package-data.ts index d769ee0a1..6bdc2abdc 100644 --- a/frontend/projects/ui/src/app/util/get-package-data.ts +++ b/frontend/projects/ui/src/app/util/get-package-data.ts @@ -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> { - return firstValueFrom(patch.watch$('package-data')) + return firstValueFrom(patch.watch$('package-data').pipe(filter(Boolean))) } diff --git a/frontend/projects/ui/src/app/util/get-server-info.ts b/frontend/projects/ui/src/app/util/get-server-info.ts index c0cb6f7e9..58eef3ecf 100644 --- a/frontend/projects/ui/src/app/util/get-server-info.ts +++ b/frontend/projects/ui/src/app/util/get-server-info.ts @@ -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 { - return firstValueFrom(patch.watch$('server-info')) + return firstValueFrom(patch.watch$('server-info').pipe(filter(Boolean))) } diff --git a/patch-db b/patch-db index 2fef1e572..f4732b18a 160000 --- a/patch-db +++ b/patch-db @@ -1 +1 @@ -Subproject commit 2fef1e572cffca84634256bb7e78d836500bf169 +Subproject commit f4732b18a22f80556df778dc27933bac32d27f97