mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
Feat/combine uis (#2633)
* wip * restructure backend for new ui structure * new patchdb bootstrap, single websocket api, local storage migration, more * update db websocket * init apis * update patch-db * setup progress * feat: implement state service, alert and routing Signed-off-by: waterplea <alexander@inkin.ru> * update setup wizard for new types * feat: add init page Signed-off-by: waterplea <alexander@inkin.ru> * chore: refactor message, patch-db source stream and connection service Signed-off-by: waterplea <alexander@inkin.ru> * fix method not found on state * fix backend bugs * fix compat assets * address comments * remove unneeded styling * cleaner progress * bugfixes * fix init logs * fix progress reporting * fix navigation by getting state after init * remove patch dependency from live api * fix caching * re-add patchDB to live api * fix metrics values * send close frame * add bootId and fix polling --------- Signed-off-by: waterplea <alexander@inkin.ru> Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: waterplea <alexander@inkin.ru>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
import { stateNot } from 'src/app/services/state.service'
|
||||
import { AuthGuard } from './guards/auth.guard'
|
||||
import { UnauthGuard } from './guards/unauth.guard'
|
||||
|
||||
@@ -15,15 +16,29 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/login/login.module').then(m => m.LoginPageModule),
|
||||
},
|
||||
{
|
||||
path: 'diagnostic',
|
||||
canActivate: [stateNot(['initializing', 'running'])],
|
||||
loadChildren: () =>
|
||||
import('./pages/diagnostic-routes/diagnostic-routing.module').then(
|
||||
m => m.DiagnosticModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'initializing',
|
||||
canActivate: [stateNot(['error', 'running'])],
|
||||
loadChildren: () =>
|
||||
import('./pages/init/init.module').then(m => m.InitPageModule),
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
canActivate: [AuthGuard],
|
||||
canActivate: [AuthGuard, stateNot(['error', 'initializing'])],
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
canActivate: [AuthGuard],
|
||||
canActivate: [AuthGuard, stateNot(['error', 'initializing'])],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/server-routes/server-routing.module').then(
|
||||
@@ -32,14 +47,14 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'updates',
|
||||
canActivate: [AuthGuard],
|
||||
canActivate: [AuthGuard, stateNot(['error', 'initializing'])],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/updates/updates.module').then(m => m.UpdatesPageModule),
|
||||
},
|
||||
{
|
||||
path: 'marketplace',
|
||||
canActivate: [AuthGuard],
|
||||
canActivate: [AuthGuard, stateNot(['error', 'initializing'])],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/marketplace-routes/marketplace-routing.module').then(
|
||||
@@ -48,7 +63,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
canActivate: [AuthGuard],
|
||||
canActivate: [AuthGuard, stateNot(['error', 'initializing'])],
|
||||
loadChildren: () =>
|
||||
import('./pages/notifications/notifications.module').then(
|
||||
m => m.NotificationsPageModule,
|
||||
@@ -56,7 +71,7 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
canActivate: [AuthGuard],
|
||||
canActivate: [AuthGuard, stateNot(['error', 'initializing'])],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/apps-routes/apps-routing.module').then(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
type="overlay"
|
||||
side="start"
|
||||
class="left-menu"
|
||||
[class.left-menu_hidden]="withoutMenu"
|
||||
>
|
||||
<ion-content color="light" scrollY="false" class="menu">
|
||||
<app-menu *ngIf="authService.isVerified$ | async"></app-menu>
|
||||
|
||||
@@ -9,11 +9,15 @@ tui-root {
|
||||
|
||||
.left-menu {
|
||||
--side-max-width: 280px;
|
||||
|
||||
&_hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
:host-context(body[data-theme='Light']) & {
|
||||
--ion-color-base: #F4F4F5 !important;
|
||||
--ion-color-base: #f4f4f5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, inject, OnDestroy } from '@angular/core'
|
||||
import { IsActiveMatchOptions, Router } from '@angular/router'
|
||||
import { combineLatest, map, merge, startWith } from 'rxjs'
|
||||
import { AuthService } from './services/auth.service'
|
||||
import { SplitPaneTracker } from './services/split-pane.service'
|
||||
@@ -15,6 +16,13 @@ import { THEME } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from './services/patch-db/data-model'
|
||||
|
||||
const OPTIONS: IsActiveMatchOptions = {
|
||||
paths: 'subset',
|
||||
queryParams: 'exact',
|
||||
fragment: 'ignored',
|
||||
matrixParams: 'ignored',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
@@ -27,7 +35,7 @@ export class AppComponent implements OnDestroy {
|
||||
readonly theme$ = inject(THEME)
|
||||
readonly offline$ = combineLatest([
|
||||
this.authService.isVerified$,
|
||||
this.connection.connected$,
|
||||
this.connection$,
|
||||
this.patch
|
||||
.watch$('serverInfo', 'statusInfo')
|
||||
.pipe(startWith({ restarting: false, shuttingDown: false })),
|
||||
@@ -44,8 +52,9 @@ export class AppComponent implements OnDestroy {
|
||||
private readonly patchMonitor: PatchMonitorService,
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly router: Router,
|
||||
readonly authService: AuthService,
|
||||
readonly connection: ConnectionService,
|
||||
readonly connection$: ConnectionService,
|
||||
readonly clientStorageService: ClientStorageService,
|
||||
readonly themeSwitcher: ThemeSwitcherService,
|
||||
) {}
|
||||
@@ -56,6 +65,13 @@ export class AppComponent implements OnDestroy {
|
||||
.subscribe(name => this.titleService.setTitle(name || 'StartOS'))
|
||||
}
|
||||
|
||||
get withoutMenu(): boolean {
|
||||
return (
|
||||
this.router.isActive('initializing', OPTIONS) ||
|
||||
this.router.isActive('diagnostic', OPTIONS)
|
||||
)
|
||||
}
|
||||
|
||||
splitPaneVisible({ detail }: any) {
|
||||
this.splitPane.sidebarOpen$.next(detail.visible)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
TuiAlertModule,
|
||||
TuiDialogModule,
|
||||
TuiModeModule,
|
||||
TuiRootModule,
|
||||
@@ -58,6 +59,7 @@ import { environment } from '../environments/environment'
|
||||
ConnectionBarComponentModule,
|
||||
TuiRootModule,
|
||||
TuiDialogModule,
|
||||
TuiAlertModule,
|
||||
TuiModeModule,
|
||||
TuiThemeNightModule,
|
||||
WidgetsPageModule,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AuthService } from './services/auth.service'
|
||||
import { ClientStorageService } from './services/client-storage.service'
|
||||
import { FilterPackagesPipe } from '../../../marketplace/src/pipes/filter-packages.pipe'
|
||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||
import { StorageService } from './services/storage.service'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
@@ -30,7 +31,7 @@ export const APP_PROVIDERS: Provider[] = [
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
deps: [AuthService, ClientStorageService, Router],
|
||||
deps: [StorageService, AuthService, ClientStorageService, Router],
|
||||
useFactory: appInitializer,
|
||||
multi: true,
|
||||
},
|
||||
@@ -45,13 +46,15 @@ export const APP_PROVIDERS: Provider[] = [
|
||||
]
|
||||
|
||||
export function appInitializer(
|
||||
storage: StorageService,
|
||||
auth: AuthService,
|
||||
localStorage: ClientStorageService,
|
||||
router: Router,
|
||||
): () => void {
|
||||
return () => {
|
||||
storage.migrate036()
|
||||
auth.init()
|
||||
localStorage.init()
|
||||
localStorage.init() // @TODO pretty sure we can navigate before this step
|
||||
router.initialNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export class MenuComponent {
|
||||
|
||||
readonly showEOSUpdate$ = this.eosService.showUpdate$
|
||||
|
||||
private readonly local$ = this.connectionService.connected$.pipe(
|
||||
private readonly local$ = this.connection$.pipe(
|
||||
filter(Boolean),
|
||||
switchMap(() => this.patch.watch$('packageData').pipe(first())),
|
||||
switchMap(outer =>
|
||||
@@ -126,6 +126,6 @@ export class MenuComponent {
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly emver: Emver,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly connection$: ConnectionService,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, map, Observable, startWith } from 'rxjs'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { NetworkService } from 'src/app/services/network.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'connection-bar',
|
||||
@@ -11,16 +12,14 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConnectionBarComponent {
|
||||
private readonly websocket$ = this.connectionService.websocketConnected$
|
||||
|
||||
readonly connection$: Observable<{
|
||||
message: string
|
||||
color: string
|
||||
icon: string
|
||||
dots: boolean
|
||||
}> = combineLatest([
|
||||
this.connectionService.networkConnected$,
|
||||
this.websocket$.pipe(startWith(false)),
|
||||
this.network$,
|
||||
this.state$.pipe(map(Boolean)),
|
||||
this.patch
|
||||
.watch$('serverInfo', 'statusInfo')
|
||||
.pipe(startWith({ restarting: false, shuttingDown: false })),
|
||||
@@ -65,7 +64,8 @@ export class ConnectionBarComponent {
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly network$: NetworkService,
|
||||
private readonly state$: StateService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import {
|
||||
LogsRes,
|
||||
ServerLogsReq,
|
||||
@@ -72,7 +71,7 @@ export class LogsComponent {
|
||||
private readonly api: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly downloadHtml: DownloadHTMLService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly connection$: ConnectionService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -149,43 +148,42 @@ export class LogsComponent {
|
||||
private reconnect$(): Observable<Log[]> {
|
||||
return from(this.followLogs({})).pipe(
|
||||
tap(_ => this.recordConnectionChange()),
|
||||
switchMap(({ guid }) => this.connect$(guid, true)),
|
||||
switchMap(({ guid }) => this.connect$(guid)),
|
||||
)
|
||||
}
|
||||
|
||||
private connect$(guid: string, reconnect = false) {
|
||||
const config: WebSocketSubjectConfig<Log> = {
|
||||
url: `/rpc/${guid}`,
|
||||
openObserver: {
|
||||
next: () => {
|
||||
this.websocketStatus = 'connected'
|
||||
private connect$(guid: string) {
|
||||
return this.api
|
||||
.openWebsocket$<Log>(guid, {
|
||||
openObserver: {
|
||||
next: () => {
|
||||
this.websocketStatus = 'connected'
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return this.api.openLogsWebsocket$(config).pipe(
|
||||
tap(_ => this.count++),
|
||||
bufferTime(1000),
|
||||
tap(msgs => {
|
||||
this.loading = false
|
||||
this.processRes({ entries: msgs })
|
||||
if (this.infiniteStatus === 0 && this.count >= this.limit)
|
||||
this.infiniteStatus = 1
|
||||
}),
|
||||
catchError(() => {
|
||||
this.recordConnectionChange(false)
|
||||
return this.connectionService.connected$.pipe(
|
||||
tap(
|
||||
connected =>
|
||||
(this.websocketStatus = connected
|
||||
? 'reconnecting'
|
||||
: 'disconnected'),
|
||||
),
|
||||
filter(Boolean),
|
||||
switchMap(() => this.reconnect$()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.pipe(
|
||||
tap(_ => this.count++),
|
||||
bufferTime(1000),
|
||||
tap(msgs => {
|
||||
this.loading = false
|
||||
this.processRes({ entries: msgs })
|
||||
if (this.infiniteStatus === 0 && this.count >= this.limit)
|
||||
this.infiniteStatus = 1
|
||||
}),
|
||||
catchError(() => {
|
||||
this.recordConnectionChange(false)
|
||||
return this.connection$.pipe(
|
||||
tap(
|
||||
connected =>
|
||||
(this.websocketStatus = connected
|
||||
? 'reconnecting'
|
||||
: 'disconnected'),
|
||||
),
|
||||
filter(Boolean),
|
||||
switchMap(() => this.reconnect$()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private recordConnectionChange(success = true) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<p
|
||||
[style.color]="
|
||||
(connected$ | async) ? 'var(--ion-color-' + rendering.color + ')' : 'gray'
|
||||
(connection$ | async) ? 'var(--ion-color-' + rendering.color + ')' : 'gray'
|
||||
"
|
||||
[style.font-size]="size"
|
||||
[style.font-style]="style"
|
||||
[style.font-weight]="weight"
|
||||
>
|
||||
{{ (connected$ | async) ? rendering.display : 'Unknown' }}
|
||||
{{ (connection$ | async) ? rendering.display : 'Unknown' }}
|
||||
|
||||
<span *ngIf="sigtermTimeout && (sigtermTimeout | durationToSeconds) > 30">
|
||||
. This may take a while
|
||||
|
||||
@@ -21,7 +21,5 @@ export class StatusComponent {
|
||||
@Input() installingInfo?: InstallingInfo
|
||||
@Input() sigtermTimeout?: string | null = null
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(private readonly connectionService: ConnectionService) {}
|
||||
constructor(readonly connection$: ConnectionService) {}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,8 @@ export class OSUpdatePage {
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly eosService: EOSService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
const releaseNotes = this.eosService.eos?.releaseNotes!
|
||||
const releaseNotes = this.eosService.osUpdate?.releaseNotes!
|
||||
|
||||
this.versions = Object.keys(releaseNotes)
|
||||
.sort()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<ng-container *ngIf="connected$ | async; else disconnected">
|
||||
<ng-container *ngIf="connection$ | async; else disconnected">
|
||||
<ion-icon
|
||||
*ngIf="pkg.error; else noError"
|
||||
class="warning-icon"
|
||||
|
||||
@@ -12,7 +12,5 @@ export class AppListIconComponent {
|
||||
@Input()
|
||||
pkg!: PkgInfo
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(private readonly connectionService: ConnectionService) {}
|
||||
constructor(readonly connection$: ConnectionService) {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<ng-container *ngIf="!(healthChecks | empty)">
|
||||
<ion-item-divider>Health Checks</ion-item-divider>
|
||||
<!-- connected -->
|
||||
<ng-container *ngIf="connected$ | async; else disconnected">
|
||||
<ng-container *ngIf="connection$ | async; else disconnected">
|
||||
<ion-item *ngFor="let check of healthChecks | keyvalue">
|
||||
<!-- result -->
|
||||
<ng-container *ngIf="check.value.result as result; else noResult">
|
||||
|
||||
@@ -12,9 +12,7 @@ export class AppShowHealthChecksComponent {
|
||||
@Input()
|
||||
healthChecks!: Record<string, T.HealthCheckResult>
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(private readonly connectionService: ConnectionService) {}
|
||||
constructor(readonly connection$: ConnectionService) {}
|
||||
|
||||
isLoading(result: T.HealthCheckResult['result']): boolean {
|
||||
return result === 'starting' || result === 'loading'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-container *ngIf="isInstalled(pkg) && (connected$ | async)">
|
||||
<ng-container *ngIf="isInstalled(pkg) && (connection$ | async)">
|
||||
<ion-grid>
|
||||
<ion-row style="padding-left: 12px">
|
||||
<ion-col>
|
||||
|
||||
@@ -39,8 +39,6 @@ export class AppShowStatusComponent {
|
||||
|
||||
isInstalled = isInstalled
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
@@ -48,7 +46,7 @@ export class AppShowStatusComponent {
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly launcherService: UiLauncherService,
|
||||
private readonly modalService: ModalService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
readonly connection$: ConnectionService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('./home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
loadChildren: () =>
|
||||
import('./logs/logs.module').then(m => m.LogsPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(ROUTES)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class DiagnosticModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { HomePage } from './home.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, RouterModule.forChild(routes)],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -0,0 +1,75 @@
|
||||
<ion-content>
|
||||
<div style="padding: 48px">
|
||||
<ng-container *ngIf="!restarted; else refresh">
|
||||
<h1
|
||||
class="ion-text-center"
|
||||
style="padding-bottom: 36px; font-size: calc(2vw + 14px)"
|
||||
>
|
||||
StartOS - Diagnostic Mode
|
||||
</h1>
|
||||
|
||||
<ng-container *ngIf="error">
|
||||
<h2
|
||||
style="
|
||||
padding-bottom: 16px;
|
||||
font-size: calc(1vw + 14px);
|
||||
font-weight: bold;
|
||||
"
|
||||
>
|
||||
StartOS launch error:
|
||||
</h2>
|
||||
<div class="code-block">
|
||||
<code>
|
||||
<ion-text color="warning">{{ error.problem }}</ion-text>
|
||||
<span *ngIf="error.details">
|
||||
<br />
|
||||
<br />
|
||||
<ion-text color="warning">{{ error.details }}</ion-text>
|
||||
</span>
|
||||
</code>
|
||||
</div>
|
||||
<ion-button routerLink="logs">View Logs</ion-button>
|
||||
<h2
|
||||
style="
|
||||
padding: 32px 0 16px 0;
|
||||
font-size: calc(1vw + 12px);
|
||||
font-weight: bold;
|
||||
"
|
||||
>
|
||||
Possible solutions:
|
||||
</h2>
|
||||
<div class="code-block">
|
||||
<code><ion-text color="success">{{ error.solution }}</ion-text></code>
|
||||
</div>
|
||||
<ion-button (click)="restart()">Restart Server</ion-button>
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
*ngIf="error.code === 15 || error.code === 25"
|
||||
(click)="forgetDrive()"
|
||||
>
|
||||
{{ error.code === 15 ? 'Setup Current Drive' : 'Enter Recovery Mode'
|
||||
}}
|
||||
</ion-button>
|
||||
|
||||
<div class="ion-padding-top">
|
||||
<ion-button (click)="presentAlertRepairDisk()" color="danger">
|
||||
Repair Drive
|
||||
</ion-button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #refresh>
|
||||
<h1
|
||||
class="ion-text-center"
|
||||
style="padding-bottom: 36px; font-size: calc(2vw + 12px)"
|
||||
>
|
||||
Server is restarting
|
||||
</h1>
|
||||
<h2 style="padding-bottom: 16px; font-size: calc(1vw + 12px)">
|
||||
Wait for the server to restart, then refresh this page.
|
||||
</h2>
|
||||
<ion-button (click)="refreshPage()">Refresh</ion-button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,5 @@
|
||||
.code-block {
|
||||
background-color: rgb(69, 69, 69);
|
||||
padding: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, LoadingController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
@Component({
|
||||
selector: 'diagnostic-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
error?: {
|
||||
code: number
|
||||
problem: string
|
||||
solution: string
|
||||
details?: string
|
||||
}
|
||||
solutions: string[] = []
|
||||
restarted = false
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const error = await this.api.diagnosticGetError()
|
||||
// incorrect drive
|
||||
if (error.code === 15) {
|
||||
this.error = {
|
||||
code: 15,
|
||||
problem: 'Unknown storage drive detected',
|
||||
solution:
|
||||
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// no drive
|
||||
} else if (error.code === 20) {
|
||||
this.error = {
|
||||
code: 20,
|
||||
problem: 'Storage drive not found',
|
||||
solution:
|
||||
'Insert your StartOS storage drive and click RESTART SERVER below.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// drive corrupted
|
||||
} else if (error.code === 25) {
|
||||
this.error = {
|
||||
code: 25,
|
||||
problem:
|
||||
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
|
||||
solution:
|
||||
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// filesystem I/O error - disk needs repair
|
||||
} else if (error.code === 2) {
|
||||
this.error = {
|
||||
code: 2,
|
||||
problem: 'Filesystem I/O error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// disk management error - disk needs repair
|
||||
} else if (error.code === 48) {
|
||||
this.error = {
|
||||
code: 48,
|
||||
problem: 'Disk management error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
} else {
|
||||
this.error = {
|
||||
code: error.code,
|
||||
problem: error.message,
|
||||
solution: 'Please contact support.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.diagnosticRestart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.diagnosticForgetDrive()
|
||||
await this.api.diagnosticRestart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertRepairDisk() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message:
|
||||
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Repair',
|
||||
handler: () => {
|
||||
try {
|
||||
this.repairDisk()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-error-message',
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
refreshPage(): void {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
private async repairDisk(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.diagnosticRepairDisk()
|
||||
await this.api.diagnosticRestart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LogsPage } from './logs.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LogsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, RouterModule.forChild(routes)],
|
||||
declarations: [LogsPage],
|
||||
})
|
||||
export class LogsPageModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Logs</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content
|
||||
[scrollEvents]="true"
|
||||
(ionScrollEnd)="scrollEnd()"
|
||||
class="ion-padding"
|
||||
>
|
||||
<ion-infinite-scroll
|
||||
id="scroller"
|
||||
*ngIf="!loading && needInfinite"
|
||||
position="top"
|
||||
threshold="0"
|
||||
(ionInfinite)="doInfinite($event)"
|
||||
>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
|
||||
<div id="container">
|
||||
<div id="template" style="white-space: pre-line"></div>
|
||||
</div>
|
||||
|
||||
<div id="bottom-div"></div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'position': 'fixed',
|
||||
'bottom': '50px',
|
||||
'right': isOnBottom ? '-52px' : '30px',
|
||||
'border-radius': '100%',
|
||||
'transition': 'right 0.25s ease-out'
|
||||
}"
|
||||
>
|
||||
<ion-button
|
||||
style="
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
--padding-start: 0px;
|
||||
--padding-end: 0px;
|
||||
--border-radius: 100%;
|
||||
"
|
||||
color="dark"
|
||||
(click)="scrollToBottom()"
|
||||
strong
|
||||
>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { IonContent } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService, toLocalIsoString } from '@start9labs/shared'
|
||||
|
||||
var Convert = require('ansi-to-html')
|
||||
var convert = new Convert({
|
||||
bg: 'transparent',
|
||||
})
|
||||
|
||||
@Component({
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.page.html',
|
||||
styleUrls: ['./logs.page.scss'],
|
||||
})
|
||||
export class LogsPage {
|
||||
@ViewChild(IonContent) private content?: IonContent
|
||||
loading = true
|
||||
needInfinite = true
|
||||
startCursor?: string
|
||||
limit = 200
|
||||
isOnBottom = true
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getLogs()
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
scrollEnd() {
|
||||
const bottomDiv = document.getElementById('bottom-div')
|
||||
this.isOnBottom =
|
||||
!!bottomDiv &&
|
||||
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.content?.scrollToBottom(500)
|
||||
}
|
||||
|
||||
async doInfinite(e: any): Promise<void> {
|
||||
await this.getLogs()
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
private async getLogs() {
|
||||
try {
|
||||
const { startCursor, entries } = await this.api.diagnosticGetLogs({
|
||||
cursor: this.startCursor,
|
||||
before: !!this.startCursor,
|
||||
limit: this.limit,
|
||||
})
|
||||
|
||||
if (!entries.length) return
|
||||
|
||||
this.startCursor = startCursor
|
||||
|
||||
const container = document.getElementById('container')
|
||||
const newLogs = document.getElementById('template')?.cloneNode(true)
|
||||
|
||||
if (!(newLogs instanceof HTMLElement)) return
|
||||
|
||||
newLogs.innerHTML = entries
|
||||
.map(
|
||||
entry =>
|
||||
`<b>${toLocalIsoString(
|
||||
new Date(entry.timestamp),
|
||||
)}</b> ${convert.toHtml(entry.message)}`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
const beforeContainerHeight = container?.scrollHeight || 0
|
||||
container?.prepend(newLogs)
|
||||
const afterContainerHeight = container?.scrollHeight || 0
|
||||
|
||||
// scroll down
|
||||
setTimeout(() => {
|
||||
this.content?.scrollToPoint(
|
||||
0,
|
||||
afterContainerHeight - beforeContainerHeight,
|
||||
)
|
||||
}, 50)
|
||||
|
||||
if (entries.length < this.limit) {
|
||||
this.needInfinite = false
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
web/projects/ui/src/app/pages/init/init.module.ts
Normal file
24
web/projects/ui/src/app/pages/init/init.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
import { LogsModule } from 'src/app/pages/init/logs/logs.module'
|
||||
import { InitPage } from './init.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: InitPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
LogsModule,
|
||||
TuiProgressModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [InitPage],
|
||||
})
|
||||
export class InitPageModule {}
|
||||
18
web/projects/ui/src/app/pages/init/init.page.html
Normal file
18
web/projects/ui/src/app/pages/init/init.page.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<section *ngIf="progress$ | async as progress">
|
||||
<h1 [style.font-size.rem]="2.5" [style.margin.rem]="1">
|
||||
Initializing StartOS
|
||||
</h1>
|
||||
<div class="center-wrapper" *ngIf="progress.total">
|
||||
Progress: {{ (progress.total * 100).toFixed(0) }}%
|
||||
</div>
|
||||
|
||||
<progress
|
||||
tuiProgressBar
|
||||
class="progress"
|
||||
[style.max-width.rem]="40"
|
||||
[style.margin]="'1rem auto'"
|
||||
[attr.value]="progress.total"
|
||||
></progress>
|
||||
<p [innerHTML]="progress.message"></p>
|
||||
</section>
|
||||
<logs-window></logs-window>
|
||||
23
web/projects/ui/src/app/pages/init/init.page.scss
Normal file
23
web/projects/ui/src/app/pages/init/init.page.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
section {
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem;
|
||||
text-align: center;
|
||||
/* TODO: Theme */
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
--tui-clear-inverse: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
logs-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 18rem;
|
||||
padding: 1rem;
|
||||
margin: 0 1.5rem auto;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
border-radius: 2rem;
|
||||
/* TODO: Theme */
|
||||
background: #181818;
|
||||
}
|
||||
11
web/projects/ui/src/app/pages/init/init.page.ts
Normal file
11
web/projects/ui/src/app/pages/init/init.page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { InitService } from 'src/app/pages/init/init.service'
|
||||
|
||||
@Component({
|
||||
selector: 'init-page',
|
||||
templateUrl: 'init.page.html',
|
||||
styleUrls: ['init.page.scss'],
|
||||
})
|
||||
export class InitPage {
|
||||
readonly progress$ = inject(InitService)
|
||||
}
|
||||
91
web/projects/ui/src/app/pages/init/init.service.ts
Normal file
91
web/projects/ui/src/app/pages/init/init.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
catchError,
|
||||
defer,
|
||||
EMPTY,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
interface MappedProgress {
|
||||
readonly total: number | null
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InitService extends Observable<MappedProgress> {
|
||||
private readonly state = inject(StateService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorToastService)
|
||||
private readonly progress$ = defer(() =>
|
||||
from(this.api.initGetProgress()),
|
||||
).pipe(
|
||||
switchMap(({ guid, progress }) =>
|
||||
this.api
|
||||
.openWebsocket$<T.FullProgress>(guid, {})
|
||||
.pipe(startWith(progress)),
|
||||
),
|
||||
map(({ phases, overall }) => {
|
||||
return {
|
||||
total: getOverallDecimal(overall),
|
||||
message: phases
|
||||
.filter(
|
||||
(
|
||||
p,
|
||||
): p is {
|
||||
name: string
|
||||
progress: {
|
||||
done: number
|
||||
total: number | null
|
||||
}
|
||||
} => p.progress !== true && p.progress !== null,
|
||||
)
|
||||
.map(p => `<b>${p.name}</b>${getPhaseBytes(p.progress)}`)
|
||||
.join(', '),
|
||||
}
|
||||
}),
|
||||
tap(({ total }) => {
|
||||
if (total === 1) {
|
||||
this.state.syncState()
|
||||
}
|
||||
}),
|
||||
catchError(e => {
|
||||
this.errorService.present(e)
|
||||
|
||||
return EMPTY
|
||||
}),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.progress$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
|
||||
function getOverallDecimal(progress: T.Progress): number {
|
||||
if (progress === true) {
|
||||
return 1
|
||||
} else if (!progress || !progress.total) {
|
||||
return 0
|
||||
} else {
|
||||
return progress.total && progress.done / progress.total
|
||||
}
|
||||
}
|
||||
|
||||
function getPhaseBytes(
|
||||
progress:
|
||||
| false
|
||||
| {
|
||||
done: number
|
||||
total: number | null
|
||||
},
|
||||
): string {
|
||||
return progress === false ? '' : `: (${progress.done}/${progress.total})`
|
||||
}
|
||||
33
web/projects/ui/src/app/pages/init/logs/logs.component.ts
Normal file
33
web/projects/ui/src/app/pages/init/logs/logs.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Component, ElementRef, inject } from '@angular/core'
|
||||
import { INTERSECTION_ROOT } from '@ng-web-apis/intersection-observer'
|
||||
import { LogsService } from 'src/app/pages/init/logs/logs.service'
|
||||
|
||||
@Component({
|
||||
selector: 'logs-window',
|
||||
templateUrl: 'logs.template.html',
|
||||
styles: [
|
||||
`
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: INTERSECTION_ROOT,
|
||||
useExisting: ElementRef,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class LogsComponent {
|
||||
readonly logs$ = inject(LogsService)
|
||||
scroll = true
|
||||
|
||||
scrollTo(bottom: HTMLElement) {
|
||||
if (this.scroll) bottom.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onBottom([{ isIntersecting }]: readonly IntersectionObserverEntry[]) {
|
||||
this.scroll = isIntersecting
|
||||
}
|
||||
}
|
||||
20
web/projects/ui/src/app/pages/init/logs/logs.module.ts
Normal file
20
web/projects/ui/src/app/pages/init/logs/logs.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IntersectionObserverModule } 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 { LogsComponent } from './logs.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
MutationObserverModule,
|
||||
IntersectionObserverModule,
|
||||
NgDompurifyModule,
|
||||
TuiScrollbarModule,
|
||||
],
|
||||
declarations: [LogsComponent],
|
||||
exports: [LogsComponent],
|
||||
})
|
||||
export class LogsModule {}
|
||||
49
web/projects/ui/src/app/pages/init/logs/logs.service.ts
Normal file
49
web/projects/ui/src/app/pages/init/logs/logs.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { Log, toLocalIsoString } from '@start9labs/shared'
|
||||
import {
|
||||
bufferTime,
|
||||
defer,
|
||||
filter,
|
||||
map,
|
||||
Observable,
|
||||
scan,
|
||||
switchMap,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
var Convert = require('ansi-to-html')
|
||||
var convert = new Convert({
|
||||
newline: true,
|
||||
bg: 'transparent',
|
||||
colors: {
|
||||
4: 'Cyan',
|
||||
},
|
||||
escapeXML: true,
|
||||
})
|
||||
|
||||
function convertAnsi(entries: readonly any[]): string {
|
||||
return entries
|
||||
.map(
|
||||
({ timestamp, message }) =>
|
||||
`<b style="color: #FFF">${toLocalIsoString(
|
||||
new Date(timestamp),
|
||||
)}</b> ${convert.toHtml(message)}`,
|
||||
)
|
||||
.join('<br />')
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LogsService extends Observable<readonly string[]> {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly log$ = defer(() => this.api.initFollowLogs({})).pipe(
|
||||
switchMap(({ guid }) => this.api.openWebsocket$<Log>(guid, {})),
|
||||
bufferTime(250),
|
||||
filter(logs => !!logs.length),
|
||||
map(convertAnsi),
|
||||
scan((logs: readonly string[], log) => [...logs, log], []),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.log$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<tui-scrollbar childList subtree (waMutationObserver)="scrollTo(bottom)">
|
||||
<pre *ngFor="let log of logs$ | async" [innerHTML]="log | dompurify"></pre>
|
||||
<section
|
||||
#bottom
|
||||
waIntersectionObserver
|
||||
[style.padding.rem]="1"
|
||||
(waIntersectionObservee)="onBottom($event)"
|
||||
></section>
|
||||
</tui-scrollbar>
|
||||
@@ -42,7 +42,7 @@ export class CAWizardComponent {
|
||||
|
||||
private async testHttps() {
|
||||
const url = `https://${this.document.location.host}${this.relativeUrl}`
|
||||
await this.api.echo({ message: 'ping' }, url).then(() => {
|
||||
await this.api.getState().then(() => {
|
||||
this.caTrusted = true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<ion-item-divider>Memory</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>Percentage Used</ion-label>
|
||||
<ion-note slot="end">{{ memory.percentageUsed }} %</ion-note>
|
||||
<ion-note slot="end">{{ memory.percentageUsed.value }} %</ion-note>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>Total</ion-label>
|
||||
@@ -98,7 +98,7 @@
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>zram Total</ion-label>
|
||||
<ion-note slot="end">{{ memory.zramTotal }} MiB</ion-note>
|
||||
<ion-note slot="end">{{ memory.zramTotal.value }} MiB</ion-note>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>zram Available</ion-label>
|
||||
|
||||
@@ -319,30 +319,6 @@ export class ServerShowPage {
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlertSystemRebuild() {
|
||||
const localPkgs = await getAllPackages(this.patch)
|
||||
const minutes = Object.keys(localPkgs).length * 2
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `This action will tear down all service containers and rebuild them from scratch. No data will be deleted. This action is useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues. It may take up to ${minutes} minutes to complete. During this time, you will lose all connectivity to your server.`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Rebuild',
|
||||
handler: () => {
|
||||
this.systemRebuild()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlertRepairDisk() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
@@ -437,23 +413,6 @@ export class ServerShowPage {
|
||||
}
|
||||
}
|
||||
|
||||
private async systemRebuild() {
|
||||
const action = 'System Rebuild'
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: `Beginning ${action}...`,
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.systemRebuild({})
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async checkForEosUpdate(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Checking for updates',
|
||||
@@ -718,14 +677,6 @@ export class ServerShowPage {
|
||||
detail: false,
|
||||
disabled$: of(false),
|
||||
},
|
||||
{
|
||||
title: 'System Rebuild',
|
||||
description: '',
|
||||
icon: 'construct-outline',
|
||||
action: () => this.presentAlertSystemRebuild(),
|
||||
detail: false,
|
||||
disabled$: of(false),
|
||||
},
|
||||
{
|
||||
title: 'Repair Disk',
|
||||
description: '',
|
||||
|
||||
@@ -16,7 +16,7 @@ export module Mock {
|
||||
restarting: false,
|
||||
shuttingDown: false,
|
||||
}
|
||||
export const MarketplaceEos: RR.GetMarketplaceEosRes = {
|
||||
export const MarketplaceEos: RR.CheckOSUpdateRes = {
|
||||
version: '0.3.5.2',
|
||||
headline: 'Our biggest release ever.',
|
||||
releaseNotes: {
|
||||
@@ -493,30 +493,23 @@ export module Mock {
|
||||
{
|
||||
timestamp: '2022-07-28T03:52:54.808769Z',
|
||||
message: '****** START *****',
|
||||
bootId: 'hsjnfdklasndhjasvbjamsksajbndjn',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:21:30.872Z',
|
||||
message:
|
||||
'\u001b[34mPOST \u001b[0;32;49m200\u001b[0m photoview.startos/api/graphql \u001b[0;36;49m1.169406ms\u001b',
|
||||
bootId: 'hsjnfdklasndhjasvbjamsksajbndjn',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:22:30.872Z',
|
||||
message: '****** FINISH *****',
|
||||
},
|
||||
]
|
||||
|
||||
export const PackageLogs: Log[] = [
|
||||
{
|
||||
timestamp: '2022-07-28T03:52:54.808769Z',
|
||||
message: '****** START *****',
|
||||
bootId: 'gvbwfiuasokdasjndasnjdmfvbahjdmdkfm',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:21:30.872Z',
|
||||
message: 'PackageLogs PackageLogs PackageLogs PackageLogs PackageLogs',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:22:30.872Z',
|
||||
message: '****** FINISH *****',
|
||||
timestamp: '2019-12-26T15:22:30.872Z',
|
||||
message: '****** AGAIN *****',
|
||||
bootId: 'gvbwfiuasokdasjndasnjdmfvbahjdmdkfm',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { Dump, Revision } from 'patch-db-client'
|
||||
import { Dump } from 'patch-db-client'
|
||||
import { MarketplacePkg, StoreInfo } from '@start9labs/marketplace'
|
||||
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
|
||||
export module RR {
|
||||
// websocket
|
||||
|
||||
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
|
||||
|
||||
// server state
|
||||
|
||||
export type ServerState = 'initializing' | 'error' | 'running'
|
||||
|
||||
// DB
|
||||
|
||||
export type GetRevisionsRes = Revision[] | Dump<DataModel>
|
||||
|
||||
export type GetDumpRes = Dump<DataModel>
|
||||
export type SubscribePatchReq = {}
|
||||
export type SubscribePatchRes = {
|
||||
dump: Dump<DataModel>
|
||||
guid: string
|
||||
}
|
||||
|
||||
export type SetDBValueReq<T> = { pointer: string; value: T } // db.put.ui
|
||||
export type SetDBValueRes = null
|
||||
@@ -33,10 +44,22 @@ export module RR {
|
||||
} // auth.reset-password
|
||||
export type ResetPasswordRes = null
|
||||
|
||||
// server
|
||||
// diagnostic
|
||||
|
||||
export type EchoReq = { message: string; timeout?: number } // server.echo
|
||||
export type EchoRes = string
|
||||
export type DiagnosticErrorRes = {
|
||||
code: number
|
||||
message: string
|
||||
data: { details: string }
|
||||
}
|
||||
|
||||
// init
|
||||
|
||||
export type InitGetProgressRes = {
|
||||
progress: T.FullProgress
|
||||
guid: string
|
||||
}
|
||||
|
||||
// server
|
||||
|
||||
export type GetSystemTimeReq = {} // server.time
|
||||
export type GetSystemTimeRes = {
|
||||
@@ -65,8 +88,8 @@ export module RR {
|
||||
export type ShutdownServerReq = {} // server.shutdown
|
||||
export type ShutdownServerRes = null
|
||||
|
||||
export type SystemRebuildReq = {} // server.rebuild
|
||||
export type SystemRebuildRes = null
|
||||
export type DiskRepairReq = {} // server.disk.repair
|
||||
export type DiskRepairRes = null
|
||||
|
||||
export type ResetTorReq = {
|
||||
wipeState: boolean
|
||||
@@ -254,8 +277,8 @@ export module RR {
|
||||
export type GetMarketplaceInfoReq = { serverId: string }
|
||||
export type GetMarketplaceInfoRes = StoreInfo
|
||||
|
||||
export type GetMarketplaceEosReq = { serverId: string }
|
||||
export type GetMarketplaceEosRes = MarketplaceEOS
|
||||
export type CheckOSUpdateReq = { serverId: string }
|
||||
export type CheckOSUpdateRes = OSUpdate
|
||||
|
||||
export type GetMarketplacePackagesReq = {
|
||||
ids?: { id: string; version: string }[]
|
||||
@@ -271,7 +294,7 @@ export module RR {
|
||||
export type GetReleaseNotesRes = { [version: string]: string }
|
||||
}
|
||||
|
||||
export interface MarketplaceEOS {
|
||||
export interface OSUpdate {
|
||||
version: string
|
||||
headline: string
|
||||
releaseNotes: { [version: string]: string }
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Observable } from 'rxjs'
|
||||
import { Update } from 'patch-db-client'
|
||||
import { RR } from './api.types'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { Log } from '@start9labs/shared'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
|
||||
export abstract class ApiService {
|
||||
// http
|
||||
@@ -14,8 +10,23 @@ export abstract class ApiService {
|
||||
// for sideloading packages
|
||||
abstract uploadPackage(guid: string, body: Blob): Promise<string>
|
||||
|
||||
// websocket
|
||||
|
||||
abstract openWebsocket$<T>(
|
||||
guid: string,
|
||||
config: RR.WebsocketConfig<T>,
|
||||
): Observable<T>
|
||||
|
||||
// server state
|
||||
|
||||
abstract getState(): Promise<RR.ServerState>
|
||||
|
||||
// db
|
||||
|
||||
abstract subscribeToPatchDB(
|
||||
params: RR.SubscribePatchReq,
|
||||
): Promise<RR.SubscribePatchRes>
|
||||
|
||||
abstract setDbValue<T>(
|
||||
pathArr: Array<string | number>,
|
||||
value: T,
|
||||
@@ -35,16 +46,26 @@ export abstract class ApiService {
|
||||
params: RR.ResetPasswordReq,
|
||||
): Promise<RR.ResetPasswordRes>
|
||||
|
||||
// diagnostic
|
||||
|
||||
abstract diagnosticGetError(): Promise<RR.DiagnosticErrorRes>
|
||||
abstract diagnosticRestart(): Promise<void>
|
||||
abstract diagnosticForgetDrive(): Promise<void>
|
||||
abstract diagnosticRepairDisk(): Promise<void>
|
||||
abstract diagnosticGetLogs(
|
||||
params: RR.GetServerLogsReq,
|
||||
): Promise<RR.GetServerLogsRes>
|
||||
|
||||
// init
|
||||
|
||||
abstract initGetProgress(): Promise<RR.InitGetProgressRes>
|
||||
|
||||
abstract initFollowLogs(
|
||||
params: RR.FollowServerLogsReq,
|
||||
): Promise<RR.FollowServerLogsRes>
|
||||
|
||||
// server
|
||||
|
||||
abstract echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes>
|
||||
|
||||
abstract openPatchWebsocket$(): Observable<Update<DataModel>>
|
||||
|
||||
abstract openLogsWebsocket$(
|
||||
config: WebSocketSubjectConfig<Log>,
|
||||
): Observable<Log>
|
||||
|
||||
abstract getSystemTime(
|
||||
params: RR.GetSystemTimeReq,
|
||||
): Promise<RR.GetSystemTimeRes>
|
||||
@@ -89,11 +110,7 @@ export abstract class ApiService {
|
||||
params: RR.ShutdownServerReq,
|
||||
): Promise<RR.ShutdownServerRes>
|
||||
|
||||
abstract systemRebuild(
|
||||
params: RR.SystemRebuildReq,
|
||||
): Promise<RR.SystemRebuildRes>
|
||||
|
||||
abstract repairDisk(params: RR.SystemRebuildReq): Promise<RR.SystemRebuildRes>
|
||||
abstract repairDisk(params: RR.DiskRepairReq): Promise<RR.DiskRepairRes>
|
||||
|
||||
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
|
||||
|
||||
@@ -105,7 +122,7 @@ export abstract class ApiService {
|
||||
url: string,
|
||||
): Promise<T>
|
||||
|
||||
abstract getEos(): Promise<RR.GetMarketplaceEosRes>
|
||||
abstract checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes>
|
||||
|
||||
// notification
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
HttpOptions,
|
||||
HttpService,
|
||||
isRpcError,
|
||||
Log,
|
||||
Method,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
@@ -12,13 +11,12 @@ import { ApiService } from './embassy-api.service'
|
||||
import { RR } from './api.types'
|
||||
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
|
||||
import { ConfigService } from '../config.service'
|
||||
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { webSocket } from 'rxjs/webSocket'
|
||||
import { Observable, filter, firstValueFrom } from 'rxjs'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { DataModel } from '../patch-db/data-model'
|
||||
import { PatchDB, pathFromArray, Update } from 'patch-db-client'
|
||||
import { getServerInfo } from 'src/app/util/get-server-info'
|
||||
import { PatchDB, pathFromArray } from 'patch-db-client'
|
||||
|
||||
@Injectable()
|
||||
export class LiveApiService extends ApiService {
|
||||
@@ -30,10 +28,11 @@ export class LiveApiService extends ApiService {
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {
|
||||
super()
|
||||
;(window as any).rpcClient = this
|
||||
; (window as any).rpcClient = this
|
||||
}
|
||||
|
||||
// for getting static files: ex icons, instructions, licenses
|
||||
|
||||
async getStatic(url: string): Promise<string> {
|
||||
return this.httpRequest({
|
||||
method: Method.GET,
|
||||
@@ -43,6 +42,7 @@ export class LiveApiService extends ApiService {
|
||||
}
|
||||
|
||||
// for sideloading packages
|
||||
|
||||
async uploadPackage(guid: string, body: Blob): Promise<string> {
|
||||
return this.httpRequest({
|
||||
method: Method.POST,
|
||||
@@ -52,8 +52,36 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
// websocket
|
||||
|
||||
openWebsocket$<T>(
|
||||
guid: string,
|
||||
config: RR.WebsocketConfig<T>,
|
||||
): Observable<T> {
|
||||
const { location } = this.document.defaultView!
|
||||
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
|
||||
const host = location.host
|
||||
|
||||
return webSocket({
|
||||
url: `${protocol}://${host}/ws/rpc/${guid}`,
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
// state
|
||||
|
||||
async getState(): Promise<RR.ServerState> {
|
||||
return this.rpcRequest({ method: 'state', params: {} })
|
||||
}
|
||||
|
||||
// db
|
||||
|
||||
async subscribeToPatchDB(
|
||||
params: RR.SubscribePatchReq,
|
||||
): Promise<RR.SubscribePatchRes> {
|
||||
return this.rpcRequest({ method: 'db.subscribe', params })
|
||||
}
|
||||
|
||||
async setDbValue<T>(
|
||||
pathArr: Array<string | number>,
|
||||
value: T,
|
||||
@@ -87,29 +115,57 @@ export class LiveApiService extends ApiService {
|
||||
return this.rpcRequest({ method: 'auth.reset-password', params })
|
||||
}
|
||||
|
||||
// diagnostic
|
||||
|
||||
async diagnosticGetError(): Promise<RR.DiagnosticErrorRes> {
|
||||
return this.rpcRequest<RR.DiagnosticErrorRes>({
|
||||
method: 'diagnostic.error',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async diagnosticRestart(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async diagnosticForgetDrive(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.forget',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async diagnosticRepairDisk(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.repair',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async diagnosticGetLogs(
|
||||
params: RR.GetServerLogsReq,
|
||||
): Promise<RR.GetServerLogsRes> {
|
||||
return this.rpcRequest<RR.GetServerLogsRes>({
|
||||
method: 'diagnostic.logs',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// init
|
||||
|
||||
async initGetProgress(): Promise<RR.InitGetProgressRes> {
|
||||
return this.rpcRequest({ method: 'init.subscribe', params: {} })
|
||||
}
|
||||
|
||||
async initFollowLogs(): Promise<RR.FollowServerLogsRes> {
|
||||
return this.rpcRequest({ method: 'init.logs.follow', params: {} })
|
||||
}
|
||||
|
||||
// server
|
||||
|
||||
async echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes> {
|
||||
return this.rpcRequest({ method: 'echo', params }, urlOverride)
|
||||
}
|
||||
|
||||
openPatchWebsocket$(): Observable<Update<DataModel>> {
|
||||
const config: WebSocketSubjectConfig<Update<DataModel>> = {
|
||||
url: `/db`,
|
||||
closeObserver: {
|
||||
next: val => {
|
||||
if (val.reason === 'UNAUTHORIZED') this.auth.setUnverified()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return this.openWebsocket(config)
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return this.openWebsocket(config)
|
||||
}
|
||||
|
||||
async getSystemTime(
|
||||
params: RR.GetSystemTimeReq,
|
||||
): Promise<RR.GetSystemTimeRes> {
|
||||
@@ -175,12 +231,6 @@ export class LiveApiService extends ApiService {
|
||||
return this.rpcRequest({ method: 'server.shutdown', params })
|
||||
}
|
||||
|
||||
async systemRebuild(
|
||||
params: RR.RestartServerReq,
|
||||
): Promise<RR.RestartServerRes> {
|
||||
return this.rpcRequest({ method: 'server.rebuild', params })
|
||||
}
|
||||
|
||||
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
|
||||
return this.rpcRequest({ method: 'disk.repair', params })
|
||||
}
|
||||
@@ -203,10 +253,7 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async getEos(): Promise<RR.GetMarketplaceEosRes> {
|
||||
const { id } = await getServerInfo(this.patch)
|
||||
const qp: RR.GetMarketplaceEosReq = { serverId: id }
|
||||
|
||||
async checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes> {
|
||||
return this.marketplaceProxy(
|
||||
'/eos/v0/latest',
|
||||
qp,
|
||||
@@ -417,16 +464,6 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
private openWebsocket<T>(config: WebSocketSubjectConfig<T>): Observable<T> {
|
||||
const { location } = this.document.defaultView!
|
||||
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
|
||||
const host = location.host
|
||||
|
||||
config.url = `${protocol}://${host}/ws${config.url}`
|
||||
|
||||
return webSocket(config)
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(
|
||||
options: RPCOptions,
|
||||
urlOverride?: string,
|
||||
@@ -445,9 +482,7 @@ export class LiveApiService extends ApiService {
|
||||
const patchSequence = res.headers.get('x-patch-sequence')
|
||||
if (patchSequence)
|
||||
await firstValueFrom(
|
||||
this.patch.cache$.pipe(
|
||||
filter(({ sequence }) => sequence >= Number(patchSequence)),
|
||||
),
|
||||
this.patch.cache$.pipe(filter(({ id }) => id >= Number(patchSequence))),
|
||||
)
|
||||
|
||||
return body.result
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Log, pauseFor } from '@start9labs/shared'
|
||||
import { Log, RPCErrorDetails, pauseFor } from '@start9labs/shared'
|
||||
import { ApiService } from './embassy-api.service'
|
||||
import {
|
||||
Operation,
|
||||
PatchOp,
|
||||
pathFromArray,
|
||||
RemoveOperation,
|
||||
Update,
|
||||
Revision,
|
||||
} from 'patch-db-client'
|
||||
import {
|
||||
DataModel,
|
||||
InstallingState,
|
||||
PackageDataEntry,
|
||||
StateInfo,
|
||||
@@ -20,22 +19,17 @@ import { parsePropertiesPermissive } from 'src/app/util/properties.util'
|
||||
import { Mock } from './api.fixures'
|
||||
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
|
||||
import {
|
||||
EMPTY,
|
||||
iif,
|
||||
from,
|
||||
interval,
|
||||
map,
|
||||
Observable,
|
||||
shareReplay,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
timer,
|
||||
} from 'rxjs'
|
||||
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
|
||||
import { mockPatchData } from './mock-patch'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { ConnectionService } from '../connection.service'
|
||||
import { StoreInfo } from '@start9labs/marketplace'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@@ -71,32 +65,17 @@ const PROGRESS: T.FullProgress = {
|
||||
|
||||
@Injectable()
|
||||
export class MockApiService extends ApiService {
|
||||
readonly mockWsSource$ = new Subject<Update<DataModel>>()
|
||||
readonly mockWsSource$ = new Subject<Revision>()
|
||||
private readonly revertTime = 1800
|
||||
sequence = 0
|
||||
|
||||
constructor(
|
||||
private readonly bootstrapper: LocalStorageBootstrap,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly auth: AuthService,
|
||||
) {
|
||||
constructor(private readonly auth: AuthService) {
|
||||
super()
|
||||
this.auth.isVerified$
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.sequence = 0
|
||||
}),
|
||||
switchMap(verified =>
|
||||
iif(
|
||||
() => verified,
|
||||
timer(2000).pipe(
|
||||
tap(() => {
|
||||
this.connectionService.websocketConnected$.next(true)
|
||||
}),
|
||||
),
|
||||
EMPTY,
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
@@ -111,8 +90,57 @@ export class MockApiService extends ApiService {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// websocket
|
||||
|
||||
openWebsocket$<T>(
|
||||
guid: string,
|
||||
config: RR.WebsocketConfig<T>,
|
||||
): Observable<T> {
|
||||
if (guid === 'db-guid') {
|
||||
return this.mockWsSource$.pipe<any>(
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
)
|
||||
} else if (guid === 'logs-guid') {
|
||||
return interval(50).pipe<any>(
|
||||
map((_, index) => {
|
||||
// mock fire open observer
|
||||
if (index === 0) config.openObserver?.next(new Event(''))
|
||||
if (index === 100) throw new Error('HAAHHA')
|
||||
return Mock.ServerLogs[0]
|
||||
}),
|
||||
)
|
||||
} else if (guid === 'init-progress-guid') {
|
||||
return from(this.initProgress()).pipe(
|
||||
startWith(PROGRESS),
|
||||
) as Observable<T>
|
||||
} else {
|
||||
throw new Error('invalid guid type')
|
||||
}
|
||||
}
|
||||
|
||||
// server state
|
||||
|
||||
private stateIndex = 0
|
||||
async getState(): Promise<RR.ServerState> {
|
||||
await pauseFor(1000)
|
||||
|
||||
this.stateIndex++
|
||||
|
||||
return this.stateIndex === 1 ? 'initializing' : 'running'
|
||||
}
|
||||
|
||||
// db
|
||||
|
||||
async subscribeToPatchDB(
|
||||
params: RR.SubscribePatchReq,
|
||||
): Promise<RR.SubscribePatchRes> {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
dump: { id: 1, value: mockPatchData },
|
||||
guid: 'db-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async setDbValue<T>(
|
||||
pathArr: Array<string | number>,
|
||||
value: T,
|
||||
@@ -136,11 +164,6 @@ export class MockApiService extends ApiService {
|
||||
|
||||
async login(params: RR.LoginReq): Promise<RR.loginRes> {
|
||||
await pauseFor(2000)
|
||||
|
||||
setTimeout(() => {
|
||||
this.mockWsSource$.next({ id: 1, value: mockPatchData })
|
||||
}, 2000)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -166,34 +189,63 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
// server
|
||||
// diagnostic
|
||||
|
||||
async echo(params: RR.EchoReq, url?: string): Promise<RR.EchoRes> {
|
||||
if (url) {
|
||||
const num = Math.floor(Math.random() * 10) + 1
|
||||
if (num > 8) return params.message
|
||||
throw new Error()
|
||||
async getError(): Promise<RPCErrorDetails> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
code: 15,
|
||||
message: 'Unknown server',
|
||||
data: { details: 'Some details about the error here' },
|
||||
}
|
||||
}
|
||||
|
||||
async diagnosticGetError(): Promise<RR.DiagnosticErrorRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
code: 15,
|
||||
message: 'Unknown server',
|
||||
data: { details: 'Some details about the error here' },
|
||||
}
|
||||
}
|
||||
|
||||
async diagnosticRestart(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async diagnosticForgetDrive(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async diagnosticRepairDisk(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async diagnosticGetLogs(
|
||||
params: RR.GetServerLogsReq,
|
||||
): Promise<RR.GetServerLogsRes> {
|
||||
return this.getServerLogs(params)
|
||||
}
|
||||
|
||||
// init
|
||||
|
||||
async initGetProgress(): Promise<RR.InitGetProgressRes> {
|
||||
await pauseFor(250)
|
||||
return {
|
||||
progress: PROGRESS,
|
||||
guid: 'init-progress-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async initFollowLogs(): Promise<RR.FollowServerLogsRes> {
|
||||
await pauseFor(2000)
|
||||
return params.message
|
||||
return {
|
||||
startCursor: 'start-cursor',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
openPatchWebsocket$(): Observable<Update<DataModel>> {
|
||||
return this.mockWsSource$.pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
)
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return interval(50).pipe(
|
||||
map((_, index) => {
|
||||
// mock fire open observer
|
||||
if (index === 0) config.openObserver?.next(new Event(''))
|
||||
if (index === 100) throw new Error('HAAHHA')
|
||||
return Mock.ServerLogs[0]
|
||||
}),
|
||||
)
|
||||
}
|
||||
// server
|
||||
|
||||
async getSystemTime(
|
||||
params: RR.GetSystemTimeReq,
|
||||
@@ -248,7 +300,7 @@ export class MockApiService extends ApiService {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
startCursor: 'start-cursor',
|
||||
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +310,7 @@ export class MockApiService extends ApiService {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
startCursor: 'start-cursor',
|
||||
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +320,11 @@ export class MockApiService extends ApiService {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
startCursor: 'start-cursor',
|
||||
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
randomLogs(limit = 1): Log[] {
|
||||
private randomLogs(limit = 1): Log[] {
|
||||
const arrLength = Math.ceil(limit / Mock.ServerLogs.length)
|
||||
const logs = new Array(arrLength)
|
||||
.fill(Mock.ServerLogs)
|
||||
@@ -374,12 +426,6 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async systemRebuild(
|
||||
params: RR.SystemRebuildReq,
|
||||
): Promise<RR.SystemRebuildRes> {
|
||||
return this.restartServer(params)
|
||||
}
|
||||
|
||||
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
|
||||
await pauseFor(2000)
|
||||
return null
|
||||
@@ -422,7 +468,7 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async getEos(): Promise<RR.GetMarketplaceEosRes> {
|
||||
async checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes> {
|
||||
await pauseFor(2000)
|
||||
return Mock.MarketplaceEos
|
||||
}
|
||||
@@ -641,13 +687,13 @@ export class MockApiService extends ApiService {
|
||||
await pauseFor(2000)
|
||||
let entries
|
||||
if (Math.random() < 0.2) {
|
||||
entries = Mock.PackageLogs
|
||||
entries = Mock.ServerLogs
|
||||
} else {
|
||||
const arrLength = params.limit
|
||||
? Math.ceil(params.limit / Mock.PackageLogs.length)
|
||||
? Math.ceil(params.limit / Mock.ServerLogs.length)
|
||||
: 10
|
||||
entries = new Array(arrLength)
|
||||
.fill(Mock.PackageLogs)
|
||||
.fill(Mock.ServerLogs)
|
||||
.reduce((acc, val) => acc.concat(val), [])
|
||||
}
|
||||
return {
|
||||
@@ -663,7 +709,7 @@ export class MockApiService extends ApiService {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
startCursor: 'start-cursor',
|
||||
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,7 +719,7 @@ export class MockApiService extends ApiService {
|
||||
await pauseFor(2000)
|
||||
|
||||
setTimeout(async () => {
|
||||
this.updateProgress(params.id)
|
||||
this.installProgress(params.id)
|
||||
}, 1000)
|
||||
|
||||
const patch: Operation<
|
||||
@@ -745,7 +791,7 @@ export class MockApiService extends ApiService {
|
||||
await pauseFor(2000)
|
||||
const patch: Operation<PackageDataEntry>[] = params.ids.map(id => {
|
||||
setTimeout(async () => {
|
||||
this.updateProgress(id)
|
||||
this.installProgress(id)
|
||||
}, 2000)
|
||||
|
||||
return {
|
||||
@@ -1013,7 +1059,57 @@ export class MockApiService extends ApiService {
|
||||
return '4120e092-05ab-4de2-9fbd-c3f1f4b1df9e' // no significance, randomly generated
|
||||
}
|
||||
|
||||
private async updateProgress(id: string): Promise<void> {
|
||||
private async initProgress(): Promise<T.FullProgress> {
|
||||
const progress = JSON.parse(JSON.stringify(PROGRESS))
|
||||
|
||||
for (let [i, phase] of progress.phases.entries()) {
|
||||
if (
|
||||
!phase.progress ||
|
||||
typeof phase.progress !== 'object' ||
|
||||
!phase.progress.total
|
||||
) {
|
||||
await pauseFor(2000)
|
||||
|
||||
progress.phases[i].progress = true
|
||||
|
||||
if (
|
||||
progress.overall &&
|
||||
typeof progress.overall === 'object' &&
|
||||
progress.overall.total
|
||||
) {
|
||||
const step = progress.overall.total / progress.phases.length
|
||||
progress.overall.done += step
|
||||
}
|
||||
} else {
|
||||
const step = phase.progress.total / 4
|
||||
|
||||
while (phase.progress.done < phase.progress.total) {
|
||||
await pauseFor(200)
|
||||
|
||||
phase.progress.done += step
|
||||
|
||||
if (
|
||||
progress.overall &&
|
||||
typeof progress.overall === 'object' &&
|
||||
progress.overall.total
|
||||
) {
|
||||
const step = progress.overall.total / progress.phases.length / 4
|
||||
|
||||
progress.overall.done += step
|
||||
}
|
||||
|
||||
if (phase.progress.done === phase.progress.total) {
|
||||
await pauseFor(250)
|
||||
|
||||
progress.phases[i].progress = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
private async installProgress(id: string): Promise<void> {
|
||||
const progress = JSON.parse(JSON.stringify(PROGRESS))
|
||||
|
||||
for (let [i, phase] of progress.phases.entries()) {
|
||||
@@ -1194,10 +1290,6 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
|
||||
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
|
||||
if (!this.sequence) {
|
||||
const { sequence } = this.bootstrapper.init()
|
||||
this.sequence = sequence
|
||||
}
|
||||
const revision = {
|
||||
id: ++this.sequence,
|
||||
patch,
|
||||
|
||||
@@ -12,7 +12,7 @@ export enum AuthState {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly LOGGED_IN_KEY = 'loggedInKey'
|
||||
private readonly LOGGED_IN_KEY = 'loggedIn'
|
||||
private readonly authState$ = new ReplaySubject<AuthState>(1)
|
||||
|
||||
readonly isVerified$ = this.authState$.pipe(
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { combineLatest, fromEvent, merge, ReplaySubject } from 'rxjs'
|
||||
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { combineLatest, Observable, shareReplay } from 'rxjs'
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators'
|
||||
import { NetworkService } from 'src/app/services/network.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConnectionService {
|
||||
readonly networkConnected$ = merge(
|
||||
fromEvent(window, 'online'),
|
||||
fromEvent(window, 'offline'),
|
||||
).pipe(
|
||||
startWith(null),
|
||||
map(() => navigator.onLine),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
readonly websocketConnected$ = new ReplaySubject<boolean>(1)
|
||||
readonly connected$ = combineLatest([
|
||||
this.networkConnected$,
|
||||
this.websocketConnected$.pipe(distinctUntilChanged()),
|
||||
export class ConnectionService extends Observable<boolean> {
|
||||
private readonly stream$ = combineLatest([
|
||||
inject(NetworkService),
|
||||
inject(StateService).pipe(map(Boolean)),
|
||||
]).pipe(
|
||||
map(([network, websocket]) => network && websocket),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { BehaviorSubject, combineLatest } from 'rxjs'
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators'
|
||||
import { MarketplaceEOS } from 'src/app/services/api/api.types'
|
||||
import { OSUpdate } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getServerInfo } from 'src/app/util/get-server-info'
|
||||
@@ -12,7 +12,7 @@ import { DataModel } from './patch-db/data-model'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EOSService {
|
||||
eos?: MarketplaceEOS
|
||||
osUpdate?: OSUpdate
|
||||
updateAvailable$ = new BehaviorSubject<boolean>(false)
|
||||
|
||||
readonly updating$ = this.patch.watch$('serverInfo', 'statusInfo').pipe(
|
||||
@@ -52,9 +52,10 @@ export class EOSService {
|
||||
) {}
|
||||
|
||||
async loadEos(): Promise<void> {
|
||||
const { version } = await getServerInfo(this.patch)
|
||||
this.eos = await this.api.getEos()
|
||||
const updateAvailable = this.emver.compare(this.eos.version, version) === 1
|
||||
const { version, id } = await getServerInfo(this.patch)
|
||||
this.osUpdate = await this.api.checkOSUpdate({ serverId: id })
|
||||
const updateAvailable =
|
||||
this.emver.compare(this.osUpdate.version, version) === 1
|
||||
this.updateAvailable$.next(updateAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
22
web/projects/ui/src/app/services/network.service.ts
Normal file
22
web/projects/ui/src/app/services/network.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { fromEvent, merge, Observable, shareReplay } from 'rxjs'
|
||||
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NetworkService extends Observable<boolean> {
|
||||
private readonly win = inject(WINDOW)
|
||||
private readonly stream$ = merge(
|
||||
fromEvent(this.win, 'online'),
|
||||
fromEvent(this.win, 'offline'),
|
||||
).pipe(
|
||||
startWith(null),
|
||||
map(() => this.win.navigator.onLine),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { Observable } from 'rxjs'
|
||||
import { filter, share, switchMap, take, tap } from 'rxjs/operators'
|
||||
import { filter, map, share, switchMap, take, tap } from 'rxjs/operators'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel, UIData } from 'src/app/services/patch-db/data-model'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
import { OSWelcomePage } from 'src/app/modals/os-welcome/os-welcome.page'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
@@ -11,21 +11,25 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
|
||||
|
||||
// Get data from PatchDb after is starts and act upon it
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PatchDataService extends Observable<DataModel> {
|
||||
private readonly stream$ = this.connectionService.connected$.pipe(
|
||||
export class PatchDataService extends Observable<void> {
|
||||
private readonly stream$ = this.connection$.pipe(
|
||||
filter(Boolean),
|
||||
switchMap(() => this.patch.watch$()),
|
||||
take(1),
|
||||
tap(({ ui }) => {
|
||||
// check for updates to eOS and services
|
||||
this.checkForUpdates()
|
||||
// show eos welcome message
|
||||
this.showEosWelcome(ui.ackWelcome)
|
||||
map((cache, index) => {
|
||||
this.bootstrapper.update(cache)
|
||||
|
||||
if (index === 0) {
|
||||
// check for updates to StartOS and services
|
||||
this.checkForUpdates()
|
||||
// show eos welcome message
|
||||
this.showEosWelcome(cache.ui.ackWelcome)
|
||||
}
|
||||
}),
|
||||
share(),
|
||||
)
|
||||
@@ -38,7 +42,8 @@ export class PatchDataService extends Observable<DataModel> {
|
||||
private readonly embassyApi: ApiService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly connection$: ConnectionService,
|
||||
private readonly bootstrapper: LocalStorageBootstrap,
|
||||
) {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bootstrapper, DBCache } from 'patch-db-client'
|
||||
import { Dump } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { StorageService } from '../storage.service'
|
||||
@@ -6,20 +6,18 @@ import { StorageService } from '../storage.service'
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LocalStorageBootstrap implements Bootstrapper<DataModel> {
|
||||
static CONTENT_KEY = 'patch-db-cache'
|
||||
export class LocalStorageBootstrap {
|
||||
static CONTENT_KEY = 'patchDB'
|
||||
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
|
||||
init(): DBCache<DataModel> {
|
||||
const cache = this.storage.get<DBCache<DataModel>>(
|
||||
LocalStorageBootstrap.CONTENT_KEY,
|
||||
)
|
||||
init(): Dump<DataModel> {
|
||||
const cache = this.storage.get<DataModel>(LocalStorageBootstrap.CONTENT_KEY)
|
||||
|
||||
return cache || { sequence: 0, data: {} as DataModel }
|
||||
return cache ? { id: 1, value: cache } : { id: 0, value: {} as DataModel }
|
||||
}
|
||||
|
||||
update(cache: DBCache<DataModel>): void {
|
||||
update(cache: DataModel): void {
|
||||
this.storage.set(LocalStorageBootstrap.CONTENT_KEY, cache)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { InjectionToken, Injector } from '@angular/core'
|
||||
import { Revision, Update } from 'patch-db-client'
|
||||
import { defer, EMPTY, from, Observable } from 'rxjs'
|
||||
import {
|
||||
bufferTime,
|
||||
catchError,
|
||||
filter,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import { Update } from 'patch-db-client'
|
||||
import { DataModel } from './data-model'
|
||||
import { defer, EMPTY, from, interval, Observable } from 'rxjs'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { ConnectionService } from '../connection.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { ApiService } from '../api/embassy-api.service'
|
||||
import { ConfigService } from '../config.service'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { DataModel } from './data-model'
|
||||
import { LocalStorageBootstrap } from './local-storage-bootstrap'
|
||||
|
||||
export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>[]>>(
|
||||
'',
|
||||
@@ -25,33 +25,31 @@ export function sourceFactory(
|
||||
// defer() needed to avoid circular dependency with ApiService, since PatchDB is needed there
|
||||
return defer(() => {
|
||||
const api = injector.get(ApiService)
|
||||
const authService = injector.get(AuthService)
|
||||
const connectionService = injector.get(ConnectionService)
|
||||
const configService = injector.get(ConfigService)
|
||||
const isTor = configService.isTor()
|
||||
const timeout = isTor ? 16000 : 4000
|
||||
const auth = injector.get(AuthService)
|
||||
const state = injector.get(StateService)
|
||||
const bootstrapper = injector.get(LocalStorageBootstrap)
|
||||
|
||||
const websocket$ = api.openPatchWebsocket$().pipe(
|
||||
bufferTime(250),
|
||||
filter(updates => !!updates.length),
|
||||
catchError((_, watch$) => {
|
||||
connectionService.websocketConnected$.next(false)
|
||||
return auth.isVerified$.pipe(
|
||||
switchMap(verified =>
|
||||
verified ? from(api.subscribeToPatchDB({})) : EMPTY,
|
||||
),
|
||||
switchMap(({ dump, guid }) =>
|
||||
api.openWebsocket$<Revision>(guid, {}).pipe(
|
||||
bufferTime(250),
|
||||
filter(revisions => !!revisions.length),
|
||||
startWith([dump]),
|
||||
),
|
||||
),
|
||||
catchError((_, original$) => {
|
||||
state.retrigger()
|
||||
|
||||
return interval(timeout).pipe(
|
||||
switchMap(() =>
|
||||
from(api.echo({ message: 'ping', timeout })).pipe(
|
||||
catchError(() => EMPTY),
|
||||
),
|
||||
),
|
||||
return state.pipe(
|
||||
filter(current => current === 'running'),
|
||||
take(1),
|
||||
switchMap(() => watch$),
|
||||
switchMap(() => original$),
|
||||
)
|
||||
}),
|
||||
tap(() => connectionService.websocketConnected$.next(true)),
|
||||
)
|
||||
|
||||
return authService.isVerified$.pipe(
|
||||
switchMap(verified => (verified ? websocket$ : EMPTY)),
|
||||
startWith([bootstrapper.init()]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,24 +4,19 @@ import { tap } from 'rxjs/operators'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { DataModel } from './patch-db/data-model'
|
||||
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
|
||||
|
||||
// Start and stop PatchDb upon verification
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PatchMonitorService extends Observable<any> {
|
||||
// @TODO not happy with Observable<void>
|
||||
export class PatchMonitorService extends Observable<unknown> {
|
||||
private readonly stream$ = this.authService.isVerified$.pipe(
|
||||
tap(verified =>
|
||||
verified ? this.patch.start(this.bootstrapper) : this.patch.stop(),
|
||||
),
|
||||
tap(verified => (verified ? this.patch.start() : this.patch.stop())),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly bootstrapper: LocalStorageBootstrap,
|
||||
) {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
|
||||
136
web/projects/ui/src/app/services/state.service.ts
Normal file
136
web/projects/ui/src/app/services/state.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { CanActivateFn, IsActiveMatchOptions, Router } from '@angular/router'
|
||||
import { ALWAYS_TRUE_HANDLER } from '@taiga-ui/cdk'
|
||||
import { TuiAlertService, TuiNotification } from '@taiga-ui/core'
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concat,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
from,
|
||||
merge,
|
||||
Observable,
|
||||
startWith,
|
||||
Subject,
|
||||
timer,
|
||||
} from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
filter,
|
||||
map,
|
||||
shareReplay,
|
||||
skip,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { NetworkService } from 'src/app/services/network.service'
|
||||
|
||||
const OPTIONS: IsActiveMatchOptions = {
|
||||
paths: 'subset',
|
||||
queryParams: 'exact',
|
||||
fragment: 'ignored',
|
||||
matrixParams: 'ignored',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateService extends Observable<RR.ServerState | null> {
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly network$ = inject(NetworkService)
|
||||
|
||||
private readonly single$ = new Subject<RR.ServerState>()
|
||||
|
||||
private readonly trigger$ = new BehaviorSubject<void>(undefined)
|
||||
private readonly poll$ = this.trigger$.pipe(
|
||||
switchMap(() =>
|
||||
timer(0, 2000).pipe(
|
||||
switchMap(() =>
|
||||
from(this.api.getState()).pipe(catchError(() => EMPTY)),
|
||||
),
|
||||
take(1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private readonly stream$ = merge(this.single$, this.poll$).pipe(
|
||||
tap(state => {
|
||||
switch (state) {
|
||||
case 'initializing':
|
||||
this.router.navigate(['initializing'], { replaceUrl: true })
|
||||
break
|
||||
case 'error':
|
||||
this.router.navigate(['diagnostic'], { replaceUrl: true })
|
||||
break
|
||||
case 'running':
|
||||
if (
|
||||
this.router.isActive('initializing', OPTIONS) ||
|
||||
this.router.isActive('diagnostic', OPTIONS)
|
||||
) {
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}),
|
||||
startWith(null),
|
||||
shareReplay(1),
|
||||
)
|
||||
|
||||
private readonly alert = merge(
|
||||
this.trigger$.pipe(skip(1)),
|
||||
this.network$.pipe(filter(v => !v)),
|
||||
)
|
||||
.pipe(
|
||||
exhaustMap(() =>
|
||||
concat(
|
||||
this.alerts
|
||||
.open('Trying to reach server', {
|
||||
label: 'State unknown',
|
||||
autoClose: false,
|
||||
status: TuiNotification.Error,
|
||||
})
|
||||
.pipe(
|
||||
takeUntil(
|
||||
combineLatest([this.stream$, this.network$]).pipe(
|
||||
filter(state => state.every(Boolean)),
|
||||
),
|
||||
),
|
||||
),
|
||||
this.alerts.open('Connection restored', {
|
||||
label: 'Server reached',
|
||||
status: TuiNotification.Success,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe() // @TODO shouldn't this be subscribed in app component with the others? Do we ever need to unsubscribe?
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.stream$.subscribe(subscriber))
|
||||
}
|
||||
|
||||
retrigger() {
|
||||
this.trigger$.next()
|
||||
}
|
||||
|
||||
async syncState() {
|
||||
const state = await this.api.getState()
|
||||
this.single$.next(state)
|
||||
}
|
||||
}
|
||||
|
||||
export function stateNot(state: RR.ServerState[]): CanActivateFn {
|
||||
return () =>
|
||||
inject(StateService).pipe(
|
||||
filter(current => !current || !state.includes(current)),
|
||||
map(ALWAYS_TRUE_HANDLER),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
const PREFIX = '_embassystorage/_embassykv/'
|
||||
const PREFIX = '_startos/'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -15,16 +15,21 @@ export class StorageService {
|
||||
return JSON.parse(String(this.storage.getItem(`${PREFIX}${key}`)))
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T) {
|
||||
set(key: string, value: any) {
|
||||
this.storage.setItem(`${PREFIX}${key}`, JSON.stringify(value))
|
||||
}
|
||||
|
||||
clear() {
|
||||
Array.from(
|
||||
{ length: this.storage.length },
|
||||
(_, i) => this.storage.key(i) || '',
|
||||
)
|
||||
.filter(key => key.startsWith(PREFIX))
|
||||
.forEach(key => this.storage.removeItem(key))
|
||||
this.storage.clear()
|
||||
}
|
||||
|
||||
migrate036() {
|
||||
const oldPrefix = '_embassystorage/_embassykv/'
|
||||
if (!!this.storage.getItem(`${oldPrefix}loggedInKey`)) {
|
||||
const cache = this.storage.getItem(`${oldPrefix}patch-db-cache`)
|
||||
this.clear()
|
||||
this.set('loggedIn', true)
|
||||
this.set('patchDB', cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user