diff --git a/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html b/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html index 6b72c38a8..c9ecc8ed5 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html +++ b/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html @@ -11,10 +11,19 @@ Standard Actions + , private readonly actionService: ActionService, + private readonly standardActionsService: StandardActionsService, ) {} async handleAction( @@ -55,51 +49,12 @@ export class AppActionsPage { ) } - async tryUninstall(manifest: T.Manifest): Promise { - 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(['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) } } diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts index a3c6cc584..7d51a6fc8 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts @@ -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, diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html index 7ea0b86fd..ec59b1042 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html @@ -16,9 +16,9 @@ - + + diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts index 1743a86ec..c1574e476 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts @@ -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), ) diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.html b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.html new file mode 100644 index 000000000..c056f2977 --- /dev/null +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.html @@ -0,0 +1,31 @@ +Message +
+ + {{ error.message }} + +
+ +Actions +
+

+ Rebuild Container + is harmless action that and only takes a few seconds to complete. It will + likely resolve this issue. + Uninstall Service + is a dangerous action that will remove the service from StartOS and wipe all + its data. +

+ + Rebuild Container + + + Uninstall Service + +
+ + + Full Stack Trace +
+ {{ error.message }} +
+
diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.ts b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.ts new file mode 100644 index 000000000..ef689f178 --- /dev/null +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.ts @@ -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 { + 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) + } +} diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html index 5dc5faf67..e31d4cd24 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html @@ -11,7 +11,14 @@ - + diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index e428456f6..65997c49d 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -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 diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index 1a5c1c57c..49571d97f 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -249,6 +249,10 @@ export abstract class ApiService { abstract stopPackage(params: RR.StopPackageReq): Promise + abstract rebuildPackage( + params: RR.RebuildPackageReq, + ): Promise + abstract uninstallPackage( params: RR.UninstallPackageReq, ): Promise diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 742076ec8..73f3e67c6 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -498,6 +498,12 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'package.stop', params }) } + async rebuildPackage( + params: RR.RebuildPackageReq, + ): Promise { + return this.rpcRequest({ method: 'package.rebuild', params }) + } + async uninstallPackage( params: RR.UninstallPackageReq, ): Promise { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 6c55bb9db..c4834c99e 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -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 { 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[] = [ { op: PatchOp.REPLACE, path: appPath, @@ -660,40 +662,43 @@ export class MockApiService extends ApiService { value: 'stopped', }, ]) - this.mockRevision([ + + const serverPatch: ReplaceOperation[] = [ { 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[] = [ { 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[] = + [ + { + 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 >[] = [ { @@ -799,7 +804,7 @@ export class MockApiService extends ApiService { params: RR.RestorePackagesReq, ): Promise { await pauseFor(2000) - const patch: Operation[] = params.ids.map(id => { + const patch: AddOperation[] = 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 { - const path = `/packageData/${params.id}/status/main` + const path = `/packageData/${params.id}/status` await pauseFor(2000) setTimeout(async () => { - const patch2 = [ - { - op: PatchOp.REPLACE, - path: path + '/status', - value: 'running', - }, - { - op: PatchOp.REPLACE, - path: path + '/started', - value: new Date().toISOString(), - }, - ] - 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 = [ + const patch2: ReplaceOperation[] = [ { op: PatchOp.REPLACE, - path: path + '/health', + path, 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.', + 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(patch4) + this.mockRevision(patch2) }, 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 { - // first enact stop await pauseFor(2000) - const path = `/packageData/${params.id}/status/main` + const path = `/packageData/${params.id}/status` setTimeout(async () => { - const patch2: Operation[] = [ + const patch2: ReplaceOperation[] = [ { op: PatchOp.REPLACE, - path: path + '/status', - value: 'starting', - }, - { - op: PatchOp.ADD, - path: path + '/restarting', - value: true, - }, - ] - this.mockRevision(patch2) - - await pauseFor(2000) - - const patch3: Operation[] = [ - { - op: PatchOp.REPLACE, - path: path + '/status', - value: 'running', - }, - { - op: PatchOp.REMOVE, - path: path + '/restarting', - }, - { - op: PatchOp.REPLACE, - path: path + '/health', + path, 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.', + 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', + }, }, }, - } as any, + }, ] - this.mockRevision(patch3) + this.mockRevision(patch2) }, this.revertTime) - const patch = [ - { - op: PatchOp.REPLACE, - path: path + '/status', - value: 'restarting', - }, + const patch: ReplaceOperation[] = [ { 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 { await pauseFor(2000) - const path = `/packageData/${params.id}/status/main` + const path = `/packageData/${params.id}/status` setTimeout(() => { - const patch2 = [ + const patch2: ReplaceOperation[] = [ { op: PatchOp.REPLACE, path: path, - value: { - status: 'stopped', - }, + value: { main: 'stopped' }, }, ] this.mockRevision(patch2) }, this.revertTime) - const patch = [ + const patch: ReplaceOperation[] = [ { 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 { + return this.restartPackage(params) + } + async uninstallPackage( params: RR.UninstallPackageReq, ): Promise { @@ -1031,7 +1005,7 @@ export class MockApiService extends ApiService { this.mockRevision(patch2) }, this.revertTime) - const patch = [ + const patch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, path: `/packageData/${params.id}/stateInfo/state`, diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index cdc8f7173..d89b5e87f 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -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', diff --git a/web/projects/ui/src/app/services/pkg-status-rendering.service.ts b/web/projects/ui/src/app/services/pkg-status-rendering.service.ts index f987f6b2c..3f2a7b917 100644 --- a/web/projects/ui/src/app/services/pkg-status-rendering.service.ts +++ b/web/projects/ui/src/app/services/pkg-status-rendering.service.ts @@ -80,6 +80,7 @@ export type PrimaryStatus = | 'stopped' | 'backingUp' | 'needsConfig' + | 'error' export type DependencyStatus = 'warning' | 'satisfied' @@ -139,6 +140,11 @@ export const PrimaryRendering: Record = { color: 'warning', showDots: false, }, + error: { + display: 'Service Launch Error', + color: 'danger', + showDots: false, + }, } export const DependencyRendering: Record = { diff --git a/web/projects/ui/src/app/services/standard-actions.service.ts b/web/projects/ui/src/app/services/standard-actions.service.ts new file mode 100644 index 000000000..664db822c --- /dev/null +++ b/web/projects/ui/src/app/services/standard-actions.service.ts @@ -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, + 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 { + 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(['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() + } + } +} diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index fa4a6598e..34c76848b 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -110,6 +110,12 @@ $subheader-height: 48px; } } +.code-block { + background-color: rgb(69, 69, 69); + padding: 12px; + margin-bottom: 32px; +} + .center { display: block; margin: auto;