UI/feature/actions (#195)

* ui: actions page

* rework actions page

* add warning to Actions

Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
This commit is contained in:
Aaron Greenspan
2021-02-15 10:25:01 -07:00
committed by Aiden McClelland
parent 5cf7d1ff88
commit 02ab63da81
17 changed files with 272 additions and 28 deletions

View File

@@ -70,7 +70,6 @@
<ion-icon name="arrow-forward"></ion-icon>
<ion-icon name="arrow-up"></ion-icon>
<ion-icon name="bookmark-outline"></ion-icon>
<ion-icon name="cart-outline"></ion-icon>
<ion-icon name="chevron-down"></ion-icon>
<ion-icon name="chevron-up"></ion-icon>
<ion-icon name="close"></ion-icon>
@@ -86,6 +85,7 @@
<ion-icon name="eye-off-outline"></ion-icon>
<ion-icon name="eye-outline"></ion-icon>
<ion-icon name="file-tray-stacked-outline"></ion-icon>
<ion-icon name="flash-outline"></ion-icon>
<ion-icon name="grid-outline"></ion-icon>
<ion-icon name="help-circle-outline"></ion-icon>
<ion-icon name="home-outline"></ion-icon>
@@ -100,6 +100,7 @@
<ion-icon name="reload-outline"></ion-icon>
<ion-icon name="refresh-outline"></ion-icon>
<ion-icon name="save-outline"></ion-icon>
<ion-icon name="storefront-outline"></ion-icon>
<ion-icon name="terminal-outline"></ion-icon>
<ion-icon name="trash-outline"></ion-icon>
<ion-icon name="warning-outline"></ion-icon>

View File

@@ -42,7 +42,7 @@ export class AppComponent {
{
title: 'Marketplace',
url: '/services/marketplace',
icon: 'cart-outline',
icon: 'storefront-outline',
},
{
title: 'Notifications',

View File

@@ -149,6 +149,7 @@ function emptyAppInstalledFull (): Omit<AppInstalledFull, keyof AppInstalledPrev
lastBackup: null,
configuredRequirements: null,
hasFetchedFull: false,
actions: [],
}
}

View File

@@ -48,9 +48,17 @@ export interface AppInstalledFull extends AppInstalledPreview {
hasFetchedFull: boolean
uninstallAlert?: string
restoreAlert?: string
actions: Actions
}
// dependencies
export type Actions = ServiceAction[]
export interface ServiceAction {
id: string,
name: string,
description: string,
warning?: string
allowedStatuses: AppStatus[]
}
export interface AppDependency extends InstalledAppDependency {
// explanation of why it *is* optional. null represents it is required.
optional: string | null

View File

@@ -0,0 +1,32 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppActionsPage } from './app-actions.page'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppActionsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
QRComponentModule,
SharingModule,
],
declarations: [AppActionsPage],
})
export class AppActionsPageModule { }

View File

@@ -0,0 +1,44 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Actions</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!($loading$ | async) && {
title: app.title | async,
versionInstalled: app.versionInstalled | async,
status: app.status | async,
actions: app.actions | async
} as vars">
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<!-- no metrics -->
<ion-item *ngIf="!vars.actions.length">
<ion-label class="ion-text-wrap">
<p>No Actions for {{ vars.title }} {{ vars.versionInstalled }}.</p>
</ion-label>
</ion-item>
<!-- actions -->
<ion-item-group>
<ion-item button *ngFor="let action of vars.actions" (click)="handleAction(action)" >
<ion-label class="ion-text-wrap">
<h2><ion-text color="primary">{{ action.name }}</ion-text><ion-icon *ngIf="!(action.allowedStatuses | includes: vars.status)" color="danger" name="close-outline"></ion-icon></h2>
<p><ion-text color="dark">{{ action.description }}</ion-text></p>
</ion-label>
</ion-item>
</ion-item-group>
</ng-container>
</ion-content>

View File

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

View File

@@ -53,7 +53,7 @@
<p class="ion-text-wrap">Get started by installing your first service.</p>
</div>
<ion-button [routerLink]="['/services','marketplace']" style="width: 50%;" fill="outline">
<ion-icon slot="start" name="cart-outline"></ion-icon>
<ion-icon slot="start" name="storefront-outline"></ion-icon>
Marketplace
</ion-button>
</div>

View File

@@ -150,6 +150,11 @@
<ion-icon slot="start" name="information-circle-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Properties</ion-text></ion-label>
</ion-item>
<!-- actions -->
<ion-item [routerLink]="['actions']">
<ion-icon slot="start" name="flash-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Actions</ion-text></ion-label>
</ion-item>
<!-- logs -->
<ion-item [disabled]="vars.status === AppStatus.INSTALLING" [routerLink]="['logs']">
<ion-icon slot="start" name="newspaper-outline" color="primary"></ion-icon>
@@ -157,7 +162,7 @@
</ion-item>
<!-- marketplace -->
<ion-item lines="none" [routerLink]="['/services', 'marketplace', appId]">
<ion-icon slot="start" name="cart-outline" color="primary"></ion-icon>
<ion-icon slot="start" name="storefront-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Marketplace Listing</ion-text></ion-label>
</ion-item>

View File

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

View File

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

View File

@@ -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<Unit>
abstract stopAppBackup (appId: string): Promise<Unit>
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<Unit>
abstract wipeAppData (app: AppInstalledPreview): Promise<Unit>
abstract addSSHKey (sshKey: string): Promise<Unit>
@@ -62,12 +62,30 @@ export abstract class ApiService {
abstract restartServer (): Promise<Unit>
abstract shutdownServer (): Promise<Unit>
abstract ejectExternalDisk (logicalName: string): Promise<Unit>
abstract serviceAction (appId: string, serviceAction: ServiceAction): Promise<ReqRes.ServiceActionResponse>
}
export function isRpcFailure<Error, Result> (arg: { error: Error } | { result: Result}): arg is { error: Error } {
return !!(arg as any).error
}
export function isRpcSuccess<Error, Result> (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 }

View File

@@ -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<ReqRes.ServiceActionResponse> {
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<T> (opts: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise<T> {
if (!this.authenticatedRequestsEnabled) throw new Error(`Authenticated requests are not enabled. Do you need to login?`)

View File

@@ -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<EmptyResponse> {
return mockShutdownServer()
}
async serviceAction (appId: string, action: ServiceAction): Promise<ReqRes.ServiceActionResponse> {
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<ReqRes.GetServerRes> {

View File

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

View File

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