feat(container): add current container metrics view

Expose a dedicated container metrics endpoint and surface current CPU, memory, and root filesystem usage in the bot container view. This gives operators a quick health snapshot while degrading cleanly on unsupported backends.
This commit is contained in:
Acbox
2026-04-24 15:10:47 +08:00
parent 8136ef6ed6
commit e4aca0db13
20 changed files with 1198 additions and 6 deletions
+15
View File
@@ -869,6 +869,21 @@
"snapshotLoadFailed": "Failed to load snapshots",
"snapshotNamePlaceholder": "Snapshot display name (optional)",
"snapshotNameHint": "This field is only for the user-visible display name. The internal snapshot name is generated automatically.",
"metricsTitle": "Resource Status",
"metricsSubtitle": "View CPU, memory, and storage usage for the container's entire filesystem.",
"metricsLoadFailed": "Failed to load container resource status",
"metricsUnsupported": "The current container backend does not support resource monitoring.",
"metricsUnavailable": "No resource metrics available.",
"metricsStopped": "The container task is not running; CPU and memory metrics are unavailable. Storage information will still be shown if available.",
"metricsPath": "Scope",
"metricsUnlimited": "No memory limit configured",
"currentSample": "Current sample",
"sampledAt": "Sampled at",
"metricsLabels": {
"cpu": "CPU",
"memory": "Memory",
"storage": "Storage"
},
"dataTitle": "Data Operations",
"dataSubtitle": "Independently manage import, export, and restore for the container `/data` directory.",
"deleteTitle": "Delete Container",
+15
View File
@@ -865,6 +865,21 @@
"snapshotLoadFailed": "加载快照失败",
"snapshotNamePlaceholder": "快照显示名称(可选)",
"snapshotNameHint": "这里只填写用户可见的显示名称,系统会自动生成内部快照名。",
"metricsTitle": "资源状态",
"metricsSubtitle": "查看当前容器的 CPU、内存与整个容器文件系统的存储使用情况。",
"metricsLoadFailed": "加载容器资源状态失败",
"metricsUnsupported": "当前容器后端暂不支持资源监控。",
"metricsUnavailable": "当前暂无可用的资源指标。",
"metricsStopped": "容器任务未运行,CPU 和内存指标暂不可用;如有存储信息仍会继续显示。",
"metricsPath": "统计范围",
"metricsUnlimited": "未配置内存限制",
"currentSample": "当前采样",
"sampledAt": "采样时间",
"metricsLabels": {
"cpu": "CPU",
"memory": "内存",
"storage": "存储"
},
"dataTitle": "数据操作",
"dataSubtitle": "独立管理容器 `/data` 目录的导入、导出与恢复。",
"deleteTitle": "删除容器",
@@ -8,6 +8,7 @@ import { ChevronRight } from 'lucide-vue-next'
import {
deleteBotsByBotIdContainer,
getBotsByBotIdContainer,
getBotsByBotIdContainerMetrics,
getBotsByBotIdContainerSnapshots,
getBotsById,
postBotsByBotIdContainerDataExport,
@@ -18,6 +19,7 @@ import {
postBotsByBotIdContainerStart,
postBotsByBotIdContainerStop,
type HandlersCreateContainerRequest,
type HandlersGetContainerMetricsResponse,
type HandlersGetContainerResponse,
type HandlersListSnapshotsResponse,
} from '@memohai/sdk'
@@ -29,6 +31,7 @@ import {
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger, Input, Label, Separator, Spinner, Switch, Textarea } from '@memohai/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ContainerCreateProgress from './container-create-progress.vue'
import ContainerMetricsPanel from './container-metrics-panel.vue'
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
import { useBotStatusMeta } from '@/composables/useBotStatusMeta'
import { useCapabilitiesStore } from '@/store/capabilities'
@@ -92,11 +95,14 @@ const botId = computed(() => route.params.botId as string)
const containerBusy = computed(() => containerLoading.value || containerAction.value !== '')
type BotContainerInfo = HandlersGetContainerResponse
type BotContainerMetrics = HandlersGetContainerMetricsResponse
type BotContainerSnapshot = HandlersListSnapshotsResponse extends { snapshots?: (infer T)[] } ? T : never
const containerInfo = ref<BotContainerInfo | null>(null)
const containerMetrics = ref<BotContainerMetrics | null>(null)
const containerMissing = ref(false)
const snapshots = ref<BotContainerSnapshot[]>([])
const metricsLoading = ref(false)
const snapshotsLoading = ref(false)
function resolveErrorMessage(error: unknown, fallback: string): string {
@@ -134,6 +140,7 @@ async function loadContainerData(showLoadingToast: boolean) {
if (result.error !== undefined) {
if (result.response.status === 404) {
containerInfo.value = null
containerMetrics.value = null
containerMissing.value = true
snapshots.value = []
return
@@ -144,10 +151,13 @@ async function loadContainerData(showLoadingToast: boolean) {
containerInfo.value = result.data
containerMissing.value = false
const metricsPromise = loadContainerMetrics(showLoadingToast)
if (capabilitiesStore.snapshotSupported) {
await loadSnapshots()
await Promise.all([metricsPromise, loadSnapshots()])
} else {
snapshots.value = []
await metricsPromise
}
} catch (error) {
if (showLoadingToast) {
@@ -158,6 +168,24 @@ async function loadContainerData(showLoadingToast: boolean) {
}
}
async function loadContainerMetrics(showLoadingToast: boolean) {
metricsLoading.value = true
try {
const { data } = await getBotsByBotIdContainerMetrics({
path: { bot_id: botId.value },
throwOnError: true,
})
containerMetrics.value = data
} catch (error) {
containerMetrics.value = null
if (showLoadingToast) {
toast.error(resolveErrorMessage(error, t('bots.container.metricsLoadFailed')))
}
} finally {
metricsLoading.value = false
}
}
async function loadSnapshots() {
if (!containerInfo.value || !capabilitiesStore.snapshotSupported) {
snapshots.value = []
@@ -411,6 +439,7 @@ async function handleDeleteContainer(preserveData: boolean) {
throwOnError: true,
})
containerInfo.value = null
containerMetrics.value = null
containerMissing.value = true
snapshots.value = []
createRestoreData.value = preserveData
@@ -958,6 +987,12 @@ watch([activeTab, botId], ([tab]) => {
</dl>
</div>
<ContainerMetricsPanel
:backend="capabilitiesStore.containerBackend"
:loading="metricsLoading"
:metrics="containerMetrics"
/>
<div class="rounded-md border px-3 py-2 text-xs text-muted-foreground">
{{ $t('bots.container.gpuRecreateHint') }}
</div>
@@ -0,0 +1,159 @@
<template>
<div class="space-y-4 rounded-md border p-4">
<div class="space-y-1">
<h4 class="text-xs font-medium">
{{ t('bots.container.metricsTitle') }}
</h4>
<p class="text-xs text-muted-foreground">
{{ t('bots.container.metricsSubtitle') }}
</p>
</div>
<div
v-if="loading && !metrics"
class="flex items-center gap-2 text-xs text-muted-foreground"
>
<Spinner />
<span>{{ t('common.loading') }}</span>
</div>
<div
v-else-if="backendUnsupported"
class="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground"
>
{{ t('bots.container.metricsUnsupported') }}
</div>
<div
v-else-if="!hasAnyMetric"
class="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground"
>
{{ taskRunning === false ? t('bots.container.metricsStopped') : t('bots.container.metricsUnavailable') }}
</div>
<template v-else>
<div
v-if="taskRunning === false"
class="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs"
>
{{ t('bots.container.metricsStopped') }}
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-md border bg-background/70 p-3">
<p class="text-xs text-muted-foreground">
{{ t('bots.container.metricsLabels.cpu') }}
</p>
<p class="mt-2 text-2xl font-semibold">
{{ cpuValueText }}
</p>
<p class="mt-2 text-[11px] text-muted-foreground">
{{ t('bots.container.currentSample') }}
</p>
</div>
<div class="rounded-md border bg-background/70 p-3">
<p class="text-xs text-muted-foreground">
{{ t('bots.container.metricsLabels.memory') }}
</p>
<p class="mt-2 text-2xl font-semibold">
{{ memoryValueText }}
</p>
<p class="mt-2 text-[11px] text-muted-foreground">
{{ memoryHintText }}
</p>
</div>
<div class="rounded-md border bg-background/70 p-3">
<p class="text-xs text-muted-foreground">
{{ t('bots.container.metricsLabels.storage') }}
</p>
<p class="mt-2 text-2xl font-semibold">
{{ storageValueText }}
</p>
<p class="mt-2 text-[11px] text-muted-foreground break-all">
{{ t('bots.container.metricsPath') }}: {{ storagePathText }}
</p>
</div>
</div>
<p
v-if="sampledAtText !== '-'"
class="text-[11px] text-muted-foreground"
>
{{ t('bots.container.sampledAt') }}: {{ sampledAtText }}
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Spinner } from '@memohai/ui'
import type { HandlersGetContainerMetricsResponse } from '@memohai/sdk'
import { formatDateTime } from '@/utils/date-time'
const props = defineProps<{
backend: string
loading: boolean
metrics: HandlersGetContainerMetricsResponse | null
}>()
const { t } = useI18n()
const status = computed(() => props.metrics?.status)
const cpuMetrics = computed(() => props.metrics?.metrics?.cpu)
const memoryMetrics = computed(() => props.metrics?.metrics?.memory)
const storageMetrics = computed(() => props.metrics?.metrics?.storage)
const backendUnsupported = computed(() =>
props.backend !== 'containerd' || props.metrics?.supported === false,
)
const taskRunning = computed(() => status.value?.task_running)
const hasAnyMetric = computed(() =>
!!cpuMetrics.value || !!memoryMetrics.value || !!storageMetrics.value,
)
const cpuValueText = computed(() => formatPercent(cpuMetrics.value?.usage_percent))
const memoryValueText = computed(() => formatBytes(memoryMetrics.value?.usage_bytes))
const storageValueText = computed(() => formatBytes(storageMetrics.value?.used_bytes))
const storagePathText = computed(() => storageMetrics.value?.path || '-')
const sampledAtText = computed(() =>
formatDateTime(props.metrics?.sampled_at, { fallback: '-' }),
)
const memoryHintText = computed(() => {
const limit = memoryMetrics.value?.limit_bytes
if (limit && limit > 0) {
const usagePercent = formatPercent(memoryMetrics.value?.usage_percent)
return `${formatBytes(memoryMetrics.value?.usage_bytes)} / ${formatBytes(limit)}${usagePercent === '--' ? '' : ` (${usagePercent})`}`
}
if (memoryMetrics.value) {
return t('bots.container.metricsUnlimited')
}
return t('bots.container.metricsUnavailable')
})
function formatBytes(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) return '--'
if (value === 0) return '0 B'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
const fractionDigits = size >= 100 || unitIndex === 0 ? 0 : 1
return `${size.toFixed(fractionDigits)} ${units[unitIndex]}`
}
function formatPercent(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) return '--'
const fractionDigits = value >= 100 ? 0 : 1
return `${value.toFixed(fractionDigits)}%`
}
</script>