mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 21:13:09 +00:00
Refactor/actions (#2733)
* store, properties, manifest * interfaces * init and backups * fix init and backups * file models * more versions * dependencies * config except dynamic types * clean up config * remove disabled from non-dynamic vaues * actions * standardize example code block formats * wip: actions refactor Co-authored-by: Jade <Blu-J@users.noreply.github.com> * commit types * fix types * update types * update action request type * update apis * add description to actionrequest * clean up imports * revert package json * chore: Remove the recursive to the index * chore: Remove the other thing I was testing * flatten action requests * update container runtime with new config paradigm * new actions strategy * seems to be working * misc backend fixes * fix fe bugs * only show breakages if breakages * only show success modal if result * don't panic on failed removal * hide config from actions page * polyfill autoconfig * use metadata strategy for actions instead of prev * misc fixes * chore: split the sdk into 2 libs (#2736) * follow sideload progress (#2718) * follow sideload progress * small bugfix * shareReplay with no refcount false * don't wrap sideload progress in RPCResult * dont present toast --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> * chore: Add the initial of the creation of the two sdk * chore: Add in the baseDist * chore: Add in the baseDist * chore: Get the web and the runtime-container running * chore: Remove the empty file * chore: Fix it so the container-runtime works --------- Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com> Co-authored-by: Aiden McClelland <me@drbonez.dev> * misc fixes * update todos * minor clean up * fix link script * update node version in CI test * fix node version syntax in ci build * wip: fixing callbacks * fix sdk makefile dependencies * add support for const outside of main * update apis * don't panic! * Chore: Capture weird case on rpc, and log that * fix procedure id issue * pass input value for dep auto config * handle disabled and warning for actions * chore: Fix for link not having node_modules * sdk fixes * fix build * fix build * fix build --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Jade <Blu-J@users.noreply.github.com> Co-authored-by: J H <dragondef@gmail.com> Co-authored-by: Jade <2364004+Blu-J@users.noreply.github.com> Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { ActionSheetController, AlertController } from '@ionic/angular'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { CB } from '@start9labs/start-sdk'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
@@ -261,8 +261,8 @@ export class BackupDrivesStatusComponent {
|
||||
@Input() hasAnyBackup!: boolean
|
||||
}
|
||||
|
||||
const cifsSpec = CB.Config.of({
|
||||
hostname: CB.Value.text({
|
||||
const cifsSpec = ISB.InputSpec.of({
|
||||
hostname: ISB.Value.text({
|
||||
name: 'Hostname',
|
||||
description:
|
||||
'The hostname of your target device on the Local Area Network.',
|
||||
@@ -271,19 +271,19 @@ const cifsSpec = CB.Config.of({
|
||||
required: { default: null },
|
||||
patterns: [],
|
||||
}),
|
||||
path: CB.Value.text({
|
||||
path: ISB.Value.text({
|
||||
name: 'Path',
|
||||
description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`,
|
||||
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
username: CB.Value.text({
|
||||
username: ISB.Value.text({
|
||||
name: 'Username',
|
||||
description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`,
|
||||
required: { default: null },
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
password: CB.Value.text({
|
||||
password: ISB.Value.text({
|
||||
name: 'Password',
|
||||
description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`,
|
||||
required: false,
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
} from '@angular/core'
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import {
|
||||
tuiMarkControlAsTouchedAndValidate,
|
||||
TuiValueChangesModule,
|
||||
@@ -30,10 +29,10 @@ export interface ActionButton<T> {
|
||||
}
|
||||
|
||||
export interface FormContext<T> {
|
||||
spec: CT.InputSpec
|
||||
spec: IST.InputSpec
|
||||
buttons: ActionButton<T>[]
|
||||
value?: T
|
||||
patch?: Operation[]
|
||||
operations?: Operation[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -112,7 +111,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||
|
||||
@Input() spec = this.context?.data.spec || {}
|
||||
@Input() buttons = this.context?.data.buttons || []
|
||||
@Input() patch = this.context?.data.patch || []
|
||||
@Input() operations = this.context?.data.operations || []
|
||||
@Input() value?: T = this.context?.data.value
|
||||
|
||||
form = new FormGroup({})
|
||||
@@ -120,7 +119,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||
ngOnInit() {
|
||||
this.dialogFormService.markAsPristine()
|
||||
this.form = this.formService.createForm(this.spec, this.value)
|
||||
this.process(this.patch)
|
||||
this.process(this.operations)
|
||||
}
|
||||
|
||||
onReset() {
|
||||
@@ -149,8 +148,8 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||
this.context?.$implicit.complete()
|
||||
}
|
||||
|
||||
private process(patch: Operation[]) {
|
||||
patch.forEach(({ op, path }) => {
|
||||
private process(operations: Operation[]) {
|
||||
operations.forEach(({ op, path }) => {
|
||||
const control = this.form.get(path.substring(1).split('/'))
|
||||
|
||||
if (!control || !control.parent) return
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { inject } from '@angular/core'
|
||||
import { FormControlComponent } from './form-control/form-control.component'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
|
||||
export abstract class Control<Spec extends CT.ValueSpec, Value> {
|
||||
export abstract class Control<Spec extends Exclude<IST.ValueSpec, IST.ValueSpecHidden>, Value> {
|
||||
private readonly control: FormControlComponent<Spec, Value> =
|
||||
inject(FormControlComponent)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { KeyValue } from '@angular/common'
|
||||
|
||||
@Pipe({
|
||||
name: 'filterHidden',
|
||||
})
|
||||
export class FilterHiddenPipe implements PipeTransform {
|
||||
transform(value: KeyValue<string, IST.ValueSpec>[]) {
|
||||
return value.filter(x => x.value.type !== 'hidden') as KeyValue<
|
||||
string,
|
||||
Exclude<IST.ValueSpec, IST.ValueSpecHidden>
|
||||
>[]
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
tuiHeightCollapse,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { filter, takeUntil } from 'rxjs'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { ERRORS } from '../form-group/form-group.component'
|
||||
@@ -22,7 +22,7 @@ import { ERRORS } from '../form-group/form-group.component'
|
||||
})
|
||||
export class FormArrayComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecList
|
||||
spec!: IST.ValueSpecList
|
||||
|
||||
@HostBinding('@tuiParentAnimation')
|
||||
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { MaskitoOptions } from '@maskito/core'
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MaskitoOptions } from '@maskito/core'
|
||||
templateUrl: './form-color.component.html',
|
||||
styleUrls: ['./form-color.component.scss'],
|
||||
})
|
||||
export class FormColorComponent extends Control<CT.ValueSpecColor, string> {
|
||||
export class FormColorComponent extends Control<IST.ValueSpecColor, string> {
|
||||
readonly mask: MaskitoOptions = {
|
||||
mask: ['#', ...Array(6).fill(/[0-9a-f]/i)],
|
||||
}
|
||||
|
||||
@@ -36,4 +36,4 @@
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
TuiNotification,
|
||||
} from '@taiga-ui/core'
|
||||
import { filter, takeUntil } from 'rxjs'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { ERRORS } from '../form-group/form-group.component'
|
||||
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
|
||||
|
||||
@@ -25,7 +25,7 @@ import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
|
||||
providers: FORM_CONTROL_PROVIDERS,
|
||||
})
|
||||
export class FormControlComponent<
|
||||
T extends CT.ValueSpec,
|
||||
T extends Exclude<IST.ValueSpec, IST.ValueSpecHidden>,
|
||||
V,
|
||||
> extends AbstractTuiNullableControl<V> {
|
||||
@Input()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, Provider } from '@angular/core'
|
||||
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { FormControlComponent } from './form-control.component'
|
||||
|
||||
interface ValidatorsPatternError {
|
||||
@@ -12,7 +12,7 @@ export const FORM_CONTROL_PROVIDERS: Provider[] = [
|
||||
{
|
||||
provide: TUI_VALIDATION_ERRORS,
|
||||
deps: [forwardRef(() => FormControlComponent)],
|
||||
useFactory: (control: FormControlComponent<CT.ValueSpec, string>) => ({
|
||||
useFactory: (control: FormControlComponent<Exclude<IST.ValueSpec, IST.ValueSpecHidden>, string>) => ({
|
||||
required: 'Required',
|
||||
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
|
||||
('patterns' in control.spec &&
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
tuiPure,
|
||||
TuiTime,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
@@ -14,7 +14,7 @@ import { Control } from '../control'
|
||||
templateUrl: './form-datetime.component.html',
|
||||
})
|
||||
export class FormDatetimeComponent extends Control<
|
||||
CT.ValueSpecDatetime,
|
||||
IST.ValueSpecDatetime,
|
||||
string
|
||||
> {
|
||||
readonly min = TUI_FIRST_DAY
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<ng-container
|
||||
*ngFor="let entry of spec | keyvalue : asIsOrder"
|
||||
*ngFor="let entry of spec | keyvalue : asIsOrder | filterHidden"
|
||||
tuiMode="onDark"
|
||||
[ngSwitch]="entry.value.type"
|
||||
[tuiTextfieldCleaner]="true"
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Input,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
|
||||
|
||||
export const ERRORS = [
|
||||
@@ -27,7 +27,7 @@ export const ERRORS = [
|
||||
viewProviders: [FORM_GROUP_PROVIDERS],
|
||||
})
|
||||
export class FormGroupComponent {
|
||||
@Input() spec: CT.InputSpec = {}
|
||||
@Input() spec: IST.InputSpec = {}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
@@ -9,7 +9,7 @@ import { invert } from '@start9labs/shared'
|
||||
templateUrl: './form-multiselect.component.html',
|
||||
})
|
||||
export class FormMultiselectComponent extends Control<
|
||||
CT.ValueSpecMultiselect,
|
||||
IST.ValueSpecMultiselect,
|
||||
readonly string[]
|
||||
> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-number',
|
||||
templateUrl: './form-number.component.html',
|
||||
})
|
||||
export class FormNumberComponent extends Control<CT.ValueSpecNumber, number> {
|
||||
export class FormNumberComponent extends Control<IST.ValueSpecNumber, number> {
|
||||
protected readonly Infinity = Infinity
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'form-object',
|
||||
@@ -17,7 +17,7 @@ import { CT } from '@start9labs/start-sdk'
|
||||
})
|
||||
export class FormObjectComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecObject
|
||||
spec!: IST.ValueSpecObject
|
||||
|
||||
@Input()
|
||||
open = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
import { Control } from '../control'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Control } from '../control'
|
||||
selector: 'form-select',
|
||||
templateUrl: './form-select.component.html',
|
||||
})
|
||||
export class FormSelectComponent extends Control<CT.ValueSpecSelect, string> {
|
||||
export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
readonly items = Object.values(this.spec.values)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT, utils } from '@start9labs/start-sdk'
|
||||
import { IST, utils } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
@@ -7,7 +7,7 @@ import { Control } from '../control'
|
||||
templateUrl: './form-text.component.html',
|
||||
styleUrls: ['./form-text.component.scss'],
|
||||
})
|
||||
export class FormTextComponent extends Control<CT.ValueSpecText, string> {
|
||||
export class FormTextComponent extends Control<IST.ValueSpecText, string> {
|
||||
masked = true
|
||||
|
||||
generate() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
@@ -7,6 +7,6 @@ import { Control } from '../control'
|
||||
templateUrl: './form-textarea.component.html',
|
||||
})
|
||||
export class FormTextareaComponent extends Control<
|
||||
CT.ValueSpecTextarea,
|
||||
IST.ValueSpecTextarea,
|
||||
string
|
||||
> {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
@@ -7,4 +7,7 @@ import { Control } from '../control'
|
||||
templateUrl: './form-toggle.component.html',
|
||||
host: { class: 'g-toggle' },
|
||||
})
|
||||
export class FormToggleComponent extends Control<CT.ValueSpecToggle, boolean> {}
|
||||
export class FormToggleComponent extends Control<
|
||||
IST.ValueSpecToggle,
|
||||
boolean
|
||||
> {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer, FormGroupName } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
|
||||
@@ -24,9 +24,9 @@ import { tuiPure } from '@taiga-ui/cdk'
|
||||
})
|
||||
export class FormUnionComponent implements OnChanges {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecUnion
|
||||
spec!: IST.ValueSpecUnion
|
||||
|
||||
selectSpec!: CT.ValueSpecSelect
|
||||
selectSpec!: IST.ValueSpecSelect
|
||||
|
||||
private readonly form = inject(FormGroupName)
|
||||
private readonly formService = inject(FormService)
|
||||
|
||||
@@ -50,6 +50,7 @@ import { ControlDirective } from './control.directive'
|
||||
import { FormColorComponent } from './form-color/form-color.component'
|
||||
import { FormDatetimeComponent } from './form-datetime/form-datetime.component'
|
||||
import { HintPipe } from './hint.pipe'
|
||||
import { FilterHiddenPipe } from './filter-hidden.pipe'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -101,6 +102,7 @@ import { HintPipe } from './hint.pipe'
|
||||
MustachePipe,
|
||||
HintPipe,
|
||||
ControlDirective,
|
||||
FilterHiddenPipe,
|
||||
],
|
||||
exports: [FormGroupComponent],
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
name: 'hint',
|
||||
})
|
||||
export class HintPipe implements PipeTransform {
|
||||
transform(spec: CT.ValueSpec): string {
|
||||
transform(spec: Exclude<IST.ValueSpec, IST.ValueSpecHidden>): string {
|
||||
const hint = []
|
||||
|
||||
if (spec.description) {
|
||||
|
||||
@@ -2,50 +2,49 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
|
||||
import { getValueByPointer, Operation } from 'fast-json-patch'
|
||||
import { isObject } from '@start9labs/shared'
|
||||
import { tuiIsNumber } from '@taiga-ui/cdk'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'config-dep',
|
||||
selector: 'action-dep',
|
||||
template: `
|
||||
<tui-notification>
|
||||
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
|
||||
{{ package }}
|
||||
{{ pkgTitle }}
|
||||
</h3>
|
||||
The following modifications have been made to {{ package }} to satisfy
|
||||
{{ dep }}:
|
||||
The following modifications have been made to {{ pkgTitle }} to satisfy
|
||||
{{ depTitle }}:
|
||||
<ul>
|
||||
<li *ngFor="let d of diff" [innerHTML]="d"></li>
|
||||
</ul>
|
||||
To accept these modifications, click "Save".
|
||||
</tui-notification>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiNotificationModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConfigDepComponent implements OnChanges {
|
||||
export class ActionDepComponent implements OnInit {
|
||||
@Input()
|
||||
package = ''
|
||||
pkgTitle = ''
|
||||
|
||||
@Input()
|
||||
dep = ''
|
||||
depTitle = ''
|
||||
|
||||
@Input()
|
||||
original: object = {}
|
||||
originalValue: object = {}
|
||||
|
||||
@Input()
|
||||
value: object = {}
|
||||
operations: Operation[] = []
|
||||
|
||||
diff: string[] = []
|
||||
|
||||
ngOnChanges() {
|
||||
this.diff = compare(this.original, this.value).map(
|
||||
ngOnInit() {
|
||||
this.diff = this.operations.map(
|
||||
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
|
||||
)
|
||||
}
|
||||
@@ -82,7 +81,7 @@ export class ConfigDepComponent implements OnChanges {
|
||||
}
|
||||
|
||||
private getOldValue(path: any): string {
|
||||
const val = getValueByPointer(this.original, path)
|
||||
const val = getValueByPointer(this.originalValue, path)
|
||||
if (['string', 'number', 'boolean'].includes(typeof val)) {
|
||||
return val
|
||||
} else if (isObject(val)) {
|
||||
208
web/projects/ui/src/app/modals/action-input.component.ts
Normal file
208
web/projects/ui/src/app/modals/action-input.component.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { getErrorMessage } from '@start9labs/shared'
|
||||
import { T, utils } from '@start9labs/start-sdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiLoaderModule,
|
||||
TuiModeModule,
|
||||
TuiNotificationModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
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 { 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'
|
||||
import { getAllPackages, getManifest } from 'src/app/util/get-package-data'
|
||||
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: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
readonly actionInfo: {
|
||||
id: string
|
||||
warning: string | null
|
||||
}
|
||||
readonly dependentInfo?: {
|
||||
title: string
|
||||
request: T.ActionRequest
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *ngIf="res$ | async as res; else loading">
|
||||
<tui-notification *ngIf="error" status="error">
|
||||
<div [innerHTML]="error"></div>
|
||||
</tui-notification>
|
||||
|
||||
<ng-container *ngIf="res">
|
||||
<tui-notification *ngIf="warning" status="warning">
|
||||
<div [innerHTML]="warning"></div>
|
||||
</tui-notification>
|
||||
|
||||
<action-dep
|
||||
*ngIf="dependentInfo"
|
||||
[pkgTitle]="pkgInfo.title"
|
||||
[depTitle]="dependentInfo.title"
|
||||
[originalValue]="res.originalValue || {}"
|
||||
[operations]="res.operations || []"
|
||||
></action-dep>
|
||||
|
||||
<app-form
|
||||
tuiMode="onDark"
|
||||
[spec]="res.spec"
|
||||
[value]="res.originalValue || {}"
|
||||
[buttons]="buttons"
|
||||
[operations]="res.operations || []"
|
||||
>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
type="reset"
|
||||
[style.margin-right]="'auto'"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</app-form>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<tui-loader size="l" textContent="loading"></tui-loader>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
tui-notification {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiLoaderModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
ActionDepComponent,
|
||||
UiPipeModule,
|
||||
FormComponent,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
})
|
||||
export class ActionInputModal {
|
||||
readonly actionId = this.context.data.actionInfo.id
|
||||
readonly warning = this.context.data.actionInfo.warning
|
||||
readonly pkgInfo = this.context.data.pkgInfo
|
||||
readonly dependentInfo = this.context.data.dependentInfo
|
||||
|
||||
buttons: ActionButton<any>[] = [
|
||||
{
|
||||
text: 'Submit',
|
||||
handler: value => this.execute(value),
|
||||
},
|
||||
]
|
||||
|
||||
error = ''
|
||||
|
||||
res$ = defer(() =>
|
||||
this.api.getActionInput({
|
||||
packageId: this.pkgInfo.id,
|
||||
actionId: this.actionId,
|
||||
}),
|
||||
).pipe(
|
||||
map(res => {
|
||||
const originalValue = res.value || {}
|
||||
|
||||
return {
|
||||
spec: res.spec,
|
||||
originalValue,
|
||||
operations: this.dependentInfo?.request.input
|
||||
? compare(
|
||||
originalValue,
|
||||
utils.deepMerge(
|
||||
originalValue,
|
||||
this.dependentInfo.request.input.value,
|
||||
) as object,
|
||||
)
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
catchError(e => {
|
||||
this.error = String(getErrorMessage(e))
|
||||
return EMPTY
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, PackageActionData>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly api: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly actionService: ActionService,
|
||||
) {}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async checkConflicts(input: object): Promise<boolean> {
|
||||
const packages = await getAllPackages(this.patch)
|
||||
|
||||
const breakages = Object.keys(packages)
|
||||
.filter(
|
||||
id =>
|
||||
id !== this.pkgInfo.id &&
|
||||
Object.values(packages[id].requestedActions).some(
|
||||
({ request, active }) =>
|
||||
!active &&
|
||||
request.packageId === this.pkgInfo.id &&
|
||||
request.actionId === this.actionId &&
|
||||
request.when?.condition === 'input-not-matches' &&
|
||||
request.input &&
|
||||
json
|
||||
.compare(input, request.input)
|
||||
.some(op => op.op === 'add' || op.op === 'replace'),
|
||||
),
|
||||
)
|
||||
.map(id => id)
|
||||
|
||||
if (!breakages.length) return true
|
||||
|
||||
const message =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const content = `${message}${breakages.map(
|
||||
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
|
||||
)}</ul>`
|
||||
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
|
||||
|
||||
return firstValueFrom(
|
||||
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { ActionResponse } from 'src/app/services/api/api.types'
|
||||
import { copyToClipboard } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'action-success',
|
||||
@@ -10,7 +10,7 @@ import { copyToClipboard } from '@start9labs/shared'
|
||||
})
|
||||
export class ActionSuccessPage {
|
||||
@Input()
|
||||
actionRes!: ActionResponse
|
||||
actionRes!: T.ActionResult
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Inject, ViewChild } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { CT, T } from '@start9labs/start-sdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiLoaderModule,
|
||||
TuiModeModule,
|
||||
TuiNotificationModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { endWith, firstValueFrom, Subscription } from 'rxjs'
|
||||
import { ActionButton, FormComponent } from 'src/app/components/form.component'
|
||||
import { InvalidService } from 'src/app/components/form/invalid.service'
|
||||
import { ConfigDepComponent } from 'src/app/modals/config-dep.component'
|
||||
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
getAllPackages,
|
||||
getManifest,
|
||||
getPackage,
|
||||
} from 'src/app/util/get-package-data'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
|
||||
export interface PackageConfigData {
|
||||
readonly pkgId: string
|
||||
readonly dependentInfo?: DependentInfo
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-loader
|
||||
*ngIf="loadingText"
|
||||
size="l"
|
||||
[textContent]="loadingText"
|
||||
></tui-loader>
|
||||
|
||||
<tui-notification
|
||||
*ngIf="!loadingText && (loadingError || !pkg)"
|
||||
status="error"
|
||||
>
|
||||
<div [innerHTML]="loadingError"></div>
|
||||
</tui-notification>
|
||||
|
||||
<ng-container
|
||||
*ngIf="
|
||||
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
|
||||
"
|
||||
>
|
||||
<tui-notification *ngIf="success" status="success">
|
||||
{{ manifest.title }} has been automatically configured with recommended
|
||||
defaults. Make whatever changes you want, then click "Save".
|
||||
</tui-notification>
|
||||
|
||||
<config-dep
|
||||
*ngIf="dependentInfo && value && original"
|
||||
[package]="manifest.title"
|
||||
[dep]="dependentInfo.title"
|
||||
[original]="original"
|
||||
[value]="value"
|
||||
></config-dep>
|
||||
|
||||
<tui-notification *ngIf="!manifest.hasConfig" status="warning">
|
||||
No config options for {{ manifest.title }} {{ manifest.version }}.
|
||||
</tui-notification>
|
||||
|
||||
<app-form
|
||||
tuiMode="onDark"
|
||||
[spec]="spec"
|
||||
[value]="value || {}"
|
||||
[buttons]="buttons"
|
||||
[patch]="patch"
|
||||
>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
type="reset"
|
||||
[style.margin-right]="'auto'"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</app-form>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
tui-notification {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormComponent,
|
||||
TuiLoaderModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
ConfigDepComponent,
|
||||
UiPipeModule,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
})
|
||||
export class ConfigModal {
|
||||
@ViewChild(FormComponent)
|
||||
private readonly form?: FormComponent<Record<string, any>>
|
||||
|
||||
readonly pkgId = this.context.data.pkgId
|
||||
readonly dependentInfo = this.context.data.dependentInfo
|
||||
|
||||
loadingError = ''
|
||||
loadingText = this.dependentInfo
|
||||
? `Setting properties to accommodate ${this.dependentInfo.title}`
|
||||
: 'Loading Config'
|
||||
|
||||
pkg?: PackageDataEntry
|
||||
spec: CT.InputSpec = {}
|
||||
patch: Operation[] = []
|
||||
buttons: ActionButton<any>[] = [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: value => this.save(value),
|
||||
},
|
||||
]
|
||||
|
||||
original: object | null = null
|
||||
value: object | null = null
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, PackageConfigData>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patchDb: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get success(): boolean {
|
||||
return (
|
||||
!!this.form &&
|
||||
!this.form.form.dirty &&
|
||||
!this.original &&
|
||||
!this.pkg?.status?.configured
|
||||
)
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.pkg = await getPackage(this.patchDb, this.pkgId)
|
||||
|
||||
if (!this.pkg) {
|
||||
this.loadingError = 'This service does not exist'
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dependentInfo) {
|
||||
const depConfig = await this.embassyApi.dryConfigureDependency({
|
||||
dependencyId: this.pkgId,
|
||||
dependentId: this.dependentInfo.id,
|
||||
})
|
||||
|
||||
this.original = depConfig.oldConfig
|
||||
this.value = depConfig.newConfig || this.original
|
||||
this.spec = depConfig.spec
|
||||
this.patch = compare(this.original, this.value)
|
||||
} else {
|
||||
const { config, spec } = await this.embassyApi.getPackageConfig({
|
||||
id: this.pkgId,
|
||||
})
|
||||
|
||||
this.original = config
|
||||
this.value = config
|
||||
this.spec = spec
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.loadingError = String(getErrorMessage(e))
|
||||
} finally {
|
||||
this.loadingText = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async save(config: any) {
|
||||
const loader = new Subscription()
|
||||
|
||||
try {
|
||||
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
|
||||
await this.configureDeps(config, loader)
|
||||
} else {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async configureDeps(
|
||||
config: Record<string, any>,
|
||||
loader: Subscription,
|
||||
) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Checking dependent services...').subscribe())
|
||||
|
||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||
id: this.pkgId,
|
||||
config,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
}
|
||||
|
||||
private async configure(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Saving...').subscribe())
|
||||
|
||||
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
private async approveBreakages(breakages: T.PackageId[]): Promise<boolean> {
|
||||
const packages = await getAllPackages(this.patchDb)
|
||||
const message =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const content = `${message}${breakages.map(
|
||||
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
|
||||
)}</ul>`
|
||||
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
|
||||
|
||||
return firstValueFrom(
|
||||
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
sameUrl,
|
||||
toUrl,
|
||||
} from '@start9labs/shared'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
@@ -182,7 +182,7 @@ export const MARKETPLACE_REGISTRY = new PolymorpheusComponent(
|
||||
MarketplaceSettingsPage,
|
||||
)
|
||||
|
||||
function getMarketplaceValueSpec(): CT.ValueSpecObject {
|
||||
function getMarketplaceValueSpec(): IST.ValueSpecObject {
|
||||
return {
|
||||
type: 'object',
|
||||
name: 'Add Custom Registry',
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
<h4>0.3.6-alpha.5</h4>
|
||||
<h6>This is an ALPHA release! DO NOT use for production data!</h6>
|
||||
<h6>Expect that any data you create or store on this version of the OS can be LOST FOREVER!</h6>
|
||||
<h6>
|
||||
Expect that any data you create or store on this version of the OS can be
|
||||
LOST FOREVER!
|
||||
</h6>
|
||||
|
||||
<div class="ion-text-center ion-padding">
|
||||
<ion-button
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<ion-item button>
|
||||
<ion-icon slot="start" [name]="action.icon" size="large"></ion-icon>
|
||||
<ion-item button [disabled]="disabledText">
|
||||
<ion-icon slot="start" [name]="icon" size="large"></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ action.name }}</h1>
|
||||
<p>{{ action.description }}</p>
|
||||
<p *ngIf="disabledText">
|
||||
<ion-text color="warning">{{ disabledText }}</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
@@ -8,32 +8,28 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top with-widgets">
|
||||
<ng-container *ngIf="pkg$ | async as pkg">
|
||||
<ion-item-group *ngIf="pkg.stateInfo.state === 'installed'">
|
||||
<!-- ** standard actions ** -->
|
||||
<ion-item-divider>Standard Actions</ion-item-divider>
|
||||
<app-actions-item
|
||||
[action]="{
|
||||
name: 'Uninstall',
|
||||
description: 'This will uninstall the service from StartOS and delete all data permanently.',
|
||||
icon: 'trash-outline'
|
||||
}"
|
||||
(click)="tryUninstall(pkg)"
|
||||
></app-actions-item>
|
||||
<ion-item-group *ngIf="pkg$ | async as pkg">
|
||||
<!-- ** standard actions ** -->
|
||||
<ion-item-divider>Standard Actions</ion-item-divider>
|
||||
<app-actions-item
|
||||
[action]="{
|
||||
name: 'Uninstall',
|
||||
description: 'This will uninstall the service from StartOS and delete all data permanently.',
|
||||
visibility: 'enabled'
|
||||
}"
|
||||
icon="trash-outline"
|
||||
(click)="tryUninstall(pkg.manifest)"
|
||||
></app-actions-item>
|
||||
|
||||
<!-- ** specific actions ** -->
|
||||
<ion-item-divider *ngIf="!(pkg.actions | empty)">
|
||||
Actions for {{ pkg.stateInfo.manifest.title }}
|
||||
</ion-item-divider>
|
||||
<app-actions-item
|
||||
*ngFor="let action of pkg.actions | keyvalue: asIsOrder"
|
||||
[action]="{
|
||||
name: action.value.name,
|
||||
description: action.value.description,
|
||||
icon: 'play-circle-outline'
|
||||
}"
|
||||
(click)="handleAction(pkg.status, action)"
|
||||
></app-actions-item>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
<!-- ** specific actions ** -->
|
||||
<ion-item-divider *ngIf="pkg.actions.length">
|
||||
Actions for {{ pkg.manifest.title }}
|
||||
</ion-item-divider>
|
||||
<app-actions-item
|
||||
*ngFor="let action of pkg.actions"
|
||||
[action]="action"
|
||||
icon="play-circle-outline"
|
||||
(click)="handleAction(pkg.mainStatus, pkg.manifest, action)"
|
||||
></app-actions-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
@@ -1,38 +1,15 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { AlertController, ModalController, NavController } from '@ionic/angular'
|
||||
import {
|
||||
ErrorService,
|
||||
getPkgId,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { AlertController, NavController } from '@ionic/angular'
|
||||
import { ErrorService, getPkgId, LoadingService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { FormComponent } from 'src/app/components/form.component'
|
||||
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ActionService } from 'src/app/services/action.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getAllPackages, getManifest } from 'src/app/util/get-package-data'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
|
||||
const allowedStatuses = {
|
||||
onlyRunning: new Set(['running']),
|
||||
onlyStopped: new Set(['stopped']),
|
||||
any: new Set([
|
||||
'running',
|
||||
'stopped',
|
||||
'restarting',
|
||||
'restoring',
|
||||
'stopping',
|
||||
'starting',
|
||||
'backingUp',
|
||||
]),
|
||||
}
|
||||
import { filter, map } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
@@ -42,101 +19,49 @@ const allowedStatuses = {
|
||||
})
|
||||
export class AppActionsPage {
|
||||
readonly pkgId = getPkgId(this.route)
|
||||
readonly pkg$ = this.patch.watch$('packageData', this.pkgId)
|
||||
readonly pkg$ = this.patch.watch$('packageData', this.pkgId).pipe(
|
||||
filter(pkg => pkg.stateInfo.state === 'installed'),
|
||||
map(pkg => ({
|
||||
mainStatus: pkg.status.main,
|
||||
manifest: getManifest(pkg),
|
||||
actions: Object.keys(pkg.actions)
|
||||
.filter(id => id !== 'config')
|
||||
.map(id => ({
|
||||
id,
|
||||
...pkg.actions[id],
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly api: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly actionService: ActionService,
|
||||
) {}
|
||||
|
||||
async handleAction(
|
||||
status: T.Status,
|
||||
action: { key: string; value: T.ActionMetadata },
|
||||
mainStatus: T.MainStatus['main'],
|
||||
manifest: T.Manifest,
|
||||
action: T.ActionMetadata & { id: string },
|
||||
) {
|
||||
if (
|
||||
status &&
|
||||
allowedStatuses[action.value.allowedStatuses].has(status.main.status)
|
||||
) {
|
||||
if (!isEmptyObject(action.value.input || {})) {
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: action.value.name,
|
||||
data: {
|
||||
spec: action.value.input,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Execute',
|
||||
handler: async (value: any) =>
|
||||
this.executeAction(action.key, value),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: `Are you sure you want to execute action "${
|
||||
action.value.name
|
||||
}"? ${action.value.warning || ''}`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Execute',
|
||||
handler: () => {
|
||||
this.executeAction(action.key)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
} else {
|
||||
const statuses = [...allowedStatuses[action.value.allowedStatuses]]
|
||||
const last = statuses.pop()
|
||||
let statusesStr = statuses.join(', ')
|
||||
let error = ''
|
||||
if (statuses.length) {
|
||||
if (statuses.length > 1) {
|
||||
// oxford comma
|
||||
statusesStr += ','
|
||||
}
|
||||
statusesStr += ` or ${last}`
|
||||
} else if (last) {
|
||||
statusesStr = `${last}`
|
||||
} else {
|
||||
error = `There is no status for which this action may be run. This is a bug. Please file an issue with the service maintainer.`
|
||||
}
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Forbidden',
|
||||
message:
|
||||
error ||
|
||||
`Action "${action.value.name}" can only be executed when service is ${statusesStr}`,
|
||||
buttons: ['OK'],
|
||||
cssClass: 'alert-error-message enter-click',
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
this.actionService.present(
|
||||
{ id: manifest.id, title: manifest.title, mainStatus },
|
||||
{ id: action.id, metadata: action },
|
||||
)
|
||||
}
|
||||
|
||||
async tryUninstall(pkg: PackageDataEntry): Promise<void> {
|
||||
const { title, alerts } = getManifest(pkg)
|
||||
|
||||
async tryUninstall(manifest: T.Manifest): Promise<void> {
|
||||
let message =
|
||||
alerts.uninstall ||
|
||||
`Uninstalling ${title} will permanently delete its data`
|
||||
manifest.alerts.uninstall ||
|
||||
`Uninstalling ${manifest.title} will permanently delete its data`
|
||||
|
||||
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patch))) {
|
||||
message = `${message}. Services that depend on ${title} will no longer work properly and may crash`
|
||||
message = `${message}. Services that depend on ${manifest.title} will no longer work properly and may crash`
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
@@ -165,8 +90,8 @@ export class AppActionsPage {
|
||||
const loader = this.loader.open(`Beginning uninstall...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.uninstallPackage({ id: this.pkgId })
|
||||
this.embassyApi
|
||||
await this.api.uninstallPackage({ id: this.pkgId })
|
||||
this.api
|
||||
.setDbValue<boolean>(['ackInstructions', this.pkgId], false)
|
||||
.catch(e => console.error('Failed to mark instructions as unseen', e))
|
||||
this.navCtrl.navigateRoot('/services')
|
||||
@@ -176,46 +101,6 @@ export class AppActionsPage {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAction(
|
||||
actionId: string,
|
||||
input?: object,
|
||||
): Promise<boolean> {
|
||||
const loader = this.loader.open('Executing action...').subscribe()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.executePackageAction({
|
||||
id: this.pkgId,
|
||||
actionId,
|
||||
input,
|
||||
})
|
||||
|
||||
const successModal = await this.modalCtrl.create({
|
||||
component: ActionSuccessPage,
|
||||
componentProps: {
|
||||
actionRes: res,
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => successModal.present(), 500)
|
||||
return true // needed to dismiss original modal/alert
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false // don't dismiss original modal/alert
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalAction {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -225,5 +110,18 @@ interface LocalAction {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppActionsItemComponent {
|
||||
@Input() action!: LocalAction
|
||||
@Input() action!: {
|
||||
name: string
|
||||
description: string
|
||||
visibility: T.ActionVisibility
|
||||
}
|
||||
|
||||
@Input() icon!: string
|
||||
|
||||
get disabledText() {
|
||||
return (
|
||||
typeof this.action.visibility === 'object' &&
|
||||
this.action.visibility.disabled.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
fill="clear"
|
||||
color="primary"
|
||||
(click)="launchUi($event, pkg.entry.serviceInterfaces, pkg.entry.hosts)"
|
||||
[disabled]="
|
||||
!(pkg.entry.stateInfo.state | isLaunchable : pkgMainStatus.status)
|
||||
"
|
||||
[disabled]="!(pkg.entry.stateInfo.state | isLaunchable : pkgMainStatus)"
|
||||
>
|
||||
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
@@ -15,16 +15,12 @@ export class AppListPkgComponent {
|
||||
|
||||
constructor(private readonly launcherService: UiLauncherService) {}
|
||||
|
||||
get pkgMainStatus(): T.MainStatus {
|
||||
return (
|
||||
this.pkg.entry.status.main || {
|
||||
status: 'stopped',
|
||||
}
|
||||
)
|
||||
get pkgMainStatus(): T.MainStatus['main'] {
|
||||
return this.pkg.entry.status.main
|
||||
}
|
||||
|
||||
get sigtermTimeout(): string | null {
|
||||
return this.pkgMainStatus.status === 'stopping' ? '30s' : null // @dr-bonez TODO
|
||||
return this.pkgMainStatus === 'stopping' ? '30s' : null // @dr-bonez TODO
|
||||
}
|
||||
|
||||
launchUi(
|
||||
|
||||
@@ -34,7 +34,7 @@ export class AppPropertiesPage {
|
||||
unmasked: { [key: string]: boolean } = {}
|
||||
|
||||
stopped$ = this.patch
|
||||
.watch$('packageData', this.pkgId, 'status', 'main', 'status')
|
||||
.watch$('packageData', this.pkgId, 'status', 'main')
|
||||
.pipe(map(status => status === 'stopped'))
|
||||
|
||||
@ViewChild(IonBackButtonDelegate, { static: false })
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
>
|
||||
<!-- ** health checks ** -->
|
||||
<app-show-health-checks
|
||||
*ngIf="pkg.status.main.status === 'running'"
|
||||
[healthChecks]="pkg.status.main.health"
|
||||
*ngIf="pkg.status.main === 'running'"
|
||||
[healthChecks]="pkg.status.health"
|
||||
></app-show-health-checks>
|
||||
<!-- ** dependencies ** -->
|
||||
<app-show-dependencies
|
||||
|
||||
@@ -20,14 +20,14 @@ 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 { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { ConfigModal, PackageConfigData } from 'src/app/modals/config.component'
|
||||
import { ActionService } from 'src/app/services/action.service'
|
||||
|
||||
export interface DependencyInfo {
|
||||
id: string
|
||||
@@ -73,7 +73,7 @@ export class AppShowPage {
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly depErrorService: DepErrorService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly actionService: ActionService,
|
||||
) {}
|
||||
|
||||
showProgress(
|
||||
@@ -89,9 +89,9 @@ export class AppShowPage {
|
||||
): DependencyInfo[] {
|
||||
const manifest = getManifest(pkg)
|
||||
|
||||
return Object.keys(pkg.currentDependencies)
|
||||
.filter(id => !!manifest.dependencies[id])
|
||||
.map(id => this.getDepValues(pkg, allPkgs, manifest, id, depErrors))
|
||||
return Object.keys(pkg.currentDependencies).map(id =>
|
||||
this.getDepValues(pkg, allPkgs, manifest, id, depErrors),
|
||||
)
|
||||
}
|
||||
|
||||
private getDepDetails(
|
||||
@@ -113,8 +113,8 @@ export class AppShowPage {
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: title ? title : depId,
|
||||
icon: icon ? icon : 'assets/img/service-icons/fallback.png',
|
||||
title: title || depId,
|
||||
icon: icon || 'assets/img/service-icons/fallback.png',
|
||||
versionRange,
|
||||
}
|
||||
}
|
||||
@@ -200,20 +200,34 @@ export class AppShowPage {
|
||||
pkg: PackageDataEntry,
|
||||
pkgManifest: T.Manifest,
|
||||
action: 'install' | 'update' | 'configure',
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
depId: string,
|
||||
) {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
case 'update':
|
||||
return this.installDep(pkg, pkgManifest, id)
|
||||
return this.installDep(pkg, pkgManifest, depId)
|
||||
case 'configure':
|
||||
return this.formDialog.open<PackageConfigData>(ConfigModal, {
|
||||
label: `${pkgManifest.title} config`,
|
||||
data: {
|
||||
pkgId: id,
|
||||
dependentInfo: pkgManifest,
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ion-button
|
||||
*ngIf="isStopped && pkgStatus?.configured"
|
||||
*ngIf="canStart"
|
||||
class="action-button"
|
||||
color="success"
|
||||
(click)="tryStart()"
|
||||
@@ -46,7 +46,7 @@
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
*ngIf="!pkgStatus?.configured"
|
||||
*ngIf="needsConfig(manifest.id, pkg.requestedActions)"
|
||||
class="action-button"
|
||||
color="warning"
|
||||
(click)="presentModalConfig()"
|
||||
@@ -59,9 +59,7 @@
|
||||
*ngIf="pkgStatus && interfaces && (interfaces | hasUi) && hosts"
|
||||
class="action-button"
|
||||
color="primary"
|
||||
[disabled]="
|
||||
!(pkg.stateInfo.state | isLaunchable: pkgStatus.main.status)
|
||||
"
|
||||
[disabled]="!(pkg.stateInfo.state | isLaunchable : pkgStatus.main)"
|
||||
(click)="launchUi(interfaces, hosts)"
|
||||
>
|
||||
<ion-icon slot="start" name="open-outline"></ion-icon>
|
||||
|
||||
@@ -3,10 +3,9 @@ import { AlertController } from '@ionic/angular'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { ConfigModal, PackageConfigData } from 'src/app/modals/config.component'
|
||||
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 { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
getAllPackages,
|
||||
getManifest,
|
||||
isInstalled,
|
||||
needsConfig,
|
||||
} from 'src/app/util/get-package-data'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
|
||||
@@ -39,6 +39,7 @@ export class AppShowStatusComponent {
|
||||
PR = PrimaryRendering
|
||||
|
||||
isInstalled = isInstalled
|
||||
needsConfig = needsConfig
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
@@ -46,9 +47,9 @@ export class AppShowStatusComponent {
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly launcherService: UiLauncherService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
readonly connection$: ConnectionService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly actionService: ActionService,
|
||||
) {}
|
||||
|
||||
get interfaces(): PackageDataEntry['serviceInterfaces'] {
|
||||
@@ -59,7 +60,7 @@ export class AppShowStatusComponent {
|
||||
return this.pkg.hosts
|
||||
}
|
||||
|
||||
get pkgStatus(): T.Status {
|
||||
get pkgStatus(): T.MainStatus {
|
||||
return this.pkg.status
|
||||
}
|
||||
|
||||
@@ -75,12 +76,15 @@ export class AppShowStatusComponent {
|
||||
return ['running', 'starting', 'restarting'].includes(this.status.primary)
|
||||
}
|
||||
|
||||
get isStopped(): boolean {
|
||||
return this.status.primary === 'stopped'
|
||||
get canStart(): boolean {
|
||||
return (
|
||||
this.status.primary === 'stopped' &&
|
||||
!Object.keys(this.pkg.requestedActions).length
|
||||
)
|
||||
}
|
||||
|
||||
get sigtermTimeout(): string | null {
|
||||
return this.pkgStatus?.main.status === 'stopping' ? '30s' : null // @dr-bonez TODO
|
||||
return this.pkgStatus?.main === 'stopping' ? '30s' : null // @dr-bonez TODO
|
||||
}
|
||||
|
||||
launchUi(
|
||||
@@ -91,9 +95,14 @@ export class AppShowStatusComponent {
|
||||
}
|
||||
|
||||
async presentModalConfig(): Promise<void> {
|
||||
return this.formDialog.open<PackageConfigData>(ConfigModal, {
|
||||
data: { pkgId: this.manifest.id },
|
||||
})
|
||||
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> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import { AlertController, ModalController, NavController } from '@ionic/angular'
|
||||
import { MarkdownComponent } from '@start9labs/shared'
|
||||
import {
|
||||
DataModel,
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { from, map, Observable } from 'rxjs'
|
||||
import { from, map, Observable, of } from 'rxjs'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { ConfigModal, PackageConfigData } from 'src/app/modals/config.component'
|
||||
import { ActionService } from 'src/app/services/action.service'
|
||||
import { needsConfig } from 'src/app/util/get-package-data'
|
||||
|
||||
export interface Button {
|
||||
title: string
|
||||
@@ -33,7 +33,8 @@ export class ToButtonsPipe implements PipeTransform {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly api: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly actionService: ActionService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) {}
|
||||
|
||||
transform(pkg: PackageDataEntry<InstalledState>): Button[] {
|
||||
@@ -53,13 +54,29 @@ export class ToButtonsPipe implements PipeTransform {
|
||||
// config
|
||||
{
|
||||
action: async () =>
|
||||
this.formDialog.open<PackageConfigData>(ConfigModal, {
|
||||
label: `${manifest.title} configuration`,
|
||||
data: { pkgId: manifest.id },
|
||||
}),
|
||||
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
|
||||
{
|
||||
|
||||
@@ -15,10 +15,10 @@ export class ToHealthChecksPipe implements PipeTransform {
|
||||
transform(
|
||||
manifest: T.Manifest,
|
||||
): Observable<Record<string, T.NamedHealthCheckResult | null> | null> {
|
||||
return this.patch.watch$('packageData', manifest.id, 'status', 'main').pipe(
|
||||
map(main => {
|
||||
return main.status === 'running' && !isEmptyObject(main.health)
|
||||
? main.health
|
||||
return this.patch.watch$('packageData', manifest.id, 'status').pipe(
|
||||
map(status => {
|
||||
return status.main === 'running' && !isEmptyObject(status.health)
|
||||
? status.health
|
||||
: null
|
||||
}),
|
||||
startWith(null),
|
||||
|
||||
@@ -30,8 +30,8 @@ export class BackingUpComponent {
|
||||
name: 'pkgMainStatus',
|
||||
})
|
||||
export class PkgMainStatusPipe implements PipeTransform {
|
||||
transform(pkgId: string): Observable<T.MainStatus['status']> {
|
||||
return this.patch.watch$('packageData', pkgId, 'status', 'main', 'status')
|
||||
transform(pkgId: string): Observable<T.MainStatus['main']> {
|
||||
return this.patch.watch$('packageData', pkgId, 'status', 'main')
|
||||
}
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { CB } from '@start9labs/start-sdk'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import { TuiAlertService, TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
@@ -694,22 +694,22 @@ interface SettingBtn {
|
||||
disabled$: Observable<boolean>
|
||||
}
|
||||
|
||||
const passwordSpec = CB.Config.of({
|
||||
currentPassword: CB.Value.text({
|
||||
const passwordSpec = ISB.InputSpec.of({
|
||||
currentPassword: ISB.Value.text({
|
||||
name: 'Current Password',
|
||||
required: {
|
||||
default: null,
|
||||
},
|
||||
masked: true,
|
||||
}),
|
||||
newPassword1: CB.Value.text({
|
||||
newPassword1: ISB.Value.text({
|
||||
name: 'New Password',
|
||||
required: {
|
||||
default: null,
|
||||
},
|
||||
masked: true,
|
||||
}),
|
||||
newPassword2: CB.Value.text({
|
||||
newPassword2: ISB.Value.text({
|
||||
name: 'Retype New Password',
|
||||
required: {
|
||||
default: null,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { ActionSheetButton, AlertInput } from '@ionic/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { ErrorService, LoadingService, pauseFor } from '@start9labs/shared'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { FormComponent, FormContext } from 'src/app/components/form.component'
|
||||
@@ -343,7 +343,7 @@ export class WifiPage {
|
||||
function getWifiValueSpec(
|
||||
ssid: string | null = null,
|
||||
needsPW: boolean = true,
|
||||
): CT.ValueSpecObject {
|
||||
): IST.ValueSpecObject {
|
||||
return {
|
||||
warning: null,
|
||||
type: 'object',
|
||||
|
||||
@@ -10,7 +10,7 @@ export class LaunchablePipe implements PipeTransform {
|
||||
|
||||
transform(
|
||||
state: T.PackageState['state'],
|
||||
status: T.MainStatus['status'],
|
||||
status: T.MainStatus['main'],
|
||||
): boolean {
|
||||
return this.configService.isLaunchable(state, status)
|
||||
}
|
||||
|
||||
159
web/projects/ui/src/app/services/action.service.ts
Normal file
159
web/projects/ui/src/app/services/action.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AlertController, ModalController } from '@ionic/angular'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
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 {
|
||||
ActionInputModal,
|
||||
PackageActionData,
|
||||
} from '../modals/action-input.component'
|
||||
|
||||
const allowedStatuses = {
|
||||
'only-running': new Set(['running']),
|
||||
'only-stopped': new Set(['stopped']),
|
||||
any: new Set([
|
||||
'running',
|
||||
'stopped',
|
||||
'restarting',
|
||||
'restoring',
|
||||
'stopping',
|
||||
'starting',
|
||||
'backingUp',
|
||||
]),
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ActionService {
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
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
|
||||
},
|
||||
) {
|
||||
if (
|
||||
allowedStatuses[actionInfo.metadata.allowedStatuses].has(
|
||||
pkgInfo.mainStatus,
|
||||
)
|
||||
) {
|
||||
if (actionInfo.metadata.hasInput) {
|
||||
this.formDialog.open<PackageActionData>(ActionInputModal, {
|
||||
label: actionInfo.metadata.name,
|
||||
data: {
|
||||
pkgInfo,
|
||||
actionInfo: {
|
||||
id: actionInfo.id,
|
||||
warning: actionInfo.metadata.warning,
|
||||
},
|
||||
dependentInfo,
|
||||
},
|
||||
})
|
||||
} 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)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
} else {
|
||||
const statuses = [...allowedStatuses[actionInfo.metadata.allowedStatuses]]
|
||||
const last = statuses.pop()
|
||||
let statusesStr = statuses.join(', ')
|
||||
let error = ''
|
||||
if (statuses.length) {
|
||||
if (statuses.length > 1) {
|
||||
// oxford comma
|
||||
statusesStr += ','
|
||||
}
|
||||
statusesStr += ` or ${last}`
|
||||
} else if (last) {
|
||||
statusesStr = `${last}`
|
||||
} else {
|
||||
error = `There is no status for which this action may be run. This is a bug. Please file an issue with the service maintainer.`
|
||||
}
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Forbidden',
|
||||
message:
|
||||
error ||
|
||||
`Action "${actionInfo.metadata.name}" can only be executed when service is ${statusesStr}`,
|
||||
buttons: ['OK'],
|
||||
cssClass: 'alert-error-message enter-click',
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
packageId: string,
|
||||
actionId: string,
|
||||
inputs?: {
|
||||
prev: RR.GetActionInputRes
|
||||
curr: object
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const loader = this.loader.open('Executing action...').subscribe()
|
||||
|
||||
try {
|
||||
const res = await this.api.runAction({
|
||||
packageId,
|
||||
actionId,
|
||||
prev: inputs?.prev || null,
|
||||
input: inputs?.curr || null,
|
||||
})
|
||||
|
||||
if (res) {
|
||||
const successModal = await this.modalCtrl.create({
|
||||
component: ActionSuccessPage,
|
||||
componentProps: {
|
||||
actionRes: res,
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => successModal.present(), 500)
|
||||
}
|
||||
return true // needed to dismiss original modal/alert
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false // don't dismiss original modal/alert
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Metric, 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, CB } from '@start9labs/start-sdk'
|
||||
import { T, ISB, IST } from '@start9labs/start-sdk'
|
||||
import { GetPackagesRes } from '@start9labs/marketplace'
|
||||
|
||||
const mockBlake3Commitment: T.Blake3Commitment = {
|
||||
@@ -112,7 +112,6 @@ export module Mock {
|
||||
},
|
||||
osVersion: '0.2.12',
|
||||
dependencies: {},
|
||||
hasConfig: true,
|
||||
images: {
|
||||
main: {
|
||||
source: 'packed',
|
||||
@@ -170,7 +169,6 @@ export module Mock {
|
||||
s9pk: '',
|
||||
},
|
||||
},
|
||||
hasConfig: true,
|
||||
images: {
|
||||
main: {
|
||||
source: 'packed',
|
||||
@@ -221,7 +219,6 @@ export module Mock {
|
||||
s9pk: '',
|
||||
},
|
||||
},
|
||||
hasConfig: false,
|
||||
images: {
|
||||
main: {
|
||||
source: 'packed',
|
||||
@@ -949,7 +946,8 @@ export module Mock {
|
||||
},
|
||||
}
|
||||
|
||||
export const ActionResponse: RR.ExecutePackageActionRes = {
|
||||
export const ActionResponse: T.ActionResult = {
|
||||
version: '0',
|
||||
message:
|
||||
'Password changed successfully. If you lose your new password, you will be lost forever.',
|
||||
value: 'NewPassword1234!',
|
||||
@@ -1137,31 +1135,29 @@ export module Mock {
|
||||
},
|
||||
}
|
||||
|
||||
export const getInputSpec = async (): Promise<
|
||||
RR.GetPackageConfigRes['spec']
|
||||
> =>
|
||||
export const getActionInputSpec = async (): Promise<IST.InputSpec> =>
|
||||
configBuilderToSpec(
|
||||
CB.Config.of({
|
||||
bitcoin: CB.Value.object(
|
||||
ISB.InputSpec.of({
|
||||
bitcoin: ISB.Value.object(
|
||||
{
|
||||
name: 'Bitcoin Settings',
|
||||
description:
|
||||
'RPC and P2P interface configuration options for Bitcoin Core',
|
||||
},
|
||||
CB.Config.of({
|
||||
'bitcoind-p2p': CB.Value.union(
|
||||
ISB.InputSpec.of({
|
||||
'bitcoind-p2p': ISB.Value.union(
|
||||
{
|
||||
name: 'P2P Settings',
|
||||
description:
|
||||
'<p>The Bitcoin Core node to connect to over the peer-to-peer (P2P) interface:</p><ul><li><strong>Bitcoin Core</strong>: The Bitcoin Core service installed on this device</li><li><strong>External Node</strong>: A Bitcoin node running on a different device</li></ul>',
|
||||
required: { default: 'internal' },
|
||||
},
|
||||
CB.Variants.of({
|
||||
internal: { name: 'Bitcoin Core', spec: CB.Config.of({}) },
|
||||
ISB.Variants.of({
|
||||
internal: { name: 'Bitcoin Core', spec: ISB.InputSpec.of({}) },
|
||||
external: {
|
||||
name: 'External Node',
|
||||
spec: CB.Config.of({
|
||||
'p2p-host': CB.Value.text({
|
||||
spec: ISB.InputSpec.of({
|
||||
'p2p-host': ISB.Value.text({
|
||||
name: 'Public Address',
|
||||
required: {
|
||||
default: null,
|
||||
@@ -1169,7 +1165,7 @@ export module Mock {
|
||||
description:
|
||||
'The public address of your Bitcoin Core server',
|
||||
}),
|
||||
'p2p-port': CB.Value.number({
|
||||
'p2p-port': ISB.Value.number({
|
||||
name: 'P2P Port',
|
||||
description:
|
||||
'The port that your Bitcoin Core P2P server is bound to',
|
||||
@@ -1186,24 +1182,23 @@ export module Mock {
|
||||
),
|
||||
}),
|
||||
),
|
||||
color: CB.Value.color({
|
||||
color: ISB.Value.color({
|
||||
name: 'Color',
|
||||
required: false,
|
||||
}),
|
||||
datetime: CB.Value.datetime({
|
||||
datetime: ISB.Value.datetime({
|
||||
name: 'Datetime',
|
||||
required: false,
|
||||
}),
|
||||
file: CB.Value.file({
|
||||
file: ISB.Value.file({
|
||||
name: 'File',
|
||||
required: false,
|
||||
extensions: ['png', 'pdf'],
|
||||
}),
|
||||
users: CB.Value.multiselect({
|
||||
users: ISB.Value.multiselect({
|
||||
name: 'Users',
|
||||
default: [],
|
||||
maxLength: 2,
|
||||
disabled: ['matt'],
|
||||
values: {
|
||||
matt: 'Matt Hill',
|
||||
alex: 'Alex Inkin',
|
||||
@@ -1211,21 +1206,19 @@ export module Mock {
|
||||
lucy: 'Lucy',
|
||||
},
|
||||
}),
|
||||
advanced: CB.Value.object(
|
||||
advanced: ISB.Value.object(
|
||||
{
|
||||
name: 'Advanced',
|
||||
description: 'Advanced settings',
|
||||
},
|
||||
CB.Config.of({
|
||||
rpcsettings: CB.Value.object(
|
||||
ISB.InputSpec.of({
|
||||
rpcsettings: ISB.Value.object(
|
||||
{
|
||||
name: 'RPC Settings',
|
||||
description: 'rpc username and password',
|
||||
warning:
|
||||
'Adding RPC users gives them special permissions on your node.',
|
||||
},
|
||||
CB.Config.of({
|
||||
rpcuser2: CB.Value.text({
|
||||
ISB.InputSpec.of({
|
||||
rpcuser2: ISB.Value.text({
|
||||
name: 'RPC Username',
|
||||
required: {
|
||||
default: 'defaultrpcusername',
|
||||
@@ -1238,7 +1231,7 @@ export module Mock {
|
||||
},
|
||||
],
|
||||
}),
|
||||
rpcuser: CB.Value.text({
|
||||
rpcuser: ISB.Value.text({
|
||||
name: 'RPC Username',
|
||||
required: {
|
||||
default: 'defaultrpcusername',
|
||||
@@ -1251,7 +1244,7 @@ export module Mock {
|
||||
},
|
||||
],
|
||||
}),
|
||||
rpcpass: CB.Value.text({
|
||||
rpcpass: ISB.Value.text({
|
||||
name: 'RPC User Password',
|
||||
required: {
|
||||
default: {
|
||||
@@ -1261,7 +1254,7 @@ export module Mock {
|
||||
},
|
||||
description: 'rpc password',
|
||||
}),
|
||||
rpcpass2: CB.Value.text({
|
||||
rpcpass2: ISB.Value.text({
|
||||
name: 'RPC User Password',
|
||||
required: {
|
||||
default: {
|
||||
@@ -1275,15 +1268,15 @@ export module Mock {
|
||||
),
|
||||
}),
|
||||
),
|
||||
testnet: CB.Value.toggle({
|
||||
testnet: ISB.Value.toggle({
|
||||
name: 'Testnet',
|
||||
default: true,
|
||||
description:
|
||||
'<ul><li>determines whether your node is running on testnet or mainnet</li></ul><script src="fake"></script>',
|
||||
warning: 'Chain will have to resync!',
|
||||
}),
|
||||
'object-list': CB.Value.list(
|
||||
CB.List.obj(
|
||||
'object-list': ISB.Value.list(
|
||||
ISB.List.obj(
|
||||
{
|
||||
name: 'Object List',
|
||||
minLength: 0,
|
||||
@@ -1295,13 +1288,13 @@ export module Mock {
|
||||
description: 'This is a list of objects, like users or something',
|
||||
},
|
||||
{
|
||||
spec: CB.Config.of({
|
||||
'first-name': CB.Value.text({
|
||||
spec: ISB.InputSpec.of({
|
||||
'first-name': ISB.Value.text({
|
||||
name: 'First Name',
|
||||
required: false,
|
||||
description: 'User first name',
|
||||
}),
|
||||
'last-name': CB.Value.text({
|
||||
'last-name': ISB.Value.text({
|
||||
name: 'Last Name',
|
||||
required: {
|
||||
default: {
|
||||
@@ -1317,7 +1310,7 @@ export module Mock {
|
||||
},
|
||||
],
|
||||
}),
|
||||
age: CB.Value.number({
|
||||
age: ISB.Value.number({
|
||||
name: 'Age',
|
||||
description: 'The age of the user',
|
||||
warning: 'User must be at least 18.',
|
||||
@@ -1331,8 +1324,8 @@ export module Mock {
|
||||
},
|
||||
),
|
||||
),
|
||||
'union-list': CB.Value.list(
|
||||
CB.List.obj(
|
||||
'union-list': ISB.Value.list(
|
||||
ISB.List.obj(
|
||||
{
|
||||
name: 'Union List',
|
||||
minLength: 0,
|
||||
@@ -1342,27 +1335,27 @@ export module Mock {
|
||||
warning: 'If you change this, things may work.',
|
||||
},
|
||||
{
|
||||
spec: CB.Config.of({
|
||||
spec: ISB.InputSpec.of({
|
||||
/* TODO: Convert range for this value ([0, 2])*/
|
||||
union: CB.Value.union(
|
||||
union: ISB.Value.union(
|
||||
{
|
||||
name: 'Preference',
|
||||
description: null,
|
||||
warning: null,
|
||||
required: { default: 'summer' },
|
||||
},
|
||||
CB.Variants.of({
|
||||
ISB.Variants.of({
|
||||
summer: {
|
||||
name: 'summer',
|
||||
spec: CB.Config.of({
|
||||
'favorite-tree': CB.Value.text({
|
||||
spec: ISB.InputSpec.of({
|
||||
'favorite-tree': ISB.Value.text({
|
||||
name: 'Favorite Tree',
|
||||
required: {
|
||||
default: 'Maple',
|
||||
},
|
||||
description: 'What is your favorite tree?',
|
||||
}),
|
||||
'favorite-flower': CB.Value.select({
|
||||
'favorite-flower': ISB.Value.select({
|
||||
name: 'Favorite Flower',
|
||||
description: 'Select your favorite flower',
|
||||
required: {
|
||||
@@ -1379,8 +1372,8 @@ export module Mock {
|
||||
},
|
||||
winter: {
|
||||
name: 'winter',
|
||||
spec: CB.Config.of({
|
||||
'like-snow': CB.Value.toggle({
|
||||
spec: ISB.InputSpec.of({
|
||||
'like-snow': ISB.Value.toggle({
|
||||
name: 'Like Snow?',
|
||||
default: true,
|
||||
description: 'Do you like snow or not?',
|
||||
@@ -1394,7 +1387,7 @@ export module Mock {
|
||||
},
|
||||
),
|
||||
),
|
||||
'random-select': CB.Value.select({
|
||||
'random-select': ISB.Value.dynamicSelect(() => ({
|
||||
name: 'Random select',
|
||||
description: 'This is not even real.',
|
||||
warning: 'Be careful changing this!',
|
||||
@@ -1407,47 +1400,47 @@ export module Mock {
|
||||
option3: 'option3',
|
||||
},
|
||||
disabled: ['option2'],
|
||||
}),
|
||||
})),
|
||||
'favorite-number':
|
||||
/* TODO: Convert range for this value ((-100,100])*/ CB.Value.number({
|
||||
name: 'Favorite Number',
|
||||
description: 'Your favorite number of all time',
|
||||
warning:
|
||||
'Once you set this number, it can never be changed without severe consequences.',
|
||||
required: {
|
||||
default: 7,
|
||||
/* TODO: Convert range for this value ((-100,100])*/ ISB.Value.number(
|
||||
{
|
||||
name: 'Favorite Number',
|
||||
description: 'Your favorite number of all time',
|
||||
warning:
|
||||
'Once you set this number, it can never be changed without severe consequences.',
|
||||
required: {
|
||||
default: 7,
|
||||
},
|
||||
integer: false,
|
||||
units: 'BTC',
|
||||
},
|
||||
integer: false,
|
||||
units: 'BTC',
|
||||
}),
|
||||
rpcsettings: CB.Value.object(
|
||||
),
|
||||
rpcsettings: ISB.Value.object(
|
||||
{
|
||||
name: 'RPC Settings',
|
||||
description: 'rpc username and password',
|
||||
warning:
|
||||
'Adding RPC users gives them special permissions on your node.',
|
||||
},
|
||||
CB.Config.of({
|
||||
laws: CB.Value.object(
|
||||
ISB.InputSpec.of({
|
||||
laws: ISB.Value.object(
|
||||
{
|
||||
name: 'Laws',
|
||||
description: 'the law of the realm',
|
||||
},
|
||||
CB.Config.of({
|
||||
law1: CB.Value.text({
|
||||
ISB.InputSpec.of({
|
||||
law1: ISB.Value.text({
|
||||
name: 'First Law',
|
||||
required: false,
|
||||
description: 'the first law',
|
||||
}),
|
||||
law2: CB.Value.text({
|
||||
law2: ISB.Value.text({
|
||||
name: 'Second Law',
|
||||
required: false,
|
||||
description: 'the second law',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
rulemakers: CB.Value.list(
|
||||
CB.List.obj(
|
||||
rulemakers: ISB.Value.list(
|
||||
ISB.List.obj(
|
||||
{
|
||||
name: 'Rule Makers',
|
||||
minLength: 0,
|
||||
@@ -1455,8 +1448,8 @@ export module Mock {
|
||||
description: 'the people who make the rules',
|
||||
},
|
||||
{
|
||||
spec: CB.Config.of({
|
||||
rulemakername: CB.Value.text({
|
||||
spec: ISB.InputSpec.of({
|
||||
rulemakername: ISB.Value.text({
|
||||
name: 'Rulemaker Name',
|
||||
required: {
|
||||
default: {
|
||||
@@ -1466,7 +1459,7 @@ export module Mock {
|
||||
},
|
||||
description: 'the name of the rule maker',
|
||||
}),
|
||||
rulemakerip: CB.Value.text({
|
||||
rulemakerip: ISB.Value.text({
|
||||
name: 'Rulemaker IP',
|
||||
required: {
|
||||
default: '192.168.1.0',
|
||||
@@ -1484,7 +1477,7 @@ export module Mock {
|
||||
},
|
||||
),
|
||||
),
|
||||
rpcuser: CB.Value.text({
|
||||
rpcuser: ISB.Value.text({
|
||||
name: 'RPC Username',
|
||||
required: {
|
||||
default: 'defaultrpcusername',
|
||||
@@ -1497,7 +1490,7 @@ export module Mock {
|
||||
},
|
||||
],
|
||||
}),
|
||||
rpcpass: CB.Value.text({
|
||||
rpcpass: ISB.Value.text({
|
||||
name: 'RPC User Password',
|
||||
required: {
|
||||
default: {
|
||||
@@ -1510,33 +1503,32 @@ export module Mock {
|
||||
}),
|
||||
}),
|
||||
),
|
||||
'bitcoin-node': CB.Value.union(
|
||||
'bitcoin-node': ISB.Value.union(
|
||||
{
|
||||
name: 'Bitcoin Node',
|
||||
description: 'Options<ul><li>Item 1</li><li>Item 2</li></ul>',
|
||||
warning: 'Careful changing this',
|
||||
required: { default: 'internal' },
|
||||
disabled: ['fake'],
|
||||
},
|
||||
CB.Variants.of({
|
||||
ISB.Variants.of({
|
||||
fake: {
|
||||
name: 'Fake',
|
||||
spec: CB.Config.of({}),
|
||||
spec: ISB.InputSpec.of({}),
|
||||
},
|
||||
internal: {
|
||||
name: 'Internal',
|
||||
spec: CB.Config.of({}),
|
||||
spec: ISB.InputSpec.of({}),
|
||||
},
|
||||
external: {
|
||||
name: 'External',
|
||||
spec: CB.Config.of({
|
||||
'emergency-contact': CB.Value.object(
|
||||
spec: ISB.InputSpec.of({
|
||||
'emergency-contact': ISB.Value.object(
|
||||
{
|
||||
name: 'Emergency Contact',
|
||||
description: 'The person to contact in case of emergency.',
|
||||
},
|
||||
CB.Config.of({
|
||||
name: CB.Value.text({
|
||||
ISB.InputSpec.of({
|
||||
name: ISB.Value.text({
|
||||
name: 'Name',
|
||||
required: {
|
||||
default: null,
|
||||
@@ -1548,7 +1540,7 @@ export module Mock {
|
||||
},
|
||||
],
|
||||
}),
|
||||
email: CB.Value.text({
|
||||
email: ISB.Value.text({
|
||||
name: 'Email',
|
||||
inputmode: 'email',
|
||||
required: {
|
||||
@@ -1557,7 +1549,7 @@ export module Mock {
|
||||
}),
|
||||
}),
|
||||
),
|
||||
'public-domain': CB.Value.text({
|
||||
'public-domain': ISB.Value.text({
|
||||
name: 'Public Domain',
|
||||
required: {
|
||||
default: 'bitcoinnode.com',
|
||||
@@ -1570,7 +1562,7 @@ export module Mock {
|
||||
},
|
||||
],
|
||||
}),
|
||||
'private-domain': CB.Value.text({
|
||||
'private-domain': ISB.Value.text({
|
||||
name: 'Private Domain',
|
||||
required: {
|
||||
default: null,
|
||||
@@ -1583,7 +1575,7 @@ export module Mock {
|
||||
},
|
||||
}),
|
||||
),
|
||||
port: CB.Value.number({
|
||||
port: ISB.Value.number({
|
||||
name: 'Port',
|
||||
description:
|
||||
'the default port for your Bitcoin node. default: 8333, testnet: 18333, regtest: 18444',
|
||||
@@ -1595,7 +1587,7 @@ export module Mock {
|
||||
step: 1,
|
||||
integer: true,
|
||||
}),
|
||||
'favorite-slogan': CB.Value.text({
|
||||
'favorite-slogan': ISB.Value.text({
|
||||
name: 'Favorite Slogan',
|
||||
generate: {
|
||||
charset: 'a-z,A-Z,2-9',
|
||||
@@ -1606,8 +1598,8 @@ export module Mock {
|
||||
'You most favorite slogan in the whole world, used for paying you.',
|
||||
masked: true,
|
||||
}),
|
||||
rpcallowip: CB.Value.list(
|
||||
CB.List.text(
|
||||
rpcallowip: ISB.Value.list(
|
||||
ISB.List.text(
|
||||
{
|
||||
name: 'RPC Allowed IPs',
|
||||
minLength: 1,
|
||||
@@ -1629,8 +1621,8 @@ export module Mock {
|
||||
},
|
||||
),
|
||||
),
|
||||
rpcauth: CB.Value.list(
|
||||
CB.List.text(
|
||||
rpcauth: ISB.Value.list(
|
||||
ISB.List.text(
|
||||
{
|
||||
name: 'RPC Auth',
|
||||
description:
|
||||
@@ -1694,14 +1686,21 @@ export module Mock {
|
||||
icon: '/assets/img/service-icons/bitcoind.svg',
|
||||
lastBackup: null,
|
||||
status: {
|
||||
configured: true,
|
||||
main: {
|
||||
status: 'running',
|
||||
started: new Date().toISOString(),
|
||||
health: {},
|
||||
main: 'running',
|
||||
started: new Date().toISOString(),
|
||||
health: {},
|
||||
},
|
||||
actions: {
|
||||
config: {
|
||||
name: 'Bitcoin Config',
|
||||
description: 'edit bitcoin.conf',
|
||||
warning: null,
|
||||
visibility: 'enabled',
|
||||
allowedStatuses: 'any',
|
||||
hasInput: true,
|
||||
group: null,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
serviceInterfaces: {
|
||||
ui: {
|
||||
id: 'ui',
|
||||
@@ -1860,6 +1859,7 @@ export module Mock {
|
||||
storeExposedDependents: [],
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
requestedActions: {},
|
||||
}
|
||||
|
||||
export const bitcoinProxy: PackageDataEntry<InstalledState> = {
|
||||
@@ -1871,10 +1871,7 @@ export module Mock {
|
||||
icon: '/assets/img/service-icons/btc-rpc-proxy.png',
|
||||
lastBackup: null,
|
||||
status: {
|
||||
configured: false,
|
||||
main: {
|
||||
status: 'stopped',
|
||||
},
|
||||
main: 'stopped',
|
||||
},
|
||||
actions: {},
|
||||
serviceInterfaces: {
|
||||
@@ -1902,13 +1899,13 @@ export module Mock {
|
||||
kind: 'running',
|
||||
versionRange: '>=26.0.0',
|
||||
healthChecks: [],
|
||||
configSatisfied: true,
|
||||
},
|
||||
},
|
||||
hosts: {},
|
||||
storeExposedDependents: [],
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
requestedActions: {},
|
||||
}
|
||||
|
||||
export const lnd: PackageDataEntry<InstalledState> = {
|
||||
@@ -1920,10 +1917,7 @@ export module Mock {
|
||||
icon: '/assets/img/service-icons/lnd.png',
|
||||
lastBackup: null,
|
||||
status: {
|
||||
configured: true,
|
||||
main: {
|
||||
status: 'stopped',
|
||||
},
|
||||
main: 'stopped',
|
||||
},
|
||||
actions: {},
|
||||
serviceInterfaces: {
|
||||
@@ -1986,20 +1980,19 @@ export module Mock {
|
||||
kind: 'running',
|
||||
versionRange: '>=26.0.0',
|
||||
healthChecks: [],
|
||||
configSatisfied: true,
|
||||
},
|
||||
'btc-rpc-proxy': {
|
||||
title: Mock.MockManifestBitcoinProxy.title,
|
||||
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
|
||||
kind: 'exists',
|
||||
versionRange: '>2.0.0',
|
||||
configSatisfied: false,
|
||||
},
|
||||
},
|
||||
hosts: {},
|
||||
storeExposedDependents: [],
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
requestedActions: {},
|
||||
}
|
||||
|
||||
export const LocalPkgs: { [key: string]: PackageDataEntry<InstalledState> } =
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { CT, T } from '@start9labs/start-sdk'
|
||||
import { IST, T } from '@start9labs/start-sdk'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
|
||||
export module RR {
|
||||
@@ -72,11 +72,10 @@ export module RR {
|
||||
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
|
||||
export type GetServerLogsRes = LogsRes
|
||||
|
||||
// @param limit: BE default is 50
|
||||
// @param boot: number is offset (0: current, -1 prev, +1 first), string is a specific boot id, and null is all
|
||||
export type FollowServerLogsReq = {
|
||||
limit?: number
|
||||
boot?: number | string | null
|
||||
limit?: number // (optional) default is 50. Ignored if cursor provided
|
||||
boot?: number | string | null // (optional) number is offset (0: current, -1 prev, +1 first), string is a specific boot id, null is all. Default is undefined
|
||||
cursor?: string // the last known log. Websocket will return all logs since this log
|
||||
} // server.logs.follow & server.kernel-logs.follow
|
||||
export type FollowServerLogsRes = {
|
||||
startCursor: string
|
||||
@@ -226,14 +225,19 @@ export module RR {
|
||||
export type InstallPackageReq = T.InstallParams
|
||||
export type InstallPackageRes = null
|
||||
|
||||
export type GetPackageConfigReq = { id: string } // package.config.get
|
||||
export type GetPackageConfigRes = { spec: CT.InputSpec; config: object }
|
||||
export type GetActionInputReq = { packageId: string; actionId: string } // package.action.get-input
|
||||
export type GetActionInputRes = {
|
||||
spec: IST.InputSpec
|
||||
value: object | null
|
||||
}
|
||||
|
||||
export type DrySetPackageConfigReq = { id: string; config: object } // package.config.set.dry
|
||||
export type DrySetPackageConfigRes = T.PackageId[]
|
||||
|
||||
export type SetPackageConfigReq = DrySetPackageConfigReq // package.config.set
|
||||
export type SetPackageConfigRes = null
|
||||
export type RunActionReq = {
|
||||
packageId: string
|
||||
actionId: string
|
||||
prev: GetActionInputRes | null
|
||||
input: object | null
|
||||
} // package.action.run
|
||||
export type RunActionRes = T.ActionResult | null
|
||||
|
||||
export type RestorePackagesReq = {
|
||||
// package.backup.restore
|
||||
@@ -244,13 +248,6 @@ export module RR {
|
||||
}
|
||||
export type RestorePackagesRes = null
|
||||
|
||||
export type ExecutePackageActionReq = {
|
||||
id: string
|
||||
actionId: string
|
||||
input?: object
|
||||
} // package.action
|
||||
export type ExecutePackageActionRes = ActionResponse
|
||||
|
||||
export type StartPackageReq = { id: string } // package.start
|
||||
export type StartPackageRes = null
|
||||
|
||||
@@ -263,16 +260,6 @@ export module RR {
|
||||
export type UninstallPackageReq = { id: string } // package.uninstall
|
||||
export type UninstallPackageRes = null
|
||||
|
||||
export type DryConfigureDependencyReq = {
|
||||
dependencyId: string
|
||||
dependentId: string
|
||||
} // package.dependency.configure.dry
|
||||
export type DryConfigureDependencyRes = {
|
||||
oldConfig: object
|
||||
newConfig: object
|
||||
spec: CT.InputSpec
|
||||
}
|
||||
|
||||
export type SideloadPackageReq = {
|
||||
manifest: T.Manifest
|
||||
icon: string // base64
|
||||
@@ -306,13 +293,6 @@ export interface TaggedDependencyError {
|
||||
error: DependencyError
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
message: string
|
||||
value: string | null
|
||||
copyable: boolean
|
||||
qr: boolean
|
||||
}
|
||||
|
||||
interface MetricData {
|
||||
value: string
|
||||
unit: string
|
||||
|
||||
@@ -231,26 +231,16 @@ export abstract class ApiService {
|
||||
params: RR.InstallPackageReq,
|
||||
): Promise<RR.InstallPackageRes>
|
||||
|
||||
abstract getPackageConfig(
|
||||
params: RR.GetPackageConfigReq,
|
||||
): Promise<RR.GetPackageConfigRes>
|
||||
abstract getActionInput(
|
||||
params: RR.GetActionInputReq,
|
||||
): Promise<RR.GetActionInputRes>
|
||||
|
||||
abstract drySetPackageConfig(
|
||||
params: RR.DrySetPackageConfigReq,
|
||||
): Promise<RR.DrySetPackageConfigRes>
|
||||
|
||||
abstract setPackageConfig(
|
||||
params: RR.SetPackageConfigReq,
|
||||
): Promise<RR.SetPackageConfigRes>
|
||||
abstract runAction(params: RR.RunActionReq): Promise<RR.RunActionRes>
|
||||
|
||||
abstract restorePackages(
|
||||
params: RR.RestorePackagesReq,
|
||||
): Promise<RR.RestorePackagesRes>
|
||||
|
||||
abstract executePackageAction(
|
||||
params: RR.ExecutePackageActionReq,
|
||||
): Promise<RR.ExecutePackageActionRes>
|
||||
|
||||
abstract startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes>
|
||||
|
||||
abstract restartPackage(
|
||||
@@ -263,9 +253,5 @@ export abstract class ApiService {
|
||||
params: RR.UninstallPackageReq,
|
||||
): Promise<RR.UninstallPackageRes>
|
||||
|
||||
abstract dryConfigureDependency(
|
||||
params: RR.DryConfigureDependencyReq,
|
||||
): Promise<RR.DryConfigureDependencyRes>
|
||||
|
||||
abstract sideloadPackage(): Promise<RR.SideloadPackageRes>
|
||||
}
|
||||
|
||||
@@ -468,22 +468,14 @@ export class LiveApiService extends ApiService {
|
||||
return this.rpcRequest({ method: 'package.install', params })
|
||||
}
|
||||
|
||||
async getPackageConfig(
|
||||
params: RR.GetPackageConfigReq,
|
||||
): Promise<RR.GetPackageConfigRes> {
|
||||
return this.rpcRequest({ method: 'package.config.get', params })
|
||||
async getActionInput(
|
||||
params: RR.GetActionInputReq,
|
||||
): Promise<RR.GetActionInputRes> {
|
||||
return this.rpcRequest({ method: 'package.action.get-input', params })
|
||||
}
|
||||
|
||||
async drySetPackageConfig(
|
||||
params: RR.DrySetPackageConfigReq,
|
||||
): Promise<RR.DrySetPackageConfigRes> {
|
||||
return this.rpcRequest({ method: 'package.config.set.dry', params })
|
||||
}
|
||||
|
||||
async setPackageConfig(
|
||||
params: RR.SetPackageConfigReq,
|
||||
): Promise<RR.SetPackageConfigRes> {
|
||||
return this.rpcRequest({ method: 'package.config.set', params })
|
||||
async runAction(params: RR.RunActionReq): Promise<RR.RunActionRes> {
|
||||
return this.rpcRequest({ method: 'package.action.run', params })
|
||||
}
|
||||
|
||||
async restorePackages(
|
||||
@@ -492,12 +484,6 @@ export class LiveApiService extends ApiService {
|
||||
return this.rpcRequest({ method: 'package.backup.restore', params })
|
||||
}
|
||||
|
||||
async executePackageAction(
|
||||
params: RR.ExecutePackageActionReq,
|
||||
): Promise<RR.ExecutePackageActionRes> {
|
||||
return this.rpcRequest({ method: 'package.action', params })
|
||||
}
|
||||
|
||||
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
|
||||
return this.rpcRequest({ method: 'package.start', params })
|
||||
}
|
||||
@@ -518,15 +504,6 @@ export class LiveApiService extends ApiService {
|
||||
return this.rpcRequest({ method: 'package.uninstall', params })
|
||||
}
|
||||
|
||||
async dryConfigureDependency(
|
||||
params: RR.DryConfigureDependencyReq,
|
||||
): Promise<RR.DryConfigureDependencyRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'package.dependency.configure.dry',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async sideloadPackage(): Promise<RR.SideloadPackageRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'package.sideload',
|
||||
|
||||
@@ -780,37 +780,19 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async getPackageConfig(
|
||||
params: RR.GetPackageConfigReq,
|
||||
): Promise<RR.GetPackageConfigRes> {
|
||||
async getActionInput(
|
||||
params: RR.GetActionInputReq,
|
||||
): Promise<RR.GetActionInputRes> {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
config: Mock.MockConfig,
|
||||
spec: await Mock.getInputSpec(),
|
||||
value: Mock.MockConfig,
|
||||
spec: await Mock.getActionInputSpec(),
|
||||
}
|
||||
}
|
||||
|
||||
async drySetPackageConfig(
|
||||
params: RR.DrySetPackageConfigReq,
|
||||
): Promise<RR.DrySetPackageConfigRes> {
|
||||
async runAction(params: RR.RunActionReq): Promise<RR.RunActionRes> {
|
||||
await pauseFor(2000)
|
||||
return []
|
||||
}
|
||||
|
||||
async setPackageConfig(
|
||||
params: RR.SetPackageConfigReq,
|
||||
): Promise<RR.SetPackageConfigRes> {
|
||||
await pauseFor(2000)
|
||||
const patch = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: `/packageData/${params.id}/status/configured`,
|
||||
value: true,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
return Mock.ActionResponse
|
||||
}
|
||||
|
||||
async restorePackages(
|
||||
@@ -843,13 +825,6 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async executePackageAction(
|
||||
params: RR.ExecutePackageActionReq,
|
||||
): Promise<RR.ExecutePackageActionRes> {
|
||||
await pauseFor(2000)
|
||||
return Mock.ActionResponse
|
||||
}
|
||||
|
||||
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
|
||||
const path = `/packageData/${params.id}/status/main`
|
||||
|
||||
@@ -1069,17 +1044,6 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async dryConfigureDependency(
|
||||
params: RR.DryConfigureDependencyReq,
|
||||
): Promise<RR.DryConfigureDependencyRes> {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
oldConfig: Mock.MockConfig,
|
||||
newConfig: Mock.MockDependencyConfig,
|
||||
spec: await Mock.getInputSpec(),
|
||||
}
|
||||
}
|
||||
|
||||
async sideloadPackage(): Promise<RR.SideloadPackageRes> {
|
||||
await pauseFor(2000)
|
||||
return {
|
||||
|
||||
@@ -96,40 +96,47 @@ export const mockPatchData: DataModel = {
|
||||
icon: '/assets/img/service-icons/bitcoind.svg',
|
||||
lastBackup: null,
|
||||
status: {
|
||||
configured: true,
|
||||
main: {
|
||||
status: 'running',
|
||||
started: '2021-06-14T20:49:17.774Z',
|
||||
health: {
|
||||
'ephemeral-health-check': {
|
||||
name: 'Ephemeral Health Check',
|
||||
result: 'starting',
|
||||
message: null,
|
||||
},
|
||||
'chain-state': {
|
||||
name: 'Chain State',
|
||||
result: 'loading',
|
||||
message: 'Bitcoin is syncing from genesis',
|
||||
},
|
||||
'p2p-interface': {
|
||||
name: 'P2P',
|
||||
result: 'success',
|
||||
message: 'Health check successful',
|
||||
},
|
||||
'rpc-interface': {
|
||||
name: 'RPC',
|
||||
result: 'failure',
|
||||
message: 'RPC interface unreachable.',
|
||||
},
|
||||
'unnecessary-health-check': {
|
||||
name: 'Unnecessary Health Check',
|
||||
result: 'disabled',
|
||||
message: null,
|
||||
},
|
||||
main: 'running',
|
||||
started: '2021-06-14T20:49:17.774Z',
|
||||
health: {
|
||||
'ephemeral-health-check': {
|
||||
name: 'Ephemeral Health Check',
|
||||
result: 'starting',
|
||||
message: null,
|
||||
},
|
||||
'chain-state': {
|
||||
name: 'Chain State',
|
||||
result: 'loading',
|
||||
message: 'Bitcoin is syncing from genesis',
|
||||
},
|
||||
'p2p-interface': {
|
||||
name: 'P2P',
|
||||
result: 'success',
|
||||
message: 'Health check successful',
|
||||
},
|
||||
'rpc-interface': {
|
||||
name: 'RPC',
|
||||
result: 'failure',
|
||||
message: 'RPC interface unreachable.',
|
||||
},
|
||||
'unnecessary-health-check': {
|
||||
name: 'Unnecessary Health Check',
|
||||
result: 'disabled',
|
||||
message: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
actions: {
|
||||
config: {
|
||||
name: 'Bitcoin Config',
|
||||
description: 'edit bitcoin.conf',
|
||||
warning: null,
|
||||
visibility: 'enabled',
|
||||
allowedStatuses: 'any',
|
||||
hasInput: true,
|
||||
group: null,
|
||||
},
|
||||
},
|
||||
serviceInterfaces: {
|
||||
ui: {
|
||||
id: 'ui',
|
||||
@@ -288,6 +295,7 @@ export const mockPatchData: DataModel = {
|
||||
storeExposedDependents: [],
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
requestedActions: {},
|
||||
},
|
||||
lnd: {
|
||||
stateInfo: {
|
||||
@@ -301,10 +309,7 @@ export const mockPatchData: DataModel = {
|
||||
icon: '/assets/img/service-icons/lnd.png',
|
||||
lastBackup: null,
|
||||
status: {
|
||||
configured: true,
|
||||
main: {
|
||||
status: 'stopped',
|
||||
},
|
||||
main: 'stopped',
|
||||
},
|
||||
actions: {},
|
||||
serviceInterfaces: {
|
||||
@@ -367,7 +372,6 @@ export const mockPatchData: DataModel = {
|
||||
kind: 'running',
|
||||
versionRange: '>=26.0.0',
|
||||
healthChecks: [],
|
||||
configSatisfied: true,
|
||||
},
|
||||
'btc-rpc-proxy': {
|
||||
title: 'Bitcoin Proxy',
|
||||
@@ -375,13 +379,13 @@ export const mockPatchData: DataModel = {
|
||||
kind: 'running',
|
||||
versionRange: '>2.0.0',
|
||||
healthChecks: [],
|
||||
configSatisfied: false,
|
||||
},
|
||||
},
|
||||
hosts: {},
|
||||
storeExposedDependents: [],
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
requestedActions: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ConfigService {
|
||||
|
||||
isLaunchable(
|
||||
state: T.PackageState['state'],
|
||||
status: T.MainStatus['status'],
|
||||
status: T.MainStatus['main'],
|
||||
): boolean {
|
||||
return state === 'installed' && status === 'running'
|
||||
}
|
||||
|
||||
@@ -102,13 +102,20 @@ export class DepErrorService {
|
||||
}
|
||||
|
||||
// invalid config
|
||||
if (!currentDep.configSatisfied) {
|
||||
if (
|
||||
Object.values(pkg.requestedActions).some(
|
||||
a =>
|
||||
a.active &&
|
||||
a.request.packageId === depId &&
|
||||
a.request.actionId === 'config',
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: 'configUnsatisfied',
|
||||
}
|
||||
}
|
||||
|
||||
const depStatus = dep.status.main.status
|
||||
const depStatus = dep.status.main
|
||||
|
||||
// not running
|
||||
if (depStatus !== 'running' && depStatus !== 'starting') {
|
||||
@@ -120,7 +127,7 @@ export class DepErrorService {
|
||||
// health check failure
|
||||
if (depStatus === 'running' && currentDep.kind === 'running') {
|
||||
for (let id of currentDep.healthChecks) {
|
||||
const check = dep.status.main.health[id]
|
||||
const check = dep.status.health[id]
|
||||
if (check?.result !== 'success') {
|
||||
return {
|
||||
type: 'healthChecksFailed',
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { CT, utils } from '@start9labs/start-sdk'
|
||||
import { IST, utils } from '@start9labs/start-sdk'
|
||||
const Mustache = require('mustache')
|
||||
|
||||
@Injectable({
|
||||
@@ -17,16 +17,16 @@ export class FormService {
|
||||
constructor(private readonly formBuilder: UntypedFormBuilder) {}
|
||||
|
||||
createForm(
|
||||
spec: CT.InputSpec,
|
||||
spec: IST.InputSpec,
|
||||
current: Record<string, any> = {},
|
||||
): UntypedFormGroup {
|
||||
return this.getFormGroup(spec, [], current)
|
||||
}
|
||||
|
||||
getUnionSelectSpec(
|
||||
spec: CT.ValueSpecUnion,
|
||||
spec: IST.ValueSpecUnion,
|
||||
selection: string | null,
|
||||
): CT.ValueSpecSelect {
|
||||
): IST.ValueSpecSelect {
|
||||
return {
|
||||
...spec,
|
||||
type: 'select',
|
||||
@@ -38,7 +38,7 @@ export class FormService {
|
||||
}
|
||||
|
||||
getUnionObject(
|
||||
spec: CT.ValueSpecUnion,
|
||||
spec: IST.ValueSpecUnion,
|
||||
selected: string | null,
|
||||
): UntypedFormGroup {
|
||||
const group = this.getFormGroup({
|
||||
@@ -53,16 +53,16 @@ export class FormService {
|
||||
return group
|
||||
}
|
||||
|
||||
getListItem(spec: CT.ValueSpecList, entry?: any) {
|
||||
if (CT.isValueSpecListOf(spec, 'text')) {
|
||||
getListItem(spec: IST.ValueSpecList, entry?: any) {
|
||||
if (IST.isValueSpecListOf(spec, 'text')) {
|
||||
return this.formBuilder.control(entry, stringValidators(spec.spec))
|
||||
} else if (CT.isValueSpecListOf(spec, 'object')) {
|
||||
} else if (IST.isValueSpecListOf(spec, 'object')) {
|
||||
return this.getFormGroup(spec.spec.spec, [], entry)
|
||||
}
|
||||
}
|
||||
|
||||
getFormGroup(
|
||||
config: CT.InputSpec,
|
||||
config: IST.InputSpec,
|
||||
validators: ValidatorFn[] = [],
|
||||
current?: Record<string, any> | null,
|
||||
): UntypedFormGroup {
|
||||
@@ -77,7 +77,7 @@ export class FormService {
|
||||
}
|
||||
|
||||
private getFormEntry(
|
||||
spec: CT.ValueSpec,
|
||||
spec: IST.ValueSpec,
|
||||
currentValue?: any,
|
||||
): UntypedFormGroup | UntypedFormArray | UntypedFormControl {
|
||||
let value: any
|
||||
@@ -145,18 +145,18 @@ export class FormService {
|
||||
}
|
||||
}
|
||||
|
||||
// function getListItemValidators(spec: CT.ValueSpecList) {
|
||||
// if (CT.isValueSpecListOf(spec, 'text')) {
|
||||
// function getListItemValidators(spec: IST.ValueSpecList) {
|
||||
// if (IST.isValueSpecListOf(spec, 'text')) {
|
||||
// return stringValidators(spec.spec)
|
||||
// }
|
||||
// }
|
||||
|
||||
function stringValidators(
|
||||
spec: CT.ValueSpecText | CT.ListValueSpecText,
|
||||
spec: IST.ValueSpecText | IST.ListValueSpecText,
|
||||
): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
if ((spec as CT.ValueSpecText).required) {
|
||||
if ((spec as IST.ValueSpecText).required) {
|
||||
validators.push(Validators.required)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ function stringValidators(
|
||||
return validators
|
||||
}
|
||||
|
||||
function textareaValidators(spec: CT.ValueSpecTextarea): ValidatorFn[] {
|
||||
function textareaValidators(spec: IST.ValueSpecTextarea): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
if (spec.required) {
|
||||
@@ -181,7 +181,7 @@ function textareaValidators(spec: CT.ValueSpecTextarea): ValidatorFn[] {
|
||||
return validators
|
||||
}
|
||||
|
||||
function colorValidators({ required }: CT.ValueSpecColor): ValidatorFn[] {
|
||||
function colorValidators({ required }: IST.ValueSpecColor): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = [Validators.pattern(/^#[0-9a-f]{6}$/i)]
|
||||
|
||||
if (required) {
|
||||
@@ -195,7 +195,7 @@ function datetimeValidators({
|
||||
required,
|
||||
min,
|
||||
max,
|
||||
}: CT.ValueSpecDatetime): ValidatorFn[] {
|
||||
}: IST.ValueSpecDatetime): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
if (required) {
|
||||
@@ -213,12 +213,12 @@ function datetimeValidators({
|
||||
return validators
|
||||
}
|
||||
|
||||
function numberValidators(spec: CT.ValueSpecNumber): ValidatorFn[] {
|
||||
function numberValidators(spec: IST.ValueSpecNumber): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
validators.push(isNumber())
|
||||
|
||||
if ((spec as CT.ValueSpecNumber).required) {
|
||||
if ((spec as IST.ValueSpecNumber).required) {
|
||||
validators.push(Validators.required)
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ function numberValidators(spec: CT.ValueSpecNumber): ValidatorFn[] {
|
||||
return validators
|
||||
}
|
||||
|
||||
function selectValidators(spec: CT.ValueSpecSelect): ValidatorFn[] {
|
||||
function selectValidators(spec: IST.ValueSpecSelect): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
if (spec.required) {
|
||||
@@ -241,13 +241,13 @@ function selectValidators(spec: CT.ValueSpecSelect): ValidatorFn[] {
|
||||
return validators
|
||||
}
|
||||
|
||||
function multiselectValidators(spec: CT.ValueSpecMultiselect): ValidatorFn[] {
|
||||
function multiselectValidators(spec: IST.ValueSpecMultiselect): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
validators.push(listInRange(spec.minLength, spec.maxLength))
|
||||
return validators
|
||||
}
|
||||
|
||||
function listValidators(spec: CT.ValueSpecList): ValidatorFn[] {
|
||||
function listValidators(spec: IST.ValueSpecList): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
validators.push(listInRange(spec.minLength, spec.maxLength))
|
||||
validators.push(listItemIssue())
|
||||
@@ -352,7 +352,7 @@ export function listItemIssue(): ValidatorFn {
|
||||
}
|
||||
}
|
||||
|
||||
export function listUnique(spec: CT.ValueSpecList): ValidatorFn {
|
||||
export function listUnique(spec: IST.ValueSpecList): ValidatorFn {
|
||||
return control => {
|
||||
const list = control.value
|
||||
for (let idx = 0; idx < list.length; idx++) {
|
||||
@@ -389,7 +389,11 @@ export function listUnique(spec: CT.ValueSpecList): ValidatorFn {
|
||||
}
|
||||
}
|
||||
|
||||
function listItemEquals(spec: CT.ValueSpecList, val1: any, val2: any): boolean {
|
||||
function listItemEquals(
|
||||
spec: IST.ValueSpecList,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
// TODO: fix types
|
||||
switch (spec.spec.type) {
|
||||
case 'text':
|
||||
@@ -402,7 +406,7 @@ function listItemEquals(spec: CT.ValueSpecList, val1: any, val2: any): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function itemEquals(spec: CT.ValueSpec, val1: any, val2: any): boolean {
|
||||
function itemEquals(spec: IST.ValueSpec, val1: any, val2: any): boolean {
|
||||
switch (spec.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
@@ -414,15 +418,15 @@ function itemEquals(spec: CT.ValueSpec, val1: any, val2: any): boolean {
|
||||
// TODO: 'unique-by' does not exist on ValueSpecObject, fix types
|
||||
return objEquals(
|
||||
(spec as any)['unique-by'],
|
||||
spec as CT.ValueSpecObject,
|
||||
spec as IST.ValueSpecObject,
|
||||
val1,
|
||||
val2,
|
||||
)
|
||||
case 'union':
|
||||
// TODO: 'unique-by' does not exist on CT.ValueSpecUnion, fix types
|
||||
// TODO: 'unique-by' does not exist onIST.ValueSpecUnion, fix types
|
||||
return unionEquals(
|
||||
(spec as any)['unique-by'],
|
||||
spec as CT.ValueSpecUnion,
|
||||
spec as IST.ValueSpecUnion,
|
||||
val1,
|
||||
val2,
|
||||
)
|
||||
@@ -442,8 +446,8 @@ function itemEquals(spec: CT.ValueSpec, val1: any, val2: any): boolean {
|
||||
}
|
||||
|
||||
function listObjEquals(
|
||||
uniqueBy: CT.UniqueBy,
|
||||
spec: CT.ListValueSpecObject,
|
||||
uniqueBy: IST.UniqueBy,
|
||||
spec: IST.ListValueSpecObject,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
@@ -470,8 +474,8 @@ function listObjEquals(
|
||||
}
|
||||
|
||||
function objEquals(
|
||||
uniqueBy: CT.UniqueBy,
|
||||
spec: CT.ValueSpecObject,
|
||||
uniqueBy: IST.UniqueBy,
|
||||
spec: IST.ValueSpecObject,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
@@ -499,8 +503,8 @@ function objEquals(
|
||||
}
|
||||
|
||||
function unionEquals(
|
||||
uniqueBy: CT.UniqueBy,
|
||||
spec: CT.ValueSpecUnion,
|
||||
uniqueBy: IST.UniqueBy,
|
||||
spec: IST.ValueSpecUnion,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
@@ -532,8 +536,8 @@ function unionEquals(
|
||||
}
|
||||
|
||||
function uniqueByMessageWrapper(
|
||||
uniqueBy: CT.UniqueBy,
|
||||
spec: CT.ListValueSpecObject,
|
||||
uniqueBy: IST.UniqueBy,
|
||||
spec: IST.ListValueSpecObject,
|
||||
) {
|
||||
let configSpec = spec.spec
|
||||
|
||||
@@ -544,8 +548,8 @@ function uniqueByMessageWrapper(
|
||||
}
|
||||
|
||||
function uniqueByMessage(
|
||||
uniqueBy: CT.UniqueBy,
|
||||
configSpec: CT.InputSpec,
|
||||
uniqueBy: IST.UniqueBy,
|
||||
configSpec: IST.InputSpec,
|
||||
outermost = true,
|
||||
): string {
|
||||
let joinFunc
|
||||
@@ -554,7 +558,7 @@ function uniqueByMessage(
|
||||
return ''
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
return configSpec[uniqueBy]
|
||||
? (configSpec[uniqueBy] as CT.ValueSpecObject).name
|
||||
? (configSpec[uniqueBy] as IST.ValueSpecObject).name
|
||||
: uniqueBy
|
||||
} else if ('any' in uniqueBy) {
|
||||
joinFunc = ' OR '
|
||||
@@ -574,14 +578,14 @@ function uniqueByMessage(
|
||||
}
|
||||
|
||||
function isObject(
|
||||
spec: CT.ListValueSpecOf<any>,
|
||||
): spec is CT.ListValueSpecObject {
|
||||
spec: IST.ListValueSpecOf<any>,
|
||||
): spec is IST.ListValueSpecObject {
|
||||
// only lists of objects have uniqueBy
|
||||
return 'uniqueBy' in spec
|
||||
}
|
||||
|
||||
export function convertValuesRecursive(
|
||||
configSpec: CT.InputSpec,
|
||||
configSpec: IST.InputSpec,
|
||||
group: UntypedFormGroup,
|
||||
) {
|
||||
Object.entries(configSpec).forEach(([key, valueSpec]) => {
|
||||
@@ -611,7 +615,7 @@ export function convertValuesRecursive(
|
||||
})
|
||||
} else if (valueSpec.spec.type === 'object') {
|
||||
controls.forEach(formGroup => {
|
||||
const objectSpec = valueSpec.spec as CT.ListValueSpecObject
|
||||
const objectSpec = valueSpec.spec as IST.ListValueSpecObject
|
||||
convertValuesRecursive(objectSpec.spec, formGroup as UntypedFormGroup)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -17,7 +18,7 @@ export function renderPkgStatus(
|
||||
let health: T.HealthStatus | null = null
|
||||
|
||||
if (pkg.stateInfo.state === 'installed') {
|
||||
primary = getInstalledPrimaryStatus(pkg.status)
|
||||
primary = getInstalledPrimaryStatus(pkg)
|
||||
dependency = getDependencyStatus(depErrors)
|
||||
health = getHealthStatus(pkg.status)
|
||||
} else {
|
||||
@@ -27,11 +28,11 @@ export function renderPkgStatus(
|
||||
return { primary, dependency, health }
|
||||
}
|
||||
|
||||
function getInstalledPrimaryStatus(status: T.Status): PrimaryStatus {
|
||||
if (!status.configured) {
|
||||
function getInstalledPrimaryStatus(pkg: T.PackageDataEntry): PrimaryStatus {
|
||||
if (needsConfig(getManifest(pkg).id, pkg.requestedActions)) {
|
||||
return 'needsConfig'
|
||||
} else {
|
||||
return status.main.status as any as PrimaryStatus
|
||||
return pkg.status.main
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +40,12 @@ function getDependencyStatus(depErrors: PkgDependencyErrors): DependencyStatus {
|
||||
return Object.values(depErrors).some(err => !!err) ? 'warning' : 'satisfied'
|
||||
}
|
||||
|
||||
function getHealthStatus(status: T.Status): T.HealthStatus | null {
|
||||
if (status.main.status !== 'running' || !status.main.health) {
|
||||
function getHealthStatus(status: T.MainStatus): T.HealthStatus | null {
|
||||
if (status.main !== 'running' || !status.main) {
|
||||
return null
|
||||
}
|
||||
|
||||
const values = Object.values(status.main.health)
|
||||
const values = Object.values(status.health)
|
||||
|
||||
if (values.some(h => h.result === 'failure')) {
|
||||
return 'failure'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CB } from '@start9labs/start-sdk'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
|
||||
export async function configBuilderToSpec(
|
||||
builder:
|
||||
| CB.Config<Record<string, unknown>, unknown>
|
||||
| CB.Config<Record<string, unknown>, never>,
|
||||
| ISB.InputSpec<Record<string, unknown>, unknown>
|
||||
| ISB.InputSpec<Record<string, unknown>, never>,
|
||||
) {
|
||||
return builder.build({} as any)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,18 @@ 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> {
|
||||
|
||||
Reference in New Issue
Block a user