Compare commits
22 Commits
dc31084d87
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e569571462 | |||
| 7f209b5fef | |||
| d9a0470ecf | |||
| 39d3254b8d | |||
| 0a12f54132 | |||
| 9afaa4b775 | |||
| 878cbea9a5 | |||
| d5df671ff6 | |||
| ba933457d4 | |||
| 01139f2874 | |||
| cb8bd05ff7 | |||
| 55170bceb0 | |||
| 8db158c390 | |||
| b8a7ba1cbb | |||
| e84cb75dda | |||
| f030f9c5c9 | |||
| 003c552238 | |||
| 1dcab7af96 | |||
| 5f4111aeb1 | |||
| c72af0bfa7 | |||
| 68a320b6c2 | |||
| a0ca86d98d |
115
.kilo/package-lock.json
generated
Normal file
115
.kilo/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.14.tgz",
|
||||
"integrity": "sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.14",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.14.tgz",
|
||||
"integrity": "sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
.kilocode/package-lock.json
generated
Normal file
115
.kilocode/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".kilocode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.14.tgz",
|
||||
"integrity": "sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.14",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.14.tgz",
|
||||
"integrity": "sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export const fetchAlertRecords = (data: {
|
||||
keyword?: string,
|
||||
sort?: string,
|
||||
order?: string,
|
||||
binding_id?: number,
|
||||
resource_category?: string,
|
||||
server_identity?: string,
|
||||
ip?: string,
|
||||
}) => {
|
||||
return request.get("/Alert/v1/record/list", { params: data });
|
||||
};
|
||||
|
||||
317
src/api/ops/host-hardware.ts
Normal file
317
src/api/ops/host-hardware.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 服务器硬件监控页专用:对接 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
|
||||
/** 是否启用监控调度 */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,31 @@ export interface RoomDeviceItem {
|
||||
name: string
|
||||
description: string
|
||||
room_id: string
|
||||
device_code?: string
|
||||
device_category: string
|
||||
type?: string
|
||||
status_url?: string
|
||||
agent_config: string
|
||||
collect_method: 'api' | 'snmp'
|
||||
snmp_target: string
|
||||
snmp_port: number
|
||||
snmp_community: string
|
||||
snmp_timeout_ms: number
|
||||
snmp_retries: number
|
||||
snmp_oids: string
|
||||
enabled: boolean
|
||||
collect_on: boolean
|
||||
collect_interval: number
|
||||
collect_last_result: string
|
||||
status?: string
|
||||
status_code?: number
|
||||
status_message?: string
|
||||
response_time?: number
|
||||
last_check_time?: string
|
||||
last_online_time?: string | null
|
||||
last_offline_time?: string | null
|
||||
continuous_errors?: number
|
||||
uptime?: number
|
||||
policy_ids?: number[]
|
||||
}
|
||||
|
||||
@@ -45,6 +64,13 @@ export interface RoomDeviceCreateData {
|
||||
room_id: string
|
||||
device_category: string
|
||||
agent_config?: string
|
||||
collect_method?: 'api' | 'snmp'
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
enabled?: boolean
|
||||
collect_on?: boolean
|
||||
collect_interval?: number
|
||||
@@ -58,6 +84,13 @@ export interface RoomDeviceUpdateData {
|
||||
room_id?: string
|
||||
device_category?: string
|
||||
agent_config?: string
|
||||
collect_method?: 'api' | 'snmp'
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
enabled?: boolean
|
||||
collect_on?: boolean
|
||||
collect_interval?: number
|
||||
@@ -66,9 +99,16 @@ export interface RoomDeviceUpdateData {
|
||||
|
||||
/** 机房设备采集配置数据 */
|
||||
export interface RoomDeviceCollectData {
|
||||
collect_method?: 'api' | 'snmp'
|
||||
agent_config?: string
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
collect_on?: boolean
|
||||
collect_interval?: number
|
||||
agent_config?: string
|
||||
}
|
||||
|
||||
/** 指标数据项 */
|
||||
@@ -83,6 +123,21 @@ export interface MetricItem {
|
||||
metric_unit?: string
|
||||
}
|
||||
|
||||
/** GET /room-devices/metrics/latest 的 details 载荷 */
|
||||
export interface RoomDeviceMetricsLatestDetails {
|
||||
service_identity: string
|
||||
latest_timestamp?: string | null
|
||||
count: number
|
||||
metrics: MetricItem[]
|
||||
}
|
||||
|
||||
/** 统一 API 封装(与 storage 等页一致) */
|
||||
export interface RoomDeviceMetricsLatestResponse {
|
||||
code: number
|
||||
message?: string
|
||||
details?: RoomDeviceMetricsLatestDetails
|
||||
}
|
||||
|
||||
/** 指标上报数据 */
|
||||
export interface MetricsUploadData {
|
||||
metrics: MetricItem[]
|
||||
@@ -120,7 +175,7 @@ export const patchRoomDeviceCollect = (id: number, data: RoomDeviceCollectData)
|
||||
|
||||
/** 查询最新指标 */
|
||||
export const fetchLatestMetrics = (serviceIdentity: string) => {
|
||||
return request.get<MetricItem[]>('/DC-Control/v1/room-devices/metrics/latest', {
|
||||
return request.get<RoomDeviceMetricsLatestResponse>('/DC-Control/v1/room-devices/metrics/latest', {
|
||||
params: { service_identity: serviceIdentity },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,24 @@ export interface RoomListParams {
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** 机房下拉选项参数 */
|
||||
export interface RoomOptionsParams {
|
||||
datacenter_id?: number
|
||||
floor_id?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** 机房下拉选项项 */
|
||||
export interface RoomOptionItem {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
datacenter_id: number
|
||||
floor_id: number
|
||||
enabled: boolean
|
||||
sort: number
|
||||
}
|
||||
|
||||
/** 机房创建数据 */
|
||||
export interface RoomCreateData {
|
||||
name: string
|
||||
@@ -66,6 +84,11 @@ export const fetchRoomDetail = (id: number) => {
|
||||
return request.get<RoomItem>(`/Assets/v1/room/detail/${id}`)
|
||||
}
|
||||
|
||||
/** 获取机房列表(下拉,不分页) */
|
||||
export const fetchRoomOptions = (params?: RoomOptionsParams) => {
|
||||
return request.get<RoomOptionItem[]>('/Assets/v1/room/all', { params })
|
||||
}
|
||||
|
||||
/** 根据楼层获取机房列表(下拉,不分页) */
|
||||
export const fetchRoomListByFloor = (floorId: number, params?: { name?: string }) => {
|
||||
return request.get<RoomItem[]>(`/Assets/v1/room/floor/${floorId}`, { params })
|
||||
|
||||
@@ -19,6 +19,13 @@ export interface SecurityServiceItem {
|
||||
tags: string
|
||||
status_url: string
|
||||
agent_config: string
|
||||
collect_method: 'api' | 'snmp'
|
||||
snmp_target: string
|
||||
snmp_port: number
|
||||
snmp_community: string
|
||||
snmp_timeout_ms: number
|
||||
snmp_retries: number
|
||||
snmp_oids: string
|
||||
collect_on: boolean
|
||||
collect_args: string
|
||||
collect_interval: number
|
||||
@@ -65,6 +72,13 @@ export interface SecurityServiceFormData {
|
||||
tags?: string
|
||||
status_url?: string
|
||||
agent_config?: string
|
||||
collect_method?: 'api' | 'snmp'
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
collect_on?: boolean
|
||||
collect_args?: string
|
||||
collect_interval?: number
|
||||
@@ -117,3 +131,78 @@ export const fetchSecurityMetricsLatest = (serviceIdentity: string) => {
|
||||
params: { service_identity: serviceIdentity },
|
||||
})
|
||||
}
|
||||
|
||||
/** 采集配置补丁参数 */
|
||||
export interface SecurityServicePatchData {
|
||||
collect_method?: 'api' | 'snmp'
|
||||
agent_config?: string
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
collect_on?: boolean
|
||||
collect_interval?: number
|
||||
}
|
||||
|
||||
/** 采集配置补丁 */
|
||||
export const patchSecurityServiceCollect = (id: number, data: SecurityServicePatchData) => {
|
||||
return request.patch<{ code: number; message: string; details: { message: string } }>(`/DC-Control/v1/security/${id}/collect`, data)
|
||||
}
|
||||
|
||||
/** 告警聚合查询参数 */
|
||||
export interface SecurityMetricsAggregateParams {
|
||||
service_identity: string
|
||||
metric_name: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
aggregation: 'avg' | 'max' | 'min' | 'sum' | 'count'
|
||||
}
|
||||
|
||||
/** 告警聚合值 */
|
||||
export interface SecurityMetricsAggregateData {
|
||||
metric_name: string
|
||||
aggregation: string
|
||||
value: number
|
||||
unit: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/** 获取告警聚合值 */
|
||||
export const fetchSecurityMetricsAggregate = (params: SecurityMetricsAggregateParams) => {
|
||||
return request.get<{ code: number; message: string; details: SecurityMetricsAggregateData }>(
|
||||
'/DC-Control/v1/services/metrics/security/aggregate',
|
||||
{ params }
|
||||
)
|
||||
}
|
||||
|
||||
/** 安全设备类型映射 */
|
||||
export const SECURITY_TYPE_MAP: Record<string, string> = {
|
||||
firewall: '防火墙',
|
||||
waf: 'WAF',
|
||||
ids: 'IDS',
|
||||
ips: 'IPS',
|
||||
vpn: 'VPN',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
/** 安全设备类型选项 */
|
||||
export const SECURITY_TYPE_OPTIONS = [
|
||||
{ label: '防火墙', value: 'firewall' },
|
||||
{ label: 'WAF', value: 'waf' },
|
||||
{ label: 'IDS', value: 'ids' },
|
||||
{ label: 'IPS', value: 'ips' },
|
||||
{ label: 'VPN', value: 'vpn' },
|
||||
{ label: '其他', value: 'other' },
|
||||
]
|
||||
|
||||
/** 运行状态映射 */
|
||||
export const STATUS_MAP: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
error: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ServerItem {
|
||||
server_type: string
|
||||
tags: string
|
||||
location: string
|
||||
asset_id?: number
|
||||
remote_access: string
|
||||
remote_port: number
|
||||
agent_config: string
|
||||
@@ -60,6 +61,7 @@ export interface ServerFormData {
|
||||
server_type?: string
|
||||
tags?: string
|
||||
location?: string
|
||||
asset_id?: number
|
||||
remote_access?: string
|
||||
remote_port?: number
|
||||
agent_config?: string
|
||||
@@ -117,6 +119,11 @@ export interface HostMetricsDiskMount {
|
||||
export interface HostMetricsCpuCard {
|
||||
usage_percent: number
|
||||
logical_cores_total: number
|
||||
socket_count?: number
|
||||
physical_cores_total?: number
|
||||
physical_threads_total?: number
|
||||
cores_per_socket?: number
|
||||
threads_per_core?: number
|
||||
}
|
||||
|
||||
export interface HostMetricsSummary {
|
||||
@@ -124,6 +131,8 @@ export interface HostMetricsSummary {
|
||||
timestamp?: string
|
||||
has_data: boolean
|
||||
memory?: HostMetricsUseStat
|
||||
/** Swap / 虚拟内存,与 dc-control /servers/metrics/summary 一致 */
|
||||
swap?: HostMetricsUseStat
|
||||
disk_root?: HostMetricsDiskMount
|
||||
data_disks?: HostMetricsDiskMount[]
|
||||
cpu?: HostMetricsCpuCard
|
||||
@@ -161,3 +170,82 @@ export const fetchServerNetworkTraffic = (serverIdentity: string, hours = 6) =>
|
||||
{ params: { server_identity: serverIdentity, hours } },
|
||||
)
|
||||
}
|
||||
|
||||
/** 虚拟机资产概览 */
|
||||
export interface VirtualOverviewPayload {
|
||||
vm_total: number
|
||||
asset_group_total: number
|
||||
online_total: number
|
||||
latest_timestamp?: string
|
||||
latest_cpu_usage_avg?: number
|
||||
latest_mem_usage_avg?: number
|
||||
}
|
||||
|
||||
/** 物理机 24h 趋势点 */
|
||||
export interface PhysicalUsagePoint {
|
||||
hour: string
|
||||
cpu_used_percent_avg: number | null
|
||||
mem_used_percent_avg: number | null
|
||||
}
|
||||
|
||||
export interface PhysicalUsageTrendPayload {
|
||||
hours: number
|
||||
points: PhysicalUsagePoint[]
|
||||
}
|
||||
|
||||
/** 资源总量项 */
|
||||
export interface ResourceTotalGroup {
|
||||
server_type: 'physical' | 'virtual'
|
||||
server_count: number
|
||||
total_vcpu: number
|
||||
total_mem_bytes: number
|
||||
}
|
||||
|
||||
/** 最新资源总量汇总 */
|
||||
export interface LatestResourceSummaryPayload {
|
||||
physical: ResourceTotalGroup
|
||||
virtual: ResourceTotalGroup
|
||||
}
|
||||
|
||||
/** 按资产聚合项 */
|
||||
export interface AssetMixedSummaryItem {
|
||||
asset_id: number
|
||||
virtual_count: number
|
||||
physical_count: number
|
||||
physical_latest_cpu_usage?: number | null
|
||||
physical_latest_mem_usage?: number | null
|
||||
}
|
||||
|
||||
export interface AssetMixedSummaryPayload {
|
||||
total: number
|
||||
data: AssetMixedSummaryItem[]
|
||||
}
|
||||
|
||||
/** 接口1:虚拟机资产概览(支持分类筛选) */
|
||||
export const fetchVirtualOverview = (category?: string) => {
|
||||
return request.get<{ code: number; details?: VirtualOverviewPayload; message?: string }>(
|
||||
'/DC-Control/v1/servers/assets/virtual/overview',
|
||||
{ params: { category } },
|
||||
)
|
||||
}
|
||||
|
||||
/** 接口2:physical 近24小时 CPU/内存趋势 */
|
||||
export const fetchPhysicalUsageTrend24h = () => {
|
||||
return request.get<{ code: number; details?: PhysicalUsageTrendPayload; message?: string }>(
|
||||
'/DC-Control/v1/servers/physical/usage/trend-24h',
|
||||
)
|
||||
}
|
||||
|
||||
/** 接口3:physical/virtual 最新资源总量 */
|
||||
export const fetchLatestResourceSummary = () => {
|
||||
return request.get<{ code: number; details?: LatestResourceSummaryPayload; message?: string }>(
|
||||
'/DC-Control/v1/servers/resources/latest/summary',
|
||||
)
|
||||
}
|
||||
|
||||
/** 接口4:按资产汇总 virtual 数量与 physical 最新指标 */
|
||||
export const fetchAssetMixedSummary = () => {
|
||||
return request.get<{ code: number; details?: AssetMixedSummaryPayload; message?: string }>(
|
||||
'/DC-Control/v1/servers/assets/mixed/summary',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ export interface StorageItem {
|
||||
tags: string
|
||||
status_url: string
|
||||
agent_config: string
|
||||
collect_method: 'api' | 'snmp'
|
||||
snmp_target: string
|
||||
snmp_port: number
|
||||
snmp_community: string
|
||||
snmp_timeout_ms: number
|
||||
snmp_retries: number
|
||||
snmp_oids: string
|
||||
collect_on: boolean
|
||||
collect_args: string
|
||||
collect_interval: number
|
||||
@@ -65,6 +72,13 @@ export interface StorageCreateData {
|
||||
tags?: string
|
||||
status_url?: string
|
||||
agent_config?: string
|
||||
collect_method?: 'api' | 'snmp'
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
collect_on?: boolean
|
||||
collect_args?: string
|
||||
collect_interval?: number
|
||||
@@ -86,6 +100,13 @@ export interface StorageUpdateData {
|
||||
tags?: string
|
||||
status_url?: string
|
||||
agent_config?: string
|
||||
collect_method?: 'api' | 'snmp'
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
collect_on?: boolean
|
||||
collect_args?: string
|
||||
collect_interval?: number
|
||||
@@ -148,3 +169,162 @@ export const fetchStorageMetricsLatest = (serviceIdentity: string) => {
|
||||
params: { service_identity: serviceIdentity },
|
||||
})
|
||||
}
|
||||
|
||||
/** 监控大屏:下拉列表(JWT,模糊 keyword) */
|
||||
export interface StorageMonitorOptionItem {
|
||||
service_identity: string
|
||||
name: string
|
||||
type: string
|
||||
server_identity: string
|
||||
status: string
|
||||
}
|
||||
|
||||
/** 监控大屏:汇总 current.host(与后端 storage_monitor 一致) */
|
||||
export interface StorageMonitorSummaryHost {
|
||||
has_data: boolean
|
||||
cpu_usage_percent?: number | null
|
||||
memory_usage_percent?: number | null
|
||||
swap_usage_percent?: number | null
|
||||
disk_root_used_percent?: number | null
|
||||
disk_root_avail_percent?: number | null
|
||||
host_server_identity?: string
|
||||
host_summary_timestamp?: string | null
|
||||
}
|
||||
|
||||
export interface StorageMonitorSummaryCurrent {
|
||||
service_identity: string
|
||||
controller_status: string
|
||||
server_identity: string
|
||||
host?: StorageMonitorSummaryHost | null
|
||||
}
|
||||
|
||||
export interface StorageMonitorSummaryPayload {
|
||||
total_devices: number
|
||||
online_count: number
|
||||
offline_count: number
|
||||
other_count: number
|
||||
current?: StorageMonitorSummaryCurrent | null
|
||||
}
|
||||
|
||||
export const fetchStorageMonitorList = (params?: { keyword?: string; limit?: number }) => {
|
||||
return request.get<{
|
||||
code: number
|
||||
details?: { data: StorageMonitorOptionItem[]; count: number }
|
||||
message?: string
|
||||
}>('/DC-Control/v1/storage/monitor/list', { params })
|
||||
}
|
||||
|
||||
export const fetchStorageMonitorSummary = (serviceIdentity?: string) => {
|
||||
return request.get<{
|
||||
code: number
|
||||
details?: StorageMonitorSummaryPayload
|
||||
message?: string
|
||||
}>('/DC-Control/v1/storage/monitor/summary', {
|
||||
params: serviceIdentity ? { service_identity: serviceIdentity } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** 监控页:按存储设备 id 拉取最新一批中的指标名(趋势图下拉) */
|
||||
export interface StorageMonitorCollectedMetricItem {
|
||||
metric_name: string
|
||||
metric_unit?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface StorageMonitorCollectedMetricsPayload {
|
||||
service_identity: string
|
||||
metrics: StorageMonitorCollectedMetricItem[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export const fetchStorageMonitorCollectedMetrics = (deviceId: number) => {
|
||||
return request.get<{
|
||||
code: number
|
||||
details?: StorageMonitorCollectedMetricsPayload
|
||||
message?: string
|
||||
}>(`/DC-Control/v1/storage/monitor/metrics/collected/${deviceId}`)
|
||||
}
|
||||
|
||||
/** 存储指标匿名聚合(告警/大屏用) */
|
||||
export interface StorageMetricsAggregateParams {
|
||||
service_identity: string
|
||||
metric_name: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
aggregation: 'avg' | 'max' | 'min' | 'sum' | 'count'
|
||||
}
|
||||
|
||||
export interface StorageMetricsAggregateDetails {
|
||||
aggregation: string
|
||||
value: number
|
||||
service_identity: string
|
||||
metric_name: string
|
||||
category?: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
}
|
||||
|
||||
/** 与 DC-Hardware GET /metrics/devices/:device_id 对齐:路径 service_identity,Query 可选 */
|
||||
export interface StorageMetricsTimeseriesQuery {
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
metric_name?: string
|
||||
}
|
||||
|
||||
/** 原始样本(control_storage_metrics_data) */
|
||||
export interface StorageMetricsRawRow {
|
||||
timestamp: string
|
||||
service_identity: string
|
||||
server_identity?: string
|
||||
type: string
|
||||
metric_name: string
|
||||
metric_value: number
|
||||
metric_unit?: string
|
||||
}
|
||||
|
||||
export const fetchStorageMetricsAggregate = (params: StorageMetricsAggregateParams) => {
|
||||
return request.get<{ code: number; details?: StorageMetricsAggregateDetails; message?: string }>(
|
||||
'/DC-Control/v1/services/metrics/storage/aggregate',
|
||||
{ params: { ...params, category: 'storage' } },
|
||||
)
|
||||
}
|
||||
|
||||
/** 存储指标时序原始点(JWT);路径为 service_identity,需 URL 编码 */
|
||||
export const fetchStorageMetricsTimeseries = (
|
||||
serviceIdentity: string,
|
||||
params?: StorageMetricsTimeseriesQuery,
|
||||
) => {
|
||||
const path = encodeURIComponent(serviceIdentity)
|
||||
return request.get<{
|
||||
code: number
|
||||
details?: {
|
||||
service_identity: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
count: number
|
||||
metrics: StorageMetricsRawRow[]
|
||||
}
|
||||
message?: string
|
||||
}>(`/DC-Control/v1/storage/monitor/metrics/timeseries/${path}`, {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/** 采集配置补丁 */
|
||||
export interface StoragePatchData {
|
||||
collect_method?: 'api' | 'snmp'
|
||||
agent_config?: string
|
||||
snmp_target?: string
|
||||
snmp_port?: number
|
||||
snmp_community?: string
|
||||
snmp_timeout_ms?: number
|
||||
snmp_retries?: number
|
||||
snmp_oids?: string
|
||||
collect_on?: boolean
|
||||
collect_interval?: number
|
||||
}
|
||||
|
||||
/** 采集配置补丁更新 */
|
||||
export const patchStorage = (id: number, data: StoragePatchData) => {
|
||||
return request.patch<{ message: string }>(`/DC-Control/v1/storage/${id}/collect`, data)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -750,6 +766,22 @@ export const localMenuFlatItems: MenuItem[] = [
|
||||
sort_key: 39,
|
||||
created_at: '2025-12-26T13:23:52.220159+08:00',
|
||||
},
|
||||
{
|
||||
id: 53,
|
||||
identity: '019d0000-0000-7000-8000-000000000053',
|
||||
title: '机房管理',
|
||||
title_en: 'Room Management',
|
||||
code: 'ops:数据中心管理:机房管理',
|
||||
description: '数据中心管理 - 机房管理',
|
||||
app_id: 2,
|
||||
parent_id: 49,
|
||||
menu_path: '/datacenter/room',
|
||||
menu_icon: 'appstore',
|
||||
component: 'ops/pages/datacenter/room',
|
||||
type: 1,
|
||||
sort_key: 39.5,
|
||||
created_at: '2026-04-14T10:00:00+08:00',
|
||||
},
|
||||
{
|
||||
id: 54,
|
||||
identity: '019b591d-0343-7ce7-91bd-d82497ea0a11',
|
||||
|
||||
@@ -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',
|
||||
@@ -807,6 +824,23 @@ export const localMenuItems: MenuItem[] = [
|
||||
created_at: '2025-12-26T13:23:52.220159+08:00',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 53,
|
||||
identity: '019d0000-0000-7000-8000-000000000053',
|
||||
title: '机房管理',
|
||||
title_en: 'Room Management',
|
||||
code: 'ops:数据中心管理:机房管理',
|
||||
description: '数据中心管理 - 机房管理',
|
||||
app_id: 2,
|
||||
parent_id: 49,
|
||||
menu_path: '/datacenter/room',
|
||||
menu_icon: 'appstore',
|
||||
component: 'ops/pages/datacenter/room',
|
||||
type: 1,
|
||||
sort_key: 9,
|
||||
created_at: '2026-04-14T10:00:00+08:00',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
<!-- 位置信息 -->
|
||||
<a-card class="info-card" title="位置信息">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-col :span="5">
|
||||
<a-form-item label="所属数据中心" field="datacenter_id">
|
||||
<a-select
|
||||
v-model="form.datacenter_id"
|
||||
@@ -229,7 +229,7 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-col :span="5">
|
||||
<a-form-item label="所属楼层" field="floor_id">
|
||||
<a-select
|
||||
v-model="form.floor_id"
|
||||
@@ -251,7 +251,30 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-col :span="5">
|
||||
<a-form-item label="所属机房" field="room_id">
|
||||
<a-select
|
||||
v-model="form.room_id"
|
||||
placeholder="请选择机房"
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="roomLoading"
|
||||
:disabled="!form.floor_id"
|
||||
:filter-option="false"
|
||||
@search="handleRoomSearch"
|
||||
@change="handleRoomChange"
|
||||
>
|
||||
<a-option
|
||||
v-for="item in roomOptions"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
>
|
||||
{{ item.name }} ({{ item.code }})
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="5">
|
||||
<a-form-item label="所属机柜" field="rack_id">
|
||||
<a-select
|
||||
v-model="form.rack_id"
|
||||
@@ -259,7 +282,7 @@
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="rackLoading"
|
||||
:disabled="!form.floor_id"
|
||||
:disabled="!form.room_id"
|
||||
:filter-option="false"
|
||||
@search="handleRackSearch"
|
||||
>
|
||||
@@ -273,7 +296,7 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="3">
|
||||
<a-col :span="2">
|
||||
<a-form-item label="起始U位" field="unit_start">
|
||||
<a-input-number
|
||||
v-model="form.unit_start"
|
||||
@@ -284,7 +307,7 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="3">
|
||||
<a-col :span="2">
|
||||
<a-form-item label="结束U位" field="unit_end">
|
||||
<a-input-number
|
||||
v-model="form.unit_end"
|
||||
@@ -387,8 +410,9 @@ import {
|
||||
AssetForm,
|
||||
} from '@/api/ops/asset'
|
||||
import { fetchAllSuppliers } from '@/api/ops/supplier'
|
||||
import { fetchDatacenterList, fetchRackListByFloor } from '@/api/ops/rack'
|
||||
import { fetchDatacenterList, fetchRackListByRoom } from '@/api/ops/rack'
|
||||
import { fetchFloorListByDatacenter } from '@/api/ops/floor'
|
||||
import { fetchRoomListByFloor } from '@/api/ops/room'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -406,6 +430,7 @@ const categoryOptions = ref<any[]>([])
|
||||
const supplierOptions = ref<any[]>([])
|
||||
const datacenterOptions = ref<{ label: string; value: number }[]>([])
|
||||
const floorOptions = ref<{ label: string; value: number }[]>([])
|
||||
const roomOptions = ref<any[]>([])
|
||||
const rackOptions = ref<any[]>([])
|
||||
|
||||
// 加载状态
|
||||
@@ -413,11 +438,13 @@ const categoryLoading = ref(false)
|
||||
const supplierLoading = ref(false)
|
||||
const datacenterLoading = ref(false)
|
||||
const floorLoading = ref(false)
|
||||
const roomLoading = ref(false)
|
||||
const rackLoading = ref(false)
|
||||
|
||||
// 搜索防抖定时器
|
||||
let datacenterSearchTimer: number | undefined
|
||||
let floorSearchTimer: number | undefined
|
||||
let roomSearchTimer: number | undefined
|
||||
let rackSearchTimer: number | undefined
|
||||
|
||||
// 表单数据
|
||||
@@ -574,13 +601,13 @@ const handleFloorSearch = (keyword: string) => {
|
||||
|
||||
// 加载机柜列表
|
||||
const fetchRacks = async (keyword?: string) => {
|
||||
if (!form.value.floor_id) {
|
||||
if (!form.value.room_id) {
|
||||
rackOptions.value = []
|
||||
return
|
||||
}
|
||||
rackLoading.value = true
|
||||
try {
|
||||
const res: any = await fetchRackListByFloor(form.value.floor_id, { name: keyword })
|
||||
const res: any = await fetchRackListByRoom(form.value.room_id, { name: keyword })
|
||||
rackOptions.value = extractList(res)
|
||||
} catch (error) {
|
||||
console.error('获取机柜列表失败:', error)
|
||||
@@ -592,7 +619,7 @@ const fetchRacks = async (keyword?: string) => {
|
||||
|
||||
// 机柜搜索(带防抖)
|
||||
const handleRackSearch = (keyword: string) => {
|
||||
if (!form.value.floor_id) return
|
||||
if (!form.value.room_id) return
|
||||
if (rackSearchTimer) {
|
||||
window.clearTimeout(rackSearchTimer)
|
||||
}
|
||||
@@ -601,11 +628,44 @@ const handleRackSearch = (keyword: string) => {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 加载机房列表
|
||||
const fetchRooms = async (keyword?: string) => {
|
||||
if (!form.value.floor_id) {
|
||||
roomOptions.value = []
|
||||
return
|
||||
}
|
||||
roomLoading.value = true
|
||||
try {
|
||||
const res: any = await fetchRoomListByFloor(form.value.floor_id, {
|
||||
name: keyword || undefined,
|
||||
})
|
||||
roomOptions.value = extractList(res)
|
||||
} catch (error) {
|
||||
console.error('获取机房列表失败:', error)
|
||||
roomOptions.value = []
|
||||
} finally {
|
||||
roomLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 机房搜索(带防抖)
|
||||
const handleRoomSearch = (keyword: string) => {
|
||||
if (!form.value.floor_id) return
|
||||
if (roomSearchTimer) {
|
||||
window.clearTimeout(roomSearchTimer)
|
||||
}
|
||||
roomSearchTimer = window.setTimeout(() => {
|
||||
fetchRooms(keyword?.trim() || undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 数据中心变化时加载楼层
|
||||
const handleDatacenterChange = async (value: number | undefined) => {
|
||||
form.value.floor_id = undefined
|
||||
form.value.room_id = undefined
|
||||
form.value.rack_id = undefined
|
||||
floorOptions.value = []
|
||||
roomOptions.value = []
|
||||
rackOptions.value = []
|
||||
|
||||
if (value) {
|
||||
@@ -615,6 +675,18 @@ const handleDatacenterChange = async (value: number | undefined) => {
|
||||
|
||||
// 楼层变化时加载机柜
|
||||
const handleFloorChange = async (value: number | undefined) => {
|
||||
form.value.room_id = undefined
|
||||
form.value.rack_id = undefined
|
||||
roomOptions.value = []
|
||||
rackOptions.value = []
|
||||
|
||||
if (value) {
|
||||
await fetchRooms()
|
||||
}
|
||||
}
|
||||
|
||||
// 机房变化时加载机柜
|
||||
const handleRoomChange = async (value: number | undefined) => {
|
||||
form.value.rack_id = undefined
|
||||
rackOptions.value = []
|
||||
|
||||
@@ -675,7 +747,17 @@ const loadDeviceDetail = async () => {
|
||||
// 如果有楼层,加载机柜
|
||||
if (res.details.floor_id) {
|
||||
try {
|
||||
const rackRes: any = await fetchRackListByFloor(res.details.floor_id)
|
||||
const roomRes: any = await fetchRoomListByFloor(res.details.floor_id)
|
||||
roomOptions.value = extractList(roomRes)
|
||||
} catch (error) {
|
||||
console.error('加载机房失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有机房,加载机柜
|
||||
if (res.details.room_id) {
|
||||
try {
|
||||
const rackRes: any = await fetchRackListByRoom(res.details.room_id)
|
||||
rackOptions.value = extractList(rackRes)
|
||||
} catch (error) {
|
||||
console.error('加载机柜失败:', error)
|
||||
|
||||
@@ -56,6 +56,16 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" field="status">
|
||||
<a-select v-model="form.status" placeholder="请选择状态">
|
||||
<a-option value="planning">规划中</a-option>
|
||||
<a-option value="construction">建设中</a-option>
|
||||
<a-option value="operating">运营中</a-option>
|
||||
<a-option value="maintenance">维护中</a-option>
|
||||
<a-option value="offline">已下线</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="面积(平方米)"
|
||||
field="area"
|
||||
@@ -152,6 +162,7 @@ interface Floor {
|
||||
name?: string
|
||||
datacenter_id?: number
|
||||
floor_number?: number
|
||||
status?: string
|
||||
area?: number
|
||||
height?: number
|
||||
load_bearing?: number
|
||||
@@ -184,6 +195,7 @@ const form = ref({
|
||||
name: '',
|
||||
datacenter_id: undefined as number | undefined,
|
||||
floor_number: 1,
|
||||
status: 'planning',
|
||||
area: undefined as number | undefined,
|
||||
height: undefined as number | undefined,
|
||||
load_bearing: undefined as number | undefined,
|
||||
@@ -252,6 +264,7 @@ watch(
|
||||
name: props.floor.name || '',
|
||||
datacenter_id: props.floor.datacenter_id,
|
||||
floor_number: props.floor.floor_number || 1,
|
||||
status: props.floor.status || 'planning',
|
||||
area: props.floor.area,
|
||||
height: props.floor.height,
|
||||
load_bearing: props.floor.load_bearing,
|
||||
@@ -277,6 +290,7 @@ watch(
|
||||
name: '',
|
||||
datacenter_id: undefined,
|
||||
floor_number: 1,
|
||||
status: 'planning',
|
||||
area: undefined,
|
||||
height: undefined,
|
||||
load_bearing: undefined,
|
||||
@@ -301,6 +315,7 @@ const handleOk = async () => {
|
||||
name: form.value.name,
|
||||
datacenter_id: form.value.datacenter_id,
|
||||
floor_number: form.value.floor_number,
|
||||
status: form.value.status,
|
||||
area: form.value.area,
|
||||
height: form.value.height,
|
||||
load_bearing: form.value.load_bearing,
|
||||
|
||||
@@ -74,7 +74,9 @@
|
||||
v-model="form.floor_id"
|
||||
placeholder="请选择所属楼层"
|
||||
:loading="loadingFloors"
|
||||
:disabled="!form.datacenter_id"
|
||||
allow-search
|
||||
@change="handleFloorChange"
|
||||
@search="handleFloorSearch"
|
||||
>
|
||||
<a-option
|
||||
@@ -88,6 +90,32 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
label="所属机房"
|
||||
field="room_id"
|
||||
:rules="[{ required: true, message: '请选择所属机房' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model="form.room_id"
|
||||
placeholder="请选择所属机房"
|
||||
:loading="loadingRooms"
|
||||
:disabled="!form.floor_id"
|
||||
allow-search
|
||||
@search="handleRoomSearch"
|
||||
>
|
||||
<a-option
|
||||
v-for="item in roomList"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 规格参数 -->
|
||||
<a-divider orientation="left">规格参数</a-divider>
|
||||
@@ -438,9 +466,10 @@ import { Message } from '@arco-design/web-vue'
|
||||
import { createRack, updateRack } from '@/api/ops/rack'
|
||||
import {
|
||||
fetchDatacenterList,
|
||||
fetchRackListByDatacenter,
|
||||
fetchSupplierList,
|
||||
} from '@/api/ops/rack'
|
||||
import { fetchFloorListByDatacenter } from '@/api/ops/floor'
|
||||
import { fetchRoomListByFloor } from '@/api/ops/room'
|
||||
|
||||
interface Rack {
|
||||
id?: number
|
||||
@@ -448,6 +477,7 @@ interface Rack {
|
||||
code?: string
|
||||
datacenter_id?: number
|
||||
floor_id?: number
|
||||
room_id?: number
|
||||
height?: number
|
||||
width?: number
|
||||
depth?: number
|
||||
@@ -496,12 +526,15 @@ const emit = defineEmits<Emits>()
|
||||
const formRef = ref()
|
||||
const loadingDatacenters = ref(false)
|
||||
const loadingFloors = ref(false)
|
||||
const loadingRooms = ref(false)
|
||||
const loadingSuppliers = ref(false)
|
||||
const submitting = ref(false)
|
||||
const datacenterList = ref<any[]>([])
|
||||
const floorList = ref<any[]>([])
|
||||
const roomList = ref<any[]>([])
|
||||
const supplierList = ref<any[]>([])
|
||||
let floorSearchTimer: number | undefined
|
||||
let roomSearchTimer: number | undefined
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
@@ -509,6 +542,7 @@ const form = ref({
|
||||
code: '',
|
||||
datacenter_id: undefined as number | undefined,
|
||||
floor_id: undefined as number | undefined,
|
||||
room_id: undefined as number | undefined,
|
||||
height: 42,
|
||||
width: undefined as number | undefined,
|
||||
depth: undefined as number | undefined,
|
||||
@@ -559,7 +593,7 @@ const loadDatacenterList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载楼层列表(通过机柜下拉接口提取去重楼层)
|
||||
// 加载楼层列表
|
||||
const loadFloorList = async (datacenterId?: number, keyword?: string) => {
|
||||
if (!datacenterId) {
|
||||
floorList.value = []
|
||||
@@ -567,20 +601,10 @@ const loadFloorList = async (datacenterId?: number, keyword?: string) => {
|
||||
}
|
||||
loadingFloors.value = true
|
||||
try {
|
||||
const res: any = await fetchRackListByDatacenter(datacenterId, { name: keyword })
|
||||
const res: any = await fetchFloorListByDatacenter(datacenterId, { name: keyword })
|
||||
if (res.code === 0) {
|
||||
const list = res.details?.data ?? res.data ?? res.details ?? []
|
||||
const rows = Array.isArray(list) ? list : []
|
||||
const floorMap = new Map<number, { id: number; name: string }>()
|
||||
rows.forEach((rack: any) => {
|
||||
const floor = rack?.floor
|
||||
if (!floor?.id || floorMap.has(floor.id)) return
|
||||
floorMap.set(floor.id, {
|
||||
id: floor.id,
|
||||
name: floor.name || String(floor.id),
|
||||
})
|
||||
})
|
||||
floorList.value = Array.from(floorMap.values())
|
||||
floorList.value = Array.isArray(list) ? list : []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取楼层列表失败:', error)
|
||||
@@ -607,9 +631,36 @@ const loadSupplierList = async () => {
|
||||
// 数据中心变化时重新加载楼层列表
|
||||
const handleDatacenterChange = async (value: number) => {
|
||||
form.value.floor_id = undefined
|
||||
form.value.room_id = undefined
|
||||
roomList.value = []
|
||||
await loadFloorList(value)
|
||||
}
|
||||
|
||||
const loadRoomList = async (floorId?: number, keyword?: string) => {
|
||||
if (!floorId) {
|
||||
roomList.value = []
|
||||
return
|
||||
}
|
||||
loadingRooms.value = true
|
||||
try {
|
||||
const res: any = await fetchRoomListByFloor(floorId, { name: keyword })
|
||||
if (res.code === 0) {
|
||||
const list = res.details?.data ?? res.data ?? res.details ?? []
|
||||
roomList.value = Array.isArray(list) ? list : []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取机房列表失败:', error)
|
||||
roomList.value = []
|
||||
} finally {
|
||||
loadingRooms.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFloorChange = async (value: number) => {
|
||||
form.value.room_id = undefined
|
||||
await loadRoomList(value)
|
||||
}
|
||||
|
||||
const handleFloorSearch = (keyword: string) => {
|
||||
if (!form.value.datacenter_id) return
|
||||
if (floorSearchTimer) {
|
||||
@@ -620,6 +671,16 @@ const handleFloorSearch = (keyword: string) => {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleRoomSearch = (keyword: string) => {
|
||||
if (!form.value.floor_id) return
|
||||
if (roomSearchTimer) {
|
||||
window.clearTimeout(roomSearchTimer)
|
||||
}
|
||||
roomSearchTimer = window.setTimeout(() => {
|
||||
loadRoomList(form.value.floor_id, keyword?.trim() || undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
@@ -632,6 +693,7 @@ watch(
|
||||
code: props.rack.code || '',
|
||||
datacenter_id: props.rack.datacenter_id,
|
||||
floor_id: props.rack.floor_id,
|
||||
room_id: props.rack.room_id,
|
||||
height: props.rack.height || 42,
|
||||
width: props.rack.width,
|
||||
depth: props.rack.depth,
|
||||
@@ -667,6 +729,9 @@ watch(
|
||||
if (props.rack.datacenter_id) {
|
||||
loadFloorList(props.rack.datacenter_id)
|
||||
}
|
||||
if (props.rack.floor_id) {
|
||||
loadRoomList(props.rack.floor_id)
|
||||
}
|
||||
} else {
|
||||
// 新建模式:重置表单
|
||||
form.value = {
|
||||
@@ -674,6 +739,7 @@ watch(
|
||||
code: '',
|
||||
datacenter_id: undefined,
|
||||
floor_id: undefined,
|
||||
room_id: undefined,
|
||||
height: 42,
|
||||
width: undefined,
|
||||
depth: undefined,
|
||||
@@ -705,6 +771,8 @@ watch(
|
||||
description: '',
|
||||
remarks: '',
|
||||
}
|
||||
floorList.value = []
|
||||
roomList.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -722,6 +790,7 @@ const handleOk = async () => {
|
||||
code: form.value.code,
|
||||
datacenter_id: form.value.datacenter_id,
|
||||
floor_id: form.value.floor_id,
|
||||
room_id: form.value.room_id,
|
||||
height: form.value.height,
|
||||
width: form.value.width,
|
||||
depth: form.value.depth,
|
||||
|
||||
@@ -24,6 +24,14 @@ export const searchFormConfig: FormItem[] = [
|
||||
options: [], // 需要动态加载
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'room_id',
|
||||
label: '机房',
|
||||
type: 'select',
|
||||
placeholder: '请选择机房',
|
||||
options: [], // 需要动态加载
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'rack_type',
|
||||
label: '机柜类型',
|
||||
|
||||
@@ -113,9 +113,10 @@ import { columns as columnsConfig } from './config/columns'
|
||||
import {
|
||||
fetchRackList,
|
||||
fetchDatacenterList,
|
||||
fetchRackListByDatacenter,
|
||||
deleteRack,
|
||||
} from '@/api/ops/rack'
|
||||
import { fetchFloorListByDatacenter } from '@/api/ops/floor'
|
||||
import { fetchRoomListByFloor } from '@/api/ops/room'
|
||||
import RackDetailDialog from './components/RackDetailDialog.vue'
|
||||
import RackFormDialog from './components/RackFormDialog.vue'
|
||||
|
||||
@@ -128,6 +129,7 @@ const formModel = ref({
|
||||
keyword: '',
|
||||
datacenter_id: undefined,
|
||||
floor_id: undefined,
|
||||
room_id: undefined,
|
||||
rack_type: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
@@ -141,8 +143,10 @@ const pagination = reactive({
|
||||
// 表单项配置
|
||||
const datacenterSelectOptions = ref<{ label: string; value: number }[]>([])
|
||||
const floorSelectOptions = ref<{ label: string; value: number }[]>([])
|
||||
const roomSelectOptions = ref<{ label: string; value: number }[]>([])
|
||||
let datacenterSearchTimer: number | undefined
|
||||
let floorSearchTimer: number | undefined
|
||||
let roomSearchTimer: number | undefined
|
||||
|
||||
const formItems = computed<FormItem[]>(() =>
|
||||
searchFormConfig.map((item) => {
|
||||
@@ -163,6 +167,15 @@ const formItems = computed<FormItem[]>(() =>
|
||||
disabled: !formModel.value.datacenter_id,
|
||||
}
|
||||
}
|
||||
if (item.field === 'room_id') {
|
||||
return {
|
||||
...item,
|
||||
options: roomSelectOptions.value,
|
||||
allowSearch: true,
|
||||
onSearch: handleRoomSearch,
|
||||
disabled: !formModel.value.floor_id,
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
)
|
||||
@@ -199,25 +212,18 @@ const loadDatacenterOptions = async (keyword?: string) => {
|
||||
const loadFloorOptions = async (datacenterId?: number, keyword?: string) => {
|
||||
if (!datacenterId) {
|
||||
floorSelectOptions.value = []
|
||||
roomSelectOptions.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await fetchRackListByDatacenter(datacenterId, {
|
||||
name: keyword,
|
||||
})
|
||||
const res: any = await fetchFloorListByDatacenter(datacenterId, { name: keyword })
|
||||
if (res.code === 0) {
|
||||
const list = res.details?.data ?? res.data ?? res.details ?? []
|
||||
const rows = Array.isArray(list) ? list : []
|
||||
const floorMap = new Map<number, { label: string; value: number }>()
|
||||
rows.forEach((rack: any) => {
|
||||
const floor = rack?.floor
|
||||
if (!floor?.id || floorMap.has(floor.id)) return
|
||||
floorMap.set(floor.id, {
|
||||
label: floor.name || String(floor.id),
|
||||
value: floor.id,
|
||||
})
|
||||
})
|
||||
floorSelectOptions.value = Array.from(floorMap.values())
|
||||
floorSelectOptions.value = rows.map((floor: any) => ({
|
||||
label: floor.name || String(floor.id),
|
||||
value: floor.id,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载楼层列表失败:', error)
|
||||
@@ -226,6 +232,28 @@ const loadFloorOptions = async (datacenterId?: number, keyword?: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRoomOptions = async (floorId?: number, keyword?: string) => {
|
||||
if (!floorId) {
|
||||
roomSelectOptions.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await fetchRoomListByFloor(floorId, { name: keyword })
|
||||
if (res.code === 0) {
|
||||
const list = res.details?.data ?? res.data ?? res.details ?? []
|
||||
const rows = Array.isArray(list) ? list : []
|
||||
roomSelectOptions.value = rows.map((room: any) => ({
|
||||
label: room.name || String(room.id),
|
||||
value: room.id,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载机房列表失败:', error)
|
||||
Message.error('加载机房列表失败')
|
||||
roomSelectOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatacenterSearch = (keyword: string) => {
|
||||
if (datacenterSearchTimer) {
|
||||
window.clearTimeout(datacenterSearchTimer)
|
||||
@@ -245,16 +273,37 @@ const handleFloorSearch = (keyword: string) => {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleRoomSearch = (keyword: string) => {
|
||||
if (!formModel.value.floor_id) return
|
||||
if (roomSearchTimer) {
|
||||
window.clearTimeout(roomSearchTimer)
|
||||
}
|
||||
roomSearchTimer = window.setTimeout(() => {
|
||||
loadRoomOptions(formModel.value.floor_id, keyword?.trim() || undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => formModel.value.datacenter_id,
|
||||
(newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
formModel.value.floor_id = undefined
|
||||
formModel.value.room_id = undefined
|
||||
}
|
||||
loadFloorOptions(newId)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => formModel.value.floor_id,
|
||||
(newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
formModel.value.room_id = undefined
|
||||
}
|
||||
loadRoomOptions(newId)
|
||||
},
|
||||
)
|
||||
|
||||
// 获取机柜类型颜色
|
||||
const getRackTypeColor = (type?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
@@ -314,6 +363,7 @@ const fetchRacks = async () => {
|
||||
keyword: formModel.value.keyword || undefined,
|
||||
datacenter_id: formModel.value.datacenter_id ?? undefined,
|
||||
floor_id: formModel.value.floor_id ?? undefined,
|
||||
room_id: formModel.value.room_id ?? undefined,
|
||||
rack_type: formModel.value.rack_type || undefined,
|
||||
status: formModel.value.status || undefined,
|
||||
}
|
||||
@@ -350,6 +400,7 @@ const handleReset = () => {
|
||||
keyword: '',
|
||||
datacenter_id: undefined,
|
||||
floor_id: undefined,
|
||||
room_id: undefined,
|
||||
rack_type: undefined,
|
||||
status: undefined,
|
||||
}
|
||||
@@ -411,7 +462,7 @@ const handleDelete = async (record: any) => {
|
||||
// U位管理
|
||||
const handleUnitManagement = (record: any) => {
|
||||
router.push({
|
||||
path: '/ops/datacenter/u-position',
|
||||
path: '/datacenter/u-position',
|
||||
query: { rack_id: record.id, rack_name: record.name },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="机房详情"
|
||||
width="700px"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleVisibleChange"
|
||||
:footer="false"
|
||||
>
|
||||
<a-spin :loading="loading" style="width: 100%">
|
||||
<a-descriptions :column="2" bordered v-if="roomDetail">
|
||||
<a-descriptions-item label="机房名称" :span="2">
|
||||
{{ roomDetail.name || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="机房编码">
|
||||
{{ roomDetail.code || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusMap[roomDetail.status]?.color || 'gray'">
|
||||
{{ statusMap[roomDetail.status]?.text || roomDetail.status || '-' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属中心">
|
||||
{{ roomDetail.datacenter?.name || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属楼层">
|
||||
{{ roomDetail.floor?.name || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="2">
|
||||
{{ roomDetail.description || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ formatDate(roomDetail.created_at) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">
|
||||
{{ formatDate(roomDetail.updated_at) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { fetchRoomDetail } from '@/api/ops/room'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
roomId?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const loading = ref(false)
|
||||
const roomDetail = ref<any>(null)
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
planning: { text: '规划中', color: 'blue' },
|
||||
construction: { text: '建设中', color: 'orange' },
|
||||
operating: { text: '运营中', color: 'green' },
|
||||
maintenance: { text: '维护中', color: 'gold' },
|
||||
offline: { text: '已下线', color: 'red' },
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const loadRoomDetail = async () => {
|
||||
if (!props.roomId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await fetchRoomDetail(props.roomId)
|
||||
if (res.code === 0) {
|
||||
roomDetail.value = res.details
|
||||
} else {
|
||||
Message.error(res.message || '获取机房详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取机房详情失败:', error)
|
||||
Message.error('获取机房详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (newVal && props.roomId) {
|
||||
loadRoomDetail()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleVisibleChange = (visible: boolean) => {
|
||||
emit('update:visible', visible)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'RoomDetailDialog',
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑机房' : '新建机房'"
|
||||
width="700px"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleVisibleChange"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form :model="form" layout="vertical" ref="formRef">
|
||||
<a-form-item
|
||||
label="机房名称"
|
||||
field="name"
|
||||
:rules="[{ required: true, message: '请输入机房名称' }]"
|
||||
>
|
||||
<a-input v-model="form.name" placeholder="请输入机房名称" :max-length="200" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="机房编码"
|
||||
field="code"
|
||||
:rules="[{ required: true, message: '请输入机房编码' }]"
|
||||
>
|
||||
<a-input v-model="form.code" placeholder="请输入机房编码" :max-length="100" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="所属中心"
|
||||
field="datacenter_id"
|
||||
:rules="[{ required: true, message: '请选择所属中心' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model="form.datacenter_id"
|
||||
placeholder="请选择所属中心"
|
||||
:loading="loadingDatacenters"
|
||||
allow-search
|
||||
@change="handleDatacenterChange"
|
||||
>
|
||||
<a-option v-for="item in datacenterList" :key="item.id" :value="item.id">
|
||||
{{ item.name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="所属楼层"
|
||||
field="floor_id"
|
||||
:rules="[{ required: true, message: '请选择所属楼层' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model="form.floor_id"
|
||||
placeholder="请选择所属楼层"
|
||||
:loading="loadingFloors"
|
||||
:disabled="!form.datacenter_id"
|
||||
allow-search
|
||||
@search="handleFloorSearch"
|
||||
>
|
||||
<a-option v-for="item in floorList" :key="item.id" :value="item.id">
|
||||
{{ item.name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" field="status">
|
||||
<a-select v-model="form.status" placeholder="请选择状态">
|
||||
<a-option value="planning">规划中</a-option>
|
||||
<a-option value="construction">建设中</a-option>
|
||||
<a-option value="operating">运营中</a-option>
|
||||
<a-option value="maintenance">维护中</a-option>
|
||||
<a-option value="offline">已下线</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" field="description">
|
||||
<a-textarea
|
||||
v-model="form.description"
|
||||
placeholder="请输入描述"
|
||||
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||
:max-length="500"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { createRoom, updateRoom } from '@/api/ops/room'
|
||||
import { fetchDatacenterList } from '@/api/ops/floor'
|
||||
import { fetchFloorListByDatacenter } from '@/api/ops/floor'
|
||||
|
||||
interface Room {
|
||||
id?: number
|
||||
name?: string
|
||||
code?: string
|
||||
datacenter_id?: number
|
||||
floor_id?: number
|
||||
status?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
room: Room | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref()
|
||||
const loadingDatacenters = ref(false)
|
||||
const loadingFloors = ref(false)
|
||||
const submitting = ref(false)
|
||||
const datacenterList = ref<any[]>([])
|
||||
const floorList = ref<any[]>([])
|
||||
let floorSearchTimer: number | undefined
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
code: '',
|
||||
datacenter_id: undefined as number | undefined,
|
||||
floor_id: undefined as number | undefined,
|
||||
status: 'planning',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!props.room?.id)
|
||||
|
||||
const loadDatacenterList = async () => {
|
||||
loadingDatacenters.value = true
|
||||
try {
|
||||
const res: any = await fetchDatacenterList()
|
||||
if (res.code === 0) {
|
||||
datacenterList.value = res.details || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取数据中心列表失败:', error)
|
||||
} finally {
|
||||
loadingDatacenters.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadFloorList = async (datacenterId?: number, keyword?: string) => {
|
||||
if (!datacenterId) {
|
||||
floorList.value = []
|
||||
return
|
||||
}
|
||||
loadingFloors.value = true
|
||||
try {
|
||||
const res: any = await fetchFloorListByDatacenter(datacenterId, { name: keyword })
|
||||
if (res.code === 0) {
|
||||
const list = res.details?.data ?? res.data ?? res.details ?? []
|
||||
floorList.value = Array.isArray(list) ? list : []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取楼层列表失败:', error)
|
||||
floorList.value = []
|
||||
} finally {
|
||||
loadingFloors.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatacenterChange = async (value: number) => {
|
||||
form.value.floor_id = undefined
|
||||
await loadFloorList(value)
|
||||
}
|
||||
|
||||
const handleFloorSearch = (keyword: string) => {
|
||||
if (!form.value.datacenter_id) return
|
||||
if (floorSearchTimer) {
|
||||
window.clearTimeout(floorSearchTimer)
|
||||
}
|
||||
floorSearchTimer = window.setTimeout(() => {
|
||||
loadFloorList(form.value.datacenter_id, keyword?.trim() || undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (newVal) => {
|
||||
if (!newVal) return
|
||||
|
||||
if (props.room && isEdit.value) {
|
||||
form.value = {
|
||||
name: props.room.name || '',
|
||||
code: props.room.code || '',
|
||||
datacenter_id: props.room.datacenter_id,
|
||||
floor_id: props.room.floor_id,
|
||||
status: props.room.status || 'planning',
|
||||
description: props.room.description || '',
|
||||
}
|
||||
if (props.room.datacenter_id) {
|
||||
await loadFloorList(props.room.datacenter_id)
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
name: '',
|
||||
code: '',
|
||||
datacenter_id: undefined,
|
||||
floor_id: undefined,
|
||||
status: 'planning',
|
||||
description: '',
|
||||
}
|
||||
floorList.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: any = {
|
||||
name: form.value.name,
|
||||
code: form.value.code,
|
||||
datacenter_id: form.value.datacenter_id,
|
||||
floor_id: form.value.floor_id,
|
||||
status: form.value.status,
|
||||
description: form.value.description,
|
||||
}
|
||||
|
||||
let res
|
||||
if (isEdit.value && props.room?.id) {
|
||||
data.id = props.room.id
|
||||
res = await updateRoom(data)
|
||||
} else {
|
||||
res = await createRoom(data)
|
||||
}
|
||||
|
||||
if (res.code === 0) {
|
||||
Message.success(isEdit.value ? '编辑成功' : '创建成功')
|
||||
emit('success')
|
||||
emit('update:visible', false)
|
||||
} else {
|
||||
Message.error(res.message || (isEdit.value ? '编辑失败' : '创建失败'))
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(isEdit.value ? '编辑失败' : '创建失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleVisibleChange = (visible: boolean) => {
|
||||
emit('update:visible', visible)
|
||||
}
|
||||
|
||||
loadDatacenterList()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'RoomFormDialog',
|
||||
}
|
||||
</script>
|
||||
52
src/views/ops/pages/datacenter/room/config/columns.ts
Normal file
52
src/views/ops/pages/datacenter/room/config/columns.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
|
||||
export const columns: TableColumnData[] = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
slotName: 'index',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '机房名称',
|
||||
dataIndex: 'name',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '机房编码',
|
||||
dataIndex: 'code',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: '所属中心',
|
||||
dataIndex: 'datacenter',
|
||||
slotName: 'datacenter',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '所属楼层',
|
||||
dataIndex: 'floor',
|
||||
slotName: 'floor',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
slotName: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
slotName: 'actions',
|
||||
width: 240,
|
||||
fixed: 'right' as const,
|
||||
},
|
||||
]
|
||||
37
src/views/ops/pages/datacenter/room/config/search-form.ts
Normal file
37
src/views/ops/pages/datacenter/room/config/search-form.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
|
||||
export const searchFormConfig: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入机房名称或编码',
|
||||
},
|
||||
{
|
||||
field: 'datacenter_id',
|
||||
label: '数据中心',
|
||||
type: 'select',
|
||||
placeholder: '请选择数据中心',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
field: 'floor_id',
|
||||
label: '所属楼层',
|
||||
type: 'select',
|
||||
placeholder: '请选择所属楼层',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
placeholder: '请选择状态',
|
||||
options: [
|
||||
{ label: '规划中', value: 'planning' },
|
||||
{ label: '建设中', value: 'construction' },
|
||||
{ label: '运营中', value: 'operating' },
|
||||
{ label: '维护中', value: 'maintenance' },
|
||||
{ label: '已下线', value: 'offline' },
|
||||
],
|
||||
},
|
||||
]
|
||||
326
src/views/ops/pages/datacenter/room/index.vue
Normal file
326
src/views/ops/pages/datacenter/room/index.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="机房管理"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
新建机房
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<template #index="{ rowIndex }">
|
||||
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
|
||||
</template>
|
||||
|
||||
<template #datacenter="{ record }">
|
||||
{{ record.datacenter?.name || '-' }}
|
||||
</template>
|
||||
|
||||
<template #floor="{ record }">
|
||||
{{ record.floor?.name || '-' }}
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="statusMap[record.status]?.color || 'gray'">
|
||||
{{ statusMap[record.status]?.text || record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<room-form-dialog
|
||||
v-model:visible="formVisible"
|
||||
:room="editingRoom"
|
||||
@success="handleFormSuccess"
|
||||
/>
|
||||
|
||||
<room-detail-dialog
|
||||
v-model:visible="detailVisible"
|
||||
:room-id="currentRoomId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus } 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 { columns as columnsConfig } from './config/columns'
|
||||
import { deleteRoom, fetchRoomList } from '@/api/ops/room'
|
||||
import { fetchDatacenterList, fetchFloorListByDatacenter } from '@/api/ops/floor'
|
||||
import RoomFormDialog from './components/RoomFormDialog.vue'
|
||||
import RoomDetailDialog from './components/RoomDetailDialog.vue'
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
planning: { text: '规划中', color: 'blue' },
|
||||
construction: { text: '建设中', color: 'orange' },
|
||||
operating: { text: '运营中', color: 'green' },
|
||||
maintenance: { text: '维护中', color: 'gold' },
|
||||
offline: { text: '已下线', color: 'red' },
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
datacenter_id: undefined as number | undefined,
|
||||
floor_id: undefined as number | undefined,
|
||||
status: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const datacenterSelectOptions = ref<{ label: string; value: number }[]>([])
|
||||
const floorSelectOptions = ref<{ label: string; value: number }[]>([])
|
||||
let datacenterSearchTimer: number | undefined
|
||||
let floorSearchTimer: number | undefined
|
||||
|
||||
const formItems = computed<FormItem[]>(() =>
|
||||
searchFormConfig.map((item) => {
|
||||
if (item.field === 'datacenter_id') {
|
||||
return {
|
||||
...item,
|
||||
options: datacenterSelectOptions.value,
|
||||
allowSearch: true,
|
||||
onSearch: handleDatacenterSearch,
|
||||
}
|
||||
}
|
||||
if (item.field === 'floor_id') {
|
||||
return {
|
||||
...item,
|
||||
options: floorSelectOptions.value,
|
||||
allowSearch: true,
|
||||
onSearch: handleFloorSearch,
|
||||
disabled: !formModel.value.datacenter_id,
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
)
|
||||
|
||||
const columns = computed(() => columnsConfig)
|
||||
|
||||
const currentRoomId = ref<number | undefined>(undefined)
|
||||
const editingRoom = ref<any>(null)
|
||||
|
||||
const formVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
|
||||
const loadDatacenterOptions = async (keyword?: string) => {
|
||||
try {
|
||||
const res: any = await fetchDatacenterList({ keyword })
|
||||
if (res.code === 0) {
|
||||
const list = res.details || []
|
||||
datacenterSelectOptions.value = Array.isArray(list)
|
||||
? list.map((d: any) => ({
|
||||
label: d.name || d.code || String(d.id),
|
||||
value: d.id,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取数据中心列表失败:', error)
|
||||
Message.error('获取数据中心列表失败')
|
||||
datacenterSelectOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadFloorOptions = async (datacenterId?: number, keyword?: string) => {
|
||||
if (!datacenterId) {
|
||||
floorSelectOptions.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await fetchFloorListByDatacenter(datacenterId, { name: keyword })
|
||||
if (res.code === 0) {
|
||||
const list = res.details?.data ?? res.data ?? res.details ?? []
|
||||
floorSelectOptions.value = Array.isArray(list)
|
||||
? list.map((f: any) => ({
|
||||
label: f.name || String(f.id),
|
||||
value: f.id,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取楼层列表失败:', error)
|
||||
Message.error('获取楼层列表失败')
|
||||
floorSelectOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleDatacenterSearch = (keyword: string) => {
|
||||
if (datacenterSearchTimer) {
|
||||
window.clearTimeout(datacenterSearchTimer)
|
||||
}
|
||||
datacenterSearchTimer = window.setTimeout(() => {
|
||||
loadDatacenterOptions(keyword?.trim() || undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleFloorSearch = (keyword: string) => {
|
||||
if (!formModel.value.datacenter_id) return
|
||||
if (floorSearchTimer) {
|
||||
window.clearTimeout(floorSearchTimer)
|
||||
}
|
||||
floorSearchTimer = window.setTimeout(() => {
|
||||
loadFloorOptions(formModel.value.datacenter_id, keyword?.trim() || undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => formModel.value.datacenter_id,
|
||||
(newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
formModel.value.floor_id = undefined
|
||||
}
|
||||
loadFloorOptions(newId)
|
||||
},
|
||||
)
|
||||
|
||||
const fetchRooms = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
keyword: formModel.value.keyword || undefined,
|
||||
datacenter_id: formModel.value.datacenter_id ?? undefined,
|
||||
floor_id: formModel.value.floor_id ?? undefined,
|
||||
status: formModel.value.status || undefined,
|
||||
}
|
||||
|
||||
const res: any = await fetchRoomList(params)
|
||||
tableData.value = res.details?.data || []
|
||||
pagination.total = res.details?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取机房列表失败:', error)
|
||||
Message.error('获取机房列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchRooms()
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (value: any) => {
|
||||
formModel.value = value
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
formModel.value = {
|
||||
keyword: '',
|
||||
datacenter_id: undefined,
|
||||
floor_id: undefined,
|
||||
status: '',
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchRooms()
|
||||
}
|
||||
|
||||
const handlePageChange = (current: number) => {
|
||||
pagination.current = current
|
||||
fetchRooms()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchRooms()
|
||||
Message.success('数据已刷新')
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
editingRoom.value = null
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
editingRoom.value = record
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetail = (record: any) => {
|
||||
currentRoomId.value = record.id
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (record: any) => {
|
||||
try {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确认删除机房 ${record.name} 吗?`,
|
||||
onOk: async () => {
|
||||
const res: any = await deleteRoom(record.id)
|
||||
if (res.code === 0) {
|
||||
Message.success('删除成功')
|
||||
fetchRooms()
|
||||
} else {
|
||||
Message.error(res.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('删除机房失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
formVisible.value = false
|
||||
fetchRooms()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDatacenterOptions()
|
||||
fetchRooms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'DataCenterRoom',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -182,6 +182,7 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconStorage, IconApps, IconPlus, IconLock, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { fetchUnitList, allocateUnit, reserveUnit, cancelReservation, releaseUnit, updateUnitStatus } from '@/api/ops/unit'
|
||||
import { fetchDatacenterList, fetchRackListByRoom } from '@/api/ops/rack'
|
||||
import { fetchFloorListByDatacenter } from '@/api/ops/floor'
|
||||
@@ -210,6 +211,7 @@ let datacenterSearchTimer: number | undefined
|
||||
let floorSearchTimer: number | undefined
|
||||
let roomSearchTimer: number | undefined
|
||||
let rackSearchTimer: number | undefined
|
||||
const route = useRoute()
|
||||
|
||||
// 对话框可见性
|
||||
const allocateVisible = ref(false)
|
||||
@@ -613,6 +615,11 @@ const handleCancelReservation = async (record: any) => {
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchDatacenters()
|
||||
const rackId = Number(route.query.rack_id)
|
||||
if (Number.isFinite(rackId) && rackId > 0) {
|
||||
selectedRackId.value = rackId
|
||||
fetchUnits(rackId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,10 +10,22 @@
|
||||
<a-descriptions-item label="机房ID">{{ record.room_id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述信息" :span="2">{{ record.description || '-' }}</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="采集地址" :span="2">
|
||||
<a-link v-if="record.agent_config" :href="record.agent_config" target="_blank">{{ record.agent_config }}</a-link>
|
||||
<a-descriptions-item label="采集方式">
|
||||
<a-tag :color="record.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
||||
{{ record.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集地址/目标" :span="2">
|
||||
<a-link v-if="record.collect_method !== 'snmp' && record.agent_config" :href="record.agent_config" target="_blank">{{ record.agent_config }}</a-link>
|
||||
<span v-else-if="record.collect_method === 'snmp'">{{ record.snmp_target || '-' }}</span>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="record.collect_method === 'snmp'" label="SNMP连接">
|
||||
{{ (record.snmp_community || '-') + ' @ ' + (record.snmp_port || 161) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="record.collect_method === 'snmp'" label="SNMP超时/重试">
|
||||
{{ (record.snmp_timeout_ms || 3000) + 'ms / ' + (record.snmp_retries ?? 1) }}
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="启用状态">
|
||||
<a-tag :color="record.enabled ? 'green' : 'gray'">
|
||||
@@ -96,7 +108,11 @@ const handleViewMetrics = async () => {
|
||||
metricsLoading.value = true
|
||||
try {
|
||||
const response = await fetchLatestMetrics(props.record.service_identity)
|
||||
metricsData.value = (response as any)?.details?.data || []
|
||||
const metrics =
|
||||
response?.code === 0 && response.details?.metrics && Array.isArray(response.details.metrics)
|
||||
? response.details.metrics
|
||||
: []
|
||||
metricsData.value = metrics
|
||||
} catch (error) {
|
||||
console.error('获取最新指标失败:', error)
|
||||
Message.error('获取最新指标失败')
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="room_id" label="机房ID">
|
||||
<a-input v-model="formData.room_id" placeholder="请输入机房ID" />
|
||||
<a-form-item field="room_id" label="机房">
|
||||
<a-select v-model="formData.room_id" placeholder="请选择机房" allow-search allow-clear>
|
||||
<a-option v-for="item in roomOptions" :key="item.id" :value="item.id" :label="item.name">
|
||||
{{ item.name }} ({{ item.code }})
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -38,10 +42,58 @@
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="agent_config" label="采集地址(HTTP/HTTPS GET)">
|
||||
<a-input v-model="formData.agent_config" placeholder="周期采集地址,返回 JSON 格式指标数组" />
|
||||
<a-divider orientation="left">采集协议与连接</a-divider>
|
||||
<a-form-item field="collect_method" label="采集方式">
|
||||
<a-radio-group v-model="formData.collect_method" type="button">
|
||||
<a-radio value="api">API</a-radio>
|
||||
<a-radio value="snmp">SNMP</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="formData.collect_method === 'api'">
|
||||
<a-form-item field="agent_config" label="采集地址(HTTP/HTTPS GET)">
|
||||
<a-input v-model="formData.agent_config" placeholder="周期采集地址,返回 JSON 格式指标数组" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_target" label="SNMP目标地址">
|
||||
<a-input v-model="formData.snmp_target" placeholder="例如 192.168.1.10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_port" label="SNMP端口">
|
||||
<a-input-number v-model="formData.snmp_port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_community" label="Community">
|
||||
<a-input v-model="formData.snmp_community" placeholder="SNMP v2c community" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item field="snmp_timeout_ms" label="超时(ms)">
|
||||
<a-input-number v-model="formData.snmp_timeout_ms" :min="1" :max="60000" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item field="snmp_retries" label="重试次数">
|
||||
<a-input-number v-model="formData.snmp_retries" :min="0" :max="10" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置(JSON数组)">
|
||||
<a-textarea
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="enabled" label="启用设备">
|
||||
@@ -86,6 +138,7 @@ import {
|
||||
type RoomDeviceUpdateData,
|
||||
} from '@/api/ops/room-device'
|
||||
import { fetchPolicyOptions, type PolicyOptionItem } from '@/api/ops/alertPolicy'
|
||||
import { fetchRoomOptions, type RoomOptionItem } from '@/api/ops/room'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -101,15 +154,23 @@ const emit = defineEmits(['update:visible', 'success'])
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
const policyOptions = ref<PolicyOptionItem[]>([])
|
||||
const roomOptions = ref<RoomOptionItem[]>([])
|
||||
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
room_id: '',
|
||||
room_id: undefined as number | undefined,
|
||||
device_category: '',
|
||||
agent_config: '',
|
||||
collect_method: 'api' as 'api' | 'snmp',
|
||||
snmp_target: '',
|
||||
snmp_port: 161,
|
||||
snmp_community: '',
|
||||
snmp_timeout_ms: 3000,
|
||||
snmp_retries: 1,
|
||||
snmp_oids: '',
|
||||
enabled: true,
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
@@ -118,7 +179,7 @@ const formData = reactive({
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入服务名称' }],
|
||||
room_id: [{ required: true, message: '请输入机房ID' }],
|
||||
room_id: [{ required: true, message: '请选择机房' }],
|
||||
device_category: [{ required: true, message: '请选择设备分类' }],
|
||||
}
|
||||
|
||||
@@ -138,6 +199,22 @@ const loadPolicyOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRoomOptions = async () => {
|
||||
try {
|
||||
const response: any = await fetchRoomOptions({ enabled: true })
|
||||
if (Array.isArray(response)) {
|
||||
roomOptions.value = response
|
||||
} else if (response && response.details) {
|
||||
roomOptions.value = Array.isArray(response.details) ? response.details : response.details.data || []
|
||||
} else {
|
||||
roomOptions.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载机房列表失败:', error)
|
||||
roomOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
@@ -146,9 +223,16 @@ watch(
|
||||
Object.assign(formData, {
|
||||
name: props.record.name || '',
|
||||
description: props.record.description || '',
|
||||
room_id: props.record.room_id || '',
|
||||
room_id: props.record.room_id ?? undefined,
|
||||
device_category: props.record.device_category || '',
|
||||
agent_config: props.record.agent_config || '',
|
||||
collect_method: props.record.collect_method || 'api',
|
||||
snmp_target: props.record.snmp_target || '',
|
||||
snmp_port: props.record.snmp_port || 161,
|
||||
snmp_community: props.record.snmp_community || '',
|
||||
snmp_timeout_ms: props.record.snmp_timeout_ms || 3000,
|
||||
snmp_retries: props.record.snmp_retries ?? 1,
|
||||
snmp_oids: props.record.snmp_oids || '',
|
||||
enabled: props.record.enabled ?? true,
|
||||
collect_on: props.record.collect_on ?? true,
|
||||
collect_interval: props.record.collect_interval || 60,
|
||||
@@ -158,9 +242,16 @@ watch(
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
description: '',
|
||||
room_id: '',
|
||||
room_id: undefined,
|
||||
device_category: '',
|
||||
agent_config: '',
|
||||
collect_method: 'api',
|
||||
snmp_target: '',
|
||||
snmp_port: 161,
|
||||
snmp_community: '',
|
||||
snmp_timeout_ms: 3000,
|
||||
snmp_retries: 1,
|
||||
snmp_oids: '',
|
||||
enabled: true,
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
@@ -174,6 +265,24 @@ watch(
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
if (formData.collect_method === 'api' && !formData.agent_config?.trim()) {
|
||||
Message.warning('API 模式下请填写采集地址')
|
||||
return
|
||||
}
|
||||
if (formData.collect_method === 'snmp') {
|
||||
if (!formData.snmp_target?.trim() || !formData.snmp_community?.trim()) {
|
||||
Message.warning('SNMP 模式下请填写目标地址和 community')
|
||||
return
|
||||
}
|
||||
if (formData.snmp_oids?.trim()) {
|
||||
try {
|
||||
JSON.parse(formData.snmp_oids)
|
||||
} catch {
|
||||
Message.warning('SNMP OID 配置必须是合法 JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
@@ -184,13 +293,24 @@ const handleOk = async () => {
|
||||
room_id: formData.room_id,
|
||||
device_category: formData.device_category,
|
||||
agent_config: formData.agent_config,
|
||||
collect_method: formData.collect_method,
|
||||
snmp_target: formData.snmp_target,
|
||||
snmp_port: formData.snmp_port,
|
||||
snmp_community: formData.snmp_community,
|
||||
snmp_timeout_ms: formData.snmp_timeout_ms,
|
||||
snmp_retries: formData.snmp_retries,
|
||||
snmp_oids: formData.snmp_oids,
|
||||
enabled: formData.enabled,
|
||||
collect_on: formData.collect_on,
|
||||
collect_interval: formData.collect_interval,
|
||||
policy_ids: formData.policy_ids,
|
||||
}
|
||||
await updateRoomDevice(props.record.id, updateData)
|
||||
Message.success('更新成功')
|
||||
const res: any = await updateRoomDevice(props.record.id, updateData)
|
||||
if (res.code === 0) {
|
||||
Message.success('更新成功')
|
||||
} else {
|
||||
Message.error(res.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
const createData: RoomDeviceCreateData = {
|
||||
name: formData.name,
|
||||
@@ -198,13 +318,24 @@ const handleOk = async () => {
|
||||
room_id: formData.room_id,
|
||||
device_category: formData.device_category,
|
||||
agent_config: formData.agent_config,
|
||||
collect_method: formData.collect_method,
|
||||
snmp_target: formData.snmp_target,
|
||||
snmp_port: formData.snmp_port,
|
||||
snmp_community: formData.snmp_community,
|
||||
snmp_timeout_ms: formData.snmp_timeout_ms,
|
||||
snmp_retries: formData.snmp_retries,
|
||||
snmp_oids: formData.snmp_oids,
|
||||
enabled: formData.enabled,
|
||||
collect_on: formData.collect_on,
|
||||
collect_interval: formData.collect_interval,
|
||||
policy_ids: formData.policy_ids,
|
||||
}
|
||||
await createRoomDevice(createData)
|
||||
Message.success('创建成功')
|
||||
const res: any = await createRoomDevice(createData)
|
||||
if (res.code === 0) {
|
||||
Message.success('创建成功')
|
||||
} else {
|
||||
Message.error(res.message || '创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
emit('success')
|
||||
@@ -228,5 +359,6 @@ const handleCancel = () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadPolicyOptions()
|
||||
loadRoomOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
<template>
|
||||
<a-modal :visible="visible" title="采集配置" :mask-closable="false" :ok-loading="loading" @ok="handleSubmit" @cancel="handleCancel">
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="采集方式">
|
||||
<a-radio-group v-model="form.collect_method" type="button">
|
||||
<a-radio value="api">API</a-radio>
|
||||
<a-radio value="snmp">SNMP</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="form.collect_method === 'api'">
|
||||
<a-form-item label="采集地址(HTTP/HTTPS GET)">
|
||||
<a-input v-model="form.agent_config" placeholder="周期采集地址,返回 JSON 格式指标数组" />
|
||||
<template #extra>
|
||||
<span style="color: #86909c">采集地址应返回 JSON 格式的指标数据,支持 {"metrics":[...]} 或数组形式</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item label="SNMP目标地址">
|
||||
<a-input v-model="form.snmp_target" placeholder="例如 192.168.1.10" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Community">
|
||||
<a-input v-model="form.snmp_community" placeholder="SNMP v2c community" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item label="参与周期采集">
|
||||
<a-switch v-model="form.collect_on" />
|
||||
</a-form-item>
|
||||
@@ -12,12 +36,6 @@
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="采集地址(HTTP/HTTPS GET)">
|
||||
<a-input v-model="form.agent_config" placeholder="周期采集地址,返回 JSON 格式指标数组" />
|
||||
<template #extra>
|
||||
<span style="color: #86909c">采集地址应返回 JSON 格式的指标数据,支持 {"metrics":[...]} 或数组形式</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -38,18 +56,24 @@ const emit = defineEmits(['update:visible', 'success'])
|
||||
const loading = ref(false)
|
||||
|
||||
const form = ref({
|
||||
collect_method: 'api' as 'api' | 'snmp',
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
agent_config: '',
|
||||
snmp_target: '',
|
||||
snmp_community: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.record) {
|
||||
form.value.collect_method = props.record.collect_method || 'api'
|
||||
form.value.collect_on = props.record.collect_on ?? true
|
||||
form.value.collect_interval = props.record.collect_interval || 60
|
||||
form.value.agent_config = props.record.agent_config || ''
|
||||
form.value.snmp_target = props.record.snmp_target || ''
|
||||
form.value.snmp_community = props.record.snmp_community || ''
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -58,9 +82,12 @@ const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data: RoomDeviceCollectData = {
|
||||
collect_method: form.value.collect_method,
|
||||
collect_on: form.value.collect_on,
|
||||
collect_interval: form.value.collect_interval,
|
||||
agent_config: form.value.agent_config,
|
||||
agent_config: form.value.collect_method === 'api' ? form.value.agent_config : undefined,
|
||||
snmp_target: form.value.collect_method === 'snmp' ? form.value.snmp_target : undefined,
|
||||
snmp_community: form.value.collect_method === 'snmp' ? form.value.snmp_community : undefined,
|
||||
}
|
||||
|
||||
await patchRoomDeviceCollect(props.record.id, data)
|
||||
|
||||
@@ -38,6 +38,12 @@ export const columns = [
|
||||
width: 100,
|
||||
slotName: 'enabled',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_method',
|
||||
title: '采集方式',
|
||||
width: 100,
|
||||
slotName: 'collect_method',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_on',
|
||||
title: '数据采集',
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #collect_method="{ record }">
|
||||
<a-tag :color="record.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
||||
{{ record.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
|
||||
214
src/views/ops/pages/dc/security/components/Detail.vue
Normal file
214
src/views/ops/pages/dc/security/components/Detail.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div class="detail-container">
|
||||
<a-divider orientation="left">基础信息</a-divider>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务标识">{{ record.service_identity }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务名称">{{ record.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="设备类型">
|
||||
{{ SECURITY_TYPE_MAP[record.type] || record.type }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="OID">{{ record.oid || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务器标识">{{ record.server_identity || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述信息" :span="2">{{ record.description || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatTime(record.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatTime(record.updated_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">采集配置</a-divider>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="采集方式">
|
||||
<a-tag :color="record.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
||||
{{ record.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集间隔">{{ record.collect_interval }}秒</a-descriptions-item>
|
||||
<a-descriptions-item label="采集地址" :span="2">
|
||||
<a-link v-if="record.collect_method !== 'snmp' && record.agent_config" :href="record.agent_config" target="_blank">{{ record.agent_config }}</a-link>
|
||||
<span v-else-if="record.collect_method === 'snmp'">{{ record.snmp_target || '-' }}</span>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="record.collect_method === 'snmp'" label="SNMP连接">
|
||||
{{ (record.snmp_community || '-') + ' @ ' + (record.snmp_port || 161) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="record.collect_method === 'snmp'" label="SNMP超时/重试">
|
||||
{{ (record.snmp_timeout_ms || 3000) + 'ms / ' + (record.snmp_retries ?? 1) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="启用周期采集">
|
||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集参数" :span="2">{{ record.collect_args || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="采集结果" :span="2">{{ record.collect_last_result || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">运行状态</a-divider>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="运行状态">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ STATUS_MAP[record.status] || record.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态码">{{ record.status_code || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态信息" :span="2">{{ record.status_message || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ record.response_time ? `${record.response_time.toFixed(2)} ms` : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="连续错误">
|
||||
<a-tag v-if="record.continuous_errors > 0" color="red">{{ record.continuous_errors }}</a-tag>
|
||||
<span v-else>0</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最近检查">{{ formatTime(record.last_check_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="运行时长">{{ formatUptime(record.uptime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最近在线">{{ formatTime(record.last_online_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最近离线">{{ formatTime(record.last_offline_time) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="action-bar">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="$emit('edit')">
|
||||
<template #icon><icon-edit /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="outline" @click="$emit('quick-config')">
|
||||
<template #icon><icon-settings /></template>
|
||||
采集配置
|
||||
</a-button>
|
||||
<a-button type="outline" @click="handleViewMetrics">
|
||||
<template #icon><icon-dashboard /></template>
|
||||
查看最新指标
|
||||
</a-button>
|
||||
<a-button type="outline" status="danger" @click="$emit('delete')">
|
||||
<template #icon><icon-delete /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-drawer v-model:visible="metricsVisible" :width="800" title="最新指标数据" :footer="false" unmount-on-close>
|
||||
<a-spin :loading="metricsLoading" style="width: 100%">
|
||||
<div v-if="metricsData && metricsData.metrics && metricsData.metrics.length > 0">
|
||||
<a-alert type="info" style="margin-bottom: 16px">
|
||||
<template #message>
|
||||
<div>最新采集时间: {{ formatTime(metricsData.latest_timestamp) || '-' }}</div>
|
||||
<div>指标数量: {{ metricsData.count }}</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-row :gutter="16">
|
||||
<a-col v-for="(item, index) in metricsData.metrics" :key="index" :span="8">
|
||||
<a-card class="metric-card" :bordered="false">
|
||||
<div class="metric-name">{{ item.metric_name }}</div>
|
||||
<div class="metric-value">{{ item.metric_value }} {{ item.metric_unit || '' }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-empty v-else description="暂无指标数据" />
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconEdit, IconDelete, IconSettings, IconDashboard } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
SECURITY_TYPE_MAP,
|
||||
STATUS_MAP,
|
||||
fetchSecurityMetricsLatest,
|
||||
type SecurityServiceItem,
|
||||
type SecurityMetricsLatestData,
|
||||
} from '@/api/ops/security'
|
||||
|
||||
interface Props {
|
||||
record: SecurityServiceItem
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits(['edit', 'quick-config', 'delete'])
|
||||
|
||||
const metricsVisible = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const metricsData = ref<SecurityMetricsLatestData | null>(null)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'gray',
|
||||
error: 'red',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
const handleViewMetrics = async () => {
|
||||
metricsVisible.value = true
|
||||
metricsLoading.value = true
|
||||
try {
|
||||
const response: any = await fetchSecurityMetricsLatest(props.record.service_identity)
|
||||
metricsData.value = response?.details || null
|
||||
} catch (error) {
|
||||
console.error('获取最新指标失败:', error)
|
||||
Message.error('获取最新指标失败')
|
||||
metricsData.value = null
|
||||
} finally {
|
||||
metricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
const formatUptime = (uptime: number) => {
|
||||
if (!uptime || uptime === 0) return '-'
|
||||
const days = Math.floor(uptime / 86400)
|
||||
const hours = Math.floor((uptime % 86400) / 3600)
|
||||
const minutes = Math.floor((uptime % 3600) / 60)
|
||||
if (days > 0) return `${days}天 ${hours}小时`
|
||||
if (hours > 0) return `${hours}小时 ${minutes}分钟`
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.arco-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
396
src/views/ops/pages/dc/security/components/FormDialog.vue
Normal file
396
src/views/ops/pages/dc/security/components/FormDialog.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑安全设备服务' : '新增安全设备服务'"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="confirmLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="服务名称">
|
||||
<a-input v-model="formData.name" placeholder="请输入服务名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="设备类型">
|
||||
<a-select v-model="formData.type" placeholder="请选择设备类型">
|
||||
<a-option v-for="item in SECURITY_TYPE_OPTIONS" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="server_identity" label="服务器">
|
||||
<a-select v-model="formData.server_identity" placeholder="请选择服务器" allow-search allow-clear>
|
||||
<a-option v-for="server in serverOptions" :key="server.server_identity" :value="server.server_identity">
|
||||
{{ server.name }} ({{ server.server_identity }})
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="oid" label="OID">
|
||||
<a-input v-model="formData.oid" placeholder="请输入OID" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="interval" label="检查间隔(秒)">
|
||||
<a-input-number v-model="formData.interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="description" label="描述信息">
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">采集协议与连接</a-divider>
|
||||
<a-form-item field="collect_method" label="采集方式">
|
||||
<a-radio-group v-model="formData.collect_method" type="button">
|
||||
<a-radio value="api">API</a-radio>
|
||||
<a-radio value="snmp">SNMP</a-radio>
|
||||
</a-radio-group>
|
||||
<template #extra>
|
||||
<span class="form-extra">推荐优先 API;设备无 API 时可切到 SNMP v2c</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="formData.collect_method === 'api'">
|
||||
<a-form-item field="agent_config" label="采集地址(HTTP/HTTPS GET)">
|
||||
<a-input v-model="formData.agent_config" placeholder="周期采集地址,返回 JSON 格式指标数组" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_target" label="SNMP目标地址">
|
||||
<a-input v-model="formData.snmp_target" placeholder="例如 192.168.1.10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_port" label="SNMP端口">
|
||||
<a-input-number v-model="formData.snmp_port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_community" label="Community">
|
||||
<a-input v-model="formData.snmp_community" placeholder="SNMP v2c community" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item field="snmp_timeout_ms" label="超时(ms)">
|
||||
<a-input-number v-model="formData.snmp_timeout_ms" :min="1" :max="60000" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item field="snmp_retries" label="重试次数">
|
||||
<a-input-number v-model="formData.snmp_retries" :min="0" :max="10" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置(JSON数组)">
|
||||
<a-textarea
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime","metric_unit":"timeticks"}]'
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="enabled" label="启用监控">
|
||||
<a-switch v-model="formData.enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_on" label="启用周期采集">
|
||||
<a-switch v-model="formData.collect_on" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number v-model="formData.collect_interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_args" label="采集参数">
|
||||
<a-input v-model="formData.collect_args" placeholder="JSON 格式采集参数" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="extra" label="扩展配置(JSON)">
|
||||
<a-textarea v-model="formData.extra" placeholder="请输入 JSON 格式扩展配置" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="policy_ids" label="告警策略">
|
||||
<a-select v-model="formData.policy_ids" placeholder="请选择告警策略" multiple allow-clear>
|
||||
<a-option v-for="policy in policyOptions" :key="policy.id" :value="policy.id">
|
||||
{{ policy.name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { createSecurityService, updateSecurityService, SECURITY_TYPE_OPTIONS, type SecurityServiceFormData } from '@/api/ops/security'
|
||||
import { fetchPolicyOptions, type PolicyOptionItem } from '@/api/ops/alertPolicy'
|
||||
import { fetchServerList, type ServerItem } from '@/api/ops/server'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
const policyOptions = ref<PolicyOptionItem[]>([])
|
||||
const serverOptions = ref<ServerItem[]>([])
|
||||
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const formData = reactive({
|
||||
service_identity: '',
|
||||
name: '',
|
||||
type: '',
|
||||
server_identity: '',
|
||||
oid: '',
|
||||
interval: 60,
|
||||
description: '',
|
||||
agent_config: '',
|
||||
collect_method: 'api' as 'api' | 'snmp',
|
||||
snmp_target: '',
|
||||
snmp_port: 161,
|
||||
snmp_community: '',
|
||||
snmp_timeout_ms: 3000,
|
||||
snmp_retries: 1,
|
||||
snmp_oids: '',
|
||||
enabled: true,
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
collect_args: '',
|
||||
extra: '',
|
||||
policy_ids: [] as number[],
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入服务名称' }],
|
||||
type: [{ required: true, message: '请选择设备类型' }],
|
||||
}
|
||||
|
||||
const loadPolicyOptions = async () => {
|
||||
try {
|
||||
const response: any = await fetchPolicyOptions({ enabled: true })
|
||||
if (Array.isArray(response)) {
|
||||
policyOptions.value = response
|
||||
} else if (response && response.details) {
|
||||
policyOptions.value = Array.isArray(response.details) ? response.details : response.details.data || []
|
||||
} else {
|
||||
policyOptions.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载告警策略列表失败:', error)
|
||||
policyOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadServerOptions = async () => {
|
||||
try {
|
||||
const response: any = await fetchServerList({ page: 1, size: 1000 })
|
||||
if (response && response.details) {
|
||||
serverOptions.value = response.details.data || []
|
||||
} else {
|
||||
serverOptions.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载服务器列表失败:', error)
|
||||
serverOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (isEdit.value && props.record) {
|
||||
Object.assign(formData, {
|
||||
service_identity: props.record.service_identity || '',
|
||||
name: props.record.name || '',
|
||||
type: props.record.type || '',
|
||||
server_identity: props.record.server_identity || '',
|
||||
oid: props.record.oid || '',
|
||||
interval: props.record.interval || 60,
|
||||
description: props.record.description || '',
|
||||
agent_config: props.record.agent_config || '',
|
||||
collect_method: props.record.collect_method || 'api',
|
||||
snmp_target: props.record.snmp_target || '',
|
||||
snmp_port: props.record.snmp_port || 161,
|
||||
snmp_community: props.record.snmp_community || '',
|
||||
snmp_timeout_ms: props.record.snmp_timeout_ms || 3000,
|
||||
snmp_retries: props.record.snmp_retries ?? 1,
|
||||
snmp_oids: props.record.snmp_oids || '',
|
||||
enabled: props.record.enabled ?? true,
|
||||
collect_on: props.record.collect_on ?? true,
|
||||
collect_interval: props.record.collect_interval || 60,
|
||||
collect_args: props.record.collect_args || '',
|
||||
extra: props.record.extra || '',
|
||||
policy_ids: props.record.policy_ids || [],
|
||||
})
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
service_identity: '',
|
||||
name: '',
|
||||
type: '',
|
||||
server_identity: '',
|
||||
oid: '',
|
||||
interval: 60,
|
||||
description: '',
|
||||
agent_config: '',
|
||||
collect_method: 'api',
|
||||
snmp_target: '',
|
||||
snmp_port: 161,
|
||||
snmp_community: '',
|
||||
snmp_timeout_ms: 3000,
|
||||
snmp_retries: 1,
|
||||
snmp_oids: '',
|
||||
enabled: true,
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
collect_args: '',
|
||||
extra: '',
|
||||
policy_ids: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
if (formData.collect_method === 'api' && !formData.agent_config?.trim()) {
|
||||
Message.warning('API 模式下请填写采集地址')
|
||||
return
|
||||
}
|
||||
if (formData.collect_method === 'snmp') {
|
||||
if (!formData.snmp_target?.trim()) {
|
||||
Message.warning('SNMP 模式下请填写目标地址')
|
||||
return
|
||||
}
|
||||
if (!formData.snmp_community?.trim()) {
|
||||
Message.warning('SNMP 模式下请填写 community')
|
||||
return
|
||||
}
|
||||
if (formData.snmp_oids?.trim()) {
|
||||
try {
|
||||
JSON.parse(formData.snmp_oids)
|
||||
} catch {
|
||||
Message.warning('SNMP OID配置必须是合法 JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
const submitData: SecurityServiceFormData = {
|
||||
service_identity: formData.service_identity,
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
server_identity: formData.server_identity,
|
||||
oid: formData.oid,
|
||||
interval: formData.interval,
|
||||
description: formData.description,
|
||||
agent_config: formData.agent_config,
|
||||
collect_method: formData.collect_method,
|
||||
snmp_target: formData.snmp_target,
|
||||
snmp_port: formData.snmp_port,
|
||||
snmp_community: formData.snmp_community,
|
||||
snmp_timeout_ms: formData.snmp_timeout_ms,
|
||||
snmp_retries: formData.snmp_retries,
|
||||
snmp_oids: formData.snmp_oids,
|
||||
enabled: formData.enabled,
|
||||
collect_on: formData.collect_on,
|
||||
collect_interval: formData.collect_interval,
|
||||
collect_args: formData.collect_args,
|
||||
extra: formData.extra,
|
||||
policy_ids: formData.policy_ids,
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const res = await updateSecurityService(props.record.id, submitData)
|
||||
if (res.code === 0) {
|
||||
Message.success('更新成功')
|
||||
} else {
|
||||
Message.success(res.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
const res = await createSecurityService(submitData)
|
||||
if (res.code === 0) {
|
||||
Message.success('创建成功')
|
||||
} else {
|
||||
Message.success(res.message || '创建失败')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
Message.error('操作失败')
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPolicyOptions()
|
||||
loadServerOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.form-extra {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
121
src/views/ops/pages/dc/security/components/QuickConfigDialog.vue
Normal file
121
src/views/ops/pages/dc/security/components/QuickConfigDialog.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="采集配置"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="confirmLoading"
|
||||
width="500px"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" layout="vertical">
|
||||
<a-form-item field="collect_method" label="采集方式">
|
||||
<a-radio-group v-model="formData.collect_method" type="button">
|
||||
<a-radio value="api">API</a-radio>
|
||||
<a-radio value="snmp">SNMP</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="formData.collect_method === 'api'">
|
||||
<a-form-item field="agent_config" label="采集地址(HTTP/HTTPS GET)">
|
||||
<a-input v-model="formData.agent_config" placeholder="周期采集地址,返回 JSON 格式指标数组" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item field="snmp_target" label="SNMP目标地址">
|
||||
<a-input v-model="formData.snmp_target" placeholder="例如 192.168.1.10" />
|
||||
</a-form-item>
|
||||
<a-form-item field="snmp_community" label="Community">
|
||||
<a-input v-model="formData.snmp_community" placeholder="SNMP v2c community" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item field="collect_on" label="启用周期采集">
|
||||
<a-switch v-model="formData.collect_on" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number v-model="formData.collect_interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-alert type="warning" style="margin-top: 16px">快速修改采集配置,无需编辑完整表单</a-alert>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { patchSecurityServiceCollect, type SecurityServicePatchData } from '@/api/ops/security'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
collect_method: 'api' as 'api' | 'snmp',
|
||||
agent_config: '',
|
||||
snmp_target: '',
|
||||
snmp_community: '',
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.record) {
|
||||
formData.collect_method = props.record.collect_method || 'api'
|
||||
formData.agent_config = props.record.agent_config || ''
|
||||
formData.snmp_target = props.record.snmp_target || ''
|
||||
formData.snmp_community = props.record.snmp_community || ''
|
||||
formData.collect_on = props.record.collect_on ?? true
|
||||
formData.collect_interval = props.record.collect_interval || 60
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
confirmLoading.value = true
|
||||
|
||||
const patchData: SecurityServicePatchData = {
|
||||
collect_method: formData.collect_method,
|
||||
agent_config: formData.collect_method === 'api' ? formData.agent_config : undefined,
|
||||
snmp_target: formData.collect_method === 'snmp' ? formData.snmp_target : undefined,
|
||||
snmp_community: formData.collect_method === 'snmp' ? formData.snmp_community : undefined,
|
||||
collect_on: formData.collect_on,
|
||||
collect_interval: formData.collect_interval,
|
||||
}
|
||||
|
||||
await patchSecurityServiceCollect(props.record.id, patchData)
|
||||
Message.success('采集配置已更新')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} catch (error) {
|
||||
console.error('更新采集配置失败:', error)
|
||||
Message.error('更新失败')
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
91
src/views/ops/pages/dc/security/config/columns.ts
Normal file
91
src/views/ops/pages/dc/security/config/columns.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { SECURITY_TYPE_MAP, STATUS_MAP } from '@/api/ops/security'
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
dataIndex: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '服务名称',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'service_identity',
|
||||
title: '服务标识',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'type',
|
||||
title: '设备类型',
|
||||
width: 100,
|
||||
render: ({ record }: any) => {
|
||||
return SECURITY_TYPE_MAP[record.type] || record.type
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'server_identity',
|
||||
title: '服务器',
|
||||
width: 150,
|
||||
slotName: 'serverIdentity',
|
||||
},
|
||||
{
|
||||
dataIndex: 'enabled',
|
||||
title: '启用状态',
|
||||
width: 100,
|
||||
slotName: 'enabled',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_method',
|
||||
title: '采集方式',
|
||||
width: 100,
|
||||
slotName: 'collectMethod',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_on',
|
||||
title: '采集状态',
|
||||
width: 100,
|
||||
slotName: 'collectOn',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_interval',
|
||||
title: '采集间隔(秒)',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '运行状态',
|
||||
width: 100,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'response_time',
|
||||
title: '响应时间',
|
||||
width: 120,
|
||||
slotName: 'responseTime',
|
||||
},
|
||||
{
|
||||
dataIndex: 'last_check_time',
|
||||
title: '最近检查',
|
||||
width: 180,
|
||||
slotName: 'lastCheckTime',
|
||||
},
|
||||
{
|
||||
dataIndex: 'continuous_errors',
|
||||
title: '连续错误',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
31
src/views/ops/pages/dc/security/config/search-form.ts
Normal file
31
src/views/ops/pages/dc/security/config/search-form.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import { SECURITY_TYPE_OPTIONS } from '@/api/ops/security'
|
||||
|
||||
export const searchFormConfig: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '名称/标识模糊搜索',
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
label: '启用状态',
|
||||
type: 'select',
|
||||
placeholder: '请选择启用状态',
|
||||
options: [
|
||||
{ label: '已启用', value: true },
|
||||
{ label: '已禁用', value: false },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
label: '设备类型',
|
||||
type: 'select',
|
||||
placeholder: '请选择设备类型',
|
||||
options: SECURITY_TYPE_OPTIONS,
|
||||
span: 6,
|
||||
},
|
||||
]
|
||||
337
src/views/ops/pages/dc/security/index.vue
Normal file
337
src/views/ops/pages/dc/security/index.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="安全设备服务"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
新增设备
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<template #serverIdentity="{ record }">
|
||||
<span>{{ getServerName(record.server_identity) }}</span>
|
||||
</template>
|
||||
|
||||
<template #enabled="{ record }">
|
||||
<a-tag :color="record.enabled ? 'green' : 'gray'">
|
||||
{{ record.enabled ? '已启用' : '已禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #collectOn="{ record }">
|
||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #collectMethod="{ record }">
|
||||
<a-tag :color="record.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
||||
{{ record.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ STATUS_MAP[record.status] || '未知' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #responseTime="{ record }">
|
||||
<span v-if="record.response_time">{{ record.response_time.toFixed(2) }} ms</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #lastCheckTime="{ record }">
|
||||
<span>{{ formatTime(record.last_check_time) }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-dropdown trigger="hover">
|
||||
<a-button type="primary" size="small">
|
||||
管理
|
||||
<icon-down />
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="handleDetail(record)">
|
||||
<template #icon>
|
||||
<icon-eye />
|
||||
</template>
|
||||
详情
|
||||
</a-doption>
|
||||
<a-doption @click="handleEdit(record)">
|
||||
<template #icon>
|
||||
<icon-edit />
|
||||
</template>
|
||||
编辑
|
||||
</a-doption>
|
||||
<a-doption @click="handleQuickConfig(record)">
|
||||
<template #icon>
|
||||
<icon-settings />
|
||||
</template>
|
||||
采集配置
|
||||
</a-doption>
|
||||
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
删除
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<FormDialog v-model:visible="formDialogVisible" :record="currentRecord" @success="handleFormSuccess" />
|
||||
|
||||
<QuickConfigDialog v-model:visible="quickConfigVisible" :record="currentRecord" @success="handleFormSuccess" />
|
||||
|
||||
<a-drawer v-model:visible="detailVisible" :width="800" title="安全设备服务详情" :footer="false" unmount-on-close>
|
||||
<Detail
|
||||
v-if="currentRecord"
|
||||
:record="currentRecord"
|
||||
@edit="handleDetailEdit"
|
||||
@quick-config="handleDetailQuickConfig"
|
||||
@delete="handleDetailDelete"
|
||||
/>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus, IconDown, IconEdit, IconDelete, IconEye, IconSettings } 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 FormDialog from './components/FormDialog.vue'
|
||||
import QuickConfigDialog from './components/QuickConfigDialog.vue'
|
||||
import Detail from './components/Detail.vue'
|
||||
import { columns as columnsConfig } from './config/columns'
|
||||
import {
|
||||
fetchSecurityServiceList,
|
||||
deleteSecurityService,
|
||||
STATUS_MAP,
|
||||
type SecurityServiceItem,
|
||||
type SecurityServiceListParams,
|
||||
} from '@/api/ops/security'
|
||||
import { fetchServerList, type ServerItem } from '@/api/ops/server'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<SecurityServiceItem[]>([])
|
||||
const serverOptions = ref<ServerItem[]>([])
|
||||
const formDialogVisible = ref(false)
|
||||
const quickConfigVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<SecurityServiceItem | null>(null)
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
enabled: undefined as boolean | undefined,
|
||||
type: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => searchFormConfig)
|
||||
const columns = computed(() => columnsConfig)
|
||||
|
||||
const fetchSecurityServiceData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const params: SecurityServiceListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
keyword: formModel.value.keyword,
|
||||
enabled: formModel.value.enabled,
|
||||
}
|
||||
|
||||
if (formModel.value.type) {
|
||||
;(params as any).type = formModel.value.type
|
||||
}
|
||||
|
||||
const response: any = await fetchSecurityServiceList(params)
|
||||
|
||||
if (response && response.details) {
|
||||
tableData.value = response.details?.data || []
|
||||
pagination.total = response.details?.total || 0
|
||||
} else {
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取安全设备服务列表失败:', error)
|
||||
Message.error('获取安全设备服务列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (value: any) => {
|
||||
formModel.value = value
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
formModel.value = {
|
||||
keyword: '',
|
||||
enabled: undefined,
|
||||
type: undefined,
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handlePageChange = (current: number) => {
|
||||
pagination.current = current
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchSecurityServiceData()
|
||||
Message.success('数据已刷新')
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
currentRecord.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleQuickConfig = (record: SecurityServiceItem) => {
|
||||
currentRecord.value = record
|
||||
quickConfigVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: SecurityServiceItem) => {
|
||||
currentRecord.value = record
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetail = (record: SecurityServiceItem) => {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetailEdit = () => {
|
||||
detailVisible.value = false
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetailQuickConfig = () => {
|
||||
detailVisible.value = false
|
||||
quickConfigVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetailDelete = () => {
|
||||
detailVisible.value = false
|
||||
if (currentRecord.value) {
|
||||
handleDelete(currentRecord.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handleDelete = (record: SecurityServiceItem) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确认删除安全设备服务 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteSecurityService(record.id)
|
||||
Message.success('删除成功')
|
||||
fetchSecurityServiceData()
|
||||
} catch (error) {
|
||||
console.error('删除安全设备服务失败:', error)
|
||||
Message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'gray',
|
||||
error: 'red',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
const getServerName = (serverIdentity: string) => {
|
||||
if (!serverIdentity) return '-'
|
||||
const server = serverOptions.value.find((s) => s.server_identity === serverIdentity)
|
||||
return server ? `${server.name}` : serverIdentity
|
||||
}
|
||||
|
||||
const loadServerOptions = async () => {
|
||||
try {
|
||||
const response: any = await fetchServerList({ page: 1, size: 1000 })
|
||||
if (response && response.details) {
|
||||
serverOptions.value = response.details.data || []
|
||||
} else {
|
||||
serverOptions.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载服务器列表失败:', error)
|
||||
serverOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
loadServerOptions()
|
||||
fetchSecurityServiceData()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'SecurityServiceManagement',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,557 @@
|
||||
<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"
|
||||
>
|
||||
<div class="modal-scroll">
|
||||
<a-spin :loading="loading" style="width: 98%; margin: 0 auto; 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-divider orientation="left">基础信息</a-divider>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="管理 IP(BMC / 带外)">
|
||||
<span class="readonly-field">{{ managementIp || '—' }}</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<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 或留空表示使用服务默认间隔</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="启用监控">
|
||||
<a-switch v-model="form.enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="tags" label="标签">
|
||||
<a-input v-model="form.tags" placeholder="可为 JSON 字符串,如实体传感器配置等" allow-clear />
|
||||
</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-divider orientation="left">采集协议与连接</a-divider>
|
||||
<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>
|
||||
|
||||
<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-divider orientation="left">备注</a-divider>
|
||||
<a-row :gutter="16">
|
||||
<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-row>
|
||||
</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,
|
||||
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)
|
||||
|
||||
/** 防止多次触发 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,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
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
|
||||
form.enabled = true
|
||||
}
|
||||
|
||||
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
|
||||
form.enabled = d.enabled !== false
|
||||
}
|
||||
|
||||
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,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
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
|
||||
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
|
||||
deviceId.value = 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
|
||||
console.log('r,', r)
|
||||
if (!(r?.name || '').trim()) {
|
||||
Message.error('服务器名称为空,请先在编辑服务器中填写名称')
|
||||
return
|
||||
}
|
||||
if (!managementIp.value) {
|
||||
Message.error('当前服务器未配置 IP 或主机地址,请先在编辑服务器中维护后再保存硬件配置')
|
||||
return
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
console.log('deviceId.value,', deviceId.value)
|
||||
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)
|
||||
}
|
||||
</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>
|
||||
@@ -25,7 +25,7 @@
|
||||
<a-form-item label="Agent URL配置">
|
||||
<a-input
|
||||
v-model="form.agent_config"
|
||||
placeholder="http://192.168.1.100:9100/dc-host/v1/control/command"
|
||||
placeholder="http://192.168.1.100:9100/dc-host/stats"
|
||||
allow-clear
|
||||
/>
|
||||
<template #extra>
|
||||
|
||||
@@ -16,16 +16,20 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="host" label="主机地址" required>
|
||||
<a-input v-model="formData.host" placeholder="请输入主机地址" />
|
||||
<a-form-item
|
||||
field="host"
|
||||
label="主机地址(IPv4 / IPv6)"
|
||||
required
|
||||
>
|
||||
<a-input v-model="formData.host" placeholder="域名或 IP,如 server.example.com 或 192.168.1.10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="ip_address" label="IP地址">
|
||||
<a-input v-model="formData.ip_address" placeholder="请输入IP地址" />
|
||||
<a-form-item field="ip_address" label="IP地址" required>
|
||||
<a-input v-model="formData.ip_address" placeholder="IPv4 或 IPv6,如 192.168.1.10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -71,6 +75,23 @@
|
||||
<a-input v-model="formData.location" placeholder="机房A-机架01" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="asset_id" label="关联资产">
|
||||
<a-select
|
||||
v-model="formData.asset_id"
|
||||
:options="assetOptions"
|
||||
:loading="assetLoading"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="false"
|
||||
placeholder="请选择资产(可选)"
|
||||
@search="handleAssetSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="8">
|
||||
<a-form-item field="tags" label="标签">
|
||||
<a-input v-model="formData.tags" placeholder="多个标签逗号分隔" />
|
||||
@@ -92,7 +113,7 @@
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="agent_config" label="Agent 配置 URL">
|
||||
<a-input v-model="formData.agent_config" placeholder="http://192.168.1.100:9100/dc-host/v1/control/command" />
|
||||
<a-input v-model="formData.agent_config" placeholder="http://192.168.1.100:9100/dc-host/stats" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="20">
|
||||
@@ -143,6 +164,7 @@ import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { createServer, updateServer } from '@/api/ops/server'
|
||||
import type { ServerFormData, ServerItem } from '@/api/ops/server'
|
||||
import { fetchAssetAll } from '@/api/ops/asset'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -157,6 +179,9 @@ const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
const assetLoading = ref(false)
|
||||
const assetOptions = ref<{ label: string; value: number }[]>([])
|
||||
let assetSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
@@ -172,6 +197,7 @@ const formData = reactive<ServerFormData>({
|
||||
server_type: '',
|
||||
tags: '',
|
||||
location: '',
|
||||
asset_id: undefined,
|
||||
remote_access: '',
|
||||
remote_port: 0,
|
||||
agent_config: '',
|
||||
@@ -182,9 +208,79 @@ const formData = reactive<ServerFormData>({
|
||||
ip_scan_port: 12429,
|
||||
})
|
||||
|
||||
/** 判断是否为合法 IPv4 字符串 */
|
||||
function isIPv4String(host: string): boolean {
|
||||
const h = host.trim()
|
||||
if (!h) return false
|
||||
const parts = h.split('.')
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((p) => {
|
||||
if (!/^\d{1,3}$/.test(p)) return false
|
||||
const n = Number(p)
|
||||
return n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 若主机地址为 IP,返回规范化后的 IP(IPv4 或 IPv6);否则返回 null
|
||||
*/
|
||||
function extractIpFromHost(host: string): string | null {
|
||||
const t = host.trim()
|
||||
if (!t) return null
|
||||
if (isIPv4String(t)) return t
|
||||
if (!t.includes(':')) return null
|
||||
try {
|
||||
const withBrackets = t.startsWith('[') && t.endsWith(']') ? t : `[${t}]`
|
||||
const u = new URL(`http://${withBrackets}/`)
|
||||
if (u.hostname.includes(':')) return u.hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 校验 IP 地址字段(IPv4 或 IPv6) */
|
||||
function isValidIpAddress(s: string): boolean {
|
||||
const t = s.trim()
|
||||
if (!t) return false
|
||||
if (isIPv4String(t)) return true
|
||||
try {
|
||||
const withBrackets = t.startsWith('[') && t.endsWith(']') ? t : `[${t}]`
|
||||
const u = new URL(`http://${withBrackets}/`)
|
||||
return u.hostname.includes(':')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 主机为 IP 时同步到 IP 地址字段 */
|
||||
function syncIpFromHost() {
|
||||
const ip = extractIpFromHost(formData.host)
|
||||
if (ip) {
|
||||
formData.ip_address = ip
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入服务器名称' }],
|
||||
host: [{ required: true, message: '请输入主机地址' }],
|
||||
ip_address: [
|
||||
{ required: true, message: '请输入IP地址' },
|
||||
{
|
||||
validator: (value: string | undefined, cb: (error?: string) => void) => {
|
||||
const v = (value || '').trim()
|
||||
if (!v) {
|
||||
cb()
|
||||
return
|
||||
}
|
||||
if (!isValidIpAddress(v)) {
|
||||
cb('请输入合法的 IPv4 或 IPv6 地址')
|
||||
return
|
||||
}
|
||||
cb()
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function validateAgentConfigURL(raw?: string): string | null {
|
||||
@@ -206,6 +302,7 @@ watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadAssetOptions()
|
||||
if (isEdit.value && props.record) {
|
||||
Object.assign(formData, {
|
||||
server_identity: props.record.server_identity || '',
|
||||
@@ -219,6 +316,7 @@ watch(
|
||||
server_type: props.record.server_type || '',
|
||||
tags: props.record.tags || '',
|
||||
location: props.record.location || '',
|
||||
asset_id: props.record.asset_id,
|
||||
remote_access: props.record.remote_access || '',
|
||||
remote_port: props.record.remote_port || 0,
|
||||
agent_config: props.record.agent_config || '',
|
||||
@@ -228,6 +326,7 @@ watch(
|
||||
is_ip_scan_server: props.record.is_ip_scan_server ?? false,
|
||||
ip_scan_port: props.record.ip_scan_port || 12429,
|
||||
})
|
||||
syncIpFromHost()
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
server_identity: '',
|
||||
@@ -241,6 +340,7 @@ watch(
|
||||
server_type: '',
|
||||
tags: '',
|
||||
location: '',
|
||||
asset_id: undefined,
|
||||
remote_access: '',
|
||||
remote_port: 0,
|
||||
agent_config: '',
|
||||
@@ -255,6 +355,47 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
async function loadAssetOptions(keyword?: string) {
|
||||
assetLoading.value = true
|
||||
try {
|
||||
const res: any = await fetchAssetAll({ keyword: (keyword || '').trim() || undefined })
|
||||
if (res?.code !== 0) {
|
||||
Message.warning(res?.message || '加载资产列表失败')
|
||||
assetOptions.value = []
|
||||
return
|
||||
}
|
||||
const rows = res?.details || []
|
||||
assetOptions.value = rows.map((item: any) => ({
|
||||
value: Number(item.id),
|
||||
label: `${item.asset_code || item.id} | ${item.asset_name || '-'}`,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('加载资产列表失败:', error)
|
||||
Message.warning('加载资产列表失败')
|
||||
assetOptions.value = []
|
||||
} finally {
|
||||
assetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 资产下拉使用远程模糊查询,避免一次加载全部资产。
|
||||
function handleAssetSearch(keyword: string) {
|
||||
if (assetSearchTimer) {
|
||||
clearTimeout(assetSearchTimer)
|
||||
}
|
||||
assetSearchTimer = setTimeout(() => {
|
||||
loadAssetOptions(keyword)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => formData.host,
|
||||
() => {
|
||||
if (!props.visible) return
|
||||
syncIpFromHost()
|
||||
},
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
@@ -279,6 +420,7 @@ const handleOk = async () => {
|
||||
server_type: formData.server_type,
|
||||
tags: formData.tags,
|
||||
location: formData.location,
|
||||
asset_id: formData.asset_id ?? (isEdit.value ? 0 : undefined),
|
||||
remote_access: formData.remote_access,
|
||||
remote_port: formData.remote_port,
|
||||
agent_config: formData.agent_config,
|
||||
|
||||
@@ -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
|
||||
@@ -460,6 +485,7 @@ const handleDelete = async (record: any) => {
|
||||
})
|
||||
}
|
||||
|
||||
/** dc-host `GET agent_config`(一般为 `/dc-host/stats`)返回裸 Metrics JSON */
|
||||
// 获取所有服务器的监控指标
|
||||
const getAllMetrics = async () => {
|
||||
try {
|
||||
|
||||
196
src/views/ops/pages/dc/storage/components/StorageDetail.vue
Normal file
196
src/views/ops/pages/dc/storage/components/StorageDetail.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:visible="visible"
|
||||
title="存储设备详情"
|
||||
placement="right"
|
||||
width="600px"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
>
|
||||
<div class="detail-container">
|
||||
<a-spin :loading="loading" style="width: 100%">
|
||||
<a-descriptions title="基础信息" :column="2" bordered>
|
||||
<a-descriptions-item label="ID">{{ detailData?.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务标识">{{ detailData?.service_identity }}</a-descriptions-item>
|
||||
<a-descriptions-item label="名称">{{ detailData?.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">{{ detailData?.type }}</a-descriptions-item>
|
||||
<a-descriptions-item label="分类">{{ detailData?.category || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="OID">{{ detailData?.oid || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务器标识">{{ detailData?.server_identity || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标签">{{ detailData?.tags || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="2">{{ detailData?.description || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions title="采集配置" :column="2" bordered style="margin-top: 20px">
|
||||
<a-descriptions-item label="采集方式">
|
||||
<a-tag :color="detailData?.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
||||
{{ detailData?.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="连接配置">
|
||||
<span v-if="detailData?.collect_method === 'snmp'">{{ detailData?.snmp_target || '-' }}</span>
|
||||
<span v-else>{{ detailData?.agent_config || '-' }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailData?.collect_method === 'snmp'" label="SNMP连接">
|
||||
{{ (detailData?.snmp_community || '-') + ' @ ' + (detailData?.snmp_port || 161) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailData?.collect_method === 'snmp'" label="SNMP超时/重试">
|
||||
{{ (detailData?.snmp_timeout_ms || 3000) + 'ms / ' + (detailData?.snmp_retries ?? 1) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="启用采集">
|
||||
<a-tag :color="detailData?.collect_on ? 'green' : 'gray'">
|
||||
{{ detailData?.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集间隔">
|
||||
{{ detailData?.collect_interval ? `${detailData?.collect_interval}秒` : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集参数">{{ detailData?.collect_args || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="采集结果" :span="2">{{ detailData?.collect_last_result || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions title="运行状态" :column="2" bordered style="margin-top: 20px">
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(detailData?.status)">
|
||||
{{ getStatusText(detailData?.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态码">{{ detailData?.status_code || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态消息" :span="2">{{ detailData?.status_message || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ detailData?.response_time ? `${detailData?.response_time}ms` : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最后检查时间">{{ formatDateTime(detailData?.last_check_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最后在线时间">{{ formatDateTime(detailData?.last_online_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最后离线时间">{{ formatDateTime(detailData?.last_offline_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="连续错误次数">{{ detailData?.continuous_errors || 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="运行时长">{{ detailData?.uptime ? `${detailData?.uptime}秒` : '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions title="最新指标" :column="2" bordered style="margin-top: 20px" v-if="metricsData?.metrics?.length">
|
||||
<a-descriptions-item label="数据时间">{{ formatDateTime(metricsData?.latest_timestamp) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="指标数量">{{ metricsData?.count || 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item v-for="metric in metricsData?.metrics" :key="metric.metric_name" :label="metric.metric_name">
|
||||
{{ metric.metric_value }} {{ metric.metric_unit }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-empty v-else-if="!loading && !metricsData?.metrics?.length" description="暂无指标数据" style="margin-top: 20px" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { fetchStorageDetail, fetchStorageMetricsLatest } from '@/api/ops/storage'
|
||||
import type { StorageItem, StorageMetricsLatestResponse } from '@/api/ops/storage'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: StorageItem | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const loading = ref(false)
|
||||
const detailData = ref<StorageItem | null>(null)
|
||||
const metricsData = ref<StorageMetricsLatestResponse | null>(null)
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
error: 'orange',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
error: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
return textMap[status || ''] || '-'
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime: string | null | undefined) => {
|
||||
if (!dateTime || dateTime === '0001-01-01T00:00:00Z') {
|
||||
return '-'
|
||||
}
|
||||
try {
|
||||
const date = new Date(dateTime)
|
||||
if (isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
if (!props.record?.id) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await fetchStorageDetail(props.record.id)
|
||||
if (res.code === 0) {
|
||||
detailData.value = res.details
|
||||
|
||||
if (detailData.value?.service_identity) {
|
||||
const metricsRes: any = await fetchStorageMetricsLatest(detailData.value.service_identity)
|
||||
if (metricsRes.code === 0) {
|
||||
metricsData.value = metricsRes.details
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Message.error(res.message || '获取详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
Message.error('获取详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.record) {
|
||||
fetchDetail()
|
||||
} else {
|
||||
detailData.value = null
|
||||
metricsData.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
356
src/views/ops/pages/dc/storage/components/StorageFormDialog.vue
Normal file
356
src/views/ops/pages/dc/storage/components/StorageFormDialog.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑存储设备' : '新增存储设备'"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="confirmLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="名称" required>
|
||||
<a-input v-model="formData.name" placeholder="请输入存储设备名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="类型" required>
|
||||
<a-select v-model="formData.type" placeholder="请选择类型">
|
||||
<a-option value="nas">NAS存储</a-option>
|
||||
<a-option value="san">SAN存储</a-option>
|
||||
<a-option value="das">DAS存储</a-option>
|
||||
<a-option value="cloud">云存储</a-option>
|
||||
<a-option value="other">其他</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="oid" label="OID">
|
||||
<a-input v-model="formData.oid" placeholder="请输入OID" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="server_identity" label="服务器">
|
||||
<a-select v-model="formData.server_identity" placeholder="请选择服务器" allow-search allow-clear>
|
||||
<a-option v-for="server in serverOptions" :key="server.server_identity" :value="server.server_identity">
|
||||
{{ server.name }} ({{ server.server_identity }})
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="enabled" label="启用状态">
|
||||
<a-switch v-model="formData.enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="interval" label="检查间隔(秒)">
|
||||
<a-input-number v-model="formData.interval" :min="10" :max="3600" placeholder="默认60秒" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_on" label="启用采集">
|
||||
<a-switch v-model="formData.collect_on" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number v-model="formData.collect_interval" :min="10" :max="3600" placeholder="默认60秒" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="left">采集配置</a-divider>
|
||||
|
||||
<a-form-item field="collect_method" label="采集方式">
|
||||
<a-radio-group v-model="formData.collect_method" type="button">
|
||||
<a-radio value="api">API</a-radio>
|
||||
<a-radio value="snmp">SNMP</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="formData.collect_method === 'api'">
|
||||
<a-form-item field="agent_config" label="Agent配置URL">
|
||||
<a-input v-model="formData.agent_config" placeholder="请输入Agent配置地址" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_target" label="SNMP目标地址">
|
||||
<a-input v-model="formData.snmp_target" placeholder="请输入SNMP目标地址" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_port" label="SNMP端口">
|
||||
<a-input-number v-model="formData.snmp_port" :min="1" :max="65535" placeholder="默认161" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="snmp_community" label="SNMP Community">
|
||||
<a-input v-model="formData.snmp_community" placeholder="请输入SNMP community" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item field="snmp_timeout_ms" label="超时(毫秒)">
|
||||
<a-input-number v-model="formData.snmp_timeout_ms" :min="1" :max="60000" placeholder="默认3000" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item field="snmp_retries" label="重试次数">
|
||||
<a-input-number v-model="formData.snmp_retries" :min="0" :max="10" placeholder="默认1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置">
|
||||
<a-textarea
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空用默认模板,或填写 JSON 数组如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item field="collect_args" label="采集参数">
|
||||
<a-textarea v-model="formData.collect_args" placeholder="请输入采集参数(JSON格式)" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="tags" label="标签">
|
||||
<a-input v-model="formData.tags" placeholder="多个标签逗号分隔" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="extra" label="扩展信息">
|
||||
<a-textarea v-model="formData.extra" placeholder="请输入扩展信息(JSON格式)" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="description" label="描述">
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { createStorage, updateStorage } from '@/api/ops/storage'
|
||||
import type { StorageCreateData, StorageItem } from '@/api/ops/storage'
|
||||
import { fetchServerList, type ServerItem } from '@/api/ops/server'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: StorageItem | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
const serverOptions = ref<ServerItem[]>([])
|
||||
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const formData = reactive<StorageCreateData>({
|
||||
name: '',
|
||||
type: '',
|
||||
oid: '',
|
||||
server_identity: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
interval: 60,
|
||||
extra: '',
|
||||
tags: '',
|
||||
agent_config: '',
|
||||
collect_method: 'api' as 'api' | 'snmp',
|
||||
snmp_target: '',
|
||||
snmp_port: 161,
|
||||
snmp_community: '',
|
||||
snmp_timeout_ms: 3000,
|
||||
snmp_retries: 1,
|
||||
snmp_oids: '',
|
||||
collect_on: true,
|
||||
collect_args: '',
|
||||
collect_interval: 60,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
type: [{ required: true, message: '请选择类型' }],
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (isEdit.value && props.record) {
|
||||
Object.assign(formData, {
|
||||
name: props.record.name || '',
|
||||
type: props.record.type || '',
|
||||
oid: props.record.oid || '',
|
||||
server_identity: props.record.server_identity || '',
|
||||
description: props.record.description || '',
|
||||
enabled: props.record.enabled ?? true,
|
||||
interval: props.record.interval || 60,
|
||||
extra: props.record.extra || '',
|
||||
tags: props.record.tags || '',
|
||||
agent_config: props.record.agent_config || '',
|
||||
collect_method: props.record.collect_method || 'api',
|
||||
snmp_target: props.record.snmp_target || '',
|
||||
snmp_port: props.record.snmp_port || 161,
|
||||
snmp_community: props.record.snmp_community || '',
|
||||
snmp_timeout_ms: props.record.snmp_timeout_ms || 3000,
|
||||
snmp_retries: props.record.snmp_retries ?? 1,
|
||||
snmp_oids: props.record.snmp_oids || '',
|
||||
collect_on: props.record.collect_on ?? true,
|
||||
collect_args: props.record.collect_args || '',
|
||||
collect_interval: props.record.collect_interval || 60,
|
||||
})
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
type: '',
|
||||
oid: '',
|
||||
server_identity: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
interval: 60,
|
||||
extra: '',
|
||||
tags: '',
|
||||
agent_config: '',
|
||||
collect_method: 'api',
|
||||
snmp_target: '',
|
||||
snmp_port: 161,
|
||||
snmp_community: '',
|
||||
snmp_timeout_ms: 3000,
|
||||
snmp_retries: 1,
|
||||
snmp_oids: '',
|
||||
collect_on: true,
|
||||
collect_args: '',
|
||||
collect_interval: 60,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
if (formData.collect_method === 'api' && !formData.agent_config?.trim()) {
|
||||
Message.warning('API 模式下请填写 Agent 配置 URL')
|
||||
return
|
||||
}
|
||||
if (formData.collect_method === 'snmp') {
|
||||
if (!formData.snmp_target?.trim() || !formData.snmp_community?.trim()) {
|
||||
Message.warning('SNMP 模式下请填写目标地址和 community')
|
||||
return
|
||||
}
|
||||
if (formData.snmp_oids?.trim()) {
|
||||
try {
|
||||
JSON.parse(formData.snmp_oids)
|
||||
} catch {
|
||||
Message.warning('SNMP OID 配置必须是合法 JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
const submitData: StorageCreateData = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
oid: formData.oid,
|
||||
server_identity: formData.server_identity,
|
||||
description: formData.description,
|
||||
enabled: formData.enabled,
|
||||
interval: formData.interval,
|
||||
extra: formData.extra,
|
||||
tags: formData.tags,
|
||||
agent_config: formData.agent_config,
|
||||
collect_method: formData.collect_method,
|
||||
snmp_target: formData.snmp_target,
|
||||
snmp_port: formData.snmp_port,
|
||||
snmp_community: formData.snmp_community,
|
||||
snmp_timeout_ms: formData.snmp_timeout_ms,
|
||||
snmp_retries: formData.snmp_retries,
|
||||
snmp_oids: formData.snmp_oids,
|
||||
collect_on: formData.collect_on,
|
||||
collect_args: formData.collect_args,
|
||||
collect_interval: formData.collect_interval,
|
||||
}
|
||||
|
||||
if (isEdit.value && props.record?.id) {
|
||||
const res: any = await updateStorage(props.record.id, submitData)
|
||||
if (res.code === 0) {
|
||||
Message.success('更新成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
Message.error(res.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
const res: any = await createStorage(submitData)
|
||||
if (res.code === 0) {
|
||||
Message.success('创建成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
Message.error(res.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error)
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const loadServerOptions = async () => {
|
||||
try {
|
||||
const response: any = await fetchServerList({ page: 1, size: 1000 })
|
||||
if (response && response.details) {
|
||||
serverOptions.value = response.details.data || []
|
||||
} else {
|
||||
serverOptions.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载服务器列表失败:', error)
|
||||
serverOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadServerOptions()
|
||||
})
|
||||
</script>
|
||||
80
src/views/ops/pages/dc/storage/config/columns.ts
Normal file
80
src/views/ops/pages/dc/storage/config/columns.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export const columns = [
|
||||
{
|
||||
dataIndex: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
slotName: 'id',
|
||||
},
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'service_identity',
|
||||
title: '服务标识',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: 'type',
|
||||
title: '类型',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'server_identity',
|
||||
title: '服务器标识',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'enabled',
|
||||
title: '启用状态',
|
||||
width: 100,
|
||||
slotName: 'enabled',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_method',
|
||||
title: '采集方式',
|
||||
width: 100,
|
||||
slotName: 'collect_method',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_on',
|
||||
title: '数据采集',
|
||||
width: 100,
|
||||
slotName: 'data_collection',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_interval',
|
||||
title: '采集间隔(秒)',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '运行状态',
|
||||
width: 100,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'response_time',
|
||||
title: '响应时间(ms)',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'last_check_time',
|
||||
title: '最后检查时间',
|
||||
width: 180,
|
||||
slotName: 'last_check_time',
|
||||
},
|
||||
{
|
||||
dataIndex: 'continuous_errors',
|
||||
title: '连续错误次数',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
22
src/views/ops/pages/dc/storage/config/search-form.ts
Normal file
22
src/views/ops/pages/dc/storage/config/search-form.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
|
||||
export const searchFormConfig: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称/标识',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
label: '启用状态',
|
||||
type: 'select',
|
||||
placeholder: '请选择启用状态',
|
||||
options: [
|
||||
{ label: '启用', value: true },
|
||||
{ label: '停用', value: false },
|
||||
],
|
||||
span: 8,
|
||||
},
|
||||
]
|
||||
291
src/views/ops/pages/dc/storage/index.vue
Normal file
291
src/views/ops/pages/dc/storage/index.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="存储设备服务"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
新增存储设备
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<template #id="{ record }">
|
||||
{{ record.id }}
|
||||
</template>
|
||||
|
||||
<template #enabled="{ record }">
|
||||
<a-tag :color="record.enabled ? 'green' : 'gray'">
|
||||
{{ record.enabled ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #data_collection="{ record }">
|
||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #collect_method="{ record }">
|
||||
<a-tag :color="record.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
||||
{{ record.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #last_check_time="{ record }">
|
||||
{{ formatDateTime(record.last_check_time) }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button type="outline" size="small" @click="handleDetail(record)">
|
||||
<template #icon>
|
||||
<icon-eye />
|
||||
</template>
|
||||
详情
|
||||
</a-button>
|
||||
<a-dropdown trigger="hover">
|
||||
<a-button type="primary" size="small">
|
||||
管理
|
||||
<icon-down />
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="handleEdit(record)">
|
||||
<template #icon>
|
||||
<icon-edit />
|
||||
</template>
|
||||
编辑
|
||||
</a-doption>
|
||||
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
删除
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<StorageFormDialog v-model:visible="formDialogVisible" :record="currentRecord" @success="handleFormSuccess" />
|
||||
|
||||
<StorageDetail v-model:visible="detailVisible" :record="currentRecord" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus, IconDown, IconEdit, IconDelete, IconEye } 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 StorageFormDialog from './components/StorageFormDialog.vue'
|
||||
import StorageDetail from './components/StorageDetail.vue'
|
||||
import { columns as columnsConfig } from './config/columns'
|
||||
import { fetchStorageList, deleteStorage } from '@/api/ops/storage'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const formDialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<any>(null)
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
enabled: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => searchFormConfig)
|
||||
|
||||
const columns = computed(() => columnsConfig)
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
error: 'orange',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
error: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
return textMap[status || ''] || '-'
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime: string | null | undefined) => {
|
||||
if (!dateTime || dateTime === '0001-01-01T00:00:00Z') {
|
||||
return '-'
|
||||
}
|
||||
try {
|
||||
const date = new Date(dateTime)
|
||||
if (isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStorages = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
}
|
||||
|
||||
if (formModel.value.keyword) {
|
||||
params.keyword = formModel.value.keyword
|
||||
}
|
||||
|
||||
if (formModel.value.enabled !== undefined) {
|
||||
params.enabled = formModel.value.enabled
|
||||
}
|
||||
|
||||
const res: any = await fetchStorageList(params)
|
||||
|
||||
if (res.code === 0) {
|
||||
const responseData = res.details || {}
|
||||
tableData.value = responseData.data || []
|
||||
pagination.total = responseData.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取存储设备列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取存储设备列表失败:', error)
|
||||
Message.error('获取存储设备列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (value: any) => {
|
||||
formModel.value = value
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
formModel.value = {
|
||||
keyword: '',
|
||||
enabled: undefined,
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handlePageChange = (current: number) => {
|
||||
pagination.current = current
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchStorages()
|
||||
Message.success('数据已刷新')
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
currentRecord.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
currentRecord.value = record
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetail = (record: any) => {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handleDelete = async (record: any) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确认删除存储设备 ${record.name} 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res: any = await deleteStorage(record.id)
|
||||
if (res.code === 0) {
|
||||
Message.success('删除成功')
|
||||
fetchStorages()
|
||||
} else {
|
||||
Message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除存储设备失败:', error)
|
||||
Message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStorages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'StorageDevice',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -97,9 +97,10 @@ const getStatusText = (status?: string) => {
|
||||
return textMap[status || ''] || '-'
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const formatTime = (time?: string | null) => {
|
||||
if (!time || time.startsWith('0001-01-01')) return '-'
|
||||
const date = new Date(time)
|
||||
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return '-'
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
@@ -156,9 +156,10 @@ const getStatusText = (status?: string) => {
|
||||
return textMap[status || ''] || '-'
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const formatTime = (time?: string | null) => {
|
||||
if (!time || time.startsWith('0001-01-01')) return '-'
|
||||
const date = new Date(time)
|
||||
if (Number.isNaN(date.getTime()) || date.getFullYear() <= 1) return '-'
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
702
src/views/ops/pages/monitor/host-hardware/index.vue
Normal file
702
src/views/ops/pages/monitor/host-hardware/index.vue
Normal file
@@ -0,0 +1,702 @@
|
||||
<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" style="width: 100%">
|
||||
<div class="hw-chart-wrap">
|
||||
<Chart v-if="historyChartOptions" :options="historyChartOptions" height="280px" width="100%" />
|
||||
<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: '磁盘' },
|
||||
]
|
||||
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 / 100)
|
||||
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 {
|
||||
width: 100%;
|
||||
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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,143 +1,140 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-col :xs="24" :sm="12" :lg="8" :xl="4">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-primary">
|
||||
<icon-safe />
|
||||
</div>
|
||||
<div class="stats-icon stats-icon-primary"><icon-code-square /></div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">安全设备</div>
|
||||
<div class="stats-value">{{ stats.totalDevices }}</div>
|
||||
<div class="stats-desc">在线设备数</div>
|
||||
<div class="stats-title">CPU使用率</div>
|
||||
<div class="stats-value">{{ formatPercent(summary.cpuUsage) }}</div>
|
||||
<div class="stats-desc">安全设备平均</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-col :xs="24" :sm="12" :lg="8" :xl="4">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-success">
|
||||
<icon-check-circle-fill />
|
||||
</div>
|
||||
<div class="stats-icon stats-icon-cyan"><icon-drive-file /></div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">威胁拦截</div>
|
||||
<div class="stats-value">{{ stats.threatsBlocked }}</div>
|
||||
<div class="stats-desc">今日拦截</div>
|
||||
<div class="stats-title">内存使用率</div>
|
||||
<div class="stats-value">{{ formatPercent(summary.memoryUsage) }}</div>
|
||||
<div class="stats-desc">安全设备平均</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-col :xs="24" :sm="12" :lg="8" :xl="4">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-danger">
|
||||
<icon-exclamation-circle-fill />
|
||||
</div>
|
||||
<div class="stats-icon stats-icon-warning"><icon-storage /></div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">高危威胁</div>
|
||||
<div class="stats-value">{{ stats.highRiskThreats }}</div>
|
||||
<div class="stats-desc text-danger">需立即处理</div>
|
||||
<div class="stats-title">虚拟内存使用率</div>
|
||||
<div class="stats-value">{{ formatPercent(summary.swapUsage) }}</div>
|
||||
<div class="stats-desc">Swap 平均</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-col :xs="24" :sm="12" :lg="8" :xl="4">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-muted">
|
||||
<icon-close-circle-fill />
|
||||
</div>
|
||||
<div class="stats-icon stats-icon-success"><icon-storage /></div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">离线设备</div>
|
||||
<div class="stats-value">{{ stats.offlineDevices }}</div>
|
||||
<div class="stats-desc text-danger">VPN网关异常</div>
|
||||
<div class="stats-title">硬盘空间使用率</div>
|
||||
<div class="stats-value">{{ formatPercent(summary.diskUsage) }}</div>
|
||||
<div class="stats-desc">安全设备平均</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="8" :xl="4">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-primary"><icon-thunderbolt /></div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">磁盘IO吞吐</div>
|
||||
<div class="stats-value">{{ formatThroughput(summary.diskIoThroughput) }}</div>
|
||||
<div class="stats-desc">平均值</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="8" :xl="4">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-success"><icon-check-circle-fill /></div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">可用率</div>
|
||||
<div class="stats-value">{{ formatPercent(summary.availability) }}</div>
|
||||
<div class="stats-desc">在线 {{ summary.onlineCount }}/{{ summary.totalCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<a-row :gutter="16" class="chart-row">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="威胁趋势" :bordered="false">
|
||||
<a-col :xs="24" :xl="14">
|
||||
<a-card title="设备资源指标对比" :bordered="false">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot legend-dot-1"></span>
|
||||
<span>检测</span>
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot legend-dot-2"></span>
|
||||
<span>拦截</span>
|
||||
</span>
|
||||
</a-space>
|
||||
<span class="text-muted">最多展示 10 台设备</span>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
<Chart :options="threatChartOptions" height="280px" />
|
||||
<Chart :options="metricsCompareChartOptions" height="300px" />
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="威胁分类统计" :bordered="false">
|
||||
<template #extra>
|
||||
<span class="text-muted">今日威胁类型分布</span>
|
||||
</template>
|
||||
<div class="threat-category-list">
|
||||
<div v-for="item in threatCategories" :key="item.name" class="threat-category-item">
|
||||
<div class="threat-category-header">
|
||||
<div class="threat-category-icon" :style="{ backgroundColor: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
<div class="threat-category-info">
|
||||
<div class="threat-category-name">{{ item.name }}</div>
|
||||
<a-progress :percent="item.percent" :stroke-width="8" :show-text="false" :status="item.progressStatus" />
|
||||
</div>
|
||||
<span class="threat-category-value" :style="{ color: item.color }">{{ item.value }}</span>
|
||||
</div>
|
||||
<a-col :xs="24" :xl="10">
|
||||
<a-card title="控制器状态分布" :bordered="false" class="controller-card">
|
||||
<div class="controller-overview">
|
||||
<div class="controller-item">
|
||||
<div class="controller-label">在线控制器</div>
|
||||
<div class="controller-value text-success">{{ summary.onlineCount }}</div>
|
||||
</div>
|
||||
<div class="controller-item">
|
||||
<div class="controller-label">离线控制器</div>
|
||||
<div class="controller-value text-danger">{{ summary.offlineCount }}</div>
|
||||
</div>
|
||||
<div class="controller-item">
|
||||
<div class="controller-label">异常控制器</div>
|
||||
<div class="controller-value text-warning">{{ summary.errorCount }}</div>
|
||||
</div>
|
||||
<div class="controller-item">
|
||||
<div class="controller-label">未知状态</div>
|
||||
<div class="controller-value text-muted-strong">{{ summary.unknownCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 设备列表 -->
|
||||
<a-card title="设备列表" :bordered="false">
|
||||
<a-card title="控制器运行状态" :bordered="false">
|
||||
<template #extra>
|
||||
<a-select v-model="filterType" placeholder="全部类型" style="width: 150px">
|
||||
<a-option value="">全部类型</a-option>
|
||||
<a-option value="firewall">防火墙</a-option>
|
||||
<a-option value="ids">IDS</a-option>
|
||||
<a-option value="ips">IPS</a-option>
|
||||
<a-option value="waf">WAF</a-option>
|
||||
<a-option value="vpn">VPN</a-option>
|
||||
</a-select>
|
||||
<a-space>
|
||||
<a-select v-model="filterType" placeholder="全部类型" style="width: 140px">
|
||||
<a-option value="">全部类型</a-option>
|
||||
<a-option value="firewall">防火墙</a-option>
|
||||
<a-option value="ids">IDS</a-option>
|
||||
<a-option value="ips">IPS</a-option>
|
||||
<a-option value="waf">WAF</a-option>
|
||||
<a-option value="vpn">VPN</a-option>
|
||||
<a-option value="other">其他</a-option>
|
||||
</a-select>
|
||||
<a-input-search v-model="keyword" placeholder="搜索名称/标识" style="width: 200px" allow-clear />
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table :data="filteredDevices" :columns="columns" :loading="loading" :pagination="false" row-key="id">
|
||||
<!-- 状态列 -->
|
||||
|
||||
<a-table :data="filteredDevices" :columns="columns" :loading="loading" :pagination="{ pageSize: 10 }" row-key="id">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)" bordered>
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 响应时间列 -->
|
||||
<template #responseTime="{ record }">
|
||||
<span v-if="record.response_time">{{ record.response_time.toFixed(2) }} ms</span>
|
||||
<span v-else>-</span>
|
||||
<template #controllerStatus="{ record }">
|
||||
<a-tag :color="getControllerStatusColor(record.controller_status)">{{ record.controller_status_text }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 运行时长列 -->
|
||||
<template #uptime="{ record }">
|
||||
<span>{{ formatUptime(record.uptime) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 最近检查时间列 -->
|
||||
<template #lastCheckTime="{ record }">
|
||||
<span>{{ formatTime(record.last_check_time) }}</span>
|
||||
{{ formatTime(record.last_check_time) }}
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
@@ -145,193 +142,220 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconSafe,
|
||||
IconCheckCircleFill,
|
||||
IconExclamationCircleFill,
|
||||
IconCloseCircleFill,
|
||||
IconCodeSquare,
|
||||
IconDriveFile,
|
||||
IconStorage,
|
||||
IconThunderbolt,
|
||||
IconLock,
|
||||
IconCode,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import Chart from '@/components/chart/index.vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import { fetchSecurityServiceList, type SecurityServiceItem } from '@/api/ops/security'
|
||||
import Chart from '@/components/chart/index.vue'
|
||||
import {
|
||||
fetchSecurityMetricsLatest,
|
||||
fetchSecurityServiceList,
|
||||
type SecurityMetric,
|
||||
type SecurityServiceItem,
|
||||
} from '@/api/ops/security'
|
||||
|
||||
type ControllerStatus = 'online' | 'offline' | 'warning' | 'unknown'
|
||||
|
||||
interface DeviceMetricRow extends SecurityServiceItem {
|
||||
cpu_usage: number | null
|
||||
memory_usage: number | null
|
||||
swap_usage: number | null
|
||||
disk_usage: number | null
|
||||
disk_io_throughput: number | null
|
||||
availability: number | null
|
||||
controller_status: ControllerStatus
|
||||
controller_status_text: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const filterType = ref('')
|
||||
const deviceData = ref<SecurityServiceItem[]>([])
|
||||
const keyword = ref('')
|
||||
const devices = ref<DeviceMetricRow[]>([])
|
||||
|
||||
const metricNameAliases = {
|
||||
cpu_usage: ['cpu_usage'],
|
||||
memory_usage: ['memory_used_percent', 'memory_usage', 'mem_usage', 'memory_percent', 'used_percent'],
|
||||
swap_usage: ['swap_used_percent', 'swap_usage', 'virtual_memory_usage', 'memory_virt'],
|
||||
disk_usage: ['disk_used_percent', 'disk_usage', 'disk_space_used_percent'],
|
||||
disk_io_throughput: ['disk_io_throughput', 'disk_io', 'disk_io_bps', 'disk_io_mb_s'],
|
||||
availability: ['availability', 'success_rate', 'available_percent'],
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: '设备名称', dataIndex: 'name', width: 150 },
|
||||
{ title: '类型', dataIndex: 'type', width: 100 },
|
||||
{ title: '服务标识', dataIndex: 'service_identity', width: 180 },
|
||||
{ title: '控制器状态', dataIndex: 'controller_status', slotName: 'controllerStatus', width: 120, align: 'center' },
|
||||
{ title: 'CPU使用率', dataIndex: 'cpu_usage', width: 110, render: ({ record }) => formatPercent(record.cpu_usage) },
|
||||
{ title: '内存使用率', dataIndex: 'memory_usage', width: 110, render: ({ record }) => formatPercent(record.memory_usage) },
|
||||
{ title: '虚拟内存使用率', dataIndex: 'swap_usage', width: 130, render: ({ record }) => formatPercent(record.swap_usage) },
|
||||
{ title: '硬盘空间', dataIndex: 'disk_usage', width: 110, render: ({ record }) => formatPercent(record.disk_usage) },
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
title: '磁盘IO吞吐',
|
||||
dataIndex: 'disk_io_throughput',
|
||||
width: 120,
|
||||
render: ({ record }) => formatThroughput(record.disk_io_throughput),
|
||||
},
|
||||
{
|
||||
title: '服务标识',
|
||||
dataIndex: 'service_identity',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
slotName: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '响应时间',
|
||||
dataIndex: 'response_time',
|
||||
slotName: 'responseTime',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '运行时长',
|
||||
dataIndex: 'uptime',
|
||||
slotName: 'uptime',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '连续错误',
|
||||
dataIndex: 'continuous_errors',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '最近检查',
|
||||
dataIndex: 'last_check_time',
|
||||
slotName: 'lastCheckTime',
|
||||
width: 180,
|
||||
},
|
||||
{ title: '可用率', dataIndex: 'availability', width: 100, render: ({ record }) => formatPercent(record.availability) },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 90, align: 'center' },
|
||||
{ title: '最近检查', dataIndex: 'last_check_time', slotName: 'lastCheckTime', width: 170 },
|
||||
]
|
||||
|
||||
// 统计数据
|
||||
const stats = computed(() => {
|
||||
const total = deviceData.value.length
|
||||
const online = deviceData.value.filter((d) => d.status === 'online').length
|
||||
const offline = deviceData.value.filter((d) => d.status === 'offline').length
|
||||
const error = deviceData.value.filter((d) => d.status === 'error').length
|
||||
const filteredDevices = computed(() => {
|
||||
const typeFiltered = filterType.value ? devices.value.filter((d) => d.type === filterType.value) : devices.value
|
||||
const text = keyword.value.trim().toLowerCase()
|
||||
if (!text) return typeFiltered
|
||||
return typeFiltered.filter((d) => d.name.toLowerCase().includes(text) || d.service_identity.toLowerCase().includes(text))
|
||||
})
|
||||
|
||||
const summary = computed(() => {
|
||||
const totalCount = devices.value.length
|
||||
const onlineCount = devices.value.filter((d) => d.status === 'online').length
|
||||
const offlineCount = devices.value.filter((d) => d.status === 'offline').length
|
||||
const errorCount = devices.value.filter((d) => d.status === 'error').length
|
||||
const unknownCount = totalCount - onlineCount - offlineCount - errorCount
|
||||
return {
|
||||
totalDevices: online,
|
||||
threatsBlocked: '-',
|
||||
highRiskThreats: error,
|
||||
offlineDevices: offline,
|
||||
cpuUsage: avgMetric(devices.value.map((d) => d.cpu_usage)),
|
||||
memoryUsage: avgMetric(devices.value.map((d) => d.memory_usage)),
|
||||
swapUsage: avgMetric(devices.value.map((d) => d.swap_usage)),
|
||||
diskUsage: avgMetric(devices.value.map((d) => d.disk_usage)),
|
||||
diskIoThroughput: avgMetric(devices.value.map((d) => d.disk_io_throughput)),
|
||||
availability: avgMetric(devices.value.map((d) => d.availability)) ?? (totalCount ? (onlineCount / totalCount) * 100 : null),
|
||||
totalCount,
|
||||
onlineCount,
|
||||
offlineCount,
|
||||
errorCount,
|
||||
unknownCount,
|
||||
}
|
||||
})
|
||||
|
||||
// 过滤后的设备列表
|
||||
const filteredDevices = computed(() => {
|
||||
if (!filterType.value) return deviceData.value
|
||||
return deviceData.value.filter((device) => device.type === filterType.value)
|
||||
const metricsCompareChartOptions = computed(() => {
|
||||
const sample = filteredDevices.value.slice(0, 10)
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { top: 4 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: sample.map((item) => item.name || item.service_identity),
|
||||
axisLabel: { interval: 0, rotate: 25 },
|
||||
},
|
||||
yAxis: { type: 'value', name: '%' },
|
||||
series: [
|
||||
{ name: 'CPU', type: 'bar', data: sample.map((item) => toNum(item.cpu_usage)), itemStyle: { color: '#165DFF' } },
|
||||
{ name: '内存', type: 'bar', data: sample.map((item) => toNum(item.memory_usage)), itemStyle: { color: '#14C9C9' } },
|
||||
{ name: '虚拟内存', type: 'bar', data: sample.map((item) => toNum(item.swap_usage)), itemStyle: { color: '#F7BA1E' } },
|
||||
{ name: '硬盘空间', type: 'bar', data: sample.map((item) => toNum(item.disk_usage)), itemStyle: { color: '#00B42A' } },
|
||||
{ name: '可用率', type: 'line', smooth: true, data: sample.map((item) => toNum(item.availability)), itemStyle: { color: '#722ED1' } },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// 威胁分类数据
|
||||
const threatCategories = ref([
|
||||
{
|
||||
name: 'DDoS攻击',
|
||||
value: 456,
|
||||
percent: 45,
|
||||
icon: IconThunderbolt,
|
||||
color: '#F53F3F',
|
||||
bgColor: 'rgba(245, 63, 63, 0.1)',
|
||||
progressStatus: 'danger' as const,
|
||||
},
|
||||
{
|
||||
name: '暴力破解',
|
||||
value: 234,
|
||||
percent: 30,
|
||||
icon: IconLock,
|
||||
color: '#FF7D00',
|
||||
bgColor: 'rgba(255, 125, 0, 0.1)',
|
||||
progressStatus: 'warning' as const,
|
||||
},
|
||||
{
|
||||
name: 'SQL注入',
|
||||
value: 189,
|
||||
percent: 25,
|
||||
icon: IconCode,
|
||||
color: '#165DFF',
|
||||
bgColor: 'rgba(22, 93, 255, 0.1)',
|
||||
progressStatus: 'normal' as const,
|
||||
},
|
||||
{
|
||||
name: 'XSS攻击',
|
||||
value: 156,
|
||||
percent: 20,
|
||||
icon: IconCode,
|
||||
color: '#14C9C9',
|
||||
bgColor: 'rgba(20, 201, 201, 0.1)',
|
||||
progressStatus: 'normal' as const,
|
||||
},
|
||||
])
|
||||
const parseMetricsMap = (metrics: SecurityMetric[]) => {
|
||||
const map = new Map<string, number>()
|
||||
for (const metric of metrics || []) {
|
||||
map.set(metric.metric_name, Number(metric.metric_value))
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// 威胁趋势图表配置
|
||||
const threatChartOptions = ref({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '数量',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '检测',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [45, 32, 89, 156, 123, 78, 56],
|
||||
areaStyle: {
|
||||
opacity: 0.1,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#165DFF',
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#165DFF',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '拦截',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [12, 8, 25, 45, 38, 22, 15],
|
||||
areaStyle: {
|
||||
opacity: 0.1,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#14C9C9',
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#14C9C9',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const pickMetricValue = (metricsMap: Map<string, number>, key: keyof typeof metricNameAliases) => {
|
||||
const aliases = metricNameAliases[key]
|
||||
for (const alias of aliases) {
|
||||
if (metricsMap.has(alias)) return metricsMap.get(alias) ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getControllerStatus = (item: SecurityServiceItem, availability: number | null): ControllerStatus => {
|
||||
if (item.status === 'online') return 'online'
|
||||
if (item.status === 'offline') return 'offline'
|
||||
if (item.status === 'error') return 'warning'
|
||||
if (availability !== null) return availability >= 95 ? 'online' : 'warning'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const controllerStatusTextMap: Record<ControllerStatus, string> = {
|
||||
online: '正常',
|
||||
offline: '离线',
|
||||
warning: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetchSecurityServiceList({ page: 1, size: 100 })
|
||||
if (!response || response.code !== 0 || !response.details?.data) {
|
||||
Message.error(response?.message || '获取安全设备列表失败')
|
||||
devices.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const baseList = response.details.data
|
||||
const metricsRespList = await Promise.all(
|
||||
baseList.map((item) =>
|
||||
fetchSecurityMetricsLatest(item.service_identity).catch(() => ({
|
||||
code: -1,
|
||||
details: { metrics: [] as SecurityMetric[] },
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
devices.value = baseList.map((item, idx) => {
|
||||
const latest = metricsRespList[idx]
|
||||
const metricsMap = parseMetricsMap(latest?.details?.metrics || [])
|
||||
const availability = pickMetricValue(metricsMap, 'availability')
|
||||
const controllerStatus = getControllerStatus(item, availability)
|
||||
return {
|
||||
...item,
|
||||
cpu_usage: pickMetricValue(metricsMap, 'cpu_usage'),
|
||||
memory_usage: pickMetricValue(metricsMap, 'memory_usage'),
|
||||
swap_usage: pickMetricValue(metricsMap, 'swap_usage'),
|
||||
disk_usage: pickMetricValue(metricsMap, 'disk_usage'),
|
||||
disk_io_throughput: pickMetricValue(metricsMap, 'disk_io_throughput'),
|
||||
availability,
|
||||
controller_status: controllerStatus,
|
||||
controller_status_text: controllerStatusTextMap[controllerStatus],
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
Message.error(error?.message || '加载安全设备监控数据失败')
|
||||
devices.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const avgMetric = (values: Array<number | null>) => {
|
||||
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
|
||||
if (!nums.length) return null
|
||||
return nums.reduce((acc, cur) => acc + cur, 0) / nums.length
|
||||
}
|
||||
|
||||
const toNum = (val: number | null) => (typeof val === 'number' && Number.isFinite(val) ? Number(val.toFixed(2)) : 0)
|
||||
|
||||
const formatPercent = (val: number | null) => {
|
||||
if (val === null || !Number.isFinite(val)) return '-'
|
||||
return `${val.toFixed(2)}%`
|
||||
}
|
||||
|
||||
const formatThroughput = (val: number | null) => {
|
||||
if (val === null || !Number.isFinite(val)) return '-'
|
||||
return `${val.toFixed(2)}`
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
@@ -343,7 +367,16 @@ const getStatusColor = (status: string) => {
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
/** 获取状态文本 */
|
||||
const getControllerStatusColor = (status: ControllerStatus) => {
|
||||
const colorMap: Record<ControllerStatus, string> = {
|
||||
online: 'green',
|
||||
offline: 'gray',
|
||||
warning: 'orange',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status]
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
online: '在线',
|
||||
@@ -355,39 +388,6 @@ const getStatusText = (status: string) => {
|
||||
return textMap[status] || '未知'
|
||||
}
|
||||
|
||||
/** 格式化运行时长 */
|
||||
const formatUptime = (uptime: number) => {
|
||||
if (!uptime || uptime === 0) return '-'
|
||||
const days = Math.floor(uptime / 86400)
|
||||
const hours = Math.floor((uptime % 86400) / 3600)
|
||||
const minutes = Math.floor((uptime % 3600) / 60)
|
||||
if (days > 0) return `${days}天 ${hours}小时`
|
||||
if (hours > 0) return `${hours}小时 ${minutes}分钟`
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/** 获取数据 */
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetchSecurityServiceList()
|
||||
if (response && response.code === 0 && response.details) {
|
||||
deviceData.value = response.details.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取安全设备列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
@@ -408,6 +408,10 @@ export default {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
height: 100%;
|
||||
|
||||
@@ -440,14 +444,14 @@ export default {
|
||||
color: rgb(var(--success-6));
|
||||
}
|
||||
|
||||
&-danger {
|
||||
background-color: rgba(245, 63, 63, 0.1);
|
||||
color: rgb(var(--danger-6));
|
||||
&-cyan {
|
||||
background-color: rgba(20, 201, 201, 0.1);
|
||||
color: #14c9c9;
|
||||
}
|
||||
|
||||
&-muted {
|
||||
background-color: rgba(134, 144, 156, 0.1);
|
||||
color: rgb(var(--gray-6));
|
||||
&-warning {
|
||||
background-color: rgba(247, 186, 30, 0.12);
|
||||
color: #f7ba1e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,115 +479,56 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 280px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
.controller-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 300px;
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
.controller-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.controller-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&-1 {
|
||||
background-color: #165dff;
|
||||
}
|
||||
|
||||
&-2 {
|
||||
background-color: #14c9c9;
|
||||
}
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: rgb(var(--danger-6));
|
||||
.controller-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: rgb(var(--success-6));
|
||||
}
|
||||
|
||||
.threat-category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 280px;
|
||||
.text-danger {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
|
||||
.threat-category-item {
|
||||
.threat-category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.threat-category-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.threat-category-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.threat-category-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.threat-category-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
.text-warning {
|
||||
color: rgb(var(--warning-6));
|
||||
}
|
||||
|
||||
.threats-value {
|
||||
font-weight: 500;
|
||||
|
||||
&.danger {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: rgb(var(--warning-6));
|
||||
}
|
||||
|
||||
&.normal {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.cpu-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.cpu-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
min-width: 36px;
|
||||
}
|
||||
.text-muted-strong {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">虚拟机总数</div>
|
||||
<div class="stats-value">{{ stats.total }}</div>
|
||||
<div class="stats-desc">跨 8 台宿主机</div>
|
||||
<div class="stats-desc">宿主机 {{ stats.assetGroups }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">运行中</div>
|
||||
<div class="stats-value">{{ stats.running }}</div>
|
||||
<div class="stats-desc text-success">90.7%</div>
|
||||
<div class="stats-desc text-success">{{ runningRateText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -38,8 +38,8 @@
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">CPU使用率</div>
|
||||
<div class="stats-value">{{ stats.cpuUsage }}%</div>
|
||||
<div class="stats-desc">集群平均</div>
|
||||
<div class="stats-value">{{ statsCpuUsageText }}%</div>
|
||||
<div class="stats-desc">平均</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -52,8 +52,8 @@
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">内存使用率</div>
|
||||
<div class="stats-value">{{ stats.memoryUsage }}%</div>
|
||||
<div class="stats-desc">集群平均</div>
|
||||
<div class="stats-value">{{ statsMemoryUsageText }}%</div>
|
||||
<div class="stats-desc">平均</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -85,7 +85,7 @@
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card title="CPU资源分配" :bordered="false">
|
||||
<template #extra>
|
||||
<span class="text-muted">集群总计 256 vCPU</span>
|
||||
<span class="text-muted">总计 {{ cpuTotalText }} vCPU</span>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
<Chart :options="cpuChartOptions" height="240px" />
|
||||
@@ -93,11 +93,11 @@
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot legend-dot-1"></span>
|
||||
<span>已分配 174 vCPU</span>
|
||||
<span>已使用 {{ cpuVirtualText }} vCPU</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot legend-dot-gray"></span>
|
||||
<span>可用 82 vCPU</span>
|
||||
<span>总计 {{ cpuPhysicalText }} vCPU</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -105,7 +105,7 @@
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card title="内存资源分配" :bordered="false">
|
||||
<template #extra>
|
||||
<span class="text-muted">集群总计 512 GB</span>
|
||||
<span class="text-muted">总计 {{ memoryTotalText }} GB</span>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
<Chart :options="memoryChartOptions" height="240px" />
|
||||
@@ -113,11 +113,11 @@
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot legend-dot-2"></span>
|
||||
<span>已使用 384 GB</span>
|
||||
<span>已使用 {{ memoryVirtualText }} GB</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot legend-dot-gray"></span>
|
||||
<span>可用 128 GB</span>
|
||||
<span>总计 {{ memoryPhysicalText }} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -130,7 +130,9 @@
|
||||
<a-card class="host-card" :bordered="false">
|
||||
<div class="host-header">
|
||||
<span class="host-name">{{ host.name }}</span>
|
||||
<a-tag color="green" size="small">在线</a-tag>
|
||||
<a-tag :color="host.physicalCount > 0 ? 'green' : 'gray'" size="small">
|
||||
{{ host.physicalCount > 0 ? '有物理机' : '仅虚拟机' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="host-metrics">
|
||||
<div class="metric-item">
|
||||
@@ -151,253 +153,58 @@
|
||||
<span class="metric-label">虚拟机数</span>
|
||||
<span class="metric-value-right">{{ host.vmCount }}</span>
|
||||
</div>
|
||||
<div class="metric-item metric-vm">
|
||||
<span class="metric-label">物理机数</span>
|
||||
<span class="metric-value-right">{{ host.physicalCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 虚拟机列表 -->
|
||||
<a-card title="虚拟机列表" :bordered="false">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select v-model="filterHost" placeholder="全部宿主机" style="width: 130px">
|
||||
<a-option value="">全部宿主机</a-option>
|
||||
<a-option value="ESXi-01">ESXi-01</a-option>
|
||||
<a-option value="ESXi-02">ESXi-02</a-option>
|
||||
<a-option value="ESXi-03">ESXi-03</a-option>
|
||||
<a-option value="ESXi-04">ESXi-04</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="filterStatus" placeholder="全部状态" style="width: 120px">
|
||||
<a-option value="">全部状态</a-option>
|
||||
<a-option value="running">运行中</a-option>
|
||||
<a-option value="stopped">已停止</a-option>
|
||||
<a-option value="warning">异常</a-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-table
|
||||
:data="filteredVMs"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="name"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.statusValue)" bordered>
|
||||
{{ record.statusText }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- CPU列 -->
|
||||
<template #cpu="{ record }">
|
||||
<div class="progress-cell">
|
||||
<a-progress
|
||||
:percent="record.cpu / 100"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
:status="getProgressStatus(record.cpu)"
|
||||
/>
|
||||
<span class="progress-text">{{ record.cpu }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 内存列 -->
|
||||
<template #memory="{ record }">
|
||||
<div class="progress-cell">
|
||||
<a-progress
|
||||
:percent="record.memory / 100"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
:status="getProgressStatus(record.memory)"
|
||||
/>
|
||||
<span class="progress-text">{{ record.memory }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 存储列 -->
|
||||
<template #storage="{ record }">
|
||||
<div class="progress-cell">
|
||||
<a-progress
|
||||
:percent="record.storage / 100"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
:status="getProgressStatus(record.storage)"
|
||||
/>
|
||||
<span class="progress-text">{{ record.storage }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
<!-- 虚拟机列表模块按需求下线(暂不渲染) -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconStorage,
|
||||
IconCheckCircleFill,
|
||||
IconCodeSquare,
|
||||
IconDriveFile,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import Breadcrumb from '@/components/breadcrumb/index.vue'
|
||||
import Chart from '@/components/chart/index.vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import {
|
||||
fetchAssetMixedSummary,
|
||||
fetchLatestResourceSummary,
|
||||
fetchPhysicalUsageTrend24h,
|
||||
fetchVirtualOverview,
|
||||
type AssetMixedSummaryItem,
|
||||
} from '@/api/ops/server'
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
total: 86,
|
||||
running: 78,
|
||||
cpuUsage: 68,
|
||||
memoryUsage: 75,
|
||||
total: 0,
|
||||
assetGroups: 0,
|
||||
running: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const filterHost = ref('')
|
||||
const filterStatus = ref('')
|
||||
|
||||
// 表格列配置
|
||||
const columns: TableColumnData[] = [
|
||||
{
|
||||
title: '虚拟机名称',
|
||||
dataIndex: 'name',
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: '宿主机',
|
||||
dataIndex: 'host',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作系统',
|
||||
dataIndex: 'os',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
slotName: 'status',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: 'CPU使用率',
|
||||
dataIndex: 'cpu',
|
||||
slotName: 'cpu',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '内存使用率',
|
||||
dataIndex: 'memory',
|
||||
slotName: 'memory',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '存储使用率',
|
||||
dataIndex: 'storage',
|
||||
slotName: 'storage',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '网络流量',
|
||||
dataIndex: 'network',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
]
|
||||
|
||||
// 虚拟机数据
|
||||
const vmData = ref([
|
||||
{
|
||||
name: 'VM-Web-01',
|
||||
host: 'ESXi-01',
|
||||
os: 'CentOS 7.9',
|
||||
statusValue: 'running',
|
||||
statusText: '运行中',
|
||||
cpu: 65,
|
||||
memory: 72,
|
||||
storage: 45,
|
||||
network: '1.2 Gbps',
|
||||
},
|
||||
{
|
||||
name: 'VM-DB-01',
|
||||
host: 'ESXi-01',
|
||||
os: 'Ubuntu 22.04',
|
||||
statusValue: 'running',
|
||||
statusText: '运行中',
|
||||
cpu: 85,
|
||||
memory: 88,
|
||||
storage: 78,
|
||||
network: '850 Mbps',
|
||||
},
|
||||
{
|
||||
name: 'VM-App-01',
|
||||
host: 'ESXi-02',
|
||||
os: 'Windows Server 2022',
|
||||
statusValue: 'running',
|
||||
statusText: '运行中',
|
||||
cpu: 42,
|
||||
memory: 55,
|
||||
storage: 35,
|
||||
network: '450 Mbps',
|
||||
},
|
||||
{
|
||||
name: 'VM-Cache-01',
|
||||
host: 'ESXi-02',
|
||||
os: 'CentOS 8',
|
||||
statusValue: 'warning',
|
||||
statusText: '高负载',
|
||||
cpu: 92,
|
||||
memory: 95,
|
||||
storage: 60,
|
||||
network: '2.1 Gbps',
|
||||
},
|
||||
{
|
||||
name: 'VM-Dev-01',
|
||||
host: 'ESXi-03',
|
||||
os: 'Ubuntu 20.04',
|
||||
statusValue: 'stopped',
|
||||
statusText: '已停止',
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
storage: 25,
|
||||
network: '-',
|
||||
},
|
||||
{
|
||||
name: 'VM-Test-01',
|
||||
host: 'ESXi-03',
|
||||
os: 'Debian 11',
|
||||
statusValue: 'running',
|
||||
statusText: '运行中',
|
||||
cpu: 28,
|
||||
memory: 35,
|
||||
storage: 42,
|
||||
network: '320 Mbps',
|
||||
},
|
||||
])
|
||||
|
||||
// 过滤后的虚拟机列表
|
||||
const filteredVMs = computed(() => {
|
||||
let result = vmData.value
|
||||
|
||||
if (filterHost.value) {
|
||||
result = result.filter((vm) => vm.host === filterHost.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value) {
|
||||
result = result.filter((vm) => vm.statusValue === filterStatus.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 宿主机状态
|
||||
const hostStatus = ref([
|
||||
{ name: 'ESXi-01', cpu: 65, memory: 78, vmCount: 24 },
|
||||
{ name: 'ESXi-02', cpu: 72, memory: 85, vmCount: 22 },
|
||||
{ name: 'ESXi-03', cpu: 45, memory: 52, vmCount: 18 },
|
||||
{ name: 'ESXi-04', cpu: 58, memory: 68, vmCount: 22 },
|
||||
])
|
||||
const hostStatus = ref<
|
||||
Array<{
|
||||
name: string
|
||||
cpu: number
|
||||
memory: number
|
||||
vmCount: number
|
||||
physicalCount: number
|
||||
}>
|
||||
>([])
|
||||
|
||||
// 资源使用趋势图表配置
|
||||
const performanceChartOptions = ref({
|
||||
@@ -413,7 +220,7 @@ const performanceChartOptions = ref({
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
|
||||
data: [] as string[],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
@@ -425,7 +232,7 @@ const performanceChartOptions = ref({
|
||||
name: 'CPU',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [45, 38, 72, 85, 78, 55, 42],
|
||||
data: [] as number[],
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#165DFF',
|
||||
@@ -438,7 +245,7 @@ const performanceChartOptions = ref({
|
||||
name: '内存',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [62, 58, 75, 82, 79, 65, 60],
|
||||
data: [] as number[],
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#F7BA1E',
|
||||
@@ -464,8 +271,8 @@ const cpuChartOptions = ref({
|
||||
show: false,
|
||||
},
|
||||
data: [
|
||||
{ value: 68, name: '已分配', itemStyle: { color: '#165DFF' } },
|
||||
{ value: 32, name: '可用', itemStyle: { color: '#E5E6EB' } },
|
||||
{ value: 0, name: 'virtual', itemStyle: { color: '#165DFF' } },
|
||||
{ value: 0, name: 'physical', itemStyle: { color: '#E5E6EB' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -485,34 +292,106 @@ const memoryChartOptions = ref({
|
||||
show: false,
|
||||
},
|
||||
data: [
|
||||
{ value: 75, name: '已使用', itemStyle: { color: '#14C9C9' } },
|
||||
{ value: 25, name: '可用', itemStyle: { color: '#E5E6EB' } },
|
||||
{ value: 0, name: 'virtual', itemStyle: { color: '#14C9C9' } },
|
||||
{ value: 0, name: 'physical', itemStyle: { color: '#E5E6EB' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
running: 'green',
|
||||
stopped: 'gray',
|
||||
warning: 'orange',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
const cpuVirtual = ref(0)
|
||||
const cpuPhysical = ref(0)
|
||||
const memVirtualGb = ref(0)
|
||||
const memPhysicalGb = ref(0)
|
||||
|
||||
const roundToTwo = (value: number) => Math.round(value * 100) / 100
|
||||
const formatMaxTwoDecimals = (value: number) => {
|
||||
const rounded = roundToTwo(value)
|
||||
return Number.isInteger(rounded) ? String(rounded) : String(rounded)
|
||||
}
|
||||
|
||||
// 获取进度条状态
|
||||
const getProgressStatus = (value: number) => {
|
||||
if (value >= 90) return 'danger'
|
||||
if (value >= 70) return 'warning'
|
||||
return 'normal'
|
||||
const runningRateText = computed(() => {
|
||||
if (!stats.value.total) return '0%'
|
||||
return `${formatMaxTwoDecimals((stats.value.running / stats.value.total) * 100)}%`
|
||||
})
|
||||
const statsCpuUsageText = computed(() => formatMaxTwoDecimals(stats.value.cpuUsage))
|
||||
const statsMemoryUsageText = computed(() => formatMaxTwoDecimals(stats.value.memoryUsage))
|
||||
const cpuTotalText = computed(() => formatMaxTwoDecimals(cpuVirtual.value + cpuPhysical.value))
|
||||
const cpuVirtualText = computed(() => formatMaxTwoDecimals(cpuVirtual.value))
|
||||
const cpuPhysicalText = computed(() => formatMaxTwoDecimals(cpuPhysical.value))
|
||||
const memoryTotalText = computed(() => formatMaxTwoDecimals(memVirtualGb.value + memPhysicalGb.value))
|
||||
const memoryVirtualText = computed(() => formatMaxTwoDecimals(memVirtualGb.value))
|
||||
const memoryPhysicalText = computed(() => formatMaxTwoDecimals(memPhysicalGb.value))
|
||||
const toGb = (bytes: number) => bytes / 1024 / 1024 / 1024
|
||||
const safeNum = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
|
||||
const hourLabel = (v: string) => {
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return v
|
||||
return `${String(d.getHours()).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
// TODO: 从API获取数据
|
||||
loading.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const [overviewRes, trendRes, resourceRes, mixedRes] = await Promise.all([
|
||||
fetchVirtualOverview(),
|
||||
fetchPhysicalUsageTrend24h(),
|
||||
fetchLatestResourceSummary(),
|
||||
fetchAssetMixedSummary(),
|
||||
])
|
||||
|
||||
if (overviewRes.code !== 0 || !overviewRes.details) {
|
||||
Message.error(overviewRes.message || '加载虚拟机概览失败')
|
||||
return
|
||||
}
|
||||
const overview = overviewRes.details
|
||||
stats.value.total = overview.vm_total || 0
|
||||
stats.value.assetGroups = overview.asset_group_total || 0
|
||||
stats.value.running = overview.online_total || 0
|
||||
stats.value.cpuUsage = roundToTwo(safeNum(overview.latest_cpu_usage_avg))
|
||||
stats.value.memoryUsage = roundToTwo(safeNum(overview.latest_mem_usage_avg))
|
||||
|
||||
if (trendRes.code === 0 && trendRes.details?.points) {
|
||||
const labels = trendRes.details.points.map((p) => hourLabel(p.hour))
|
||||
const cpuSeries = trendRes.details.points.map((p) => roundToTwo(safeNum(p.cpu_used_percent_avg)))
|
||||
const memSeries = trendRes.details.points.map((p) => roundToTwo(safeNum(p.mem_used_percent_avg)))
|
||||
;(performanceChartOptions.value.xAxis as { data: string[] }).data = labels
|
||||
;(performanceChartOptions.value.series[0] as { data: number[] }).data = cpuSeries
|
||||
;(performanceChartOptions.value.series[1] as { data: number[] }).data = memSeries
|
||||
}
|
||||
|
||||
if (resourceRes.code === 0 && resourceRes.details) {
|
||||
cpuVirtual.value = roundToTwo(safeNum(resourceRes.details.virtual?.total_vcpu))
|
||||
cpuPhysical.value = roundToTwo(safeNum(resourceRes.details.physical?.total_vcpu))
|
||||
memVirtualGb.value = roundToTwo(toGb(safeNum(resourceRes.details.virtual?.total_mem_bytes)))
|
||||
memPhysicalGb.value = roundToTwo(toGb(safeNum(resourceRes.details.physical?.total_mem_bytes)))
|
||||
;(cpuChartOptions.value.series[0] as { data: Array<{ value: number; name: string }> }).data = [
|
||||
{ value: cpuVirtual.value, name: 'virtual' },
|
||||
{ value: cpuPhysical.value, name: 'physical' },
|
||||
]
|
||||
;(memoryChartOptions.value.series[0] as { data: Array<{ value: number; name: string }> }).data = [
|
||||
{ value: memVirtualGb.value, name: 'virtual' },
|
||||
{ value: memPhysicalGb.value, name: 'physical' },
|
||||
]
|
||||
}
|
||||
|
||||
if (mixedRes.code === 0 && mixedRes.details?.data) {
|
||||
hostStatus.value = (mixedRes.details.data as AssetMixedSummaryItem[]).map((item) => ({
|
||||
name: `Asset-${item.asset_id}`,
|
||||
cpu: Number(safeNum(item.physical_latest_cpu_usage).toFixed(2)),
|
||||
memory: Number(safeNum(item.physical_latest_mem_usage).toFixed(2)),
|
||||
vmCount: item.virtual_count || 0,
|
||||
physicalCount: item.physical_count || 0,
|
||||
}))
|
||||
} else {
|
||||
hostStatus.value = []
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载虚拟化监控数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
|
||||
@@ -30,19 +30,10 @@
|
||||
<a-doption value="server">服务器</a-doption>
|
||||
<a-doption value="switch">交换机</a-doption>
|
||||
<a-doption value="router">路由器</a-doption>
|
||||
<a-doption value="firewall">防火墙</a-doption>
|
||||
<a-doption value="storage">存储</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-button-group>
|
||||
|
||||
<a-tooltip v-if="props.onBatchImportAssets" content="按资产 ID 批量导入(绑定 ref_type=asset)">
|
||||
<a-button type="outline" size="small" @click="props.onBatchImportAssets">
|
||||
<icon-import :size="18" />
|
||||
<span class="btn-text">资产导入</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 布局 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-dropdown trigger="click" @select="props.onLayout">
|
||||
@@ -108,27 +99,23 @@ import {
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconRotateLeft,
|
||||
IconImport,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
|
||||
interface Props {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitView: () => void;
|
||||
onAddDevice: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onLayout: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onEdgeStyle: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
onReset?: () => void;
|
||||
/** 打开「批量导入资产」对话框(无拓扑 ID 时不传) */
|
||||
onBatchImportAssets?: () => void;
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onFitView: () => void
|
||||
onAddDevice: (value: string | number | Record<string, any> | undefined) => void
|
||||
onLayout: (value: string | number | Record<string, any> | undefined) => void
|
||||
onEdgeStyle: (value: string | number | Record<string, any> | undefined) => void
|
||||
onRefresh: () => void
|
||||
onExport: () => void
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
onReset: undefined,
|
||||
onBatchImportAssets: undefined,
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -25,8 +25,6 @@ export const DEVICE_TYPE_CONFIG: Record<DeviceType, { icon: any; label: string;
|
||||
/** 扩展设备类型配置(包含更多设备) */
|
||||
export const EXTENDED_DEVICE_CONFIG = {
|
||||
...DEVICE_TYPE_CONFIG,
|
||||
firewall: { icon: IconShield, label: '防火墙', color: '#DC2626' },
|
||||
storage: { icon: IconDatabase, label: '存储设备', color: '#7C3AED' },
|
||||
mobile: { icon: IconDeviceMobile, label: '移动设备', color: '#EC4899' },
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
@refresh="refreshTopology"
|
||||
@export="exportTopology"
|
||||
@reset="resetTopology"
|
||||
:on-batch-import-assets="currentTopologyId ? openBatchImportAssets : undefined"
|
||||
/>
|
||||
|
||||
<!-- Vue Flow 画布 -->
|
||||
@@ -46,7 +45,7 @@
|
||||
>
|
||||
<background pattern-color="#aaa" :gap="16" />
|
||||
<mini-map :node-color="getNodeColor" node-stroke-color="#555" />
|
||||
<controls />
|
||||
<!-- <controls /> -->
|
||||
</vue-flow>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,26 +85,6 @@
|
||||
/>
|
||||
|
||||
<delete-confirm-dialog v-model:visible="deleteEdgeDialogOpen" node-name="链路" @confirm="handleDeleteEdgeConfirm" />
|
||||
|
||||
<a-modal
|
||||
v-model:visible="batchImportAssetsOpen"
|
||||
title="批量导入资产节点"
|
||||
@ok="handleBatchImportAssetsConfirm"
|
||||
@cancel="batchImportAssetsOpen = false"
|
||||
>
|
||||
<p class="text-muted" style="margin-bottom: 8px; font-size: 12px; color: var(--color-text-3)">
|
||||
调用 DC-Control
|
||||
<code>/topologies/:id/nodes/batch-import</code>
|
||||
,节点将绑定
|
||||
<code>ref_type=asset</code>
|
||||
。
|
||||
</p>
|
||||
<a-textarea
|
||||
v-model="batchImportAssetIdsText"
|
||||
placeholder="请输入资产 ID,逗号或换行分隔,例如:1,2,3"
|
||||
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -180,45 +159,6 @@ const edgeActionDialogOpen = ref(false)
|
||||
const edgeEditDialogOpen = ref(false)
|
||||
const deleteEdgeDialogOpen = ref(false)
|
||||
|
||||
const batchImportAssetsOpen = ref(false)
|
||||
const batchImportAssetIdsText = ref('')
|
||||
|
||||
const openBatchImportAssets = () => {
|
||||
batchImportAssetIdsText.value = ''
|
||||
batchImportAssetsOpen.value = true
|
||||
}
|
||||
|
||||
const handleBatchImportAssetsConfirm = async () => {
|
||||
const id = currentTopologyId.value
|
||||
if (!id) {
|
||||
Message.warning('请先通过路由选择拓扑(?id=)')
|
||||
batchImportAssetsOpen.value = false
|
||||
return
|
||||
}
|
||||
const raw = batchImportAssetIdsText.value
|
||||
.split(/[\s,,;;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
const assetIds = [...new Set(raw.map((x) => parseInt(x, 10)).filter((n) => !Number.isNaN(n)))]
|
||||
if (assetIds.length === 0) {
|
||||
Message.warning('请输入至少一个有效的资产 ID')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await TopoAPI.batchImportAssetNodes(id, assetIds)
|
||||
if (res?.code === 0) {
|
||||
Message.success(`已导入 ${res.details?.imported ?? assetIds.length} 个节点请求已提交`)
|
||||
batchImportAssetsOpen.value = false
|
||||
await refreshTopology()
|
||||
} else {
|
||||
Message.error(res?.message || '导入失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
Message.error('导入请求失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 布局钩子
|
||||
const { applyLayout } = useTopoLayout()
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user