mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user