feat(watch): 监测历史四槽同屏展示趋势

资源趋势 2×2 四图;探针记录默认全部槽位;表头内存/硬盘中文。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-12 05:44:28 +08:00
parent 7a0001d8f4
commit e42612e75f
3 changed files with 110 additions and 39 deletions
@@ -0,0 +1,18 @@
# 监测历史:4 槽同时展示趋势
**日期**: 2026-06-12
## 变更摘要
- 资源趋势 Tab 改为 **2×2 四槽同屏**,每槽独立曲线;共用时间范围与指标选择
- 探针记录 Tab 默认展示 **全部槽位** 记录(不再单选服务器)
- 探针表头 MEM/DISK 改为「内存」「硬盘」
## 涉及文件
- `frontend/src/pages/WatchMetricsPage.vue`
- `frontend/src/components/watch/WatchProbeRecordsTable.vue`
## 验证
`npm run type-check`;打开 `#/watch-metrics`,4 槽图表同屏;探针记录含多机数据。
@@ -37,8 +37,8 @@ const headers = [
{ title: '状态', key: 'probe_status', width: 100 },
{ title: '来源', key: 'source', width: 72 },
{ title: 'CPU', key: 'cpu_pct', width: 64 },
{ title: 'MEM', key: 'mem_pct', width: 64 },
{ title: 'DISK', key: 'disk_pct', width: 64 },
{ title: '内存', key: 'mem_pct', width: 64 },
{ title: '硬盘', key: 'disk_pct', width: 64 },
{ title: '↑', key: 'net_up_bps', width: 88 },
{ title: '↓', key: 'net_down_bps', width: 88 },
{ title: '耗时', key: 'duration_ms', width: 72 },
+90 -37
View File
@@ -8,11 +8,11 @@ import { useWatchPins } from '@/composables/useWatchPins'
defineOptions({ name: 'WatchMetricsPage' })
const WATCH_SLOT_COUNT = 4
const route = useRoute()
const tab = ref('trend')
const hours = ref(24)
const serverId = ref<number | null>(null)
const sessionId = ref<number | null>(null)
const points = ref<WatchTrendPoint[]>([])
const loading = ref(false)
const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bps' | 'net_down_bps'>>([
@@ -21,21 +21,47 @@ const selectedSeries = ref<Array<'cpu_pct' | 'mem_pct' | 'disk_pct' | 'net_up_bp
const { slots, refreshPins } = useWatchPins()
const serverOptions = computed(() =>
slots.value
.filter((s) => !s.empty && s.server_id)
.map((s) => ({ title: s.server_name || `ID ${s.server_id}`, value: s.server_id! })),
const slotPanels = computed(() =>
Array.from({ length: WATCH_SLOT_COUNT }, (_, slotIndex) => {
const slot = slots.value.find((s) => s.slot_index === slotIndex)
const empty = !slot || slot.empty
return {
slotIndex,
empty,
serverId: empty ? null : (slot.server_id ?? null),
serverName: empty ? '' : (slot.server_name || `ID ${slot.server_id}`),
}
}),
)
const pinnedServerIds = computed(() =>
slotPanels.value
.map((p) => p.serverId)
.filter((id): id is number => id != null),
)
const pointsByServer = computed(() => {
const map = new Map<number, WatchTrendPoint[]>()
for (const p of points.value) {
const sid = p.server_id
if (sid == null) continue
const list = map.get(sid) ?? []
list.push(p)
map.set(sid, list)
}
return map
})
async function loadTrend() {
if (!serverId.value) {
const ids = pinnedServerIds.value
if (!ids.length) {
points.value = []
return
}
loading.value = true
try {
const data = await http.get<{ points: WatchTrendPoint[] }>('/watch/metrics', {
server_ids: String(serverId.value),
server_ids: ids.join(','),
hours: hours.value,
})
points.value = data.points || []
@@ -46,22 +72,18 @@ async function loadTrend() {
onMounted(async () => {
await refreshPins()
const qSid = route.query.server_id
const qSess = route.query.session_id
if (qSid) serverId.value = Number(qSid)
if (qSess) sessionId.value = Number(qSess)
if (!serverId.value && serverOptions.value.length) {
serverId.value = serverOptions.value[0].value
}
const qTab = route.query.tab
if (qTab === 'records') tab.value = 'records'
})
watch([serverId, hours], loadTrend, { immediate: true })
watch([pinnedServerIds, hours], loadTrend, { immediate: true, deep: true })
</script>
<template>
<v-container fluid class="pa-4 pa-md-6">
<div class="d-flex align-center mb-4">
<h1 class="text-h6">监测历史</h1>
<v-chip size="x-small" variant="tonal" class="ml-2" label>4 槽同时展示</v-chip>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-arrow-left" :to="{ name: 'Servers' }">返回服务器</v-btn>
</div>
@@ -76,17 +98,6 @@ watch([serverId, hours], loadTrend, { immediate: true })
<v-window v-model="tab">
<v-window-item value="trend">
<div class="d-flex flex-wrap ga-2 mb-4">
<v-select
v-model="serverId"
:items="serverOptions"
item-title="title"
item-value="value"
label="服务器"
density="compact"
hide-details
variant="outlined"
style="min-width: 200px"
/>
<v-btn-toggle v-model="hours" mandatory density="compact" variant="outlined" divided>
<v-btn :value="1" size="small">1h</v-btn>
<v-btn :value="6" size="small">6h</v-btn>
@@ -104,7 +115,7 @@ watch([serverId, hours], loadTrend, { immediate: true })
]"
item-title="title"
item-value="value"
label="曲线"
label="曲线(各槽共用)"
multiple
chips
density="compact"
@@ -114,20 +125,62 @@ watch([serverId, hours], loadTrend, { immediate: true })
/>
</div>
<v-progress-linear v-if="loading" indeterminate class="mb-2" />
<WatchTrendChart v-if="points.length" :points="points" :series="selectedSeries" />
<div v-else class="text-center text-medium-emphasis py-8">
暂无趋势数据需先 Pin 监测并等待探针采样
</div>
<v-row dense>
<v-col
v-for="panel in slotPanels"
:key="panel.slotIndex"
cols="12"
md="6"
>
<v-card variant="outlined" rounded="lg" class="watch-metrics-slot pa-3">
<div class="d-flex align-center mb-2">
<v-chip size="x-small" variant="tonal" color="info" label>
{{ panel.slotIndex + 1 }}
</v-chip>
<span class="text-subtitle-2 text-truncate ml-2" :title="panel.serverName">
{{ panel.empty ? '未占用' : panel.serverName }}
</span>
</div>
<WatchTrendChart
v-if="!panel.empty && panel.serverId != null && (pointsByServer.get(panel.serverId)?.length ?? 0) > 0"
:points="pointsByServer.get(panel.serverId!) ?? []"
:series="selectedSeries"
:height="280"
/>
<div
v-else-if="panel.empty"
class="text-center text-medium-emphasis watch-metrics-slot__empty"
>
未添加监测
</div>
<div
v-else
class="text-center text-medium-emphasis watch-metrics-slot__empty"
>
暂无趋势数据需开启监测并等待探针采样
</div>
</v-card>
</v-col>
</v-row>
</v-window-item>
<v-window-item value="records">
<WatchProbeRecordsTable
:server-id="serverId"
:session-id="sessionId"
:hours="hours"
/>
<WatchProbeRecordsTable :hours="hours" />
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</v-container>
</template>
<style scoped>
.watch-metrics-slot {
min-height: 320px;
}
.watch-metrics-slot__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 1rem;
}
</style>