archy/neode-ui/src/components/ContainerStatus.vue
archipelago a8158b1ef5 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.
2026-04-23 05:20:15 -04:00

158 lines
3.4 KiB
Vue

<template>
<div class="flex items-center gap-2">
<!-- Status Indicator -->
<div class="relative">
<div
class="w-3 h-3 rounded-full transition-colors"
:class="statusClass"
></div>
<div
v-if="isRunning"
class="absolute inset-0 w-3 h-3 rounded-full animate-ping opacity-75"
:class="statusClass"
></div>
</div>
<!-- Status Text -->
<span class="text-sm font-medium" :class="textClass">
{{ statusText }}
</span>
<!-- Health Badge (if running) -->
<span
v-if="isRunning && health"
class="px-2 py-0.5 rounded text-xs font-medium"
:class="healthClass"
>
{{ healthText }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
state:
| 'created'
| 'running'
| 'stopped'
| 'exited'
| 'paused'
| 'unknown'
| 'stopping'
| 'starting'
| 'restarting'
| 'installing'
| 'updating'
| 'removing'
| 'installed'
health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
}
const props = withDefaults(defineProps<Props>(), {
health: 'unknown',
})
const isRunning = computed(() => props.state === 'running')
const statusClass = computed(() => {
switch (props.state) {
case 'running':
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'
}
})
const textClass = computed(() => {
switch (props.state) {
case 'running':
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'
}
})
const statusText = computed(() => {
switch (props.state) {
case 'running':
return 'Running'
case 'stopped':
return 'Stopped'
case 'exited':
return 'Exited'
case 'paused':
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'
}
})
const healthClass = computed(() => {
switch (props.health) {
case 'healthy':
return 'bg-green-500/20 text-green-400 border border-green-500/30'
case 'unhealthy':
return 'bg-red-500/20 text-red-400 border border-red-500/30'
case 'starting':
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
default:
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
}
})
const healthText = computed(() => {
switch (props.health) {
case 'healthy':
return 'Healthy'
case 'unhealthy':
return 'Unhealthy'
case 'starting':
return 'Starting'
default:
return 'Unknown'
}
})
</script>