fix
This commit is contained in:
334
src/api/ops/host-hardware.ts
Normal file
334
src/api/ops/host-hardware.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 服务器硬件监控页专用:对接 DC-Hardware 服务(/DC-Hardware/v1)。
|
||||
* 与「机房设备监控」等业务区分,勿混用命名。
|
||||
*/
|
||||
import { request } from '@/api/request'
|
||||
|
||||
const HW_PREFIX = '/DC-Hardware/v1'
|
||||
|
||||
/** 与 bsm-sdk 成功响应一致:业务数据在 details */
|
||||
export interface HostHardwareApiEnvelope<T = unknown> {
|
||||
code?: number | string
|
||||
message?: string
|
||||
details?: T
|
||||
}
|
||||
|
||||
export interface HostHardwareDeviceDetailPayload {
|
||||
device: HostHardwareDevice
|
||||
status: HostHardwareStatus | null
|
||||
}
|
||||
|
||||
export interface HostHardwareDevice {
|
||||
id: string
|
||||
name?: string
|
||||
ip: string
|
||||
/** 设备类别,如 server/switch/storage;未返回时前端可按 server 展示 */
|
||||
type?: string
|
||||
protocol: string
|
||||
manufacturer?: string
|
||||
model?: string
|
||||
serial_number?: string
|
||||
username?: string
|
||||
password?: string
|
||||
port?: number
|
||||
snmp_port?: number
|
||||
community?: string
|
||||
snmp_version?: string
|
||||
redfish_base_url?: string
|
||||
redfish_tls_skip_verify?: boolean
|
||||
ipmi_timeout_seconds?: number
|
||||
snmp_timeout_seconds?: number
|
||||
redfish_timeout_seconds?: number
|
||||
ipmi_collect_enabled?: boolean
|
||||
snmp_collect_enabled?: boolean
|
||||
redfish_collect_enabled?: boolean
|
||||
collect_interval?: number
|
||||
asset_id?: string
|
||||
server_identity?: string
|
||||
enabled?: boolean
|
||||
status?: string
|
||||
tags?: string
|
||||
description?: string
|
||||
extra_config?: string
|
||||
}
|
||||
|
||||
/** 创建/更新 DC-Hardware 设备(与 CreateDeviceRequest 一致;密码在更新时为空则不修改) */
|
||||
export interface HostHardwareDeviceUpsert {
|
||||
name: string
|
||||
ip: string
|
||||
/** 省略或空则服务端默认 server */
|
||||
type?: string
|
||||
protocol: string
|
||||
username?: string
|
||||
password?: string
|
||||
port?: number
|
||||
snmp_port?: number
|
||||
community?: string
|
||||
snmp_version?: string
|
||||
redfish_base_url?: string
|
||||
redfish_tls_skip_verify?: boolean
|
||||
location?: string
|
||||
description?: string
|
||||
tags?: string
|
||||
asset_id?: string
|
||||
server_identity?: string
|
||||
extra_config?: string
|
||||
ipmi_timeout_seconds?: number
|
||||
snmp_timeout_seconds?: number
|
||||
redfish_timeout_seconds?: number
|
||||
ipmi_collect_enabled?: boolean
|
||||
snmp_collect_enabled?: boolean
|
||||
redfish_collect_enabled?: boolean
|
||||
collect_interval?: number
|
||||
}
|
||||
|
||||
export interface HostHardwareDeviceListPayload {
|
||||
total: number
|
||||
page: string | number
|
||||
page_size: string | number
|
||||
data: HostHardwareDevice[]
|
||||
}
|
||||
|
||||
export interface HostHardwareStatus {
|
||||
id?: string
|
||||
device_id?: string
|
||||
status?: string
|
||||
power_status?: string
|
||||
cpu_status?: string
|
||||
memory_status?: string
|
||||
disk_status?: string
|
||||
fan_status?: string
|
||||
temperature_status?: string
|
||||
network_status?: string
|
||||
psu_status?: string
|
||||
raid_status?: string
|
||||
last_check_time?: string
|
||||
error_message?: string
|
||||
raw_data?: string
|
||||
}
|
||||
|
||||
export interface HostHardwareMetricsRow {
|
||||
id?: string
|
||||
device_id?: string
|
||||
metric_name: string
|
||||
metric_type: string
|
||||
metric_value: number
|
||||
unit?: string
|
||||
status?: string
|
||||
threshold?: number
|
||||
location?: string
|
||||
collection_time?: string
|
||||
}
|
||||
|
||||
export interface HostHardwareLatestCollectionPayload {
|
||||
device_id: string
|
||||
collected_at?: string | null
|
||||
status: HostHardwareStatus | null
|
||||
metrics: HostHardwareMetricsRow[]
|
||||
timescaledb?: boolean
|
||||
message?: string
|
||||
server_identity?: string
|
||||
}
|
||||
|
||||
interface RawCollectionMetric {
|
||||
name?: string
|
||||
type?: string
|
||||
value?: number
|
||||
unit?: string
|
||||
status?: string
|
||||
threshold?: number
|
||||
location?: string
|
||||
}
|
||||
|
||||
interface RawCollectionRoot {
|
||||
metrics?: RawCollectionMetric[]
|
||||
}
|
||||
|
||||
export interface NormalizedHostHardwareMetric {
|
||||
name: string
|
||||
type: string
|
||||
value: number
|
||||
unit: string
|
||||
status: string
|
||||
threshold?: number
|
||||
location?: string
|
||||
}
|
||||
|
||||
export interface HostHardwareMetricHistoryPayload {
|
||||
device_id: string
|
||||
metric_name: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
data: HostHardwareMetricsRow[]
|
||||
timescaledb?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface HostHardwareStatisticsRow {
|
||||
device_id: string
|
||||
stat_date: string
|
||||
online_time?: number
|
||||
offline_time?: number
|
||||
warning_count?: number
|
||||
critical_count?: number
|
||||
avg_temperature?: number
|
||||
max_temperature?: number
|
||||
min_temperature?: number
|
||||
avg_fan_speed?: number
|
||||
avg_power_usage?: number
|
||||
max_power_usage?: number
|
||||
total_check_count?: number
|
||||
success_check_count?: number
|
||||
failed_check_count?: number
|
||||
availability?: number
|
||||
}
|
||||
|
||||
export function isHostHardwareApiSuccess(res: HostHardwareApiEnvelope | null | undefined): boolean {
|
||||
const c = res?.code
|
||||
if (c === 0 || c === '0') return true
|
||||
// 少数网关/代理以 200 表示业务成功
|
||||
if (c === 200 || c === '200') return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function unwrapHostHardwareDetails<T>(
|
||||
res: (HostHardwareApiEnvelope<T> & { data?: T }) | null | undefined,
|
||||
): T | null {
|
||||
if (!res || !isHostHardwareApiSuccess(res)) return null
|
||||
// 部分网关/SDK 将载荷放在 data 而非 details,与 logs 等模块一致做兼容
|
||||
return res.details ?? res.data ?? null
|
||||
}
|
||||
|
||||
/** 按 server_identity 拉取最新一整轮采集(JWT) */
|
||||
export function fetchHostHardwareLatestCollection(serverIdentity: string) {
|
||||
return request.get<HostHardwareApiEnvelope<HostHardwareLatestCollectionPayload>>(
|
||||
`${HW_PREFIX}/devices/by-server-identity/collection/latest`,
|
||||
{ params: { server_identity: serverIdentity } },
|
||||
)
|
||||
}
|
||||
|
||||
/** 设备详情(含最新一条 status) */
|
||||
export function fetchHostHardwareDevice(deviceId: string) {
|
||||
return request.get<HostHardwareApiEnvelope<HostHardwareDeviceDetailPayload>>(
|
||||
`${HW_PREFIX}/devices/${encodeURIComponent(deviceId)}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 分页列表(可按 type / status / asset_id 筛选) */
|
||||
export function fetchHostHardwareDeviceList(params?: {
|
||||
type?: string
|
||||
status?: string
|
||||
asset_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
return request.get<HostHardwareApiEnvelope<HostHardwareDeviceListPayload>>(`${HW_PREFIX}/devices`, {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建设备 */
|
||||
export function createHostHardwareDevice(data: HostHardwareDeviceUpsert) {
|
||||
return request.post<HostHardwareApiEnvelope<HostHardwareDevice>>(`${HW_PREFIX}/devices`, data)
|
||||
}
|
||||
|
||||
/** 更新设备(全量必填字段;密码留空则不修改) */
|
||||
export function updateHostHardwareDevice(deviceId: string, data: HostHardwareDeviceUpsert) {
|
||||
return request.put<HostHardwareApiEnvelope<HostHardwareDevice>>(
|
||||
`${HW_PREFIX}/devices/${encodeURIComponent(deviceId)}`,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
/** 异步立即采集 */
|
||||
export function triggerHostHardwareCollect(deviceId: string) {
|
||||
return request.post<HostHardwareApiEnvelope<string>>(`${HW_PREFIX}/devices/${encodeURIComponent(deviceId)}/collect`)
|
||||
}
|
||||
|
||||
/** 启用监控 */
|
||||
export function enableHostHardwareDevice(deviceId: string) {
|
||||
return request.post<HostHardwareApiEnvelope<string>>(`${HW_PREFIX}/devices/${encodeURIComponent(deviceId)}/enable`)
|
||||
}
|
||||
|
||||
/** 禁用监控 */
|
||||
export function disableHostHardwareDevice(deviceId: string) {
|
||||
return request.post<HostHardwareApiEnvelope<string>>(`${HW_PREFIX}/devices/${encodeURIComponent(deviceId)}/disable`)
|
||||
}
|
||||
|
||||
/** 单指标历史曲线(JWT) */
|
||||
export function fetchHostHardwareMetricHistory(
|
||||
deviceId: string,
|
||||
metricName: string,
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
) {
|
||||
return request.get<HostHardwareApiEnvelope<HostHardwareMetricHistoryPayload>>(
|
||||
`${HW_PREFIX}/metrics/devices/${encodeURIComponent(deviceId)}/history`,
|
||||
{
|
||||
params: {
|
||||
metric_name: metricName,
|
||||
...(startTime ? { start_time: startTime } : {}),
|
||||
...(endTime ? { end_time: endTime } : {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 日汇总统计 */
|
||||
export function fetchHostHardwareStatistics(
|
||||
deviceId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
) {
|
||||
return request.get<HostHardwareApiEnvelope<HostHardwareStatisticsRow[]>>(
|
||||
`${HW_PREFIX}/metrics/statistics`,
|
||||
{
|
||||
params: {
|
||||
device_id: deviceId,
|
||||
...(startDate ? { start_date: startDate } : {}),
|
||||
...(endDate ? { end_date: endDate } : {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 API metrics 与 raw_data 兜底(无时序库时 metrics 可能为空)
|
||||
*/
|
||||
export function normalizeHostHardwareMetrics(
|
||||
status: HostHardwareStatus | null | undefined,
|
||||
metricsFromApi: HostHardwareMetricsRow[] | null | undefined,
|
||||
): NormalizedHostHardwareMetric[] {
|
||||
const fromApi = metricsFromApi ?? []
|
||||
if (fromApi.length > 0) {
|
||||
return fromApi.map((m) => ({
|
||||
name: m.metric_name,
|
||||
type: m.metric_type,
|
||||
value: m.metric_value,
|
||||
unit: m.unit ?? '',
|
||||
status: m.status ?? 'ok',
|
||||
threshold: m.threshold,
|
||||
location: m.location,
|
||||
}))
|
||||
}
|
||||
const raw = status?.raw_data
|
||||
if (!raw || typeof raw !== 'string') return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as RawCollectionRoot
|
||||
const arr = parsed.metrics
|
||||
if (!Array.isArray(arr)) return []
|
||||
return arr
|
||||
.map((m) => ({
|
||||
name: String(m.name ?? ''),
|
||||
type: String(m.type ?? ''),
|
||||
value: typeof m.value === 'number' ? m.value : Number(m.value) || 0,
|
||||
unit: String(m.unit ?? ''),
|
||||
status: String(m.status ?? 'ok'),
|
||||
threshold: m.threshold,
|
||||
location: m.location ? String(m.location) : undefined,
|
||||
}))
|
||||
.filter((m) => m.name || m.type)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,22 @@ export const localMenuFlatItems: MenuItem[] = [
|
||||
sort_key: 13.5,
|
||||
created_at: '2026-04-11T10:00:00+08:00',
|
||||
},
|
||||
{
|
||||
id: 12021,
|
||||
identity: '019c7100-0001-7000-8000-000000000021',
|
||||
title: '服务器硬件监控',
|
||||
title_en: 'Server Hardware (OOB)',
|
||||
code: 'ops:综合监控:服务器硬件监控',
|
||||
description: '综合监控 - 服务器带外硬件(BMC/IPMI/Redfish 等,DC-Hardware)',
|
||||
app_id: 2,
|
||||
parent_id: 23,
|
||||
menu_path: '/monitor/host-hardware',
|
||||
menu_icon: 'appstore',
|
||||
component: 'ops/pages/monitor/host-hardware',
|
||||
type: 1,
|
||||
sort_key: 13.52,
|
||||
created_at: '2026-04-13T10:00:00+08:00',
|
||||
},
|
||||
{
|
||||
id: 27,
|
||||
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
|
||||
|
||||
@@ -243,6 +243,23 @@ export const localMenuItems: MenuItem[] = [
|
||||
created_at: '2026-04-11T10:00:00+08:00',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 12021,
|
||||
identity: '019c7100-0001-7000-8000-000000000021',
|
||||
title: '服务器硬件监控',
|
||||
title_en: 'Server Hardware (OOB)',
|
||||
code: 'ops:综合监控:服务器硬件监控',
|
||||
description: '综合监控 - 服务器带外硬件(BMC/IPMI/Redfish 等,DC-Hardware)',
|
||||
app_id: 2,
|
||||
parent_id: 23,
|
||||
menu_path: '/monitor/host-hardware',
|
||||
menu_icon: 'appstore',
|
||||
component: 'ops/pages/monitor/host-hardware',
|
||||
type: 1,
|
||||
sort_key: 5,
|
||||
created_at: '2026-04-13T10:00:00+08:00',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 27,
|
||||
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="modalTitle"
|
||||
:width="940"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="submitLoading"
|
||||
:ok-text="okText"
|
||||
:cancel-text="'取消'"
|
||||
:ok-button-props="{ disabled: blockedNoIdentity || loading }"
|
||||
unmount-on-close
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="(v: boolean) => emit('update:visible', v)"
|
||||
>
|
||||
<div class="modal-scroll">
|
||||
<a-spin :loading="loading" style="width: 100%; min-height: 200px">
|
||||
<div v-if="!loading && blockedNoIdentity" class="blocked-wrap">
|
||||
<a-alert type="warning" show-icon>
|
||||
当前服务器未配置唯一标识。请先在「编辑服务器」中填写并保存,以便与 DC-Hardware 带外设备关联。
|
||||
</a-alert>
|
||||
</div>
|
||||
|
||||
<template v-else-if="!loading">
|
||||
|
||||
|
||||
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
|
||||
<a-collapse :default-active-key="['base', 'proto', 'collect']" :bordered="false">
|
||||
<a-collapse-item key="base" header="基础信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="管理 IP(BMC / 带外)">
|
||||
<span class="readonly-field">{{ managementIp || '—' }}</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item field="description" label="备注">
|
||||
<a-textarea v-model="form.description" :rows="2" placeholder="可选" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item field="tags" label="标签">
|
||||
<a-input v-model="form.tags" placeholder="可为 JSON 字符串,如实体传感器配置等" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-collapse-item>
|
||||
|
||||
<a-collapse-item key="proto" header="采集协议与连接">
|
||||
<a-form-item field="protocol" label="协议" required>
|
||||
<a-radio-group
|
||||
:model-value="form.protocol"
|
||||
type="button"
|
||||
@update:model-value="onProtocolModelUpdate"
|
||||
>
|
||||
<a-radio value="ipmi">IPMI</a-radio>
|
||||
<a-radio value="snmp">SNMP</a-radio>
|
||||
<a-radio value="redfish">Redfish</a-radio>
|
||||
</a-radio-group>
|
||||
<template #extra>
|
||||
<span class="form-extra">切换协议后请填写对应区域的认证与端口;默认值:IPMI 623、SNMP 161</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<!-- IPMI / Redfish 共用账号 -->
|
||||
<template v-if="form.protocol === 'ipmi' || form.protocol === 'redfish'">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="username" label="用户名">
|
||||
<a-input v-model="form.username" placeholder="BMC 用户名" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="password" label="密码">
|
||||
<a-input-password v-model="form.password" :placeholder="passwordPlaceholder" allow-clear />
|
||||
<template #extra>
|
||||
<span class="form-extra">{{ passwordHint }}</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<template v-if="form.protocol === 'ipmi'">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item field="port" label="IPMI 端口">
|
||||
<a-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="ipmi_timeout_seconds" label="超时(秒)">
|
||||
<a-input-number v-model="form.ipmi_timeout_seconds" :min="0" style="width: 100%" />
|
||||
<template #extra><span class="form-extra">0 表示使用服务默认</span></template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="启用 IPMI 采集">
|
||||
<a-switch v-model="form.ipmi_collect_enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<template v-if="form.protocol === 'snmp'">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item field="snmp_port" label="SNMP 端口">
|
||||
<a-input-number v-model="form.snmp_port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="snmp_version" label="SNMP 版本">
|
||||
<a-select v-model="form.snmp_version" allow-clear>
|
||||
<a-option value="v2c">v2c</a-option>
|
||||
<a-option value="v1">v1</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="snmp_timeout_seconds" label="超时(秒)">
|
||||
<a-input-number v-model="form.snmp_timeout_seconds" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="community" label="Community 共享口令">
|
||||
<a-input v-model="form.community" placeholder="v2c Community" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="启用 SNMP 采集">
|
||||
<a-switch v-model="form.snmp_collect_enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<template v-if="form.protocol === 'redfish'">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="24">
|
||||
<a-form-item field="redfish_base_url" label="Redfish 根 URL">
|
||||
<a-input
|
||||
v-model="form.redfish_base_url"
|
||||
placeholder="留空则默认 https://{管理IP}/redfish/v1"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="跳过 TLS 校验">
|
||||
<a-switch v-model="form.redfish_tls_skip_verify" />
|
||||
<template #extra><span class="form-extra">仅内网测试环境建议开启</span></template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="redfish_timeout_seconds" label="超时(秒)">
|
||||
<a-input-number v-model="form.redfish_timeout_seconds" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="启用 Redfish 采集">
|
||||
<a-switch v-model="form.redfish_collect_enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</a-collapse-item>
|
||||
|
||||
<a-collapse-item key="collect" header="采集调度与扩展">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number v-model="form.collect_interval" :min="0" style="width: 100%" />
|
||||
<template #extra>
|
||||
<span class="form-extra">0 或留空表示使用服务默认间隔(YAML)</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="isEdit && hwEnabled !== undefined" :span="12">
|
||||
<a-form-item label="监控开关(只读)">
|
||||
<a-space>
|
||||
<a-tag :color="hwEnabled ? 'green' : 'gray'">{{ hwEnabled ? '已启用' : '已禁用' }}</a-tag>
|
||||
<span class="form-extra">保存后可用下方快捷操作切换</span>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item field="extra_config" label="扩展配置(JSON 字符串)">
|
||||
<a-textarea
|
||||
v-model="form.extra_config"
|
||||
:rows="4"
|
||||
placeholder='可选,非空须为合法 JSON,如 {"key":"value"}'
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-collapse-item>
|
||||
</a-collapse>
|
||||
|
||||
<a-divider v-if="isEdit && deviceId" orientation="left">快捷操作</a-divider>
|
||||
<a-space v-if="isEdit && deviceId" wrap>
|
||||
<a-button type="outline" :loading="actionLoading === 'collect'" @click="doCollect">
|
||||
立即采集
|
||||
</a-button>
|
||||
<a-button v-if="!hwEnabled" type="outline" status="success" :loading="actionLoading === 'enable'" @click="doEnable">
|
||||
启用监控
|
||||
</a-button>
|
||||
<a-button v-else type="outline" status="warning" :loading="actionLoading === 'disable'" @click="doDisable">
|
||||
禁用监控
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</template>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance, FieldRule } from '@arco-design/web-vue'
|
||||
import type { ServerItem } from '@/api/ops/server'
|
||||
import {
|
||||
fetchHostHardwareLatestCollection,
|
||||
fetchHostHardwareDevice,
|
||||
fetchHostHardwareDeviceList,
|
||||
createHostHardwareDevice,
|
||||
updateHostHardwareDevice,
|
||||
triggerHostHardwareCollect,
|
||||
enableHostHardwareDevice,
|
||||
disableHostHardwareDevice,
|
||||
isHostHardwareApiSuccess,
|
||||
unwrapHostHardwareDetails,
|
||||
type HostHardwareDevice,
|
||||
type HostHardwareDeviceUpsert,
|
||||
} from '@/api/ops/host-hardware'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record: ServerItem | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const blockedNoIdentity = ref(false)
|
||||
const deviceId = ref<string | null>(null)
|
||||
const hwEnabled = ref<boolean | undefined>(undefined)
|
||||
const actionLoading = ref<'collect' | 'enable' | 'disable' | ''>('')
|
||||
|
||||
/** 防止多次触发 loadHardware 时,较早返回的请求覆盖用户已切换的协议/表单 */
|
||||
let hardwareLoadGeneration = 0
|
||||
|
||||
function onProtocolModelUpdate(v: 'ipmi' | 'snmp' | 'redfish') {
|
||||
form.protocol = v
|
||||
}
|
||||
|
||||
const isEdit = computed(() => !!deviceId.value)
|
||||
|
||||
/** 已存在 DC-Hardware 设备时为「修改」,否则为「新增」 */
|
||||
const modalTitle = computed(() => {
|
||||
if (loading.value) return '硬件设备配置'
|
||||
if (blockedNoIdentity.value) return '硬件设备配置'
|
||||
return deviceId.value ? '修改硬件设备配置' : '新增硬件设备配置'
|
||||
})
|
||||
|
||||
const okText = computed(() => {
|
||||
if (loading.value || blockedNoIdentity.value) return '保存'
|
||||
return deviceId.value ? '保存修改' : '保存'
|
||||
})
|
||||
|
||||
/** 与列表行一致:ip_address 优先,否则 host(同服务器管理列表) */
|
||||
const managementIp = computed(() => {
|
||||
const r = props.record
|
||||
if (!r) return ''
|
||||
return (r.ip_address || r.host || '').trim()
|
||||
})
|
||||
|
||||
/** 与接口对齐:名称/位置/关联键来自服务器档案;类型与资产 ID 在编辑时来自已加载设备 */
|
||||
const hardwareType = ref('server')
|
||||
const hardwareAssetId = ref('')
|
||||
|
||||
const passwordPlaceholder = computed(() =>
|
||||
isEdit.value ? '留空则不修改已保存的密码' : 'BMC / Redfish 密码',
|
||||
)
|
||||
|
||||
const passwordHint = computed(() =>
|
||||
isEdit.value ? '编辑时留空将保留原密码' : '按机房安全要求妥善保管',
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
protocol: 'ipmi' as 'ipmi' | 'snmp' | 'redfish',
|
||||
username: '',
|
||||
password: '',
|
||||
port: 623,
|
||||
snmp_port: 161,
|
||||
community: '',
|
||||
snmp_version: 'v2c',
|
||||
redfish_base_url: '',
|
||||
redfish_tls_skip_verify: false,
|
||||
description: '',
|
||||
tags: '',
|
||||
extra_config: '',
|
||||
ipmi_timeout_seconds: 0,
|
||||
snmp_timeout_seconds: 0,
|
||||
redfish_timeout_seconds: 0,
|
||||
ipmi_collect_enabled: true,
|
||||
snmp_collect_enabled: true,
|
||||
redfish_collect_enabled: true,
|
||||
collect_interval: 0,
|
||||
})
|
||||
|
||||
const rules: Record<string, FieldRule | FieldRule[]> = {
|
||||
protocol: [{ required: true, message: '请选择协议' }],
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
hardwareType.value = 'server'
|
||||
hardwareAssetId.value = ''
|
||||
form.protocol = 'ipmi'
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.port = 623
|
||||
form.snmp_port = 161
|
||||
form.community = ''
|
||||
form.snmp_version = 'v2c'
|
||||
form.redfish_base_url = ''
|
||||
form.redfish_tls_skip_verify = false
|
||||
form.description = ''
|
||||
form.tags = ''
|
||||
form.extra_config = ''
|
||||
form.ipmi_timeout_seconds = 0
|
||||
form.snmp_timeout_seconds = 0
|
||||
form.redfish_timeout_seconds = 0
|
||||
form.ipmi_collect_enabled = true
|
||||
form.snmp_collect_enabled = true
|
||||
form.redfish_collect_enabled = true
|
||||
form.collect_interval = 0
|
||||
}
|
||||
|
||||
function applyFromServer(r: ServerItem) {
|
||||
resetForm()
|
||||
form.description = r.description || ''
|
||||
form.tags = r.tags || ''
|
||||
}
|
||||
|
||||
function applyFromDevice(d: HostHardwareDevice) {
|
||||
resetForm()
|
||||
hardwareType.value = (d.type || 'server').trim() || 'server'
|
||||
hardwareAssetId.value = (d.asset_id || '').trim()
|
||||
form.protocol = (d.protocol as typeof form.protocol) || 'ipmi'
|
||||
form.username = d.username || ''
|
||||
form.password = ''
|
||||
form.port = d.port ?? 623
|
||||
form.snmp_port = d.snmp_port ?? 161
|
||||
form.community = d.community || ''
|
||||
form.snmp_version = d.snmp_version || 'v2c'
|
||||
form.redfish_base_url = d.redfish_base_url || ''
|
||||
form.redfish_tls_skip_verify = !!d.redfish_tls_skip_verify
|
||||
form.description = d.description || ''
|
||||
form.tags = d.tags || ''
|
||||
form.extra_config = d.extra_config || ''
|
||||
form.ipmi_timeout_seconds = d.ipmi_timeout_seconds ?? 0
|
||||
form.snmp_timeout_seconds = d.snmp_timeout_seconds ?? 0
|
||||
form.redfish_timeout_seconds = d.redfish_timeout_seconds ?? 0
|
||||
form.ipmi_collect_enabled = d.ipmi_collect_enabled !== false
|
||||
form.snmp_collect_enabled = d.snmp_collect_enabled !== false
|
||||
form.redfish_collect_enabled = d.redfish_collect_enabled !== false
|
||||
form.collect_interval = d.collect_interval ?? 0
|
||||
hwEnabled.value = d.enabled
|
||||
}
|
||||
|
||||
function validateExtraConfigJson(): boolean {
|
||||
const s = form.extra_config?.trim()
|
||||
if (!s) return true
|
||||
try {
|
||||
JSON.parse(s)
|
||||
return true
|
||||
} catch {
|
||||
Message.error('扩展配置须为合法 JSON')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(): HostHardwareDeviceUpsert {
|
||||
const r = props.record
|
||||
const name = (r?.name || '').trim()
|
||||
const serverIdentity = (r?.server_identity || '').trim()
|
||||
const location = (r?.location || '').trim()
|
||||
const p: HostHardwareDeviceUpsert = {
|
||||
name,
|
||||
ip: managementIp.value,
|
||||
type: (hardwareType.value || 'server').trim() || 'server',
|
||||
protocol: form.protocol,
|
||||
username: form.username?.trim() || undefined,
|
||||
port: form.port,
|
||||
snmp_port: form.snmp_port,
|
||||
community: form.community?.trim() || undefined,
|
||||
snmp_version: form.snmp_version?.trim() || undefined,
|
||||
redfish_base_url: form.redfish_base_url?.trim() || undefined,
|
||||
redfish_tls_skip_verify: form.redfish_tls_skip_verify,
|
||||
location: location || undefined,
|
||||
description: form.description?.trim() || undefined,
|
||||
tags: form.tags?.trim() || undefined,
|
||||
asset_id: hardwareAssetId.value?.trim() || undefined,
|
||||
server_identity: serverIdentity || undefined,
|
||||
extra_config: form.extra_config?.trim() || undefined,
|
||||
ipmi_timeout_seconds: form.ipmi_timeout_seconds,
|
||||
snmp_timeout_seconds: form.snmp_timeout_seconds,
|
||||
redfish_timeout_seconds: form.redfish_timeout_seconds,
|
||||
ipmi_collect_enabled: form.ipmi_collect_enabled,
|
||||
snmp_collect_enabled: form.snmp_collect_enabled,
|
||||
redfish_collect_enabled: form.redfish_collect_enabled,
|
||||
collect_interval: form.collect_interval,
|
||||
}
|
||||
const pw = form.password?.trim()
|
||||
if (pw) {
|
||||
p.password = pw
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
/** 按设备 id 拉详情并写入表单(gen 用于丢弃过期请求,避免覆盖用户已改的协议) */
|
||||
async function loadDeviceDetailIntoForm(id: string, gen: number): Promise<boolean> {
|
||||
const devRes = await fetchHostHardwareDevice(id)
|
||||
if (gen !== hardwareLoadGeneration) return false
|
||||
if (!isHostHardwareApiSuccess(devRes)) return false
|
||||
const detail = unwrapHostHardwareDetails(devRes)
|
||||
const dev = detail?.device
|
||||
if (!dev?.id) return false
|
||||
if (gen !== hardwareLoadGeneration) return false
|
||||
deviceId.value = dev.id
|
||||
applyFromDevice(dev)
|
||||
return true
|
||||
}
|
||||
|
||||
/** collection 接口失败或缺 device_id 时,按 server_identity 在列表中查找设备再拉详情 */
|
||||
async function loadDeviceByServerIdentityFallback(sid: string, gen: number): Promise<boolean> {
|
||||
const listRes = await fetchHostHardwareDeviceList({ page: 1, page_size: 500 })
|
||||
if (gen !== hardwareLoadGeneration) return false
|
||||
if (!isHostHardwareApiSuccess(listRes)) return false
|
||||
const payload = unwrapHostHardwareDetails(listRes)
|
||||
const rows = payload?.data ?? []
|
||||
const found = rows.find((d) => (d.server_identity || '').trim() === sid)
|
||||
if (!found?.id) return false
|
||||
return loadDeviceDetailIntoForm(found.id, gen)
|
||||
}
|
||||
|
||||
async function loadHardware() {
|
||||
const gen = ++hardwareLoadGeneration
|
||||
deviceId.value = null
|
||||
hwEnabled.value = undefined
|
||||
blockedNoIdentity.value = false
|
||||
if (!props.record) {
|
||||
return
|
||||
}
|
||||
const sid = (props.record.server_identity || '').trim()
|
||||
if (!sid) {
|
||||
blockedNoIdentity.value = true
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
applyFromServer(props.record)
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const colRes = await fetchHostHardwareLatestCollection(sid)
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
if (isHostHardwareApiSuccess(colRes)) {
|
||||
const col = unwrapHostHardwareDetails(colRes)
|
||||
const did = col?.device_id
|
||||
if (did) {
|
||||
const detailOk = await loadDeviceDetailIntoForm(did, gen)
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
if (detailOk) return
|
||||
}
|
||||
}
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
if (await loadDeviceByServerIdentityFallback(sid, gen)) {
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
return
|
||||
}
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
applyFromServer(props.record)
|
||||
} catch {
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
if (await loadDeviceByServerIdentityFallback(sid, gen)) {
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
return
|
||||
}
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
applyFromServer(props.record)
|
||||
} finally {
|
||||
if (gen === hardwareLoadGeneration) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible && props.record) {
|
||||
formRef.value?.clearValidate()
|
||||
loadHardware()
|
||||
}
|
||||
if (!visible) {
|
||||
hardwareLoadGeneration += 1
|
||||
loading.value = false
|
||||
resetForm()
|
||||
deviceId.value = null
|
||||
blockedNoIdentity.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (blockedNoIdentity.value) {
|
||||
Message.warning('请先在编辑服务器中配置唯一标识')
|
||||
return
|
||||
}
|
||||
const validateErrors = await formRef.value?.validate()
|
||||
if (validateErrors) {
|
||||
return
|
||||
}
|
||||
if (!validateExtraConfigJson()) return
|
||||
|
||||
const r = props.record
|
||||
if (!(r?.name || '').trim()) {
|
||||
Message.error('服务器名称为空,请先在编辑服务器中填写名称')
|
||||
return
|
||||
}
|
||||
if (!managementIp.value) {
|
||||
Message.error('当前服务器未配置 IP 或主机地址,请先在编辑服务器中维护后再保存硬件配置')
|
||||
return
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
let res: { code?: number | string; message?: string }
|
||||
if (deviceId.value) {
|
||||
res = await updateHostHardwareDevice(deviceId.value, payload) as typeof res
|
||||
} else {
|
||||
res = await createHostHardwareDevice(payload) as typeof res
|
||||
}
|
||||
if (isHostHardwareApiSuccess(res)) {
|
||||
Message.success(deviceId.value ? '修改成功' : '保存成功')
|
||||
emit('success')
|
||||
emit('update:visible', false)
|
||||
} else {
|
||||
Message.error((res as { message?: string }).message || '保存失败')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { message?: string } }; message?: string })?.response?.data?.message
|
||||
|| (e as { message?: string })?.message
|
||||
Message.error(msg || '保存失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
async function doCollect() {
|
||||
if (!deviceId.value) return
|
||||
actionLoading.value = 'collect'
|
||||
try {
|
||||
const res = await triggerHostHardwareCollect(deviceId.value)
|
||||
if (isHostHardwareApiSuccess(res)) {
|
||||
Message.success('采集任务已启动')
|
||||
} else {
|
||||
Message.error((res as { message?: string }).message || '操作失败')
|
||||
}
|
||||
} catch {
|
||||
Message.error('请求失败')
|
||||
} finally {
|
||||
actionLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doEnable() {
|
||||
if (!deviceId.value) return
|
||||
actionLoading.value = 'enable'
|
||||
try {
|
||||
const res = await enableHostHardwareDevice(deviceId.value)
|
||||
if (isHostHardwareApiSuccess(res)) {
|
||||
Message.success('已启用监控')
|
||||
hwEnabled.value = true
|
||||
} else {
|
||||
Message.error((res as { message?: string }).message || '操作失败')
|
||||
}
|
||||
} catch {
|
||||
Message.error('请求失败')
|
||||
} finally {
|
||||
actionLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doDisable() {
|
||||
if (!deviceId.value) return
|
||||
actionLoading.value = 'disable'
|
||||
try {
|
||||
const res = await disableHostHardwareDevice(deviceId.value)
|
||||
if (isHostHardwareApiSuccess(res)) {
|
||||
Message.success('已禁用监控')
|
||||
hwEnabled.value = false
|
||||
} else {
|
||||
Message.error((res as { message?: string }).message || '操作失败')
|
||||
}
|
||||
} catch {
|
||||
Message.error('请求失败')
|
||||
} finally {
|
||||
actionLoading.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'HardwareDeviceConfigDialog',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.form-extra {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.blocked-wrap {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.readonly-field {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-scroll {
|
||||
max-height: min(72vh, 720px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -143,6 +143,12 @@
|
||||
</template>
|
||||
详情
|
||||
</a-doption> -->
|
||||
<a-doption @click="handleHardwareDeviceConfig(record)">
|
||||
<template #icon>
|
||||
<icon-storage />
|
||||
</template>
|
||||
硬件设备配置
|
||||
</a-doption>
|
||||
<a-doption @click="handleEdit(record)">
|
||||
<template #icon>
|
||||
<icon-edit />
|
||||
@@ -180,6 +186,12 @@
|
||||
:record="currentRecord"
|
||||
@success="handleFormSuccess"
|
||||
/>
|
||||
|
||||
<HardwareDeviceConfigDialog
|
||||
v-model:visible="hardwareConfigVisible"
|
||||
:record="currentRecord"
|
||||
@success="handleHardwareConfigSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -195,13 +207,15 @@ import {
|
||||
IconDelete,
|
||||
IconRefresh,
|
||||
IconEye,
|
||||
IconSettings
|
||||
IconSettings,
|
||||
IconStorage,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import { searchFormConfig } from './config/search-form'
|
||||
import ServerFormDialog from './components/ServerFormDialog.vue'
|
||||
import QuickConfigDialog from './components/QuickConfigDialog.vue'
|
||||
import HardwareDeviceConfigDialog from './components/HardwareDeviceConfigDialog.vue'
|
||||
import { columns as columnsConfig } from './config/columns'
|
||||
import {
|
||||
fetchServerList,
|
||||
@@ -221,6 +235,7 @@ const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const formDialogVisible = ref(false)
|
||||
const quickConfigVisible = ref(false)
|
||||
const hardwareConfigVisible = ref(false)
|
||||
const currentRecord = ref<any>(null)
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
@@ -389,6 +404,16 @@ const handleQuickConfig = (record: any) => {
|
||||
quickConfigVisible.value = true
|
||||
}
|
||||
|
||||
// DC-Hardware 带外硬件设备配置
|
||||
const handleHardwareDeviceConfig = (record: any) => {
|
||||
currentRecord.value = record
|
||||
hardwareConfigVisible.value = true
|
||||
}
|
||||
|
||||
const handleHardwareConfigSuccess = () => {
|
||||
fetchServers()
|
||||
}
|
||||
|
||||
// 编辑服务器
|
||||
const handleEdit = (record: any) => {
|
||||
currentRecord.value = record
|
||||
|
||||
736
src/views/ops/pages/monitor/host-hardware/index.vue
Normal file
736
src/views/ops/pages/monitor/host-hardware/index.vue
Normal file
@@ -0,0 +1,736 @@
|
||||
<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="loading" style="width: 100%">
|
||||
<div v-if="!selectedServerIdentity" class="hw-empty">
|
||||
<a-empty description="请先选择服务器" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="notConfigured" class="hw-empty">
|
||||
<a-empty :description="notConfiguredHint">
|
||||
<template v-if="hardwareError" #extra>
|
||||
<span class="text-muted">{{ hardwareError }}</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<a-alert
|
||||
v-if="statusSnapshot?.error_message"
|
||||
type="warning"
|
||||
:title="statusSnapshot.error_message"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<a-card title="带外设备与采集摘要" :bordered="false" class="hw-card">
|
||||
<a-descriptions :column="{ xs: 1, sm: 2, md: 3 }" size="small" layout="horizontal">
|
||||
<a-descriptions-item label="设备名称">
|
||||
{{ deviceMeta?.name || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="管理 IP">
|
||||
{{ deviceMeta?.ip || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="协议">
|
||||
{{ deviceMeta?.protocol || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">
|
||||
{{ deviceTypeLabel(deviceMeta?.type) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="厂商">
|
||||
{{ deviceMeta?.manufacturer || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="型号">
|
||||
{{ deviceMeta?.model || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="序列号">
|
||||
{{ deviceMeta?.serial_number || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集间隔(秒)">
|
||||
{{ intervalLabel }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="启用">
|
||||
<a-tag v-if="deviceMeta" size="small" :color="deviceMeta.enabled ? 'green' : 'red'">
|
||||
{{ deviceMeta.enabled ? '是' : '否' }}
|
||||
</a-tag>
|
||||
<span v-else>—</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最近采集时间">
|
||||
{{ collectedAtLabel }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="整体健康">
|
||||
<a-tag v-if="statusSnapshot?.status" size="small" :color="healthColor(statusSnapshot.status)">
|
||||
{{ statusSnapshot.status }}
|
||||
</a-tag>
|
||||
<span v-else>—</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="时序库">
|
||||
<a-tag size="small" :color="timescaledb ? 'arcoblue' : 'gray'">
|
||||
{{ timescaledb ? '已配置' : '未配置' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="健康分项" :bordered="false" class="hw-card">
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col v-for="item in subStatusItems" :key="item.key" :xs="12" :sm="8" :md="6" :lg="4">
|
||||
<div class="hw-sub-item">
|
||||
<div class="hw-sub-label">{{ item.label }}</div>
|
||||
<a-tag v-if="item.value" size="small" :color="healthColor(item.value)">
|
||||
{{ item.value }}
|
||||
</a-tag>
|
||||
<span v-else class="hw-sub-dash">—</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="statisticsSummary" title="近 7 日汇总(最新日)" :bordered="false" class="hw-card">
|
||||
<a-row :gutter="16">
|
||||
<a-col v-for="cell in statisticsSummary" :key="cell.label" :xs="12" :sm="8" :md="6">
|
||||
<div class="hw-stat-cell">
|
||||
<div class="hw-stat-label">{{ cell.label }}</div>
|
||||
<div class="hw-stat-value">{{ cell.value }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<template v-if="groupedMetrics.length">
|
||||
<a-card
|
||||
v-for="g in groupedMetrics"
|
||||
:key="g.type"
|
||||
:title="g.title"
|
||||
:bordered="false"
|
||||
class="hw-card"
|
||||
>
|
||||
<a-table
|
||||
:columns="metricColumns"
|
||||
:data="g.rows"
|
||||
:pagination="false"
|
||||
row-key="__key"
|
||||
size="small"
|
||||
:bordered="{ cell: true }"
|
||||
>
|
||||
<template #status="{ record }">
|
||||
<a-tag size="small" :color="healthColor(record.status)">
|
||||
{{ record.status || '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</template>
|
||||
<a-card v-else title="指标明细" :bordered="false" class="hw-card">
|
||||
<a-empty description="暂无指标数据(请确认采集成功或查看原始 JSON)" />
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="timescaledb && deviceId && metricNameOptions.length" title="单指标趋势" :bordered="false" class="hw-card">
|
||||
<div class="hw-chart-toolbar">
|
||||
<a-select
|
||||
v-model="selectedMetricName"
|
||||
allow-search
|
||||
:style="{ width: '280px' }"
|
||||
placeholder="选择指标"
|
||||
:options="metricNameOptions"
|
||||
/>
|
||||
<span class="text-muted">默认最近 24 小时</span>
|
||||
</div>
|
||||
<a-spin :loading="historyLoading">
|
||||
<div class="hw-chart-wrap">
|
||||
<Chart v-if="historyChartOptions" :options="historyChartOptions" height="280px" />
|
||||
<a-empty v-else description="暂无曲线数据" />
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
<a-card v-else-if="deviceId && !timescaledb" title="单指标趋势" :bordered="false" class="hw-card">
|
||||
<p class="text-muted">未配置 TimescaleDB,时序曲线不可用;可查看上方指标表或下方原始 JSON。</p>
|
||||
</a-card>
|
||||
|
||||
<a-card title="原始采集 JSON" :bordered="false" class="hw-card">
|
||||
<a-collapse>
|
||||
<a-collapse-item key="raw" header="展开查看 raw_data(排障)">
|
||||
<pre class="hw-raw-pre">{{ rawDataFormatted }}</pre>
|
||||
</a-collapse-item>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-spin>
|
||||
</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 Chart from '@/components/chart/index.vue'
|
||||
import { fetchServerList, type ServerItem } from '@/api/ops/server'
|
||||
import {
|
||||
fetchHostHardwareDevice,
|
||||
fetchHostHardwareLatestCollection,
|
||||
fetchHostHardwareMetricHistory,
|
||||
fetchHostHardwareStatistics,
|
||||
isHostHardwareApiSuccess,
|
||||
normalizeHostHardwareMetrics,
|
||||
unwrapHostHardwareDetails,
|
||||
type HostHardwareDevice,
|
||||
type HostHardwareLatestCollectionPayload,
|
||||
type HostHardwareStatisticsRow,
|
||||
type HostHardwareStatus,
|
||||
type NormalizedHostHardwareMetric,
|
||||
} from '@/api/ops/host-hardware'
|
||||
|
||||
interface ServerOption {
|
||||
server_identity: string
|
||||
name: string
|
||||
host: string
|
||||
ip: string
|
||||
os: string
|
||||
location: string
|
||||
}
|
||||
|
||||
const serverListLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
const notConfigured = ref(false)
|
||||
const hardwareError = ref('')
|
||||
const collection = ref<HostHardwareLatestCollectionPayload | null>(null)
|
||||
const deviceMeta = ref<HostHardwareDevice | null>(null)
|
||||
const historyLoading = ref(false)
|
||||
const statisticsRows = ref<HostHardwareStatisticsRow[]>([])
|
||||
const selectedMetricName = ref<string>('')
|
||||
const historyChartOptions = ref<Record<string, unknown> | null>(null)
|
||||
|
||||
const serverOptions = ref<ServerOption[]>([])
|
||||
const selectedServerIdentity = ref<string | undefined>(undefined)
|
||||
|
||||
const activeServer = computed(() =>
|
||||
serverOptions.value.find((s) => s.server_identity === selectedServerIdentity.value),
|
||||
)
|
||||
|
||||
/** 与 hardware_devices.type 一致:server/switch/storage 等 */
|
||||
function deviceTypeLabel(t?: string) {
|
||||
if (t === undefined || t === null || String(t).trim() === '') return '—'
|
||||
const raw = String(t).trim()
|
||||
const map: Record<string, string> = {
|
||||
server: '服务器',
|
||||
switch: '交换机',
|
||||
storage: '存储',
|
||||
other: '其它',
|
||||
}
|
||||
return map[raw] || raw
|
||||
}
|
||||
|
||||
const statusSnapshot = computed(() => collection.value?.status ?? null)
|
||||
const deviceId = computed(() => collection.value?.device_id ?? '')
|
||||
const timescaledb = computed(() => collection.value?.timescaledb === true)
|
||||
|
||||
const normalizedMetrics = computed<NormalizedHostHardwareMetric[]>(() =>
|
||||
normalizeHostHardwareMetrics(statusSnapshot.value, collection.value?.metrics),
|
||||
)
|
||||
|
||||
const metricNameOptions = computed(() => {
|
||||
const names = [...new Set(normalizedMetrics.value.map((m) => m.name).filter(Boolean))]
|
||||
return names.sort().map((n) => ({ label: n, value: n }))
|
||||
})
|
||||
|
||||
const collectedAtLabel = computed(() => {
|
||||
const t = collection.value?.collected_at || statusSnapshot.value?.last_check_time
|
||||
if (!t) return '—'
|
||||
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
|
||||
})
|
||||
|
||||
const intervalLabel = computed(() => {
|
||||
const n = deviceMeta.value?.collect_interval
|
||||
if (n === undefined || n === null) return '—'
|
||||
if (n <= 0) return '默认(服务端 YAML)'
|
||||
return String(n)
|
||||
})
|
||||
|
||||
const notConfiguredHint = computed(() => {
|
||||
if (hardwareError.value) return '无法加载服务器硬件监控数据'
|
||||
return '未配置带外监控:请在 DC-Hardware 中创建设备并填写与当前主机一致的 server_identity'
|
||||
})
|
||||
|
||||
const subStatusItems = computed(() => {
|
||||
const s = statusSnapshot.value
|
||||
const fields: { key: keyof HostHardwareStatus; label: string }[] = [
|
||||
{ key: 'power_status', label: '电源' },
|
||||
{ key: 'temperature_status', label: '温度' },
|
||||
{ key: 'fan_status', label: '风扇' },
|
||||
{ key: 'psu_status', label: 'PSU' },
|
||||
{ key: 'cpu_status', label: 'CPU' },
|
||||
{ key: 'memory_status', label: '内存' },
|
||||
{ key: 'disk_status', label: '磁盘' },
|
||||
{ key: 'network_status', label: '网络' },
|
||||
{ key: 'raid_status', label: 'RAID' },
|
||||
]
|
||||
return fields.map((f) => ({
|
||||
key: String(f.key),
|
||||
label: f.label,
|
||||
value: (s?.[f.key] as string | undefined) || '',
|
||||
}))
|
||||
})
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'temperature',
|
||||
'fan_speed',
|
||||
'voltage',
|
||||
'current',
|
||||
'power',
|
||||
'power_status',
|
||||
'network',
|
||||
'system',
|
||||
'uptime',
|
||||
]
|
||||
|
||||
function groupTitle(t: string): string {
|
||||
const map: Record<string, string> = {
|
||||
temperature: '温度',
|
||||
fan_speed: '风扇',
|
||||
voltage: '电压',
|
||||
current: '电流',
|
||||
power: '功耗',
|
||||
power_status: '电源状态',
|
||||
network: '网络',
|
||||
system: '系统',
|
||||
uptime: '运行时间',
|
||||
other: '其它',
|
||||
}
|
||||
return map[t] || t || '其它'
|
||||
}
|
||||
|
||||
const groupedMetrics = computed(() => {
|
||||
const rows = normalizedMetrics.value
|
||||
const map = new Map<string, NormalizedHostHardwareMetric[]>()
|
||||
for (const r of rows) {
|
||||
const t = r.type || 'other'
|
||||
const list = map.get(t) ?? []
|
||||
list.push(r)
|
||||
map.set(t, list)
|
||||
}
|
||||
const keys = [...map.keys()].sort((a, b) => {
|
||||
const ia = GROUP_ORDER.indexOf(a)
|
||||
const ib = GROUP_ORDER.indexOf(b)
|
||||
if (ia === -1 && ib === -1) return a.localeCompare(b)
|
||||
if (ia === -1) return 1
|
||||
if (ib === -1) return -1
|
||||
return ia - ib
|
||||
})
|
||||
return keys.map((k) => ({
|
||||
type: k,
|
||||
title: groupTitle(k),
|
||||
rows: map.get(k)!.map((m, idx) => ({
|
||||
__key: `${k}-${idx}-${m.name}-${m.location ?? ''}`,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
value: formatMetricValue(m.value),
|
||||
unit: m.unit || '—',
|
||||
location: m.location || '—',
|
||||
threshold: m.threshold !== undefined && m.threshold !== 0 ? String(m.threshold) : '—',
|
||||
status: m.status,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
const metricColumns = [
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true, width: 200 },
|
||||
{ title: '类型', dataIndex: 'type', width: 120 },
|
||||
{ title: '位置', dataIndex: 'location', ellipsis: true, width: 140 },
|
||||
{ title: '数值', dataIndex: 'value', width: 120 },
|
||||
{ title: '单位', dataIndex: 'unit', width: 80 },
|
||||
{ title: '阈值', dataIndex: 'threshold', width: 110 },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, slotName: 'status' },
|
||||
]
|
||||
|
||||
function formatMetricValue(v: number): string {
|
||||
if (Number.isInteger(v)) return String(v)
|
||||
if (Number.isNaN(v)) return '-'
|
||||
return v.toFixed(2)
|
||||
}
|
||||
|
||||
function healthColor(s: string): string {
|
||||
const x = (s || '').toLowerCase()
|
||||
if (x === 'ok' || x === 'online' || x === 'on') return 'green'
|
||||
if (x === 'warning') return 'orange'
|
||||
if (x === 'critical' || x === 'offline' || x === 'error') return 'red'
|
||||
if (x === 'unknown') return 'gray'
|
||||
return 'arcoblue'
|
||||
}
|
||||
|
||||
const rawDataFormatted = computed(() => {
|
||||
const raw = statusSnapshot.value?.raw_data
|
||||
if (!raw) return '—'
|
||||
try {
|
||||
const o = JSON.parse(raw)
|
||||
return JSON.stringify(o, null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})
|
||||
|
||||
const statisticsSummary = computed(() => {
|
||||
const rows = statisticsRows.value
|
||||
if (!rows.length) return null
|
||||
const latest = [...rows].sort((a, b) => {
|
||||
const ta = new Date(a.stat_date).getTime()
|
||||
const tb = new Date(b.stat_date).getTime()
|
||||
return tb - ta
|
||||
})[0]
|
||||
const cells: { label: string; value: string }[] = []
|
||||
const push = (label: string, v: number | undefined) => {
|
||||
if (v === undefined || v === null || Number.isNaN(v)) return
|
||||
cells.push({ label, value: String(v) })
|
||||
}
|
||||
push('平均温度', latest.avg_temperature)
|
||||
push('最高温度', latest.max_temperature)
|
||||
push('最低温度', latest.min_temperature)
|
||||
push('平均风扇转速', latest.avg_fan_speed)
|
||||
push('平均功耗', latest.avg_power_usage)
|
||||
push('最大功耗', latest.max_power_usage)
|
||||
push('可用率(%)', latest.availability)
|
||||
push('告警次数', latest.warning_count)
|
||||
push('严重次数', latest.critical_count)
|
||||
return cells.length ? cells : null
|
||||
})
|
||||
|
||||
async function loadStatistics(id: string) {
|
||||
try {
|
||||
const end = dayjs().format('YYYY-MM-DD')
|
||||
const start = dayjs().subtract(6, 'day').format('YYYY-MM-DD')
|
||||
const res = await fetchHostHardwareStatistics(id, start, end)
|
||||
const rows = unwrapHostHardwareDetails(res)
|
||||
statisticsRows.value = Array.isArray(rows) ? rows : []
|
||||
} catch {
|
||||
statisticsRows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const id = deviceId.value
|
||||
const name = selectedMetricName.value
|
||||
if (!id || !name || !timescaledb.value) {
|
||||
historyChartOptions.value = null
|
||||
return
|
||||
}
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const end = dayjs()
|
||||
const start = end.subtract(24, 'hour')
|
||||
const res = await fetchHostHardwareMetricHistory(id, name, start.toISOString(), end.toISOString())
|
||||
const payload = unwrapHostHardwareDetails(res)
|
||||
const points = payload?.data ?? []
|
||||
if (!points.length) {
|
||||
historyChartOptions.value = null
|
||||
return
|
||||
}
|
||||
const labels = points.map((p) => dayjs(p.collection_time).format('MM-DD HH:mm'))
|
||||
const vals = points.map((p) => p.metric_value)
|
||||
historyChartOptions.value = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: labels },
|
||||
yAxis: { type: 'value', name: points[0]?.unit || '' },
|
||||
series: [
|
||||
{
|
||||
name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: vals,
|
||||
lineStyle: { width: 2, color: '#165DFF' },
|
||||
itemStyle: { color: '#165DFF' },
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch {
|
||||
historyChartOptions.value = null
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
notConfigured.value = false
|
||||
hardwareError.value = ''
|
||||
collection.value = null
|
||||
deviceMeta.value = null
|
||||
statisticsRows.value = []
|
||||
historyChartOptions.value = null
|
||||
selectedMetricName.value = ''
|
||||
}
|
||||
|
||||
async function loadHostHardware() {
|
||||
const sid = selectedServerIdentity.value
|
||||
if (!sid) {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
hardwareError.value = ''
|
||||
try {
|
||||
const res = await fetchHostHardwareLatestCollection(sid)
|
||||
if (!isHostHardwareApiSuccess(res)) {
|
||||
notConfigured.value = true
|
||||
collection.value = null
|
||||
deviceMeta.value = null
|
||||
hardwareError.value = (res as { message?: string })?.message || ''
|
||||
return
|
||||
}
|
||||
const data = unwrapHostHardwareDetails(res)
|
||||
if (!data) {
|
||||
notConfigured.value = true
|
||||
return
|
||||
}
|
||||
notConfigured.value = false
|
||||
collection.value = data
|
||||
if (data.device_id) {
|
||||
const devRes = await fetchHostHardwareDevice(data.device_id)
|
||||
if (isHostHardwareApiSuccess(devRes)) {
|
||||
const devPayload = unwrapHostHardwareDetails(devRes)
|
||||
deviceMeta.value = devPayload?.device ?? null
|
||||
} else {
|
||||
deviceMeta.value = null
|
||||
}
|
||||
await loadStatistics(data.device_id)
|
||||
} else {
|
||||
deviceMeta.value = null
|
||||
statisticsRows.value = []
|
||||
}
|
||||
const names = [
|
||||
...new Set(
|
||||
normalizeHostHardwareMetrics(data.status, data.metrics).map((m) => m.name).filter(Boolean),
|
||||
),
|
||||
]
|
||||
selectedMetricName.value = names.sort()[0] || ''
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } }; message?: string }
|
||||
notConfigured.value = true
|
||||
collection.value = null
|
||||
deviceMeta.value = null
|
||||
hardwareError.value = err?.response?.data?.message || err?.message || '请求失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function filterServerOption(input: string, option: { label?: string }) {
|
||||
if (!input) return true
|
||||
const q = input.trim().toLowerCase()
|
||||
return String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedServerIdentity,
|
||||
() => {
|
||||
loadHostHardware()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([selectedMetricName, timescaledb, deviceId], () => {
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadServerOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'ServerHardwareMonitor',
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.hw-empty {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hw-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hw-sub-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--color-border-1);
|
||||
}
|
||||
|
||||
.hw-sub-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.hw-sub-dash {
|
||||
color: var(--color-text-4);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hw-stat-cell {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--color-border-1);
|
||||
}
|
||||
|
||||
.hw-stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hw-stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.hw-chart-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hw-chart-wrap {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.hw-raw-pre {
|
||||
margin: 0;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user