fix
This commit is contained in:
@@ -10,7 +10,9 @@ export const fetchAlertRecords = (data: {
|
||||
severity_id?: number,
|
||||
start_time?: string,
|
||||
end_time?: string,
|
||||
keyword?: string
|
||||
keyword?: string,
|
||||
sort?: string,
|
||||
order?: string,
|
||||
}) => {
|
||||
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) => {
|
||||
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,
|
||||
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,
|
||||
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
|
||||
|
||||
@@ -226,6 +226,23 @@ export const localMenuItems: MenuItem[] = [
|
||||
created_at: '2025-12-26T13:23:51.892569+08:00',
|
||||
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,
|
||||
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