fix
This commit is contained in:
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,返回规范化后的 IP(IPv4 或 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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
// 更新记录的监控数据
|
// 更新记录的监控数据
|
||||||
|
|||||||
@@ -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"> </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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user