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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user