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.
359 lines
11 KiB
Vue
359 lines
11 KiB
Vue
<template>
|
||
<div class="p-6">
|
||
<div class="mb-6">
|
||
<button
|
||
@click="$router.back()"
|
||
class="mb-4 flex items-center gap-2 text-white/70 hover:text-white transition-colors"
|
||
>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
{{ t('containerDetails.back') }}
|
||
</button>
|
||
|
||
<div class="flex items-start justify-between">
|
||
<div>
|
||
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
|
||
<p class="text-white/70">{{ t('containerDetails.subtitle') }}</p>
|
||
</div>
|
||
<ContainerStatus
|
||
v-if="container"
|
||
:state="container.state as ContainerStateValue"
|
||
:health="healthStatus as HealthStatusValue"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Transition name="content-fade" mode="out-in">
|
||
<div v-if="loading" key="loading" class="flex items-center justify-center py-12">
|
||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||
</div>
|
||
|
||
<div v-else-if="error" key="error" class="glass-card p-6">
|
||
<div class="flex items-center gap-3 text-red-400">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span>{{ error }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="container" key="content" class="space-y-6">
|
||
<!-- Container Info Card -->
|
||
<div class="glass-card p-6">
|
||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.containerInfo') }}</h2>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<span class="text-sm text-white/60">{{ t('containerDetails.containerId') }}</span>
|
||
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm text-white/60">{{ t('containerDetails.image') }}</span>
|
||
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm text-white/60">{{ t('containerDetails.state') }}</span>
|
||
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
|
||
</div>
|
||
<div>
|
||
<span class="text-sm text-white/60">{{ t('containerDetails.created') }}</span>
|
||
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions Card -->
|
||
<div class="glass-card p-6">
|
||
<h2 class="text-xl font-semibold text-white mb-4">{{ t('containerDetails.actions') }}</h2>
|
||
<div class="flex gap-4">
|
||
<!-- Single primary button: Start when stopped, Stop when running,
|
||
transitional label + spinner while stopping/starting/restarting. -->
|
||
<button
|
||
:disabled="isPrimaryDisabled"
|
||
@click="handlePrimary"
|
||
:class="primaryButtonClass"
|
||
class="px-6 py-3 rounded-lg font-medium text-white transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center gap-2"
|
||
>
|
||
<svg v-if="isTransitional" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
<span>{{ primaryButtonLabel }}</span>
|
||
</button>
|
||
<button
|
||
@click="handleRestart"
|
||
:disabled="actionLoading || isTransitional || container.state !== 'running'"
|
||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||
>
|
||
{{ t('common.restart') }}
|
||
</button>
|
||
<button
|
||
@click="handleRemove"
|
||
:disabled="actionLoading || isTransitional"
|
||
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
|
||
>
|
||
{{ t('common.remove') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Logs Card -->
|
||
<div class="glass-card p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-xl font-semibold text-white">{{ t('containerDetails.logs') }}</h2>
|
||
<button
|
||
@click="refreshLogs"
|
||
:disabled="logsLoading"
|
||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||
>
|
||
{{ t('common.refresh') }}
|
||
</button>
|
||
</div>
|
||
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
|
||
<div v-if="logsLoading" class="text-center py-4 text-white/60">
|
||
{{ t('containerDetails.loadingLogs') }}
|
||
</div>
|
||
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
|
||
{{ t('containerDetails.noLogs') }}
|
||
</div>
|
||
<div v-else>
|
||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||
{{ log }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useContainerStore } from '@/stores/container'
|
||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||
|
||
type ContainerStateValue =
|
||
| 'created'
|
||
| 'running'
|
||
| 'stopped'
|
||
| 'exited'
|
||
| 'paused'
|
||
| 'unknown'
|
||
| 'stopping'
|
||
| 'starting'
|
||
| 'restarting'
|
||
| 'installing'
|
||
| 'updating'
|
||
| 'removing'
|
||
| 'installed'
|
||
type HealthStatusValue = 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const store = useContainerStore()
|
||
const { t } = useI18n()
|
||
|
||
const appId = computed(() => route.params.id as string)
|
||
const appName = computed(() => {
|
||
return appId.value
|
||
.split('-')
|
||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||
.join(' ')
|
||
})
|
||
|
||
const container = ref<ContainerStatusData | null>(null)
|
||
const logs = ref<string[]>([])
|
||
const loading = ref(false)
|
||
const logsLoading = ref(false)
|
||
const actionLoading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const healthStatus = ref<string>('unknown')
|
||
|
||
onMounted(async () => {
|
||
await loadContainer()
|
||
await loadLogs()
|
||
await loadHealthStatus()
|
||
})
|
||
|
||
async function loadContainer() {
|
||
loading.value = true
|
||
error.value = null
|
||
try {
|
||
const status = await store.getContainerStatus(appId.value)
|
||
container.value = status
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : t('common.error')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadLogs() {
|
||
logsLoading.value = true
|
||
try {
|
||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||
} catch (e) {
|
||
if (import.meta.env.DEV) console.error('Failed to load logs:', e)
|
||
} finally {
|
||
logsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadHealthStatus() {
|
||
await store.fetchHealthStatus()
|
||
healthStatus.value = store.getHealthStatus(appId.value)
|
||
}
|
||
|
||
async function refreshLogs() {
|
||
await loadLogs()
|
||
}
|
||
|
||
async function handleStart() {
|
||
actionLoading.value = true
|
||
try {
|
||
await store.startContainer(appId.value)
|
||
await loadContainer()
|
||
await loadHealthStatus()
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : t('common.error')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleStop() {
|
||
actionLoading.value = true
|
||
try {
|
||
await store.stopContainer(appId.value)
|
||
await loadContainer()
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : t('common.error')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleRestart() {
|
||
actionLoading.value = true
|
||
try {
|
||
// Use the async container-restart RPC (returns immediately, backend
|
||
// flips state to Restarting and spawns the stop+start sequence).
|
||
await store.restartContainer(appId.value)
|
||
await loadContainer()
|
||
await loadHealthStatus()
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : t('common.error')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
}
|
||
|
||
// Single-button state helpers mirroring ContainerApps.vue — driven off the
|
||
// backend state so transitional labels stay accurate across the 5–600s
|
||
// graceful-stop window.
|
||
const isTransitional = computed(() => {
|
||
const s = container.value?.state
|
||
return (
|
||
s === 'stopping' ||
|
||
s === 'starting' ||
|
||
s === 'restarting' ||
|
||
s === 'installing' ||
|
||
s === 'updating' ||
|
||
s === 'removing'
|
||
)
|
||
})
|
||
|
||
const isPrimaryDisabled = computed(() => actionLoading.value || isTransitional.value)
|
||
|
||
const primaryButtonLabel = computed(() => {
|
||
const s = container.value?.state
|
||
switch (s) {
|
||
case 'running':
|
||
return t('containerDetails.stopContainer')
|
||
case 'stopping':
|
||
return 'Stopping…'
|
||
case 'starting':
|
||
return 'Starting…'
|
||
case 'restarting':
|
||
return 'Restarting…'
|
||
case 'installing':
|
||
return 'Installing…'
|
||
case 'updating':
|
||
return 'Updating…'
|
||
case 'removing':
|
||
return 'Removing…'
|
||
default:
|
||
return t('containerDetails.startContainer')
|
||
}
|
||
})
|
||
|
||
const primaryButtonClass = computed(() => {
|
||
const s = container.value?.state
|
||
if (s === 'running') {
|
||
return 'glass-button hover:text-white text-white/90'
|
||
}
|
||
if (isTransitional.value) {
|
||
return 'bg-yellow-700/40 text-yellow-200'
|
||
}
|
||
return 'bg-green-600 hover:bg-green-500'
|
||
})
|
||
|
||
async function handlePrimary() {
|
||
const s = container.value?.state
|
||
if (s === 'running') {
|
||
return handleStop()
|
||
}
|
||
if (!isTransitional.value) {
|
||
return handleStart()
|
||
}
|
||
}
|
||
|
||
// Poll every 2s while a transition is in flight so the label updates
|
||
// without needing a manual refresh.
|
||
let transitionalPollInterval: ReturnType<typeof setInterval> | null = null
|
||
onMounted(() => {
|
||
transitionalPollInterval = setInterval(async () => {
|
||
if (isTransitional.value) {
|
||
try {
|
||
await loadContainer()
|
||
} catch {
|
||
// ignore transient poll errors
|
||
}
|
||
}
|
||
}, 2000)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (transitionalPollInterval) clearInterval(transitionalPollInterval)
|
||
})
|
||
|
||
async function handleRemove() {
|
||
if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) {
|
||
return
|
||
}
|
||
|
||
actionLoading.value = true
|
||
try {
|
||
await store.removeContainer(appId.value)
|
||
router.push('/dashboard/apps')
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : t('common.error')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
}
|
||
|
||
function formatDate(dateString: string): string {
|
||
try {
|
||
const date = new Date(dateString)
|
||
return date.toLocaleString()
|
||
} catch {
|
||
return dateString
|
||
}
|
||
}
|
||
</script>
|