Compare commits

...

2 Commits

Author SHA1 Message Date
zxr
01139f2874 fix 2026-04-16 18:59:33 +08:00
zxr
cb8bd05ff7 fix 2026-04-15 21:41:47 +08:00
20 changed files with 851 additions and 406 deletions

View File

@@ -12,6 +12,13 @@ export interface RoomDeviceItem {
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
@@ -45,6 +52,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 +72,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 +87,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
}
/** 指标数据项 */

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
@@ -120,6 +134,14 @@ export const fetchSecurityMetricsLatest = (serviceIdentity: string) => {
/** 采集配置补丁参数 */
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
}

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
@@ -151,6 +172,14 @@ export const fetchStorageMetricsLatest = (serviceIdentity: string) => {
/** 采集配置补丁 */
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
}

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

View File

@@ -38,10 +38,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="启用设备">
@@ -110,6 +158,13 @@ const formData = reactive({
room_id: '',
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,
@@ -149,6 +204,13 @@ watch(
room_id: props.record.room_id || '',
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,
@@ -161,6 +223,13 @@ watch(
room_id: '',
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 +243,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,6 +271,13 @@ 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,
@@ -198,6 +292,13 @@ 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,

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

@@ -17,16 +17,28 @@
<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.agent_config" :href="record.agent_config" target="_blank">{{ record.agent_config }}</a-link>
<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="采集间隔">{{ record.collect_interval }}</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>

View File

@@ -55,10 +55,62 @@
<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>
<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="启用监控">
@@ -135,6 +187,13 @@ const formData = reactive({
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,
@@ -192,6 +251,13 @@ watch(
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,
@@ -209,6 +275,13 @@ watch(
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,
@@ -224,6 +297,28 @@ 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()) {
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
@@ -236,6 +331,13 @@ const handleOk = async () => {
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,
@@ -276,3 +378,10 @@ onMounted(() => {
loadServerOptions()
})
</script>
<style scoped lang="less">
.form-extra {
color: var(--color-text-3);
font-size: 12px;
}
</style>

View File

@@ -9,6 +9,27 @@
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>
@@ -43,6 +64,10 @@ 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,
})
@@ -51,6 +76,10 @@ 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
}
@@ -62,6 +91,10 @@ const handleOk = async () => {
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,
}

View File

@@ -40,6 +40,12 @@ export const columns = [
width: 100,
slotName: 'enabled',
},
{
dataIndex: 'collect_method',
title: '采集方式',
width: 100,
slotName: 'collectMethod',
},
{
dataIndex: 'collect_on',
title: '采集状态',

View File

@@ -40,6 +40,11 @@
{{ 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)">

View File

@@ -22,7 +22,21 @@
</a-descriptions>
<a-descriptions title="采集配置" :column="2" bordered style="margin-top: 20px">
<a-descriptions-item label="Agent配置">{{ detailData?.agent_config || '-' }}</a-descriptions-item>
<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 ? '已启用' : '未启用' }}

View File

@@ -68,7 +68,39 @@
</a-row>
<a-form-item field="agent_config" label="Agent配置URL">
<a-input v-model="formData.agent_config" placeholder="请输入Agent配置地址" />
<a-radio-group v-model="formData.collect_method" type="button" style="margin-bottom: 12px">
<a-radio value="api">API</a-radio>
<a-radio value="snmp">SNMP</a-radio>
</a-radio-group>
<template v-if="formData.collect_method === 'api'">
<a-input v-model="formData.agent_config" placeholder="请输入Agent配置地址" />
</template>
<template v-else>
<a-row :gutter="12">
<a-col :span="12">
<a-input v-model="formData.snmp_target" placeholder="SNMP目标地址" />
</a-col>
<a-col :span="12">
<a-input-number v-model="formData.snmp_port" :min="1" :max="65535" placeholder="SNMP端口" style="width: 100%" />
</a-col>
<a-col :span="12">
<a-input v-model="formData.snmp_community" placeholder="SNMP community" />
</a-col>
<a-col :span="6">
<a-input-number v-model="formData.snmp_timeout_ms" :min="1" :max="60000" placeholder="超时(ms)" style="width: 100%" />
</a-col>
<a-col :span="6">
<a-input-number v-model="formData.snmp_retries" :min="0" :max="10" placeholder="重试次数" style="width: 100%" />
</a-col>
<a-col :span="24">
<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-col>
</a-row>
</template>
</a-form-item>
<a-form-item field="collect_args" label="采集参数">
@@ -124,6 +156,13 @@ const formData = reactive<StorageCreateData>({
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,
@@ -150,6 +189,13 @@ watch(
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,
@@ -166,6 +212,13 @@ watch(
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,
@@ -178,6 +231,24 @@ watch(
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
@@ -192,6 +263,13 @@ const handleOk = async () => {
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,

View File

@@ -31,6 +31,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 #status="{ record }">
<a-tag :color="getStatusColor(record.status)">

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

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>