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:
Matt Hill
2024-06-19 13:51:44 -06:00
committed by GitHub
parent e92d4ff147
commit da3720c7a9
147 changed files with 3939 additions and 2637 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,5 @@ export class AppListIconComponent {
@Input()
pkg!: PkgInfo
readonly connected$ = this.connectionService.connected$
constructor(private readonly connectionService: ConnectionService) {}
constructor(readonly connection$: ConnectionService) {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
.code-block {
background-color: rgb(69, 69, 69);
padding: 12px;
margin-bottom: 32px;
}

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View 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>&nbsp;&nbsp;${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))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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