refactor: extract loading status to shared library (#2282)

* refactor: extract loading status to shared library

* chore: remove inline style
This commit is contained in:
Alex Inkin
2023-05-26 00:04:47 +04:00
committed by Aiden McClelland
parent 52c0bb5302
commit 3804a46f3b
27 changed files with 320 additions and 218 deletions

View File

@@ -17,7 +17,7 @@ export class AppComponent {
async ngOnInit() {
try {
const inProgress = await this.apiService.getStatus()
const inProgress = await this.apiService.getSetupStatus()
let route = '/home'
if (inProgress) {

View File

@@ -18,7 +18,12 @@ import { HomePageModule } from './pages/home/home.module'
import { LoadingPageModule } from './pages/loading/loading.module'
import { RecoverPageModule } from './pages/recover/recover.module'
import { TransferPageModule } from './pages/transfer/transfer.module'
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
import {
provideSetupLogsService,
provideSetupService,
RELATIVE_URL,
WorkspaceConfig,
} from '@start9labs/shared'
const {
useMocks,
@@ -43,6 +48,8 @@ const {
TuiRootModule,
],
providers: [
provideSetupService(ApiService),
provideSetupLogsService(ApiService),
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { LoadingPage } from './loading.page'
const routes: Routes = [
{
path: '',
component: LoadingPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class LoadingPageRoutingModule { }

View File

@@ -1,13 +1,17 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { LoadingPage, ToMessagePipe } from './loading.page'
import { LogsWindowComponent } from './logs-window/logs-window.component'
import { LoadingPageRoutingModule } from './loading-routing.module'
import { RouterModule, Routes } from '@angular/router'
import { LoadingModule } from '@start9labs/shared'
import { LoadingPage } from './loading.page'
const routes: Routes = [
{
path: '',
component: LoadingPage,
},
]
@NgModule({
imports: [CommonModule, FormsModule, IonicModule, LoadingPageRoutingModule],
declarations: [LoadingPage, ToMessagePipe, LogsWindowComponent],
imports: [LoadingModule, RouterModule.forChild(routes)],
declarations: [LoadingPage],
})
export class LoadingPageModule {}

View File

@@ -1,40 +1,5 @@
<ion-content>
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-center">
<ion-card
*ngIf="{ decimal: 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.decimal as decimal">
Progress: {{ (decimal * 100).toFixed(0)}}%
</ion-card-subtitle>
</div>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-progress-bar
color="tertiary"
style="
max-width: 700px;
margin: auto;
padding-bottom: 20px;
margin-bottom: 40px;
"
[type]="progress.decimal && progress.decimal < 1 ? 'determinate' : 'indeterminate'"
[value]="progress.decimal || 0"
></ion-progress-bar>
<p>{{ progress.decimal | toMessage }}</p>
</ion-card-content>
</ion-card>
<div class="logs-container">
<logs-window></logs-window>
</div>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>
<app-loading
class="ion-page"
[setupType]="stateService.setupType"
(finished)="navCtrl.navigateForward('/success')"
></app-loading>

View File

@@ -1,51 +1,13 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { StateService } from 'src/app/services/state.service'
import { Pipe, PipeTransform } from '@angular/core'
@Component({
selector: 'app-loading',
templateUrl: 'loading.page.html',
styleUrls: ['loading.page.scss'],
})
export class LoadingPage {
readonly progress$ = this.stateService.dataProgress$
constructor(
private readonly stateService: StateService,
private readonly navCtrl: NavController,
readonly stateService: StateService,
readonly navCtrl: NavController,
) {}
ngOnInit() {
this.stateService.pollDataTransferProgress()
const progSub = this.stateService.dataCompletionSubject$.subscribe(
async complete => {
if (complete) {
progSub.unsubscribe()
await this.navCtrl.navigateForward(`/success`)
}
},
)
}
}
@Pipe({
name: 'toMessage',
})
export class ToMessagePipe implements PipeTransform {
constructor(private readonly stateService: StateService) {}
transform(progress: number | null): string {
if (['fresh', 'attach'].includes(this.stateService.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'
}
}
}

View File

@@ -1,11 +1,17 @@
import * as jose from 'node-jose'
import { DiskListResponse, StartOSDiskInfo, Log } from '@start9labs/shared'
import {
DiskListResponse,
StartOSDiskInfo,
Log,
SetupStatus,
} from '@start9labs/shared'
import { Observable } from 'rxjs'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export abstract class ApiService {
pubkey?: jose.JWK.Key
abstract getStatus(): Promise<StatusRes> // setup.status
abstract getSetupStatus(): Promise<SetupStatus | null> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
abstract verifyCifs(cifs: CifsRecoverySource): Promise<StartOSDiskInfo> // setup.cifs.verify
@@ -14,7 +20,9 @@ export abstract class ApiService {
abstract complete(): Promise<CompleteRes> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract followLogs(): Promise<string> // setup.logs.follow
abstract openLogsWebsocket$(guid: string): Observable<Log>
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
async encrypt(toEncrypt: string): Promise<Encrypted> {
if (!this.pubkey) throw new Error('No pubkey found!')
@@ -31,12 +39,6 @@ type Encrypted = {
encrypted: string
}
export type StatusRes = {
'bytes-transferred': number
'total-bytes': number | null
complete: boolean
} | null
export type AttachReq = {
guid: string
'embassy-password': Encrypted

View File

@@ -8,18 +8,18 @@ import {
Log,
RpcError,
RPCOptions,
SetupStatus,
} from '@start9labs/shared'
import {
ApiService,
CifsRecoverySource,
DiskRecoverySource,
StatusRes,
AttachReq,
ExecuteReq,
CompleteRes,
} from './api.service'
import * as jose from 'node-jose'
import { webSocket } from 'rxjs/webSocket'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
@Injectable({
@@ -30,8 +30,8 @@ export class LiveApiService extends ApiService {
super()
}
async getStatus() {
return this.rpcRequest<StatusRes>({
async getSetupStatus() {
return this.rpcRequest<SetupStatus | null>({
method: 'setup.status',
params: {},
})
@@ -94,8 +94,8 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
}
openLogsWebsocket$(guid: string): Observable<Log> {
return webSocket(`http://start.local/ws/${guid}`)
openLogsWebsocket$({ url }: WebSocketSubjectConfig<Log>): Observable<Log> {
return webSocket(`http://start.local/ws/${url}`)
}
async complete() {

View File

@@ -1,5 +1,10 @@
import { Injectable } from '@angular/core'
import { encodeBase64, Log, pauseFor } from '@start9labs/shared'
import {
encodeBase64,
getSetupStatusMock,
Log,
pauseFor,
} from '@start9labs/shared'
import {
ApiService,
CifsRecoverySource,
@@ -9,32 +14,14 @@ import {
} from './api.service'
import * as jose from 'node-jose'
import { interval, map, Observable } from 'rxjs'
let tries: number
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
@Injectable({
providedIn: 'root',
})
export class MockApiService extends ApiService {
async getStatus() {
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,
}
async getSetupStatus() {
return getSetupStatusMock()
}
async getPubKey() {
@@ -152,7 +139,7 @@ export class MockApiService extends ApiService {
return 'fake-guid'
}
openLogsWebsocket$(guid: string): Observable<Log> {
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(500).pipe(
map(() => ({
timestamp: new Date().toISOString(),

View File

@@ -1,7 +1,5 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { ApiService, RecoverySource } from './api/api.service'
import { pauseFor, ErrorToastService } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
@@ -12,47 +10,7 @@ export class StateService {
recoverySource?: RecoverySource
recoveryPassword?: string
dataTransferProgress?: {
bytesTransferred: number
totalBytes: number | null
complete: boolean
}
dataProgress$ = new BehaviorSubject<number>(0)
dataCompletionSubject$ = new BehaviorSubject(false)
constructor(
private readonly api: ApiService,
private readonly errorToastService: ErrorToastService,
) {}
async pollDataTransferProgress() {
await pauseFor(500)
if (this.dataTransferProgress?.complete) {
this.dataCompletionSubject$.next(true)
return
}
try {
const progress = await this.api.getStatus()
if (!progress) return
this.dataTransferProgress = {
bytesTransferred: progress['bytes-transferred'],
totalBytes: progress['total-bytes'],
complete: progress.complete,
}
if (this.dataTransferProgress.totalBytes) {
this.dataProgress$.next(
this.dataTransferProgress.bytesTransferred /
this.dataTransferProgress.totalBytes,
)
}
} catch (e: any) {
this.errorToastService.present(e)
}
setTimeout(() => this.pollDataTransferProgress(), 0) // prevent call stack from growing
}
constructor(private readonly api: ApiService) {}
async importDrive(guid: string, password: string): Promise<void> {
await this.api.attach({

View File

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

View File

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

View File

@@ -2,6 +2,12 @@ ion-card-title {
font-size: 42px;
}
.progress {
max-width: 700px;
padding-bottom: 20px;
margin: auto auto 40px;
}
.logs-container {
margin-top: 24px;
height: 280px;
@@ -9,4 +15,4 @@ ion-card-title {
overflow: hidden;
border-radius: 31px;
margin-inline: 10px;
}
}

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { Component, ViewChild } from '@angular/core'
import { IonContent } from '@ionic/angular'
import { from, map, switchMap, takeUntil } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { Log, toLocalIsoString } from '@start9labs/shared'
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({
@@ -23,36 +24,29 @@ export class LogsWindowComponent {
autoScroll = true
constructor(
private readonly api: ApiService,
private readonly logs: SetupLogsService,
private readonly destroy$: TuiDestroyService,
) {}
ngOnInit() {
from(this.api.followLogs())
this.logs
.pipe(
switchMap(guid =>
this.api.openLogsWebsocket$(guid).pipe(
map(log => {
const container = document.getElementById('container')
const newLogs = document.getElementById('template')?.cloneNode()
if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML = this.convertToAnsi(log)
container?.append(newLogs)
if (this.autoScroll) {
setTimeout(() => {
this.content?.scrollToBottom(250)
}, 0)
}
}),
),
),
map(log => this.convertToAnsi(log)),
takeUntil(this.destroy$),
)
.subscribe()
.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) {

View 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,
}
}

View File

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

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

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

View File

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

View File

@@ -0,0 +1 @@
export type Constructor<T> = abstract new (...args: any[]) => T

View File

@@ -2,7 +2,7 @@ import { BehaviorSubject, Observable } from 'rxjs'
import { Update } from 'patch-db-client'
import { RR, Encrypted, BackupTargetType, Metrics } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log } from '@start9labs/shared'
import { Log, SetupStatus } from '@start9labs/shared'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import type { JWK } from 'node-jose'
@@ -293,4 +293,6 @@ export abstract class ApiService {
abstract sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes>
abstract getSetupStatus(): Promise<SetupStatus | null>
}

View File

@@ -8,6 +8,7 @@ import {
Method,
RpcError,
RPCOptions,
SetupStatus,
} from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { BackupTargetType, Metrics, RR } from './api.types'
@@ -31,7 +32,9 @@ export class LiveApiService extends ApiService {
private readonly patch: PatchDB<DataModel>,
) {
super()
; (window as any).rpcClient = this
// @ts-ignore
this.document.defaultView.rpcClient = this
}
// for getting static files: ex icons, instructions, licenses
@@ -122,6 +125,10 @@ export class LiveApiService extends ApiService {
return this.openWebsocket(config)
}
async followLogs(): Promise<string> {
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return this.openWebsocket(config)
}
@@ -498,6 +505,13 @@ export class LiveApiService extends ApiService {
})
}
async getSetupStatus() {
return this.rpcRequest<SetupStatus | null>({
method: 'setup.status',
params: {},
})
}
private openWebsocket<T>(config: WebSocketSubjectConfig<T>): Observable<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { Log, pauseFor } from '@start9labs/shared'
import { pauseFor, Log, getSetupStatusMock } from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import {
Operation,
@@ -999,6 +999,10 @@ export class MockApiService extends ApiService {
return '4120e092-05ab-4de2-9fbd-c3f1f4b1df9e' // no significance, randomly generated
}
async getSetupStatus() {
return getSetupStatusMock()
}
private async updateProgress(id: string): Promise<void> {
const progress = { ...PROGRESS }
const phases = [