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:
Aiden McClelland
2024-09-25 16:12:52 -06:00
committed by GitHub
parent eec5cf6b65
commit db0695126f
469 changed files with 16218 additions and 10485 deletions

View File

@@ -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,

View File

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

View File

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

View File

@@ -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>
>[]
}
}

View File

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

View File

@@ -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)],
}

View File

@@ -36,4 +36,4 @@
Accept
</button>
</div>
</ng-template>
</ng-template>

View File

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

View File

@@ -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 &&

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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],
})

View File

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

View File

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

View 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)),
)
}
}

View File

@@ -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,

View File

@@ -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)),
)
}
}

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
},
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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',

View File

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

View 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()
}
}
}

View File

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

View File

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

View File

@@ -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>
}

View File

@@ -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',

View File

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

View File

@@ -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: {},
},
},
}

View File

@@ -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'
}

View File

@@ -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',

View File

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

View File

@@ -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'

View File

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

View File

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