fix(ui): single-button lifecycle control with transitional labels

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.
This commit is contained in:
archipelago
2026-04-23 05:20:15 -04:00
parent cd69c3b2f6
commit a8158b1ef5
5 changed files with 367 additions and 96 deletions
+57
View File
@@ -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,