Compare commits

...

22 Commits

Author SHA1 Message Date
e569571462 feat 2026-04-19 21:44:17 +08:00
7f209b5fef feat 2026-04-19 20:57:48 +08:00
zxr
d9a0470ecf fix 2026-04-19 14:24:03 +08:00
zxr
39d3254b8d Merge branch 'main' of https://git.apinb.com/ops/front 2026-04-18 16:26:28 +08:00
zxr
0a12f54132 fix 2026-04-18 16:26:24 +08:00
9afaa4b775 feat 2026-04-18 15:07:05 +08:00
878cbea9a5 feat 2026-04-18 14:23:06 +08:00
d5df671ff6 feat 2026-04-16 22:38:21 +08:00
ba933457d4 feat 2026-04-16 22:18:26 +08:00
zxr
01139f2874 fix 2026-04-16 18:59:33 +08:00
zxr
cb8bd05ff7 fix 2026-04-15 21:41:47 +08:00
zxr
55170bceb0 fix 2026-04-14 17:42:31 +08:00
zxr
8db158c390 fix 2026-04-14 16:17:31 +08:00
b8a7ba1cbb feat 2026-04-13 23:27:09 +08:00
e84cb75dda feat 2026-04-13 23:18:15 +08:00
zxr
f030f9c5c9 fix 2026-04-13 20:57:41 +08:00
zxr
003c552238 fix 2026-04-12 16:40:33 +08:00
zxr
1dcab7af96 fix 2026-04-12 15:01:46 +08:00
5f4111aeb1 feat 2026-04-11 22:22:07 +08:00
c72af0bfa7 feat 2026-04-11 21:23:10 +08:00
68a320b6c2 Merge branch 'main' of https://git.apinb.com/ops/front 2026-04-11 21:08:39 +08:00
a0ca86d98d feat 2026-04-11 21:08:34 +08:00
54 changed files with 8907 additions and 2865 deletions

115
.kilo/package-lock.json generated Normal file
View 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
View 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"
}
}
}
}

View File

@@ -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 });
};

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

View File

@@ -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 },
})
}

View File

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

View File

@@ -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: '未知',
}

View File

@@ -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 } },
)
}
/** 接口2physical 近24小时 CPU/内存趋势 */
export const fetchPhysicalUsageTrend24h = () => {
return request.get<{ code: number; details?: PhysicalUsageTrendPayload; message?: string }>(
'/DC-Control/v1/servers/physical/usage/trend-24h',
)
}
/** 接口3physical/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',
)
}

View File

@@ -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_identityQuery 可选 */
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)
}

View File

@@ -227,6 +227,22 @@ export const localMenuFlatItems: MenuItem[] = [
sort_key: 13.5,
created_at: '2026-04-11T10:00:00+08:00',
},
{
id: 12021,
identity: '019c7100-0001-7000-8000-000000000021',
title: '服务器硬件监控',
title_en: 'Server Hardware (OOB)',
code: 'ops:综合监控:服务器硬件监控',
description: '综合监控 - 服务器带外硬件BMC/IPMI/Redfish 等DC-Hardware',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/host-hardware',
menu_icon: 'appstore',
component: 'ops/pages/monitor/host-hardware',
type: 1,
sort_key: 13.52,
created_at: '2026-04-13T10:00:00+08:00',
},
{
id: 27,
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
@@ -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',

View File

@@ -243,6 +243,23 @@ export const localMenuItems: MenuItem[] = [
created_at: '2026-04-11T10:00:00+08:00',
children: [],
},
{
id: 12021,
identity: '019c7100-0001-7000-8000-000000000021',
title: '服务器硬件监控',
title_en: 'Server Hardware (OOB)',
code: 'ops:综合监控:服务器硬件监控',
description: '综合监控 - 服务器带外硬件BMC/IPMI/Redfish 等DC-Hardware',
app_id: 2,
parent_id: 23,
menu_path: '/monitor/host-hardware',
menu_icon: 'appstore',
component: 'ops/pages/monitor/host-hardware',
type: 1,
sort_key: 5,
created_at: '2026-04-13T10:00:00+08:00',
children: [],
},
{
id: 27,
identity: '019b591d-01a5-776f-ac4b-3cd896dd3f48',
@@ -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: [],
},
],
},
{

View File

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

View File

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

View File

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

View File

@@ -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: '机柜类型',

View File

@@ -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 },
})
}

View File

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

View File

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

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

View 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' },
],
},
]

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

View File

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

View File

@@ -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('获取最新指标失败')

View File

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

View File

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

View File

@@ -38,6 +38,12 @@ export const columns = [
width: 100,
slotName: 'enabled',
},
{
dataIndex: 'collect_method',
title: '采集方式',
width: 100,
slotName: 'collect_method',
},
{
dataIndex: 'collect_on',
title: '数据采集',

View File

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

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

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

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

View 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',
},
]

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

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

View File

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

View File

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

View File

@@ -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返回规范化后的 IPIPv4 或 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,

View File

@@ -143,6 +143,12 @@
</template>
详情
</a-doption> -->
<a-doption @click="handleHardwareDeviceConfig(record)">
<template #icon>
<icon-storage />
</template>
硬件设备配置
</a-doption>
<a-doption @click="handleEdit(record)">
<template #icon>
<icon-edit />
@@ -180,6 +186,12 @@
:record="currentRecord"
@success="handleFormSuccess"
/>
<HardwareDeviceConfigDialog
v-model:visible="hardwareConfigVisible"
:record="currentRecord"
@success="handleHardwareConfigSuccess"
/>
</div>
</template>
@@ -195,13 +207,15 @@ import {
IconDelete,
IconRefresh,
IconEye,
IconSettings
IconSettings,
IconStorage,
} from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form'
import ServerFormDialog from './components/ServerFormDialog.vue'
import QuickConfigDialog from './components/QuickConfigDialog.vue'
import HardwareDeviceConfigDialog from './components/HardwareDeviceConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import {
fetchServerList,
@@ -221,6 +235,7 @@ const loading = ref(false)
const tableData = ref<any[]>([])
const formDialogVisible = ref(false)
const quickConfigVisible = ref(false)
const hardwareConfigVisible = ref(false)
const currentRecord = ref<any>(null)
const formModel = ref({
keyword: '',
@@ -389,6 +404,16 @@ const handleQuickConfig = (record: any) => {
quickConfigVisible.value = true
}
// DC-Hardware 带外硬件设备配置
const handleHardwareDeviceConfig = (record: any) => {
currentRecord.value = record
hardwareConfigVisible.value = true
}
const handleHardwareConfigSuccess = () => {
fetchServers()
}
// 编辑服务器
const handleEdit = (record: any) => {
currentRecord.value = record
@@ -460,6 +485,7 @@ const handleDelete = async (record: any) => {
})
}
/** dc-host `GET agent_config`(一般为 `/dc-host/stats`)返回裸 Metrics JSON */
// 获取所有服务器的监控指标
const getAllMetrics = async () => {
try {

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

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

View 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',
},
]

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

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

View File

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

View File

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

View 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

View File

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

View File

@@ -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
}
}
// 初始化

View File

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

View File

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

View File

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