Files
vdi-cloud/console/src/views/Monitor.vue
T

83 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { api, errorMessage, type AlertRule, type AlertRuleInput, type AlertRecord, type ResourcePoint } from '../api'
import { usePlatform } from '../stores/platform'
import { useAuth } from '../stores/auth'
import { useAction } from '../composables/useAction'
import ResourceTrend from '../components/ResourceTrend.vue'
const data = usePlatform(), auth = useAuth(), { run, busy: saving } = useAction()
const metricLabels: Record<string, string> = { node_cpu: '节点 CPU', node_memory: '节点内存', node_storage: '节点存储', node_unavailable: '节点不可用 / 采集异常', terminal_offline: '终端离线', terminal_cpu: '终端 CPU', terminal_memory: '终端内存', terminal_temperature: '终端 SoC 温度' }
const rules = ref<AlertRule[]>([]), alerts = ref<AlertRecord[]>([]), points = ref<ResourcePoint[]>([])
const resourceId = ref(''), hours = ref(24), bucket = ref(300), from = ref(Date.now() - 86400000), to = ref(Date.now())
const status = ref(''), keyword = ref(''), page = ref(1), total = ref(0), firing = ref(0), unacknowledged = ref(0), loading = ref(false), error = ref(''), chartError = ref('')
const nodes = computed(() => data.resources.filter(r => r.provider === 'proxmox'))
const tab = ref('alerts'), dialog = ref(false), editId = ref('')
const form = reactive<AlertRuleInput>({ name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true })
const offline = computed(() => ['node_unavailable', 'terminal_offline'].includes(form.metric))
const temperature = computed(() => form.metric === 'terminal_temperature')
const terminalMetric = computed(() => ['terminal_cpu', 'terminal_memory', 'terminal_temperature'].includes(form.metric))
const unit = (metric: string) => ['node_unavailable', 'terminal_offline'].includes(metric) ? '秒' : metric === 'terminal_temperature' ? '°C' : '%'
const stamp = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '—'
const reasons: Record<string, string> = { condition_cleared: '指标恢复', rule_changed: '规则变更', target_unavailable: '目标移除或停用' }
async function chart() {
chartError.value = ''
if (!resourceId.value) { points.value = []; return }
const selected = resourceId.value, selectedHours = hours.value, end = Date.now(), start = end - selectedHours * 3600000
try {
const result = await api.resourceSeries(selected, new Date(start).toISOString(), new Date(end).toISOString())
if (resourceId.value !== selected || hours.value !== selectedHours) return
points.value = result.items; bucket.value = result.bucketSeconds; from.value = start; to.value = end
} catch (e) { chartError.value = errorMessage(e); points.value = [] }
}
async function load(reset = false) {
if (loading.value) return
if (reset) page.value = 1
loading.value = true; error.value = ''
try {
const [r, a] = await Promise.all([api.alertRules(), api.alerts(page.value, status.value, keyword.value)])
rules.value = r.items; alerts.value = a.items; total.value = a.total; firing.value = a.firingCount; unacknowledged.value = a.unacknowledgedCount
if (!resourceId.value) resourceId.value = nodes.value[0]?.id || ''
await chart()
} catch (e) { error.value = errorMessage(e) } finally { loading.value = false }
}
function edit(rule?: AlertRule) {
editId.value = rule?.id || ''
Object.assign(form, rule ? { name: rule.name, metric: rule.metric, threshold: rule.threshold, holdSeconds: rule.holdSeconds, enabled: rule.enabled } : { name: '', metric: 'node_cpu', threshold: 90, holdSeconds: 120, enabled: true })
dialog.value = true
}
function metricChanged() { form.threshold = offline.value ? 180 : temperature.value ? 80 : 90; form.holdSeconds = offline.value ? 0 : 120 }
async function save() {
if (!form.name.trim()) return
const ok = await run(() => api.saveAlertRule(editId.value, { ...form, name: form.name.trim() }), '告警规则已保存')
if (ok) { dialog.value = false; await load() }
}
async function acknowledge(row: AlertRecord) { if (await run(() => api.acknowledgeAlert(row.id), '已确认,恢复状态仍由指标判断')) await load() }
let timer: ReturnType<typeof setInterval>
onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !dialog.value) void load() }, 15000) })
onUnmounted(() => clearInterval(timer))
</script>
<template>
<div class="page-heading"><div><h1>监控告警</h1><p class="subtext">节点与终端指标告警 · 每 15 秒刷新</p></div><el-button :loading="loading" @click="load()">刷新监控</el-button></div>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="notice" />
<div class="monitor-summary"><article class="panel"><span>正在告警</span><strong :class="firing ? 'tone-danger' : 'tone-ok'">{{ firing }}</strong></article><article class="panel"><span>等待确认</span><strong>{{ unacknowledged }}</strong></article><article class="panel"><span>启用规则</span><strong>{{ rules.filter(r => r.enabled).length }}</strong></article></div>
<section class="panel">
<div class="panel-heading"><div><h2>节点资源趋势</h2><p class="subtext">采样保留 30 天,图表单次最多查看 7 天;空缺表示无有效数据。</p></div><div class="chart-filters"><el-select v-model="resourceId" aria-label="监控节点" placeholder="暂无节点" @change="chart"><el-option v-for="r in nodes" :key="r.id" :label="r.name" :value="r.id" /></el-select><el-select v-model="hours" aria-label="监控时间范围" @change="chart"><el-option :value="1" label="最近 1 小时" /><el-option :value="6" label="最近 6 小时" /><el-option :value="24" label="最近 24 小时" /><el-option :value="168" label="最近 7 天" /></el-select></div></div>
<el-alert v-if="chartError" type="error" :title="chartError" :closable="false" class="notice" /><div class="trend-grid"><ResourceTrend v-for="m in ([{ key: 'cpu', label: 'CPU 使用率' }, { key: 'memory', label: '内存使用率' }, { key: 'storage', label: '存储使用率' }] as const)" :key="m.key" :points="points" :metric="m.key" :title="m.label" :bucket="bucket" :from="from" :to="to" /></div>
</section>
<section class="panel">
<el-tabs v-model="tab"><el-tab-pane label="告警事件" name="alerts" /><el-tab-pane label="告警规则" name="rules" /></el-tabs>
<template v-if="tab === 'alerts'">
<div class="alert-filters"><el-input v-model="keyword" aria-label="搜索告警" placeholder="规则或目标名称" clearable @keyup.enter="load(true)" /><el-select v-model="status" aria-label="告警状态"><el-option value="" label="全部状态" /><el-option value="firing" label="正在告警" /><el-option value="resolved" label="已恢复" /></el-select><el-button :loading="loading" @click="load(true)">查询告警</el-button></div>
<el-table :data="alerts" empty-text="暂无告警事件"><el-table-column label="规则 / 目标" min-width="190"><template #default="{ row }"><strong>{{ row.ruleName }}</strong><div class="subtext">{{ row.targetName }}</div></template></el-table-column><el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag :type="row.status === 'firing' ? 'danger' : 'success'">{{ row.status === 'firing' ? '正在告警' : '已恢复' }}</el-tag></template></el-table-column><el-table-column label="观测值 / 阈值" min-width="160"><template #default="{ row }">{{ row.value.toFixed(1) }} / {{ row.threshold }} {{ unit(row.metric) }}<div v-if="row.status === 'firing' && row.dataFresh === false" class="data-unknown" role="status">数据已过期或缺失;显示最近告警值,等待有效采样</div><div v-if="row.metric === 'node_unavailable'" class="subtext">含节点离线状态检测</div></template></el-table-column><el-table-column label="发生 / 恢复" min-width="185"><template #default="{ row }"><div>{{ stamp(row.openedAt) }}</div><div class="subtext">{{ row.resolvedAt ? stamp(row.resolvedAt) : '持续中' }}</div><div class="subtext">{{ reasons[row.resolveReason] }}</div></template></el-table-column><el-table-column label="确认" min-width="150"><template #default="{ row }"><span v-if="row.acknowledgedAt">{{ row.acknowledgedBy }}<div class="subtext">{{ stamp(row.acknowledgedAt) }}</div></span><el-button v-else-if="auth.canOperate && row.status === 'firing'" link type="primary" :disabled="saving" @click="acknowledge(row)">确认告警</el-button><span v-else class="secondary">未确认</span></template></el-table-column></el-table>
<el-pagination v-model:current-page="page" :page-size="20" :total="total" layout="prev, pager, next, total" @current-change="load()" /><p class="subtext retention-note">确认表示已知悉,指标恢复后自动结束告警。已恢复事件保留 90 天。</p>
</template>
<template v-else><div class="panel-heading"><p class="subtext">水位按持续时间触发;离线按秒数触发。修改规则后重新计时。</p><el-button v-if="auth.canAdmin" type="primary" @click="edit()">新增规则</el-button></div><el-table :data="rules" empty-text="暂无规则"><el-table-column prop="name" label="规则名称" min-width="190" /><el-table-column label="指标" min-width="190"><template #default="{ row }">{{ metricLabels[row.metric] }}</template></el-table-column><el-table-column label="阈值 / 持续" min-width="155"><template #default="{ row }">{{ row.threshold }}{{ ['node_unavailable', 'terminal_offline'].includes(row.metric) ? ' 秒离线' : `${unit(row.metric)} / ${row.holdSeconds} 秒` }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column><el-table-column v-if="auth.canAdmin" label="操作" width="90"><template #default="{ row }"><el-button link type="primary" @click="edit(row)">编辑规则</el-button></template></el-table-column></el-table></template>
</section>
<el-dialog v-model="dialog" :title="editId ? '编辑告警规则' : '新增告警规则'" width="540px" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving"><el-form label-position="top"><el-form-item label="规则名称"><el-input v-model="form.name" maxlength="80" aria-label="规则名称" :disabled="saving" /></el-form-item><el-form-item label="监控指标"><el-select v-model="form.metric" aria-label="规则监控指标" :disabled="saving" @change="metricChanged"><el-option v-for="(label, value) in metricLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item><el-form-item :label="offline ? '离线阈值(秒)' : temperature ? 'SoC 温度阈值(°C)' : '使用率阈值(%)'"><el-input-number v-model="form.threshold" :min="offline ? 90 : 1" :max="offline ? 86400 : temperature ? 150 : 100" :precision="offline ? 0 : 1" aria-label="告警阈值" :disabled="saving" /></el-form-item><el-form-item v-if="!offline" label="持续时间(秒)"><el-input-number v-model="form.holdSeconds" :min="0" :max="3600" :precision="0" aria-label="告警持续时间" :disabled="saving" /></el-form-item><el-form-item label="启用规则"><el-switch v-model="form.enabled" aria-label="启用告警规则" :disabled="saving" /></el-form-item></el-form><p v-if="terminalMetric" class="subtext terminal-alert-note">对已激活且启用的终端生效;SoC 温度只使用 soc-thermal 传感器。持续时间按新采样累计,重复读取不推进;采样超过 90 秒或缺失会重新计时,已有告警等待有效正常采样才恢复。此规则只在控制台告警,不发送外部通知。</p><el-alert v-if="editId" title="保存会结束本规则当前告警,并按新规则重新计时。" type="info" :closable="false" /><template #footer><el-button :disabled="saving" @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="!form.name.trim()" @click="save">保存规则</el-button></template></el-dialog>
</template>
<style scoped>
.monitor-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.monitor-summary .panel{display:flex;justify-content:space-between;align-items:center;gap:12px}.monitor-summary span{font-size:12px;color:var(--vdi-muted)}.monitor-summary strong{font-size:28px}.trend-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:20px}.chart-filters,.alert-filters{display:flex;gap:12px;flex-wrap:wrap}.chart-filters .el-select{width:160px}.alert-filters{margin:18px 0}.alert-filters .el-input{width:230px}.alert-filters .el-select{width:150px}.el-pagination{margin-top:20px;flex-wrap:wrap}.retention-note{margin-top:15px}.subtext{line-height:1.7}.data-unknown{color:var(--el-color-warning);font-size:12px;line-height:1.7;margin-top:6px}.terminal-alert-note{margin:14px 0;font-size:12px}
@media(max-width:1200px){.trend-grid{grid-template-columns:1fr}.panel-heading{flex-wrap:wrap;gap:16px}}
@media(max-width:600px){.monitor-summary{gap:8px}.monitor-summary .panel{padding:14px;flex-direction:column;align-items:flex-start}.monitor-summary strong{font-size:24px}.chart-filters,.alert-filters{width:100%}.chart-filters .el-select,.alert-filters .el-select{flex:1;min-width:120px}.alert-filters .el-input{width:100%}}
</style>