mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
refactor: extract loading status to shared library (#2282)
* refactor: extract loading status to shared library * chore: remove inline style
This commit is contained in:
committed by
Aiden McClelland
parent
52c0bb5302
commit
3804a46f3b
@@ -9,7 +9,8 @@
|
||||
"@ng-web-apis/mutation-observer": ">=2.0.0",
|
||||
"@ng-web-apis/resize-observer": ">=2.0.0",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@taiga-ui/cdk": ">=3.0.0"
|
||||
"@taiga-ui/cdk": ">=3.0.0",
|
||||
"ansi-to-html": "^0.7.2"
|
||||
},
|
||||
"exports": {
|
||||
"./assets/": "./assets/"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col class="ion-text-center">
|
||||
<ion-card *tuiLet="progress$ | async as progress" color="dark">
|
||||
<ion-card-header>
|
||||
<ion-card-title>Initializing StartOS</ion-card-title>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle *ngIf="progress">
|
||||
Progress: {{ (progress * 100).toFixed(0) }}%
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-progress-bar
|
||||
color="tertiary"
|
||||
class="progress"
|
||||
[type]="
|
||||
progress && progress < 1 ? 'determinate' : 'indeterminate'
|
||||
"
|
||||
[value]="progress || 0"
|
||||
></ion-progress-bar>
|
||||
<p>{{ getMessage(progress) }}</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<div class="logs-container">
|
||||
<logs-window></logs-window>
|
||||
</div>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,18 @@
|
||||
ion-card-title {
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
max-width: 700px;
|
||||
padding-bottom: 20px;
|
||||
margin: auto auto 40px;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
margin-top: 24px;
|
||||
height: 280px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
border-radius: 31px;
|
||||
margin-inline: 10px;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, inject, Input, Output } from '@angular/core'
|
||||
import { delay, filter } from 'rxjs'
|
||||
import { SetupService } from '../../services/setup.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading',
|
||||
templateUrl: 'loading.component.html',
|
||||
styleUrls: ['loading.component.scss'],
|
||||
})
|
||||
export class LoadingComponent {
|
||||
readonly progress$ = inject(SetupService)
|
||||
|
||||
@Input()
|
||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
|
||||
@Output()
|
||||
readonly finished = this.progress$.pipe(
|
||||
filter(progress => progress === 1),
|
||||
delay(500),
|
||||
)
|
||||
|
||||
getMessage(progress: number | null): string {
|
||||
if (['fresh', 'attach'].includes(this.setupType || '')) {
|
||||
return 'Setting up your server'
|
||||
}
|
||||
|
||||
if (!progress) {
|
||||
return 'Preparing data. This can take a while'
|
||||
} else if (progress < 1) {
|
||||
return 'Copying data'
|
||||
} else {
|
||||
return 'Finalizing'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
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 { LoadingComponent } from './loading.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, TuiLetModule],
|
||||
declarations: [LoadingComponent, LogsWindowComponent],
|
||||
exports: [LoadingComponent],
|
||||
})
|
||||
export class LoadingModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
<ion-content
|
||||
[scrollEvents]="true"
|
||||
(ionScroll)="handleScroll($event)"
|
||||
(ionScrollEnd)="handleScrollEnd()"
|
||||
class="ion-padding"
|
||||
color="light"
|
||||
>
|
||||
<div id="container"></div>
|
||||
</ion-content>
|
||||
|
||||
<div id="template"></div>
|
||||
@@ -0,0 +1,10 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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'
|
||||
|
||||
var Convert = require('ansi-to-html')
|
||||
var 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 />`
|
||||
}
|
||||
}
|
||||
25
frontend/projects/shared/src/mocks/get-setup-status.ts
Normal file
25
frontend/projects/shared/src/mocks/get-setup-status.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SetupStatus } from '../types/api'
|
||||
import { pauseFor } from '../util/misc.util'
|
||||
|
||||
let tries: number | undefined
|
||||
|
||||
export async function getSetupStatusMock(): Promise<SetupStatus | null> {
|
||||
const restoreOrMigrate = true
|
||||
const total = 4
|
||||
|
||||
await pauseFor(1000)
|
||||
|
||||
if (tries === undefined) {
|
||||
tries = 0
|
||||
return null
|
||||
}
|
||||
|
||||
tries++
|
||||
const progress = tries - 1
|
||||
|
||||
return {
|
||||
'bytes-transferred': restoreOrMigrate ? progress : 0,
|
||||
'total-bytes': restoreOrMigrate ? total : null,
|
||||
complete: progress === total,
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export * from './components/alert/alert.component'
|
||||
export * from './components/alert/alert.module'
|
||||
export * from './components/alert/alert-button.directive'
|
||||
export * from './components/alert/alert-input.directive'
|
||||
export * from './components/loading/logs-window/logs-window.component'
|
||||
export * from './components/loading/loading.module'
|
||||
export * from './components/loading/loading.component'
|
||||
export * from './components/markdown/markdown.component'
|
||||
export * from './components/markdown/markdown.component.module'
|
||||
export * from './components/text-spinner/text-spinner.component'
|
||||
@@ -25,6 +28,8 @@ export * from './directives/responsive-col/responsive-col-viewport.directive'
|
||||
export * from './directives/safe-links/safe-links.directive'
|
||||
export * from './directives/safe-links/safe-links.module'
|
||||
|
||||
export * from './mocks/get-setup-status'
|
||||
|
||||
export * from './pipes/emver/emver.module'
|
||||
export * from './pipes/emver/emver.pipe'
|
||||
export * from './pipes/guid/guid.module'
|
||||
@@ -43,6 +48,8 @@ export * from './services/emver.service'
|
||||
export * from './services/error.service'
|
||||
export * from './services/error-toast.service'
|
||||
export * from './services/http.service'
|
||||
export * from './services/setup.service'
|
||||
export * from './services/setup-logs.service'
|
||||
|
||||
export * from './themes/dark-theme/dark-theme.component'
|
||||
export * from './themes/dark-theme/dark-theme.module'
|
||||
@@ -50,6 +57,7 @@ export * from './themes/light-theme/light-theme.component'
|
||||
export * from './themes/light-theme/light-theme.module'
|
||||
|
||||
export * from './types/api'
|
||||
export * from './types/constructor'
|
||||
export * from './types/http.types'
|
||||
export * from './types/rpc.types'
|
||||
export * from './types/url'
|
||||
|
||||
30
frontend/projects/shared/src/services/setup-logs.service.ts
Normal file
30
frontend/projects/shared/src/services/setup-logs.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { StaticClassProvider } from '@angular/core'
|
||||
import { defer, Observable, switchMap } from 'rxjs'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { Log } from '../types/api'
|
||||
import { Constructor } from '../types/constructor'
|
||||
|
||||
interface Api {
|
||||
followLogs: () => Promise<string>
|
||||
openLogsWebsocket$: (config: WebSocketSubjectConfig<Log>) => Observable<Log>
|
||||
}
|
||||
|
||||
export function provideSetupLogsService(
|
||||
api: Constructor<Api>,
|
||||
): StaticClassProvider {
|
||||
return {
|
||||
provide: SetupLogsService,
|
||||
deps: [api],
|
||||
useClass: SetupLogsService,
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupLogsService extends Observable<Log> {
|
||||
private readonly log$ = defer(() => this.api.followLogs()).pipe(
|
||||
switchMap(url => this.api.openLogsWebsocket$({ url })),
|
||||
)
|
||||
|
||||
constructor(private readonly api: Api) {
|
||||
super(subscriber => this.log$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
59
frontend/projects/shared/src/services/setup.service.ts
Normal file
59
frontend/projects/shared/src/services/setup.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { inject, StaticClassProvider, Type } from '@angular/core'
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
filter,
|
||||
from,
|
||||
interval,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
takeWhile,
|
||||
} from 'rxjs'
|
||||
import { SetupStatus } from '../types/api'
|
||||
import { ErrorToastService } from './error-toast.service'
|
||||
import { Constructor } from '../types/constructor'
|
||||
|
||||
export function provideSetupService(
|
||||
api: Constructor<ConstructorParameters<typeof SetupService>[0]>,
|
||||
): StaticClassProvider {
|
||||
return {
|
||||
provide: SetupService,
|
||||
deps: [api],
|
||||
useClass: SetupService,
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupService extends Observable<number> {
|
||||
private readonly errorToastService = inject(ErrorToastService)
|
||||
private readonly progress$ = interval(500).pipe(
|
||||
exhaustMap(() =>
|
||||
from(this.api.getSetupStatus()).pipe(
|
||||
catchError(e => {
|
||||
this.errorToastService.present(e)
|
||||
|
||||
return EMPTY
|
||||
}),
|
||||
),
|
||||
),
|
||||
filter(Boolean),
|
||||
map(progress => {
|
||||
if (progress.complete) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return progress['total-bytes']
|
||||
? progress['bytes-transferred'] / progress['total-bytes']
|
||||
: 0
|
||||
}),
|
||||
takeWhile(value => value !== 1, true),
|
||||
shareReplay(1),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly api: { getSetupStatus: () => Promise<SetupStatus | null> },
|
||||
) {
|
||||
super(subscriber => this.progress$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
@@ -41,3 +41,9 @@ export type StartOSDiskInfo = {
|
||||
'password-hash': string | null
|
||||
'wrapped-key': string | null
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
'bytes-transferred': number
|
||||
'total-bytes': number | null
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
1
frontend/projects/shared/src/types/constructor.ts
Normal file
1
frontend/projects/shared/src/types/constructor.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Constructor<T> = abstract new (...args: any[]) => T
|
||||
Reference in New Issue
Block a user