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

19
web/package-lock.json generated
View File

@@ -31,6 +31,7 @@
"@taiga-ui/core": "3.20.0",
"@taiga-ui/icons": "3.20.0",
"@taiga-ui/kit": "3.20.0",
"@tinkoff/ng-dompurify": "4.0.0",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
@@ -1973,7 +1974,7 @@
},
"../sdk/dist": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-rev0.lib0.rc8.beta10",
"version": "0.3.6-alpha1",
"license": "MIT",
"dependencies": {
"isomorphic-fetch": "^3.0.0",
@@ -5432,6 +5433,20 @@
"rxjs": ">=6.0.0"
}
},
"node_modules/@tinkoff/ng-dompurify": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@tinkoff/ng-dompurify/-/ng-dompurify-4.0.0.tgz",
"integrity": "sha512-BjKUweWLrOx8UOZw+Tl+Dae5keYuSbeMkppcXQdsvwASMrPfmP7d3Q206Q6HDqOV2WnpnFqGUB95IMbLAeRRuw==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"@angular/core": ">=12.0.0",
"@angular/platform-browser": ">=12.0.0",
"@types/dompurify": ">=2.3.0",
"dompurify": ">= 2.3.0"
}
},
"node_modules/@tinkoff/ng-event-plugins": {
"version": "3.1.0",
"license": "Apache-2.0",
@@ -5549,7 +5564,6 @@
},
"node_modules/@types/dompurify": {
"version": "2.3.4",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
@@ -5726,7 +5740,6 @@
},
"node_modules/@types/trusted-types": {
"version": "2.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/@types/uuid": {

View File

@@ -13,7 +13,6 @@
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
"build:deps": "rm -rf .angular/cache && (cd ../patch-db/client && npm ci && npm run build) && (cd ../sdk && make bundle)",
"build:dui": "ng run diagnostic-ui:build",
"build:install-wiz": "ng run install-wizard:build",
"build:setup": "ng run setup-wizard:build",
"build:ui": "ng run ui:build",
@@ -25,7 +24,6 @@
"analyze:ui": "webpack-bundle-analyzer dist/raw/ui/stats.json",
"publish:shared": "npm run build:shared && npm publish ./dist/shared --access public",
"publish:marketplace": "npm run build:marketplace && npm publish ./dist/marketplace --access public",
"start:dui": "npm run-script build-config && ionic serve --project diagnostic-ui --host 0.0.0.0",
"start:install-wiz": "npm run-script build-config && ionic serve --project install-wizard --host 0.0.0.0",
"start:setup": "npm run-script build-config && ionic serve --project setup-wizard --host 0.0.0.0",
"start:ui": "npm run-script build-config && ionic serve --project ui --ip --host 0.0.0.0",
@@ -56,6 +54,7 @@
"@taiga-ui/core": "3.20.0",
"@taiga-ui/icons": "3.20.0",
"@taiga-ui/kit": "3.20.0",
"@tinkoff/ng-dompurify": "4.0.0",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",

View File

@@ -1,27 +0,0 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
const routes: Routes = [
{
path: '',
loadChildren: () =>
import('./pages/home/home.module').then(m => m.HomePageModule),
},
{
path: 'logs',
loadChildren: () =>
import('./pages/logs/logs.module').then(m => m.LogsPageModule),
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
preloadingStrategy: PreloadAllModules,
useHash: true,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -1,5 +0,0 @@
<tui-root>
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
</tui-root>

View File

@@ -1,8 +0,0 @@
:host {
display: block;
height: 100%;
}
tui-root {
height: 100%;
}

View File

@@ -1,10 +0,0 @@
import { Component } from '@angular/core'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
export class AppComponent {
constructor() {}
}

View File

@@ -1,43 +0,0 @@
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
import { TuiRootModule } from '@taiga-ui/core'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
@NgModule({
declarations: [AppComponent],
imports: [
HttpClientModule,
BrowserAnimationsModule,
IonicModule.forRoot({
mode: 'md',
}),
AppRoutingModule,
TuiRootModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

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

View File

@@ -1,16 +0,0 @@
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
export abstract class ApiService {
abstract getError(): Promise<GetErrorRes>
abstract restart(): Promise<void>
abstract forgetDrive(): Promise<void>
abstract repairDisk(): Promise<void>
abstract systemRebuild(): Promise<void>
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
}
export interface GetErrorRes {
code: number
message: string
data: { details: string }
}

View File

@@ -1,68 +0,0 @@
import { Injectable } from '@angular/core'
import {
HttpService,
isRpcError,
RpcError,
RPCOptions,
} from '@start9labs/shared'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
@Injectable()
export class LiveApiService implements ApiService {
constructor(private readonly http: HttpService) {}
async getError(): Promise<GetErrorRes> {
return this.rpcRequest<GetErrorRes>({
method: 'diagnostic.error',
params: {},
})
}
async restart(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.restart',
params: {},
})
}
async forgetDrive(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.forget',
params: {},
})
}
async repairDisk(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.repair',
params: {},
})
}
async systemRebuild(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.rebuild',
params: {},
})
}
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
return this.rpcRequest<LogsRes>({
method: 'diagnostic.logs',
params,
})
}
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
const res = await this.http.rpcRequest<T>(opts)
const rpcRes = res.body
if (isRpcError(rpcRes)) {
throw new RpcError(rpcRes.error)
}
return rpcRes.result
}
}

View File

@@ -1,67 +0,0 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared'
@Injectable()
export class MockApiService implements ApiService {
async getError(): Promise<GetErrorRes> {
await pauseFor(1000)
return {
code: 15,
message: 'Unknown server',
data: { details: 'Some details about the error here' },
}
}
async restart(): Promise<void> {
await pauseFor(1000)
}
async forgetDrive(): Promise<void> {
await pauseFor(1000)
}
async repairDisk(): Promise<void> {
await pauseFor(1000)
}
async systemRebuild(): Promise<void> {
await pauseFor(1000)
}
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
await pauseFor(1000)
let entries: Log[]
if (Math.random() < 0.2) {
entries = packageLogs
} else {
const arrLength = params.limit
? Math.ceil(params.limit / packageLogs.length)
: 10
entries = new Array(arrLength)
.fill(packageLogs)
.reduce((acc, val) => acc.concat(val), [])
}
return {
entries,
startCursor: 'start-cursor',
endCursor: 'end-cursor',
}
}
}
const packageLogs = [
{
timestamp: '2019-12-26T14:20:30.872Z',
message: '****** START *****',
},
{
timestamp: '2019-12-26T14:21:30.872Z',
message: 'ServerLogs ServerLogs ServerLogs ServerLogs ServerLogs',
},
{
timestamp: '2019-12-26T14:22:30.872Z',
message: '****** FINISH *****',
},
]

View File

@@ -1,3 +0,0 @@
export const environment = {
production: true,
}

View File

@@ -1,16 +0,0 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
}
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

View File

@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>StartOS Diagnostic UI</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="icon" type="image/png" href="assets/icon/favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,12 +0,0 @@
import { enableProdMode } from '@angular/core'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
import { AppModule } from './app/app.module'
import { environment } from './environments/environment'
if (environment.production) {
enableProdMode()
}
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err))

View File

@@ -1,64 +0,0 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
import './zone-flags'
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone' // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@@ -1,41 +0,0 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
}
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Montserrat';
--ion-color-primary: #0075e1;
--ion-color-medium: #989aa2;
--ion-color-medium-rgb: 152,154,162;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0,0,0;
--ion-color-medium-shade: #86888f;
--ion-color-medium-tint: #a2a4ab;
--ion-color-light: #222428;
--ion-color-light-rgb: 34,36,40;
--ion-color-light-contrast: #ffffff;
--ion-color-light-contrast-rgb: 255,255,255;
--ion-color-light-shade: #1e2023;
--ion-color-light-tint: #383a3e;
--ion-item-background: #2b2b2b;
--ion-toolbar-background: #2b2b2b;
--ion-card-background: #2b2b2b;
--ion-background-color: #282828;
--ion-background-color-rgb: 30,30,30;
--ion-text-color: var(--ion-color-dark);
--ion-text-color-rgb: var(--ion-color-dark-rgb);
}
.loader {
--spinner-color: var(--ion-color-warning) !important;
z-index: 40000 !important;
}

View File

@@ -1,6 +0,0 @@
/**
* Prevents Angular change detection from
* running with certain Web Component callbacks
*/
// eslint-disable-next-line no-underscore-dangle
(window as any).__Zone_disable_customElements = true

View File

@@ -1,9 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./"
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@@ -21,7 +21,7 @@ export class AppComponent {
let route = '/home'
if (inProgress) {
route = inProgress.complete ? '/success' : '/loading'
route = inProgress.status === 'complete' ? '/success' : '/loading'
}
await this.navCtrl.navigateForward(route)

View File

@@ -5,12 +5,7 @@ import {
ModalController,
NavController,
} from '@ionic/angular'
import {
ApiService,
BackupRecoverySource,
DiskRecoverySource,
DiskMigrateSource,
} from 'src/app/services/api/api.service'
import { ApiService } from 'src/app/services/api/api.service'
import { DiskInfo, ErrorToastService, GuidPipe } from '@start9labs/shared'
import { StateService } from 'src/app/services/state.service'
import { PasswordPage } from '../../modals/password/password.page'
@@ -58,18 +53,17 @@ export class EmbassyPage {
} else if (this.stateService.setupType === 'restore') {
this.storageDrives = disks.filter(
d =>
this.stateService.recoverySource?.type === 'backup' &&
this.stateService.recoverySource.target?.type === 'disk' &&
!d.partitions
.map(p => p.logicalname)
.includes(
(
(this.stateService.recoverySource as BackupRecoverySource)
?.target as DiskRecoverySource
)?.logicalname,
),
.includes(this.stateService.recoverySource.target.logicalname),
)
} else if (this.stateService.setupType === 'transfer') {
const guid = (this.stateService.recoverySource as DiskMigrateSource)
.guid
} else if (
this.stateService.setupType === 'transfer' &&
this.stateService.recoverySource?.type === 'migrate'
) {
const guid = this.stateService.recoverySource.guid
this.storageDrives = disks.filter(d => {
return (
d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid)

View File

@@ -2,11 +2,11 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { LoadingPage, ToMessagePipe } from './loading.page'
import { LoadingPage } from './loading.page'
import { LoadingPageRoutingModule } from './loading-routing.module'
@NgModule({
imports: [CommonModule, FormsModule, IonicModule, LoadingPageRoutingModule],
declarations: [LoadingPage, ToMessagePipe],
declarations: [LoadingPage],
})
export class LoadingPageModule {}

View File

@@ -1,39 +1,17 @@
<ion-content>
<ion-grid>
<ion-row class="ion-align-items-center">
<ion-col class="ion-text-center">
<ion-card *ngIf="progress$ | async as progress" color="dark">
<ion-card-header>
<ion-card-title>Initializing StartOS</ion-card-title>
<div class="center-wrapper">
<ion-card-subtitle>
{{ progress.transferred | toMessage }}
</ion-card-subtitle>
</div>
</ion-card-header>
<section *ngIf="progress$ | async as progress">
<h1 [style.font-size.rem]="2.5" [style.margin.rem]="1">
Setting up your server
</h1>
<div class="center-wrapper" *ngIf="progress.total">
Progress: {{ (progress.total * 100).toFixed(0) }}%
</div>
<ion-card-content class="ion-margin">
<ion-progress-bar
color="tertiary"
style="max-width: 700px; margin: auto; margin-bottom: 36px"
[type]="progress.transferred && progress.transferred < 1 ? 'determinate' : 'indeterminate'"
[value]="progress.transferred || 0"
></ion-progress-bar>
<p>
<ng-container *ngIf="progress.totalBytes as total">
<ng-container
*ngIf="progress.transferred as transferred; else calculating"
>
Progress: {{ (transferred * 100).toFixed() }}%
</ng-container>
<ng-template #calculating>
{{ (progress.totalBytes / 1073741824).toFixed(2) }} GB
</ng-template>
</ng-container>
</p>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>
<progress
tuiProgressBar
class="progress"
[style.max-width.rem]="40"
[style.margin]="'1rem auto'"
[attr.value]="progress.total"
></progress>
<p>{{ progress.message }}</p>
</section>

View File

@@ -1,3 +0,0 @@
ion-card-title {
font-size: 42px;
}

View File

@@ -1,15 +1,23 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { StateService } from 'src/app/services/state.service'
import { Pipe, PipeTransform } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import {
EMPTY,
Observable,
catchError,
filter,
from,
interval,
map,
of,
startWith,
switchMap,
take,
tap,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { ErrorToastService, pauseFor } from '@start9labs/shared'
type Progress = {
totalBytes: number | null
transferred: number
}
import { ErrorToastService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@Component({
selector: 'app-loading',
@@ -17,10 +25,46 @@ type Progress = {
styleUrls: ['loading.page.scss'],
})
export class LoadingPage {
readonly progress$ = new BehaviorSubject<Progress>({
totalBytes: null,
transferred: 0,
})
readonly progress$ = this.getRunningStatus$().pipe(
switchMap(res =>
this.api.openProgressWebsocket$(res.guid).pipe(
startWith(res.progress),
catchError((_, watch$) => {
return interval(2000).pipe(
switchMap(() =>
from(this.api.getStatus()).pipe(catchError(() => EMPTY)),
),
take(1),
switchMap(() => watch$),
)
}),
tap(progress => {
if (progress.overall === true) {
this.getStatus()
}
}),
),
),
map(({ phases, overall }) => {
return {
total: getDecimal(overall),
message: phases
.filter(
(
p,
): p is {
name: string
progress: {
done: number
total: number | null
}
} => p.progress !== true && p.progress !== null,
)
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
.join(','),
}
}),
)
constructor(
private readonly navCtrl: NavController,
@@ -28,55 +72,55 @@ export class LoadingPage {
private readonly errorToastService: ErrorToastService,
) {}
ngOnInit() {
this.poll()
}
private async getStatus(): Promise<{
status: 'running'
guid: string
progress: T.FullProgress
} | void> {
const res = await this.api.getStatus()
async poll() {
try {
const progress = await this.api.getStatus()
if (!progress) return
const { totalBytes, bytesTransferred } = progress
this.progress$.next({
totalBytes,
transferred: totalBytes ? bytesTransferred / totalBytes : 0,
})
if (progress.complete) {
this.navCtrl.navigateForward(`/success`)
this.progress$.complete()
return
}
await pauseFor(250)
setTimeout(() => this.poll(), 0) // prevent call stack from growing
} catch (e: any) {
this.errorToastService.present(e)
}
}
}
@Pipe({
name: 'toMessage',
})
export class ToMessagePipe implements PipeTransform {
constructor(private readonly stateService: StateService) {}
transform(progress: number | null): string {
if (['fresh', 'attach'].includes(this.stateService.setupType || '')) {
return 'Setting up your server'
}
if (!progress) {
return 'Calculating size'
} else if (progress < 1) {
return 'Copying data'
if (!res) {
this.navCtrl.navigateRoot('/home')
} else if (res.status === 'complete') {
this.navCtrl.navigateForward(`/success`)
} else {
return 'Finalizing'
return res
}
}
private getRunningStatus$(): Observable<{
status: 'running'
guid: string
progress: T.FullProgress
}> {
return from(this.getStatus()).pipe(
filter(Boolean),
catchError(e => {
this.errorToastService.present(e)
return of(e)
}),
take(1),
)
}
}
function getDecimal(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

@@ -1,16 +1,21 @@
import * as jose from 'node-jose'
import { DiskListResponse, StartOSDiskInfo } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
export abstract class ApiService {
pubkey?: jose.JWK.Key
abstract getStatus(): Promise<StatusRes> // setup.status
abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
abstract verifyCifs(cifs: CifsRecoverySource): Promise<StartOSDiskInfo> // setup.cifs.verify
abstract attach(importInfo: AttachReq): Promise<void> // setup.attach
abstract execute(setupInfo: ExecuteReq): Promise<void> // setup.execute
abstract complete(): Promise<CompleteRes> // setup.complete
abstract verifyCifs(cifs: T.VerifyCifsParams): Promise<StartOSDiskInfo> // setup.cifs.verify
abstract attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract openProgressWebsocket$(guid: string): Observable<T.FullProgress>
async encrypt(toEncrypt: string): Promise<Encrypted> {
if (!this.pubkey) throw new Error('No pubkey found!')
@@ -27,29 +32,7 @@ type Encrypted = {
encrypted: string
}
export type StatusRes = {
bytesTransferred: number
totalBytes: number | null
complete: boolean
} | null
export type AttachReq = {
guid: string
startOsPassword: Encrypted
}
export type ExecuteReq = {
startOsLogicalname: string
startOsPassword: Encrypted
recoverySource: RecoverySource | null
recoveryPassword: Encrypted | null
}
export type CompleteRes = {
torAddress: string
lanAddress: string
rootCa: string
}
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
export type DiskBackupTarget = {
vendor: string | null
@@ -68,27 +51,3 @@ export type CifsBackupTarget = {
mountable: boolean
startOs: StartOSDiskInfo | null
}
export type DiskRecoverySource = {
type: 'disk'
logicalname: string // partition logicalname
}
export type BackupRecoverySource = {
type: 'backup'
target: CifsRecoverySource | DiskRecoverySource
}
export type RecoverySource = BackupRecoverySource | DiskMigrateSource
export type DiskMigrateSource = {
type: 'migrate'
guid: string
}
export type CifsRecoverySource = {
type: 'cifs'
hostname: string
path: string
username: string
password: Encrypted | null
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'
import { Inject, Injectable } from '@angular/core'
import {
DiskListResponse,
StartOSDiskInfo,
@@ -8,27 +8,35 @@ import {
RpcError,
RPCOptions,
} from '@start9labs/shared'
import {
ApiService,
CifsRecoverySource,
DiskRecoverySource,
StatusRes,
AttachReq,
ExecuteReq,
CompleteRes,
} from './api.service'
import { T } from '@start9labs/start-sdk'
import { ApiService, WebsocketConfig } from './api.service'
import * as jose from 'node-jose'
import { Observable } from 'rxjs'
import { DOCUMENT } from '@angular/common'
import { webSocket } from 'rxjs/webSocket'
@Injectable({
providedIn: 'root',
})
export class LiveApiService extends ApiService {
constructor(private readonly http: HttpService) {
constructor(
private readonly http: HttpService,
@Inject(DOCUMENT) private readonly document: Document,
) {
super()
}
async getStatus() {
return this.rpcRequest<StatusRes>({
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
const { location } = this.document.defaultView!
const host = location.host
return webSocket({
url: `ws://${host}/ws/rpc/${guid}`,
})
}
async getStatus(): Promise<T.SetupStatusRes | null> {
return this.rpcRequest<T.SetupStatusRes | null>({
method: 'setup.status',
params: {},
})
@@ -41,7 +49,7 @@ export class LiveApiService extends ApiService {
* this wil all public/private key, which means that there is no information loss
* through the network.
*/
async getPubKey() {
async getPubKey(): Promise<void> {
const response: jose.JWK.Key = await this.rpcRequest({
method: 'setup.get-pubkey',
params: {},
@@ -50,14 +58,14 @@ export class LiveApiService extends ApiService {
this.pubkey = response
}
async getDrives() {
async getDrives(): Promise<DiskListResponse> {
return this.rpcRequest<DiskListResponse>({
method: 'setup.disk.list',
params: {},
})
}
async verifyCifs(source: CifsRecoverySource) {
async verifyCifs(source: T.VerifyCifsParams): Promise<StartOSDiskInfo> {
source.path = source.path.replace('/\\/g', '/')
return this.rpcRequest<StartOSDiskInfo>({
method: 'setup.cifs.verify',
@@ -65,14 +73,14 @@ export class LiveApiService extends ApiService {
})
}
async attach(params: AttachReq) {
await this.rpcRequest<void>({
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
return this.rpcRequest<T.SetupProgress>({
method: 'setup.attach',
params,
})
}
async execute(setupInfo: ExecuteReq) {
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
if (setupInfo.recoverySource?.type === 'backup') {
if (isCifsSource(setupInfo.recoverySource.target)) {
setupInfo.recoverySource.target.path =
@@ -80,14 +88,14 @@ export class LiveApiService extends ApiService {
}
}
await this.rpcRequest<void>({
return this.rpcRequest<T.SetupProgress>({
method: 'setup.execute',
params: setupInfo,
})
}
async complete() {
const res = await this.rpcRequest<CompleteRes>({
async complete(): Promise<T.SetupResult> {
const res = await this.rpcRequest<T.SetupResult>({
method: 'setup.complete',
params: {},
})
@@ -98,7 +106,7 @@ export class LiveApiService extends ApiService {
}
}
async exit() {
async exit(): Promise<void> {
await this.rpcRequest<void>({
method: 'setup.exit',
params: {},
@@ -119,7 +127,7 @@ export class LiveApiService extends ApiService {
}
function isCifsSource(
source: CifsRecoverySource | DiskRecoverySource | null,
): source is CifsRecoverySource {
return !!(source as CifsRecoverySource)?.hostname
source: T.BackupTargetFS | null,
): source is T.Cifs & { type: 'cifs' } {
return !!(source as T.Cifs)?.hostname
}

View File

@@ -1,42 +1,151 @@
import { Injectable } from '@angular/core'
import { encodeBase64, pauseFor } from '@start9labs/shared'
import {
ApiService,
AttachReq,
CifsRecoverySource,
CompleteRes,
ExecuteReq,
} from './api.service'
DiskListResponse,
StartOSDiskInfo,
encodeBase64,
pauseFor,
} from '@start9labs/shared'
import { ApiService } from './api.service'
import * as jose from 'node-jose'
let tries: number
import { T } from '@start9labs/start-sdk'
import {
Observable,
concatMap,
delay,
from,
interval,
map,
mergeScan,
of,
startWith,
switchMap,
switchScan,
takeWhile,
} from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class MockApiService extends ApiService {
async getStatus() {
const restoreOrMigrate = true
// fullProgress$(): Observable<T.FullProgress> {
// const phases = [
// {
// name: 'Preparing Data',
// progress: null,
// },
// {
// name: 'Transferring Data',
// progress: null,
// },
// {
// name: 'Finalizing Setup',
// progress: null,
// },
// ]
// return from(phases).pipe(
// switchScan((acc, val, i) => {}, { overall: null, phases }),
// )
// }
// namedProgress$(namedProgress: T.NamedProgress): Observable<T.NamedProgress> {
// return of(namedProgress).pipe(startWith(namedProgress))
// }
// progress$(progress: T.Progress): Observable<T.Progress> {}
// websocket
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
return of(PROGRESS)
// const numPhases = PROGRESS.phases.length
// return of(PROGRESS).pipe(
// switchMap(full =>
// from(PROGRESS.phases).pipe(
// mergeScan((full, phase, i) => {
// if (
// !phase.progress ||
// typeof phase.progress !== 'object' ||
// !phase.progress.total
// ) {
// full.phases[i].progress = true
// if (
// full.overall &&
// typeof full.overall === 'object' &&
// full.overall.total
// ) {
// const step = full.overall.total / numPhases
// full.overall.done += step
// }
// return of(full).pipe(delay(2000))
// } else {
// const total = phase.progress.total
// const step = total / 4
// let done = phase.progress.done
// return interval(1000).pipe(
// takeWhile(() => done < total),
// map(() => {
// done += step
// console.error(done)
// if (
// full.overall &&
// typeof full.overall === 'object' &&
// full.overall.total
// ) {
// const step = full.overall.total / numPhases / 4
// full.overall.done += step
// }
// if (done === total) {
// full.phases[i].progress = true
// if (i === numPhases - 1) {
// full.overall = true
// }
// }
// return full
// }),
// )
// }
// }, full),
// ),
// ),
// )
}
private statusIndex = 0
async getStatus(): Promise<T.SetupStatusRes | null> {
await pauseFor(1000)
if (tries === undefined) {
tries = 0
return null
}
this.statusIndex++
tries++
const total = tries <= 4 ? tries * 268435456 : 1073741824
const progress = tries > 4 ? (tries - 4) * 268435456 : 0
return {
bytesTransferred: restoreOrMigrate ? progress : 0,
totalBytes: restoreOrMigrate ? total : null,
complete: progress === total,
switch (this.statusIndex) {
case 2:
return {
status: 'running',
progress: PROGRESS,
guid: 'progress-guid',
}
case 3:
return {
status: 'complete',
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
lanAddress: 'https://adjective-noun.local',
rootCa: encodeBase64(rootCA),
}
default:
return null
}
}
async getPubKey() {
async getPubKey(): Promise<void> {
await pauseFor(1000)
// randomly generated
@@ -52,7 +161,7 @@ export class MockApiService extends ApiService {
})
}
async getDrives() {
async getDrives(): Promise<DiskListResponse> {
await pauseFor(1000)
return [
{
@@ -127,7 +236,7 @@ export class MockApiService extends ApiService {
]
}
async verifyCifs(params: CifsRecoverySource) {
async verifyCifs(params: T.VerifyCifsParams): Promise<StartOSDiskInfo> {
await pauseFor(1000)
return {
version: '0.3.0',
@@ -138,15 +247,25 @@ export class MockApiService extends ApiService {
}
}
async attach(params: AttachReq) {
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
await pauseFor(1000)
return {
progress: PROGRESS,
guid: 'progress-guid',
}
}
async execute(setupInfo: ExecuteReq) {
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
await pauseFor(1000)
return {
progress: PROGRESS,
guid: 'progress-guid',
}
}
async complete(): Promise<CompleteRes> {
async complete(): Promise<T.SetupResult> {
await pauseFor(1000)
return {
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
@@ -155,7 +274,7 @@ export class MockApiService extends ApiService {
}
}
async exit() {
async exit(): Promise<void> {
await pauseFor(1000)
}
}
@@ -182,3 +301,8 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
-----END CERTIFICATE-----`
const PROGRESS = {
overall: null,
phases: [],
}

View File

@@ -1,13 +1,13 @@
import { Injectable } from '@angular/core'
import { ApiService, RecoverySource } from './api/api.service'
import { ApiService } from './api/api.service'
import { T } from '@start9labs/start-sdk'
@Injectable({
providedIn: 'root',
})
export class StateService {
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
recoverySource?: RecoverySource
recoverySource?: T.RecoverySource
recoveryPassword?: string
constructor(private readonly api: ApiService) {}

View File

@@ -13,6 +13,7 @@ export type LogsRes = {
export interface Log {
timestamp: string
message: string
bootId: string
}
export type DiskListResponse = DiskInfo[]

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

@@ -1,12 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { HomePage } from './home.page'
import { HomePageRoutingModule } from './home-routing.module'
const routes: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [CommonModule, FormsModule, IonicModule, HomePageRoutingModule],
imports: [CommonModule, IonicModule, RouterModule.forChild(routes)],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -51,12 +51,6 @@
}}
</ion-button>
<div class="ion-padding-top">
<ion-button (click)="presentAlertSystemRebuild()" color="warning">
System Rebuild
</ion-button>
</div>
<div class="ion-padding-top">
<ion-button (click)="presentAlertRepairDisk()" color="danger">
Repair Drive

View File

@@ -1,9 +1,9 @@
import { Component } from '@angular/core'
import { AlertController, LoadingController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: 'app-home',
selector: 'diagnostic-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
@@ -25,7 +25,7 @@ export class HomePage {
async ngOnInit() {
try {
const error = await this.api.getError()
const error = await this.api.diagnosticGetError()
// incorrect drive
if (error.code === 15) {
this.error = {
@@ -92,7 +92,7 @@ export class HomePage {
await loader.present()
try {
await this.api.restart()
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
@@ -108,8 +108,8 @@ export class HomePage {
await loader.present()
try {
await this.api.forgetDrive()
await this.api.restart()
await this.api.diagnosticForgetDrive()
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
@@ -118,32 +118,6 @@ export class HomePage {
}
}
async presentAlertSystemRebuild() {
const alert = await this.alertCtrl.create({
header: 'Warning',
message:
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Rebuild',
handler: () => {
try {
this.systemRebuild()
} catch (e) {
console.error(e)
}
},
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
}
async presentAlertRepairDisk() {
const alert = await this.alertCtrl.create({
header: 'Warning',
@@ -174,23 +148,6 @@ export class HomePage {
window.location.reload()
}
private async systemRebuild(): Promise<void> {
const loader = await this.loadingCtrl.create({
cssClass: 'loader',
})
await loader.present()
try {
await this.api.systemRebuild()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.dismiss()
}
}
private async repairDisk(): Promise<void> {
const loader = await this.loadingCtrl.create({
cssClass: 'loader',
@@ -198,8 +155,8 @@ export class HomePage {
await loader.present()
try {
await this.api.repairDisk()
await this.api.restart()
await this.api.diagnosticRepairDisk()
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)

View File

@@ -1,6 +1,6 @@
import { Component, ViewChild } from '@angular/core'
import { IonContent } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService, toLocalIsoString } from '@start9labs/shared'
var Convert = require('ansi-to-html')
@@ -49,7 +49,7 @@ export class LogsPage {
private async getLogs() {
try {
const { startCursor, entries } = await this.api.getLogs({
const { startCursor, entries } = await this.api.diagnosticGetLogs({
cursor: this.startCursor,
before: !!this.startCursor,
limit: this.limit,

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