Refactor/status info (#3066)

* refactor status info

* wip fe

* frontend changes and version bump

* fix tests and motd

* add registry workflow

* better starttunnel instructions

* placeholders for starttunnel tables

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-12-02 16:31:02 -07:00
committed by GitHub
parent 7c772e873d
commit 3c27499795
80 changed files with 920 additions and 1062 deletions

View File

@@ -19,7 +19,6 @@ import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model'
import { DEVICES_ADD } from './add'
import { DEVICES_CONFIG } from './config'
import { MappedDevice, MappedSubnet } from './utils'
@@ -84,6 +83,8 @@ import { MappedDevice, MappedSubnet } from './utils'
</button>
</td>
</tr>
} @empty {
<div class="placeholder">No devices</div>
}
</tbody>
</table>

View File

@@ -55,6 +55,8 @@ import { MappedDevice, MappedForward } from './utils'
</button>
</td>
</tr>
} @empty {
<div class="placeholder">No port forwards</div>
}
</tbody>
</table>

View File

@@ -67,6 +67,8 @@ import { SUBNETS_ADD } from './add'
</button>
</td>
</tr>
} @empty {
<div class="placeholder">No subnets</div>
}
</tbody>
</table>

View File

@@ -80,6 +80,12 @@ tui-dialog[new][data-appearance~='start-9'] {
}
}
.placeholder {
padding: 1rem;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
}
qr-code {
display: flex;
justify-content: center;

View File

@@ -15,7 +15,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
selector: 'service-error',
template: `
<header>{{ 'Service Launch Error' | i18n }}</header>
<p class="error-message">{{ error?.message }}</p>
<p class="error-message">{{ error?.details }}</p>
<p>{{ error?.debug }}</p>
<h4>
{{ 'Actions' | i18n }}
@@ -95,7 +95,7 @@ export class ServiceErrorComponent {
overflow = false
get error() {
return this.pkg.status.main === 'error' ? this.pkg.status : null
return this.pkg.statusInfo.error
}
rebuild() {
@@ -108,7 +108,7 @@ export class ServiceErrorComponent {
show() {
this.dialog
.openAlert(this.error?.message as i18nKey, { label: 'Service error' })
.openAlert(this.error?.details as i18nKey, { label: 'Service error' })
.subscribe()
}
}

View File

@@ -37,7 +37,7 @@ import {
<span class="loading-dots"></span>
}
@if ($any(pkg().status)?.started; as started) {
@if (pkg().statusInfo.started; as started) {
<service-uptime [started]="started" />
}
</h3>

View File

@@ -19,6 +19,7 @@ import { ServiceTasksComponent } from 'src/app/routes/portal/routes/services/com
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
@@ -161,7 +162,7 @@ export class ServiceTaskComponent {
pkgInfo: {
id: this.task().packageId,
title,
mainStatus: pkg.status.main,
status: getInstalledBaseStatus(pkg.statusInfo),
icon: pkg.icon,
},
actionInfo: { id: this.task().actionId, metadata },

View File

@@ -114,11 +114,10 @@ import { distinctUntilChanged } from 'rxjs/operators'
})
export class ServiceUptimeComponent {
protected readonly uptime$ = timer(0, 1000).pipe(
map(() =>
this.started()
? Math.max(Date.now() - new Date(this.started()).getTime(), 0)
: 0,
),
map(() => {
const started = this.started()
return started ? Math.max(Date.now() - new Date(started).getTime(), 0) : 0
}),
distinctUntilChanged(),
map(delta => ({
seconds: Math.floor(delta / 1000) % 60,
@@ -128,5 +127,5 @@ export class ServiceUptimeComponent {
})),
)
readonly started = input('')
readonly started = input<string | null>(null)
}

View File

@@ -34,7 +34,7 @@ import { StatusComponent } from './status.component'
></td>
<td [style.grid-area]="'2 / 2'">{{ manifest.version }}</td>
<td class="uptime">
@if ($any(pkg.status)?.started; as started) {
@if (pkg.statusInfo.started; as started) {
<span>{{ 'Uptime' | i18n }}:</span>
<service-uptime [started]="started" />
} @else {

View File

@@ -56,7 +56,7 @@ export class StatusComponent {
const { primary, health } = this.getStatus(this.pkg)
return (
!this.hasDepErrors &&
primary !== 'taskRequired' &&
primary !== 'task-required' &&
primary !== 'error' &&
health !== 'failure'
)
@@ -80,7 +80,7 @@ export class StatusComponent {
case 'updating':
case 'stopping':
case 'starting':
case 'backingUp':
case 'backing-up':
case 'restarting':
case 'removing':
return true
@@ -93,7 +93,7 @@ export class StatusComponent {
switch (this.getStatus(this.pkg).primary) {
case 'running':
return 'var(--tui-status-positive)'
case 'taskRequired':
case 'task-required':
return 'var(--tui-status-warning)'
case 'error':
return 'var(--tui-status-negative)'
@@ -101,7 +101,7 @@ export class StatusComponent {
case 'updating':
case 'stopping':
case 'starting':
case 'backingUp':
case 'backing-up':
case 'restarting':
case 'removing':
case 'restoring':

View File

@@ -11,6 +11,7 @@ import { tuiPure } from '@taiga-ui/cdk'
import { TuiDataList, TuiDropdown, TuiButton } from '@taiga-ui/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { InterfaceService } from '../../../components/interfaces/interface.service'
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({
selector: 'app-ui-launch',
@@ -70,7 +71,7 @@ export class UILaunchComponent {
}
get isRunning(): boolean {
return this.pkg.status.main === 'running'
return getInstalledPrimaryStatus(this.pkg) === 'running'
}
@tuiPure

View File

@@ -27,6 +27,7 @@ import { TaskInfoComponent } from 'src/app/routes/portal/modals/config-dep.compo
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { BaseStatus } from 'src/app/services/pkg-status-rendering.service'
import { getAllPackages, getManifest } from 'src/app/utils/get-package-data'
export type PackageActionData = {
@@ -34,7 +35,7 @@ export type PackageActionData = {
id: string
title: string
icon: string
mainStatus: T.MainStatus['main']
status: BaseStatus
}
actionInfo: {
id: string

View File

@@ -16,6 +16,10 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component'
import {
BaseStatus,
getInstalledBaseStatus,
} from 'src/app/services/pkg-status-rendering.service'
@Component({
template: `
@@ -27,7 +31,7 @@ import { ServiceActionComponent } from '../components/action.component'
<button
tuiCell
[action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)"
(click)="handle(pkg.status, pkg.icon, pkg.manifest, a)"
></button>
}
</section>
@@ -77,7 +81,7 @@ export default class ServiceActionsRoute {
? 'Other'
: 'General'
return {
mainStatus: pkg.status.main,
status: getInstalledBaseStatus(pkg.statusInfo),
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
@@ -131,13 +135,13 @@ export default class ServiceActionsRoute {
}
handle(
mainStatus: T.MainStatus['main'],
status: BaseStatus,
icon: string,
{ id, title }: T.Manifest,
action: T.ActionMetadata & { id: string },
) {
this.actions.present({
pkgInfo: { id, title, icon, mainStatus },
pkgInfo: { id, title, icon, status },
actionInfo: { id: action.id, metadata: action },
})
}

View File

@@ -22,6 +22,7 @@ import {
InterfaceService,
} from '../../../components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({
template: `
@@ -101,7 +102,8 @@ export default class ServiceInterfaceRoute {
readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId))
readonly isRunning = computed(() => {
return this.pkg()?.status.main === 'running'
const pkg = this.pkg()
return pkg ? getInstalledBaseStatus(pkg.statusInfo) === 'running' : false
})
readonly serviceInterface = computed(() => {

View File

@@ -25,7 +25,7 @@ const INACTIVE: PrimaryStatus[] = [
'updating',
'removing',
'restoring',
'backingUp',
'backing-up',
]
@Component({

View File

@@ -34,7 +34,7 @@ import { ServiceUptimeComponent } from '../components/uptime.component'
@Component({
template: `
@if (pkg(); as pkg) {
@if (pkg.status.main === 'error') {
@if (pkg.statusInfo.error) {
<service-error [pkg]="pkg" />
} @else if (installing()) {
<service-install-progress [pkg]="pkg" />
@@ -45,9 +45,9 @@ import { ServiceUptimeComponent } from '../components/uptime.component'
}
</service-status>
@if (status() !== 'backingUp') {
@if (status() !== 'backing-up') {
<service-health-checks [checks]="health()" />
<service-uptime class="g-card" [started]="$any(pkg.status).started" />
<service-uptime class="g-card" [started]="pkg.statusInfo.started" />
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
@@ -179,7 +179,7 @@ export class ServiceRoute {
protected readonly pkg = computed(() => this.services()[this.id() || ''])
protected readonly health = computed((pkg = this.pkg()) =>
pkg ? toHealthCheck(pkg.status) : [],
pkg ? toHealthCheck(pkg.statusInfo) : [],
)
protected readonly status = computed((pkg = this.pkg()) =>
@@ -202,8 +202,10 @@ export class ServiceRoute {
)
}
function toHealthCheck(status: T.MainStatus): T.NamedHealthCheckResult[] {
return status.main !== 'running' || isEmptyObject(status.health)
function toHealthCheck(statusInfo: T.StatusInfo): T.NamedHealthCheckResult[] {
return statusInfo.desired.main !== 'running' ||
!statusInfo.started ||
isEmptyObject(statusInfo.health)
? []
: Object.values(status.health).filter(h => !!h)
: Object.values(statusInfo.health).filter(h => !!h)
}

View File

@@ -28,7 +28,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
<tui-icon icon="@tui.check" class="g-positive" />
{{ 'complete' | i18n }}
} @else {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backing-up') {
<tui-loader size="s" />
{{ 'backing up' | i18n }}
} @else {
@@ -65,5 +65,5 @@ export class BackupProgressComponent {
)
readonly toStatus = (pkgId: string) =>
this.patch.watch$('packageData', pkgId, 'status', 'main')
this.patch.watch$('packageData', pkgId, 'statusInfo', 'desired', 'main')
}

View File

@@ -26,7 +26,7 @@ const allowedStatuses = {
'restoring',
'stopping',
'starting',
'backingUp',
'backing-up',
]),
}
@@ -45,9 +45,7 @@ export class ActionService {
const { pkgInfo, actionInfo } = data
if (
allowedStatuses[actionInfo.metadata.allowedStatuses].has(
pkgInfo.mainStatus,
)
allowedStatuses[actionInfo.metadata.allowedStatuses].has(pkgInfo.status)
) {
if (actionInfo.metadata.hasInput) {
this.formDialog.open<PackageActionData>(ActionInputModal, {

View File

@@ -1921,8 +1921,9 @@ export namespace Mock {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/bitcoin-core.svg',
lastBackup: null,
status: {
main: 'running',
statusInfo: {
error: null,
desired: { main: 'running' },
started: new Date().toISOString(),
health: {},
},
@@ -2201,8 +2202,11 @@ export namespace Mock {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/btc-rpc-proxy.png',
lastBackup: null,
status: {
main: 'stopped',
statusInfo: {
desired: { main: 'stopped' },
started: null,
health: {},
error: null,
},
actions: {},
serviceInterfaces: {
@@ -2246,8 +2250,11 @@ export namespace Mock {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/lnd.png',
lastBackup: null,
status: {
main: 'stopped',
statusInfo: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
actions: {
config: {

View File

@@ -762,12 +762,12 @@ export class MockApiService extends ApiService {
setTimeout(async () => {
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const appPath = `/packageData/${id}/status/main/`
const appPatch: ReplaceOperation<T.MainStatus['main']>[] = [
const appPath = `/packageData/${id}/statusInfo/desired/main`
const appPatch: ReplaceOperation<T.DesiredStatus['main']>[] = [
{
op: PatchOp.REPLACE,
path: appPath,
value: 'backingUp',
value: 'backing-up',
},
]
this.mockRevision(appPatch)
@@ -1073,17 +1073,18 @@ export class MockApiService extends ApiService {
}
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
const path = `/packageData/${params.id}/status`
const path = `/packageData/${params.id}/statusInfo`
await pauseFor(2000)
setTimeout(async () => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
const patch2: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
value: {
main: 'running',
error: null,
desired: { main: 'running' },
started: new Date().toISOString(),
health: {
'ephemeral-health-check': {
@@ -1118,14 +1119,14 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2)
}, 2000)
const originalPatch: ReplaceOperation<
T.MainStatus & { main: 'starting' }
>[] = [
const originalPatch: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
value: {
main: 'starting',
desired: { main: 'running' },
started: null,
error: null,
health: {},
},
},
@@ -1140,15 +1141,16 @@ export class MockApiService extends ApiService {
params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> {
await pauseFor(2000)
const path = `/packageData/${params.id}/status`
const path = `/packageData/${params.id}/statusInfo`
setTimeout(async () => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
const patch2: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
value: {
main: 'running',
desired: { main: 'running' },
error: null,
started: new Date().toISOString(),
health: {
'ephemeral-health-check': {
@@ -1183,12 +1185,15 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2)
}, this.revertTime)
const patch: ReplaceOperation<T.MainStatus & { main: 'restarting' }>[] = [
const patch: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
value: {
main: 'restarting',
desired: { main: 'restarting' },
started: null,
error: null,
health: {},
},
},
]
@@ -1200,24 +1205,34 @@ export class MockApiService extends ApiService {
async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
await pauseFor(2000)
const path = `/packageData/${params.id}/status`
const path = `/packageData/${params.id}/statusInfo`
setTimeout(() => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'stopped' }>[] = [
const patch2: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path: path,
value: { main: 'stopped' },
value: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
},
]
this.mockRevision(patch2)
}, this.revertTime)
const patch: ReplaceOperation<T.MainStatus & { main: 'stopping' }>[] = [
const patch: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path: path,
value: { main: 'stopping' },
value: {
desired: { main: 'stopped' },
error: null,
health: {},
started: new Date().toISOString(),
},
},
]

View File

@@ -232,8 +232,11 @@ export const mockPatchData: DataModel = {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/bitcoin-core.svg',
lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(),
status: {
main: 'stopped',
statusInfo: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
// status: {
// main: 'error',
@@ -518,8 +521,11 @@ export const mockPatchData: DataModel = {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/lnd.png',
lastBackup: null,
status: {
main: 'stopped',
statusInfo: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
actions: {
config: {

View File

@@ -11,6 +11,7 @@ import deepEqual from 'fast-deep-equal'
import { Observable } from 'rxjs'
import { isInstalled } from 'src/app/utils/get-package-data'
import { T } from '@start9labs/start-sdk'
import { getInstalledBaseStatus } from './pkg-status-rendering.service'
export type AllDependencyErrors = Record<string, PkgDependencyErrors>
export type PkgDependencyErrors = Record<string, DependencyError | null>
@@ -153,7 +154,7 @@ export class DepErrorService {
}
}
const depStatus = dep.status.main
const depStatus = getInstalledBaseStatus(dep.statusInfo)
// not running
if (depStatus !== 'running' && depStatus !== 'starting') {
@@ -165,7 +166,7 @@ export class DepErrorService {
// health check failure
if (depStatus === 'running' && currentDep?.kind === 'running') {
for (let id of currentDep.healthChecks) {
const check = dep.status.health[id]
const check = dep.statusInfo.health[id]
if (check && check?.result !== 'success') {
return {
type: 'healthChecksFailed',

View File

@@ -13,7 +13,7 @@ export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
if (pkg.stateInfo.state === 'installed') {
primary = getInstalledPrimaryStatus(pkg)
health = getHealthStatus(pkg.status)
health = getHealthStatus(pkg.statusInfo)
} else {
primary = pkg.stateInfo.state
}
@@ -21,33 +21,43 @@ export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
return { primary, health }
}
export function getInstalledPrimaryStatus({
tasks,
status,
}: T.PackageDataEntry): PrimaryStatus {
export function getInstalledBaseStatus(statusInfo: T.StatusInfo): BaseStatus {
if (
Object.values(tasks).some(t => t.active && t.task.severity === 'critical')
) {
return 'taskRequired'
}
if (
Object.values(status.main === 'running' && status.health)
.filter(h => !!h)
.some(h => h.result === 'starting')
statusInfo.desired.main === 'running' &&
(!statusInfo.started ||
Object.values(statusInfo.health)
.filter(h => !!h)
.some(h => h.result === 'starting'))
) {
return 'starting'
}
return status.main
if (statusInfo.desired.main === 'stopped' && statusInfo.started) {
return 'stopping'
}
return statusInfo.desired.main
}
function getHealthStatus(status: T.MainStatus): T.HealthStatus | null {
if (status.main !== 'running' || !status.main) {
export function getInstalledPrimaryStatus({
tasks,
statusInfo,
}: T.PackageDataEntry): PrimaryStatus {
if (
Object.values(tasks).some(t => t.active && t.task.severity === 'critical')
) {
return 'task-required'
}
return getInstalledBaseStatus(statusInfo)
}
function getHealthStatus(statusInfo: T.StatusInfo): T.HealthStatus | null {
if (statusInfo.desired.main !== 'running') {
return null
}
const values = Object.values(status.health).filter(h => !!h)
const values = Object.values(statusInfo.health).filter(h => !!h)
if (values.some(h => h.result === 'failure')) {
return 'failure'
@@ -70,7 +80,7 @@ export interface StatusRendering {
showDots?: boolean
}
export type PrimaryStatus =
export type BaseStatus =
| 'installing'
| 'updating'
| 'removing'
@@ -80,10 +90,11 @@ export type PrimaryStatus =
| 'stopping'
| 'restarting'
| 'stopped'
| 'backingUp'
| 'taskRequired'
| 'backing-up'
| 'error'
export type PrimaryStatus = BaseStatus | 'task-required'
export type DependencyStatus = 'warning' | 'satisfied'
export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
@@ -122,7 +133,7 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
color: 'dark-shade',
showDots: false,
},
backingUp: {
'backing-up': {
display: 'Backing Up',
color: 'primary',
showDots: true,
@@ -137,7 +148,7 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
color: 'success',
showDots: false,
},
taskRequired: {
'task-required': {
display: 'Task Required',
color: 'warning',
showDots: false,