Skip to content

Commit 2bbed6d

Browse files
committed
feat(dashboard): add lifecycle status display and action guards
- Add status column with color-coded chips: creating(amber), running(success), error(error), stopping(warning), stopped/stopped(grey) - Add i18n labels for all new lifecycle states in en-US and zh-CN - Disable action buttons (console, screenshot, config, set-default) when sandbox status is not 'running' to prevent errors on creating/stopping sandboxes - Update table headers to show status instead of lease chip
1 parent 21e1e34 commit 2bbed6d

4 files changed

Lines changed: 116 additions & 31 deletions

File tree

dashboard/src/i18n/locales/en-US/features/sandbox.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@
3131
"unknown": "Unknown",
3232
"none": "None",
3333
"temporary": "Temporary",
34-
"persistent": "Persistent"
34+
"persistent": "Persistent",
35+
"creating": "Creating",
36+
"running": "Running",
37+
"error": "Error",
38+
"stopping": "Stopping",
39+
"stopped": "Stopped"
3540
},
3641
"headers": {
3742
"sandbox": "Sandbox",
3843
"provider": "Provider",
39-
"lease": "Status",
44+
"status": "Status",
4045
"lastUsed": "Last used",
4146
"actions": "Actions"
4247
},

dashboard/src/i18n/locales/ru-RU/features/sandbox.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@
3131
"unknown": "Unknown",
3232
"none": "None",
3333
"temporary": "Temporary",
34-
"persistent": "Persistent"
34+
"persistent": "Persistent",
35+
"creating": "Creating",
36+
"running": "Running",
37+
"error": "Error",
38+
"stopping": "Stopping",
39+
"stopped": "Stopped"
3540
},
3641
"headers": {
3742
"sandbox": "Sandbox",
3843
"provider": "Provider",
39-
"lease": "Status",
44+
"status": "Status",
4045
"lastUsed": "Last used",
4146
"actions": "Actions"
4247
},

dashboard/src/i18n/locales/zh-CN/features/sandbox.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,17 @@
3131
"unknown": "未知",
3232
"none": "",
3333
"temporary": "临时",
34-
"persistent": "持久"
34+
"persistent": "持久",
35+
"creating": "创建中",
36+
"running": "运行中",
37+
"error": "异常",
38+
"stopping": "销毁中",
39+
"stopped": "已停止"
3540
},
3641
"headers": {
3742
"sandbox": "沙盒",
3843
"provider": "Provider",
39-
"lease": "状态",
44+
"status": "状态",
4045
"lastUsed": "最后使用",
4146
"actions": "操作"
4247
},

dashboard/src/views/SandboxManagementPage.vue

Lines changed: 95 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,13 @@
6161
</div>
6262
</template>
6363

64-
<template #item.lease="{ item }">
64+
<template #item.status="{ item }">
6565
<div class="py-2">
66-
<v-chip size="small" :color="item.controller_session_id ? 'warning' : 'success'" variant="tonal">
67-
{{ item.controller_session_id ? tm('labels.busy') : tm('labels.available') }}
66+
<v-chip size="small" :color="statusColor(item.status)" variant="tonal">
67+
{{ statusLabel(item.status) }}
6868
</v-chip>
69-
<div class="text-caption text-medium-emphasis mt-1">
70-
{{ item.controller_session_id || tm('labels.noController') }}
69+
<div v-if="item.controller_session_id" class="text-caption text-medium-emphasis mt-1">
70+
{{ item.controller_session_id }}
7171
</div>
7272
</div>
7373
</template>
@@ -79,14 +79,14 @@
7979
<template #item.actions="{ item }">
8080
<div class="sandbox-actions-cell">
8181
<v-btn size="small" variant="tonal" @click="openDetails(item)">{{ tm('actions.inspect') }}</v-btn>
82-
<v-btn size="small" color="amber" variant="tonal" :disabled="item.is_default" @click="setDefaultSandbox(item)">{{ tm('actions.setDefault') }}</v-btn>
83-
<v-btn size="small" variant="tonal" @click="openConfig(item)">{{ tm('actions.configure') }}</v-btn>
84-
<v-btn size="small" color="primary" variant="tonal" :disabled="!hasCapability(item, 'shell')" @click="openConsole(item)">
82+
<v-btn size="small" color="amber" variant="tonal" :disabled="item.is_default || !isOperational(item)" @click="setDefaultSandbox(item)">{{ tm('actions.setDefault') }}</v-btn>
83+
<v-btn size="small" variant="tonal" :disabled="!isOperational(item)" @click="openConfig(item)">{{ tm('actions.configure') }}</v-btn>
84+
<v-btn size="small" color="primary" variant="tonal" :disabled="!isOperational(item) || !hasCapability(item, 'shell')" @click="openConsole(item)">
8585
{{ tm('actions.console') }}
8686
<v-tooltip activator="parent" location="top">{{ tm('tooltips.console') }}</v-tooltip>
8787
</v-btn>
8888
<v-btn size="small" variant="tonal" :disabled="!canReleaseFromDashboard(item)" @click="releaseSandbox(item)">{{ tm('actions.release') }}</v-btn>
89-
<v-btn size="small" variant="tonal" :disabled="!hasCapability(item, 'screenshot')" @click="screenshotSandbox(item)">{{ tm('actions.screenshot') }}</v-btn>
89+
<v-btn size="small" variant="tonal" :disabled="!isOperational(item) || !hasCapability(item, 'screenshot')" @click="screenshotSandbox(item)">{{ tm('actions.screenshot') }}</v-btn>
9090
<v-btn size="small" color="error" variant="tonal" @click="openDestroyConfirm(item)">{{ tm('actions.destroy') }}</v-btn>
9191
</div>
9292
</template>
@@ -285,7 +285,7 @@
285285

286286
<script setup lang="ts">
287287
import axios, { type AxiosRequestConfig } from 'axios'
288-
import { computed, nextTick, onMounted, ref } from 'vue'
288+
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
289289
import { useModuleI18n } from '@/i18n/composables'
290290
291291
type SandboxRecord = {
@@ -335,6 +335,7 @@ const configExpiresAt = ref('')
335335
const configSandboxName = ref('')
336336
const createName = ref('')
337337
const createProvider = ref('cua')
338+
const createPollingTimer = ref<ReturnType<typeof setTimeout> | null>(null)
338339
const screenshotDialog = ref(false)
339340
const screenshotDataUrl = ref('')
340341
const destroyDialog = ref(false)
@@ -362,7 +363,7 @@ const providerOptions = [
362363
const headers = computed(() => [
363364
{ title: tm('headers.sandbox'), key: 'identity', sortable: false, width: '22%' },
364365
{ title: tm('headers.provider'), key: 'provider', sortable: false, width: '14%' },
365-
{ title: tm('headers.lease'), key: 'lease', sortable: false, width: '12%' },
366+
{ title: tm('headers.status'), key: 'status', sortable: false, width: '12%' },
366367
{ title: tm('headers.lastUsed'), key: 'last_used', sortable: false, width: '18%' },
367368
{ title: tm('headers.actions'), key: 'actions', sortable: false, align: 'end' as const, width: 520 }
368369
])
@@ -392,6 +393,35 @@ function canReleaseFromDashboard(item: SandboxRecord) {
392393
return !!item.controller_session_id
393394
}
394395
396+
function statusLabel(status?: string | null) {
397+
const key = status || 'unknown'
398+
const labels: Record<string, string> = {
399+
creating: tm('labels.creating'),
400+
running: tm('labels.running'),
401+
error: tm('labels.error'),
402+
stopping: tm('labels.stopping'),
403+
stopped: tm('labels.stopped'),
404+
unknown: tm('labels.unknown'),
405+
}
406+
return labels[key] || key
407+
}
408+
409+
function statusColor(status?: string | null) {
410+
const colors: Record<string, string> = {
411+
creating: 'amber',
412+
running: 'success',
413+
error: 'error',
414+
stopping: 'warning',
415+
stopped: 'grey',
416+
unknown: 'grey',
417+
}
418+
return colors[status || 'unknown'] || 'grey'
419+
}
420+
421+
function isOperational(item: SandboxRecord) {
422+
return item.status === 'running'
423+
}
424+
395425
function formatTime(value?: number | null) {
396426
if (!value) return '-'
397427
return new Date(value * 1000).toLocaleString()
@@ -479,21 +509,55 @@ async function sandboxAction(
479509
async function createSandbox() {
480510
creating.value = true
481511
try {
482-
const data = await sandboxAction(
483-
'post',
484-
'/api/sandbox',
485-
{
486-
provider_id: createProvider.value,
487-
sandbox_name: createName.value || undefined
488-
},
489-
tm('messages.created'),
490-
{ params: { session_id: 'dashboard' } }
491-
)
492-
if (data) {
493-
createDialog.value = false
494-
createName.value = ''
512+
const res = await axios.post('/api/sandbox', {
513+
provider_id: createProvider.value,
514+
sandbox_name: createName.value || undefined
515+
}, { params: { session_id: 'dashboard' } })
516+
517+
if (res.data.status !== 'ok') {
518+
toast(res.data.message || tm('messages.operationFailed'), 'error')
519+
return
495520
}
496-
} finally {
521+
522+
const created = res.data.data?.sandbox as SandboxRecord | undefined
523+
if (!created?.sandbox_id) {
524+
toast(tm('messages.operationFailed'), 'error')
525+
return
526+
}
527+
528+
toast(tm('messages.created'))
529+
createDialog.value = false
530+
createName.value = ''
531+
532+
// Poll until the sandbox leaves the "creating" state.
533+
const sandboxId = created.sandbox_id
534+
let attempts = 0
535+
const maxAttempts = 15 // 30 seconds total
536+
537+
const poll = async () => {
538+
attempts++
539+
await loadSandboxes()
540+
const record = sandboxes.value.find((s) => s.sandbox_id === sandboxId)
541+
if (!record) {
542+
// Sandbox was removed (e.g. boot failed and cleaned up)
543+
creating.value = false
544+
return
545+
}
546+
if (record.status !== 'creating') {
547+
creating.value = false
548+
return
549+
}
550+
if (attempts >= maxAttempts) {
551+
creating.value = false
552+
toast(`Sandbox ${sandboxId} is still being created. Refresh to check status.`, 'warning')
553+
return
554+
}
555+
createPollingTimer.value = setTimeout(poll, 2000)
556+
}
557+
558+
await poll()
559+
} catch (e: any) {
560+
toast(e?.response?.data?.message || tm('messages.operationFailed'), 'error')
497561
creating.value = false
498562
}
499563
}
@@ -725,6 +789,12 @@ async function scrollConsoleToBottom() {
725789
}
726790
727791
onMounted(loadSandboxes)
792+
793+
onUnmounted(() => {
794+
if (createPollingTimer.value) {
795+
clearTimeout(createPollingTimer.value)
796+
}
797+
})
728798
</script>
729799

730800
<style scoped>

0 commit comments

Comments
 (0)