功能:扩展云管资源运维、账号授权及监控管理
This commit is contained in:
1 parent
eef485306f
commit
6134d49c16
87 files changed
+29498
-126
No files matched your search
@@ -0,0 +1,79 @@
|
||||
<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: '终端离线' }
|
||||
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 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 : 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 }} {{ row.metric.endsWith('offline') || row.metric === 'node_unavailable' ? '秒' : '%' }}<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) ? ' 秒离线' : `% / ${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 ? '离线阈值(秒)' : '使用率阈值(%)'"><el-input-number v-model="form.threshold" :min="offline ? 90 : 1" :max="offline ? 86400 : 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><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}
|
||||
@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>
|
||||
Reference in new issue
Block a user