From a8158b1ef5e7f0ad9bbc940ee7521cc5d1a49d4b Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 23 Apr 2026 05:20:15 -0400 Subject: [PATCH] fix(ui): single-button lifecycle control with transitional labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app card and details view previously used a pair of Start/Stop buttons whose labels were driven off isAppLoading(), a client-side "I just clicked the button" flag. When the backend's graceful stop took longer than the RPC round-trip (up to 600s on bitcoin-core), the flag cleared while the container was still shutting down, the UI flipped back to "Running" as soon as the next 10s scan saw the still-alive container, and the user had no indication the stop was still in flight. Now that the backend flips PackageState to Stopping / Starting / Restarting / Installing / Updating / Removing for the duration of each lifecycle operation and the scan loop preserves those states, the UI can drive its label off the container state itself. A single full-width primary button replaces the Start/Stop pair. Its label, color, and disabled state come from getAppVisualState(), which collapses resting states (exited/created/paused/installed) into "stopped" and passes transitional states through untouched. Changes: - container-client.ts: widen ContainerStatus.state union to include the six transitional variants plus "installed". Add restartContainer() calling the new container-restart RPC. - stores/container.ts: add getAppVisualState() computed and the restartContainer() action. - ContainerApps.vue: single primary button (Start / Stop / Starting / Stopping / Restarting etc.) plus a separate circular Restart button visible only when running. Critically, handleStartApp and handleStopApp now route through store.startContainer and stopContainer (which call container-start / container-stop, the async RPCs) instead of the legacy synchronous bundled-app-start / bundled-app-stop path. Transitional-state polling widened from just "created" to the full set of transitional variants. - ContainerAppDetails.vue: same single-button pattern, Restart button now calls container-restart instead of the old stop-sleep-start sequence, added 2s polling interval for transitional states. - components/ContainerStatus.vue: widen state prop to match the shared union, render transitional labels with a trailing ellipsis and a yellow dot. No new tests — this is presentation logic. Manual verification on .228 will confirm the end-to-end async path: click Stop on LND, button becomes "Stopping" in under a second, stays that way for roughly 5 minutes, then flips to "Start" with a grey dot. The UI must never revert to "Running" mid-stop. --- neode-ui/src/api/container-client.ts | 25 ++- neode-ui/src/components/ContainerStatus.vue | 43 +++- neode-ui/src/stores/container.ts | 57 ++++++ neode-ui/src/views/ContainerAppDetails.vue | 130 ++++++++++-- neode-ui/src/views/ContainerApps.vue | 208 +++++++++++++------- 5 files changed, 367 insertions(+), 96 deletions(-) diff --git a/neode-ui/src/api/container-client.ts b/neode-ui/src/api/container-client.ts index 83547db0..82ba0af9 100644 --- a/neode-ui/src/api/container-client.ts +++ b/neode-ui/src/api/container-client.ts @@ -6,7 +6,20 @@ import { rpcClient } from './rpc-client' export interface ContainerStatus { id: string name: string - state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown' + state: + | 'created' + | 'running' + | 'stopped' + | 'exited' + | 'paused' + | 'unknown' + | 'stopping' + | 'starting' + | 'restarting' + | 'installing' + | 'updating' + | 'removing' + | 'installed' image: string created: string ports: string[] @@ -60,6 +73,16 @@ export const containerClient = { }) }, + /** + * Restart a container (async; returns immediately with restarting state) + */ + async restartContainer(appId: string): Promise { + return rpcClient.call({ + method: 'container-restart', + params: { app_id: appId }, + }) + }, + /** * Remove a container */ diff --git a/neode-ui/src/components/ContainerStatus.vue b/neode-ui/src/components/ContainerStatus.vue index 1ef698e2..ab921507 100644 --- a/neode-ui/src/components/ContainerStatus.vue +++ b/neode-ui/src/components/ContainerStatus.vue @@ -33,7 +33,20 @@ import { computed } from 'vue' interface Props { - state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown' + state: + | 'created' + | 'running' + | 'stopped' + | 'exited' + | 'paused' + | 'unknown' + | 'stopping' + | 'starting' + | 'restarting' + | 'installing' + | 'updating' + | 'removing' + | 'installed' health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting' } @@ -49,8 +62,15 @@ const statusClass = computed(() => { return 'bg-green-400' case 'stopped': case 'exited': + case 'installed': return 'bg-gray-400' case 'paused': + case 'starting': + case 'stopping': + case 'restarting': + case 'installing': + case 'updating': + case 'removing': return 'bg-yellow-400' default: return 'bg-red-400' @@ -63,8 +83,15 @@ const textClass = computed(() => { return 'text-green-400' case 'stopped': case 'exited': + case 'installed': return 'text-gray-400' case 'paused': + case 'starting': + case 'stopping': + case 'restarting': + case 'installing': + case 'updating': + case 'removing': return 'text-yellow-400' default: return 'text-red-400' @@ -83,6 +110,20 @@ const statusText = computed(() => { return 'Paused' case 'created': return 'Created' + case 'installed': + return 'Installed' + case 'starting': + return 'Starting…' + case 'stopping': + return 'Stopping…' + case 'restarting': + return 'Restarting…' + case 'installing': + return 'Installing…' + case 'updating': + return 'Updating…' + case 'removing': + return 'Removing…' default: return 'Unknown' } diff --git a/neode-ui/src/stores/container.ts b/neode-ui/src/stores/container.ts index 584f47a2..61f6f9be 100644 --- a/neode-ui/src/stores/container.ts +++ b/neode-ui/src/stores/container.ts @@ -141,6 +141,46 @@ export const useContainerStore = defineStore('container', () => { return container.state }) + // Get visual state for UI — collapses backend states into the small set the + // single-button component needs. Transitional states (stopping/starting/ + // restarting/installing/updating/removing) pass through; resting states + // (exited/created/paused/installed) collapse to 'stopped' or 'running' so + // the button logic can stay simple. + type VisualState = + | 'not-installed' + | 'running' + | 'stopped' + | 'stopping' + | 'starting' + | 'restarting' + | 'installing' + | 'updating' + | 'removing' + | 'unknown' + const getAppVisualState = computed(() => (appId: string): VisualState => { + const container = getContainerForApp.value(appId) + if (!container) return 'not-installed' + switch (container.state) { + case 'running': + return 'running' + case 'stopped': + case 'exited': + case 'created': + case 'paused': + case 'installed': + return 'stopped' + case 'stopping': + case 'starting': + case 'restarting': + case 'installing': + case 'updating': + case 'removing': + return container.state + default: + return 'unknown' + } + }) + // Get enriched bundled apps with runtime data (like lan_address) const enrichedBundledApps = computed(() => { return BUNDLED_APPS.map(app => { @@ -218,6 +258,21 @@ export const useContainerStore = defineStore('container', () => { } } + async function restartContainer(appId: string) { + loadingApps.value.add(appId) + error.value = null + try { + await containerClient.restartContainer(appId) + await fetchContainers() + await fetchHealthStatus() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to restart container' + throw e + } finally { + loadingApps.value.delete(appId) + } + } + // Start a bundled app (creates and starts container) async function startBundledApp(app: BundledApp) { loadingApps.value.add(app.id) @@ -296,6 +351,7 @@ export const useContainerStore = defineStore('container', () => { getContainerForApp, isAppLoading, getAppState, + getAppVisualState, enrichedBundledApps, // Actions fetchContainers, @@ -303,6 +359,7 @@ export const useContainerStore = defineStore('container', () => { installApp, startContainer, stopContainer, + restartContainer, removeContainer, getContainerLogs, getContainerStatus, diff --git a/neode-ui/src/views/ContainerAppDetails.vue b/neode-ui/src/views/ContainerAppDetails.vue index 0135f11a..906942b1 100644 --- a/neode-ui/src/views/ContainerAppDetails.vue +++ b/neode-ui/src/views/ContainerAppDetails.vue @@ -66,32 +66,30 @@

{{ t('containerDetails.actions') }}

+ -