This commit is contained in:
zxr
2026-04-11 20:03:06 +08:00
parent ac74bc20ec
commit 8b3bd98e68
5 changed files with 927 additions and 1 deletions

View File

@@ -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 });
}; };

View File

@@ -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 } },
)
}

View File

@@ -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',

View File

@@ -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',

View 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>