This commit is contained in:
zxr
2026-04-12 16:40:33 +08:00
parent 1dcab7af96
commit 003c552238
5 changed files with 1269 additions and 853 deletions

View File

@@ -13,6 +13,10 @@ export const fetchAlertRecords = (data: {
keyword?: string, keyword?: string,
sort?: string, sort?: string,
order?: string, order?: string,
binding_id?: number,
resource_category?: string,
server_identity?: string,
ip?: string,
}) => { }) => {
return request.get("/Alert/v1/record/list", { params: data }); return request.get("/Alert/v1/record/list", { params: data });
}; };

View File

@@ -25,7 +25,7 @@
<a-form-item label="Agent URL配置"> <a-form-item label="Agent URL配置">
<a-input <a-input
v-model="form.agent_config" v-model="form.agent_config"
placeholder="http://192.168.1.100:9100/dc-host/v1/control/command" placeholder="http://192.168.1.100:9100/dc-host/stats"
allow-clear allow-clear
/> />
<template #extra> <template #extra>

View File

@@ -16,16 +16,20 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="host" label="主机地址" required> <a-form-item
<a-input v-model="formData.host" placeholder="请输入主机地址" /> field="host"
label="主机地址(IPv4 / IPv6)"
required
>
<a-input v-model="formData.host" placeholder="域名或 IP如 server.example.com 或 192.168.1.10" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-row :gutter="20"> <a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="ip_address" label="IP地址"> <a-form-item field="ip_address" label="IP地址" required>
<a-input v-model="formData.ip_address" placeholder="请输入IP地址" /> <a-input v-model="formData.ip_address" placeholder="IPv4 或 IPv6如 192.168.1.10" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -92,7 +96,7 @@
</a-row> </a-row>
<a-form-item field="agent_config" label="Agent 配置 URL"> <a-form-item field="agent_config" label="Agent 配置 URL">
<a-input v-model="formData.agent_config" placeholder="http://192.168.1.100:9100/dc-host/v1/control/command" /> <a-input v-model="formData.agent_config" placeholder="http://192.168.1.100:9100/dc-host/stats" />
</a-form-item> </a-form-item>
<a-row :gutter="20"> <a-row :gutter="20">
@@ -182,9 +186,79 @@ const formData = reactive<ServerFormData>({
ip_scan_port: 12429, ip_scan_port: 12429,
}) })
/** 判断是否为合法 IPv4 字符串 */
function isIPv4String(host: string): boolean {
const h = host.trim()
if (!h) return false
const parts = h.split('.')
if (parts.length !== 4) return false
return parts.every((p) => {
if (!/^\d{1,3}$/.test(p)) return false
const n = Number(p)
return n >= 0 && n <= 255
})
}
/**
* 若主机地址为 IP返回规范化后的 IPIPv4 或 IPv6否则返回 null
*/
function extractIpFromHost(host: string): string | null {
const t = host.trim()
if (!t) return null
if (isIPv4String(t)) return t
if (!t.includes(':')) return null
try {
const withBrackets = t.startsWith('[') && t.endsWith(']') ? t : `[${t}]`
const u = new URL(`http://${withBrackets}/`)
if (u.hostname.includes(':')) return u.hostname
} catch {
return null
}
return null
}
/** 校验 IP 地址字段IPv4 或 IPv6 */
function isValidIpAddress(s: string): boolean {
const t = s.trim()
if (!t) return false
if (isIPv4String(t)) return true
try {
const withBrackets = t.startsWith('[') && t.endsWith(']') ? t : `[${t}]`
const u = new URL(`http://${withBrackets}/`)
return u.hostname.includes(':')
} catch {
return false
}
}
/** 主机为 IP 时同步到 IP 地址字段 */
function syncIpFromHost() {
const ip = extractIpFromHost(formData.host)
if (ip) {
formData.ip_address = ip
}
}
const rules = { const rules = {
name: [{ required: true, message: '请输入服务器名称' }], name: [{ required: true, message: '请输入服务器名称' }],
host: [{ required: true, message: '请输入主机地址' }], host: [{ required: true, message: '请输入主机地址' }],
ip_address: [
{ required: true, message: '请输入IP地址' },
{
validator: (value: string | undefined, cb: (error?: string) => void) => {
const v = (value || '').trim()
if (!v) {
cb()
return
}
if (!isValidIpAddress(v)) {
cb('请输入合法的 IPv4 或 IPv6 地址')
return
}
cb()
},
},
],
} }
function validateAgentConfigURL(raw?: string): string | null { function validateAgentConfigURL(raw?: string): string | null {
@@ -228,6 +302,7 @@ watch(
is_ip_scan_server: props.record.is_ip_scan_server ?? false, is_ip_scan_server: props.record.is_ip_scan_server ?? false,
ip_scan_port: props.record.ip_scan_port || 12429, ip_scan_port: props.record.ip_scan_port || 12429,
}) })
syncIpFromHost()
} else { } else {
Object.assign(formData, { Object.assign(formData, {
server_identity: '', server_identity: '',
@@ -255,6 +330,14 @@ watch(
} }
) )
watch(
() => formData.host,
() => {
if (!props.visible) return
syncIpFromHost()
},
)
const handleOk = async () => { const handleOk = async () => {
try { try {
await formRef.value?.validate() await formRef.value?.validate()

View File

@@ -460,19 +460,7 @@ const handleDelete = async (record: any) => {
}) })
} }
/** dc-host `/dc-host/v1/control/command` 拉取本机采集指标execute_task + service_collect */ /** dc-host `GET agent_config`(一般为 `/dc-host/stats`)返回裸 Metrics JSON */
const buildDcHostServiceCollectBody = () => ({
command: 'execute_task' as const,
params: {
task_id: 0,
task_name: 'service_collect',
type: 'host' as const,
config: '{}',
timeout: 120,
},
timestamp: new Date().toISOString(),
})
// 获取所有服务器的监控指标 // 获取所有服务器的监控指标
const getAllMetrics = async () => { const getAllMetrics = async () => {
try { try {
@@ -485,9 +473,8 @@ const getAllMetrics = async () => {
let metricsUrl = record.agent_config let metricsUrl = record.agent_config
// 验证 URL 是否合法 // 验证 URL 是否合法
let agentUrl: URL
try { try {
agentUrl = new URL(metricsUrl) new URL(metricsUrl)
} catch (urlError) { } catch (urlError) {
console.warn(`服务器 ${record.name} 的 agent_config 不是合法的 URL:`, metricsUrl) console.warn(`服务器 ${record.name} 的 agent_config 不是合法的 URL:`, metricsUrl)
// 设置默认值 0 // 设置默认值 0
@@ -496,12 +483,8 @@ const getAllMetrics = async () => {
record.disk_info = { value: 0, total: '', used: '' } record.disk_info = { value: 0, total: '', used: '' }
return return
} }
// dc-host 控制面拉取指标接口为 POST
const isDcHostCommand = agentUrl.pathname.includes('/dc-host/v1/control/command')
// 使用独立的 axios 实例请求外部 agent绕过全局拦截器 // 使用独立的 axios 实例请求外部 agent绕过全局拦截器
const response = isDcHostCommand const response = await agentAxios.get(metricsUrl)
? await agentAxios.post(metricsUrl, buildDcHostServiceCollectBody())
: await agentAxios.get(metricsUrl)
console.log('获取指标数据:', response.data) console.log('获取指标数据:', response.data)
if (response.data) { if (response.data) {
// 更新记录的监控数据 // 更新记录的监控数据

View File

@@ -30,74 +30,75 @@
</div> </div>
</div> </div>
<!-- 资源统计 --> <!-- 资源统计卡片仅展示名称 + 剩余 -->
<a-spin :loading="statsLoading" style="width: 100%"> <a-spin :loading="statsLoading" style="width: 100%">
<a-row :gutter="16" class="stats-row"> <a-row :gutter="16" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6"> <a-col
<a-card class="stats-card" :bordered="false"> v-for="(panel, idx) in resourcePanels"
<div class="stats-content"> :key="`card-${panel.key}`"
<div class="stats-icon stats-icon-primary"> :xs="24"
<icon-storage /> :sm="12"
</div> :lg="6"
<div class="stats-info"> >
<div class="stats-title">内存</div> <a-card class="stats-card" :bordered="false">
<div class="stats-value">{{ stats.memory.util }}%</div> <div class="stats-content">
<div class="stats-desc"> <div class="stats-icon" :class="statsIconClass(idx)">
总值 {{ stats.memory.total }} · 已用 {{ stats.memory.used }} <component :is="statsIcons[idx]" />
</div>
<div class="stats-info">
<div class="stats-title">{{ panel.title }}</div>
<div class="stats-value">{{ panel.remaining }}</div>
</div> </div>
</div> </div>
</div> </a-card>
</a-card> </a-col>
</a-col> </a-row>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false"> <!-- 与卡片一一对应的饼图总值 / 已用 / 剩余 -->
<div class="stats-content"> <a-row :gutter="16" class="pie-row">
<div class="stats-icon stats-icon-cyan"> <a-col v-for="panel in resourcePanels" :key="panel.key" :xs="24" :sm="12" :lg="6">
<icon-drive-file /> <a-card class="pie-card" :bordered="false">
</div> <template #title>
<div class="stats-info"> <span>{{ panel.title }}</span>
<div class="stats-title">磁盘系统盘 /</div> </template>
<div class="stats-value">{{ stats.systemDisk.util }}%</div> <div class="pie-body">
<div class="stats-desc"> <div
总值 {{ stats.systemDisk.total }} · 已用 {{ stats.systemDisk.used }} class="pie-chart-slot"
:class="{ 'pie-chart-slot--empty': !panel.hasData }"
>
<Chart v-if="panel.hasData" :options="panel.pieOptions" height="220px" />
<a-empty
v-else
class="pie-empty-inner"
:description="emptyPieDescription(panel)"
/>
</div>
<div class="pie-meta text-muted">
<template v-if="panel.hasData">
总值 {{ panel.totalLabel }} · 已用 {{ panel.usedLabel }} · 剩余
{{ panel.freeLabel }}
</template>
<span v-else class="pie-meta-placeholder" aria-hidden="true">&nbsp;</span>
</div> </div>
</div> </div>
</div> </a-card>
</a-card> </a-col>
</a-col> </a-row>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false"> <!-- 磁盘挂载明细disk_root + data_disks dc-control summary 一致 -->
<div class="stats-content"> <a-row v-if="diskTableRows.length" :gutter="16" class="disk-table-row">
<div class="stats-icon stats-icon-green"> <a-col :span="24">
<icon-code-square /> <a-card title="磁盘挂载详情" :bordered="false">
</div> <a-table
<div class="stats-info"> :columns="diskTableColumns"
<div class="stats-title">CPU</div> :data="diskTableRows"
<div class="stats-value">{{ stats.cpu.util }}%</div> :pagination="false"
<div class="stats-desc"> size="small"
总值 {{ stats.cpu.total }} · 已用 {{ stats.cpu.used }} :bordered="{ cell: true }"
</div> />
</div> </a-card>
</div> </a-col>
</a-card> </a-row>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-purple">
<icon-folder />
</div>
<div class="stats-info">
<div class="stats-title">硬盘数据盘汇总</div>
<div class="stats-value">{{ stats.dataDisk.util }}%</div>
<div class="stats-desc">
总值 {{ stats.dataDisk.total }} · 已用 {{ stats.dataDisk.used }}
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</a-spin> </a-spin>
<a-row :gutter="16" class="chart-row"> <a-row :gutter="16" class="chart-row">
@@ -126,7 +127,7 @@
<a-col :xs="24" :lg="10"> <a-col :xs="24" :lg="10">
<a-card title="最近告警" :bordered="false"> <a-card title="最近告警" :bordered="false">
<template #extra> <template #extra>
<span class="text-muted">当前主机 · 最近 5 </span> <span class="text-muted">当前主机 · {{ recentAlerts.length }} 最多 5 </span>
</template> </template>
<a-spin :loading="alertsLoading" class="alert-spin"> <a-spin :loading="alertsLoading" class="alert-spin">
<div v-if="recentAlerts.length" class="alert-list"> <div v-if="recentAlerts.length" class="alert-list">
@@ -160,11 +161,9 @@
</div> </div>
</div> </div>
</div> </div>
<a-empty <div v-else class="alert-empty-wrap">
v-else <a-empty description="暂无与该主机相关的告警" />
class="alert-empty" </div>
description="暂无与该主机相关的告警"
/>
</a-spin> </a-spin>
</a-card> </a-card>
</a-col> </a-col>
@@ -187,7 +186,6 @@ import {
fetchServerList, fetchServerList,
fetchServerMetricsSummary, fetchServerMetricsSummary,
fetchServerNetworkTraffic, fetchServerNetworkTraffic,
type HostMetricsDiskMount,
type HostMetricsSummary, type HostMetricsSummary,
type HostNetworkTrafficPayload, type HostNetworkTrafficPayload,
type ServerItem, type ServerItem,
@@ -203,17 +201,23 @@ interface ServerOption {
location: string location: string
} }
interface MetricBlock { /** 单块资源:顶部卡片 + 饼图共用 */
total: string interface OsResourcePanel {
used: string key: string
util: number title: string
remaining: string
hasData: boolean
totalLabel: string
usedLabel: string
freeLabel: string
pieOptions: Record<string, unknown>
} }
interface ServerMetrics { const statsIcons = [IconStorage, IconDriveFile, IconCodeSquare, IconFolder]
memory: MetricBlock
systemDisk: MetricBlock function statsIconClass(idx: number) {
cpu: MetricBlock const map = ['stats-icon-primary', 'stats-icon-cyan', 'stats-icon-green', 'stats-icon-purple']
dataDisk: MetricBlock return map[idx] ?? 'stats-icon-primary'
} }
const serverListLoading = ref(false) const serverListLoading = ref(false)
@@ -234,17 +238,204 @@ const activeServer = computed(() =>
serverOptions.value.find((s) => s.server_identity === selectedServerIdentity.value), serverOptions.value.find((s) => s.server_identity === selectedServerIdentity.value),
) )
const defaultMetrics: ServerMetrics = { const emptyPieOptions = (): Record<string, unknown> => ({
memory: { total: '-', used: '-', util: 0 }, graphic: [
systemDisk: { total: '-', used: '-', util: 0 }, {
cpu: { total: '-', used: '-', util: 0 }, type: 'text',
dataDisk: { total: '-', used: '-', util: 0 }, left: 'center',
top: 'middle',
style: {
text: '暂无数据',
fill: 'var(--color-text-3)',
fontSize: 14,
},
},
],
series: [],
})
/** 字节类资源饼图(已用 / 剩余) */
function buildPieOptionsBytes(
usedBytes: number,
freeBytes: number,
usedColor: string,
): Record<string, unknown> {
const u = Math.max(0, usedBytes)
const f = Math.max(0, freeBytes)
if (u + f <= 0) return emptyPieOptions()
return {
tooltip: {
trigger: 'item',
formatter: (p: { name?: string; value?: number; percent?: number }) => {
const v = p.value ?? 0
return `${p.name}<br/>${formatBytes(v)} (${p.percent ?? 0}%)`
},
},
legend: { bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
series: [
{
type: 'pie',
radius: ['42%', '68%'],
avoidLabelOverlap: false,
label: { show: false },
data: [
{ name: '已用', value: u, itemStyle: { color: usedColor } },
{ name: '剩余', value: f, itemStyle: { color: '#E5E6EB' } },
],
},
],
}
} }
/** CPU 核数饼图(已用 / 剩余) */
function buildPieOptionsCores(
usedCores: number,
freeCores: number,
usedColor: string,
): Record<string, unknown> {
const u = Math.max(0, usedCores)
const f = Math.max(0, freeCores)
if (u + f <= 0) return emptyPieOptions()
return {
tooltip: {
trigger: 'item',
formatter: (p: { name?: string; value?: number; percent?: number }) => {
const v = p.value ?? 0
return `${p.name}<br/>${round1(v)} 核 (${p.percent ?? 0}%)`
},
},
legend: { bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
series: [
{
type: 'pie',
radius: ['42%', '68%'],
avoidLabelOverlap: false,
label: { show: false },
data: [
{ name: '已用', value: u, itemStyle: { color: usedColor } },
{ name: '剩余', value: f, itemStyle: { color: '#E5E6EB' } },
],
},
],
}
}
const defaultResourcePanels: OsResourcePanel[] = [
{
key: 'memory',
title: '内存',
remaining: '-',
hasData: false,
totalLabel: '-',
usedLabel: '-',
freeLabel: '-',
pieOptions: emptyPieOptions(),
},
{
key: 'systemDisk',
title: '磁盘(系统盘 /',
remaining: '-',
hasData: false,
totalLabel: '-',
usedLabel: '-',
freeLabel: '-',
pieOptions: emptyPieOptions(),
},
{
key: 'cpu',
title: 'CPU',
remaining: '-',
hasData: false,
totalLabel: '-',
usedLabel: '-',
freeLabel: '-',
pieOptions: emptyPieOptions(),
},
{
key: 'dataDisk',
title: '本地挂载(汇总)',
remaining: '-',
hasData: false,
totalLabel: '-',
usedLabel: '-',
freeLabel: '-',
pieOptions: emptyPieOptions(),
},
]
function round1(n: number) { function round1(n: number) {
return Math.round(n * 10) / 10 return Math.round(n * 10) / 10
} }
function formatPercent(n: number | undefined | null): string {
if (n === undefined || n === null || Number.isNaN(n)) return '-'
return `${round1(n)}%`
}
/** 饼图无数据时的说明(区分「无根分区」与「无其它挂载点」) */
function emptyPieDescription(panel: OsResourcePanel): string {
const sum = summaryPayload.value
if (!sum?.has_data) return '暂无数据'
if (panel.key === 'systemDisk' && !panel.hasData) {
return '暂无根分区磁盘指标(请确认 dc-host 与入库)'
}
if (panel.key === 'dataDisk' && !panel.hasData) {
return '暂无其它本地挂载点dc-host disk_usages 或尚未采集)'
}
return '暂无数据'
}
const diskTableColumns = [
{ title: '挂载点', dataIndex: 'mount', ellipsis: true, tooltip: true, width: 200 },
{ title: '类型', dataIndex: 'kindLabel', width: 120 },
{ title: '使用率', dataIndex: 'usedPercentLabel', width: 100 },
{ title: '总容量', dataIndex: 'totalLabel', width: 120 },
{ title: '已用', dataIndex: 'usedLabel', width: 120 },
{ title: '剩余', dataIndex: 'freeLabel', width: 120 },
]
/** 汇总接口 disk_root + data_disks 明细行 */
const diskTableRows = computed(() => {
const d = summaryPayload.value
if (!d?.has_data) return []
const rows: Array<{
mount: string
kindLabel: string
usedPercentLabel: string
totalLabel: string
usedLabel: string
freeLabel: string
}> = []
const root = d.disk_root
if (root && root.total_bytes > 0) {
const freeBytes = root.free_bytes ?? Math.max(0, root.total_bytes - root.used_bytes)
rows.push({
mount: root.mount || '/',
kindLabel: '系统盘(根)',
usedPercentLabel: formatPercent(root.used_percent),
totalLabel: formatBytes(root.total_bytes),
usedLabel: formatBytes(root.used_bytes),
freeLabel: formatBytes(freeBytes),
})
}
const sorted = [...(d.data_disks ?? [])].sort((a, b) =>
(a.mount || '').localeCompare(b.mount || '', undefined, { sensitivity: 'base' }),
)
for (const disk of sorted) {
if (!disk.mount || disk.total_bytes <= 0) continue
const freeBytes = disk.free_bytes ?? Math.max(0, disk.total_bytes - disk.used_bytes)
rows.push({
mount: disk.mount,
kindLabel: '本地挂载',
usedPercentLabel: formatPercent(disk.used_percent),
totalLabel: formatBytes(disk.total_bytes),
usedLabel: formatBytes(disk.used_bytes),
freeLabel: formatBytes(freeBytes),
})
}
return rows
})
function formatBytes(n: number | undefined | null): string { function formatBytes(n: number | undefined | null): string {
if (n === undefined || n === null || n <= 0) return '-' if (n === undefined || n === null || n <= 0) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB'] const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -258,61 +449,106 @@ function formatBytes(n: number | undefined | null): string {
return `${x.toFixed(digits)} ${units[i]}` return `${x.toFixed(digits)} ${units[i]}`
} }
function useStatToBlock( const resourcePanels = computed<OsResourcePanel[]>(() => {
m: { total_bytes: number; used_bytes: number; used_percent: number } | undefined,
): MetricBlock {
if (!m) return { total: '-', used: '-', util: 0 }
return {
total: formatBytes(m.total_bytes),
used: formatBytes(m.used_bytes),
util: round1(m.used_percent),
}
}
function diskMountToBlock(d: HostMetricsDiskMount | undefined): MetricBlock {
if (!d) return { total: '-', used: '-', util: 0 }
return {
total: formatBytes(d.total_bytes),
used: formatBytes(d.used_bytes),
util: round1(d.used_percent),
}
}
function aggregateDataDisks(disks: HostMetricsDiskMount[] | undefined): MetricBlock {
if (!disks?.length) return { total: '-', used: '-', util: 0 }
let total = 0
let used = 0
for (const d of disks) {
total += d.total_bytes
used += d.used_bytes
}
if (total <= 0) return { total: '-', used: '-', util: 0 }
return {
total: formatBytes(total),
used: formatBytes(used),
util: round1((used / total) * 100),
}
}
const stats = computed<ServerMetrics>(() => {
const d = summaryPayload.value const d = summaryPayload.value
if (!d || !d.has_data) { if (!d || !d.has_data) {
return defaultMetrics return defaultResourcePanels
} }
const mem = d.memory
let memPanel: OsResourcePanel
if (mem && mem.total_bytes > 0) {
const freeBytes = mem.free_bytes ?? Math.max(0, mem.total_bytes - mem.used_bytes)
memPanel = {
key: 'memory',
title: '内存',
remaining: formatBytes(freeBytes),
hasData: true,
totalLabel: formatBytes(mem.total_bytes),
usedLabel: formatBytes(mem.used_bytes),
freeLabel: formatBytes(freeBytes),
pieOptions: buildPieOptionsBytes(mem.used_bytes, freeBytes, '#165DFF'),
}
} else {
memPanel = { ...defaultResourcePanels[0] }
}
const root = d.disk_root
let sysPanel: OsResourcePanel
if (root && root.total_bytes > 0) {
const freeBytes = root.free_bytes ?? Math.max(0, root.total_bytes - root.used_bytes)
const rootTitle = root.mount ? `磁盘(${root.mount}` : '磁盘(系统盘 /'
sysPanel = {
key: 'systemDisk',
title: rootTitle,
remaining: formatBytes(freeBytes),
hasData: true,
totalLabel: formatBytes(root.total_bytes),
usedLabel: formatBytes(root.used_bytes),
freeLabel: formatBytes(freeBytes),
pieOptions: buildPieOptionsBytes(root.used_bytes, freeBytes, '#14C9C9'),
}
} else {
sysPanel = { ...defaultResourcePanels[1] }
}
const cores = d.cpu?.logical_cores_total ?? 0 const cores = d.cpu?.logical_cores_total ?? 0
const usage = d.cpu?.usage_percent ?? 0 const usage = d.cpu?.usage_percent ?? 0
const usedCores = cores > 0 ? (usage / 100) * cores : 0 const usedCores = cores > 0 ? (usage / 100) * cores : 0
const cpuBlock: MetricBlock = { const freeCores = cores > 0 ? Math.max(0, cores - usedCores) : 0
total: cores > 0 ? `${round1(cores)}` : '-', let cpuPanel: OsResourcePanel
used: cores > 0 ? `${round1(usedCores)}` : '-', if (cores > 0) {
util: round1(usage), cpuPanel = {
key: 'cpu',
title: 'CPU',
remaining: `${round1(freeCores)}`,
hasData: true,
totalLabel: `${round1(cores)}`,
usedLabel: `${round1(usedCores)}`,
freeLabel: `${round1(freeCores)}`,
pieOptions: buildPieOptionsCores(usedCores, freeCores, '#00B42A'),
}
} else {
cpuPanel = { ...defaultResourcePanels[2] }
} }
return {
memory: useStatToBlock(d.memory), const disks = d.data_disks
systemDisk: diskMountToBlock(d.disk_root), let aggTotal = 0
cpu: cpuBlock, let aggUsed = 0
dataDisk: aggregateDataDisks(d.data_disks), let aggFree = 0
if (disks?.length) {
for (const disk of disks) {
aggTotal += disk.total_bytes
aggUsed += disk.used_bytes
aggFree += disk.free_bytes ?? Math.max(0, disk.total_bytes - disk.used_bytes)
}
} }
let dataPanel: OsResourcePanel
if (aggTotal > 0) {
const freeBytes = aggFree > 0 ? aggFree : Math.max(0, aggTotal - aggUsed)
const list = disks ?? []
const n = list.length
const dataTitle =
n === 1
? `硬盘(${list[0].mount}`
: n > 1
? `本地挂载(${n} 个挂载点汇总)`
: '硬盘(数据盘汇总)'
dataPanel = {
key: 'dataDisk',
title: dataTitle,
remaining: formatBytes(freeBytes),
hasData: true,
totalLabel: formatBytes(aggTotal),
usedLabel: formatBytes(aggUsed),
freeLabel: formatBytes(freeBytes),
pieOptions: buildPieOptionsBytes(aggUsed, freeBytes, '#722ED1'),
}
} else {
dataPanel = { ...defaultResourcePanels[3] }
}
return [memPanel, sysPanel, cpuPanel, dataPanel]
}) })
function filterServerOption(input: string, option: { label?: string }) { function filterServerOption(input: string, option: { label?: string }) {
@@ -322,32 +558,6 @@ function filterServerOption(input: string, option: { label?: string }) {
return label.includes(q) return label.includes(q)
} }
/** 判断告警记录是否属于当前主机labels 或文本中出现 identity / IP / hostname */
function matchServerAlert(record: any, server: ServerOption): boolean {
try {
const raw = record?.labels
const labels =
typeof raw === 'string' && raw
? JSON.parse(raw)
: raw && typeof raw === 'object'
? raw
: null
if (labels && typeof labels === 'object') {
if (labels.server_identity === server.server_identity) return true
const inst = String(labels.instance ?? '')
if (inst && (inst === server.ip || inst === server.host)) return true
}
} catch {
/* ignore */
}
const text = `${record?.alert_name ?? ''} ${record?.summary ?? ''} ${record?.description ?? ''}`
return (
text.includes(server.server_identity) ||
(!!server.ip && server.ip !== '-' && text.includes(server.ip)) ||
(!!server.host && text.includes(server.host))
)
}
function formatAlertTime(iso: string | undefined | null) { function formatAlertTime(iso: string | undefined | null) {
if (!iso) return '-' if (!iso) return '-'
return dayjs(iso).format('MM-DD HH:mm') return dayjs(iso).format('MM-DD HH:mm')
@@ -377,28 +587,48 @@ function alertStatusText(status: string | undefined) {
return map[status || ''] || status || '-' return map[status || ''] || status || '-'
} }
/**
* 从 Alert/v1/record/list 的 details 中解析列表(兼容 data / list / records
*/
function normalizeAlertRecordList(res: any): any[] {
const d = res?.details
if (!d) return []
const raw = d.data ?? d.list ?? d.records
return Array.isArray(raw) ? raw : []
}
async function loadRecentAlertsForServer(server: ServerOption | undefined) { async function loadRecentAlertsForServer(server: ServerOption | undefined) {
if (!server) { if (!server) {
recentAlerts.value = [] recentAlerts.value = []
return return
} }
const targetSid = server.server_identity
try { try {
const res: any = await fetchAlertRecords({ const res: any = await fetchAlertRecords({
page: 1, page: 1,
page_size: 100, page_size: 5,
sort: 'starts_at', sort: 'starts_at',
order: 'desc', order: 'desc',
server_identity: targetSid,
}) })
if (res.code !== 0) { /** 切换主机后丢弃过期响应,避免空结果仍显示上一台主机的列表 */
if (selectedServerIdentity.value !== targetSid) {
return
}
const code = res?.code
const codeOk = code === undefined || code === null || code === 0 || code === '0'
if (!codeOk) {
recentAlerts.value = [] recentAlerts.value = []
Message.error(res.message || '加载最近告警失败') Message.error(res.message || '加载最近告警失败')
return return
} }
const rows: any[] = res.details?.data ?? [] const rows = normalizeAlertRecordList(res).slice(0, 5)
recentAlerts.value = rows.filter((r) => matchServerAlert(r, server)).slice(0, 5) recentAlerts.value = rows
} catch (e: any) { } catch (e: any) {
recentAlerts.value = [] if (selectedServerIdentity.value === targetSid) {
Message.error(e?.message || '加载最近告警失败') recentAlerts.value = []
Message.error(e?.message || '加载最近告警失败')
}
} }
} }
@@ -540,6 +770,7 @@ async function refreshDashboard() {
statsLoading.value = true statsLoading.value = true
chartLoading.value = true chartLoading.value = true
alertsLoading.value = true alertsLoading.value = true
recentAlerts.value = []
try { try {
const [sumRes, trafRes]: any[] = await Promise.all([ const [sumRes, trafRes]: any[] = await Promise.all([
fetchServerMetricsSummary(sid), fetchServerMetricsSummary(sid),
@@ -649,6 +880,88 @@ export default {
margin-bottom: 16px; margin-bottom: 16px;
} }
.pie-row {
margin-bottom: 16px;
:deep(.arco-row) {
align-items: stretch;
}
> .arco-col {
display: flex;
}
}
.disk-table-row {
margin-bottom: 16px;
}
.pie-card {
flex: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
:deep(.arco-card-header) {
border-bottom: none;
padding-bottom: 0;
}
:deep(.arco-card-body) {
flex: 1;
display: flex;
flex-direction: column;
padding-top: 8px;
}
}
.pie-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/** 与有数据时 Chart 高度一致,避免同排卡片高低不齐 */
.pie-chart-slot {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 220px;
min-height: 220px;
}
.pie-chart-slot--empty {
background-color: var(--color-fill-1);
border: 1px dashed var(--color-border-2);
border-radius: 8px;
}
.pie-empty-inner {
padding: 0;
}
.pie-meta {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 8px;
min-height: 36px;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.pie-meta-placeholder {
display: inline-block;
opacity: 0;
pointer-events: none;
}
.stats-card { .stats-card {
height: 100%; height: 100%;
@@ -720,10 +1033,32 @@ export default {
.chart-row { .chart-row {
margin-bottom: 16px; margin-bottom: 16px;
:deep(.arco-row) {
align-items: stretch;
}
> .arco-col {
display: flex;
}
> .arco-col > .arco-card {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
:deep(.arco-card-body) {
flex: 1;
display: flex;
flex-direction: column;
}
}
} }
.chart-container { .chart-container {
height: 300px; height: 300px;
min-height: 300px;
} }
.legend-item { .legend-item {
@@ -749,13 +1084,19 @@ export default {
} }
.chart-spin { .chart-spin {
display: block; display: flex;
flex-direction: column;
flex: 1;
width: 100%; width: 100%;
min-width: 0;
} }
.alert-spin { .alert-spin {
display: block; display: flex;
min-height: 120px; flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
} }
.alert-list { .alert-list {
@@ -816,8 +1157,13 @@ export default {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.alert-empty { .alert-empty-wrap {
padding: 32px 0; display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 300px;
box-sizing: border-box;
} }
.text-muted { .text-muted {