|
61 | 61 | </div> |
62 | 62 | </template> |
63 | 63 |
|
64 | | - <template #item.lease="{ item }"> |
| 64 | + <template #item.status="{ item }"> |
65 | 65 | <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) }} |
68 | 68 | </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 }} |
71 | 71 | </div> |
72 | 72 | </div> |
73 | 73 | </template> |
|
79 | 79 | <template #item.actions="{ item }"> |
80 | 80 | <div class="sandbox-actions-cell"> |
81 | 81 | <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)"> |
85 | 85 | {{ tm('actions.console') }} |
86 | 86 | <v-tooltip activator="parent" location="top">{{ tm('tooltips.console') }}</v-tooltip> |
87 | 87 | </v-btn> |
88 | 88 | <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> |
90 | 90 | <v-btn size="small" color="error" variant="tonal" @click="openDestroyConfirm(item)">{{ tm('actions.destroy') }}</v-btn> |
91 | 91 | </div> |
92 | 92 | </template> |
|
285 | 285 |
|
286 | 286 | <script setup lang="ts"> |
287 | 287 | import axios, { type AxiosRequestConfig } from 'axios' |
288 | | -import { computed, nextTick, onMounted, ref } from 'vue' |
| 288 | +import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' |
289 | 289 | import { useModuleI18n } from '@/i18n/composables' |
290 | 290 |
|
291 | 291 | type SandboxRecord = { |
@@ -335,6 +335,7 @@ const configExpiresAt = ref('') |
335 | 335 | const configSandboxName = ref('') |
336 | 336 | const createName = ref('') |
337 | 337 | const createProvider = ref('cua') |
| 338 | +const createPollingTimer = ref<ReturnType<typeof setTimeout> | null>(null) |
338 | 339 | const screenshotDialog = ref(false) |
339 | 340 | const screenshotDataUrl = ref('') |
340 | 341 | const destroyDialog = ref(false) |
@@ -362,7 +363,7 @@ const providerOptions = [ |
362 | 363 | const headers = computed(() => [ |
363 | 364 | { title: tm('headers.sandbox'), key: 'identity', sortable: false, width: '22%' }, |
364 | 365 | { 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%' }, |
366 | 367 | { title: tm('headers.lastUsed'), key: 'last_used', sortable: false, width: '18%' }, |
367 | 368 | { title: tm('headers.actions'), key: 'actions', sortable: false, align: 'end' as const, width: 520 } |
368 | 369 | ]) |
@@ -392,6 +393,35 @@ function canReleaseFromDashboard(item: SandboxRecord) { |
392 | 393 | return !!item.controller_session_id |
393 | 394 | } |
394 | 395 |
|
| 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 | +
|
395 | 425 | function formatTime(value?: number | null) { |
396 | 426 | if (!value) return '-' |
397 | 427 | return new Date(value * 1000).toLocaleString() |
@@ -479,21 +509,55 @@ async function sandboxAction( |
479 | 509 | async function createSandbox() { |
480 | 510 | creating.value = true |
481 | 511 | 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 |
495 | 520 | } |
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') |
497 | 561 | creating.value = false |
498 | 562 | } |
499 | 563 | } |
@@ -725,6 +789,12 @@ async function scrollConsoleToBottom() { |
725 | 789 | } |
726 | 790 |
|
727 | 791 | onMounted(loadSandboxes) |
| 792 | +
|
| 793 | +onUnmounted(() => { |
| 794 | + if (createPollingTimer.value) { |
| 795 | + clearTimeout(createPollingTimer.value) |
| 796 | + } |
| 797 | +}) |
728 | 798 | </script> |
729 | 799 |
|
730 | 800 | <style scoped> |
|
0 commit comments