fix
This commit is contained in:
@@ -10,7 +10,9 @@ export const fetchAlertRecords = (data: {
|
|||||||
severity_id?: number,
|
severity_id?: number,
|
||||||
start_time?: string,
|
start_time?: string,
|
||||||
end_time?: string,
|
end_time?: string,
|
||||||
keyword?: string
|
keyword?: string,
|
||||||
|
sort?: string,
|
||||||
|
order?: string,
|
||||||
}) => {
|
}) => {
|
||||||
return request.get("/Alert/v1/record/list", { params: data });
|
return request.get("/Alert/v1/record/list", { params: data });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,3 +97,67 @@ export const updateServer = (id: number, data: Partial<ServerFormData>) => {
|
|||||||
export const deleteServer = (id: number) => {
|
export const deleteServer = (id: number) => {
|
||||||
return request.delete<{ message: string }>(`/DC-Control/v1/servers/${id}`)
|
return request.delete<{ message: string }>(`/DC-Control/v1/servers/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 主机最新指标摘要(统计卡片,与 dc-control /servers/metrics/summary 一致) */
|
||||||
|
export interface HostMetricsUseStat {
|
||||||
|
total_bytes: number
|
||||||
|
used_bytes: number
|
||||||
|
free_bytes: number
|
||||||
|
used_percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostMetricsDiskMount {
|
||||||
|
mount: string
|
||||||
|
total_bytes: number
|
||||||
|
used_bytes: number
|
||||||
|
free_bytes: number
|
||||||
|
used_percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostMetricsCpuCard {
|
||||||
|
usage_percent: number
|
||||||
|
logical_cores_total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostMetricsSummary {
|
||||||
|
server_identity: string
|
||||||
|
timestamp?: string
|
||||||
|
has_data: boolean
|
||||||
|
memory?: HostMetricsUseStat
|
||||||
|
disk_root?: HostMetricsDiskMount
|
||||||
|
data_disks?: HostMetricsDiskMount[]
|
||||||
|
cpu?: HostMetricsCpuCard
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostNetworkTrafficPoint {
|
||||||
|
time: string
|
||||||
|
recv_mbps: number
|
||||||
|
send_mbps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostNetworkTrafficPayload {
|
||||||
|
server_identity: string
|
||||||
|
hours: number
|
||||||
|
/** 时间窗内是否有主机采集写入的网络累计指标行 */
|
||||||
|
has_data: boolean
|
||||||
|
/** 是否已根据相邻采样差分算出速率曲线(至少两个采样点) */
|
||||||
|
has_rate_series?: boolean
|
||||||
|
points: HostNetworkTrafficPoint[]
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最新一批主机指标(用于监控大屏卡片) */
|
||||||
|
export const fetchServerMetricsSummary = (serverIdentity: string) => {
|
||||||
|
return request.get<{ code: number; details?: HostMetricsSummary; message?: string }>(
|
||||||
|
'/DC-Control/v1/servers/metrics/summary',
|
||||||
|
{ params: { server_identity: serverIdentity } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 近 N 小时网络收/发速率(Mbps,相邻采样字节差分) */
|
||||||
|
export const fetchServerNetworkTraffic = (serverIdentity: string, hours = 6) => {
|
||||||
|
return request.get<{ code: number; details?: HostNetworkTrafficPayload; message?: string }>(
|
||||||
|
'/DC-Control/v1/servers/metrics/network-traffic',
|
||||||
|
{ params: { server_identity: serverIdentity, hours } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,6 +211,22 @@ export const localMenuFlatItems: MenuItem[] = [
|
|||||||
sort_key: 14,
|
sort_key: 14,
|
||||||
created_at: '2025-12-26T13:23:51.892569+08:00',
|
created_at: '2025-12-26T13:23:51.892569+08:00',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 12020,
|
||||||
|
identity: '019c7100-0001-7000-8000-000000000020',
|
||||||
|
title: '操作系统监控',
|
||||||
|
title_en: 'OS Monitoring',
|
||||||
|
code: 'ops:综合监控:操作系统监控',
|
||||||
|
description: '综合监控 - 操作系统资源与流量、告警',
|
||||||
|
app_id: 2,
|
||||||
|
parent_id: 23,
|
||||||
|
menu_path: '/monitor/os',
|
||||||
|
menu_icon: 'appstore',
|
||||||
|
component: 'ops/pages/monitor/os',
|
||||||
|
type: 1,
|
||||||
|
sort_key: 13.5,
|
||||||
|
created_at: '2026-04-11T10:00:00+08:00',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 27,
|
id: 27,
|
||||||
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
|
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
|
||||||
|
|||||||
@@ -226,6 +226,23 @@ export const localMenuItems: MenuItem[] = [
|
|||||||
created_at: '2025-12-26T13:23:51.892569+08:00',
|
created_at: '2025-12-26T13:23:51.892569+08:00',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 12020,
|
||||||
|
identity: '019c7100-0001-7000-8000-000000000020',
|
||||||
|
title: '操作系统监控',
|
||||||
|
title_en: 'OS Monitoring',
|
||||||
|
code: 'ops:综合监控:操作系统监控',
|
||||||
|
description: '综合监控 - 操作系统资源与流量、告警',
|
||||||
|
app_id: 2,
|
||||||
|
parent_id: 23,
|
||||||
|
menu_path: '/monitor/os',
|
||||||
|
menu_icon: 'appstore',
|
||||||
|
component: 'ops/pages/monitor/os',
|
||||||
|
type: 1,
|
||||||
|
sort_key: 5,
|
||||||
|
created_at: '2026-04-11T10:00:00+08:00',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 27,
|
id: 27,
|
||||||
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
|
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
|
||||||
|
|||||||
827
src/views/ops/pages/monitor/os/index.vue
Normal file
827
src/views/ops/pages/monitor/os/index.vue
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="toolbar-label">当前服务器</span>
|
||||||
|
<a-select
|
||||||
|
v-model="selectedServerIdentity"
|
||||||
|
class="server-select"
|
||||||
|
placeholder="输入名称或地址搜索"
|
||||||
|
allow-search
|
||||||
|
allow-clear
|
||||||
|
:loading="serverListLoading"
|
||||||
|
:filter-option="filterServerOption"
|
||||||
|
>
|
||||||
|
<a-option
|
||||||
|
v-for="item in serverOptions"
|
||||||
|
:key="item.server_identity"
|
||||||
|
:value="item.server_identity"
|
||||||
|
:label="`${item.name} ${item.host} ${item.ip}`"
|
||||||
|
>
|
||||||
|
<div class="server-option">
|
||||||
|
<span class="server-option-name">{{ item.name }}</span>
|
||||||
|
<span class="server-option-ip">{{ item.ip }}</span>
|
||||||
|
</div>
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
<div v-if="activeServer" class="toolbar-meta text-muted">
|
||||||
|
{{ activeServer.os }} · {{ activeServer.location }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 资源统计 -->
|
||||||
|
<a-spin :loading="statsLoading" style="width: 100%">
|
||||||
|
<a-row :gutter="16" class="stats-row">
|
||||||
|
<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-primary">
|
||||||
|
<icon-storage />
|
||||||
|
</div>
|
||||||
|
<div class="stats-info">
|
||||||
|
<div class="stats-title">内存</div>
|
||||||
|
<div class="stats-value">{{ stats.memory.util }}%</div>
|
||||||
|
<div class="stats-desc">
|
||||||
|
总值 {{ stats.memory.total }} · 已用 {{ stats.memory.used }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</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-cyan">
|
||||||
|
<icon-drive-file />
|
||||||
|
</div>
|
||||||
|
<div class="stats-info">
|
||||||
|
<div class="stats-title">磁盘(系统盘 /)</div>
|
||||||
|
<div class="stats-value">{{ stats.systemDisk.util }}%</div>
|
||||||
|
<div class="stats-desc">
|
||||||
|
总值 {{ stats.systemDisk.total }} · 已用 {{ stats.systemDisk.used }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</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-green">
|
||||||
|
<icon-code-square />
|
||||||
|
</div>
|
||||||
|
<div class="stats-info">
|
||||||
|
<div class="stats-title">CPU</div>
|
||||||
|
<div class="stats-value">{{ stats.cpu.util }}%</div>
|
||||||
|
<div class="stats-desc">
|
||||||
|
总值 {{ stats.cpu.total }} · 已用 {{ stats.cpu.used }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</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-row :gutter="16" class="chart-row">
|
||||||
|
<a-col :xs="24" :lg="14">
|
||||||
|
<a-card title="网络流量(近 6 小时)" :bordered="false">
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-dot legend-dot-1"></span>
|
||||||
|
<span>接收</span>
|
||||||
|
</span>
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-dot legend-dot-2"></span>
|
||||||
|
<span>发送</span>
|
||||||
|
</span>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<a-spin :loading="chartLoading" class="chart-spin">
|
||||||
|
<p v-if="trafficHint" class="traffic-hint text-muted">{{ trafficHint }}</p>
|
||||||
|
<div class="chart-container">
|
||||||
|
<Chart :options="trafficChartOptions" height="300px" />
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :xs="24" :lg="10">
|
||||||
|
<a-card title="最近告警" :bordered="false">
|
||||||
|
<template #extra>
|
||||||
|
<span class="text-muted">当前主机 · 最近 5 条</span>
|
||||||
|
</template>
|
||||||
|
<a-spin :loading="alertsLoading" class="alert-spin">
|
||||||
|
<div v-if="recentAlerts.length" class="alert-list">
|
||||||
|
<div
|
||||||
|
v-for="item in recentAlerts"
|
||||||
|
:key="item.id"
|
||||||
|
class="alert-item"
|
||||||
|
>
|
||||||
|
<div class="alert-item-top">
|
||||||
|
<span class="alert-item-name" :title="item.alert_name">{{
|
||||||
|
item.alert_name || '-'
|
||||||
|
}}</span>
|
||||||
|
<a-tag
|
||||||
|
v-if="item.severity"
|
||||||
|
size="small"
|
||||||
|
:color="item.severity.color || undefined"
|
||||||
|
>
|
||||||
|
{{ item.severity.name }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="alert-item-meta">
|
||||||
|
<a-tag size="small" :color="alertStatusColor(item.status)">
|
||||||
|
{{ alertStatusText(item.status) }}
|
||||||
|
</a-tag>
|
||||||
|
<span class="alert-item-time">{{
|
||||||
|
formatAlertTime(item.starts_at)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.summary" class="alert-item-summary text-muted">
|
||||||
|
{{ item.summary }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-empty
|
||||||
|
v-else
|
||||||
|
class="alert-empty"
|
||||||
|
description="暂无与该主机相关的告警"
|
||||||
|
/>
|
||||||
|
</a-spin>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import {
|
||||||
|
IconStorage,
|
||||||
|
IconDriveFile,
|
||||||
|
IconCodeSquare,
|
||||||
|
IconFolder,
|
||||||
|
} from '@arco-design/web-vue/es/icon'
|
||||||
|
import Chart from '@/components/chart/index.vue'
|
||||||
|
import {
|
||||||
|
fetchServerList,
|
||||||
|
fetchServerMetricsSummary,
|
||||||
|
fetchServerNetworkTraffic,
|
||||||
|
type HostMetricsDiskMount,
|
||||||
|
type HostMetricsSummary,
|
||||||
|
type HostNetworkTrafficPayload,
|
||||||
|
type ServerItem,
|
||||||
|
} from '@/api/ops/server'
|
||||||
|
import { fetchAlertRecords } from '@/api/ops/alertRecord'
|
||||||
|
|
||||||
|
interface ServerOption {
|
||||||
|
server_identity: string
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
ip: string
|
||||||
|
os: string
|
||||||
|
location: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricBlock {
|
||||||
|
total: string
|
||||||
|
used: string
|
||||||
|
util: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerMetrics {
|
||||||
|
memory: MetricBlock
|
||||||
|
systemDisk: MetricBlock
|
||||||
|
cpu: MetricBlock
|
||||||
|
dataDisk: MetricBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverListLoading = ref(false)
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
const chartLoading = ref(false)
|
||||||
|
const alertsLoading = ref(false)
|
||||||
|
|
||||||
|
/** 与当前选中主机相关的最近告警(来自 Alert record/list) */
|
||||||
|
const recentAlerts = ref<any[]>([])
|
||||||
|
|
||||||
|
const serverOptions = ref<ServerOption[]>([])
|
||||||
|
const selectedServerIdentity = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const summaryPayload = ref<HostMetricsSummary | null>(null)
|
||||||
|
const trafficPayload = ref<HostNetworkTrafficPayload | null>(null)
|
||||||
|
|
||||||
|
const activeServer = computed(() =>
|
||||||
|
serverOptions.value.find((s) => s.server_identity === selectedServerIdentity.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultMetrics: ServerMetrics = {
|
||||||
|
memory: { total: '-', used: '-', util: 0 },
|
||||||
|
systemDisk: { total: '-', used: '-', util: 0 },
|
||||||
|
cpu: { total: '-', used: '-', util: 0 },
|
||||||
|
dataDisk: { total: '-', used: '-', util: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(n: number) {
|
||||||
|
return Math.round(n * 10) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number | undefined | null): string {
|
||||||
|
if (n === undefined || n === null || n <= 0) return '-'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let i = 0
|
||||||
|
let x = n
|
||||||
|
while (x >= 1024 && i < units.length - 1) {
|
||||||
|
x /= 1024
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
const digits = i === 0 ? 0 : x >= 10 ? 1 : 2
|
||||||
|
return `${x.toFixed(digits)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStatToBlock(
|
||||||
|
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
|
||||||
|
if (!d || !d.has_data) {
|
||||||
|
return defaultMetrics
|
||||||
|
}
|
||||||
|
const cores = d.cpu?.logical_cores_total ?? 0
|
||||||
|
const usage = d.cpu?.usage_percent ?? 0
|
||||||
|
const usedCores = cores > 0 ? (usage / 100) * cores : 0
|
||||||
|
const cpuBlock: MetricBlock = {
|
||||||
|
total: cores > 0 ? `${round1(cores)} 核` : '-',
|
||||||
|
used: cores > 0 ? `约 ${round1(usedCores)} 核` : '-',
|
||||||
|
util: round1(usage),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
memory: useStatToBlock(d.memory),
|
||||||
|
systemDisk: diskMountToBlock(d.disk_root),
|
||||||
|
cpu: cpuBlock,
|
||||||
|
dataDisk: aggregateDataDisks(d.data_disks),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function filterServerOption(input: string, option: { label?: string }) {
|
||||||
|
if (!input) return true
|
||||||
|
const q = input.trim().toLowerCase()
|
||||||
|
const label = String(option?.label ?? '').toLowerCase()
|
||||||
|
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) {
|
||||||
|
if (!iso) return '-'
|
||||||
|
return dayjs(iso).format('MM-DD HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertStatusColor(status: string | undefined) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
firing: 'red',
|
||||||
|
pending: 'orange',
|
||||||
|
acked: 'gold',
|
||||||
|
resolved: 'green',
|
||||||
|
silenced: 'gray',
|
||||||
|
suppressed: 'lightgray',
|
||||||
|
}
|
||||||
|
return map[status || ''] || 'blue'
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertStatusText(status: string | undefined) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
firing: '告警中',
|
||||||
|
pending: '待处理',
|
||||||
|
acked: '已确认',
|
||||||
|
resolved: '已解决',
|
||||||
|
silenced: '已屏蔽',
|
||||||
|
suppressed: '已抑制',
|
||||||
|
}
|
||||||
|
return map[status || ''] || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentAlertsForServer(server: ServerOption | undefined) {
|
||||||
|
if (!server) {
|
||||||
|
recentAlerts.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res: any = await fetchAlertRecords({
|
||||||
|
page: 1,
|
||||||
|
page_size: 100,
|
||||||
|
sort: 'starts_at',
|
||||||
|
order: 'desc',
|
||||||
|
})
|
||||||
|
if (res.code !== 0) {
|
||||||
|
recentAlerts.value = []
|
||||||
|
Message.error(res.message || '加载最近告警失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rows: any[] = res.details?.data ?? []
|
||||||
|
recentAlerts.value = rows.filter((r) => matchServerAlert(r, server)).slice(0, 5)
|
||||||
|
} catch (e: any) {
|
||||||
|
recentAlerts.value = []
|
||||||
|
Message.error(e?.message || '加载最近告警失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已有速率曲线数据(兼容旧接口仅看 points) */
|
||||||
|
const hasTrafficRateSeries = computed(() => {
|
||||||
|
const p = trafficPayload.value
|
||||||
|
if (!p) return false
|
||||||
|
if (typeof p.has_rate_series === 'boolean') return p.has_rate_series
|
||||||
|
return (p.points?.length ?? 0) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const trafficHint = computed(() => {
|
||||||
|
const p = trafficPayload.value
|
||||||
|
if (!p || chartLoading.value) return ''
|
||||||
|
if (hasTrafficRateSeries.value) return ''
|
||||||
|
if (p.has_data) {
|
||||||
|
return p.note || '已有指标,需至少两次采集后才展示速率曲线'
|
||||||
|
}
|
||||||
|
return '暂无主机网络累计指标(control_host_metrics_data),请确认主机采集与落库。'
|
||||||
|
})
|
||||||
|
|
||||||
|
const trafficChartOptions = computed(() => {
|
||||||
|
const pts = trafficPayload.value?.points ?? []
|
||||||
|
if (!pts.length) {
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { show: false },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: { type: 'category', boundaryGap: false, data: ['-'] },
|
||||||
|
yAxis: { type: 'value', name: 'Mb/s' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '接收',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: [0],
|
||||||
|
lineStyle: { width: 2, color: '#165DFF' },
|
||||||
|
itemStyle: { color: '#165DFF' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '发送',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: [0],
|
||||||
|
lineStyle: { width: 2, color: '#14C9C9' },
|
||||||
|
itemStyle: { color: '#14C9C9' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const labels = pts.map((p) => dayjs(p.time).format('MM-DD HH:mm'))
|
||||||
|
const rx = pts.map((p) => round1(p.recv_mbps))
|
||||||
|
const tx = pts.map((p) => round1(p.send_mbps))
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { show: false },
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: labels,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: 'Mb/s',
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '接收',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: rx,
|
||||||
|
areaStyle: { opacity: 0.08 },
|
||||||
|
lineStyle: { width: 2, color: '#165DFF' },
|
||||||
|
itemStyle: { color: '#165DFF' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '发送',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: tx,
|
||||||
|
areaStyle: { opacity: 0.08 },
|
||||||
|
lineStyle: { width: 2, color: '#14C9C9' },
|
||||||
|
itemStyle: { color: '#14C9C9' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadServerOptions() {
|
||||||
|
serverListLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await fetchServerList({ page: 1, size: 500 })
|
||||||
|
if (res.code !== 0) {
|
||||||
|
Message.error(res.message || '加载服务器列表失败')
|
||||||
|
serverOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list: ServerItem[] = res.details?.data ?? []
|
||||||
|
serverOptions.value = list.map((r) => ({
|
||||||
|
server_identity: r.server_identity,
|
||||||
|
name: r.name,
|
||||||
|
host: r.host,
|
||||||
|
ip: r.ip_address || r.host,
|
||||||
|
os: [r.os, r.os_version].filter(Boolean).join(' ') || '-',
|
||||||
|
location: r.location || '-',
|
||||||
|
}))
|
||||||
|
if (
|
||||||
|
selectedServerIdentity.value &&
|
||||||
|
!serverOptions.value.some((s) => s.server_identity === selectedServerIdentity.value)
|
||||||
|
) {
|
||||||
|
selectedServerIdentity.value = undefined
|
||||||
|
}
|
||||||
|
if (!selectedServerIdentity.value && serverOptions.value.length > 0) {
|
||||||
|
selectedServerIdentity.value = serverOptions.value[0].server_identity
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.message || '加载服务器列表失败')
|
||||||
|
serverOptions.value = []
|
||||||
|
} finally {
|
||||||
|
serverListLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDashboard() {
|
||||||
|
const sid = selectedServerIdentity.value
|
||||||
|
if (!sid) {
|
||||||
|
summaryPayload.value = null
|
||||||
|
trafficPayload.value = null
|
||||||
|
recentAlerts.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const server = serverOptions.value.find((s) => s.server_identity === sid)
|
||||||
|
statsLoading.value = true
|
||||||
|
chartLoading.value = true
|
||||||
|
alertsLoading.value = true
|
||||||
|
try {
|
||||||
|
const [sumRes, trafRes]: any[] = await Promise.all([
|
||||||
|
fetchServerMetricsSummary(sid),
|
||||||
|
fetchServerNetworkTraffic(sid, 6),
|
||||||
|
loadRecentAlertsForServer(server),
|
||||||
|
])
|
||||||
|
if (sumRes.code === 0) {
|
||||||
|
summaryPayload.value = sumRes.details ?? null
|
||||||
|
} else {
|
||||||
|
summaryPayload.value = null
|
||||||
|
Message.error(sumRes.message || '加载统计卡片失败')
|
||||||
|
}
|
||||||
|
if (trafRes.code === 0) {
|
||||||
|
trafficPayload.value = trafRes.details ?? null
|
||||||
|
} else {
|
||||||
|
trafficPayload.value = null
|
||||||
|
Message.error(trafRes.message || '加载网络流量失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.message || '请求失败')
|
||||||
|
summaryPayload.value = null
|
||||||
|
trafficPayload.value = null
|
||||||
|
recentAlerts.value = []
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
chartLoading.value = false
|
||||||
|
alertsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedServerIdentity, (sid) => {
|
||||||
|
if (!sid) {
|
||||||
|
summaryPayload.value = null
|
||||||
|
trafficPayload.value = null
|
||||||
|
recentAlerts.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshDashboard()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadServerOptions()
|
||||||
|
// 首次选中由 loadServerOptions 设置,watch(selectedServerIdentity) 会拉取指标
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
name: 'OsMonitor',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-select {
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-option-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-option-ip {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
:deep(.arco-card-body) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
&-primary {
|
||||||
|
background-color: rgba(22, 93, 255, 0.1);
|
||||||
|
color: rgb(var(--primary-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&-cyan {
|
||||||
|
background-color: rgba(20, 201, 201, 0.1);
|
||||||
|
color: #14c9c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-green {
|
||||||
|
background-color: rgba(0, 180, 42, 0.1);
|
||||||
|
color: rgb(var(--success-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
&-purple {
|
||||||
|
background-color: rgba(114, 46, 209, 0.1);
|
||||||
|
color: #722ed1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&-1 {
|
||||||
|
background-color: #165dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-2 {
|
||||||
|
background-color: #14c9c9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-spin {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-spin {
|
||||||
|
display: block;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
border: 1px solid var(--color-border-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
line-height: 1.4;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-item-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-empty {
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user