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:
Alex Inkin
2023-12-08 20:12:03 +04:00
committed by GitHub
parent 8bc93d23b2
commit 7324a4973f
52 changed files with 1181 additions and 255 deletions

62
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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%);
}

View File

@@ -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>

View File

@@ -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],

View File

@@ -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')

View 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 {}

View File

@@ -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 {

View File

@@ -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,
})

View File

@@ -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) {

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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')
}
}
}

View File

@@ -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',
},
}

View 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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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],

View File

@@ -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: `

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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])
}
}
}

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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) {

View File

@@ -25,7 +25,7 @@ export class ServerMetricsPage {
constructor(
private readonly api: ApiService,
readonly timeService: TimeService,
private readonly timeService: TimeService,
private readonly connectionService: ConnectionService,
) {}

View File

@@ -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$

View File

@@ -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

View File

@@ -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),

View File

@@ -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,
},
]

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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

View File

@@ -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);
}