mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
feat: refactor logs (#2555)
* feat: refactor logs * chore: comments * feat: add system logs * feat: update shared logs
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -25,9 +25,7 @@
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<div class="logs-container">
|
||||
<logs-window></logs-window>
|
||||
</div>
|
||||
<logs-window class="logs" />
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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: `
|
||||
<tui-scrollbar childList subtree (waMutationObserver)="scrollTo(bottom)">
|
||||
@for (log of logs$ | async; track log) {
|
||||
<pre [innerHTML]="log | dompurify"></pre>
|
||||
}
|
||||
<section
|
||||
#bottom
|
||||
waIntersectionObserver
|
||||
[style.padding.rem]="1"
|
||||
(waIntersectionObservee)="onBottom($event)"
|
||||
></section>
|
||||
</tui-scrollbar>
|
||||
`,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<ion-content
|
||||
[scrollEvents]="true"
|
||||
(ionScroll)="handleScroll($event)"
|
||||
(ionScrollEnd)="handleScrollEnd()"
|
||||
class="ion-padding"
|
||||
color="light"
|
||||
>
|
||||
<div id="container"></div>
|
||||
</ion-content>
|
||||
|
||||
<div id="template"></div>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 `<span style="color: #FFF; font-weight: bold;">${toLocalIsoString(
|
||||
new Date(log.timestamp),
|
||||
)}</span> ${convert.toHtml(log.message)}<br />`
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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<FollowLogsRes>
|
||||
@@ -19,11 +20,14 @@ export function provideSetupLogsService(
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupLogsService extends Observable<Log> {
|
||||
export class SetupLogsService extends Observable<readonly string[]> {
|
||||
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) {
|
||||
|
||||
20
web/projects/shared/src/util/convert-ansi.ts
Normal file
20
web/projects/shared/src/util/convert-ansi.ts
Normal file
@@ -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 }) =>
|
||||
`<b style="color: #FFF">${toLocalIsoString(
|
||||
new Date(timestamp),
|
||||
)}</b> ${CONVERT.toHtml(message)}`,
|
||||
)
|
||||
.join('<br />')
|
||||
}
|
||||
@@ -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%);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FetchLogsRes>
|
||||
|
||||
@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',
|
||||
}
|
||||
@@ -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('')
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<tui-scrollbar class="scrollbar">
|
||||
<section
|
||||
class="top"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="onLoading($event[0].isIntersecting)"
|
||||
(logsFetch)="onPrevious($event)"
|
||||
>
|
||||
@if (loading) {
|
||||
<tui-loader textContent="Loading older logs" />
|
||||
}
|
||||
</section>
|
||||
|
||||
<section #el childList (waMutationObserver)="restoreScroll(el)">
|
||||
@for (log of previous; track log) {
|
||||
<pre [innerHTML]="log | dompurify"></pre>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (followLogs | logs | async; as logs) {
|
||||
<section childList (waMutationObserver)="scrollToBottom()">
|
||||
@for (log of logs; track log) {
|
||||
<pre [innerHTML]="log | dompurify"></pre>
|
||||
}
|
||||
|
||||
@if ((status$ | async) !== 'connected') {
|
||||
<p class="loading-dots" [attr.data-status]="status$.value">
|
||||
{{
|
||||
status$.value === 'reconnecting'
|
||||
? 'Reconnecting'
|
||||
: 'Waiting for network connectivity'
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<tui-loader textContent="Loading logs" [style.margin-top.rem]="5" />
|
||||
}
|
||||
|
||||
<section
|
||||
#bottom
|
||||
class="bottom"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="
|
||||
setScroll($event[$event.length - 1].isIntersecting)
|
||||
"
|
||||
></section>
|
||||
</tui-scrollbar>
|
||||
|
||||
<footer class="footer">
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
iconLeft="tuiIconArrowDownCircle"
|
||||
(click)="setScroll(true); scrollToBottom()"
|
||||
>
|
||||
Scroll to bottom
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
iconLeft="tuiIconDownload"
|
||||
[logsDownload]="fetchLogs"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</footer>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement>
|
||||
|
||||
@ViewChild(TuiScrollbarComponent, { read: ElementRef })
|
||||
private readonly scrollbar?: ElementRef<HTMLElement>
|
||||
|
||||
@Input({ required: true }) followLogs!: (
|
||||
params: RR.FollowServerLogsReq,
|
||||
) => Promise<RR.FollowServerLogsRes>
|
||||
|
||||
@Input({ required: true }) fetchLogs!: (
|
||||
params: FetchLogsReq,
|
||||
) => Promise<FetchLogsRes>
|
||||
|
||||
@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',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<RR.FollowServerLogsRes>,
|
||||
): Observable<readonly string[]> {
|
||||
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 `<p style="color: ${
|
||||
success ? 'var(--tui-success-fill)' : 'var(--tui-error-fill)'
|
||||
}; text-align: center;">${
|
||||
success ? 'Reconnected' : 'Disconnected'
|
||||
} at ${toLocalIsoString(new Date())}</p>`
|
||||
}
|
||||
@@ -4,6 +4,10 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
icon: 'tuiIconSave',
|
||||
title: 'Backups',
|
||||
},
|
||||
'/portal/system/logs': {
|
||||
icon: 'tuiIconFileText',
|
||||
title: 'Logs',
|
||||
},
|
||||
'/portal/system/marketplace': {
|
||||
icon: 'tuiIconShoppingCart',
|
||||
title: 'Marketplace',
|
||||
|
||||
@@ -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: '<logs [fetchLogs]="fetch" [followLogs]="follow" [context]="id" />',
|
||||
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)
|
||||
|
||||
@@ -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: `
|
||||
<a
|
||||
*ngIf="service$ | async as service"
|
||||
routerLinkActive="_current"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[routerLink]="getLink(service.manifest.id)"
|
||||
>
|
||||
<tui-icon icon="tuiIconChevronLeft" />
|
||||
{{ service.manifest.title }}
|
||||
</a>
|
||||
<ng-container *ngIf="service$ | async" />
|
||||
<router-outlet />
|
||||
`,
|
||||
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<DataModel>)
|
||||
@@ -58,8 +32,4 @@ export class ServiceOutletComponent {
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
getLink(id: string): string {
|
||||
return toRouterLink(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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: `
|
||||
<div class="g-plaque"></div>
|
||||
<tui-select
|
||||
tuiTextfieldAppearance="unstyled"
|
||||
tuiTextfieldSize="m"
|
||||
[(ngModel)]="logs"
|
||||
>
|
||||
{{ subtitle }}
|
||||
<select tuiSelect [items]="items"></select>
|
||||
</tui-select>
|
||||
@switch (logs) {
|
||||
@case ('OS Logs') {
|
||||
<logs
|
||||
context="OS Logs"
|
||||
[followLogs]="followOS"
|
||||
[fetchLogs]="fetchOS"
|
||||
></logs>
|
||||
}
|
||||
@case ('Kernel Logs') {
|
||||
<logs
|
||||
context="Kernel Logs"
|
||||
[followLogs]="followKernel"
|
||||
[fetchLogs]="fetchKernel"
|
||||
></logs>
|
||||
}
|
||||
@case ('Tor Logs') {
|
||||
<logs
|
||||
context="Tor Logs"
|
||||
[followLogs]="followTor"
|
||||
[fetchLogs]="fetchTor"
|
||||
></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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Routes } from '@angular/router'
|
||||
|
||||
import { SettingsComponent } from './settings.component'
|
||||
|
||||
export const SETTINGS_ROUTES: Routes = [
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
component: SettingsComponent,
|
||||
|
||||
@@ -93,7 +93,7 @@ import { SideloadPackageComponent } from './package.component'
|
||||
SideloadPackageComponent,
|
||||
],
|
||||
})
|
||||
export class SideloadComponent {
|
||||
export default class SideloadComponent {
|
||||
readonly refresh$ = new Subject<void>()
|
||||
readonly isTor = inject(ConfigService).isTor()
|
||||
|
||||
|
||||
@@ -5,4 +5,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SnekComponent {}
|
||||
export default class SnekComponent {}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -34,7 +34,7 @@ import { SkeletonListComponent } from '../../../components/skeleton-list.compone
|
||||
</p>
|
||||
<updates-item
|
||||
*ngFor="
|
||||
let pkg of data.mp[host.url]?.packages | filterUpdates : data.local;
|
||||
let pkg of data.mp[host.url]?.packages | filterUpdates: data.local;
|
||||
else: loading;
|
||||
empty: blank
|
||||
"
|
||||
@@ -61,7 +61,7 @@ import { SkeletonListComponent } from '../../../components/skeleton-list.compone
|
||||
SkeletonListComponent,
|
||||
],
|
||||
})
|
||||
export class UpdatesComponent {
|
||||
export default class UpdatesComponent {
|
||||
private readonly marketplace = inject(
|
||||
AbstractMarketplaceService,
|
||||
) as MarketplaceService
|
||||
|
||||
@@ -40,7 +40,7 @@ var convert = new Convert({
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.component.html',
|
||||
styleUrls: ['./logs.component.scss'],
|
||||
providers: [TuiDestroyService, DownloadHTMLService],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class LogsComponent {
|
||||
@ViewChild(IonContent)
|
||||
|
||||
@@ -380,6 +380,55 @@ ul {
|
||||
background: #373a3f;
|
||||
}
|
||||
|
||||
.g-edged {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: calc(100% - 1rem);
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
isolation: isolate;
|
||||
padding: 2rem 3rem;
|
||||
margin: 0 2.75rem 1rem;
|
||||
backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75);
|
||||
clip-path: var(--clip-path);
|
||||
|
||||
--clip-path: polygon(
|
||||
1.75rem 0%,
|
||||
calc(100% - 3.35rem) 0%,
|
||||
100% 5rem,
|
||||
100% calc(100% - 4rem),
|
||||
calc(100% - 3rem) 100%,
|
||||
3rem 100%,
|
||||
0% calc(100% - 2rem),
|
||||
0% 3rem
|
||||
);
|
||||
|
||||
.g-plaque {
|
||||
@include transition(opacity);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
filter: url(#round-corners);
|
||||
opacity: 0.75;
|
||||
// TODO: Theme
|
||||
box-shadow: inset 0 0 2rem white;
|
||||
|
||||
&::before {
|
||||
@include transition(clip-path);
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
clip-path: var(--clip-path);
|
||||
// TODO: Theme
|
||||
background: #333;
|
||||
box-shadow:
|
||||
inset 0 1px rgba(255, 255, 255, 0.2),
|
||||
inset -1px 0 rgba(255, 255, 255, 0.1),
|
||||
inset 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.g-table {
|
||||
width: 100%;
|
||||
min-width: 40rem;
|
||||
@@ -491,3 +540,7 @@ svg:not(:root) {
|
||||
.externalLink {
|
||||
color: var(--ion-color-secondary);
|
||||
}
|
||||
|
||||
.ion-page {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user