This commit is contained in:
zxr
2026-04-13 20:57:41 +08:00
parent 003c552238
commit f030f9c5c9
6 changed files with 1780 additions and 1 deletions

View 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 []
}
}

View File

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

View File

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

View File

@@ -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="管理 IPBMC / 带外)">
<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 623SNMP 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>

View File

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

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