diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index c94db0d14..1f40cf842 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -70,7 +70,6 @@ - @@ -86,6 +85,7 @@ + @@ -100,6 +100,7 @@ + diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index a8745bc80..cf4748aad 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -42,7 +42,7 @@ export class AppComponent { { title: 'Marketplace', url: '/services/marketplace', - icon: 'cart-outline', + icon: 'storefront-outline', }, { title: 'Notifications', diff --git a/ui/src/app/models/app-model.ts b/ui/src/app/models/app-model.ts index e17402bbc..2b2ad9639 100644 --- a/ui/src/app/models/app-model.ts +++ b/ui/src/app/models/app-model.ts @@ -149,6 +149,7 @@ function emptyAppInstalledFull (): Omit + + + + + Actions + + + + + + + + + + + + {{ error }} + + + + + + No Actions for {{ vars.title }} {{ vars.versionInstalled }}. + + + + + + + + {{ action.name }} + {{ action.description }} + + + + + + \ No newline at end of file diff --git a/ui/src/app/pages/apps-routes/app-actions/app-actions.page.scss b/ui/src/app/pages/apps-routes/app-actions/app-actions.page.scss new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts b/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts new file mode 100644 index 000000000..a9508203e --- /dev/null +++ b/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts @@ -0,0 +1,94 @@ +import { Component } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { ApiService, isRpcFailure, isRpcSuccess } from 'src/app/services/api/api.service' +import { BehaviorSubject } from 'rxjs' +import { AlertController } from '@ionic/angular' +import { ModelPreload } from 'src/app/models/model-preload' +import { LoaderService, markAsLoadingDuring$ } from 'src/app/services/loader.service' +import { ServiceAction, AppInstalledFull } from 'src/app/models/app-types' +import { PropertySubject } from 'src/app/util/property-subject.util' +import { map } from 'rxjs/operators' +import { Cleanup } from 'src/app/util/cleanup' + +@Component({ + selector: 'app-actions', + templateUrl: './app-actions.page.html', + styleUrls: ['./app-actions.page.scss'], +}) +export class AppActionsPage extends Cleanup { + error = '' + $loading$ = new BehaviorSubject(true) + appId: string + app: PropertySubject + + constructor ( + private readonly route: ActivatedRoute, + private readonly apiService: ApiService, + private readonly alertCtrl: AlertController, + private readonly preload: ModelPreload, + private readonly loaderService: LoaderService, + ) { super() } + + ngOnInit () { + this.appId = this.route.snapshot.paramMap.get('appId') + + markAsLoadingDuring$(this.$loading$, this.preload.appFull(this.appId)).pipe( + map(app => this.app = app), + ).subscribe( { error: e => this.error = e.message } ) + } + + async handleAction (action: ServiceAction) { + if (action.allowedStatuses.includes(this.app.status.getValue())) { + const alert = await this.alertCtrl.create({ + header: 'Confirm', + message: `Are you sure you want to execute action "${action.name}"? ${action.warning}`, + buttons: [ + { + text: 'Cancel', + role: 'cancel', + }, + { + text: 'Execute', + handler: () => { + this.executeAction(action) + }, + }, + ], + }) + await alert.present() + } else { + const alert = await this.alertCtrl.create({ + header: 'Forbidden', + message: `Action "${action.name}" can only be executed when service is ${action.allowedStatuses.join(', ')}`, + buttons: ['OK'], + cssClass: 'alert-error-message', + }) + await alert.present() + } + } + + private async executeAction (action: ServiceAction) { + const res = await this.loaderService.displayDuringP( + this.apiService.serviceAction(this.appId, action), + ) + if (isRpcFailure(res)) { + const successAlert = await this.alertCtrl.create({ + header: 'Execution Failed', + message: `Error code ${res.error.code}. ${res.error.message}`, + buttons: ['OK'], + cssClass: 'alert-error-message', + }) + return await successAlert.present() + } + + if (isRpcSuccess(res)) { + const successAlert = await this.alertCtrl.create({ + header: 'Execution Complete', + message: res.result, + buttons: ['OK'], + cssClass: 'alert-success-message', + }) + return await successAlert.present() + } + } +} diff --git a/ui/src/app/pages/apps-routes/app-installed-list/app-installed-list.page.html b/ui/src/app/pages/apps-routes/app-installed-list/app-installed-list.page.html index 57977e0b1..74e0be14f 100644 --- a/ui/src/app/pages/apps-routes/app-installed-list/app-installed-list.page.html +++ b/ui/src/app/pages/apps-routes/app-installed-list/app-installed-list.page.html @@ -53,7 +53,7 @@ Get started by installing your first service. - + Marketplace diff --git a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html index 970b8fda4..5e33219cd 100644 --- a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html +++ b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html @@ -150,6 +150,11 @@ Properties + + + + Actions + @@ -157,7 +162,7 @@ - + Marketplace Listing diff --git a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts index 9a20de05c..2894ff47b 100644 --- a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts +++ b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts @@ -13,7 +13,7 @@ import { LoaderService, markAsLoadingDuring$, markAsLoadingDuringP } from 'src/a import { BehaviorSubject, combineLatest, from, merge, Observable, of, Subject } from 'rxjs' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' -import { catchError, concatMap, delay, filter, map, retryWhen, switchMap, take, tap } from 'rxjs/operators' +import { catchError, concatMap, delay, distinctUntilChanged, filter, map, retryWhen, switchMap, take, tap } from 'rxjs/operators' import { Cleanup } from 'src/app/util/cleanup' import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component' import { Emver } from 'src/app/services/emver.service' @@ -79,6 +79,7 @@ export class AppInstalledShowPage extends Cleanup { concatMap(app => merge( this.syncWhenDependencyInstalls(), + // new lan info from sync daemon combineLatest([app.lanEnabled, this.$lanConnected$, app.status, this.$testingLanConnection$]).pipe( filter(([_, __, s, alreadyConnecting]) => s === AppStatus.RUNNING && !alreadyConnecting), concatMap(([enabled, connected]) => { @@ -87,6 +88,22 @@ export class AppInstalledShowPage extends Cleanup { return of() }), ), + // toggle lan + combineLatest([this.$lanToggled$, app.lanEnabled, this.$testingLanConnection$]).pipe( + distinctUntilChanged(([toggled1], [toggled2]) => toggled1 !== toggled2), + filter(([_, __, alreadyLoading]) => !alreadyLoading), + map(([e, _]) => [(e as any).detail.checked, _]), + // if the app is already in the desired state, we bail + // this can happen because ionChange triggers when the [checked] value changes + filter(([uiEnabled, appEnabled]) => (uiEnabled && !appEnabled) || (!uiEnabled && appEnabled)), + map(([enabled]) => enabled + ? this.enableLan().pipe(concatMap(() => this.testLanConnection())) + : this.disableLan(), + ), + concatMap(o => markAsLoadingDuring$(this.$testingLanConnection$, o).pipe( + catchError(e => this.setError(e)), + )), + ), ), ), //must be final in stack catchError(e => this.setError(e)), @@ -121,22 +138,6 @@ export class AppInstalledShowPage extends Cleanup { $lanToggled$ = new Subject() ionViewDidEnter () { markAsLoadingDuringP(this.$loadingDependencies$, this.getApp()) - this.cleanup( - combineLatest([this.$lanToggled$, this.app.lanEnabled, this.$testingLanConnection$]).pipe( - filter(([_, __, alreadyLoading]) => !alreadyLoading), - map(([e, _]) => [(e as any).detail.checked, _]), - // if the app is already in the desired state, we bail - // this can happen because ionChange triggers when the [checked] value changes - filter(([uiEnabled, appEnabled]) => (uiEnabled && !appEnabled) || (!uiEnabled && appEnabled)), - map(([enabled]) => enabled - ? this.enableLan().pipe(concatMap(() => this.testLanConnection())) - : this.disableLan(), - ), - concatMap(o => markAsLoadingDuring$(this.$testingLanConnection$, o).pipe( - catchError(e => this.setError(e)), - )), - ).subscribe({ error: e => console.error(e) }), - ) } async doRefresh (event: any) { diff --git a/ui/src/app/pages/apps-routes/apps-routing.module.ts b/ui/src/app/pages/apps-routes/apps-routing.module.ts index 952909371..0a25bc60a 100644 --- a/ui/src/app/pages/apps-routes/apps-routing.module.ts +++ b/ui/src/app/pages/apps-routes/apps-routing.module.ts @@ -43,6 +43,10 @@ const routes: Routes = [ path: 'installed/:appId/metrics', loadChildren: () => import('./app-metrics/app-metrics.module').then(m => m.AppMetricsPageModule), }, + { + path: 'installed/:appId/actions', + loadChildren: () => import('./app-actions/app-actions.module').then(m => m.AppActionsPageModule), + }, ] @NgModule({ diff --git a/ui/src/app/services/api/api.service.ts b/ui/src/app/services/api/api.service.ts index 3bbd7846a..82b60d990 100644 --- a/ui/src/app/services/api/api.service.ts +++ b/ui/src/app/services/api/api.service.ts @@ -1,5 +1,5 @@ import { Rules } from '../../models/app-model' -import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types' +import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo, ServiceAction } from '../../models/app-types' import { S9Notification, SSHFingerprint, ServerMetrics, DiskInfo } from '../../models/server-model' import { Subject, Observable } from 'rxjs' import { Unit, ApiServer, ApiAppInstalledFull, ApiAppConfig, ApiAppAvailableFull } from './api-types' @@ -51,7 +51,7 @@ export abstract class ApiService { abstract restoreAppBackup (appId: string, logicalname: string, password?: string): Promise abstract stopAppBackup (appId: string): Promise abstract patchAppConfig (app: AppInstalledPreview, config: object, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }> - abstract postConfigureDependency (dependencyId: string, dependentId: string, dryRun?: boolean): Promise< {config: object, breakages: DependentBreakage[] }> + abstract postConfigureDependency (dependencyId: string, dependentId: string, dryRun?: boolean): Promise< { config: object, breakages: DependentBreakage[] }> abstract patchServerConfig (attr: string, value: any): Promise abstract wipeAppData (app: AppInstalledPreview): Promise abstract addSSHKey (sshKey: string): Promise @@ -62,12 +62,30 @@ export abstract class ApiService { abstract restartServer (): Promise abstract shutdownServer (): Promise abstract ejectExternalDisk (logicalName: string): Promise + abstract serviceAction (appId: string, serviceAction: ServiceAction): Promise +} + +export function isRpcFailure (arg: { error: Error } | { result: Result}): arg is { error: Error } { + return !!(arg as any).error +} + +export function isRpcSuccess (arg: { error: Error } | { result: Result}): arg is { result: Result } { + return !!(arg as any).result } export module ReqRes { export type GetVersionRes = { version: string } export type PostLoginReq = { password: string } export type PostLoginRes = Unit + export type ServiceActionRequest = { + jsonrpc: '2.0', + id: string, + method: string + } + export type ServiceActionResponse = { + jsonrpc: '2.0', + id: string + } & ({ error: { code: number, message: string } } | { result : string }) export type GetCheckAuthRes = { } export type GetServerRes = ApiServer export type GetVersionLatestRes = { versionLatest: string, releaseNotes: string } diff --git a/ui/src/app/services/api/live-api.service.ts b/ui/src/app/services/api/live-api.service.ts index adf522823..24dc01453 100644 --- a/ui/src/app/services/api/live-api.service.ts +++ b/ui/src/app/services/api/live-api.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core' import { HttpService, Method, HttpOptions } from '../http.service' import { AppModel, AppStatus } from '../../models/app-model' -import { AppAvailablePreview, AppAvailableFull, AppInstalledFull, AppInstalledPreview, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types' +import { AppAvailablePreview, AppAvailableFull, AppInstalledFull, AppInstalledPreview, DependentBreakage, AppAvailableVersionSpecificInfo, ServiceAction } from '../../models/app-types' import { S9Notification, SSHFingerprint, ServerModel, DiskInfo } from '../../models/server-model' import { ApiService, ReqRes } from './api.service' import { ApiServer, Unit } from './api-types' @@ -12,6 +12,7 @@ import { AppMetrics, parseMetricsPermissive } from 'src/app/util/metrics.util' import { modulateTime } from 'src/app/util/misc.util' import { Observable, of, throwError } from 'rxjs' import { catchError, mapTo } from 'rxjs/operators' +import * as uuid from 'uuid' @Injectable() export class LiveApiService extends ApiService { @@ -266,6 +267,15 @@ export class LiveApiService extends ApiService { return this.authRequest({ method: Method.POST, url: '/shutdown', readTimeout: 60000 }) } + async serviceAction (appId: string, s: ServiceAction): Promise { + const data: ReqRes.ServiceActionRequest = { + jsonrpc: '2.0', + id: uuid.v4(), + method: s.id, + } + return this.authRequest({ method: Method.POST, url: `apps/${appId}/actions`, data }) + } + private async authRequest (opts: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise { if (!this.authenticatedRequestsEnabled) throw new Error(`Authenticated requests are not enabled. Do you need to login?`) diff --git a/ui/src/app/services/api/mock-api.service.ts b/ui/src/app/services/api/mock-api.service.ts index f78464dbb..ef88419f3 100644 --- a/ui/src/app/services/api/mock-api.service.ts +++ b/ui/src/app/services/api/mock-api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import { AppStatus, AppModel } from '../../models/app-model' -import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo } from '../../models/app-types' +import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo, ServiceAction } from '../../models/app-types' import { S9Notification, SSHFingerprint, ServerStatus, ServerModel, DiskInfo } from '../../models/server-model' import { pauseFor } from '../../util/misc.util' import { ApiService, ReqRes } from './api.service' @@ -228,6 +228,20 @@ export class MockApiService extends ApiService { async shutdownServer (): Promise { return mockShutdownServer() } + + async serviceAction (appId: string, action: ServiceAction): Promise { + console.log('service action', appId, action) + await pauseFor(1000) + return { + jsonrpc: '2.0', + id: '0', + // result: 'Congrats! you did ' + action.name, + error: { + code: 1, + message: 'woooo that was bad bad bad', + }, + } + } } async function mockGetServer (): Promise { diff --git a/ui/src/app/services/api/mock-app-fixures.ts b/ui/src/app/services/api/mock-app-fixures.ts index b4c8d8d59..43c197112 100644 --- a/ui/src/app/services/api/mock-app-fixures.ts +++ b/ui/src/app/services/api/mock-app-fixures.ts @@ -60,7 +60,11 @@ export const bitcoinI: AppInstalledFull = { configuredRequirements: [], hasFetchedFull: true, ui: false, - restoreAlert: 'if you restore this app horrible things will happen to the people you love.' + restoreAlert: 'if you restore this app horrible things will happen to the people you love.', + actions: [ + { id: 'sync-chain', name: 'Sync Chain', description: 'this will sync with the chain like from Avatar', allowedStatuses: [ AppStatus.RUNNING, AppStatus.RUNNING, AppStatus.RUNNING, AppStatus.RUNNING ]}, + { id: 'off-sync-chain', name: 'Off Sync Chain', description: 'this will off sync with the chain like from Avatar', allowedStatuses: [ AppStatus.STOPPED ]} + ], } export const lightningI: AppInstalledFull = { @@ -86,6 +90,7 @@ export const lightningI: AppInstalledFull = { ], hasFetchedFull: true, ui: true, + actions: [], } export const cupsI: AppInstalledFull = { @@ -132,6 +137,7 @@ export const cupsI: AppInstalledFull = { }), ], hasFetchedFull: true, + actions: [], } export const bitcoinA: AppAvailableFull = { diff --git a/ui/src/global.scss b/ui/src/global.scss index 5142d3276..a25fdd886 100644 --- a/ui/src/global.scss +++ b/ui/src/global.scss @@ -221,11 +221,17 @@ ion-popover { } .alert-error-message { - .alert-message { + .alert-title { color: var(--ion-color-danger); } } +.alert-success-message { + .alert-title { + color: var(--ion-color-success); + } +} + ion-slides { .slider-wrapper { height: 100%;
No Actions for {{ vars.title }} {{ vars.versionInstalled }}.
{{ action.description }}
Get started by installing your first service.