From 7324a4973f3c467dc90e575b4a08f75a51dae1e2 Mon Sep 17 00:00:00 2001 From: Alex Inkin Date: Fri, 8 Dec 2023 20:12:03 +0400 Subject: [PATCH] feat(portal): add notifications sidebar (#2516) * feat(portal): add notifications sidebar * chore: add service * chore: simplify style * chore: fix comments * WIP, moving notifications to patch-db * revamp notifications * chore: small adjustments --------- Co-authored-by: Matt Hill --- web/package-lock.json | 62 ++++++++ web/package.json | 5 +- .../src/app/services/api/api.service.ts | 4 +- .../src/app/services/api/live-api.service.ts | 6 +- .../src/app/services/api/mock-api.service.ts | 9 +- .../shared/src/services/setup-logs.service.ts | 10 +- web/projects/shared/src/types/api.ts | 10 +- web/projects/shared/styles/taiga.scss | 5 + web/projects/ui/src/app/app.component.html | 19 +-- web/projects/ui/src/app/app.module.ts | 2 + .../ui/src/app/app/menu/menu.component.ts | 3 +- .../ui/src/app/app/sidebar-host.component.ts | 35 +++++ .../diagnostic/services/diagnostic.service.ts | 4 +- .../services/live-diagnostic.service.ts | 6 +- .../services/mock-diagnostic.service.ts | 4 +- .../components/drawer/drawer.component.html | 2 +- .../components/drawer/drawer.component.ts | 4 +- .../header/header-menu.component.ts | 109 +++++++++++++ .../header-menu/header-menu.component.html | 56 ------- .../header-menu/header-menu.component.scss | 17 -- .../header-menu/header-menu.component.ts | 32 ---- .../header/header-notification.component.ts | 85 ++++++++++ .../header/header-notifications.component.ts | 145 ++++++++++++++++++ .../components/header/header.component.html | 13 -- .../components/header/header.component.scss | 14 -- .../components/header/header.component.ts | 104 ++++++++++++- .../apps/portal/constants/system-utilities.ts | 4 + .../backups => }/modals/report.component.ts | 0 .../ui/src/app/apps/portal/pipes/to-badge.ts | 15 ++ .../app/apps/portal/pipes/to-notifications.ts | 15 -- .../routes/desktop/desktop.component.html | 2 +- .../portal/routes/desktop/desktop.module.ts | 4 +- .../backups/modals/history.component.ts | 2 +- .../system/notifications/item.component.ts | 91 +++++++++++ .../notifications/notifications.component.ts | 123 +++++++++++++++ .../system/notifications/table.component.ts | 113 ++++++++++++++ .../portal/routes/system/system.module.ts | 9 ++ ...ifications.service.ts => badge.service.ts} | 8 +- .../portal/services/notification.service.ts | 116 ++++++++++++++ .../pages/notifications/notifications.page.ts | 10 +- .../server-metrics/server-metrics.page.ts | 2 +- .../badge-menu-button/badge-menu.component.ts | 3 +- .../ui/src/app/common/logs/logs.component.ts | 10 +- .../notifications-toast.service.ts | 2 +- .../ui/src/app/services/api/api.fixures.ts | 6 +- .../ui/src/app/services/api/api.types.ts | 44 ++++-- .../app/services/api/embassy-api.service.ts | 18 ++- .../services/api/embassy-live-api.service.ts | 26 ++-- .../services/api/embassy-mock-api.service.ts | 35 +++-- .../ui/src/app/services/api/mock-patch.ts | 5 +- .../src/app/services/patch-db/data-model.ts | 7 +- web/projects/ui/src/styles.scss | 1 + 52 files changed, 1181 insertions(+), 255 deletions(-) create mode 100644 web/projects/ui/src/app/app/sidebar-host.component.ts create mode 100644 web/projects/ui/src/app/apps/portal/components/header/header-menu.component.ts delete mode 100644 web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.html delete mode 100644 web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.scss delete mode 100644 web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.ts create mode 100644 web/projects/ui/src/app/apps/portal/components/header/header-notification.component.ts create mode 100644 web/projects/ui/src/app/apps/portal/components/header/header-notifications.component.ts delete mode 100644 web/projects/ui/src/app/apps/portal/components/header/header.component.html delete mode 100644 web/projects/ui/src/app/apps/portal/components/header/header.component.scss rename web/projects/ui/src/app/apps/portal/{routes/system/backups => }/modals/report.component.ts (100%) create mode 100644 web/projects/ui/src/app/apps/portal/pipes/to-badge.ts delete mode 100644 web/projects/ui/src/app/apps/portal/pipes/to-notifications.ts create mode 100644 web/projects/ui/src/app/apps/portal/routes/system/notifications/item.component.ts create mode 100644 web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts create mode 100644 web/projects/ui/src/app/apps/portal/routes/system/notifications/table.component.ts rename web/projects/ui/src/app/apps/portal/services/{notifications.service.ts => badge.service.ts} (92%) create mode 100644 web/projects/ui/src/app/apps/portal/services/notification.service.ts diff --git a/web/package-lock.json b/web/package-lock.json index 9aa528c56..8ce5b8d52 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.3.5.1", "dependencies": { "@angular/animations": "^16.1.4", + "@angular/cdk": "^16.1.4", "@angular/common": "^16.1.4", "@angular/compiler": "^16.1.4", "@angular/core": "^16.1.4", @@ -24,6 +25,7 @@ "@start9labs/emver": "^0.1.5", "@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2", "@taiga-ui/addon-charts": "3.53.0", + "@taiga-ui/addon-mobile": "3.53.0", "@taiga-ui/cdk": "3.53.0", "@taiga-ui/core": "3.53.0", "@taiga-ui/experimental": "3.53.0", @@ -31,6 +33,7 @@ "@taiga-ui/kit": "3.53.0", "@taiga-ui/styles": "3.53.0", "@tinkoff/ng-dompurify": "4.0.0", + "@tinkoff/ng-event-plugins": "3.1.0", "ansi-to-html": "^0.7.2", "base64-js": "^1.5.1", "cbor": "npm:@jprochazk/cbor@^0.4.9", @@ -440,6 +443,46 @@ "@angular/core": "16.2.12" } }, + "node_modules/@angular/cdk": { + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-16.2.12.tgz", + "integrity": "sha512-wT8/265zm2WKY0BDaRoYbrAT4kadrmejTRLjuimQIEUKnw4vBsJMWCwQkpFo3s6zr6eznGqYVAFb8KKPVLKGBg==", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@angular/common": "^16.0.0 || ^17.0.0", + "@angular/core": "^16.0.0 || ^17.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/cdk/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@angular/cdk/node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "optional": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@angular/cli": { "version": "16.2.10", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-16.2.10.tgz", @@ -4312,6 +4355,25 @@ "rxjs": ">=6.0.0" } }, + "node_modules/@taiga-ui/addon-mobile": { + "version": "3.53.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-3.53.0.tgz", + "integrity": "sha512-MHfG7I488fyT5NCOvQvCCgCMzIcYFO0MgfNGowcm/nyVaXcTzINF2JWMmdSbMJHCVBFy5xl2CQl0kjOFLYYg9g==", + "dependencies": { + "tslib": ">=2.0.0" + }, + "peerDependencies": { + "@angular/cdk": ">=12.0.0", + "@angular/common": ">=12.0.0", + "@angular/core": ">=12.0.0", + "@ng-web-apis/common": ">=3.0.0", + "@taiga-ui/cdk": ">=3.53.0", + "@taiga-ui/core": ">=3.53.0", + "@taiga-ui/kit": ">=3.53.0", + "@tinkoff/ng-polymorpheus": ">=4.0.0", + "rxjs": ">=6.0.0" + } + }, "node_modules/@taiga-ui/cdk": { "version": "3.53.0", "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.53.0.tgz", diff --git a/web/package.json b/web/package.json index d2219f5bb..8b313ccf1 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck", "check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck", "check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck", - "build:deps": "rm -rf .angular/cache && cd ../patch-db/client && npm ci && npm run build", + "build:deps": "npx rimraf .angular/cache && cd ../patch-db/client && npm ci && npm run build", "build:install": "ng run install-wizard:build", "build:setup": "ng run setup-wizard:build", "build:ui": "ng run ui:build", @@ -31,6 +31,7 @@ }, "dependencies": { "@angular/animations": "^16.1.4", + "@angular/cdk": "^16.1.4", "@angular/common": "^16.1.4", "@angular/compiler": "^16.1.4", "@angular/core": "^16.1.4", @@ -46,6 +47,7 @@ "@start9labs/emver": "^0.1.5", "@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2", "@taiga-ui/addon-charts": "3.53.0", + "@taiga-ui/addon-mobile": "3.53.0", "@taiga-ui/cdk": "3.53.0", "@taiga-ui/core": "3.53.0", "@taiga-ui/experimental": "3.53.0", @@ -53,6 +55,7 @@ "@taiga-ui/kit": "3.53.0", "@taiga-ui/styles": "3.53.0", "@tinkoff/ng-dompurify": "4.0.0", + "@tinkoff/ng-event-plugins": "3.1.0", "ansi-to-html": "^0.7.2", "base64-js": "^1.5.1", "cbor": "npm:@jprochazk/cbor@^0.4.9", diff --git a/web/projects/setup-wizard/src/app/services/api/api.service.ts b/web/projects/setup-wizard/src/app/services/api/api.service.ts index 0b94cd39d..019524eac 100644 --- a/web/projects/setup-wizard/src/app/services/api/api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api/api.service.ts @@ -4,6 +4,8 @@ import { StartOSDiskInfo, Log, SetupStatus, + FollowLogsRes, + FollowLogsReq, } from '@start9labs/shared' import { Observable } from 'rxjs' import { WebSocketSubjectConfig } from 'rxjs/webSocket' @@ -19,7 +21,7 @@ export abstract class ApiService { abstract execute(setupInfo: ExecuteReq): Promise // setup.execute abstract complete(): Promise // setup.complete abstract exit(): Promise // setup.exit - abstract followLogs(): Promise // setup.logs.follow + abstract followServerLogs(params: FollowLogsReq): Promise // setup.logs.follow abstract openLogsWebsocket$( config: WebSocketSubjectConfig, ): Observable diff --git a/web/projects/setup-wizard/src/app/services/api/live-api.service.ts b/web/projects/setup-wizard/src/app/services/api/live-api.service.ts index 808258015..b13967f91 100644 --- a/web/projects/setup-wizard/src/app/services/api/live-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api/live-api.service.ts @@ -9,6 +9,8 @@ import { RpcError, RPCOptions, SetupStatus, + FollowLogsRes, + FollowLogsReq, } from '@start9labs/shared' import { ApiService, @@ -90,8 +92,8 @@ export class LiveApiService extends ApiService { }) } - async followLogs(): Promise { - return this.rpcRequest({ method: 'setup.logs.follow', params: {} }) + async followServerLogs(params: FollowLogsReq): Promise { + return this.rpcRequest({ method: 'setup.logs.follow', params }) } openLogsWebsocket$({ url }: WebSocketSubjectConfig): Observable { diff --git a/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts index 375cd2135..e170683eb 100644 --- a/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core' import { encodeBase64, + FollowLogsReq, + FollowLogsRes, getSetupStatusMock, Log, pauseFor, @@ -134,9 +136,12 @@ export class MockApiService extends ApiService { await pauseFor(1000) } - async followLogs(): Promise { + async followServerLogs(params: FollowLogsReq): Promise { await pauseFor(1000) - return 'fake-guid' + return { + 'start-cursor': 'fakestartcursor', + guid: 'fake-guid', + } } openLogsWebsocket$(config: WebSocketSubjectConfig): Observable { diff --git a/web/projects/shared/src/services/setup-logs.service.ts b/web/projects/shared/src/services/setup-logs.service.ts index 1c3182e29..cf1ce7738 100644 --- a/web/projects/shared/src/services/setup-logs.service.ts +++ b/web/projects/shared/src/services/setup-logs.service.ts @@ -1,11 +1,11 @@ import { StaticClassProvider } from '@angular/core' import { defer, Observable, switchMap } from 'rxjs' import { WebSocketSubjectConfig } from 'rxjs/webSocket' -import { Log } from '../types/api' +import { FollowLogsReq, FollowLogsRes, Log } from '../types/api' import { Constructor } from '../types/constructor' interface Api { - followLogs: () => Promise + followServerLogs: (params: FollowLogsReq) => Promise openLogsWebsocket$: (config: WebSocketSubjectConfig) => Observable } @@ -20,8 +20,10 @@ export function provideSetupLogsService( } export class SetupLogsService extends Observable { - private readonly log$ = defer(() => this.api.followLogs()).pipe( - switchMap(url => this.api.openLogsWebsocket$({ url })), + private readonly log$ = defer(() => this.api.followServerLogs({})).pipe( + switchMap(({ guid }) => + this.api.openLogsWebsocket$({ url: `/rpc/${guid}` }), + ), ) constructor(private readonly api: Api) { diff --git a/web/projects/shared/src/types/api.ts b/web/projects/shared/src/types/api.ts index 419e99a07..895ce2cb2 100644 --- a/web/projects/shared/src/types/api.ts +++ b/web/projects/shared/src/types/api.ts @@ -1,10 +1,16 @@ -export type ServerLogsReq = { +export type FollowLogsReq = {} +export type FollowLogsRes = { + 'start-cursor': string + guid: string +} + +export type FetchLogsReq = { before: boolean cursor?: string limit?: number } -export type LogsRes = { +export type FetchLogsRes = { entries: Log[] 'start-cursor'?: string 'end-cursor'?: string diff --git a/web/projects/shared/styles/taiga.scss b/web/projects/shared/styles/taiga.scss index ea9cd857b..2df7487b8 100644 --- a/web/projects/shared/styles/taiga.scss +++ b/web/projects/shared/styles/taiga.scss @@ -96,3 +96,8 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] { } } } + +[tuiSidebar] > div.t-wrapper { + backdrop-filter: blur(1rem); + background: rgb(34 34 34 / 80%); +} diff --git a/web/projects/ui/src/app/app.component.html b/web/projects/ui/src/app/app.component.html index ceac3e214..dbb0ae039 100644 --- a/web/projects/ui/src/app/app.component.html +++ b/web/projects/ui/src/app/app.component.html @@ -18,7 +18,7 @@ class="left-menu" > - + @@ -38,7 +38,7 @@ (click)="onResize(drawer)" > - + - + - + + - - + + - + - - + + diff --git a/web/projects/ui/src/app/app.module.ts b/web/projects/ui/src/app/app.module.ts index 64787ff00..e573debd4 100644 --- a/web/projects/ui/src/app/app.module.ts +++ b/web/projects/ui/src/app/app.module.ts @@ -34,6 +34,7 @@ import { ConnectionBarComponentModule } from './app/connection-bar/connection-ba import { WidgetsPageModule } from 'src/app/apps/ui/pages/widgets/widgets.module' import { ServiceWorkerModule } from '@angular/service-worker' import { environment } from '../environments/environment' +import { SidebarHostComponent } from './app/sidebar-host.component' @NgModule({ declarations: [AppComponent], @@ -72,6 +73,7 @@ import { environment } from '../environments/environment' }), LoadingModule, QRComponentModule, + SidebarHostComponent, ], providers: APP_PROVIDERS, bootstrap: [AppComponent], diff --git a/web/projects/ui/src/app/app/menu/menu.component.ts b/web/projects/ui/src/app/app/menu/menu.component.ts index 39d8900e5..48a2ee6f7 100644 --- a/web/projects/ui/src/app/app/menu/menu.component.ts +++ b/web/projects/ui/src/app/app/menu/menu.component.ts @@ -68,7 +68,8 @@ export class MenuComponent { readonly notificationCount$ = this.patch.watch$( 'server-info', - 'unread-notification-count', + 'unreadNotifications', + 'count', ) readonly snekScore$ = this.patch.watch$('ui', 'gaming', 'snake', 'high-score') diff --git a/web/projects/ui/src/app/app/sidebar-host.component.ts b/web/projects/ui/src/app/app/sidebar-host.component.ts new file mode 100644 index 000000000..b8585d56e --- /dev/null +++ b/web/projects/ui/src/app/app/sidebar-host.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + Component, + Directive, + Injectable, +} from '@angular/core' +import { + AbstractTuiPortalHostComponent, + AbstractTuiPortalService, + TuiDropdownPortalService, +} from '@taiga-ui/cdk' + +@Injectable({ providedIn: `root` }) +export class SidebarService extends AbstractTuiPortalService {} + +@Directive({ + selector: '[tuiSidebar]', + standalone: true, + providers: [ + { provide: TuiDropdownPortalService, useExisting: SidebarService }, + ], +}) +export class SidebarDirective {} + +@Component({ + selector: 'sidebar-host', + template: '', + styles: [':host { position: fixed; top: 0; }'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + providers: [ + { provide: AbstractTuiPortalService, useExisting: SidebarService }, + ], +}) +export class SidebarHostComponent extends AbstractTuiPortalHostComponent {} diff --git a/web/projects/ui/src/app/apps/diagnostic/services/diagnostic.service.ts b/web/projects/ui/src/app/apps/diagnostic/services/diagnostic.service.ts index e8bd20a28..a22ca567d 100644 --- a/web/projects/ui/src/app/apps/diagnostic/services/diagnostic.service.ts +++ b/web/projects/ui/src/app/apps/diagnostic/services/diagnostic.service.ts @@ -1,4 +1,4 @@ -import { LogsRes, ServerLogsReq } from '@start9labs/shared' +import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared' export abstract class DiagnosticService { abstract getError(): Promise @@ -6,7 +6,7 @@ export abstract class DiagnosticService { abstract forgetDrive(): Promise abstract repairDisk(): Promise abstract systemRebuild(): Promise - abstract getLogs(params: ServerLogsReq): Promise + abstract getLogs(params: FetchLogsReq): Promise } export interface GetErrorRes { diff --git a/web/projects/ui/src/app/apps/diagnostic/services/live-diagnostic.service.ts b/web/projects/ui/src/app/apps/diagnostic/services/live-diagnostic.service.ts index dc4d3e9c4..3930abe9e 100644 --- a/web/projects/ui/src/app/apps/diagnostic/services/live-diagnostic.service.ts +++ b/web/projects/ui/src/app/apps/diagnostic/services/live-diagnostic.service.ts @@ -5,7 +5,7 @@ import { RpcError, RPCOptions, } from '@start9labs/shared' -import { LogsRes, ServerLogsReq } from '@start9labs/shared' +import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared' import { DiagnosticService, GetErrorRes } from './diagnostic.service' @Injectable() @@ -47,8 +47,8 @@ export class LiveDiagnosticService implements DiagnosticService { }) } - async getLogs(params: ServerLogsReq): Promise { - return this.rpcRequest({ + async getLogs(params: FetchLogsReq): Promise { + return this.rpcRequest({ method: 'diagnostic.logs', params, }) diff --git a/web/projects/ui/src/app/apps/diagnostic/services/mock-diagnostic.service.ts b/web/projects/ui/src/app/apps/diagnostic/services/mock-diagnostic.service.ts index 4a16f3e58..f7c847a19 100644 --- a/web/projects/ui/src/app/apps/diagnostic/services/mock-diagnostic.service.ts +++ b/web/projects/ui/src/app/apps/diagnostic/services/mock-diagnostic.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import { pauseFor } from '@start9labs/shared' -import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared' +import { FetchLogsReq, FetchLogsRes, Log } from '@start9labs/shared' import { DiagnosticService, GetErrorRes } from './diagnostic.service' @Injectable() @@ -30,7 +30,7 @@ export class MockDiagnosticService implements DiagnosticService { await pauseFor(1000) } - async getLogs(params: ServerLogsReq): Promise { + async getLogs(params: FetchLogsReq): Promise { await pauseFor(1000) let entries: Log[] if (Math.random() < 0.2) { diff --git a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html b/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html index 8be5ae964..5b1a3c1f7 100644 --- a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html +++ b/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html @@ -22,7 +22,7 @@ empty: empty " appCard - [badge]="item.key | toNotifications | async" + [badge]="item.key | toBadge | async" [drawerItem]="item.key" [id]="item.key" [title]="item.value.title" diff --git a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts b/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts index 2bf63a14c..9bfca5632 100644 --- a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts @@ -24,7 +24,7 @@ import { ServicesService } from '../../services/services.service' import { toRouterLink } from '../../utils/to-router-link' import { DrawerItemDirective } from './drawer-item.directive' import { SYSTEM_UTILITIES } from '../../constants/system-utilities' -import { ToNotificationsPipe } from '../../pipes/to-notifications' +import { ToBadgePipe } from '../../pipes/to-badge' @Component({ selector: 'app-drawer', @@ -45,7 +45,7 @@ import { ToNotificationsPipe } from '../../pipes/to-notifications' TuiFilterPipeModule, CardComponent, DrawerItemDirective, - ToNotificationsPipe, + ToBadgePipe, ], }) export class DrawerComponent { diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-menu.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header-menu.component.ts new file mode 100644 index 000000000..28f6fad05 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/header/header-menu.component.ts @@ -0,0 +1,109 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + TuiDataListModule, + TuiHostedDropdownModule, + TuiSvgModule, +} from '@taiga-ui/core' +import { TuiButtonModule } from '@taiga-ui/experimental' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { AuthService } from 'src/app/services/auth.service' + +@Component({ + selector: 'header-menu', + template: ` + + + + +

StartOS

+ + + + + + + + + + + + + + +
+
+
+ `, + styles: [ + ` + .item { + justify-content: flex-start; + gap: 0.75rem; + } + + .title { + margin: 0; + padding: 0 0.5rem 0.25rem; + white-space: nowrap; + font: var(--tui-font-text-l); + font-weight: bold; + } + + .external { + margin-left: auto; + padding-left: 0.5rem; + } + `, + ], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiHostedDropdownModule, + TuiDataListModule, + TuiSvgModule, + TuiButtonModule, + ], +}) +export class HeaderMenuComponent { + private readonly api = inject(ApiService) + private readonly auth = inject(AuthService) + + logout() { + this.api.logout({}).catch(e => console.error('Failed to log out', e)) + this.auth.setUnverified() + } +} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.html b/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.html deleted file mode 100644 index c6db54658..000000000 --- a/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - -

StartOS

- - - - - - - - - - - - - - -
-
-
diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.scss b/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.scss deleted file mode 100644 index 37973ef33..000000000 --- a/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.scss +++ /dev/null @@ -1,17 +0,0 @@ -.item { - justify-content: flex-start; - gap: 0.75rem; -} - -.title { - margin: 0; - padding: 0 0.5rem 0.25rem; - white-space: nowrap; - font: var(--tui-font-text-l); - font-weight: bold; -} - -.external { - margin-left: auto; - padding-left: 0.5rem; -} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.ts deleted file mode 100644 index 5a08d4ba9..000000000 --- a/web/projects/ui/src/app/apps/portal/components/header/header-menu/header-menu.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { - TuiDataListModule, - TuiHostedDropdownModule, - TuiSvgModule, -} from '@taiga-ui/core' -import { TuiButtonModule } from '@taiga-ui/experimental' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { AuthService } from 'src/app/services/auth.service' - -@Component({ - selector: 'header-menu', - templateUrl: 'header-menu.component.html', - styleUrls: ['header-menu.component.scss'], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiHostedDropdownModule, - TuiDataListModule, - TuiSvgModule, - TuiButtonModule, - ], -}) -export class HeaderMenuComponent { - private readonly api = inject(ApiService) - private readonly auth = inject(AuthService) - - logout() { - this.api.logout({}).catch(e => console.error('Failed to log out', e)) - this.auth.setUnverified() - } -} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-notification.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header-notification.component.ts new file mode 100644 index 000000000..fa1e8a4be --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/header/header-notification.component.ts @@ -0,0 +1,85 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + inject, + Input, +} from '@angular/core' +import { TuiSvgModule } from '@taiga-ui/core' +import { TuiButtonModule, TuiTitleModule } from '@taiga-ui/experimental' +import { TuiLineClampModule } from '@taiga-ui/kit' +import { ServerNotification } from 'src/app/services/api/api.types' +import { NotificationService } from '../../services/notification.service' + +@Component({ + selector: 'header-notification', + template: ` + +
+
+
+ {{ notification.title }} +
+ +
+ + + + +
+
+ + `, + styles: [':host { box-shadow: 0 1px var(--tui-clear); }'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TuiSvgModule, + TuiTitleModule, + TuiButtonModule, + TuiLineClampModule, + ], +}) +export class HeaderNotificationComponent { + readonly service = inject(NotificationService) + + @Input({ required: true }) notification!: ServerNotification + + overflow = false + + get color(): string { + return this.service.getColor(this.notification) + } + + get icon(): string { + return this.service.getIcon(this.notification) + } +} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-notifications.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header-notifications.component.ts new file mode 100644 index 000000000..afe60b05e --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/header/header-notifications.component.ts @@ -0,0 +1,145 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + Output, + inject, + EventEmitter, +} from '@angular/core' +import { RouterLink } from '@angular/router' +import { TuiForModule } from '@taiga-ui/cdk' +import { TuiScrollbarModule } from '@taiga-ui/core' +import { + TuiAvatarStackModule, + TuiButtonModule, + TuiCellModule, + TuiTitleModule, +} from '@taiga-ui/experimental' +import { PatchDB } from 'patch-db-client' +import { Subject, first, tap } from 'rxjs' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { HeaderNotificationComponent } from './header-notification.component' +import { toRouterLink } from '../../utils/to-router-link' +import { + ServerNotification, + ServerNotifications, +} from 'src/app/services/api/api.types' +import { NotificationService } from '../../services/notification.service' + +@Component({ + selector: 'header-notifications', + template: ` + +

+ Notifications + + Mark All Seen + +

+ + + + {{ $any(packageData[pkgId])?.manifest.title || pkgId }} + + + + View Service + + + + + View All + +
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 22rem; + max-width: 80vw; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + RouterLink, + TuiForModule, + TuiScrollbarModule, + TuiButtonModule, + HeaderNotificationComponent, + TuiCellModule, + TuiAvatarStackModule, + TuiTitleModule, + ], +}) +export class HeaderNotificationsComponent { + private readonly patch = inject(PatchDB) + private readonly service = inject(NotificationService) + + readonly packageData$ = this.patch.watch$('package-data').pipe(first()) + + readonly notifications$ = new Subject() + + @Output() onEmpty = new EventEmitter() + + ngAfterViewInit() { + this.patch + .watch$('server-info', 'unreadNotifications', 'recent') + .pipe( + tap(recent => this.notifications$.next(recent)), + first(), + ) + .subscribe() + } + + markSeen( + current: ServerNotifications, + notification: ServerNotification, + ) { + this.notifications$.next(current.filter(c => c.id !== notification.id)) + + if (current.length === 1) this.onEmpty.emit() + + this.service.markSeen([notification]) + } + + markAllSeen(latestId: number) { + this.notifications$.next([]) + + this.service.markSeenAll(latestId) + + this.onEmpty.emit() + } + + getLink(id: string) { + return toRouterLink(id) + } +} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header.component.html b/web/projects/ui/src/app/apps/portal/components/header/header.component.html deleted file mode 100644 index 5a0c60aa5..000000000 --- a/web/projects/ui/src/app/apps/portal/components/header/header.component.html +++ /dev/null @@ -1,13 +0,0 @@ - -
- - - 4 - - - -
diff --git a/web/projects/ui/src/app/apps/portal/components/header/header.component.scss b/web/projects/ui/src/app/apps/portal/components/header/header.component.scss deleted file mode 100644 index 71d68d68f..000000000 --- a/web/projects/ui/src/app/apps/portal/components/header/header.component.scss +++ /dev/null @@ -1,14 +0,0 @@ -:host { - display: flex; - align-items: center; - height: 4.5rem; - padding: 0 1rem 0 2rem; - font-size: 1.5rem; - // TODO: Theme - background: rgb(51 51 51 / 84%); -} - -.toolbar { - display: flex; - margin-left: auto; -} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts index 1ef1c3005..3ee4f951f 100644 --- a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts @@ -1,4 +1,15 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core' +import { CommonModule } from '@angular/common' +import { Router } from '@angular/router' +import { TuiSidebarModule } from '@taiga-ui/addon-mobile' +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + inject, + ViewChild, +} from '@angular/core' +import { tuiContainsOrAfter, tuiIsElement, TuiLetModule } from '@taiga-ui/cdk' import { TuiDataListModule, TuiHostedDropdownModule, @@ -9,22 +20,105 @@ import { TuiBadgeNotificationModule, TuiButtonModule, } from '@taiga-ui/experimental' -import { HeaderMenuComponent } from './header-menu/header-menu.component' +import { Subject } from 'rxjs' +import { SidebarDirective } from '../../../../app/sidebar-host.component' +import { HeaderMenuComponent } from './header-menu.component' +import { HeaderNotificationsComponent } from './header-notifications.component' +import { NotificationService } from '../../services/notification.service' @Component({ selector: 'header[appHeader]', - templateUrl: 'header.component.html', - styleUrls: ['header.component.scss'], + template: ` + + + + + {{ unread }} + + + + + + `, + styles: [ + ` + :host { + display: flex; + align-items: center; + height: 4.5rem; + padding: 0 1rem 0 2rem; + font-size: 1.5rem; + // TODO: Theme + background: rgb(51 51 51 / 84%); + } + `, + ], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + CommonModule, TuiBadgedContentModule, TuiBadgeNotificationModule, TuiButtonModule, TuiHostedDropdownModule, TuiDataListModule, TuiSvgModule, + TuiSidebarModule, + SidebarDirective, HeaderMenuComponent, + HeaderNotificationsComponent, + TuiLetModule, ], }) -export class HeaderComponent {} +export class HeaderComponent { + private readonly router = inject(Router) + readonly notificationService = inject(NotificationService) + + @ViewChild(HeaderNotificationsComponent, { read: ElementRef }) + private readonly panel?: ElementRef + + private readonly _ = this.router.events.subscribe(() => { + this.open$.next(false) + }) + + readonly open$ = new Subject() + + @HostListener('document:click.capture', ['$event.target']) + onClick(target: EventTarget | null) { + if ( + tuiIsElement(target) && + this.panel?.nativeElement && + !tuiContainsOrAfter(this.panel.nativeElement, target) + ) { + this.open$.next(false) + } + } + + handleNotificationsClick(unread: number) { + if (unread) { + this.open$.next(true) + } else { + this.router.navigateByUrl('/portal/system/notifications') + } + } +} diff --git a/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts b/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts index 3ddfdd947..c177dff7e 100644 --- a/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts +++ b/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts @@ -16,4 +16,8 @@ export const SYSTEM_UTILITIES: Record = icon: 'assets/img/icon_transparent.png', title: 'Snek', }, + '/portal/system/notifications': { + icon: 'tuiIconBellLarge', + title: 'Notifications', + }, } diff --git a/web/projects/ui/src/app/apps/portal/routes/system/backups/modals/report.component.ts b/web/projects/ui/src/app/apps/portal/modals/report.component.ts similarity index 100% rename from web/projects/ui/src/app/apps/portal/routes/system/backups/modals/report.component.ts rename to web/projects/ui/src/app/apps/portal/modals/report.component.ts diff --git a/web/projects/ui/src/app/apps/portal/pipes/to-badge.ts b/web/projects/ui/src/app/apps/portal/pipes/to-badge.ts new file mode 100644 index 000000000..04aad4ec1 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/pipes/to-badge.ts @@ -0,0 +1,15 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { BadgeService } from '../services/badge.service' +import { Observable } from 'rxjs' + +@Pipe({ + name: 'toBadge', + standalone: true, +}) +export class ToBadgePipe implements PipeTransform { + readonly badge = inject(BadgeService) + + transform(id: string): Observable { + return this.badge.getCount(id) + } +} diff --git a/web/projects/ui/src/app/apps/portal/pipes/to-notifications.ts b/web/projects/ui/src/app/apps/portal/pipes/to-notifications.ts deleted file mode 100644 index fb5add305..000000000 --- a/web/projects/ui/src/app/apps/portal/pipes/to-notifications.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { inject, Pipe, PipeTransform } from '@angular/core' -import { NotificationsService } from '../services/notifications.service' -import { Observable } from 'rxjs' - -@Pipe({ - name: 'toNotifications', - standalone: true, -}) -export class ToNotificationsPipe implements PipeTransform { - readonly notifications = inject(NotificationsService) - - transform(id: string): Observable { - return this.notifications.getNotifications(id) - } -} diff --git a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html index 4b993c6c5..0945fda7e 100644 --- a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html +++ b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html @@ -33,7 +33,7 @@ appCard @tuiFadeIn [id]="item" - [badge]="item | toNotifications | async" + [badge]="item | toBadge | async" [title]="navigationItem.title" [icon]="navigationItem.icon" [routerLink]="navigationItem.routerLink" diff --git a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts index a68955baf..34932972c 100644 --- a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts +++ b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts @@ -8,7 +8,7 @@ import { TuiTilesModule } from '@taiga-ui/kit' import { DesktopComponent } from './desktop.component' import { CardComponent } from '../../components/card/card.component' import { ToNavigationItemPipe } from '../../pipes/to-navigation-item' -import { ToNotificationsPipe } from '../../pipes/to-notifications' +import { ToBadgePipe } from '../../pipes/to-badge' import { DesktopItemDirective } from './desktop-item.directive' const ROUTES: Routes = [ @@ -30,7 +30,7 @@ const ROUTES: Routes = [ RouterModule.forChild(ROUTES), TuiFadeModule, DragScrollerDirective, - ToNotificationsPipe, + ToBadgePipe, ], declarations: [DesktopComponent], exports: [DesktopComponent], diff --git a/web/projects/ui/src/app/apps/portal/routes/system/backups/modals/history.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/backups/modals/history.component.ts index ad38c6aeb..fd3a59d8d 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/backups/modals/history.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/backups/modals/history.component.ts @@ -14,10 +14,10 @@ import { TuiCheckboxModule } from '@taiga-ui/kit' import { BehaviorSubject } from 'rxjs' import { BackupRun } from 'src/app/services/api/api.types' import { ApiService } from 'src/app/services/api/embassy-api.service' +import { REPORT } from 'src/app/apps/portal/modals/report.component' import { DurationPipe } from '../pipes/duration.pipe' import { HasErrorPipe } from '../pipes/has-error.pipe' import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe' -import { REPORT } from './report.component' @Component({ template: ` diff --git a/web/projects/ui/src/app/apps/portal/routes/system/notifications/item.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/notifications/item.component.ts new file mode 100644 index 000000000..87c3d1442 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/routes/system/notifications/item.component.ts @@ -0,0 +1,91 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + Input, + inject, +} from '@angular/core' +import { RouterLink } from '@angular/router' +import { Manifest } from '@start9labs/marketplace' +import { tuiPure } from '@taiga-ui/cdk' +import { TuiSvgModule } from '@taiga-ui/core' +import { TuiLineClampModule } from '@taiga-ui/kit' +import { PatchDB } from 'patch-db-client' +import { Observable, first } from 'rxjs' +import { ServerNotification } from 'src/app/services/api/api.types' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { NotificationService } from '../../../services/notification.service' +import { toRouterLink } from '../../../utils/to-router-link' + +@Component({ + selector: '[notificationItem]', + template: ` + + {{ notificationItem['created-at'] | date : 'MMM d, y, h:mm a' }} + + + {{ notificationItem.title }} + + + + {{ manifest.title }} + + N/A + + + + + View Full + + + View Report + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, RouterLink, TuiLineClampModule, TuiSvgModule], +}) +export class NotificationItemComponent { + private readonly patch = inject(PatchDB) + readonly service = inject(NotificationService) + + @Input({ required: true }) notificationItem!: ServerNotification + + overflow = false + + @tuiPure + get manifest$(): Observable { + return this.patch + .watch$( + 'package-data', + this.notificationItem['package-id'] || '', + 'manifest', + ) + .pipe(first()) + } + + get color(): string { + return this.service.getColor(this.notificationItem) + } + + get icon(): string { + return this.service.getIcon(this.notificationItem) + } + + getLink(id: string) { + return toRouterLink(id) + } +} diff --git a/web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts new file mode 100644 index 000000000..59a95ce2a --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts @@ -0,0 +1,123 @@ +import { CommonModule } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { Subject } from 'rxjs' +import { RR, ServerNotifications } from 'src/app/services/api/api.types' +import { NotificationService } from '../../../services/notification.service' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorService } from '@start9labs/shared' +import { TuiLetModule } from '@taiga-ui/cdk' +import { TuiButtonModule } from '@taiga-ui/experimental' +import { TuiDataListModule, TuiHostedDropdownModule } from '@taiga-ui/core' +import { NotificationsTableComponent } from './table.component' + +@Component({ + template: ` + +

+ Notifications + + + + + + + + + + + + +

+
+
+ `, + host: { class: 'g-page' }, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TuiHostedDropdownModule, + TuiButtonModule, + TuiDataListModule, + NotificationsTableComponent, + TuiLetModule, + ], +}) +export class NotificationsComponent { + readonly service = inject(NotificationService) + readonly api = inject(ApiService) + readonly errorService = inject(ErrorService) + + readonly notifications$ = new Subject() + + open = false + + ngOnInit() { + this.getMore({}) + } + + async getMore(params: RR.GetNotificationsReq) { + try { + this.notifications$.next(null) + this.notifications$.next(await this.api.getNotifications(params)) + } catch (e: any) { + this.errorService.handleError(e) + } + } + + markSeen(current: ServerNotifications, toUpdate: ServerNotifications) { + this.open = false + + this.notifications$.next( + current.map(c => ({ + ...c, + read: toUpdate.some(n => n.id === c.id) || c.read, + })), + ) + + this.service.markSeen(toUpdate) + } + + markUnseen(current: ServerNotifications, toUpdate: ServerNotifications) { + this.open = false + + this.notifications$.next( + current.map(c => ({ + ...c, + read: c.read && !toUpdate.some(n => n.id === c.id), + })), + ) + + this.service.markUnseen(toUpdate) + } + + remove(current: ServerNotifications, toDelete: ServerNotifications) { + this.open = false + + this.notifications$.next( + current.filter(c => !toDelete.some(n => n.id === c.id)), + ) + + this.service.remove(toDelete) + } +} diff --git a/web/projects/ui/src/app/apps/portal/routes/system/notifications/table.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/notifications/table.component.ts new file mode 100644 index 000000000..27f97aefd --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/routes/system/notifications/table.component.ts @@ -0,0 +1,113 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, +} from '@angular/core' +import { + ServerNotification, + ServerNotifications, +} from 'src/app/services/api/api.types' +import { TuiForModule } from '@taiga-ui/cdk' +import { BehaviorSubject } from 'rxjs' +import { TuiLineClampModule } from '@taiga-ui/kit' +import { FormsModule } from '@angular/forms' +import { NotificationItemComponent } from './item.component' +import { TuiCheckboxModule } from '@taiga-ui/experimental' + +@Component({ + selector: 'table[notifications]', + template: ` + + + + + + Date + Title + Service + Message + + + + + + + + + You have no notifications + + + + +
Loading
+ +
+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TuiForModule, + TuiCheckboxModule, + FormsModule, + TuiLineClampModule, + NotificationItemComponent, + ], +}) +export class NotificationsTableComponent implements OnChanges { + @Input() notifications: ServerNotifications | null = null + + get all(): boolean | null { + if (!this.notifications?.length || !this.selected$.value.length) { + return false + } + + if (this.notifications?.length === this.selected$.value.length) { + return true + } + + return null + } + + readonly selected$ = new BehaviorSubject([]) + + ngOnChanges() { + this.selected$.next([]) + } + + onAll(selected: boolean) { + this.selected$.next((selected && this.notifications) || []) + } + + handleToggle(notification: ServerNotification) { + const selected = this.selected$.value + + if (selected.some(s => s.id === notification.id)) { + this.selected$.next(selected.filter(s => s.id !== notification.id)) + } else { + this.selected$.next([...selected, notification]) + } + } +} diff --git a/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts b/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts index cc5638b33..c4e74cd71 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts @@ -11,6 +11,15 @@ const ROUTES: Routes = [ import('./backups/backups.component').then(m => m.BackupsComponent), data: toNavigationItem('/portal/system/backups'), }, + { + title: systemTabResolver, + path: 'notifications', + loadComponent: () => + import('./notifications/notifications.component').then( + m => m.NotificationsComponent, + ), + data: toNavigationItem('/portal/system/notifications'), + }, { title: systemTabResolver, path: 'sideload', diff --git a/web/projects/ui/src/app/apps/portal/services/notifications.service.ts b/web/projects/ui/src/app/apps/portal/services/badge.service.ts similarity index 92% rename from web/projects/ui/src/app/apps/portal/services/notifications.service.ts rename to web/projects/ui/src/app/apps/portal/services/badge.service.ts index 1c8eeb8a0..e8ff3982f 100644 --- a/web/projects/ui/src/app/apps/portal/services/notifications.service.ts +++ b/web/projects/ui/src/app/apps/portal/services/badge.service.ts @@ -20,7 +20,7 @@ import { ConnectionService } from 'src/app/services/connection.service' @Injectable({ providedIn: 'root', }) -export class NotificationsService { +export class BadgeService { private readonly emver = inject(Emver) private readonly patch = inject(PatchDB) private readonly marketplace = inject( @@ -47,7 +47,7 @@ export class NotificationsService { ), ) - private readonly updates$ = combineLatest([ + private readonly updateCount$ = combineLatest([ this.marketplace.getMarketplace$(true), this.local$, ]).pipe( @@ -67,10 +67,10 @@ export class NotificationsService { ), ) - getNotifications(id: string): Observable { + getCount(id: string): Observable { switch (id) { case '/portal/system/updates': - return this.updates$ + return this.updateCount$ default: return EMPTY } diff --git a/web/projects/ui/src/app/apps/portal/services/notification.service.ts b/web/projects/ui/src/app/apps/portal/services/notification.service.ts new file mode 100644 index 000000000..0c17c4f48 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/services/notification.service.ts @@ -0,0 +1,116 @@ +import { inject, Injectable } from '@angular/core' +import { ErrorService } from '@start9labs/shared' +import { TuiDialogService } from '@taiga-ui/core' +import { + NotificationLevel, + ServerNotification, + ServerNotifications, +} from 'src/app/services/api/api.types' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { REPORT } from '../modals/report.component' +import { firstValueFrom, merge, shareReplay, Subject } from 'rxjs' +import { PatchDB } from 'patch-db-client' +import { DataModel } from 'src/app/services/patch-db/data-model' + +@Injectable({ providedIn: 'root' }) +export class NotificationService { + private readonly patch = inject(PatchDB) + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + private readonly dialogs = inject(TuiDialogService) + private readonly localUnreadCount$ = new Subject() + + readonly unreadCount$ = merge( + this.patch.watch$('server-info', 'unreadNotifications', 'count'), + this.localUnreadCount$, + ).pipe(shareReplay(1)) + + async markSeen(notifications: ServerNotifications) { + const ids = notifications.filter(n => !n.read).map(n => n.id) + + this.updateCount(-ids.length) + + this.api + .markSeenNotifications({ ids }) + .catch(e => this.errorService.handleError(e)) + } + + async markSeenAll(latestId: number) { + this.localUnreadCount$.next(0) + + this.api + .markSeenAllNotifications({ before: latestId }) + .catch(e => this.errorService.handleError(e)) + } + + async markUnseen(notifications: ServerNotifications) { + const ids = notifications.filter(n => n.read).map(n => n.id) + + this.updateCount(ids.length) + + this.api + .markUnseenNotifications({ ids }) + .catch(e => this.errorService.handleError(e)) + } + + async remove(notifications: ServerNotifications): Promise { + this.updateCount(-notifications.filter(n => !n.read).length) + + this.api + .deleteNotifications({ ids: notifications.map(n => n.id) }) + .catch(e => this.errorService.handleError(e)) + } + + getColor(notification: ServerNotification): string { + switch (notification.level) { + case NotificationLevel.Info: + return 'var(--tui-info-fill)' + case NotificationLevel.Success: + return 'var(--tui-success-fill)' + case NotificationLevel.Warning: + return 'var(--tui-warning-fill)' + case NotificationLevel.Error: + return 'var(--tui-error-fill)' + default: + return '' + } + } + + getIcon(notification: ServerNotification): string { + switch (notification.level) { + case NotificationLevel.Info: + return 'tuiIconInfo' + case NotificationLevel.Success: + return 'tuiIconCheckCircle' + case NotificationLevel.Warning: + case NotificationLevel.Error: + return 'tuiIconAlertCircle' + default: + return '' + } + } + + viewFull(notification: ServerNotification) { + this.dialogs + .open(notification.message, { label: notification.title }) + .subscribe() + } + + viewReport(notification: ServerNotification) { + this.dialogs + .open(REPORT, { + label: 'Backup Report', + data: { + report: notification.data, + timestamp: notification['created-at'], + }, + }) + .subscribe() + } + + private async updateCount(toAdjust: number) { + const currentCount = await firstValueFrom(this.unreadCount$) + + this.localUnreadCount$.next(Math.max(currentCount + toAdjust, 0)) + } +} diff --git a/web/projects/ui/src/app/apps/ui/pages/notifications/notifications.page.ts b/web/projects/ui/src/app/apps/ui/pages/notifications/notifications.page.ts index 909ffcc75..a057aaf6d 100644 --- a/web/projects/ui/src/app/apps/ui/pages/notifications/notifications.page.ts +++ b/web/projects/ui/src/app/apps/ui/pages/notifications/notifications.page.ts @@ -73,7 +73,7 @@ export class NotificationsPage { const loader = this.loader.open('Deleting...').subscribe() try { - await this.embassyApi.deleteNotification({ id }) + // await this.embassyApi.deleteNotification({ id }) this.notifications.splice(index, 1) this.beforeCursor = this.notifications[this.notifications.length - 1]?.id } catch (e: any) { @@ -98,7 +98,7 @@ export class NotificationsPage { .subscribe(() => this.deleteAll()) } - async viewBackupReport(notification: ServerNotification<1>) { + async viewBackupReport(notification: ServerNotification) { this.dialogs .open(new PolymorpheusComponent(BackupReportComponent), { label: 'Backup Report', @@ -137,9 +137,9 @@ export class NotificationsPage { const loader = this.loader.open('Deleting...').subscribe() try { - await this.embassyApi.deleteAllNotifications({ - before: this.notifications[0].id + 1, - }) + // await this.embassyApi.deleteAllNotifications({ + // before: this.notifications[0].id + 1, + // }) this.notifications = [] this.beforeCursor = undefined } catch (e: any) { diff --git a/web/projects/ui/src/app/apps/ui/pages/system/server-metrics/server-metrics.page.ts b/web/projects/ui/src/app/apps/ui/pages/system/server-metrics/server-metrics.page.ts index 2c41ef6fb..5209ee91e 100644 --- a/web/projects/ui/src/app/apps/ui/pages/system/server-metrics/server-metrics.page.ts +++ b/web/projects/ui/src/app/apps/ui/pages/system/server-metrics/server-metrics.page.ts @@ -25,7 +25,7 @@ export class ServerMetricsPage { constructor( private readonly api: ApiService, - readonly timeService: TimeService, + private readonly timeService: TimeService, private readonly connectionService: ConnectionService, ) {} diff --git a/web/projects/ui/src/app/common/badge-menu-button/badge-menu.component.ts b/web/projects/ui/src/app/common/badge-menu-button/badge-menu.component.ts index b66d721ae..6e3406375 100644 --- a/web/projects/ui/src/app/common/badge-menu-button/badge-menu.component.ts +++ b/web/projects/ui/src/app/common/badge-menu-button/badge-menu.component.ts @@ -22,7 +22,8 @@ const { enableWidgets } = export class BadgeMenuComponent { readonly unreadCount$ = this.patch.watch$( 'server-info', - 'unread-notification-count', + 'unreadNotifications', + 'count', ) readonly sidebarOpen$ = this.splitPane.sidebarOpen$ readonly widgetDrawer$ = this.clientStorageService.widgetDrawer$ diff --git a/web/projects/ui/src/app/common/logs/logs.component.ts b/web/projects/ui/src/app/common/logs/logs.component.ts index 776d6fa1c..701d09fb4 100644 --- a/web/projects/ui/src/app/common/logs/logs.component.ts +++ b/web/projects/ui/src/app/common/logs/logs.component.ts @@ -13,8 +13,8 @@ import { } from 'rxjs' import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { - LogsRes, - ServerLogsReq, + FetchLogsReq, + FetchLogsRes, toLocalIsoString, Log, DownloadHTMLService, @@ -50,8 +50,8 @@ export class LogsComponent { params: RR.FollowServerLogsReq, ) => Promise @Input({ required: true }) fetchLogs!: ( - params: ServerLogsReq, - ) => Promise + params: FetchLogsReq, + ) => Promise @Input({ required: true }) context!: string @Input() defaultBack = '' @Input() pageTitle = '' @@ -205,7 +205,7 @@ export class LogsComponent { } } - private processRes(res: LogsRes) { + private processRes(res: FetchLogsRes) { const { entries, 'start-cursor': startCursor } = res if (!entries.length) return diff --git a/web/projects/ui/src/app/common/toast-container/notifications-toast/notifications-toast.service.ts b/web/projects/ui/src/app/common/toast-container/notifications-toast/notifications-toast.service.ts index f5f88bb08..9dd3671f7 100644 --- a/web/projects/ui/src/app/common/toast-container/notifications-toast/notifications-toast.service.ts +++ b/web/projects/ui/src/app/common/toast-container/notifications-toast/notifications-toast.service.ts @@ -6,7 +6,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model' @Injectable({ providedIn: 'root' }) export class NotificationsToastService extends Observable { private readonly stream$ = this.patch - .watch$('server-info', 'unread-notification-count') + .watch$('server-info', 'unreadNotifications', 'count') .pipe( pairwise(), map(([prev, cur]) => cur > prev), diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index e5cb10eec..8a2893b6c 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -339,6 +339,7 @@ export module Mock { }, }, }, + read: false, }, { id: 2, @@ -349,6 +350,7 @@ export module Mock { title: 'SSH Key Added', message: 'A new SSH key was added. If you did not do this, shit is bad.', data: null, + read: false, }, { id: 3, @@ -359,6 +361,7 @@ export module Mock { title: 'SSH Key Removed', message: 'A SSH key was removed.', data: null, + read: false, }, { id: 4, @@ -367,7 +370,7 @@ export module Mock { code: 4, level: NotificationLevel.Error, title: 'Service Crashed', - message: new Array(40) + message: new Array(3) .fill( `2021-11-27T18:36:30.451064Z 2021-11-27T18:36:30Z tor: Thread interrupt 2021-11-27T18:36:30.452833Z 2021-11-27T18:36:30Z Shutdown: In progress... @@ -376,6 +379,7 @@ export module Mock { ) .join(''), data: null, + read: false, }, ] diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 8aa790aa9..a4986857a 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -9,7 +9,13 @@ import { ServiceOutboundProxy, HealthCheckResult, } from 'src/app/services/patch-db/data-model' -import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared' +import { + StartOSDiskInfo, + FetchLogsReq, + FetchLogsRes, + FollowLogsRes, + FollowLogsReq, +} from '@start9labs/shared' import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants' export module RR { @@ -50,14 +56,11 @@ export module RR { uptime: number // seconds } - export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs - export type GetServerLogsRes = LogsRes + export type GetServerLogsReq = FetchLogsReq // server.logs & server.kernel-logs & server.tor-logs + export type GetServerLogsRes = FetchLogsRes - export type FollowServerLogsReq = { limit?: number } // server.logs.follow & server.kernel-logs.follow - export type FollowServerLogsRes = { - 'start-cursor': string - guid: string - } + export type FollowServerLogsReq = FollowLogsReq & { limit?: number } // server.logs.follow & server.kernel-logs.follow & server.tor-logs.follow + export type FollowServerLogsRes = FollowLogsRes export type GetServerMetricsReq = {} // server.metrics export type GetServerMetricsRes = { @@ -109,17 +112,29 @@ export module RR { // notification + export type FollowNotificationsReq = {} + export type FollowNotificationsRes = { + notifications: ServerNotifications + guid: string + } + export type GetNotificationsReq = { before?: number limit?: number } // notification.list export type GetNotificationsRes = ServerNotification[] - export type DeleteNotificationReq = { id: number } // notification.delete + export type DeleteNotificationReq = { ids: number[] } // notification.delete export type DeleteNotificationRes = null - export type DeleteAllNotificationsReq = { before: number } // notification.delete-before - export type DeleteAllNotificationsRes = null + export type MarkSeenNotificationReq = DeleteNotificationReq // notification.mark-seen + export type MarkSeenNotificationRes = null + + export type MarkSeenAllNotificationsReq = { before: number } // notification.mark-seen-before + export type MarkSeenAllNotificationsRes = null + + export type MarkUnseenNotificationReq = DeleteNotificationReq // notification.mark-unseen + export type MarkUnseenNotificationRes = null // network @@ -298,8 +313,8 @@ export module RR { export type GetPackageCredentialsReq = { id: string } // package.credentials export type GetPackageCredentialsRes = Record - export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs - export type GetPackageLogsRes = LogsRes + export type GetPackageLogsReq = FetchLogsReq & { id: string } // package.logs + export type GetPackageLogsRes = FetchLogsRes export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow export type FollowPackageLogsRes = FollowServerLogsRes @@ -562,7 +577,7 @@ export interface SSHKey { fingerprint: string } -export type ServerNotifications = ServerNotification[] +export type ServerNotifications = ServerNotification[] export interface ServerNotification { id: number @@ -573,6 +588,7 @@ export interface ServerNotification { title: string message: string data: NotificationData + read: boolean } export enum NotificationLevel { diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index e63c1d64a..0bf947848 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -125,13 +125,21 @@ export abstract class ApiService { params: RR.GetNotificationsReq, ): Promise - abstract deleteNotification( + abstract markSeenNotifications( + params: RR.MarkSeenNotificationReq, + ): Promise + + abstract markSeenAllNotifications( + params: RR.MarkSeenAllNotificationsReq, + ): Promise + + abstract markUnseenNotifications( params: RR.DeleteNotificationReq, ): Promise - abstract deleteAllNotifications( - params: RR.DeleteAllNotificationsReq, - ): Promise + abstract deleteNotifications( + params: RR.DeleteNotificationReq, + ): Promise // network @@ -308,8 +316,6 @@ export abstract class ApiService { abstract getSetupStatus(): Promise - abstract followLogs(): Promise - abstract setInterfaceClearnetAddress( params: RR.SetInterfaceClearnetAddressReq, ): Promise diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 247ce986a..873b8efdf 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -117,10 +117,6 @@ export class LiveApiService extends ApiService { return this.openWebsocket(config) } - async followLogs(): Promise { - return this.rpcRequest({ method: 'setup.logs.follow', params: {} }) - } - openLogsWebsocket$(config: WebSocketSubjectConfig): Observable { return this.openWebsocket(config) } @@ -259,21 +255,33 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'notification.list', params }) } - async deleteNotification( + async deleteNotifications( params: RR.DeleteNotificationReq, ): Promise { return this.rpcRequest({ method: 'notification.delete', params }) } - async deleteAllNotifications( - params: RR.DeleteAllNotificationsReq, - ): Promise { + async markSeenNotifications( + params: RR.MarkSeenNotificationReq, + ): Promise { + return this.rpcRequest({ method: 'notification.mark-seen', params }) + } + + async markSeenAllNotifications( + params: RR.MarkSeenAllNotificationsReq, + ): Promise { return this.rpcRequest({ - method: 'notification.delete-before', + method: 'notification.mark-seen-before', params, }) } + async markUnseenNotifications( + params: RR.MarkUnseenNotificationReq, + ): Promise { + return this.rpcRequest({ method: 'notification.mark-unseen', params }) + } + // network async addProxy(params: RR.AddProxyReq): Promise { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 3fadcd504..842e3b4a2 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -473,28 +473,34 @@ export class MockApiService extends ApiService { params: RR.GetNotificationsReq, ): Promise { await pauseFor(2000) - const patch = [ - { - op: PatchOp.REPLACE, - path: '/server-info/unread-notification-count', - value: 0, - }, - ] - this.mockRevision(patch) return Mock.Notifications } - async deleteNotification( + async deleteNotifications( params: RR.DeleteNotificationReq, ): Promise { await pauseFor(2000) return null } - async deleteAllNotifications( - params: RR.DeleteAllNotificationsReq, - ): Promise { + async markSeenNotifications( + params: RR.MarkSeenNotificationReq, + ): Promise { + await pauseFor(2000) + return null + } + + async markSeenAllNotifications( + params: RR.MarkSeenAllNotificationsReq, + ): Promise { + await pauseFor(2000) + return null + } + + async markUnseenNotifications( + params: RR.MarkUnseenNotificationReq, + ): Promise { await pauseFor(2000) return null } @@ -1244,11 +1250,6 @@ export class MockApiService extends ApiService { return getSetupStatusMock() } - async followLogs(): Promise { - await pauseFor(1000) - return 'fake-guid' - } - async setInterfaceClearnetAddress( params: RR.SetInterfaceClearnetAddressReq, ): Promise { diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 5b7855ccb..663496214 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -88,7 +88,10 @@ export const mockPatchData: DataModel = { outboundProxy: null, }, 'last-backup': new Date(new Date().valueOf() - 604800001).toISOString(), - 'unread-notification-count': 4, + unreadNotifications: { + count: 4, + recent: Mock.Notifications, + }, 'eos-version-compat': '>=0.3.0 <=0.3.0.1', 'status-info': { 'current-backup': null, diff --git a/web/projects/ui/src/app/services/patch-db/data-model.ts b/web/projects/ui/src/app/services/patch-db/data-model.ts index 837459ea2..2a151612b 100644 --- a/web/projects/ui/src/app/services/patch-db/data-model.ts +++ b/web/projects/ui/src/app/services/patch-db/data-model.ts @@ -1,7 +1,7 @@ import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes' import { Url } from '@start9labs/shared' import { Manifest } from '@start9labs/marketplace' -import { BackupJob } from '../api/api.types' +import { BackupJob, ServerNotifications } from '../api/api.types' import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants' import { NetworkInterfaceType } from '@start9labs/start-sdk/lib/util/utils' import { DependencyInfo } from 'src/app/apps/portal/routes/service/types/dependency-info' @@ -61,7 +61,10 @@ export interface ServerInfo { ui: AddressInfo network: NetworkInfo 'last-backup': string | null - 'unread-notification-count': number + unreadNotifications: { + count: number + recent: ServerNotifications + } 'status-info': ServerStatusInfo 'eos-version-compat': string pubkey: string diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index 255b86188..c573cdcaa 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -411,6 +411,7 @@ ul { text-transform: uppercase; font-weight: bold; font-size: 1rem; + line-height: 1.5rem; margin: 2rem 0 1rem; color: var(--tui-text-02); }