add error status (#2746)

* add error status

* update types

* ṗ̶̰̙̓͒̈́ͅü̵̢̙̫̣ŗ̷̪̺̺͛g̴̲͉͎̬̒̇e̵̪̎̅͌ ̶̡̜̘͐͛t̶͎͍̣̿̍̐h̴͕̩͗̈́̎̑e̵͚͒̂͝ ̸̛͙̦͈͝v̶̱͙̬̽̔ọ̶̧̡̒̓i̸̬̲͍̋̈́d̴͉̀

* fix some extra voids

* add `package.rebuild`

* introduce error status and pkg rebuild and fix mocks

* minor fixes

* fix build

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Aiden McClelland
2024-09-26 20:19:06 -06:00
committed by GitHub
parent db0695126f
commit e7fa94c3d3
49 changed files with 642 additions and 413 deletions

View File

@@ -11,10 +11,19 @@
<ion-item-group *ngIf="pkg$ | async as pkg">
<!-- ** standard actions ** -->
<ion-item-divider>Standard Actions</ion-item-divider>
<app-actions-item
[action]="{
name: 'Rebuild Service',
description: 'Rebuilds the service container. It is harmless and only takes a few seconds to complete, but it should only be necessary if a StartOS bug is preventing dependencies, interfaces, or actions from synchronizing.',
visibility: 'enabled'
}"
icon="construct-outline"
(click)="rebuild(pkg.manifest.id)"
></app-actions-item>
<app-actions-item
[action]="{
name: 'Uninstall',
description: 'This will uninstall the service from StartOS and delete all data permanently.',
description: 'Uninstalls this service from StartOS and delete all data permanently.',
visibility: 'enabled'
}"
icon="trash-outline"

View File

@@ -1,14 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AlertController, NavController } from '@ionic/angular'
import { ErrorService, getPkgId, LoadingService } from '@start9labs/shared'
import { getPkgId } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActionService } from 'src/app/services/action.service'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getAllPackages, getManifest } from 'src/app/util/get-package-data'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { getManifest } from 'src/app/util/get-package-data'
import { filter, map } from 'rxjs'
@Component({
@@ -35,13 +33,9 @@ export class AppActionsPage {
constructor(
private readonly route: ActivatedRoute,
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly actionService: ActionService,
private readonly standardActionsService: StandardActionsService,
) {}
async handleAction(
@@ -55,51 +49,12 @@ export class AppActionsPage {
)
}
async tryUninstall(manifest: T.Manifest): Promise<void> {
let message =
manifest.alerts.uninstall ||
`Uninstalling ${manifest.title} will permanently delete its data`
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patch))) {
message = `${message}. Services that depend on ${manifest.title} will no longer work properly and may crash`
}
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Uninstall',
handler: () => {
this.uninstall()
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
async rebuild(id: string) {
return this.standardActionsService.rebuild(id)
}
private async uninstall() {
const loader = this.loader.open(`Beginning uninstall...`).subscribe()
try {
await this.api.uninstallPackage({ id: this.pkgId })
this.api
.setDbValue<boolean>(['ackInstructions', this.pkgId], false)
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navCtrl.navigateRoot('/services')
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
async tryUninstall(manifest: T.Manifest) {
return this.standardActionsService.tryUninstall(manifest)
}
}

View File

@@ -18,6 +18,7 @@ import { AppShowDependenciesComponent } from './components/app-show-dependencies
import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.component'
import { AppShowHealthChecksComponent } from './components/app-show-health-checks/app-show-health-checks.component'
import { AppShowAdditionalComponent } from './components/app-show-additional/app-show-additional.component'
import { AppShowErrorComponent } from './components/app-show-error/app-show-error.component'
import { HealthColorPipe } from './pipes/health-color.pipe'
import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
@@ -43,6 +44,7 @@ const routes: Routes = [
AppShowMenuComponent,
AppShowHealthChecksComponent,
AppShowAdditionalComponent,
AppShowErrorComponent,
],
imports: [
CommonModule,

View File

@@ -16,9 +16,9 @@
<ion-item-group *ngIf="pkgPlus.status as status">
<!-- ** status ** -->
<app-show-status [pkg]="pkg" [status]="status"></app-show-status>
<!-- ** installed && !backingUp ** -->
<!-- ** installed && !backingUp && !error ** -->
<ng-container
*ngIf="isInstalled(pkg) && status.primary !== 'backingUp'"
*ngIf="isInstalled(pkg) && status.primary !== 'backingUp' && status.primary !== 'error'"
>
<!-- ** health checks ** -->
<app-show-health-checks
@@ -35,6 +35,11 @@
<!-- ** additional ** -->
<app-show-additional [pkg]="pkg"></app-show-additional>
</ng-container>
<app-show-error
*ngIf="pkg.status.main === 'error'"
[manifest]="pkgPlus.manifest"
[error]="pkg.status"
></app-show-error>
</ion-item-group>
</ng-template>
</ion-content>

View File

@@ -45,7 +45,7 @@ export interface DependencyInfo {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowPage {
private readonly pkgId = getPkgId(this.route)
readonly pkgId = getPkgId(this.route)
readonly pkgPlus$ = combineLatest([
this.patch.watch$('packageData'),
@@ -58,9 +58,11 @@ export class AppShowPage {
}),
map(([allPkgs, depErrors]) => {
const pkg = allPkgs[this.pkgId]
const manifest = getManifest(pkg)
return {
pkg,
dependencies: this.getDepInfo(pkg, allPkgs, depErrors),
manifest,
dependencies: this.getDepInfo(pkg, manifest, allPkgs, depErrors),
status: renderPkgStatus(pkg, depErrors),
}
}),
@@ -84,11 +86,10 @@ export class AppShowPage {
private getDepInfo(
pkg: PackageDataEntry,
manifest: T.Manifest,
allPkgs: AllPackageData,
depErrors: PkgDependencyErrors,
): DependencyInfo[] {
const manifest = getManifest(pkg)
return Object.keys(pkg.currentDependencies).map(id =>
this.getDepValues(pkg, allPkgs, manifest, id, depErrors),
)

View File

@@ -0,0 +1,31 @@
<ion-item-divider>Message</ion-item-divider>
<div class="code-block ion-margin">
<code>
<ion-text color="warning">{{ error.message }}</ion-text>
</code>
</div>
<ion-item-divider>Actions</ion-item-divider>
<div class="ion-margin">
<p>
<b>Rebuild Container</b>
is harmless action that and only takes a few seconds to complete. It will
likely resolve this issue.
<b>Uninstall Service</b>
is a dangerous action that will remove the service from StartOS and wipe all
its data.
</p>
<ion-button class="ion-margin-end" (click)="rebuild()">
Rebuild Container
</ion-button>
<ion-button (click)="tryUninstall()" color="danger">
Uninstall Service
</ion-button>
</div>
<ng-container *ngIf="error.debug">
<ion-item-divider>Full Stack Trace</ion-item-divider>
<div class="code-block ion-margin">
<code>{{ error.message }}</code>
</div>
</ng-container>

View File

@@ -0,0 +1,45 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ToastController } from '@ionic/angular'
import { copyToClipboard } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
@Component({
selector: 'app-show-error',
templateUrl: 'app-show-error.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowErrorComponent {
@Input()
manifest!: T.Manifest
@Input()
error!: T.MainStatus & { main: 'error' }
constructor(
private readonly toastCtrl: ToastController,
private readonly standardActionsService: StandardActionsService,
) {}
async copy(text: string): Promise<void> {
const success = await copyToClipboard(text)
const message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
async rebuild() {
return this.standardActionsService.rebuild(this.manifest.id)
}
async tryUninstall() {
return this.standardActionsService.tryUninstall(this.manifest)
}
}

View File

@@ -11,7 +11,14 @@
</ion-label>
</ion-item>
<ng-container *ngIf="isInstalled(pkg) && (connection$ | async)">
<ng-container
*ngIf="
isInstalled(pkg) &&
pkg.status.main !== 'backingUp' &&
pkg.status.main !== 'error' &&
(connection$ | async)
"
>
<ion-grid>
<ion-row style="padding-left: 12px">
<ion-col>

View File

@@ -257,6 +257,9 @@ export module RR {
export type StopPackageReq = { id: string } // package.stop
export type StopPackageRes = null
export type RebuildPackageReq = { id: string } // package.rebuild
export type RebuildPackageRes = null
export type UninstallPackageReq = { id: string } // package.uninstall
export type UninstallPackageRes = null

View File

@@ -249,6 +249,10 @@ export abstract class ApiService {
abstract stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes>
abstract rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes>
abstract uninstallPackage(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes>

View File

@@ -498,6 +498,12 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.stop', params })
}
async rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes> {
return this.rpcRequest({ method: 'package.rebuild', params })
}
async uninstallPackage(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> {

View File

@@ -2,10 +2,12 @@ import { Injectable } from '@angular/core'
import { Log, RPCErrorDetails, RPCOptions, pauseFor } from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import {
AddOperation,
Operation,
PatchOp,
pathFromArray,
RemoveOperation,
ReplaceOperation,
Revision,
} from 'patch-db-client'
import {
@@ -636,14 +638,14 @@ export class MockApiService extends ApiService {
async createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
await pauseFor(2000)
const path = '/serverInfo/statusInfo/backupProgress'
const serverPath = '/serverInfo/statusInfo/backupProgress'
const ids = params.packageIds
setTimeout(async () => {
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const appPath = `/packageData/${id}/status/main/status`
const appPatch = [
const appPath = `/packageData/${id}/status/main/`
const appPatch: ReplaceOperation<T.MainStatus['main']>[] = [
{
op: PatchOp.REPLACE,
path: appPath,
@@ -660,40 +662,43 @@ export class MockApiService extends ApiService {
value: 'stopped',
},
])
this.mockRevision([
const serverPatch: ReplaceOperation<T.BackupProgress['complete']>[] = [
{
op: PatchOp.REPLACE,
path: `${path}/${id}/complete`,
path: `${serverPath}/${id}/complete`,
value: true,
},
])
]
this.mockRevision(serverPatch)
}
await pauseFor(1000)
// set server back to running
const lastPatch = [
// remove backupProgress
const lastPatch: ReplaceOperation<T.ServerStatus['backupProgress']>[] = [
{
op: PatchOp.REPLACE,
path,
path: serverPath,
value: null,
},
]
this.mockRevision(lastPatch)
}, 500)
const originalPatch = [
{
op: PatchOp.REPLACE,
path,
value: ids.reduce((acc, val) => {
return {
...acc,
[val]: { complete: false },
}
}, {}),
},
]
const originalPatch: ReplaceOperation<T.ServerStatus['backupProgress']>[] =
[
{
op: PatchOp.REPLACE,
path: serverPath,
value: ids.reduce((acc, val) => {
return {
...acc,
[val]: { complete: false },
}
}, {}),
},
]
this.mockRevision(originalPatch)
@@ -750,7 +755,7 @@ export class MockApiService extends ApiService {
this.installProgress(params.id)
}, 1000)
const patch: Operation<
const patch: AddOperation<
PackageDataEntry<InstallingState | UpdatingState>
>[] = [
{
@@ -799,7 +804,7 @@ export class MockApiService extends ApiService {
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> {
await pauseFor(2000)
const patch: Operation<PackageDataEntry>[] = params.ids.map(id => {
const patch: AddOperation<PackageDataEntry>[] = params.ids.map(id => {
setTimeout(async () => {
this.installProgress(id)
}, 2000)
@@ -826,76 +831,61 @@ export class MockApiService extends ApiService {
}
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
const path = `/packageData/${params.id}/status/main`
const path = `/packageData/${params.id}/status`
await pauseFor(2000)
setTimeout(async () => {
const patch2 = [
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: 'running',
},
{
op: PatchOp.REPLACE,
path: path + '/started',
value: new Date().toISOString(),
path,
value: {
main: 'running',
started: new Date().toISOString(),
health: {
'ephemeral-health-check': {
name: 'Ephemeral Health Check',
result: 'starting',
message: null,
},
'unnecessary-health-check': {
name: 'Unnecessary Health Check',
result: 'disabled',
message: 'Custom disabled message',
},
'chain-state': {
name: 'Chain State',
result: 'loading',
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
name: 'P2P Interface',
result: 'success',
message: null,
},
'rpc-interface': {
name: 'RPC Interface',
result: 'failure',
message: 'Custom failure message',
},
},
},
},
]
this.mockRevision(patch2)
const patch3 = [
{
op: PatchOp.REPLACE,
path: path + '/health',
value: {
'ephemeral-health-check': {
result: 'starting',
},
'unnecessary-health-check': {
result: 'disabled',
},
},
},
]
this.mockRevision(patch3)
await pauseFor(2000)
const patch4 = [
{
op: PatchOp.REPLACE,
path: path + '/health',
value: {
'ephemeral-health-check': {
result: 'starting',
},
'unnecessary-health-check': {
result: 'disabled',
},
'chain-state': {
result: 'loading',
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
result: 'success',
},
'rpc-interface': {
result: 'failure',
error: 'RPC interface unreachable.',
},
},
},
]
this.mockRevision(patch4)
}, 2000)
const originalPatch = [
const originalPatch: ReplaceOperation<
T.MainStatus & { main: 'starting' }
>[] = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: 'starting',
path,
value: {
main: 'starting',
health: {},
},
},
]
@@ -907,74 +897,57 @@ export class MockApiService extends ApiService {
async restartPackage(
params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> {
// first enact stop
await pauseFor(2000)
const path = `/packageData/${params.id}/status/main`
const path = `/packageData/${params.id}/status`
setTimeout(async () => {
const patch2: Operation<any>[] = [
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: 'starting',
},
{
op: PatchOp.ADD,
path: path + '/restarting',
value: true,
path,
value: {
main: 'running',
started: new Date().toISOString(),
health: {
'ephemeral-health-check': {
name: 'Ephemeral Health Check',
result: 'starting',
message: null,
},
'unnecessary-health-check': {
name: 'Unnecessary Health Check',
result: 'disabled',
message: 'Custom disabled message',
},
'chain-state': {
name: 'Chain State',
result: 'loading',
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
name: 'P2P Interface',
result: 'success',
message: null,
},
'rpc-interface': {
name: 'RPC Interface',
result: 'failure',
message: 'Custom failure message',
},
},
},
},
]
this.mockRevision(patch2)
await pauseFor(2000)
const patch3: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: 'running',
},
{
op: PatchOp.REMOVE,
path: path + '/restarting',
},
{
op: PatchOp.REPLACE,
path: path + '/health',
value: {
'ephemeral-health-check': {
result: 'starting',
},
'unnecessary-health-check': {
result: 'disabled',
},
'chain-state': {
result: 'loading',
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
result: 'success',
},
'rpc-interface': {
result: 'failure',
error: 'RPC interface unreachable.',
},
},
} as any,
]
this.mockRevision(patch3)
}, this.revertTime)
const patch = [
const patch: ReplaceOperation<T.MainStatus & { main: 'restarting' }>[] = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: 'restarting',
},
{
op: PatchOp.REPLACE,
path: path + '/health',
value: {},
path,
value: {
main: 'restarting',
},
},
]
@@ -985,29 +958,24 @@ export class MockApiService extends ApiService {
async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
await pauseFor(2000)
const path = `/packageData/${params.id}/status/main`
const path = `/packageData/${params.id}/status`
setTimeout(() => {
const patch2 = [
const patch2: ReplaceOperation<T.MainStatus & { main: 'stopped' }>[] = [
{
op: PatchOp.REPLACE,
path: path,
value: {
status: 'stopped',
},
value: { main: 'stopped' },
},
]
this.mockRevision(patch2)
}, this.revertTime)
const patch = [
const patch: ReplaceOperation<T.MainStatus & { main: 'stopping' }>[] = [
{
op: PatchOp.REPLACE,
path: path,
value: {
status: 'stopping',
timeout: '35s',
},
value: { main: 'stopping' },
},
]
@@ -1016,6 +984,12 @@ export class MockApiService extends ApiService {
return null
}
async rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes> {
return this.restartPackage(params)
}
async uninstallPackage(
params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> {
@@ -1031,7 +1005,7 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2)
}, this.revertTime)
const patch = [
const patch: ReplaceOperation<T.PackageState['state']>[] = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.id}/stateInfo/state`,

View File

@@ -96,36 +96,14 @@ export const mockPatchData: DataModel = {
icon: '/assets/img/service-icons/bitcoind.svg',
lastBackup: null,
status: {
main: 'running',
started: '2021-06-14T20:49:17.774Z',
health: {
'ephemeral-health-check': {
name: 'Ephemeral Health Check',
result: 'starting',
message: null,
},
'chain-state': {
name: 'Chain State',
result: 'loading',
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
name: 'P2P',
result: 'success',
message: 'Health check successful',
},
'rpc-interface': {
name: 'RPC',
result: 'failure',
message: 'RPC interface unreachable.',
},
'unnecessary-health-check': {
name: 'Unnecessary Health Check',
result: 'disabled',
message: null,
},
},
main: 'stopped',
},
// status: {
// main: 'error',
// message: 'Bitcoin is erroring out',
// debug: 'This is a complete stack trace for bitcoin',
// onRebuild: 'start',
// },
actions: {
config: {
name: 'Bitcoin Config',

View File

@@ -80,6 +80,7 @@ export type PrimaryStatus =
| 'stopped'
| 'backingUp'
| 'needsConfig'
| 'error'
export type DependencyStatus = 'warning' | 'satisfied'
@@ -139,6 +140,11 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
color: 'warning',
showDots: false,
},
error: {
display: 'Service Launch Error',
color: 'danger',
showDots: false,
},
}
export const DependencyRendering: Record<DependencyStatus, StatusRendering> = {

View File

@@ -0,0 +1,85 @@
import { Injectable } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { hasCurrentDeps } from '../util/has-deps'
import { getAllPackages } from '../util/get-package-data'
import { PatchDB } from 'patch-db-client'
import { DataModel } from './patch-db/data-model'
import { AlertController, NavController } from '@ionic/angular'
import { ApiService } from './api/embassy-api.service'
import { ErrorService, LoadingService } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
})
export class StandardActionsService {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly navCtrl: NavController,
) {}
async rebuild(id: string) {
const loader = this.loader.open(`Rebuilding Container...`).subscribe()
try {
await this.api.rebuildPackage({ id })
this.navCtrl.navigateBack('/services/' + id)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async tryUninstall(manifest: T.Manifest): Promise<void> {
const { id, title, alerts } = manifest
let message =
alerts.uninstall ||
`Uninstalling ${title} will permanently delete its data`
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
message = `${message}. Services that depend on ${title} will no longer work properly and may crash`
}
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Uninstall',
handler: () => {
this.uninstall(id)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
}
private async uninstall(id: string) {
const loader = this.loader.open(`Beginning uninstall...`).subscribe()
try {
await this.api.uninstallPackage({ id })
this.api
.setDbValue<boolean>(['ackInstructions', id], false)
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navCtrl.navigateRoot('/services')
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}