mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
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 <mattnine@protonmail.com>
This commit is contained in:
62
web/package-lock.json
generated
62
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> // setup.execute
|
||||
abstract complete(): Promise<CompleteRes> // setup.complete
|
||||
abstract exit(): Promise<void> // setup.exit
|
||||
abstract followLogs(): Promise<string> // setup.logs.follow
|
||||
abstract followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> // setup.logs.follow
|
||||
abstract openLogsWebsocket$(
|
||||
config: WebSocketSubjectConfig<Log>,
|
||||
): Observable<Log>
|
||||
|
||||
@@ -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<string> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
|
||||
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params })
|
||||
}
|
||||
|
||||
openLogsWebsocket$({ url }: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
|
||||
@@ -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<string> {
|
||||
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
|
||||
await pauseFor(1000)
|
||||
return 'fake-guid'
|
||||
return {
|
||||
'start-cursor': 'fakestartcursor',
|
||||
guid: 'fake-guid',
|
||||
}
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
|
||||
@@ -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<string>
|
||||
followServerLogs: (params: FollowLogsReq) => Promise<FollowLogsRes>
|
||||
openLogsWebsocket$: (config: WebSocketSubjectConfig<Log>) => Observable<Log>
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ export function provideSetupLogsService(
|
||||
}
|
||||
|
||||
export class SetupLogsService extends Observable<Log> {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
class="left-menu"
|
||||
>
|
||||
<ion-content color="light" scrollY="false" class="menu">
|
||||
<app-menu *ngIf="authService.isVerified$ | async"></app-menu>
|
||||
<app-menu *ngIf="authService.isVerified$ | async" />
|
||||
</ion-content>
|
||||
</ion-menu>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
(click)="onResize(drawer)"
|
||||
></button>
|
||||
</div>
|
||||
<widgets *ngIf="drawer.open" [wide]="drawer.width === 600"></widgets>
|
||||
<widgets *ngIf="drawer.open" [wide]="drawer.width === 600" />
|
||||
</ion-menu>
|
||||
|
||||
<ion-router-outlet
|
||||
@@ -68,22 +68,23 @@
|
||||
!(sidebarOpen$ | async)
|
||||
"
|
||||
>
|
||||
<connection-bar></connection-bar>
|
||||
<connection-bar />
|
||||
</ion-footer>
|
||||
<toast-container></toast-container>
|
||||
<toast-container />
|
||||
</ion-app>
|
||||
<sidebar-host ngProjectAs="tuiOverContent" />
|
||||
</tui-root>
|
||||
<ng-container
|
||||
*ngIf="authService.isVerified$ | async; else defaultTheme"
|
||||
[ngSwitch]="theme$ | async"
|
||||
>
|
||||
<ng-container *ngSwitchCase="'Dark'">
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<dark-theme></dark-theme>
|
||||
<tui-theme-night />
|
||||
<dark-theme />
|
||||
</ng-container>
|
||||
<light-theme *ngSwitchCase="'Light'"></light-theme>
|
||||
<light-theme *ngSwitchCase="'Light'" />
|
||||
</ng-container>
|
||||
<ng-template #defaultTheme>
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<dark-theme></dark-theme>
|
||||
<tui-theme-night />
|
||||
<dark-theme />
|
||||
</ng-template>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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')
|
||||
|
||||
35
web/projects/ui/src/app/app/sidebar-host.component.ts
Normal file
35
web/projects/ui/src/app/app/sidebar-host.component.ts
Normal file
@@ -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: '<ng-container #viewContainer></ng-container>',
|
||||
styles: [':host { position: fixed; top: 0; }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
providers: [
|
||||
{ provide: AbstractTuiPortalService, useExisting: SidebarService },
|
||||
],
|
||||
})
|
||||
export class SidebarHostComponent extends AbstractTuiPortalHostComponent {}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
|
||||
|
||||
export abstract class DiagnosticService {
|
||||
abstract getError(): Promise<GetErrorRes>
|
||||
@@ -6,7 +6,7 @@ export abstract class DiagnosticService {
|
||||
abstract forgetDrive(): Promise<void>
|
||||
abstract repairDisk(): Promise<void>
|
||||
abstract systemRebuild(): Promise<void>
|
||||
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
|
||||
abstract getLogs(params: FetchLogsReq): Promise<FetchLogsRes>
|
||||
}
|
||||
|
||||
export interface GetErrorRes {
|
||||
|
||||
@@ -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<LogsRes> {
|
||||
return this.rpcRequest<LogsRes>({
|
||||
async getLogs(params: FetchLogsReq): Promise<FetchLogsRes> {
|
||||
return this.rpcRequest<FetchLogsRes>({
|
||||
method: 'diagnostic.logs',
|
||||
params,
|
||||
})
|
||||
|
||||
@@ -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<LogsRes> {
|
||||
async getLogs(params: FetchLogsReq): Promise<FetchLogsRes> {
|
||||
await pauseFor(1000)
|
||||
let entries: Log[]
|
||||
if (Math.random() < 0.2) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: `
|
||||
<tui-hosted-dropdown
|
||||
[content]="content"
|
||||
[tuiDropdownMaxHeight]="9999"
|
||||
(click.stop.prevent)="(0)"
|
||||
(pointerdown.stop)="(0)"
|
||||
>
|
||||
<button tuiIconButton appearance="">
|
||||
<img style="max-width: 62%" src="assets/img/icon.png" alt="StartOS" />
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
<h3 class="title">StartOS</h3>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconInfo"></tui-svg>
|
||||
About this server
|
||||
</button>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconBookOpen"></tui-svg>
|
||||
User Manual
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconHeadphones"></tui-svg>
|
||||
Contact Support
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconDollarSign"></tui-svg>
|
||||
Donate to Start9
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconTool"></tui-svg>
|
||||
System Rebuild
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconRefreshCw"></tui-svg>
|
||||
Restart
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconPower"></tui-svg>
|
||||
Shutdown
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="logout()">
|
||||
<tui-svg src="tuiIconLogOut"></tui-svg>
|
||||
Logout
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</tui-hosted-dropdown>
|
||||
`,
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<tui-hosted-dropdown
|
||||
[content]="content"
|
||||
[tuiDropdownMaxHeight]="9999"
|
||||
(click.stop.prevent)="(0)"
|
||||
(pointerdown.stop)="(0)"
|
||||
>
|
||||
<button tuiIconButton appearance="">
|
||||
<img style="max-width: 62%" src="assets/img/icon.png" alt="StartOS" />
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
<h3 class="title">StartOS</h3>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconInfo"></tui-svg>
|
||||
About this server
|
||||
</button>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconBookOpen"></tui-svg>
|
||||
User Manual
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconHeadphones"></tui-svg>
|
||||
Contact Support
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconDollarSign"></tui-svg>
|
||||
Donate to Start9
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconTool"></tui-svg>
|
||||
System Rebuild
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconRefreshCw"></tui-svg>
|
||||
Restart
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconPower"></tui-svg>
|
||||
Shutdown
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="logout()">
|
||||
<tui-svg src="tuiIconLogOut"></tui-svg>
|
||||
Logout
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</tui-hosted-dropdown>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<tui-svg
|
||||
style="align-self: flex-start; margin: 0.25rem 0;"
|
||||
[style.color]="color"
|
||||
[src]="icon"
|
||||
></tui-svg>
|
||||
<div tuiTitle>
|
||||
<div tuiSubtitle><ng-content></ng-content></div>
|
||||
<div [style.color]="color">
|
||||
{{ notification.title }}
|
||||
</div>
|
||||
<tui-line-clamp
|
||||
tuiSubtitle
|
||||
style="pointer-events: none"
|
||||
[linesLimit]="4"
|
||||
[lineHeight]="16"
|
||||
[content]="notification.message"
|
||||
(overflownChange)="overflow = $event"
|
||||
/>
|
||||
<div style="display: flex; gap: 0.5rem; padding-top: 0.5rem;">
|
||||
<button
|
||||
*ngIf="notification.code === 1"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="xs"
|
||||
(click)="service.viewReport(notification)"
|
||||
>
|
||||
View Report
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="overflow"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="xs"
|
||||
(click)="service.viewFull(notification)"
|
||||
>
|
||||
View full
|
||||
</button>
|
||||
<ng-content select="a"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content select="button"></ng-content>
|
||||
`,
|
||||
styles: [':host { box-shadow: 0 1px var(--tui-clear); }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiSvgModule,
|
||||
TuiTitleModule,
|
||||
TuiButtonModule,
|
||||
TuiLineClampModule,
|
||||
],
|
||||
})
|
||||
export class HeaderNotificationComponent<T extends number> {
|
||||
readonly service = inject(NotificationService)
|
||||
|
||||
@Input({ required: true }) notification!: ServerNotification<T>
|
||||
|
||||
overflow = false
|
||||
|
||||
get color(): string {
|
||||
return this.service.getColor(this.notification)
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
return this.service.getIcon(this.notification)
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<ng-container *ngIf="notifications$ | async as notifications">
|
||||
<h3 class="g-title" style="padding: 0 1rem">
|
||||
Notifications
|
||||
<a
|
||||
*ngIf="notifications.length"
|
||||
style="margin-left: auto; text-transform: none; font-size: 0.9rem; font-weight: 600;"
|
||||
(click)="markAllSeen(notifications[0].id)"
|
||||
>
|
||||
Mark All Seen
|
||||
</a>
|
||||
</h3>
|
||||
<tui-scrollbar *ngIf="packageData$ | async as packageData">
|
||||
<header-notification
|
||||
*ngFor="let not of notifications; let i = index"
|
||||
tuiCell
|
||||
[notification]="not"
|
||||
>
|
||||
<ng-container *ngIf="not['package-id'] as pkgId">
|
||||
{{ $any(packageData[pkgId])?.manifest.title || pkgId }}
|
||||
</ng-container>
|
||||
<button
|
||||
style="align-self: flex-start; flex-shrink: 0;"
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconMinusCircle"
|
||||
(click)="markSeen(notifications, not)"
|
||||
></button>
|
||||
<a
|
||||
*ngIf="not['package-id'] && packageData[not['package-id']]"
|
||||
tuiButton
|
||||
size="xs"
|
||||
appearance="secondary"
|
||||
[routerLink]="getLink(not['package-id'] || '')"
|
||||
>
|
||||
View Service
|
||||
</a>
|
||||
</header-notification>
|
||||
</tui-scrollbar>
|
||||
<a
|
||||
style="margin: 2rem; text-align: center; font-size: 0.9rem; font-weight: 600;"
|
||||
[routerLink]="'/portal/system/notifications'"
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
</ng-container>
|
||||
`,
|
||||
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<DataModel>)
|
||||
private readonly service = inject(NotificationService)
|
||||
|
||||
readonly packageData$ = this.patch.watch$('package-data').pipe(first())
|
||||
|
||||
readonly notifications$ = new Subject<ServerNotifications>()
|
||||
|
||||
@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<number>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<ng-content></ng-content>
|
||||
<div class="toolbar">
|
||||
<button tuiIconButton iconLeft="tuiIconCloudLarge" appearance="success">
|
||||
Connection
|
||||
</button>
|
||||
<tui-badged-content [style.--tui-radius.%]="50">
|
||||
<tui-badge-notification tuiSlot="bottom" size="s">4</tui-badge-notification>
|
||||
<button tuiIconButton iconLeft="tuiIconBellLarge" appearance="warning">
|
||||
Notifications
|
||||
</button>
|
||||
</tui-badged-content>
|
||||
<header-menu></header-menu>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: `
|
||||
<ng-content></ng-content>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconCloudLarge"
|
||||
appearance="success"
|
||||
[style.margin-left]="'auto'"
|
||||
>
|
||||
Connection
|
||||
</button>
|
||||
<tui-badged-content
|
||||
*tuiLet="notificationService.unreadCount$ | async as unread"
|
||||
[style.--tui-radius.%]="50"
|
||||
>
|
||||
<tui-badge-notification *ngIf="unread" tuiSlot="bottom" size="s">
|
||||
{{ unread }}
|
||||
</tui-badge-notification>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconBellLarge"
|
||||
appearance="warning"
|
||||
(click)="handleNotificationsClick(unread || 0)"
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
</tui-badged-content>
|
||||
<header-menu></header-menu>
|
||||
<header-notifications
|
||||
(onEmpty)="this.open$.next(false)"
|
||||
*tuiSidebar="!!(open$ | async); direction: 'right'; autoWidth: true"
|
||||
/>
|
||||
`,
|
||||
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<HTMLElement>
|
||||
|
||||
private readonly _ = this.router.events.subscribe(() => {
|
||||
this.open$.next(false)
|
||||
})
|
||||
|
||||
readonly open$ = new Subject<boolean>()
|
||||
|
||||
@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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,8 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
icon: 'assets/img/icon_transparent.png',
|
||||
title: 'Snek',
|
||||
},
|
||||
'/portal/system/notifications': {
|
||||
icon: 'tuiIconBellLarge',
|
||||
title: 'Notifications',
|
||||
},
|
||||
}
|
||||
|
||||
15
web/projects/ui/src/app/apps/portal/pipes/to-badge.ts
Normal file
15
web/projects/ui/src/app/apps/portal/pipes/to-badge.ts
Normal file
@@ -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<number> {
|
||||
return this.badge.getCount(id)
|
||||
}
|
||||
}
|
||||
@@ -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<number> {
|
||||
return this.notifications.getNotifications(id)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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: `
|
||||
<td><ng-content /></td>
|
||||
<td>{{ notificationItem['created-at'] | date : 'MMM d, y, h:mm a' }}</td>
|
||||
<td [style.color]="color">
|
||||
<tui-svg [style.color]="color" [src]="icon"></tui-svg>
|
||||
{{ notificationItem.title }}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
*ngIf="manifest$ | async as manifest; else na"
|
||||
[routerLink]="getLink(manifest.id)"
|
||||
>
|
||||
{{ manifest.title }}
|
||||
</a>
|
||||
<ng-template #na>N/A</ng-template>
|
||||
</td>
|
||||
<td [style.padding-bottom.rem]="0.5">
|
||||
<tui-line-clamp
|
||||
style="pointer-events: none"
|
||||
[linesLimit]="4"
|
||||
[lineHeight]="21"
|
||||
[content]="notificationItem.message"
|
||||
(overflownChange)="overflow = $event"
|
||||
/>
|
||||
<a *ngIf="overflow" (click)="service.viewFull(notificationItem)">
|
||||
View Full
|
||||
</a>
|
||||
<a
|
||||
*ngIf="notificationItem.code === 1"
|
||||
(click)="service.viewReport(notificationItem)"
|
||||
>
|
||||
View Report
|
||||
</a>
|
||||
</td>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, TuiLineClampModule, TuiSvgModule],
|
||||
})
|
||||
export class NotificationItemComponent {
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
readonly service = inject(NotificationService)
|
||||
|
||||
@Input({ required: true }) notificationItem!: ServerNotification<number>
|
||||
|
||||
overflow = false
|
||||
|
||||
@tuiPure
|
||||
get manifest$(): Observable<Manifest> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<ng-container *tuiLet="notifications$ | async as notifications">
|
||||
<h3 class="g-title">
|
||||
Notifications
|
||||
<ng-container *ngIf="table.selected$ | async as selected">
|
||||
<tui-hosted-dropdown
|
||||
tuiDropdownAlign="right"
|
||||
[content]="dropdown"
|
||||
[sided]="true"
|
||||
[(open)]="open"
|
||||
>
|
||||
<button
|
||||
appearance="primary"
|
||||
iconRight="tuiIconChevronDown"
|
||||
tuiButton
|
||||
size="xs"
|
||||
type="button"
|
||||
[disabled]="!selected.length"
|
||||
>
|
||||
Batch Action
|
||||
</button>
|
||||
</tui-hosted-dropdown>
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list>
|
||||
<button tuiOption (click)="markSeen(notifications!, selected)">
|
||||
Mark seen
|
||||
</button>
|
||||
<button tuiOption (click)="markUnseen(notifications!, selected)">
|
||||
Mark unseen
|
||||
</button>
|
||||
<button tuiOption (click)="remove(notifications!, selected)">
|
||||
Delete
|
||||
</button>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</h3>
|
||||
<table #table class="g-table" [notifications]="notifications"></table>
|
||||
</ng-container>
|
||||
`,
|
||||
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<ServerNotifications | null>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<thead>
|
||||
<tr>
|
||||
<th [style.width.rem]="1.5">
|
||||
<input
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
type="checkbox"
|
||||
[style.display]="'block'"
|
||||
[disabled]="!notifications?.length"
|
||||
[ngModel]="all"
|
||||
(ngModelChange)="onAll($event)"
|
||||
/>
|
||||
</th>
|
||||
<th [style.min-width.rem]="12">Date</th>
|
||||
<th [style.min-width.rem]="12">Title</th>
|
||||
<th [style.min-width.rem]="8">Service</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let notification of notifications; else: loading; empty: blank"
|
||||
[notificationItem]="notification"
|
||||
[style.font-weight]="notification.read ? 'normal' : 'bold'"
|
||||
>
|
||||
<input
|
||||
tuiCheckbox
|
||||
size="s"
|
||||
type="checkbox"
|
||||
[style.display]="'block'"
|
||||
[ngModel]="selected$.value.includes(notification)"
|
||||
(ngModelChange)="handleToggle(notification)"
|
||||
/>
|
||||
</tr>
|
||||
<ng-template #blank>
|
||||
<tr>
|
||||
<td colspan="5">You have no notifications</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template #loading>
|
||||
<tr *ngFor="let row of ['', '']">
|
||||
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</tbody>
|
||||
`,
|
||||
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<ServerNotifications>([])
|
||||
|
||||
ngOnChanges() {
|
||||
this.selected$.next([])
|
||||
}
|
||||
|
||||
onAll(selected: boolean) {
|
||||
this.selected$.next((selected && this.notifications) || [])
|
||||
}
|
||||
|
||||
handleToggle(notification: ServerNotification<number>) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<DataModel>)
|
||||
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<number> {
|
||||
getCount(id: string): Observable<number> {
|
||||
switch (id) {
|
||||
case '/portal/system/updates':
|
||||
return this.updates$
|
||||
return this.updateCount$
|
||||
default:
|
||||
return EMPTY
|
||||
}
|
||||
@@ -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<DataModel>)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly localUnreadCount$ = new Subject<number>()
|
||||
|
||||
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<void> {
|
||||
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<number>): 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<number>): 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<number>) {
|
||||
this.dialogs
|
||||
.open(notification.message, { label: notification.title })
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
viewReport(notification: ServerNotification<number>) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<number>) {
|
||||
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) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ServerMetricsPage {
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
readonly timeService: TimeService,
|
||||
private readonly timeService: TimeService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -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$
|
||||
|
||||
@@ -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<RR.FollowServerLogsRes>
|
||||
@Input({ required: true }) fetchLogs!: (
|
||||
params: ServerLogsReq,
|
||||
) => Promise<LogsRes>
|
||||
params: FetchLogsReq,
|
||||
) => Promise<FetchLogsRes>
|
||||
@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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotificationsToastService extends Observable<boolean> {
|
||||
private readonly stream$ = this.patch
|
||||
.watch$('server-info', 'unread-notification-count')
|
||||
.watch$('server-info', 'unreadNotifications', 'count')
|
||||
.pipe(
|
||||
pairwise(),
|
||||
map(([prev, cur]) => cur > prev),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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<number>[]
|
||||
|
||||
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<string, string>
|
||||
|
||||
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<any>[]
|
||||
export type ServerNotifications = ServerNotification<number>[]
|
||||
|
||||
export interface ServerNotification<T extends number> {
|
||||
id: number
|
||||
@@ -573,6 +588,7 @@ export interface ServerNotification<T extends number> {
|
||||
title: string
|
||||
message: string
|
||||
data: NotificationData<T>
|
||||
read: boolean
|
||||
}
|
||||
|
||||
export enum NotificationLevel {
|
||||
|
||||
@@ -125,13 +125,21 @@ export abstract class ApiService {
|
||||
params: RR.GetNotificationsReq,
|
||||
): Promise<RR.GetNotificationsRes>
|
||||
|
||||
abstract deleteNotification(
|
||||
abstract markSeenNotifications(
|
||||
params: RR.MarkSeenNotificationReq,
|
||||
): Promise<RR.MarkSeenNotificationRes>
|
||||
|
||||
abstract markSeenAllNotifications(
|
||||
params: RR.MarkSeenAllNotificationsReq,
|
||||
): Promise<RR.MarkSeenAllNotificationsRes>
|
||||
|
||||
abstract markUnseenNotifications(
|
||||
params: RR.DeleteNotificationReq,
|
||||
): Promise<RR.DeleteNotificationRes>
|
||||
|
||||
abstract deleteAllNotifications(
|
||||
params: RR.DeleteAllNotificationsReq,
|
||||
): Promise<RR.DeleteAllNotificationsRes>
|
||||
abstract deleteNotifications(
|
||||
params: RR.DeleteNotificationReq,
|
||||
): Promise<RR.DeleteNotificationRes>
|
||||
|
||||
// network
|
||||
|
||||
@@ -308,8 +316,6 @@ export abstract class ApiService {
|
||||
|
||||
abstract getSetupStatus(): Promise<SetupStatus | null>
|
||||
|
||||
abstract followLogs(): Promise<string>
|
||||
|
||||
abstract setInterfaceClearnetAddress(
|
||||
params: RR.SetInterfaceClearnetAddressReq,
|
||||
): Promise<RR.SetInterfaceClearnetAddressRes>
|
||||
|
||||
@@ -117,10 +117,6 @@ export class LiveApiService extends ApiService {
|
||||
return this.openWebsocket(config)
|
||||
}
|
||||
|
||||
async followLogs(): Promise<string> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
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<RR.DeleteNotificationRes> {
|
||||
return this.rpcRequest({ method: 'notification.delete', params })
|
||||
}
|
||||
|
||||
async deleteAllNotifications(
|
||||
params: RR.DeleteAllNotificationsReq,
|
||||
): Promise<RR.DeleteAllNotificationsRes> {
|
||||
async markSeenNotifications(
|
||||
params: RR.MarkSeenNotificationReq,
|
||||
): Promise<RR.MarkSeenNotificationRes> {
|
||||
return this.rpcRequest({ method: 'notification.mark-seen', params })
|
||||
}
|
||||
|
||||
async markSeenAllNotifications(
|
||||
params: RR.MarkSeenAllNotificationsReq,
|
||||
): Promise<RR.MarkSeenAllNotificationsRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'notification.delete-before',
|
||||
method: 'notification.mark-seen-before',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async markUnseenNotifications(
|
||||
params: RR.MarkUnseenNotificationReq,
|
||||
): Promise<RR.MarkUnseenNotificationRes> {
|
||||
return this.rpcRequest({ method: 'notification.mark-unseen', params })
|
||||
}
|
||||
|
||||
// network
|
||||
|
||||
async addProxy(params: RR.AddProxyReq): Promise<RR.AddProxyRes> {
|
||||
|
||||
@@ -473,28 +473,34 @@ export class MockApiService extends ApiService {
|
||||
params: RR.GetNotificationsReq,
|
||||
): Promise<RR.GetNotificationsRes> {
|
||||
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<RR.DeleteNotificationRes> {
|
||||
await pauseFor(2000)
|
||||
return null
|
||||
}
|
||||
|
||||
async deleteAllNotifications(
|
||||
params: RR.DeleteAllNotificationsReq,
|
||||
): Promise<RR.DeleteAllNotificationsRes> {
|
||||
async markSeenNotifications(
|
||||
params: RR.MarkSeenNotificationReq,
|
||||
): Promise<RR.MarkSeenNotificationRes> {
|
||||
await pauseFor(2000)
|
||||
return null
|
||||
}
|
||||
|
||||
async markSeenAllNotifications(
|
||||
params: RR.MarkSeenAllNotificationsReq,
|
||||
): Promise<RR.MarkSeenAllNotificationsRes> {
|
||||
await pauseFor(2000)
|
||||
return null
|
||||
}
|
||||
|
||||
async markUnseenNotifications(
|
||||
params: RR.MarkUnseenNotificationReq,
|
||||
): Promise<RR.MarkUnseenNotificationRes> {
|
||||
await pauseFor(2000)
|
||||
return null
|
||||
}
|
||||
@@ -1244,11 +1250,6 @@ export class MockApiService extends ApiService {
|
||||
return getSetupStatusMock()
|
||||
}
|
||||
|
||||
async followLogs(): Promise<string> {
|
||||
await pauseFor(1000)
|
||||
return 'fake-guid'
|
||||
}
|
||||
|
||||
async setInterfaceClearnetAddress(
|
||||
params: RR.SetInterfaceClearnetAddressReq,
|
||||
): Promise<RR.SetInterfaceClearnetAddressRes> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user