From 9a0ae549f65915dfd4dd74e3873a10b6df9fd2f3 Mon Sep 17 00:00:00 2001 From: Alex Inkin Date: Tue, 6 Feb 2024 06:26:00 +0400 Subject: [PATCH] feat: refactor logs (#2555) * feat: refactor logs * chore: comments * feat: add system logs * feat: update shared logs --- .../src/app/pages/success/success.page.ts | 1 - .../initializing/initializing.component.html | 4 +- .../initializing/initializing.component.scss | 14 ++- .../initializing/initializing.module.ts | 6 +- .../initializing/logs-window.component.ts | 53 +++++++++ .../logs-window/logs-window.component.html | 11 -- .../logs-window/logs-window.component.scss | 10 -- .../logs-window/logs-window.component.ts | 67 ----------- web/projects/shared/src/public-api.ts | 3 +- .../src/services/download-html.service.ts | 4 +- .../shared/src/services/setup-logs.service.ts | 8 +- web/projects/shared/src/util/convert-ansi.ts | 20 ++++ web/projects/shared/styles/taiga.scss | 1 + .../components/header/header.component.ts | 15 ++- .../logs/logs-download.directive.ts | 52 +++++++++ .../components/logs/logs-fetch.directive.ts | 36 ++++++ .../components/logs/logs.component.html | 66 +++++++++++ .../components/logs/logs.component.scss | 43 ++++++++ .../portal/components/logs/logs.component.ts | 104 ++++++++++++++++++ .../apps/portal/components/logs/logs.pipe.ts | 87 +++++++++++++++ .../apps/portal/constants/system-utilities.ts | 4 + .../routes/service/routes/logs.component.ts | 19 +--- .../routes/service/routes/outlet.component.ts | 36 +----- .../system/backups/backups.component.ts | 2 +- .../routes/system/logs/logs.component.ts | 95 ++++++++++++++++ .../notifications/notifications.component.ts | 2 +- .../routes/system/settings/settings.routes.ts | 4 +- .../system/sideload/sideload.component.ts | 2 +- .../routes/system/snek/snek.component.ts | 2 +- .../portal/routes/system/system.module.ts | 26 ++--- .../system/updates/updates.component.ts | 4 +- .../ui/src/app/common/logs/logs.component.ts | 2 +- web/projects/ui/src/styles.scss | 53 +++++++++ 33 files changed, 676 insertions(+), 180 deletions(-) create mode 100644 web/projects/shared/src/components/initializing/logs-window.component.ts delete mode 100644 web/projects/shared/src/components/initializing/logs-window/logs-window.component.html delete mode 100644 web/projects/shared/src/components/initializing/logs-window/logs-window.component.scss delete mode 100644 web/projects/shared/src/components/initializing/logs-window/logs-window.component.ts create mode 100644 web/projects/shared/src/util/convert-ansi.ts create mode 100644 web/projects/ui/src/app/apps/portal/components/logs/logs-download.directive.ts create mode 100644 web/projects/ui/src/app/apps/portal/components/logs/logs-fetch.directive.ts create mode 100644 web/projects/ui/src/app/apps/portal/components/logs/logs.component.html create mode 100644 web/projects/ui/src/app/apps/portal/components/logs/logs.component.scss create mode 100644 web/projects/ui/src/app/apps/portal/components/logs/logs.component.ts create mode 100644 web/projects/ui/src/app/apps/portal/components/logs/logs.pipe.ts create mode 100644 web/projects/ui/src/app/apps/portal/routes/system/logs/logs.component.ts diff --git a/web/projects/setup-wizard/src/app/pages/success/success.page.ts b/web/projects/setup-wizard/src/app/pages/success/success.page.ts index 7dc4b9add..9847c0e67 100644 --- a/web/projects/setup-wizard/src/app/pages/success/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success/success.page.ts @@ -8,7 +8,6 @@ import { StateService } from 'src/app/services/state.service' selector: 'success', templateUrl: 'success.page.html', styleUrls: ['success.page.scss'], - providers: [DownloadHTMLService], }) export class SuccessPage { @ViewChild('canvas', { static: true }) diff --git a/web/projects/shared/src/components/initializing/initializing.component.html b/web/projects/shared/src/components/initializing/initializing.component.html index b58ee2f74..0722943bf 100644 --- a/web/projects/shared/src/components/initializing/initializing.component.html +++ b/web/projects/shared/src/components/initializing/initializing.component.html @@ -25,9 +25,7 @@ -
- -
+ diff --git a/web/projects/shared/src/components/initializing/initializing.component.scss b/web/projects/shared/src/components/initializing/initializing.component.scss index f21705ce5..e394fa18e 100644 --- a/web/projects/shared/src/components/initializing/initializing.component.scss +++ b/web/projects/shared/src/components/initializing/initializing.component.scss @@ -8,11 +8,15 @@ ion-card-title { margin: auto auto 40px; } -.logs-container { - margin-top: 24px; - height: 280px; +.logs { + display: flex; + flex-direction: column; + height: 18rem; + padding: 1rem; + margin: 1.5rem 0.75rem; text-align: left; overflow: hidden; - border-radius: 31px; - margin-inline: 10px; + border-radius: 2rem; + // TODO: Theme + background: #181818; } diff --git a/web/projects/shared/src/components/initializing/initializing.module.ts b/web/projects/shared/src/components/initializing/initializing.module.ts index daa025aa3..c72a7e978 100644 --- a/web/projects/shared/src/components/initializing/initializing.module.ts +++ b/web/projects/shared/src/components/initializing/initializing.module.ts @@ -3,12 +3,12 @@ import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { TuiLetModule } from '@taiga-ui/cdk' -import { LogsWindowComponent } from './logs-window/logs-window.component' +import { LogsWindowComponent } from './logs-window.component' import { InitializingComponent } from './initializing.component' @NgModule({ - imports: [CommonModule, IonicModule, TuiLetModule], - declarations: [InitializingComponent, LogsWindowComponent], + imports: [CommonModule, IonicModule, TuiLetModule, LogsWindowComponent], + declarations: [InitializingComponent], exports: [InitializingComponent], }) export class InitializingModule {} diff --git a/web/projects/shared/src/components/initializing/logs-window.component.ts b/web/projects/shared/src/components/initializing/logs-window.component.ts new file mode 100644 index 000000000..131fd1709 --- /dev/null +++ b/web/projects/shared/src/components/initializing/logs-window.component.ts @@ -0,0 +1,53 @@ +import { AsyncPipe } from '@angular/common' +import { Component, ElementRef, inject } from '@angular/core' +import { + IntersectionObserverModule, + INTERSECTION_ROOT, +} from '@ng-web-apis/intersection-observer' +import { MutationObserverModule } from '@ng-web-apis/mutation-observer' +import { TuiScrollbarModule } from '@taiga-ui/core' +import { NgDompurifyModule } from '@tinkoff/ng-dompurify' +import { SetupLogsService } from '../../services/setup-logs.service' + +@Component({ + standalone: true, + selector: 'logs-window', + template: ` + + @for (log of logs$ | async; track log) { +

+      }
+      
+
+ `, + imports: [ + AsyncPipe, + MutationObserverModule, + IntersectionObserverModule, + NgDompurifyModule, + TuiScrollbarModule, + ], + providers: [ + { + provide: INTERSECTION_ROOT, + useExisting: ElementRef, + }, + ], +}) +export class LogsWindowComponent { + readonly logs$ = inject(SetupLogsService) + scroll = true + + scrollTo(bottom: HTMLElement) { + if (this.scroll) bottom.scrollIntoView({ behavior: 'smooth' }) + } + + onBottom([{ isIntersecting }]: readonly IntersectionObserverEntry[]) { + this.scroll = isIntersecting + } +} diff --git a/web/projects/shared/src/components/initializing/logs-window/logs-window.component.html b/web/projects/shared/src/components/initializing/logs-window/logs-window.component.html deleted file mode 100644 index 4c6866ff1..000000000 --- a/web/projects/shared/src/components/initializing/logs-window/logs-window.component.html +++ /dev/null @@ -1,11 +0,0 @@ - -
-
- -
diff --git a/web/projects/shared/src/components/initializing/logs-window/logs-window.component.scss b/web/projects/shared/src/components/initializing/logs-window/logs-window.component.scss deleted file mode 100644 index 032ba006f..000000000 --- a/web/projects/shared/src/components/initializing/logs-window/logs-window.component.scss +++ /dev/null @@ -1,10 +0,0 @@ -// Hide scrollbar for Chrome, Safari and Opera -ion-content::part(scroll)::-webkit-scrollbar { - display: none; -} - -// Hide scrollbar for IE, Edge and Firefox -ion-content::part(scroll) { - -ms-overflow-style: none; // IE and Edge - scrollbar-width: none; // Firefox -} \ No newline at end of file diff --git a/web/projects/shared/src/components/initializing/logs-window/logs-window.component.ts b/web/projects/shared/src/components/initializing/logs-window/logs-window.component.ts deleted file mode 100644 index 449b89dcc..000000000 --- a/web/projects/shared/src/components/initializing/logs-window/logs-window.component.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Component, ViewChild } from '@angular/core' -import { IonContent } from '@ionic/angular' -import { map, takeUntil } from 'rxjs' -import { TuiDestroyService } from '@taiga-ui/cdk' -import { SetupLogsService } from '../../../services/setup-logs.service' -import { Log } from '../../../types/api' -import { toLocalIsoString } from '../../../util/to-local-iso-string' -import Convert from 'ansi-to-html' -const convert = new Convert({ - bg: 'transparent', -}) - -@Component({ - selector: 'logs-window', - templateUrl: 'logs-window.component.html', - styleUrls: ['logs-window.component.scss'], - providers: [TuiDestroyService], -}) -export class LogsWindowComponent { - @ViewChild(IonContent) - private content?: IonContent - - autoScroll = true - - constructor( - private readonly logs: SetupLogsService, - private readonly destroy$: TuiDestroyService, - ) {} - - ngOnInit() { - this.logs - .pipe( - map(log => this.convertToAnsi(log)), - takeUntil(this.destroy$), - ) - .subscribe(innerHTML => { - const container = document.getElementById('container') - const newLogs = document.getElementById('template')?.cloneNode() - - if (!(newLogs instanceof HTMLElement)) return - - newLogs.innerHTML = innerHTML - container?.append(newLogs) - - if (this.autoScroll) { - setTimeout(() => this.content?.scrollToBottom(250)) - } - }) - } - - handleScroll(e: any) { - if (e.detail.deltaY < 0) this.autoScroll = false - } - - async handleScrollEnd() { - const elem = await this.content?.getScrollElement() - if (elem && elem.scrollHeight - elem.scrollTop - elem.clientHeight < 64) { - this.autoScroll = true - } - } - - private convertToAnsi(log: Log) { - return `${toLocalIsoString( - new Date(log.timestamp), - )}  ${convert.toHtml(log.message)}
` - } -} diff --git a/web/projects/shared/src/public-api.ts b/web/projects/shared/src/public-api.ts index 31846b93e..f926e0da9 100644 --- a/web/projects/shared/src/public-api.ts +++ b/web/projects/shared/src/public-api.ts @@ -5,7 +5,7 @@ export * from './classes/http-error' export * from './classes/rpc-error' -export * from './components/initializing/logs-window/logs-window.component' +export * from './components/initializing/logs-window.component' export * from './components/initializing/initializing.module' export * from './components/initializing/initializing.component' export * from './components/loading/loading.component' @@ -63,6 +63,7 @@ export * from './tokens/relative-url' export * from './tokens/theme' export * from './util/base-64' +export * from './util/convert-ansi' export * from './util/copy-to-clipboard' export * from './util/get-new-entries' export * from './util/get-pkg-id' diff --git a/web/projects/shared/src/services/download-html.service.ts b/web/projects/shared/src/services/download-html.service.ts index 81f7b945b..13a146186 100644 --- a/web/projects/shared/src/services/download-html.service.ts +++ b/web/projects/shared/src/services/download-html.service.ts @@ -1,7 +1,9 @@ import { DOCUMENT } from '@angular/common' import { Inject, Injectable } from '@angular/core' -@Injectable() +@Injectable({ + providedIn: 'root', +}) export class DownloadHTMLService { constructor(@Inject(DOCUMENT) private readonly document: Document) {} diff --git a/web/projects/shared/src/services/setup-logs.service.ts b/web/projects/shared/src/services/setup-logs.service.ts index cf1ce7738..3d27811ce 100644 --- a/web/projects/shared/src/services/setup-logs.service.ts +++ b/web/projects/shared/src/services/setup-logs.service.ts @@ -1,8 +1,9 @@ import { StaticClassProvider } from '@angular/core' -import { defer, Observable, switchMap } from 'rxjs' +import { bufferTime, defer, map, Observable, scan, switchMap } from 'rxjs' import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { FollowLogsReq, FollowLogsRes, Log } from '../types/api' import { Constructor } from '../types/constructor' +import { convertAnsi } from '../util/convert-ansi' interface Api { followServerLogs: (params: FollowLogsReq) => Promise @@ -19,11 +20,14 @@ export function provideSetupLogsService( } } -export class SetupLogsService extends Observable { +export class SetupLogsService extends Observable { private readonly log$ = defer(() => this.api.followServerLogs({})).pipe( switchMap(({ guid }) => this.api.openLogsWebsocket$({ url: `/rpc/${guid}` }), ), + bufferTime(1000), + map(convertAnsi), + scan((logs: readonly string[], log) => [...logs, log], []), ) constructor(private readonly api: Api) { diff --git a/web/projects/shared/src/util/convert-ansi.ts b/web/projects/shared/src/util/convert-ansi.ts new file mode 100644 index 000000000..67d934456 --- /dev/null +++ b/web/projects/shared/src/util/convert-ansi.ts @@ -0,0 +1,20 @@ +import { Log } from '../types/api' +import { toLocalIsoString } from './to-local-iso-string' + +const Convert = require('ansi-to-html') +const CONVERT = new Convert({ + bg: 'transparent', + colors: { 4: 'Cyan' }, + escapeXML: true, +}) + +export function convertAnsi(entries: readonly Log[]): string { + return entries + .map( + ({ timestamp, message }) => + `${toLocalIsoString( + new Date(timestamp), + )}  ${CONVERT.toHtml(message)}`, + ) + .join('
') +} diff --git a/web/projects/shared/styles/taiga.scss b/web/projects/shared/styles/taiga.scss index 064ecc855..f2566adfa 100644 --- a/web/projects/shared/styles/taiga.scss +++ b/web/projects/shared/styles/taiga.scss @@ -168,6 +168,7 @@ tui-hint[data-appearance='onDark'] { tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] { border: 0; + backdrop-filter: blur(0.25rem); box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%); // TODO: Replace --tui-elevation-02 when Taiga UI is updated background: rgb(63 63 63 / 95%); diff --git a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts index 6823d6f37..8d9599943 100644 --- a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts @@ -43,11 +43,15 @@ import { BreadcrumbsService } from '../../services/breadcrumbs.service' ); > * { - @include transition(clip-path); + @include transition(all); position: relative; margin-left: -1.25rem; backdrop-filter: blur(1rem); clip-path: var(--clip-path); + + &:active { + backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75); + } } } @@ -60,11 +64,16 @@ import { BreadcrumbsService } from '../../services/breadcrumbs.service' opacity: 0.5; .active & { - opacity: 0.25; + opacity: 0.75; + + &::before { + // TODO: Theme + background: #363636; + } } &::before { - @include transition(clip-path); + @include transition(all); content: ''; position: absolute; inset: 0; diff --git a/web/projects/ui/src/app/apps/portal/components/logs/logs-download.directive.ts b/web/projects/ui/src/app/apps/portal/components/logs/logs-download.directive.ts new file mode 100644 index 000000000..b983579f1 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/logs/logs-download.directive.ts @@ -0,0 +1,52 @@ +import { Directive, HostListener, inject, Input } from '@angular/core' +import { + convertAnsi, + DownloadHTMLService, + ErrorService, + FetchLogsReq, + FetchLogsRes, + LoadingService, +} from '@start9labs/shared' +import { LogsComponent } from './logs.component' + +@Directive({ + standalone: true, + selector: 'button[logsDownload]', +}) +export class LogsDownloadDirective { + private readonly component = inject(LogsComponent) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly downloadHtml = inject(DownloadHTMLService) + + @Input({ required: true }) + logsDownload!: (params: FetchLogsReq) => Promise + + @HostListener('click') + async download() { + const loader = this.loader.open('Processing 10,000 logs...').subscribe() + + try { + const { entries } = await this.logsDownload({ + before: true, + limit: 10000, + }) + + this.downloadHtml.download( + `${this.component.context}-logs.html`, + convertAnsi(entries), + STYLES, + ) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } +} + +const STYLES = { + 'background-color': '#222428', + color: '#e0e0e0', + 'font-family': 'monospace', +} diff --git a/web/projects/ui/src/app/apps/portal/components/logs/logs-fetch.directive.ts b/web/projects/ui/src/app/apps/portal/components/logs/logs-fetch.directive.ts new file mode 100644 index 000000000..17128ff47 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/logs/logs-fetch.directive.ts @@ -0,0 +1,36 @@ +import { Directive, inject, Output } from '@angular/core' +import { IntersectionObserveeService } from '@ng-web-apis/intersection-observer' +import { convertAnsi, ErrorService } from '@start9labs/shared' +import { catchError, defer, filter, from, map, of, switchMap, tap } from 'rxjs' +import { LogsComponent } from './logs.component' + +@Directive({ + standalone: true, + selector: '[logsFetch]', +}) +export class LogsFetchDirective { + private readonly observer = inject(IntersectionObserveeService) + private readonly component = inject(LogsComponent) + private readonly errors = inject(ErrorService) + + @Output() + readonly logsFetch = defer(() => this.observer).pipe( + filter(([{ isIntersecting }]) => isIntersecting && !this.component.scroll), + switchMap(() => + from( + this.component.fetchLogs({ + cursor: this.component.startCursor, + before: true, + limit: 400, + }), + ), + ), + tap(res => this.component.setCursor(res['start-cursor'])), + map(({ entries }) => convertAnsi(entries)), + catchError(e => { + this.errors.handleError(e) + + return of('') + }), + ) +} diff --git a/web/projects/ui/src/app/apps/portal/components/logs/logs.component.html b/web/projects/ui/src/app/apps/portal/components/logs/logs.component.html new file mode 100644 index 000000000..0f1424d0b --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/logs/logs.component.html @@ -0,0 +1,66 @@ + +
+ @if (loading) { + + } +
+ +
+ @for (log of previous; track log) { +

+    }
+  
+ + @if (followLogs | logs | async; as logs) { +
+ @for (log of logs; track log) { +

+      }
+
+      @if ((status$ | async) !== 'connected') {
+        

+ {{ + status$.value === 'reconnecting' + ? 'Reconnecting' + : 'Waiting for network connectivity' + }} +

+ } +
+ } @else { + + } + +
+
+ +
+ + +
diff --git a/web/projects/ui/src/app/apps/portal/components/logs/logs.component.scss b/web/projects/ui/src/app/apps/portal/components/logs/logs.component.scss new file mode 100644 index 000000000..cdca063f1 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/logs/logs.component.scss @@ -0,0 +1,43 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.scrollbar { + flex: 1; +} + +.loading-dots { + text-align: center; +} + +.top { + height: 10rem; + margin-bottom: -5rem; +} + +.bottom { + height: 3rem; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 1rem; + border-top: 1px solid var(--tui-clear); +} + +[data-status='reconnecting'] { + color: var(--tui-success-fill); +} + +[data-status='disconnected'] { + color: var(--tui-warning-fill); +} + +pre { + overflow: visible; +} diff --git a/web/projects/ui/src/app/apps/portal/components/logs/logs.component.ts b/web/projects/ui/src/app/apps/portal/components/logs/logs.component.ts new file mode 100644 index 000000000..ce36eed3a --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/logs/logs.component.ts @@ -0,0 +1,104 @@ +import { CommonModule } from '@angular/common' +import { Component, ElementRef, Input, ViewChild } from '@angular/core' +import { + INTERSECTION_ROOT, + IntersectionObserverModule, +} from '@ng-web-apis/intersection-observer' +import { MutationObserverModule } from '@ng-web-apis/mutation-observer' +import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared' +import { + TuiLoaderModule, + TuiScrollbarComponent, + TuiScrollbarModule, +} from '@taiga-ui/core' +import { TuiButtonModule } from '@taiga-ui/experimental' +import { NgDompurifyModule } from '@tinkoff/ng-dompurify' +import { RR } from 'src/app/services/api/api.types' +import { LogsDownloadDirective } from './logs-download.directive' +import { LogsFetchDirective } from './logs-fetch.directive' +import { LogsPipe } from './logs.pipe' +import { BehaviorSubject } from 'rxjs' + +@Component({ + standalone: true, + selector: 'logs', + templateUrl: './logs.component.html', + styleUrls: ['./logs.component.scss'], + imports: [ + CommonModule, + IntersectionObserverModule, + MutationObserverModule, + NgDompurifyModule, + TuiButtonModule, + TuiLoaderModule, + TuiScrollbarModule, + LogsDownloadDirective, + LogsFetchDirective, + LogsPipe, + ], + providers: [ + { + provide: INTERSECTION_ROOT, + useExisting: ElementRef, + }, + ], +}) +export class LogsComponent { + @ViewChild('bottom') + private readonly bottom?: ElementRef + + @ViewChild(TuiScrollbarComponent, { read: ElementRef }) + private readonly scrollbar?: ElementRef + + @Input({ required: true }) followLogs!: ( + params: RR.FollowServerLogsReq, + ) => Promise + + @Input({ required: true }) fetchLogs!: ( + params: FetchLogsReq, + ) => Promise + + @Input({ required: true }) context!: string + + scrollTop = 0 + startCursor?: string + scroll = true + loading = false + previous: readonly string[] = [] + + readonly status$ = new BehaviorSubject< + 'connected' | 'disconnected' | 'reconnecting' + >('connected') + + onLoading(loading: boolean) { + this.loading = loading && !this.scroll + } + + onPrevious(previous: string) { + this.onLoading(false) + this.scrollTop = this.scrollbar?.nativeElement.scrollTop || 0 + this.previous = [previous, ...this.previous] + } + + setCursor(startCursor = this.startCursor) { + this.startCursor = startCursor + } + + setScroll(scroll: boolean) { + this.scroll = scroll + } + + restoreScroll({ firstElementChild }: HTMLElement) { + this.scrollbar?.nativeElement.scrollTo( + this.scrollbar?.nativeElement.scrollLeft || 0, + this.scrollTop + (firstElementChild?.clientHeight || 0), + ) + } + + scrollToBottom() { + if (this.scroll) + this.bottom?.nativeElement.scrollIntoView({ + behavior: 'smooth', + }) + } +} diff --git a/web/projects/ui/src/app/apps/portal/components/logs/logs.pipe.ts b/web/projects/ui/src/app/apps/portal/components/logs/logs.pipe.ts new file mode 100644 index 000000000..4c3df9c4c --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/logs/logs.pipe.ts @@ -0,0 +1,87 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { convertAnsi, toLocalIsoString } from '@start9labs/shared' +import { + bufferTime, + catchError, + defer, + filter, + ignoreElements, + map, + merge, + Observable, + repeat, + scan, + skipWhile, + startWith, + switchMap, + take, + tap, +} from 'rxjs' +import { RR } from 'src/app/services/api/api.types' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ConnectionService } from 'src/app/services/connection.service' +import { LogsComponent } from './logs.component' + +@Pipe({ + name: 'logs', + standalone: true, +}) +export class LogsPipe implements PipeTransform { + private readonly api = inject(ApiService) + private readonly logs = inject(LogsComponent) + private readonly connection = inject(ConnectionService) + + transform( + followLogs: ( + params: RR.FollowServerLogsReq, + ) => Promise, + ): Observable { + return merge( + this.logs.status$.pipe( + skipWhile(value => value === 'connected'), + filter(value => value === 'connected'), + map(() => getMessage(true)), + ), + defer(() => followLogs(this.options)).pipe( + tap(r => this.logs.setCursor(r['start-cursor'])), + switchMap(r => this.api.openLogsWebsocket$(this.toConfig(r.guid))), + bufferTime(1000), + filter(logs => !!logs.length), + map(convertAnsi), + ), + ).pipe( + catchError(() => + this.connection.connected$.pipe( + tap(v => this.logs.status$.next(v ? 'reconnecting' : 'disconnected')), + filter(Boolean), + take(1), + ignoreElements(), + startWith(getMessage(false)), + ), + ), + repeat(), + scan((logs: string[], log) => [...logs, log], []), + ) + } + + private get options() { + return this.logs.status$.value === 'connected' ? { limit: 400 } : {} + } + + private toConfig(guid: string) { + return { + url: `/rpc/${guid}`, + openObserver: { + next: () => this.logs.status$.next('connected'), + }, + } + } +} + +function getMessage(success: boolean): string { + return `

${ + success ? 'Reconnected' : 'Disconnected' + } at ${toLocalIsoString(new Date())}

` +} diff --git a/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts b/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts index 361cc319d..b71ee8e01 100644 --- a/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts +++ b/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts @@ -4,6 +4,10 @@ export const SYSTEM_UTILITIES: Record = icon: 'tuiIconSave', title: 'Backups', }, + '/portal/system/logs': { + icon: 'tuiIconFileText', + title: 'Logs', + }, '/portal/system/marketplace': { icon: 'tuiIconShoppingCart', title: 'Marketplace', diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts index 207317b85..719eab01a 100644 --- a/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts @@ -1,29 +1,16 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { getPkgId } from '@start9labs/shared' -import { LogsComponentModule } from 'src/app/common/logs/logs.component.module' import { ApiService } from 'src/app/services/api/embassy-api.service' import { RR } from 'src/app/services/api/api.types' +import { LogsComponent } from 'src/app/apps/portal/components/logs/logs.component' @Component({ template: '', - styles: [ - ` - logs { - display: block; - height: calc(100% - 9rem); - min-height: 20rem; - margin-bottom: 5rem; - - ::ng-deep ion-header { - display: none; - } - } - `, - ], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [LogsComponentModule], + styles: [':host { height: 100%}'], + imports: [LogsComponent], }) export class ServiceLogsRoute { private readonly api = inject(ApiService) diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts index a0a1f4121..6dcd7a26a 100644 --- a/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts @@ -1,45 +1,19 @@ import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { ActivatedRoute, Router, RouterModule } from '@angular/router' -import { TuiIconModule } from '@taiga-ui/experimental' +import { ActivatedRoute, Router, RouterOutlet } from '@angular/router' import { PatchDB } from 'patch-db-client' import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs' import { DataModel } from 'src/app/services/patch-db/data-model' -import { toRouterLink } from '../../../utils/to-router-link' @Component({ template: ` - - - {{ service.manifest.title }} - + `, - styles: [ - ` - a { - display: inline-flex; - align-items: center; - gap: 0.5rem; - margin: 1rem 0; - font-size: 1rem; - color: var(--tui-text-01); - } - - ._current { - display: none; - } - `, - ], host: { class: 'g-page' }, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [CommonModule, RouterModule, TuiIconModule], + imports: [CommonModule, RouterOutlet], }) export class ServiceOutletComponent { private readonly patch = inject(PatchDB) @@ -58,8 +32,4 @@ export class ServiceOutletComponent { } }), ) - - getLink(id: string): string { - return toRouterLink(id) - } } diff --git a/web/projects/ui/src/app/apps/portal/routes/system/backups/backups.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/backups/backups.component.ts index 59e5af346..01f29e551 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/backups/backups.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/backups/backups.component.ts @@ -40,7 +40,7 @@ import { JOBS } from './modals/jobs.component' BackupsUpcomingComponent, ], }) -export class BackupsComponent { +export default class BackupsComponent { private readonly dialogs = inject(TuiDialogService) readonly options = [ diff --git a/web/projects/ui/src/app/apps/portal/routes/system/logs/logs.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/logs/logs.component.ts new file mode 100644 index 000000000..0ea32aa6f --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/routes/system/logs/logs.component.ts @@ -0,0 +1,95 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { TuiTextfieldControllerModule } from '@taiga-ui/core' +import { TuiSelectModule } from '@taiga-ui/kit' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { RR } from 'src/app/services/api/api.types' +import { LogsComponent } from '../../../components/logs/logs.component' + +@Component({ + template: ` +
+ + {{ subtitle }} + + + @switch (logs) { + @case ('OS Logs') { + + } + @case ('Kernel Logs') { + + } + @case ('Tor Logs') { + + } + } + `, + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'g-edged' }, + styles: [ + ` + tui-select { + margin: -0.5rem 0 1rem; + } + + logs { + height: calc(100% - 4rem); + } + `, + ], + imports: [ + FormsModule, + TuiSelectModule, + TuiTextfieldControllerModule, + LogsComponent, + ], +}) +export default class SystemLogsComponent { + private readonly api = inject(ApiService) + readonly items = ['OS Logs', 'Kernel Logs', 'Tor Logs'] + logs = 'OS Logs' + + readonly followOS = async (params: RR.FollowServerLogsReq) => + this.api.followServerLogs(params) + readonly fetchOS = async (params: RR.GetServerLogsReq) => + this.api.getServerLogs(params) + + readonly followKernel = async (params: RR.FollowServerLogsReq) => + this.api.followKernelLogs(params) + readonly fetchKernel = async (params: RR.GetServerLogsReq) => + this.api.getKernelLogs(params) + + readonly followTor = async (params: RR.FollowServerLogsReq) => + this.api.followTorLogs(params) + readonly fetchTor = async (params: RR.GetServerLogsReq) => + this.api.getTorLogs(params) + + get subtitle(): string { + switch (this.logs) { + case 'OS Logs': + return 'Raw, unfiltered operating system logs' + case 'Kernel Logs': + return 'Diagnostic log stream for device drivers and other kernel processes' + default: + return 'Diagnostic log stream for the Tor daemon on StartOS' + } + } +} diff --git a/web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts index 59a95ce2a..7a54eb8da 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/notifications/notifications.component.ts @@ -63,7 +63,7 @@ import { NotificationsTableComponent } from './table.component' TuiLetModule, ], }) -export class NotificationsComponent { +export default class NotificationsComponent { readonly service = inject(NotificationService) readonly api = inject(ApiService) readonly errorService = inject(ErrorService) diff --git a/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.routes.ts b/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.routes.ts index cdab4fd56..3b5c37a37 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.routes.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/settings/settings.routes.ts @@ -1,8 +1,6 @@ -import { Routes } from '@angular/router' - import { SettingsComponent } from './settings.component' -export const SETTINGS_ROUTES: Routes = [ +export default [ { path: '', component: SettingsComponent, diff --git a/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.component.ts index a6d5c84d4..dfd08b16a 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/sideload/sideload.component.ts @@ -93,7 +93,7 @@ import { SideloadPackageComponent } from './package.component' SideloadPackageComponent, ], }) -export class SideloadComponent { +export default class SideloadComponent { readonly refresh$ = new Subject() readonly isTor = inject(ConfigService).isTor() diff --git a/web/projects/ui/src/app/apps/portal/routes/system/snek/snek.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/snek/snek.component.ts index 57f7c2653..6eb12a532 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/snek/snek.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/snek/snek.component.ts @@ -5,4 +5,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SnekComponent {} +export default class SnekComponent {} diff --git a/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts b/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts index c8000e851..92691e6e2 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/system.module.ts @@ -7,10 +7,15 @@ const ROUTES: Routes = [ { title: systemTabResolver, path: 'backups', - loadComponent: () => - import('./backups/backups.component').then(m => m.BackupsComponent), + loadComponent: () => import('./backups/backups.component'), data: toNavigationItem('/portal/system/backups'), }, + { + title: systemTabResolver, + path: 'logs', + loadComponent: () => import('./logs/logs.component'), + data: toNavigationItem('/portal/system/logs'), + }, { title: systemTabResolver, path: 'marketplace', @@ -20,38 +25,31 @@ const ROUTES: Routes = [ { title: systemTabResolver, path: 'settings', - loadChildren: () => - import('./settings/settings.routes').then(m => m.SETTINGS_ROUTES), + loadChildren: () => import('./settings/settings.routes'), data: toNavigationItem('/portal/system/settings'), }, { title: systemTabResolver, path: 'notifications', - loadComponent: () => - import('./notifications/notifications.component').then( - m => m.NotificationsComponent, - ), + loadComponent: () => import('./notifications/notifications.component'), data: toNavigationItem('/portal/system/notifications'), }, { title: systemTabResolver, path: 'sideload', - loadComponent: () => - import('./sideload/sideload.component').then(m => m.SideloadComponent), + loadComponent: () => import('./sideload/sideload.component'), data: toNavigationItem('/portal/system/sideload'), }, { title: systemTabResolver, path: 'updates', - loadComponent: () => - import('./updates/updates.component').then(m => m.UpdatesComponent), + loadComponent: () => import('./updates/updates.component'), data: toNavigationItem('/portal/system/updates'), }, { title: systemTabResolver, path: 'snek', - loadComponent: () => - import('./snek/snek.component').then(m => m.SnekComponent), + loadComponent: () => import('./snek/snek.component'), data: toNavigationItem('/portal/system/snek'), }, ] diff --git a/web/projects/ui/src/app/apps/portal/routes/system/updates/updates.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/updates/updates.component.ts index 1b4b48434..1f8de4437 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/updates/updates.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/updates/updates.component.ts @@ -34,7 +34,7 @@ import { SkeletonListComponent } from '../../../components/skeleton-list.compone