Convert properties to an action (#2751)

* update actions response types and partially implement in UI

* further remove diagnostic ui

* convert action response nested to array

* prepare action res modal for Alex

* ad dproperties action for Bitcoin

* feat: add action success dialog (#2753)

* feat: add action success dialog

* mocks for string action res and hide properties from actions page

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* return null

* remove properties from backend

* misc fixes

* make severity separate argument

* rename ActionRequest to ActionRequestOptions

* add clearRequests

* fix s9pk build

* remove config and properties, introduce action requests

* better ux, better moocks, include icons

* fix dependency types

* add variant for versionCompat

* fix dep icon display and patch operation display

* misc fixes

* misc fixes

* alpha 12

* honor provided input to set values in action

* fix: show full descriptions of action success items (#2758)

* fix type

* fix: fix build:deps command on Windows (#2752)

* fix: fix build:deps command on Windows

* fix: add escaped quotes

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>

* misc db compatibility fixes

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
This commit is contained in:
Matt Hill
2024-10-17 13:31:56 -06:00
committed by GitHub
parent fb074c8c32
commit 2ba56b8c59
105 changed files with 1385 additions and 1578 deletions

View File

@@ -83,7 +83,7 @@ export class SuccessPage {
await this.api.exit()
}
} catch (e: any) {
await this.errorService.handleError(e)
this.errorService.handleError(e)
}
}

View File

@@ -2,7 +2,7 @@ export type WorkspaceConfig = {
gitHash: string
useMocks: boolean
enableWidgets: boolean
// each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard, diagnostic-ui
// each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard
ui: {
api: {
url: string

View File

@@ -65,7 +65,6 @@ const ICONS = [
'options-outline',
'pencil',
'phone-portrait-outline',
'play-circle-outline',
'play-outline',
'power',
'pricetag-outline',

View File

@@ -149,14 +149,15 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
}
private process(operations: Operation[]) {
operations.forEach(({ op, path }) => {
const control = this.form.get(path.substring(1).split('/'))
operations.forEach(operation => {
const control = this.form.get(operation.path.substring(1).split('/'))
if (!control || !control.parent) return
if (op !== 'remove') {
if (operation.op === 'add' || operation.op === 'replace') {
control.markAsDirty()
control.markAsTouched()
control.setValue(operation.value)
}
control.parent.markAsDirty()

View File

@@ -16,7 +16,7 @@ import { compare } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { catchError, defer, EMPTY, endWith, firstValueFrom, map } from 'rxjs'
import { InvalidService } from 'src/app/components/form/invalid.service'
import { ActionDepComponent } from 'src/app/modals/action-dep.component'
import { ActionRequestInfoComponent } from 'src/app/modals/action-request-input.component'
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -25,23 +25,29 @@ import * as json from 'fast-json-patch'
import { ActionService } from '../services/action.service'
import { ActionButton, FormComponent } from '../components/form.component'
export interface PackageActionData {
readonly pkgInfo: {
export type PackageActionData = {
pkgInfo: {
id: string
title: string
icon: string
mainStatus: T.MainStatus['main']
}
readonly actionInfo: {
actionInfo: {
id: string
warning: string | null
metadata: T.ActionMetadata
}
readonly dependentInfo?: {
title: string
requestInfo?: {
dependentId?: string
request: T.ActionRequest
}
}
@Component({
template: `
<div class="service-title">
<img [src]="pkgInfo.icon" alt="" />
<h4>{{ pkgInfo.title }}</h4>
</div>
<ng-container *ngIf="res$ | async as res; else loading">
<tui-notification *ngIf="error" status="error">
<div [innerHTML]="error"></div>
@@ -52,13 +58,11 @@ export interface PackageActionData {
<div [innerHTML]="warning"></div>
</tui-notification>
<action-dep
*ngIf="dependentInfo"
[pkgTitle]="pkgInfo.title"
[depTitle]="dependentInfo.title"
<action-request-info
*ngIf="requestInfo"
[originalValue]="res.originalValue || {}"
[operations]="res.operations || []"
></action-dep>
></action-request-info>
<app-form
tuiMode="onDark"
@@ -87,7 +91,19 @@ export interface PackageActionData {
`
tui-notification {
font-size: 1rem;
margin-bottom: 1rem;
margin-bottom: 1.4rem;
}
.service-title {
display: inline-flex;
align-items: center;
margin-bottom: 1.4rem;
img {
height: 20px;
margin-right: 4px;
}
h4 {
margin: 0;
}
}
`,
],
@@ -98,7 +114,7 @@ export interface PackageActionData {
TuiNotificationModule,
TuiButtonModule,
TuiModeModule,
ActionDepComponent,
ActionRequestInfoComponent,
UiPipeModule,
FormComponent,
],
@@ -106,9 +122,9 @@ export interface PackageActionData {
})
export class ActionInputModal {
readonly actionId = this.context.data.actionInfo.id
readonly warning = this.context.data.actionInfo.warning
readonly warning = this.context.data.actionInfo.metadata.warning
readonly pkgInfo = this.context.data.pkgInfo
readonly dependentInfo = this.context.data.dependentInfo
readonly requestInfo = this.context.data.requestInfo
buttons: ActionButton<any>[] = [
{
@@ -131,12 +147,12 @@ export class ActionInputModal {
return {
spec: res.spec,
originalValue,
operations: this.dependentInfo?.request.input
operations: this.requestInfo?.request.input
? compare(
originalValue,
JSON.parse(JSON.stringify(originalValue)),
utils.deepMerge(
originalValue,
this.dependentInfo.request.input.value,
JSON.parse(JSON.stringify(originalValue)),
this.requestInfo.request.input.value,
) as object,
)
: null,
@@ -159,15 +175,7 @@ export class ActionInputModal {
async execute(input: object) {
if (await this.checkConflicts(input)) {
const res = await firstValueFrom(this.res$)
return this.actionService.execute(this.pkgInfo.id, this.actionId, {
prev: {
spec: res.spec,
value: res.originalValue,
},
curr: input,
})
return this.actionService.execute(this.pkgInfo.id, this.actionId, input)
}
}
@@ -181,6 +189,7 @@ export class ActionInputModal {
Object.values(packages[id].requestedActions).some(
({ request, active }) =>
!active &&
request.severity === 'critical' &&
request.packageId === this.pkgInfo.id &&
request.actionId === this.actionId &&
request.when?.condition === 'input-not-matches' &&

View File

@@ -11,14 +11,10 @@ import { CommonModule } from '@angular/common'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'action-dep',
selector: 'action-request-info',
template: `
<tui-notification>
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
{{ pkgTitle }}
</h3>
The following modifications have been made to {{ pkgTitle }} to satisfy
{{ depTitle }}:
<tui-notification *ngIf="diff.length">
The following modifications were made:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
@@ -27,14 +23,15 @@ import { TuiNotificationModule } from '@taiga-ui/core'
standalone: true,
imports: [CommonModule, TuiNotificationModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
tui-notification {
margin-bottom: 1.5rem;
}
`,
],
})
export class ActionDepComponent implements OnInit {
@Input()
pkgTitle = ''
@Input()
depTitle = ''
export class ActionRequestInfoComponent implements OnInit {
@Input()
originalValue: object = {}
@@ -68,15 +65,15 @@ export class ActionDepComponent implements OnInit {
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
return `added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
return `removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
return `changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
return `Unknown operation` // unreachable
}
}

View File

@@ -0,0 +1,54 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiFadeModule, TuiTitleModule } from '@taiga-ui/experimental'
import { TuiAccordionModule } from '@taiga-ui/kit'
import { ActionSuccessItemComponent } from './action-success-item.component'
@Component({
standalone: true,
selector: 'app-action-success-group',
template: `
<p *ngFor="let item of value?.value">
<app-action-success-item
*ngIf="isSingle(item)"
[value]="item"
></app-action-success-item>
<tui-accordion-item *ngIf="!isSingle(item)" size="s">
<div tuiFade>{{ item.name }}</div>
<ng-template tuiAccordionItemContent>
<app-action-success-group [value]="item"></app-action-success-group>
</ng-template>
</tui-accordion-item>
</p>
`,
styles: [
`
p:first-child {
margin-top: 0;
}
p:last-child {
margin-bottom: 0;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiTitleModule,
ActionSuccessItemComponent,
TuiAccordionModule,
TuiFadeModule,
],
})
export class ActionSuccessGroupComponent {
@Input()
value?: T.ActionResultV1 & { type: 'object' }
isSingle(
value: T.ActionResultV1,
): value is T.ActionResultV1 & { type: 'string' } {
return value.type === 'string'
}
}

View File

@@ -0,0 +1,184 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
Input,
TemplateRef,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { T } from '@start9labs/start-sdk'
import {
TuiDialogService,
TuiLabelModule,
TuiTextfieldComponent,
TuiTextfieldControllerModule,
} from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import { QrCodeModule } from 'ng-qrcode'
import { ActionSuccessGroupComponent } from './action-success-group.component'
@Component({
standalone: true,
selector: 'app-action-success-item',
template: `
<p *ngIf="!parent" class="qr">
<ng-container *ngTemplateOutlet="qr"></ng-container>
</p>
<label [tuiLabel]="value.description">
<tui-input
[readOnly]="true"
[ngModel]="value.value"
[tuiTextfieldCustomContent]="actions"
>
<input
tuiTextfield
[style.border-inline-end-width.rem]="border"
[type]="value.masked && masked ? 'password' : 'text'"
/>
</tui-input>
</label>
<ng-template #actions>
<button
*ngIf="value.masked"
tuiIconButton
appearance="icon"
size="s"
type="button"
tabindex="-1"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
[style.pointer-events]="'auto'"
(click)="masked = !masked"
>
Reveal/Hide
</button>
<button
*ngIf="value.copyable"
tuiIconButton
appearance="icon"
size="s"
type="button"
tabindex="-1"
iconLeft="tuiIconCopy"
[style.pointer-events]="'auto'"
(click)="copy()"
>
Copy
</button>
<button
*ngIf="value.qr && parent"
tuiIconButton
appearance="icon"
size="s"
type="button"
tabindex="-1"
iconLeft="tuiIconGrid"
[style.pointer-events]="'auto'"
(click)="show(qr)"
>
Show QR
</button>
</ng-template>
<ng-template #qr>
<qr-code
[value]="value.value"
[style.filter]="value.masked && masked ? 'blur(0.5rem)' : null"
size="350"
></qr-code>
<button
*ngIf="value.masked && masked"
tuiIconButton
class="reveal"
iconLeft="tuiIconEye"
[style.border-radius.%]="100"
(click)="masked = false"
>
Reveal
</button>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
.reveal {
@include center-all();
}
.qr {
position: relative;
text-align: center;
}
`,
],
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiTextfieldControllerModule,
TuiButtonModule,
QrCodeModule,
TuiLabelModule,
],
})
export class ActionSuccessItemComponent {
@ViewChild(TuiTextfieldComponent, { read: ElementRef })
private readonly input!: ElementRef<HTMLInputElement>
private readonly dialogs = inject(TuiDialogService)
readonly parent = inject(ActionSuccessGroupComponent, {
optional: true,
})
@Input()
value!: T.ActionResultV1 & { type: 'string' }
masked = true
get border(): number {
let border = 0
if (this.value.masked) {
border += 2
}
if (this.value.copyable) {
border += 2
}
if (this.value.qr && this.parent) {
border += 2
}
return border
}
show(template: TemplateRef<any>) {
const masked = this.masked
this.masked = this.value.masked
this.dialogs
.open(template, { label: 'Scan this QR', size: 's' })
.subscribe({
complete: () => (this.masked = masked),
})
}
copy() {
const el = this.input.nativeElement
if (!el) {
return
}
el.type = 'text'
el.focus()
el.select()
el.ownerDocument.execCommand('copy')
el.type = this.masked && this.value.masked ? 'password' : 'text'
}
}

View File

@@ -1,12 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { ActionSuccessPage } from './action-success.page'
import { QrCodeModule } from 'ng-qrcode'
@NgModule({
declarations: [ActionSuccessPage],
imports: [CommonModule, IonicModule, QrCodeModule],
exports: [ActionSuccessPage],
})
export class ActionSuccessPageModule {}

View File

@@ -1,35 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>Execution Complete</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()" class="enter-click">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h2 class="ion-padding">{{ actionRes.message }}</h2>
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
<qr-code [value]="actionRes.value" size="240"></qr-code>
</div>
<p *ngIf="!actionRes.copyable">{{ actionRes.value }}</p>
<a
*ngIf="actionRes.copyable"
style="cursor: copy"
(click)="copy(actionRes.value)"
>
<b>{{ actionRes.value }}</b>
<sup>
<ion-icon
name="copy-outline"
style="padding-left: 6px; font-size: small"
></ion-icon>
</sup>
</a>
</div>
</ion-content>

View File

@@ -1,39 +1,36 @@
import { Component, Input } from '@angular/core'
import { ModalController, ToastController } from '@ionic/angular'
import { copyToClipboard } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { CommonModule } from '@angular/common'
import { Component, inject } from '@angular/core'
import { TuiDialogContext, TuiTextfieldControllerModule } from '@taiga-ui/core'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { RR } from 'src/app/services/api/api.types'
import { ActionSuccessGroupComponent } from './action-success-group.component'
import { ActionSuccessItemComponent } from './action-success-item.component'
@Component({
selector: 'action-success',
templateUrl: './action-success.page.html',
styleUrls: ['./action-success.page.scss'],
standalone: true,
template: `
<ng-container tuiTextfieldSize="m" [tuiTextfieldLabelOutside]="true">
<app-action-success-item
*ngIf="item"
[value]="item"
></app-action-success-item>
<app-action-success-group
*ngIf="group"
[value]="group"
></app-action-success-group>
</ng-container>
`,
imports: [
CommonModule,
ActionSuccessGroupComponent,
ActionSuccessItemComponent,
TuiTextfieldControllerModule,
],
})
export class ActionSuccessPage {
@Input()
actionRes!: T.ActionResult
readonly data =
inject<TuiDialogContext<void, RR.ActionRes>>(POLYMORPHEUS_CONTEXT).data
constructor(
private readonly modalCtrl: ModalController,
private readonly toastCtrl: ToastController,
) {}
async copy(address: string) {
let message = ''
await copyToClipboard(address || '').then(success => {
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 dismiss() {
return this.modalCtrl.dismiss()
}
readonly item = this.data?.type === 'string' ? this.data : null
readonly group = this.data?.type === 'object' ? this.data : null
}

View File

@@ -5,7 +5,6 @@ import { IonicModule } from '@ionic/angular'
import { AppActionsPage, AppActionsItemComponent } from './app-actions.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharedPipesModule } from '@start9labs/shared'
import { ActionSuccessPageModule } from 'src/app/modals/action-success/action-success.module'
const routes: Routes = [
{
@@ -21,7 +20,6 @@ const routes: Routes = [
RouterModule.forChild(routes),
QRComponentModule,
SharedPipesModule,
ActionSuccessPageModule,
],
declarations: [AppActionsPage, AppActionsItemComponent],
})

View File

@@ -37,8 +37,8 @@
<app-actions-item
*ngFor="let action of pkg.actions"
[action]="action"
icon="play-circle-outline"
(click)="handleAction(pkg.mainStatus, pkg.manifest, action)"
icon="play-outline"
(click)="handleAction(pkg.mainStatus, pkg.icon, pkg.manifest, action)"
></app-actions-item>
</ion-item-group>
</ion-content>

View File

@@ -21,13 +21,12 @@ export class AppActionsPage {
filter(pkg => pkg.stateInfo.state === 'installed'),
map(pkg => ({
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.keys(pkg.actions)
.filter(id => id !== 'config')
.map(id => ({
id,
...pkg.actions[id],
})),
actions: Object.keys(pkg.actions).map(id => ({
id,
...pkg.actions[id],
})),
})),
)
@@ -40,13 +39,14 @@ export class AppActionsPage {
async handleAction(
mainStatus: T.MainStatus['main'],
icon: string,
manifest: T.Manifest,
action: T.ActionMetadata & { id: string },
) {
this.actionService.present(
{ id: manifest.id, title: manifest.title, mainStatus },
{ id: action.id, metadata: action },
)
this.actionService.present({
pkgInfo: { id: manifest.id, title: manifest.title, icon, mainStatus },
actionInfo: { id: action.id, metadata: action },
})
}
async rebuild(id: string) {
@@ -76,7 +76,7 @@ export class AppActionsItemComponent {
get disabledText() {
return (
typeof this.action.visibility === 'object' &&
this.action.visibility.disabled.reason
this.action.visibility.disabled
)
}
}

View File

@@ -1,26 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppMetricsPage } from './app-metrics.page'
import { SharedPipesModule } from '@start9labs/shared'
import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/skeleton-list.component.module'
const routes: Routes = [
{
path: '',
component: AppMetricsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
SkeletonListComponentModule,
],
declarations: [AppMetricsPage],
})
export class AppMetricsPageModule {}

View File

@@ -1,25 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Monitor</ion-title>
<ion-title slot="end">
<ion-spinner name="dots" class="fader"></ion-spinner>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding with-widgets">
<skeleton-list *ngIf="loading"></skeleton-list>
<ion-item-group *ngIf="!loading">
<ion-item *ngFor="let metric of metrics | keyvalue : asIsOrder">
<ion-label>{{ metric.key }}</ion-label>
<ion-note *ngIf="metric.value" slot="end" class="metric-note">
<ion-text style="color: white">
{{ metric.value.value }} {{ metric.value.unit }}
</ion-text>
</ion-note>
</ion-item>
</ion-item-group>
</ion-content>

View File

@@ -1,3 +0,0 @@
.metric-note {
font-size: 16px;
}

View File

@@ -1,59 +0,0 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ErrorService, getPkgId, pauseFor } from '@start9labs/shared'
import { Metric } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: 'app-metrics',
templateUrl: './app-metrics.page.html',
styleUrls: ['./app-metrics.page.scss'],
})
export class AppMetricsPage {
loading = true
readonly pkgId = getPkgId(this.route)
going = false
metrics?: Metric
constructor(
private readonly route: ActivatedRoute,
private readonly errorService: ErrorService,
private readonly embassyApi: ApiService,
) {}
ngOnInit() {
this.startDaemon()
}
ngOnDestroy() {
this.stopDaemon()
}
async startDaemon(): Promise<void> {
this.going = true
while (this.going) {
const startTime = Date.now()
await this.getMetrics()
await pauseFor(Math.max(4000 - (Date.now() - startTime), 0))
}
}
stopDaemon() {
this.going = false
}
async getMetrics(): Promise<void> {
try {
this.metrics = await this.embassyApi.getPkgMetrics({ id: this.pkgId })
} catch (e: any) {
this.errorService.handleError(e)
this.stopDaemon()
} finally {
this.loading = false
}
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -1,32 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppPropertiesPage } from './app-properties.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { MaskPipeModule } from 'src/app/pipes/mask/mask.module'
import {
SharedPipesModule,
TextSpinnerComponentModule,
} from '@start9labs/shared'
const routes: Routes = [
{
path: '',
component: AppPropertiesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
QRComponentModule,
SharedPipesModule,
TextSpinnerComponentModule,
MaskPipeModule,
],
declarations: [AppPropertiesPage],
})
export class AppPropertiesPageModule {}

View File

@@ -1,119 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Properties</ion-title>
<ion-buttons *ngIf="!loading" slot="end">
<ion-button (click)="refresh()">
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<text-spinner
*ngIf="loading; else loaded"
text="Loading Properties"
></text-spinner>
<ng-template #loaded>
<!-- not running -->
<ion-item *ngIf="stopped$ | async" class="ion-margin-bottom">
<ion-label>
<p>
<ion-text color="warning">
Service is stopped. Information on this page could be inaccurate.
</ion-text>
</p>
</ion-label>
</ion-item>
<!-- no properties -->
<ion-item *ngIf="properties | empty">
<ion-label>
<p>No properties.</p>
</ion-label>
</ion-item>
<!-- properties -->
<ion-item-group *ngIf="!(properties | empty)">
<div *ngFor="let prop of node | keyvalue: asIsOrder">
<!-- object -->
<ion-item
button
detail="true"
*ngIf="prop.value.type === 'object'"
(click)="goToNested(prop.key)"
>
<ion-button
*ngIf="prop.value.description"
fill="clear"
slot="start"
(click)="presentDescription(prop, $event)"
>
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
</ion-label>
</ion-item>
<!-- not object -->
<ion-item *ngIf="prop.value.type === 'string'">
<ion-button
*ngIf="prop.value.description"
fill="clear"
slot="start"
(click)="presentDescription(prop, $event)"
>
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
<p class="courier-new">
{{ prop.value.masked && !unmasked[prop.key] ? (prop.value.value |
mask : 64) : prop.value.value }}
</p>
</ion-label>
<div slot="end" *ngIf="prop.value.copyable || prop.value.qr">
<ion-button
*ngIf="prop.value.masked"
fill="clear"
(click)="toggleMask(prop.key)"
>
<ion-icon
slot="icon-only"
[name]="unmasked[prop.key] ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
<ion-button
*ngIf="prop.value.qr"
fill="clear"
(click)="showQR(prop.value.value)"
>
<ion-icon
slot="icon-only"
name="qr-code-outline"
size="small"
></ion-icon>
</ion-button>
<ion-button
*ngIf="prop.value.copyable"
fill="clear"
(click)="copy(prop.value.value)"
>
<ion-icon
slot="icon-only"
name="copy-outline"
size="small"
></ion-icon>
</ion-button>
</div>
</ion-item>
</div>
</ion-item-group>
</ng-template>
</ion-content>

View File

@@ -1,3 +0,0 @@
.metric-note {
font-size: 16px;
}

View File

@@ -1,147 +0,0 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import {
AlertController,
IonBackButtonDelegate,
ModalController,
NavController,
ToastController,
} from '@ionic/angular'
import { copyToClipboard, ErrorService, getPkgId } from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { getValueByPointer } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { map, takeUntil } from 'rxjs/operators'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PackageProperties } from 'src/app/util/properties.util'
@Component({
selector: 'app-properties',
templateUrl: './app-properties.page.html',
styleUrls: ['./app-properties.page.scss'],
providers: [TuiDestroyService],
})
export class AppPropertiesPage {
loading = true
readonly pkgId = getPkgId(this.route)
pointer = ''
node: PackageProperties = {}
properties: PackageProperties = {}
unmasked: { [key: string]: boolean } = {}
stopped$ = this.patch
.watch$('packageData', this.pkgId, 'status', 'main')
.pipe(map(status => status === 'stopped'))
@ViewChild(IonBackButtonDelegate, { static: false })
backButton?: IonBackButtonDelegate
constructor(
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly errorService: ErrorService,
private readonly alertCtrl: AlertController,
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly destroy$: TuiDestroyService,
) {}
ionViewDidEnter() {
if (!this.backButton) return
this.backButton.onClick = () => {
history.back()
}
}
async ngOnInit() {
await this.getProperties()
this.route.queryParams
.pipe(takeUntil(this.destroy$))
.subscribe(queryParams => {
if (queryParams['pointer'] === this.pointer) return
this.pointer = queryParams['pointer'] || ''
this.node = getValueByPointer(this.properties, this.pointer)
})
}
async refresh() {
await this.getProperties()
}
async presentDescription(
property: { key: string; value: PackageProperties[''] },
e: Event,
) {
e.stopPropagation()
const alert = await this.alertCtrl.create({
header: property.key,
message: property.value.description || undefined,
})
await alert.present()
}
async goToNested(key: string): Promise<any> {
this.navCtrl.navigateForward(`/services/${this.pkgId}/properties`, {
queryParams: {
pointer: `${this.pointer}/${key}/value`,
},
})
}
async copy(text: string): Promise<void> {
let message = ''
await copyToClipboard(text).then(success => {
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 showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
toggleMask(key: string) {
this.unmasked[key] = !this.unmasked[key]
}
private async getProperties(): Promise<void> {
this.loading = true
try {
this.properties = await this.embassyApi.getPackageProperties({
id: this.pkgId,
})
this.node = getValueByPointer(this.properties, this.pointer)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading = false
}
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -19,6 +19,7 @@ import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.c
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 { AppShowActionRequestsComponent } from './components/app-show-action-requests/app-show-action-requests.component'
import { HealthColorPipe } from './pipes/health-color.pipe'
import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
@@ -45,6 +46,7 @@ const routes: Routes = [
AppShowHealthChecksComponent,
AppShowAdditionalComponent,
AppShowErrorComponent,
AppShowActionRequestsComponent,
],
imports: [
CommonModule,

View File

@@ -20,6 +20,12 @@
<ng-container
*ngIf="isInstalled(pkg) && status.primary !== 'backingUp' && status.primary !== 'error'"
>
<!-- ** action requests ** -->
<app-show-action-requests
[allPkgs]="pkgPlus.allPkgs"
[pkg]="pkg"
[manifest]="pkgPlus.manifest"
></app-show-action-requests>
<!-- ** health checks ** -->
<app-show-health-checks
*ngIf="pkg.status.main === 'running'"

View File

@@ -20,14 +20,13 @@ import {
import { combineLatest } from 'rxjs'
import {
getManifest,
getPackage,
isInstalled,
isInstalling,
isRestoring,
isUpdating,
} from 'src/app/util/get-package-data'
import { T } from '@start9labs/start-sdk'
import { ActionService } from 'src/app/services/action.service'
import { getDepDetails } from 'src/app/util/dep-info'
export interface DependencyInfo {
id: string
@@ -35,7 +34,7 @@ export interface DependencyInfo {
icon: string
version: string
errorText: string
actionText: string
actionText: string | null
action: () => any
}
@@ -60,6 +59,7 @@ export class AppShowPage {
const pkg = allPkgs[this.pkgId]
const manifest = getManifest(pkg)
return {
allPkgs,
pkg,
manifest,
dependencies: this.getDepInfo(pkg, manifest, allPkgs, depErrors),
@@ -75,7 +75,6 @@ export class AppShowPage {
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly depErrorService: DepErrorService,
private readonly actionService: ActionService,
) {}
showProgress(
@@ -95,32 +94,6 @@ export class AppShowPage {
)
}
private getDepDetails(
pkg: PackageDataEntry,
allPkgs: AllPackageData,
depId: string,
) {
const { title, icon, versionRange } = pkg.currentDependencies[depId]
if (
allPkgs[depId] &&
(allPkgs[depId].stateInfo.state === 'installed' ||
allPkgs[depId].stateInfo.state === 'updating')
) {
return {
title: allPkgs[depId].stateInfo.manifest!.title,
icon: allPkgs[depId].icon,
versionRange,
}
} else {
return {
title: title || depId,
icon: icon || 'assets/img/service-icons/fallback.png',
versionRange,
}
}
}
private getDepValues(
pkg: PackageDataEntry,
allPkgs: AllPackageData,
@@ -135,23 +108,16 @@ export class AppShowPage {
depErrors,
)
const { title, icon, versionRange } = this.getDepDetails(
pkg,
allPkgs,
depId,
)
const { title, icon, versionRange } = getDepDetails(pkg, allPkgs, depId)
return {
id: depId,
version: versionRange,
title,
icon,
errorText: errorText
? `${errorText}. ${manifest.title} will not work as expected.`
: '',
actionText: fixText || 'View',
action:
fixAction || (() => this.navCtrl.navigateForward(`/services/${depId}`)),
errorText: errorText ? errorText : '',
actionText: fixText,
action: fixAction,
}
}
@@ -165,28 +131,31 @@ export class AppShowPage {
let errorText: string | null = null
let fixText: string | null = null
let fixAction: (() => any) | null = null
let fixAction: () => any = () => {}
if (depError) {
if (depError.type === 'notInstalled') {
errorText = 'Not installed'
fixText = 'Install'
fixAction = () => this.fixDep(pkg, manifest, 'install', depId)
fixAction = () => this.installDep(pkg, manifest, depId)
} else if (depError.type === 'incorrectVersion') {
errorText = 'Incorrect version'
fixText = 'Update'
fixAction = () => this.fixDep(pkg, manifest, 'update', depId)
} else if (depError.type === 'configUnsatisfied') {
errorText = 'Config not satisfied'
fixText = 'Auto config'
fixAction = () => this.fixDep(pkg, manifest, 'configure', depId)
fixAction = () => this.installDep(pkg, manifest, depId)
} else if (depError.type === 'actionRequired') {
errorText = 'Action Required (see above)'
} else if (depError.type === 'notRunning') {
errorText = 'Not running'
fixText = 'Start'
fixAction = () => this.navCtrl.navigateForward(`/services/${depId}`)
} else if (depError.type === 'healthChecksFailed') {
errorText = 'Required health check not passing'
fixText = 'View'
fixAction = () => this.navCtrl.navigateForward(`/services/${depId}`)
} else if (depError.type === 'transitive') {
errorText = 'Dependency has a dependency issue'
fixText = 'View'
fixAction = () => this.navCtrl.navigateForward(`/services/${depId}`)
}
}
@@ -197,41 +166,6 @@ export class AppShowPage {
}
}
private async fixDep(
pkg: PackageDataEntry,
pkgManifest: T.Manifest,
action: 'install' | 'update' | 'configure',
depId: string,
) {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkg, pkgManifest, depId)
case 'configure':
const depPkg = await getPackage(this.patch, depId)
if (!depPkg) return
const depManifest = getManifest(depPkg)
return this.actionService.present(
{
id: depId,
title: depManifest.title,
mainStatus: depPkg.status.main,
},
{ id: 'config', metadata: pkg.actions['config'] },
{
title: pkgManifest.title,
request: Object.values(pkg.requestedActions).find(
r =>
r.active &&
r.request.packageId === depId &&
r.request.actionId === 'config',
)!.request,
},
)
}
}
private async installDep(
pkg: PackageDataEntry,
pkgManifest: T.Manifest,

View File

@@ -0,0 +1,45 @@
<ng-container *ngIf="actionRequests.critical.length">
<ion-item-divider>Required Actions</ion-item-divider>
<ion-item
*ngFor="let request of actionRequests.critical"
button
(click)="handleAction(request)"
>
<ion-icon slot="start" name="warning-outline" color="warning"></ion-icon>
<ion-label>
<h2 class="highlighted">{{ request.actionName }}</h2>
<p *ngIf="request.dependency" class="dependency">
<span class="light">Service:</span>
<img [src]="request.dependency.icon" alt="" />
{{ request.dependency.title }}
</p>
<p>
<span class="light">Reason:</span>
{{ request.reason || 'no reason provided' }}
</p>
</ion-label>
</ion-item>
</ng-container>
<ng-container *ngIf="actionRequests.important.length">
<ion-item-divider>Requested Actions</ion-item-divider>
<ion-item
*ngFor="let request of actionRequests.important"
button
(click)="handleAction(request)"
>
<ion-icon slot="start" name="play-outline" color="warning"></ion-icon>
<ion-label>
<h2 class="highlighted">{{ request.actionName }}</h2>
<p *ngIf="request.dependency" class="dependency">
<span class="light">Service:</span>
<img [src]="request.dependency.icon" alt="" />
{{ request.dependency.title }}
</p>
<p>
<span class="light">Reason:</span>
{{ request.reason || 'no reason provided' }}
</p>
</ion-label>
</ion-item>
</ng-container>

View File

@@ -0,0 +1,16 @@
.light {
color: var(--ion-color-dark);
}
.highlighted {
color: var(--ion-color-dark);
font-weight: bold;
}
.dependency {
display: inline-flex;
img {
max-width: 16px;
margin: 0 2px 0 5px;
}
}

View File

@@ -0,0 +1,94 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { ActionService } from 'src/app/services/action.service'
import { getDepDetails } from 'src/app/util/dep-info'
@Component({
selector: 'app-show-action-requests',
templateUrl: './app-show-action-requests.component.html',
styleUrls: ['./app-show-action-requests.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowActionRequestsComponent {
@Input()
allPkgs!: Record<string, T.PackageDataEntry>
@Input()
pkg!: T.PackageDataEntry
@Input()
manifest!: T.Manifest
get actionRequests() {
const critical: (T.ActionRequest & {
actionName: string
dependency: {
title: string
icon: string
} | null
})[] = []
const important: (T.ActionRequest & {
actionName: string
dependency: {
title: string
icon: string
} | null
})[] = []
Object.values(this.pkg.requestedActions)
.filter(r => r.active)
.forEach(r => {
const self = r.request.packageId === this.manifest.id
const toReturn = {
...r.request,
actionName: self
? this.pkg.actions[r.request.actionId].name
: this.allPkgs[r.request.packageId]?.actions[r.request.actionId]
.name || 'Unknown Action',
dependency: self
? null
: getDepDetails(this.pkg, this.allPkgs, r.request.packageId),
}
if (r.request.severity === 'critical') {
critical.push(toReturn)
} else {
important.push(toReturn)
}
})
return { critical, important }
}
constructor(private readonly actionService: ActionService) {}
async handleAction(request: T.ActionRequest) {
const self = request.packageId === this.manifest.id
this.actionService.present({
pkgInfo: {
id: request.packageId,
title: self
? this.manifest.title
: getDepDetails(this.pkg, this.allPkgs, request.packageId).title,
mainStatus: self
? this.pkg.status.main
: this.allPkgs[request.packageId].status.main,
icon: self
? this.pkg.icon
: getDepDetails(this.pkg, this.allPkgs, request.packageId).icon,
},
actionInfo: {
id: request.actionId,
metadata:
request.packageId === this.manifest.id
? this.pkg.actions[request.actionId]
: this.allPkgs[request.packageId].actions[request.actionId],
},
requestInfo: {
request,
dependentId:
request.packageId === this.manifest.id ? undefined : this.manifest.id,
},
})
}
}

View File

@@ -1,6 +1,10 @@
<ion-item-divider>Dependencies</ion-item-divider>
<!-- dependencies are a subset of the pkg.manifest.dependencies that are currently required as determined by the service config -->
<ion-item button *ngFor="let dep of dependencies" (click)="dep.action()">
<ion-item
[button]="!!dep.actionText"
*ngFor="let dep of dependencies"
(click)="dep.action()"
>
<ion-thumbnail slot="start">
<img [src]="dep.icon" alt="" />
</ion-thumbnail>

View File

@@ -52,16 +52,6 @@
Start
</ion-button>
<ion-button
*ngIf="needsConfig(manifest.id, pkg.requestedActions)"
class="action-button"
color="warning"
(click)="presentModalConfig()"
>
<ion-icon slot="start" name="construct-outline"></ion-icon>
Configure
</ion-button>
<ion-button
*ngIf="pkgStatus && interfaces && (interfaces | hasUi) && hosts"
class="action-button"

View File

@@ -3,7 +3,6 @@ import { AlertController } from '@ionic/angular'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
import {
@@ -19,7 +18,6 @@ import {
getAllPackages,
getManifest,
isInstalled,
needsConfig,
} from 'src/app/util/get-package-data'
import { hasCurrentDeps } from 'src/app/util/has-deps'
@@ -39,7 +37,6 @@ export class AppShowStatusComponent {
PR = PrimaryRendering
isInstalled = isInstalled
needsConfig = needsConfig
constructor(
private readonly alertCtrl: AlertController,
@@ -49,7 +46,6 @@ export class AppShowStatusComponent {
private readonly launcherService: UiLauncherService,
readonly connection$: ConnectionService,
private readonly patch: PatchDB<DataModel>,
private readonly actionService: ActionService,
) {}
get interfaces(): PackageDataEntry['serviceInterfaces'] {
@@ -77,14 +73,11 @@ export class AppShowStatusComponent {
}
get canStart(): boolean {
return (
this.status.primary === 'stopped' &&
!Object.keys(this.pkg.requestedActions).length
)
return this.status.primary === 'stopped'
}
get sigtermTimeout(): string | null {
return this.pkgStatus?.main === 'stopping' ? '30s' : null // @dr-bonez TODO
return this.pkgStatus?.main === 'stopping' ? '30s' : null // @TODO Aiden
}
launchUi(
@@ -94,17 +87,6 @@ export class AppShowStatusComponent {
this.launcherService.launch(interfaces, hosts)
}
async presentModalConfig(): Promise<void> {
return this.actionService.present(
{
id: this.manifest.id,
title: this.manifest.title,
mainStatus: this.pkg.status.main,
},
{ id: 'config', metadata: this.pkg.actions['config'] },
)
}
async tryStart(): Promise<void> {
if (this.status.dependency === 'warning') {
const depErrMsg = `${this.manifest.title} has unmet dependencies. It will not work as expected.`
@@ -221,6 +203,7 @@ export class AppShowStatusComponent {
loader.unsubscribe()
}
}
private async presentAlertStart(message: string): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({

View File

@@ -1,6 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AlertController, ModalController, NavController } from '@ionic/angular'
import { ModalController, NavController } from '@ionic/angular'
import { MarkdownComponent } from '@start9labs/shared'
import {
DataModel,
@@ -8,10 +8,8 @@ import {
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable, of } from 'rxjs'
import { from, map, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { ActionService } from 'src/app/services/action.service'
import { needsConfig } from 'src/app/util/get-package-data'
export interface Button {
title: string
@@ -33,8 +31,6 @@ export class ToButtonsPipe implements PipeTransform {
private readonly apiService: ApiService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly actionService: ActionService,
private readonly alertCtrl: AlertController,
) {}
transform(pkg: PackageDataEntry<InstalledState>): Button[] {
@@ -51,50 +47,12 @@ export class ToButtonsPipe implements PipeTransform {
.watch$('ui', 'ackInstructions', manifest.id)
.pipe(map(seen => !seen)),
},
// config
{
action: async () =>
pkg.actions['config']
? this.actionService.present(
{
id: manifest.id,
title: manifest.title,
mainStatus: pkg.status.main,
},
{
id: 'config',
metadata: pkg.actions['config'],
},
)
: this.alertCtrl
.create({
header: 'No Config',
message: `No config options for ${manifest.title} v${manifest.version}`,
buttons: ['OK'],
})
.then(a => a.present()),
title: 'Config',
description: `Customize ${manifest.title}`,
icon: 'options-outline',
highlighted$: of(needsConfig(manifest.id, pkg.requestedActions)),
},
// properties
{
action: () =>
this.navCtrl.navigateForward(['properties'], {
relativeTo: this.route,
}),
title: 'Properties',
description:
'Runtime information, credentials, and other values of interest',
icon: 'briefcase-outline',
},
// actions
{
action: () =>
this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }),
title: 'Actions',
description: `Uninstall and other commands specific to ${manifest.title}`,
description: `All actions for ${manifest.title}`,
icon: 'flash-outline',
},
// interfaces

View File

@@ -36,20 +36,6 @@ const routes: Routes = [
loadChildren: () =>
import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
},
{
path: ':pkgId/metrics',
loadChildren: () =>
import('./app-metrics/app-metrics.module').then(
m => m.AppMetricsPageModule,
),
},
{
path: ':pkgId/properties',
loadChildren: () =>
import('./app-properties/app-properties.module').then(
m => m.AppPropertiesPageModule,
),
},
]
@NgModule({

View File

@@ -1,9 +1,9 @@
import { Injectable } from '@angular/core'
import { AlertController, ModalController } from '@ionic/angular'
import { AlertController } from '@ionic/angular'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
@@ -31,28 +31,16 @@ const allowedStatuses = {
export class ActionService {
constructor(
private readonly api: ApiService,
private readonly modalCtrl: ModalController,
private readonly dialogs: TuiDialogService,
private readonly alertCtrl: AlertController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly formDialog: FormDialogService,
) {}
async present(
pkgInfo: {
id: string
title: string
mainStatus: T.MainStatus['main']
},
actionInfo: {
id: string
metadata: T.ActionMetadata
},
dependentInfo?: {
title: string
request: T.ActionRequest
},
) {
async present(data: PackageActionData) {
const { pkgInfo, actionInfo } = data
if (
allowedStatuses[actionInfo.metadata.allowedStatuses].has(
pkgInfo.mainStatus,
@@ -61,36 +49,32 @@ export class ActionService {
if (actionInfo.metadata.hasInput) {
this.formDialog.open<PackageActionData>(ActionInputModal, {
label: actionInfo.metadata.name,
data: {
pkgInfo,
actionInfo: {
id: actionInfo.id,
warning: actionInfo.metadata.warning,
},
dependentInfo,
},
data,
})
} else {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to execute action "${
actionInfo.metadata.name
}"? ${actionInfo.metadata.warning || ''}`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Execute',
handler: () => {
this.execute(pkgInfo.id, actionInfo.id)
if (actionInfo.metadata.warning) {
const alert = await this.alertCtrl.create({
header: 'Warning',
message: actionInfo.metadata.warning,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
cssClass: 'enter-click',
},
],
})
await alert.present()
{
text: 'Run',
handler: () => {
this.execute(pkgInfo.id, actionInfo.id)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
} else {
this.execute(pkgInfo.id, actionInfo.id)
}
}
} else {
const statuses = [...allowedStatuses[actionInfo.metadata.allowedStatuses]]
@@ -123,30 +107,24 @@ export class ActionService {
async execute(
packageId: string,
actionId: string,
inputs?: {
prev: RR.GetActionInputRes
curr: object
},
input?: object,
): Promise<boolean> {
const loader = this.loader.open('Executing action...').subscribe()
const loader = this.loader.open('Loading...').subscribe()
try {
const res = await this.api.runAction({
packageId,
actionId,
prev: inputs?.prev || null,
input: inputs?.curr || null,
input: input || null,
})
if (res) {
const successModal = await this.modalCtrl.create({
component: ActionSuccessPage,
componentProps: {
actionRes: res,
},
})
setTimeout(() => successModal.present(), 500)
this.dialogs
.open(new PolymorpheusComponent(ActionSuccessPage), {
label: res.name,
data: res,
})
.subscribe()
}
return true // needed to dismiss original modal/alert
} catch (e: any) {

View File

@@ -2,18 +2,13 @@ import {
InstalledState,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { Metric, NotificationLevel, RR, ServerNotifications } from './api.types'
import { NotificationLevel, RR, ServerNotifications } from './api.types'
import { BTC_ICON, LND_ICON, PROXY_ICON, REGISTRY_ICON } from './api-icons'
import { Log } from '@start9labs/shared'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { T, ISB, IST } from '@start9labs/start-sdk'
import { GetPackagesRes } from '@start9labs/marketplace'
const mockBlake3Commitment: T.Blake3Commitment = {
hash: 'fakehash',
size: 0,
}
const mockMerkleArchiveCommitment: T.MerkleArchiveCommitment = {
rootSighash: 'fakehash',
rootMaxsize: 0,
@@ -880,25 +875,6 @@ export module Mock {
}
}
export function getAppMetrics() {
const metr: Metric = {
Metric1: {
value: Math.random(),
unit: 'mi/b',
},
Metric2: {
value: Math.random(),
unit: '%',
},
Metric3: {
value: 10.1,
unit: '%',
},
}
return metr
}
export const ServerLogs: Log[] = [
{
timestamp: '2022-07-28T03:52:54.808769Z',
@@ -946,15 +922,6 @@ export module Mock {
},
}
export const ActionResponse: T.ActionResult = {
version: '0',
message:
'Password changed successfully. If you lose your new password, you will be lost forever.',
value: 'NewPassword1234!',
copyable: true,
qr: true,
}
export const SshKeys: RR.GetSSHKeysRes = [
{
createdAt: new Date().toISOString(),
@@ -1082,11 +1049,26 @@ export module Mock {
},
}
export const PackageProperties: RR.GetPackagePropertiesRes<2> = {
version: 2,
data: {
lndconnect: {
export const ActionRes: RR.ActionRes = {
version: '1',
type: 'string',
name: 'New Password',
description:
'Action was run successfully Action was run successfully Action was run successfully Action was run successfully Action was run successfully',
copyable: true,
qr: true,
masked: true,
value: 'iwejdoiewdhbew',
}
export const ActionProperties: RR.ActionRes = {
version: '1',
type: 'object',
name: 'Properties',
value: [
{
type: 'string',
name: 'LND Connect',
description: 'This is some information about the thing.',
copyable: true,
qr: true,
@@ -1094,45 +1076,50 @@ export module Mock {
value:
'lndconnect://udlyfq2mxa4355pt7cqlrdipnvk2tsl4jtsdw7zaeekenufwcev2wlad.onion:10009?cert=MIICJTCCAcugAwIBAgIRAOyq85fqAiA3U3xOnwhH678wCgYIKoZIzj0EAwIwODEfMB0GAkUEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNTc0OTkwMzIyYzZlMB4XDTIwMTAyNjA3MzEyN1oXDTIxMTIyMTA3MzEyN1owODEfMB0GA1UEChMWbG5kIGF1dG9nZW5lcmF0ZWQgY2VydDEVMBMGA1UEAxMMNTc0OTkwMzIyYzZlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKqfhAMMZdY-eFnU5P4bGrQTSx0lo7m8u4V0yYkzUM6jlql_u31_mU2ovLTj56wnZApkEjoPl6fL2yasZA2wiy6OBtTCBsjAOBgNVHQ8BAf8EBAMCAqQwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0TAQH_BAUwAwEB_zAdBgNVHQ4EFgQUYQ9uIO6spltnVCx4rLFL5BvBF9IwWwYDVR0RBFQwUoIMNTc0OTkwMzIyYzZlgglsb2NhbGhvc3SCBHVuaXiCCnVuaXhwYWNrZXSCB2J1ZmNvbm6HBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAGHBKwSAAswCgYIKoZIzj0EAwIDSAAwRQIgVZH2Z2KlyAVY2Q2aIQl0nsvN-OEN49wreFwiBqlxNj4CIQD5_JbpuBFJuf81I5J0FQPtXY-4RppWOPZBb-y6-rkIUQ&macaroon=AgEDbG5kAusBAwoQuA8OUMeQ8Fr2h-f65OdXdRIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaFAoIbWFjYXJvb24SCGdlbmVyYXRlGhYKB21lc3NhZ2USBHJlYWQSBXdyaXRlGhcKCG9mZmNoYWluEgRyZWFkEgV3cml0ZRoWCgdvbmNoYWluEgRyZWFkEgV3cml0ZRoUCgVwZWVycxIEcmVhZBIFd3JpdGUaGAoGc2lnbmVyEghnZW5lcmF0ZRIEcmVhZAAABiCYsRUoUWuAHAiCSLbBR7b_qULDSl64R8LIU2aqNIyQfA',
},
Nested: {
{
type: 'object',
name: 'Nested Stuff',
description: 'This is a nested thing metric',
value: {
'Last Name': {
value: [
{
type: 'string',
name: 'Last Name',
description: 'The last name of the user',
copyable: true,
qr: true,
masked: false,
value: 'Hill',
},
Age: {
{
type: 'string',
name: 'Age',
description: 'The age of the user',
copyable: false,
qr: false,
masked: false,
value: '35',
},
Password: {
{
type: 'string',
name: 'Password',
description: 'A secret password',
copyable: true,
qr: false,
masked: true,
value: 'password123',
},
},
],
},
'Another Value': {
{
type: 'string',
name: 'Another Value',
description: 'Some more information about the service.',
copyable: false,
qr: true,
masked: false,
value: 'https://guessagain.com',
},
},
],
}
export const getActionInputSpec = async (): Promise<IST.InputSpec> =>
@@ -1692,7 +1679,7 @@ export module Mock {
},
actions: {
config: {
name: 'Bitcoin Config',
name: 'Set Config',
description: 'edit bitcoin.conf',
warning: null,
visibility: 'enabled',
@@ -1700,6 +1687,25 @@ export module Mock {
hasInput: true,
group: null,
},
properties: {
name: 'View Properties',
description: 'view important information about Bitcoin',
warning: null,
visibility: 'enabled',
allowedStatuses: 'any',
hasInput: false,
group: null,
},
test: {
name: 'Do Another Thing',
description:
'An example of an action that shows a warning and takes no input',
warning: 'careful running this action',
visibility: 'enabled',
allowedStatuses: 'only-running',
hasInput: false,
group: null,
},
},
serviceInterfaces: {
ui: {
@@ -1859,7 +1865,27 @@ export module Mock {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind-config': {
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason:
'You must run Config before starting Bitcoin for the first time',
},
active: true,
},
'bitcoind-properties': {
request: {
packageId: 'bitcoind',
actionId: 'properties',
severity: 'important',
reason: 'Check out all the info about your Bitcoin node',
},
active: true,
},
},
}
export const bitcoinProxy: PackageDataEntry<InstalledState> = {
@@ -1992,7 +2018,27 @@ export module Mock {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind/config': {
active: true,
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason: 'LND likes BTC a certain way',
input: {
kind: 'partial',
value: {
color: '#ffffff',
rpcsettings: {
rpcuser: 'lnd',
},
testnet: false,
},
},
},
},
},
}
export const LocalPkgs: { [key: string]: PackageDataEntry<InstalledState> } =

View File

@@ -1,5 +1,4 @@
import { Dump } from 'patch-db-client'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
import { IST, T } from '@start9labs/start-sdk'
@@ -209,19 +208,12 @@ export module RR {
// package
export type GetPackagePropertiesReq = { id: string } // package.properties
export type GetPackagePropertiesRes<T extends number> =
PackagePropertiesVersioned<T>
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = LogsRes
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
export type GetPackageMetricsReq = { id: string } // package.metrics
export type GetPackageMetricsRes = Metric
export type InstallPackageReq = T.InstallParams
export type InstallPackageRes = null
@@ -231,13 +223,12 @@ export module RR {
value: object | null
}
export type RunActionReq = {
export type ActionReq = {
packageId: string
actionId: string
prev: GetActionInputRes | null
input: object | null
} // package.action.run
export type RunActionRes = T.ActionResult | null
export type ActionRes = (T.ActionResult & { version: '1' }) | null
export type RestorePackagesReq = {
// package.backup.restore
@@ -494,7 +485,7 @@ export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
| DependencyErrorIncorrectVersion
| DependencyErrorConfigUnsatisfied
| DependencyErrorActionRequired
| DependencyErrorHealthChecksFailed
| DependencyErrorTransitive
@@ -512,8 +503,8 @@ export interface DependencyErrorIncorrectVersion {
received: string // version
}
export interface DependencyErrorConfigUnsatisfied {
type: 'configUnsatisfied'
export interface DependencyErrorActionRequired {
type: 'actionRequired'
}
export interface DependencyErrorHealthChecksFailed {

View File

@@ -113,10 +113,6 @@ export abstract class ApiService {
params: RR.GetServerMetricsReq,
): Promise<RR.GetServerMetricsRes>
abstract getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes>
abstract updateServer(url?: string): Promise<RR.UpdateServerRes>
abstract restartServer(
@@ -215,10 +211,6 @@ export abstract class ApiService {
// package
abstract getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']>
abstract getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes>
@@ -235,7 +227,7 @@ export abstract class ApiService {
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes>
abstract runAction(params: RR.RunActionReq): Promise<RR.RunActionRes>
abstract runAction(params: RR.ActionReq): Promise<RR.ActionRes>
abstract restorePackages(
params: RR.RestorePackagesReq,

View File

@@ -10,7 +10,6 @@ import {
import { PATCH_CACHE } from 'src/app/services/patch-db/patch-db-source'
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 } from 'rxjs/webSocket'
import { Observable, filter, firstValueFrom } from 'rxjs'
@@ -436,14 +435,6 @@ export class LiveApiService extends ApiService {
// package
async getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
return this.rpcRequest({ method: 'package.properties', params }).then(
parsePropertiesPermissive,
)
}
async getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> {
@@ -456,12 +447,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.logs.follow', params })
}
async getPkgMetrics(
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes> {
return this.rpcRequest({ method: 'package.metrics', params })
}
async installPackage(
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {
@@ -474,7 +459,7 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.action.get-input', params })
}
async runAction(params: RR.RunActionReq): Promise<RR.RunActionRes> {
async runAction(params: RR.ActionReq): Promise<RR.ActionRes> {
return this.rpcRequest({ method: 'package.action.run', params })
}

View File

@@ -17,7 +17,6 @@ import {
UpdatingState,
} from 'src/app/services/patch-db/data-model'
import { CifsBackupTarget, RR } from './api.types'
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 {
@@ -368,13 +367,6 @@ export class MockApiService extends ApiService {
return Mock.getServerMetrics()
}
async getPkgMetrics(
params: RR.GetServerMetricsReq,
): Promise<RR.GetPackageMetricsRes> {
await pauseFor(2000)
return Mock.getAppMetrics()
}
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
await pauseFor(2000)
const initialProgress = {
@@ -707,13 +699,6 @@ export class MockApiService extends ApiService {
// package
async getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
await pauseFor(2000)
return parsePropertiesPermissive(Mock.PackageProperties)
}
async getPackageLogs(
params: RR.GetPackageLogsReq,
): Promise<RR.GetPackageLogsRes> {
@@ -795,9 +780,23 @@ export class MockApiService extends ApiService {
}
}
async runAction(params: RR.RunActionReq): Promise<RR.RunActionRes> {
async runAction(params: RR.ActionReq): Promise<RR.ActionRes> {
await pauseFor(2000)
return Mock.ActionResponse
if (params.actionId === 'properties') {
return Mock.ActionProperties
} else if (params.actionId === 'config') {
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.packageId}/requestedActions/${params.packageId}-config`,
},
]
this.mockRevision(patch)
return null
} else {
return Mock.ActionRes
}
}
async restorePackages(

View File

@@ -61,6 +61,7 @@ export const mockPatchData: DataModel = {
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
versionCompat: '>=0.3.0 <=0.3.6',
postInitMigrationTodos: [],
statusInfo: {
backupProgress: null,
updated: false,
@@ -82,7 +83,6 @@ export const mockPatchData: DataModel = {
selected: null,
lastRegion: null,
},
postInitMigrationTodos: [],
},
packageData: {
bitcoind: {
@@ -107,7 +107,7 @@ export const mockPatchData: DataModel = {
// },
actions: {
config: {
name: 'Bitcoin Config',
name: 'Set Config',
description: 'edit bitcoin.conf',
warning: null,
visibility: 'enabled',
@@ -115,6 +115,25 @@ export const mockPatchData: DataModel = {
hasInput: true,
group: null,
},
properties: {
name: 'View Properties',
description: 'view important information about Bitcoin',
warning: null,
visibility: 'enabled',
allowedStatuses: 'any',
hasInput: false,
group: null,
},
test: {
name: 'Do Another Thing',
description:
'An example of an action that shows a warning and takes no input',
warning: 'careful running this action',
visibility: 'enabled',
allowedStatuses: 'only-running',
hasInput: false,
group: null,
},
},
serviceInterfaces: {
ui: {
@@ -274,7 +293,27 @@ export const mockPatchData: DataModel = {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind-config': {
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason:
'You must run Config before starting Bitcoin for the first time',
},
active: true,
},
'bitcoind-properties': {
request: {
packageId: 'bitcoind',
actionId: 'properties',
severity: 'important',
reason: 'Check out all the info about your Bitcoin node',
},
active: true,
},
},
},
lnd: {
stateInfo: {
@@ -364,7 +403,27 @@ export const mockPatchData: DataModel = {
storeExposedDependents: [],
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
requestedActions: {},
requestedActions: {
'bitcoind/config': {
active: true,
request: {
packageId: 'bitcoind',
actionId: 'config',
severity: 'critical',
reason: 'LND likes BTC a certain way',
input: {
kind: 'partial',
value: {
color: '#ffffff',
rpcsettings: {
rpcuser: 'lnd',
},
testnet: false,
},
},
},
},
},
},
},
}

View File

@@ -101,17 +101,17 @@ export class DepErrorService {
}
}
// invalid config
// action required
if (
Object.values(pkg.requestedActions).some(
a =>
a.active &&
a.request.packageId === depId &&
a.request.actionId === 'config',
a.request.severity === 'critical',
)
) {
return {
type: 'configUnsatisfied',
type: 'actionRequired',
}
}

View File

@@ -1,7 +1,6 @@
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PkgDependencyErrors } from './dep-error.service'
import { T } from '@start9labs/start-sdk'
import { getManifest, needsConfig } from '../util/get-package-data'
export interface PackageStatus {
primary: PrimaryStatus
@@ -29,8 +28,12 @@ export function renderPkgStatus(
}
function getInstalledPrimaryStatus(pkg: T.PackageDataEntry): PrimaryStatus {
if (needsConfig(getManifest(pkg).id, pkg.requestedActions)) {
return 'needsConfig'
if (
Object.values(pkg.requestedActions).some(
r => r.active && r.request.severity === 'critical',
)
) {
return 'actionRequired'
} else {
return pkg.status.main
}
@@ -79,7 +82,7 @@ export type PrimaryStatus =
| 'restarting'
| 'stopped'
| 'backingUp'
| 'needsConfig'
| 'actionRequired'
| 'error'
export type DependencyStatus = 'warning' | 'satisfied'
@@ -135,8 +138,8 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
color: 'success',
showDots: false,
},
needsConfig: {
display: 'Needs Config',
actionRequired: {
display: 'Action Required',
color: 'warning',
showDots: false,
},

View File

@@ -0,0 +1,30 @@
import {
AllPackageData,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
export function getDepDetails(
pkg: PackageDataEntry,
allPkgs: AllPackageData,
depId: string,
) {
const { title, icon, versionRange } = pkg.currentDependencies[depId]
if (
allPkgs[depId] &&
(allPkgs[depId].stateInfo.state === 'installed' ||
allPkgs[depId].stateInfo.state === 'updating')
) {
return {
title: allPkgs[depId].stateInfo.manifest!.title,
icon: allPkgs[depId].icon,
versionRange,
}
} else {
return {
title: title || depId,
icon: icon || 'assets/img/service-icons/fallback.png',
versionRange,
}
}
}

View File

@@ -28,18 +28,6 @@ export function getManifest(pkg: PackageDataEntry): T.Manifest {
return (pkg.stateInfo as InstallingState).installingInfo.newManifest
}
export function needsConfig(
pkgId: string,
requestedActions: PackageDataEntry['requestedActions'],
): boolean {
return Object.values(requestedActions).some(
r =>
r.active &&
r.request.packageId === pkgId &&
r.request.actionId === 'config',
)
}
export function isInstalled(
pkg: PackageDataEntry,
): pkg is PackageDataEntry<InstalledState> {

View File

@@ -20,7 +20,7 @@ export function getPackageInfo(
primaryRendering,
primaryStatus: statuses.primary,
error: statuses.health === 'failure' || statuses.dependency === 'warning',
warning: statuses.primary === 'needsConfig',
warning: statuses.primary === 'actionRequired',
transitioning:
primaryRendering.showDots ||
statuses.health === 'loading' ||

View File

@@ -1,150 +0,0 @@
import { applyOperation } from 'fast-json-patch'
import matches, {
Parser,
shape,
string,
literal,
boolean,
deferred,
dictionary,
anyOf,
number,
arrayOf,
} from 'ts-matches'
type PropertiesV1 = typeof matchPropertiesV1._TYPE
type PackagePropertiesV1 = PropertiesV1[]
type PackagePropertiesV2 = {
[name: string]: PackagePropertyString | PackagePropertyObject
}
type PackagePropertiesVersionedData<T extends number> = T extends 1
? PackagePropertiesV1
: T extends 2
? PackagePropertiesV2
: never
type PackagePropertyString = typeof matchPackagePropertyString._TYPE
export type PackagePropertiesVersioned<T extends number> = {
version: T
data: PackagePropertiesVersionedData<T>
}
export type PackageProperties = PackagePropertiesV2
const matchPropertiesV1 = shape(
{
name: string,
value: string,
description: string,
copyable: boolean,
qr: boolean,
},
['description', 'copyable', 'qr'],
{ copyable: false, qr: false } as const,
)
const [matchPackagePropertiesV2, setPPV2] = deferred<PackagePropertiesV2>()
const matchPackagePropertyString = shape(
{
type: literal('string'),
description: string,
value: string,
copyable: boolean,
qr: boolean,
masked: boolean,
},
['description', 'copyable', 'qr', 'masked'],
{
copyable: false,
qr: false,
masked: false,
} as const,
)
const matchPackagePropertyObject = shape(
{
type: literal('object'),
value: matchPackagePropertiesV2,
description: string,
},
['description'],
)
const matchPropertyV2 = anyOf(
matchPackagePropertyString,
matchPackagePropertyObject,
)
type PackagePropertyObject = typeof matchPackagePropertyObject._TYPE
setPPV2(dictionary([string, matchPropertyV2]))
const matchPackagePropertiesVersionedV1 = shape({
version: number,
data: arrayOf(matchPropertiesV1),
})
const matchPackagePropertiesVersionedV2 = shape({
version: number,
data: dictionary([string, matchPropertyV2]),
})
export function parsePropertiesPermissive(
properties: unknown,
errorCallback: (err: Error) => any = console.warn,
): PackageProperties {
return matches(properties)
.when(matchPackagePropertiesVersionedV1, prop =>
parsePropertiesV1Permissive(prop.data, errorCallback),
)
.when(matchPackagePropertiesVersionedV2, prop => prop.data)
.when(matches.nill, {})
.defaultToLazy(() => {
errorCallback(new TypeError(`value is not valid`))
return {}
})
}
function parsePropertiesV1Permissive(
properties: unknown,
errorCallback: (err: Error) => any,
): PackageProperties {
if (!Array.isArray(properties)) {
errorCallback(new TypeError(`${properties} is not an array`))
return {}
}
return properties.reduce(
(prev: PackagePropertiesV2, cur: unknown, idx: number) => {
const result = matchPropertiesV1.enumParsed(cur)
if ('value' in result) {
const value = result.value
prev[value.name] = {
type: 'string',
value: value.value,
description: value.description,
copyable: value.copyable,
qr: value.qr,
masked: false,
}
} else {
const error = result.error
const message = Parser.validatorErrorAsString(error)
const dataPath = error.keys.map(removeQuotes).join('/')
errorCallback(new Error(`/data/${idx}: ${message}`))
if (dataPath) {
applyOperation(cur, {
op: 'replace',
path: `/${dataPath}`,
value: undefined,
})
}
}
return prev
},
{},
)
}
const removeRegex = /('|")/
function removeQuotes(x: string) {
while (removeRegex.test(x)) {
x = x.replace(removeRegex, '')
}
return x
}