mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 04:01:58 +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',
|
selector: 'success',
|
||||||
templateUrl: 'success.page.html',
|
templateUrl: 'success.page.html',
|
||||||
styleUrls: ['success.page.scss'],
|
styleUrls: ['success.page.scss'],
|
||||||
providers: [DownloadHTMLService],
|
|
||||||
})
|
})
|
||||||
export class SuccessPage {
|
export class SuccessPage {
|
||||||
@ViewChild('canvas', { static: true })
|
@ViewChild('canvas', { static: true })
|
||||||
|
|||||||
@@ -25,9 +25,7 @@
|
|||||||
</ion-card-content>
|
</ion-card-content>
|
||||||
</ion-card>
|
</ion-card>
|
||||||
|
|
||||||
<div class="logs-container">
|
<logs-window class="logs" />
|
||||||
<logs-window></logs-window>
|
|
||||||
</div>
|
|
||||||
</ion-col>
|
</ion-col>
|
||||||
</ion-row>
|
</ion-row>
|
||||||
</ion-grid>
|
</ion-grid>
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ ion-card-title {
|
|||||||
margin: auto auto 40px;
|
margin: auto auto 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-container {
|
.logs {
|
||||||
margin-top: 24px;
|
display: flex;
|
||||||
height: 280px;
|
flex-direction: column;
|
||||||
|
height: 18rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 31px;
|
border-radius: 2rem;
|
||||||
margin-inline: 10px;
|
// TODO: Theme
|
||||||
|
background: #181818;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { CommonModule } from '@angular/common'
|
|||||||
import { IonicModule } from '@ionic/angular'
|
import { IonicModule } from '@ionic/angular'
|
||||||
import { TuiLetModule } from '@taiga-ui/cdk'
|
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'
|
import { InitializingComponent } from './initializing.component'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, IonicModule, TuiLetModule],
|
imports: [CommonModule, IonicModule, TuiLetModule, LogsWindowComponent],
|
||||||
declarations: [InitializingComponent, LogsWindowComponent],
|
declarations: [InitializingComponent],
|
||||||
exports: [InitializingComponent],
|
exports: [InitializingComponent],
|
||||||
})
|
})
|
||||||
export class InitializingModule {}
|
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/http-error'
|
||||||
export * from './classes/rpc-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.module'
|
||||||
export * from './components/initializing/initializing.component'
|
export * from './components/initializing/initializing.component'
|
||||||
export * from './components/loading/loading.component'
|
export * from './components/loading/loading.component'
|
||||||
@@ -63,6 +63,7 @@ export * from './tokens/relative-url'
|
|||||||
export * from './tokens/theme'
|
export * from './tokens/theme'
|
||||||
|
|
||||||
export * from './util/base-64'
|
export * from './util/base-64'
|
||||||
|
export * from './util/convert-ansi'
|
||||||
export * from './util/copy-to-clipboard'
|
export * from './util/copy-to-clipboard'
|
||||||
export * from './util/get-new-entries'
|
export * from './util/get-new-entries'
|
||||||
export * from './util/get-pkg-id'
|
export * from './util/get-pkg-id'
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { DOCUMENT } from '@angular/common'
|
import { DOCUMENT } from '@angular/common'
|
||||||
import { Inject, Injectable } from '@angular/core'
|
import { Inject, Injectable } from '@angular/core'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
export class DownloadHTMLService {
|
export class DownloadHTMLService {
|
||||||
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
|
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { StaticClassProvider } from '@angular/core'
|
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 { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||||
import { FollowLogsReq, FollowLogsRes, Log } from '../types/api'
|
import { FollowLogsReq, FollowLogsRes, Log } from '../types/api'
|
||||||
import { Constructor } from '../types/constructor'
|
import { Constructor } from '../types/constructor'
|
||||||
|
import { convertAnsi } from '../util/convert-ansi'
|
||||||
|
|
||||||
interface Api {
|
interface Api {
|
||||||
followServerLogs: (params: FollowLogsReq) => Promise<FollowLogsRes>
|
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(
|
private readonly log$ = defer(() => this.api.followServerLogs({})).pipe(
|
||||||
switchMap(({ guid }) =>
|
switchMap(({ guid }) =>
|
||||||
this.api.openLogsWebsocket$({ url: `/rpc/${guid}` }),
|
this.api.openLogsWebsocket$({ url: `/rpc/${guid}` }),
|
||||||
),
|
),
|
||||||
|
bufferTime(1000),
|
||||||
|
map(convertAnsi),
|
||||||
|
scan((logs: readonly string[], log) => [...logs, log], []),
|
||||||
)
|
)
|
||||||
|
|
||||||
constructor(private readonly api: Api) {
|
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'] {
|
tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
backdrop-filter: blur(0.25rem);
|
||||||
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%);
|
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%);
|
||||||
// TODO: Replace --tui-elevation-02 when Taiga UI is updated
|
// TODO: Replace --tui-elevation-02 when Taiga UI is updated
|
||||||
background: rgb(63 63 63 / 95%);
|
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;
|
position: relative;
|
||||||
margin-left: -1.25rem;
|
margin-left: -1.25rem;
|
||||||
backdrop-filter: blur(1rem);
|
backdrop-filter: blur(1rem);
|
||||||
clip-path: var(--clip-path);
|
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;
|
opacity: 0.5;
|
||||||
|
|
||||||
.active & {
|
.active & {
|
||||||
opacity: 0.25;
|
opacity: 0.75;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
// TODO: Theme
|
||||||
|
background: #363636;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@include transition(clip-path);
|
@include transition(all);
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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',
|
icon: 'tuiIconSave',
|
||||||
title: 'Backups',
|
title: 'Backups',
|
||||||
},
|
},
|
||||||
|
'/portal/system/logs': {
|
||||||
|
icon: 'tuiIconFileText',
|
||||||
|
title: 'Logs',
|
||||||
|
},
|
||||||
'/portal/system/marketplace': {
|
'/portal/system/marketplace': {
|
||||||
icon: 'tuiIconShoppingCart',
|
icon: 'tuiIconShoppingCart',
|
||||||
title: 'Marketplace',
|
title: 'Marketplace',
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
import { getPkgId } from '@start9labs/shared'
|
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 { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { RR } from 'src/app/services/api/api.types'
|
import { RR } from 'src/app/services/api/api.types'
|
||||||
|
import { LogsComponent } from 'src/app/apps/portal/components/logs/logs.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: '<logs [fetchLogs]="fetch" [followLogs]="follow" [context]="id" />',
|
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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [LogsComponentModule],
|
styles: [':host { height: 100%}'],
|
||||||
|
imports: [LogsComponent],
|
||||||
})
|
})
|
||||||
export class ServiceLogsRoute {
|
export class ServiceLogsRoute {
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
|
|||||||
@@ -1,45 +1,19 @@
|
|||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'
|
||||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
|
||||||
import { PatchDB } from 'patch-db-client'
|
import { PatchDB } from 'patch-db-client'
|
||||||
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
|
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
import { toRouterLink } from '../../../utils/to-router-link'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<a
|
<ng-container *ngIf="service$ | async" />
|
||||||
*ngIf="service$ | async as service"
|
|
||||||
routerLinkActive="_current"
|
|
||||||
[routerLinkActiveOptions]="{ exact: true }"
|
|
||||||
[routerLink]="getLink(service.manifest.id)"
|
|
||||||
>
|
|
||||||
<tui-icon icon="tuiIconChevronLeft" />
|
|
||||||
{{ service.manifest.title }}
|
|
||||||
</a>
|
|
||||||
<router-outlet />
|
<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' },
|
host: { class: 'g-page' },
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule, TuiIconModule],
|
imports: [CommonModule, RouterOutlet],
|
||||||
})
|
})
|
||||||
export class ServiceOutletComponent {
|
export class ServiceOutletComponent {
|
||||||
private readonly patch = inject(PatchDB<DataModel>)
|
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,
|
BackupsUpcomingComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BackupsComponent {
|
export default class BackupsComponent {
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
|
||||||
readonly options = [
|
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,
|
TuiLetModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class NotificationsComponent {
|
export default class NotificationsComponent {
|
||||||
readonly service = inject(NotificationService)
|
readonly service = inject(NotificationService)
|
||||||
readonly api = inject(ApiService)
|
readonly api = inject(ApiService)
|
||||||
readonly errorService = inject(ErrorService)
|
readonly errorService = inject(ErrorService)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Routes } from '@angular/router'
|
|
||||||
|
|
||||||
import { SettingsComponent } from './settings.component'
|
import { SettingsComponent } from './settings.component'
|
||||||
|
|
||||||
export const SETTINGS_ROUTES: Routes = [
|
export default [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: SettingsComponent,
|
component: SettingsComponent,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ import { SideloadPackageComponent } from './package.component'
|
|||||||
SideloadPackageComponent,
|
SideloadPackageComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SideloadComponent {
|
export default class SideloadComponent {
|
||||||
readonly refresh$ = new Subject<void>()
|
readonly refresh$ = new Subject<void>()
|
||||||
readonly isTor = inject(ConfigService).isTor()
|
readonly isTor = inject(ConfigService).isTor()
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SnekComponent {}
|
export default class SnekComponent {}
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ const ROUTES: Routes = [
|
|||||||
{
|
{
|
||||||
title: systemTabResolver,
|
title: systemTabResolver,
|
||||||
path: 'backups',
|
path: 'backups',
|
||||||
loadComponent: () =>
|
loadComponent: () => import('./backups/backups.component'),
|
||||||
import('./backups/backups.component').then(m => m.BackupsComponent),
|
|
||||||
data: toNavigationItem('/portal/system/backups'),
|
data: toNavigationItem('/portal/system/backups'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: systemTabResolver,
|
||||||
|
path: 'logs',
|
||||||
|
loadComponent: () => import('./logs/logs.component'),
|
||||||
|
data: toNavigationItem('/portal/system/logs'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: systemTabResolver,
|
title: systemTabResolver,
|
||||||
path: 'marketplace',
|
path: 'marketplace',
|
||||||
@@ -20,38 +25,31 @@ const ROUTES: Routes = [
|
|||||||
{
|
{
|
||||||
title: systemTabResolver,
|
title: systemTabResolver,
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
loadChildren: () =>
|
loadChildren: () => import('./settings/settings.routes'),
|
||||||
import('./settings/settings.routes').then(m => m.SETTINGS_ROUTES),
|
|
||||||
data: toNavigationItem('/portal/system/settings'),
|
data: toNavigationItem('/portal/system/settings'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: systemTabResolver,
|
title: systemTabResolver,
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
loadComponent: () =>
|
loadComponent: () => import('./notifications/notifications.component'),
|
||||||
import('./notifications/notifications.component').then(
|
|
||||||
m => m.NotificationsComponent,
|
|
||||||
),
|
|
||||||
data: toNavigationItem('/portal/system/notifications'),
|
data: toNavigationItem('/portal/system/notifications'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: systemTabResolver,
|
title: systemTabResolver,
|
||||||
path: 'sideload',
|
path: 'sideload',
|
||||||
loadComponent: () =>
|
loadComponent: () => import('./sideload/sideload.component'),
|
||||||
import('./sideload/sideload.component').then(m => m.SideloadComponent),
|
|
||||||
data: toNavigationItem('/portal/system/sideload'),
|
data: toNavigationItem('/portal/system/sideload'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: systemTabResolver,
|
title: systemTabResolver,
|
||||||
path: 'updates',
|
path: 'updates',
|
||||||
loadComponent: () =>
|
loadComponent: () => import('./updates/updates.component'),
|
||||||
import('./updates/updates.component').then(m => m.UpdatesComponent),
|
|
||||||
data: toNavigationItem('/portal/system/updates'),
|
data: toNavigationItem('/portal/system/updates'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: systemTabResolver,
|
title: systemTabResolver,
|
||||||
path: 'snek',
|
path: 'snek',
|
||||||
loadComponent: () =>
|
loadComponent: () => import('./snek/snek.component'),
|
||||||
import('./snek/snek.component').then(m => m.SnekComponent),
|
|
||||||
data: toNavigationItem('/portal/system/snek'),
|
data: toNavigationItem('/portal/system/snek'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { SkeletonListComponent } from '../../../components/skeleton-list.compone
|
|||||||
</p>
|
</p>
|
||||||
<updates-item
|
<updates-item
|
||||||
*ngFor="
|
*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;
|
else: loading;
|
||||||
empty: blank
|
empty: blank
|
||||||
"
|
"
|
||||||
@@ -61,7 +61,7 @@ import { SkeletonListComponent } from '../../../components/skeleton-list.compone
|
|||||||
SkeletonListComponent,
|
SkeletonListComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UpdatesComponent {
|
export default class UpdatesComponent {
|
||||||
private readonly marketplace = inject(
|
private readonly marketplace = inject(
|
||||||
AbstractMarketplaceService,
|
AbstractMarketplaceService,
|
||||||
) as MarketplaceService
|
) as MarketplaceService
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ var convert = new Convert({
|
|||||||
selector: 'logs',
|
selector: 'logs',
|
||||||
templateUrl: './logs.component.html',
|
templateUrl: './logs.component.html',
|
||||||
styleUrls: ['./logs.component.scss'],
|
styleUrls: ['./logs.component.scss'],
|
||||||
providers: [TuiDestroyService, DownloadHTMLService],
|
providers: [TuiDestroyService],
|
||||||
})
|
})
|
||||||
export class LogsComponent {
|
export class LogsComponent {
|
||||||
@ViewChild(IonContent)
|
@ViewChild(IonContent)
|
||||||
|
|||||||
@@ -380,6 +380,55 @@ ul {
|
|||||||
background: #373a3f;
|
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 {
|
.g-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 40rem;
|
min-width: 40rem;
|
||||||
@@ -491,3 +540,7 @@ svg:not(:root) {
|
|||||||
.externalLink {
|
.externalLink {
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ion-page {
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user