Convert properties to an action (#2751)

* update actions response types and partially implement in UI

* further remove diagnostic ui

* convert action response nested to array

* prepare action res modal for Alex

* ad dproperties action for Bitcoin

* feat: add action success dialog (#2753)

* feat: add action success dialog

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

---------

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

* return null

* remove properties from backend

* misc fixes

* make severity separate argument

* rename ActionRequest to ActionRequestOptions

* add clearRequests

* fix s9pk build

* remove config and properties, introduce action requests

* better ux, better moocks, include icons

* fix dependency types

* add variant for versionCompat

* fix dep icon display and patch operation display

* misc fixes

* misc fixes

* alpha 12

* honor provided input to set values in action

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

* fix type

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

* fix: fix build:deps command on Windows

* fix: add escaped quotes

---------

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

* misc db compatibility fixes

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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