feat: refactor logs (#2555)

* feat: refactor logs

* chore: comments

* feat: add system logs

* feat: update shared logs
This commit is contained in:
Alex Inkin
2024-02-06 06:26:00 +04:00
committed by GitHub
parent 4410d7f195
commit 9a0ae549f6
33 changed files with 676 additions and 180 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { Routes } from '@angular/router'
import { SettingsComponent } from './settings.component'
export const SETTINGS_ROUTES: Routes = [
export default [
{
path: '',
component: SettingsComponent,

View File

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

View File

@@ -5,4 +5,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SnekComponent {}
export default class SnekComponent {}

View File

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

View File

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

View File

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