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