Compare commits

3 Commits

Author SHA1 Message Date
zxr
9b22196eb0 fix: 验收修正 2026-09-21 10:46:12 +08:00
zxr
391f3fafe9 Merge branch 'main' of https://git.apinb.com/ops/front 2026-09-16 13:59:24 +08:00
zxr
6452bf7f71 fix 2026-09-16 13:59:14 +08:00
14 changed files with 2436 additions and 410 deletions

View File

@@ -71,6 +71,12 @@ export interface CollectorNode {
capabilities: string
version?: string
queue_depth: number
spool_bytes: number
max_concurrency: number
execution_timeout_seconds: number
running_tasks: number
pending_tasks: number
oldest_pending_at?: string
last_heartbeat_at?: string
last_error?: string
enrollment_expires_at?: string
@@ -83,6 +89,8 @@ export interface CollectorNodeInput {
region_code?: string
description?: string
capabilities: string
max_concurrency: number
execution_timeout_seconds: number
enabled?: boolean
}
@@ -98,6 +106,7 @@ export interface CollectionBinding {
target: string
port: number
interval_seconds: number
inventory_interval_seconds: number
retry_policy: string
enabled: boolean
state: string
@@ -117,20 +126,21 @@ export interface CollectionBindingInput {
target: string
port: number
interval_seconds: number
inventory_interval_seconds: number
retry_policy?: string
enabled?: boolean
}
export interface HostAgentRegistration {
id: number
agent_id: string
resource_uid: string
claimed_resource_uid: string
hostname: string
version: string
status: 'confirmed' | 'pending_confirmation'
last_error: string
last_seen_at: string
id: number
agent_id: string
resource_uid: string
claimed_resource_uid: string
hostname: string
version: string
status: 'confirmed' | 'pending_confirmation'
last_error: string
last_seen_at: string
}
export const fetchCredentials = (params?: Record<string, unknown>) =>
@@ -158,16 +168,22 @@ export const deleteCollectorNode = (id: number) => request.delete(`${PREFIX}/col
export const rotateCollectorNodeToken = (id: number) => request.post(`${PREFIX}/collector-nodes/${id}/installation-token`)
export const fetchHostAgentRegistrations = (params?: Record<string, unknown>) =>
request.get<{ code: number; details?: PageResult<HostAgentRegistration>; message?: string }>(`${PREFIX}/host-agents`, { params })
request.get<{ code: number; details?: PageResult<HostAgentRegistration>; message?: string }>(`${PREFIX}/host-agents`, { params })
export const confirmHostAgentRegistration = (id: number, resourceUid: string) =>
request.post(`${PREFIX}/host-agents/${id}/confirm`, { resource_uid: resourceUid })
request.post(`${PREFIX}/host-agents/${id}/confirm`, { resource_uid: resourceUid })
export const fetchCollectionBindings = (params?: Record<string, unknown>) =>
request.get<{ code: number; details?: PageResult<CollectionBinding>; message?: string }>(`${PREFIX}/bindings`, { params })
export const createCollectionBinding = (data: CollectionBindingInput) => request.post(`${PREFIX}/bindings`, data)
export const updateCollectionBinding = (id: number, data: CollectionBindingInput) => request.put(`${PREFIX}/bindings/${id}`, data)
export const batchUpdateCollectionBindings = (data: {
ids: number[]
interval_seconds?: number
inventory_interval_seconds?: number
enabled?: boolean
}) => request.put(`${PREFIX}/bindings/batch`, data)
export const deleteCollectionBinding = (id: number) => request.delete(`${PREFIX}/bindings/${id}`)
export const fetchDiscoveryRuns = (params?: Record<string, string | number | boolean | undefined>) =>

View File

@@ -46,7 +46,7 @@ export const fetchIpScanList = (params?: { page?: number; size?: number; keyword
export const fetchIpScanDetail = (id: number) =>
request.get<{ code: number; details?: IpScanTask; message?: string }>(`/DC-Control/v1/ipscans/${id}`)
/** 触发一次扫描,运行记录进入指定采集节点的等待队列。 */
/** 触发一次扫描,由独立网络扫描服务执行。 */
export const triggerIpScan = (id: number) =>
request.post<{ code: number; details?: { message?: string }; message?: string }>(`/DC-Control/v1/ipscans/${id}/trigger`)

View File

@@ -26,6 +26,18 @@ export interface Topology {
updated_at?: string
}
export interface TopologyDiscoveryRun {
id: number
topology_id: number
status: 'running' | 'succeeded' | 'failed'
total_tasks: number
completed_tasks: number
failed_tasks: number
started_at: string
completed_at?: string
last_error?: string
}
/** 链路对象 */
export interface Link {
id?: number
@@ -188,7 +200,11 @@ export const updateTopology = (data: Partial<Topology> & { id: number }) => requ
export const deleteTopology = (id: number) => request.delete(`/DC-Control/v1/topologies/${id}`)
/** 触发拓扑发现 */
export const discoverTopology = (id: number) => request.post(`/DC-Control/v1/topologies/${id}/discover`)
export const discoverTopology = (id: number) =>
request.post<{ code: number; details?: TopologyDiscoveryRun; message?: string }>(`/DC-Control/v1/topologies/${id}/discover`)
export const fetchLatestTopologyDiscoveryRun = (id: number) =>
request.get<{ code: number; details?: TopologyDiscoveryRun | null; message?: string }>(`/DC-Control/v1/topologies/${id}/discovery-run`)
/** 获取拓扑视图(包含拓扑和链路) */
export const fetchTopologyView = (id: number) => request.get(`/DC-Control/v1/topologies/${id}/view`)
@@ -216,10 +232,9 @@ export const createNode = (topologyId: number, data: Partial<TopologyNode>) =>
/** 将已有统一设备引用加入手工拓扑 */
export const addResourceNodes = (topologyId: number, resource_uids: string[]) =>
request.post<{ code: number; details?: { message: string; added: number } }>(
`/DC-Control/v1/topologies/${topologyId}/resource-nodes`,
{ resource_uids }
)
request.post<{ code: number; details?: { message: string; added: number } }>(`/DC-Control/v1/topologies/${topologyId}/resource-nodes`, {
resource_uids,
})
/** 更新节点 */
export const updateNode = (topologyId: number, nodeId: string, data: Partial<TopologyNode>) =>

View File

@@ -1,21 +1,116 @@
import { request } from '@/api/request'
import type { NetworkDeviceService } from '@/api/ops/network-device'
export const fetchNetworkScreenOverview = () => request.get('/DC-Control/v1/network-screen/overview')
export interface NetworkScreenResponse<T> {
code?: number
details?: T
message?: string
timeseq?: number
}
export const fetchNetworkScreenHealthMatrix = (params?: { window?: string }) =>
request.get('/DC-Control/v1/network-screen/health-matrix', { params })
export interface NetworkScreenOverview {
total_devices: number
online_devices: number
abnormal_devices: number
avg_response_time_ms: number
updated_at: string
}
export const fetchNetworkScreenFreshnessDistribution = () => request.get('/DC-Control/v1/network-screen/freshness-distribution')
export interface NetworkHealthMatrixItem {
error_bucket: string
latency_bucket: string
count: number
}
export const fetchNetworkScreenProtocolDistribution = () => request.get('/DC-Control/v1/network-screen/protocol-distribution')
export interface NetworkFreshnessItem {
bucket_label: string
count: number
}
export const fetchNetworkScreenResourceOverview = (params?: { window?: string }) =>
request.get('/DC-Control/v1/network-screen/resource-overview', { params })
export interface NetworkProtocolItem {
protocol: string
snmp_version: string
count: number
}
export const fetchNetworkScreenAlertsStream = (params?: { limit?: number; window?: string }) =>
request.get('/DC-Control/v1/network-screen/alerts-stream', { params })
export interface NetworkDistributionItem {
name: string
count: number
}
export const fetchNetworkScreenDevices = (params?: {
export interface NetworkInterfaceSummary {
latest_timestamp: string
total: number
up: number
down: number
}
export interface NetworkArpSummary {
latest_timestamp: string
total: number
unique_ip_count: number
}
export interface NetworkRouteSummary {
latest_timestamp: string
total: number
protocol_share: NetworkDistributionItem[]
}
export interface NetworkFibSummary {
latest_timestamp: string
total: number
vendor_share: NetworkDistributionItem[]
}
export interface NetworkResourceOverview {
interface_summary: NetworkInterfaceSummary
arp_summary: NetworkArpSummary
route_summary: NetworkRouteSummary
fib_summary: NetworkFibSummary
meta: { count: number }
}
export interface NetworkContinuousErrorItem {
id: number
name: string
host: string
status: string
continuous_errors: number
last_check_time: string
status_message: string
}
export interface NetworkOfflineItem {
id: number
name: string
host: string
last_offline_time: string | null
}
export interface NetworkStatusHotspotItem {
status_message: string
count: number
}
export interface NetworkAlertsStream {
top_continuous_errors: NetworkContinuousErrorItem[]
recent_offline: NetworkOfflineItem[]
status_message_hotspots: NetworkStatusHotspotItem[]
}
export interface NetworkScreenDevice extends NetworkDeviceService {
protocol: string
}
export interface NetworkDevicePage {
total: number
page: number
size: number
data: NetworkScreenDevice[]
}
export interface NetworkDeviceQuery {
page?: number
size?: number
keyword?: string
@@ -23,4 +118,27 @@ export const fetchNetworkScreenDevices = (params?: {
enabled?: boolean
protocol?: string
vendor?: string
}) => request.get('/DC-Control/v1/network-screen/devices', { params })
}
export const fetchNetworkScreenOverview = () =>
request.get<NetworkScreenResponse<NetworkScreenOverview>>('/DC-Control/v1/network-screen/overview')
export const fetchNetworkScreenHealthMatrix = (params?: { window?: string }) =>
request.get<NetworkScreenResponse<{ window: string; data: NetworkHealthMatrixItem[] }>>('/DC-Control/v1/network-screen/health-matrix', {
params,
})
export const fetchNetworkScreenFreshnessDistribution = () =>
request.get<NetworkScreenResponse<{ data: NetworkFreshnessItem[] }>>('/DC-Control/v1/network-screen/freshness-distribution')
export const fetchNetworkScreenProtocolDistribution = () =>
request.get<NetworkScreenResponse<{ data: NetworkProtocolItem[] }>>('/DC-Control/v1/network-screen/protocol-distribution')
export const fetchNetworkScreenResourceOverview = (params?: { window?: string }) =>
request.get<NetworkScreenResponse<NetworkResourceOverview>>('/DC-Control/v1/network-screen/resource-overview', { params })
export const fetchNetworkScreenAlertsStream = (params?: { limit?: number; window?: string }) =>
request.get<NetworkScreenResponse<NetworkAlertsStream>>('/DC-Control/v1/network-screen/alerts-stream', { params })
export const fetchNetworkScreenDevices = (params?: NetworkDeviceQuery) =>
request.get<NetworkScreenResponse<NetworkDevicePage>>('/DC-Control/v1/network-screen/devices', { params })

View File

@@ -1,17 +1,64 @@
<template>
<div class="page-container">
<a-card title="设备采集绑定" :bordered="false">
<template #extra><a-space><a-input v-model="keyword" allow-clear placeholder="绑定名称或目标" @press-enter="loadData" /><a-button @click="loadData">查询</a-button><a-button type="primary" @click="openCreate">新增绑定</a-button></a-space></template>
<a-table row-key="id" :columns="columns" :data="rows" :loading="loading" :pagination="pagination" @page-change="onPageChange">
<template #extra>
<a-space>
<a-input v-model="keyword" allow-clear placeholder="绑定名称或目标" @press-enter="loadData" />
<a-button @click="loadData">查询</a-button>
<a-button :disabled="!selectedKeys.length" @click="openBatch">批量修改</a-button>
<a-button type="primary" @click="openCreate">新增绑定</a-button>
</a-space>
</template>
<a-table
v-model:selected-keys="selectedKeys"
row-key="id"
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
:columns="columns"
:data="rows"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #node="{ record }">{{ record.collector_node?.name || `#${record.collector_node_id}` }}</template>
<template #credential="{ record }">{{ record.credential?.name || `#${record.credential_id}` }}</template>
<template #template="{ record }">{{ record.template?.name || `#${record.template_id}` }} / v{{ record.template_version }}</template>
<template #target="{ record }"><span class="mono">{{ displayTarget(record) }}</span></template>
<template #state="{ record }"><a-tag :color="record.enabled && record.state === 'active' ? 'green' : 'gray'">{{ record.enabled ? record.state : '停用' }}</a-tag></template>
<template #actions="{ record }"><a-space><a-button type="text" size="small" @click="openEdit(record)">编辑</a-button><a-popconfirm content="确认删除该绑定?" @ok="remove(record)"><a-button type="text" size="small" status="danger">删除</a-button></a-popconfirm></a-space></template>
<template #target="{ record }">
<span class="mono">{{ displayTarget(record) }}</span>
</template>
<template #inventoryInterval="{ record }">{{ record.protocol === 'snmp' ? record.inventory_interval_seconds : '-' }}</template>
<template #state="{ record }">
<a-tag :color="record.enabled && record.state === 'active' ? 'green' : 'gray'">
{{ record.enabled ? record.state : '停用' }}
</a-tag>
</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-popconfirm content="确认删除该绑定?" @ok="remove(record)">
<a-button type="text" size="small" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
<binding-form-modal v-model:visible="visible" :record="current" @success="loadData" />
<a-modal v-model:visible="batchVisible" title="批量修改采集绑定" :ok-loading="batchSaving" @ok="saveBatch">
<a-alert type="info" class="mb-16">只修改填写的项目;资产清单间隔仅作用于 SNMP 绑定。</a-alert>
<a-form :model="batchForm" layout="vertical">
<a-form-item label="实时指标间隔(秒)">
<a-input-number v-model="batchForm.interval_seconds" allow-clear :min="10" :max="86400" style="width: 100%" />
</a-form-item>
<a-form-item label="资产清单间隔(秒)">
<a-input-number v-model="batchForm.inventory_interval_seconds" allow-clear :min="10" :max="86400" style="width: 100%" />
</a-form-item>
<a-form-item label="启用状态">
<a-select v-model="batchForm.enabled" allow-clear placeholder="保持不变">
<a-option :value="true">启用</a-option>
<a-option :value="false">停用</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -19,7 +66,12 @@
import { onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import { deleteCollectionBinding, fetchCollectionBindings, type CollectionBinding } from '@/api/ops/collection'
import {
batchUpdateCollectionBindings,
deleteCollectionBinding,
fetchCollectionBindings,
type CollectionBinding,
} from '@/api/ops/collection'
import BindingFormModal from '../components/BindingFormModal.vue'
const rows = ref<CollectionBinding[]>([])
@@ -27,22 +79,90 @@ const current = ref<CollectionBinding | null>(null)
const visible = ref(false)
const loading = ref(false)
const keyword = ref('')
const selectedKeys = ref<number[]>([])
const batchVisible = ref(false)
const batchSaving = ref(false)
const batchForm = reactive<{ interval_seconds?: number; inventory_interval_seconds?: number; enabled?: boolean }>({})
const pagination = reactive({ current: 1, pageSize: 20, total: 0 })
const columns: TableColumnData[] = [
{ title: '绑定名称', dataIndex: 'name' }, { title: '设备 UID', dataIndex: 'resource_uid', ellipsis: true, tooltip: true }, { title: '采集节点', slotName: 'node' },
{ title: '凭证', slotName: 'credential' }, { title: '厂商模板', slotName: 'template' }, { title: '目标', slotName: 'target', minWidth: 210 },
{ title: '间隔(秒)', dataIndex: 'interval_seconds', width: 100 }, { title: '状态', slotName: 'state', width: 100 }, { title: '操作', slotName: 'actions', width: 140, fixed: 'right' },
{ title: '绑定名称', dataIndex: 'name' },
{ title: '设备 UID', dataIndex: 'resource_uid', ellipsis: true, tooltip: true },
{ title: '采集节点', slotName: 'node' },
{ title: '凭证', slotName: 'credential' },
{ title: '厂商模板', slotName: 'template' },
{ title: '目标', slotName: 'target', minWidth: 210 },
{ title: '实时指标间隔(秒)', dataIndex: 'interval_seconds', width: 150 },
{ title: '资产清单间隔(秒)', slotName: 'inventoryInterval', width: 150 },
{ title: '状态', slotName: 'state', width: 100 },
{ title: '操作', slotName: 'actions', width: 140, fixed: 'right' },
]
const displayTarget = (record: CollectionBinding) => record.target.includes('://') ? `${record.target}(端口 ${record.port})` : `${record.protocol}://${record.target}:${record.port}`
const loadData = async () => { loading.value = true; try { const res = await fetchCollectionBindings({ page: pagination.current, size: pagination.pageSize, keyword: keyword.value || undefined }); rows.value = res.details?.data || []; pagination.total = res.details?.total || 0 } finally { loading.value = false } }
const onPageChange = (page: number) => { pagination.current = page; loadData() }
const openCreate = () => { current.value = null; visible.value = true }
const openEdit = (row: CollectionBinding) => { current.value = row; visible.value = true }
const remove = async (row: CollectionBinding) => { const res: any = await deleteCollectionBinding(row.id); if (res.code === 0) { Message.success('已删除'); await loadData() } else Message.error(res.message || '删除失败') }
const displayTarget = (record: CollectionBinding) =>
record.target.includes('://') ? `${record.target}(端口 ${record.port})` : `${record.protocol}://${record.target}:${record.port}`
const loadData = async () => {
loading.value = true
try {
const res = await fetchCollectionBindings({ page: pagination.current, size: pagination.pageSize, keyword: keyword.value || undefined })
rows.value = res.details?.data || []
pagination.total = res.details?.total || 0
} finally {
loading.value = false
}
}
const onPageChange = (page: number) => {
pagination.current = page
loadData()
}
const openCreate = () => {
current.value = null
visible.value = true
}
const openEdit = (row: CollectionBinding) => {
current.value = row
visible.value = true
}
const openBatch = () => {
Object.assign(batchForm, { interval_seconds: undefined, inventory_interval_seconds: undefined, enabled: undefined })
batchVisible.value = true
}
const saveBatch = async () => {
if (!selectedKeys.value.length) return
if (batchForm.interval_seconds === undefined && batchForm.inventory_interval_seconds === undefined && batchForm.enabled === undefined) {
Message.warning('请至少填写一项修改内容')
return false
}
batchSaving.value = true
try {
const res: any = await batchUpdateCollectionBindings({ ids: selectedKeys.value, ...batchForm })
if (res.code !== 0) {
Message.error(res.message || '批量修改失败')
return false
}
Message.success(`已修改 ${res.details?.updated || selectedKeys.value.length} 条绑定`)
batchVisible.value = false
selectedKeys.value = []
await loadData()
} finally {
batchSaving.value = false
}
}
const remove = async (row: CollectionBinding) => {
const res: any = await deleteCollectionBinding(row.id)
if (res.code === 0) {
Message.success('已删除')
await loadData()
} else Message.error(res.message || '删除失败')
}
onMounted(loadData)
</script>
<style scoped lang="less">
.page-container { padding: 20px; }
.mono { font-family: monospace; }
.page-container {
padding: 20px;
}
.mono {
font-family: monospace;
}
.mb-16 {
margin-bottom: 16px;
}
</style>

View File

@@ -1,18 +1,78 @@
<template>
<a-modal :visible="visible" :title="editing ? '编辑采集绑定' : '新增采集绑定'" width="700px" :ok-loading="saving" @ok="save" @cancel="close">
<a-modal
:visible="visible"
:title="editing ? '编辑采集绑定' : '新增采集绑定'"
width="700px"
:ok-loading="saving"
@ok="save"
@cancel="close"
>
<a-form :model="form" layout="vertical">
<a-alert type="info" class="mb-16">每条绑定唯一确定设备、节点、凭证、模板、协议、目标和采集间隔。绑定区域节点后,立即采集也只由该节点执行。</a-alert>
<a-alert type="info" class="mb-16">
每条绑定唯一确定设备、节点、凭证、模板、协议、目标和采集间隔。绑定区域节点后,立即采集也只由该节点执行。
</a-alert>
<a-row :gutter="16">
<a-col :span="12"><a-form-item label="绑定名称" required><a-input v-model="form.name" /></a-form-item></a-col>
<a-col :span="12"><a-form-item label="设备" required><a-select v-model="form.resource_uid" allow-search :disabled="Boolean(fixedResourceUid)" placeholder="选择统一设备" @change="onResourceChange"><a-option v-for="item in resources" :key="item.value" :value="item.value">{{ item.label }}</a-option></a-select></a-form-item></a-col>
<a-col :span="12"><a-form-item label="采集节点" required><a-select v-model="form.collector_node_id"><a-option v-for="item in nodes" :key="item.id" :value="item.id">{{ item.name }}({{ item.node_type === 'center' ? '中心' : item.region_code }})</a-option></a-select></a-form-item></a-col>
<a-col :span="12"><a-form-item label="厂商采集模板" required><a-select v-model="form.template_id" allow-search @change="onTemplateChange"><a-option v-for="item in templates" :key="item.id" :value="item.id">{{ item.name }} / v{{ item.version }}</a-option></a-select></a-form-item></a-col>
<a-col :span="12"><a-form-item label="凭证" required><a-select v-model="form.credential_id" allow-search><a-option v-for="item in compatibleCredentials" :key="item.id" :value="item.id">{{ item.name }}({{ item.type }})</a-option></a-select></a-form-item></a-col>
<a-col :span="12"><a-form-item label="协议"><a-input v-model="form.protocol" readonly /></a-form-item></a-col>
<a-col :span="16"><a-form-item label="目标 IP 或域名" required><a-input v-model="form.target" /></a-form-item></a-col>
<a-col :span="8"><a-form-item label="端口" required><a-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%" /></a-form-item></a-col>
<a-col :span="12"><a-form-item label="采集间隔(秒)" required><a-input-number v-model="form.interval_seconds" :min="10" :max="86400" style="width: 100%" /></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 label="绑定名称" required><a-input v-model="form.name" /></a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备" required>
<a-select
v-model="form.resource_uid"
allow-search
:disabled="Boolean(fixedResourceUid)"
placeholder="选择统一设备"
@change="onResourceChange"
>
<a-option v-for="item in resources" :key="item.value" :value="item.value">{{ item.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="采集节点" required>
<a-select v-model="form.collector_node_id">
<a-option v-for="item in nodes" :key="item.id" :value="item.id">
{{ item.name }}({{ item.node_type === 'center' ? '中心' : item.region_code }})
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="厂商采集模板" required>
<a-select v-model="form.template_id" allow-search @change="onTemplateChange">
<a-option v-for="item in templates" :key="item.id" :value="item.id">{{ item.name }} / v{{ item.version }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="凭证" required>
<a-select v-model="form.credential_id" allow-search>
<a-option v-for="item in compatibleCredentials" :key="item.id" :value="item.id">{{ item.name }}({{ item.type }})</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="协议"><a-input v-model="form.protocol" readonly /></a-form-item>
</a-col>
<a-col :span="16">
<a-form-item label="目标 IP 或域名" required><a-input v-model="form.target" /></a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="端口" required><a-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%" /></a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="实时指标间隔(秒)" required>
<a-input-number v-model="form.interval_seconds" :min="10" :max="86400" style="width: 100%" />
</a-form-item>
</a-col>
<a-col v-if="form.protocol === 'snmp'" :span="12">
<a-form-item label="资产清单间隔(秒)" required>
<a-input-number v-model="form.inventory_interval_seconds" :min="10" :max="86400" style="width: 100%" />
</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-row>
</a-form>
</a-modal>
@@ -22,7 +82,18 @@
import { computed, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchControlResourceOptions, type OptionItem } from '@/api/ops/dcControl'
import { createCollectionBinding, fetchCollectionTemplates, fetchCollectorNodes, fetchCredentials, updateCollectionBinding, type CollectionBinding, type CollectionBindingInput, type CollectionCredential, type CollectionTemplate, type CollectorNode } from '@/api/ops/collection'
import {
createCollectionBinding,
fetchCollectionTemplates,
fetchCollectorNodes,
fetchCredentials,
updateCollectionBinding,
type CollectionBinding,
type CollectionBindingInput,
type CollectionCredential,
type CollectionTemplate,
type CollectorNode,
} from '@/api/ops/collection'
import { collectionTemplateMatchesModel } from '@/utils/collectionTemplate'
const props = defineProps<{ visible: boolean; record?: CollectionBinding | null; fixedResourceUid?: string; defaultTarget?: string }>()
@@ -33,50 +104,126 @@ const nodes = ref<CollectorNode[]>([])
const credentials = ref<CollectionCredential[]>([])
const allTemplates = ref<CollectionTemplate[]>([])
const selectedResource = computed(() => resources.value.find((item) => item.value === form.resource_uid))
const templates = computed(() => allTemplates.value.filter((item) => {
const resource = selectedResource.value
if (!resource) return false
return item.resource_category === resource.resource_category
&& (item.manufacturer_code === 'GENERIC' || item.manufacturer_code === resource.manufacturer_code)
&& collectionTemplateMatchesModel(item, resource.model)
}))
const defaultForm = (): CollectionBindingInput => ({ name: '', resource_uid: '', collector_node_id: 0, credential_id: 0, template_id: 0, protocol: '', target: '', port: 161, interval_seconds: 60, retry_policy: '{"max_attempts":2}', enabled: true })
const templates = computed(() =>
allTemplates.value.filter((item) => {
const resource = selectedResource.value
if (!resource) return false
return (
item.resource_category === resource.resource_category &&
(item.manufacturer_code === 'GENERIC' || item.manufacturer_code === resource.manufacturer_code) &&
collectionTemplateMatchesModel(item, resource.model)
)
})
)
const defaultForm = (): CollectionBindingInput => ({
name: '',
resource_uid: '',
collector_node_id: 0,
credential_id: 0,
template_id: 0,
protocol: '',
target: '',
port: 161,
interval_seconds: 60,
inventory_interval_seconds: 600,
retry_policy: '{"max_attempts":2}',
enabled: true,
})
const form = reactive<CollectionBindingInput>(defaultForm())
const editing = computed(() => Boolean(props.record?.id))
const compatibleCredentials = computed(() => credentials.value.filter((item) => {
if (form.protocol === 'snmp') return ['snmp_v1', 'snmp_v2c', 'snmp_v3'].includes(item.type)
if (form.protocol === 'agent') return item.type === 'http_api'
if (form.protocol === 'middleware') return item.type === 'http_api'
return item.type === form.protocol
}))
const defaultPort = (protocol: string) => ({ snmp: 161, ssh: 22, http_api: 443, redfish: 443, ipmi: 623, database: 5432, middleware: 443, agent: 9030 }[protocol] || 443)
const compatibleCredentials = computed(() =>
credentials.value.filter((item) => {
if (form.protocol === 'snmp') return ['snmp_v1', 'snmp_v2c', 'snmp_v3'].includes(item.type)
if (form.protocol === 'agent') return item.type === 'http_api'
if (form.protocol === 'middleware') return item.type === 'http_api'
return item.type === form.protocol
})
)
const defaultPort = (protocol: string) =>
({ snmp: 161, ssh: 22, http_api: 443, redfish: 443, ipmi: 623, database: 5432, middleware: 443, agent: 9030 })[protocol] || 443
const loadOptions = async () => {
const [resourceRes, nodeRes, credentialRes, templateRes] = await Promise.all([
fetchControlResourceOptions(), fetchCollectorNodes({ page: 1, size: 500, enabled: true }), fetchCredentials({ page: 1, size: 500, enabled: true }), fetchCollectionTemplates({ page: 1, size: 500, enabled: true }),
fetchControlResourceOptions(),
fetchCollectorNodes({ page: 1, size: 500, enabled: true }),
fetchCredentials({ page: 1, size: 500, enabled: true }),
fetchCollectionTemplates({ page: 1, size: 500, enabled: true }),
])
resources.value = resourceRes.details?.list || []
if (props.fixedResourceUid && !resources.value.some((item) => item.value === props.fixedResourceUid)) resources.value.unshift({ label: props.fixedResourceUid, value: props.fixedResourceUid })
nodes.value = nodeRes.details?.data || []; credentials.value = credentialRes.details?.data || []; allTemplates.value = templateRes.details?.data || []
if (props.fixedResourceUid && !resources.value.some((item) => item.value === props.fixedResourceUid))
resources.value.unshift({ label: props.fixedResourceUid, value: props.fixedResourceUid })
nodes.value = nodeRes.details?.data || []
credentials.value = credentialRes.details?.data || []
allTemplates.value = templateRes.details?.data || []
}
const reset = () => {
Object.assign(form, defaultForm(), { resource_uid: props.fixedResourceUid || '', target: props.defaultTarget || '' })
if (props.record) Object.assign(form, { name: props.record.name, resource_uid: props.record.resource_uid, collector_node_id: props.record.collector_node_id, credential_id: props.record.credential_id, template_id: props.record.template_id, protocol: props.record.protocol, target: props.record.target, port: props.record.port, interval_seconds: props.record.interval_seconds, retry_policy: props.record.retry_policy, enabled: props.record.enabled })
if (props.record)
Object.assign(form, {
name: props.record.name,
resource_uid: props.record.resource_uid,
collector_node_id: props.record.collector_node_id,
credential_id: props.record.credential_id,
template_id: props.record.template_id,
protocol: props.record.protocol,
target: props.record.target,
port: props.record.port,
interval_seconds: props.record.interval_seconds,
inventory_interval_seconds: props.record.inventory_interval_seconds,
retry_policy: props.record.retry_policy,
enabled: props.record.enabled,
})
}
const onResourceChange = () => {
form.template_id = 0
form.protocol = ''
form.credential_id = 0
}
const onTemplateChange = () => {
const selected = templates.value.find((item) => item.id === form.template_id)
if (!selected) return
form.protocol = selected.protocol
form.port = defaultPort(selected.protocol)
if (!compatibleCredentials.value.some((item) => item.id === form.credential_id)) form.credential_id = 0
}
const onResourceChange = () => { form.template_id = 0; form.protocol = ''; form.credential_id = 0 }
const onTemplateChange = () => { const selected = templates.value.find((item) => item.id === form.template_id); if (!selected) return; form.protocol = selected.protocol; form.port = defaultPort(selected.protocol); if (!compatibleCredentials.value.some((item) => item.id === form.credential_id)) form.credential_id = 0 }
const close = () => emit('update:visible', false)
const save = async () => {
if (!form.name.trim() || !form.resource_uid || !form.collector_node_id || !form.credential_id || !form.template_id || !form.target.trim()) { Message.warning('请完整填写采集绑定'); return }
if (
!form.name.trim() ||
!form.resource_uid ||
!form.collector_node_id ||
!form.credential_id ||
!form.template_id ||
!form.target.trim()
) {
Message.warning('请完整填写采集绑定')
return
}
saving.value = true
try {
const res: any = props.record?.id ? await updateCollectionBinding(props.record.id, form) : await createCollectionBinding(form)
if (res.code !== 0) { Message.error(res.message || '保存失败'); return }
Message.success('采集绑定已保存'); close(); emit('success')
} finally { saving.value = false }
if (res.code !== 0) {
Message.error(res.message || '保存失败')
return
}
Message.success('采集绑定已保存')
close()
emit('success')
} finally {
saving.value = false
}
}
watch(() => props.visible, async (value) => { if (!value) return; await loadOptions(); reset() })
watch(
() => props.visible,
async (value) => {
if (!value) return
await loadOptions()
reset()
}
)
</script>
<style scoped lang="less">
.mb-16 { margin-bottom: 16px; }
.mb-16 {
margin-bottom: 16px;
}
</style>

View File

@@ -19,6 +19,9 @@
<template #heartbeat="{ record }">
{{ record.last_heartbeat_at ? new Date(record.last_heartbeat_at).toLocaleString() : '尚未连接' }}
</template>
<template #concurrency="{ record }">{{ record.running_tasks }}/{{ record.max_concurrency }}</template>
<template #oldestPending="{ record }">{{ formatWait(record.oldest_pending_at) }}</template>
<template #spool="{ record }">{{ record.queue_depth }} 条 / {{ formatBytes(record.spool_bytes) }}</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
@@ -85,6 +88,16 @@
<a-input v-model="form.region_code" placeholder="如 NORTH-HOSPITAL" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="最大并发任务数" required>
<a-input-number v-model="form.max_concurrency" :min="1" :max="1000" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="单任务执行超时(秒)" required>
<a-input-number v-model="form.execution_timeout_seconds" :min="1" :max="3600" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="24">
<section class="capability-section">
<div class="capability-section-header">
@@ -226,6 +239,8 @@ const defaultForm = (): CollectorNodeInput => ({
region_code: '',
description: '',
capabilities: JSON.stringify(defaultCapabilities),
max_concurrency: 32,
execution_timeout_seconds: 45,
enabled: true,
})
const form = reactive<CollectorNodeInput>(defaultForm())
@@ -236,7 +251,10 @@ const columns: TableColumnData[] = [
{ title: '区域', dataIndex: 'region_code' },
{ title: '状态', slotName: 'status', width: 100 },
{ title: '版本', dataIndex: 'version', width: 100 },
{ title: '队列深度', dataIndex: 'queue_depth', width: 100 },
{ title: '运行/并发', slotName: 'concurrency', width: 100 },
{ title: '待领取', dataIndex: 'pending_tasks', width: 90 },
{ title: '最老等待', slotName: 'oldestPending', width: 110 },
{ title: '本地结果缓存', slotName: 'spool', width: 150 },
{ title: '最近心跳', slotName: 'heartbeat', width: 180 },
{ title: '最近错误', dataIndex: 'last_error', ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'actions', width: 250, fixed: 'right' },
@@ -256,6 +274,17 @@ const statusLabel = (status: string) =>
({ pending: '待安装', enrolled: '等待心跳', active: '在线', degraded: '异常', offline: '离线' })[status] || status
const statusColor = (status: string) =>
({ pending: 'orange', enrolled: 'orange', active: 'green', degraded: 'red', offline: 'gray' })[status] || 'blue'
const formatBytes = (value: number) => {
if (!value) return '0 B'
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
const formatWait = (value?: string) => {
if (!value) return '-'
const seconds = Math.max(0, Math.floor((Date.now() - new Date(value).getTime()) / 1000))
return seconds < 60 ? `${seconds} 秒` : `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`
}
const loadData = async () => {
loading.value = true
try {
@@ -309,6 +338,8 @@ const openEdit = (row: CollectorNode) => {
region_code: row.region_code || '',
description: row.description || '',
capabilities: JSON.stringify(capabilities),
max_concurrency: row.max_concurrency,
execution_timeout_seconds: row.execution_timeout_seconds,
enabled: row.enabled,
})
selectedCapabilities.value = capabilities
@@ -335,6 +366,10 @@ const save = async () => {
Message.warning('请至少选择一项节点能力')
return
}
if (form.max_concurrency < 1 || form.execution_timeout_seconds < 1) {
Message.warning('最大并发任务数和单任务执行超时必须大于 0')
return
}
form.capabilities = JSON.stringify(selectedCapabilities.value)
saving.value = true
try {

View File

@@ -9,7 +9,8 @@
<a-col :span="8"><a-form-item label="采集凭证" required><a-select v-model="model.credential_id" placeholder="选择统一凭证"><a-option v-for="item in compatibleCredentials" :key="item.id" :value="item.id">{{ item.name }}</a-option></a-select></a-form-item></a-col>
<a-col :span="14"><a-form-item label="采集目标" required><a-input v-model="model.target" placeholder="设备管理 IP 或域名" /></a-form-item></a-col>
<a-col :span="5"><a-form-item label="端口" required><a-input-number v-model="model.port" :min="1" :max="65535" style="width: 100%" /></a-form-item></a-col>
<a-col :span="5"><a-form-item label="间隔(秒)" required><a-input-number v-model="model.interval_seconds" :min="10" :max="86400" style="width: 100%" /></a-form-item></a-col>
<a-col :span="5"><a-form-item label="实时指标间隔(秒)" required><a-input-number v-model="model.interval_seconds" :min="10" :max="86400" style="width: 100%" /></a-form-item></a-col>
<a-col v-if="model.protocol === 'snmp'" :span="8"><a-form-item label="资产清单间隔(秒)" required><a-input-number v-model="model.inventory_interval_seconds" :min="10" :max="86400" style="width: 100%" /></a-form-item></a-col>
</a-row>
<a-empty v-if="model.enabled && !templates.length" description="当前厂商、设备类型和型号没有适用的已启用采集模板,请先到供应商管理维护模板" />
</template>

View File

@@ -37,6 +37,7 @@ export interface PrimaryCollectionDraft {
target: string
port: number
interval_seconds: number
inventory_interval_seconds: number
}
export interface DeviceMasterContext {
@@ -78,7 +79,7 @@ export function createDeviceMasterState(): DeviceMasterState {
}
export function createPrimaryCollectionDraft(): PrimaryCollectionDraft {
return { enabled: false, protocol: '', target: '', port: 161, interval_seconds: 60 }
return { enabled: false, protocol: '', target: '', port: 161, interval_seconds: 60, inventory_interval_seconds: 600 }
}
export async function persistPrimaryCollectionBinding(
@@ -100,6 +101,7 @@ export async function persistPrimaryCollectionBinding(
target: draft.target.trim(),
port: draft.port,
interval_seconds: draft.interval_seconds,
inventory_interval_seconds: draft.inventory_interval_seconds,
retry_policy: '{"max_attempts":2}',
enabled: true,
}

View File

@@ -84,7 +84,7 @@
<a-row :gutter="16">
<a-col v-for="(item, index) in metricsData.metrics" :key="`${item.metric_name}-${item.series_key || index}`" :span="8">
<a-card class="metric-card" :bordered="false">
<div class="metric-name">{{ item.metric_name }}</div>
<div class="metric-name">{{ metricDisplayName(item.metric_name) }}</div>
<div v-if="metricSeriesLabel(item)" class="metric-series">{{ metricSeriesLabel(item) }}</div>
<div class="metric-value">{{ item.metric_value }} {{ item.metric_unit || '' }}</div>
</a-card>
@@ -117,6 +117,7 @@ import { Message } from '@arco-design/web-vue'
import { IconEdit, IconDelete, IconDashboard } from '@arco-design/web-vue/es/icon'
import Chart from '@/components/chart/index.vue'
import ImportantInterfacesPanel from '../../components/ImportantInterfacesPanel.vue'
import { fetchControlMetricDefinitionOptions } from '@/api/ops/dcControl'
import {
SECURITY_TYPE_MAP,
STATUS_MAP,
@@ -139,6 +140,7 @@ defineEmits(['edit', 'delete'])
const metricsVisible = ref(false)
const metricsLoading = ref(false)
const metricsData = ref<SecurityMetricsLatestData | null>(null)
const metricDefinitionNames = ref<Record<string, string>>({})
const selectedMetric = ref('')
const trendLoading = ref(false)
const trendRows = ref<SecurityMetric[]>([])
@@ -152,7 +154,7 @@ const accessPointColumns = [
const metricOptions = computed(() => {
const names = new Set((metricsData.value?.metrics || []).map((item) => item.metric_name))
return [...names].sort().map((value) => ({ label: value, value }))
return [...names].sort().map((value) => ({ label: metricDisplayName(value), value }))
})
const trendChartOptions = computed(() => {
@@ -191,6 +193,7 @@ watch(
async (id) => {
accessPoints.value = []
metricsData.value = null
metricDefinitionNames.value = {}
selectedMetric.value = ''
trendRows.value = []
if (!id) return
@@ -219,8 +222,17 @@ const handleViewMetrics = async () => {
metricsVisible.value = true
metricsLoading.value = true
try {
const response: any = await fetchSecurityMetricsLatest(props.record.resource_uid)
metricsData.value = response?.details || null
const [definitionsResponse, metricsResponse] = await Promise.all([
fetchControlMetricDefinitionOptions({ resource_category: 'security', data_source: 'dc-security' }),
fetchSecurityMetricsLatest(props.record.resource_uid),
])
if (definitionsResponse.code !== undefined && definitionsResponse.code !== 0) {
throw new Error(definitionsResponse.message || '获取安全设备指标定义失败')
}
metricDefinitionNames.value = Object.fromEntries(
(definitionsResponse.details?.list || []).map((item) => [item.metric_code, item.metric_name])
)
metricsData.value = metricsResponse.details || null
selectedMetric.value = metricOptions.value[0]?.value || ''
if (selectedMetric.value) await loadTrend()
} catch (error) {
@@ -273,9 +285,11 @@ const metricSeriesLabel = (metric: SecurityMetric) => {
const trendSeriesName = (seriesKey: string, metric?: SecurityMetric) => {
const label = metric ? metricSeriesLabel(metric) : ''
if (label) return label
return seriesKey === 'default' ? selectedMetric.value : seriesKey.slice(0, 8)
return seriesKey === 'default' ? metricDisplayName(selectedMetric.value) : seriesKey.slice(0, 8)
}
const metricDisplayName = (metricName: string) => metricDefinitionNames.value[metricName]
const formatTime = (time?: string | null) => {
if (!time) return '-'
if (time.startsWith('0001-01-01')) return '-'

File diff suppressed because it is too large Load Diff

View File

@@ -93,20 +93,6 @@
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="4">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-success">
<icon-folder />
</div>
<div class="stats-info">
<div class="stats-title">厂商适配</div>
<div class="stats-value">{{ monitorSummary?.current?.adapter_status === 'verified' ? '已验证' : '待联调' }}</div>
<div class="stats-desc">{{ monitorSummary?.current?.product_version || '版本待采集' }}</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="4">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
@@ -152,40 +138,92 @@
</a-card>
</a-col>
<a-col :xs="24" :lg="14" class="chart-pair-col">
<a-card :bordered="false" class="chart-pair-card trend-side-card">
<template #title>
<div class="card-header">
<div class="card-title">存储指标趋势</div>
<div class="card-subtitle">近 24 小时 · ECharts 折线图(原始样本按小时取 max 聚合展示)</div>
</div>
</template>
<template #extra>
<a-space size="small" wrap align="center">
<span class="text-muted">指标</span>
<a-select
v-model="trendSelectedMetric"
class="trend-metric-select"
placeholder="选择指标"
allow-search
:loading="metricsOptionsLoading"
:options="trendMetricOptions"
:disabled="!trendMetricOptions.length"
@change="refreshTimeseries"
/>
<a-button type="outline" size="small" @click="refreshTimeseries">刷新曲线</a-button>
</a-space>
</template>
<a-spin :loading="chartLoading" class="chart-spin chart-spin--fill">
<div class="chart-content-wrapper">
<p v-if="chartHint" class="chart-hint text-muted">{{ chartHint }}</p>
<div class="chart-container chart-container--line">
<!-- Chart 为 ECharts 封装;单序列 type: 'line' 折线 -->
<Chart v-if="hasChartSeries" :options="ioChartOptions" />
<a-empty v-else description="暂无趋势数据或指标未上报" />
<div class="storage-metrics-stack">
<a-card :bordered="false" class="capacity-overview-card">
<template #title>
<div class="card-header">
<div class="card-title">容量概览</div>
<div class="card-subtitle">总容量、已用容量、可用容量</div>
</div>
</template>
<div v-if="capacitySummary" class="capacity-overview-content">
<div class="capacity-gauge">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle class="capacity-gauge-track" cx="60" cy="60" r="48" />
<circle
class="capacity-gauge-value"
cx="60"
cy="60"
r="48"
pathLength="100"
:stroke-dasharray="`${capacitySummary.usagePercent} ${100 - capacitySummary.usagePercent}`"
/>
</svg>
<div class="capacity-gauge-text">
<strong>{{ capacitySummary.usagePercent.toFixed(1) }}%</strong>
<span>已使用</span>
</div>
</div>
<div class="capacity-values">
<div class="capacity-value-item">
<span class="capacity-value-label capacity-value-label--total">总容量</span>
<strong>{{ formatBytes(capacitySummary.totalBytes) }}</strong>
</div>
<div class="capacity-value-item">
<span class="capacity-value-label capacity-value-label--used">已使用</span>
<strong>{{ formatBytes(capacitySummary.usedBytes) }}</strong>
</div>
<div class="capacity-value-item">
<span class="capacity-value-label capacity-value-label--free">可用容量</span>
<strong>{{ formatBytes(capacitySummary.freeBytes) }}</strong>
</div>
</div>
</div>
</a-spin>
</a-card>
<a-empty v-else description="暂无完整容量指标数据" />
</a-card>
<a-card v-if="keyMetrics.length" :bordered="false" class="key-metrics-card">
<div class="key-metrics-list">
<div v-for="metric in keyMetrics" :key="metric.name" class="key-metric-item">
<span class="key-metric-name">{{ metric.label }}</span>
<strong>{{ formatMetricValue(metric.value, metric.unit) }}</strong>
</div>
</div>
</a-card>
<a-card :bordered="false" class="storage-trend-card">
<template #title>
<div class="card-header">
<div class="card-title">存储指标趋势</div>
<div class="card-subtitle">近 24 小时 · 原始样本按小时取最大值</div>
</div>
</template>
<template #extra>
<a-space size="small" wrap align="center">
<span class="text-muted">指标</span>
<a-select
v-model="trendSelectedMetric"
class="trend-metric-select"
placeholder="选择指标"
:loading="metricsOptionsLoading"
:options="trendMetricOptions"
:disabled="!trendMetricOptions.length"
@change="refreshTimeseries"
/>
<a-button type="outline" size="small" @click="refreshTimeseries">刷新曲线</a-button>
</a-space>
</template>
<a-spin :loading="chartLoading" class="chart-spin chart-spin--fill">
<div class="chart-content-wrapper">
<p v-if="chartHint" class="chart-hint text-muted">{{ chartHint }}</p>
<div class="chart-container chart-container--line">
<Chart v-if="hasChartSeries" :options="ioChartOptions" />
<a-empty v-else description="暂无趋势数据或指标未上报" />
</div>
</div>
</a-spin>
</a-card>
</div>
</a-col>
</a-row>
</div>
@@ -197,23 +235,55 @@ import dayjs from 'dayjs'
import { Message } from '@arco-design/web-vue'
import { getSystemParameter, SystemParameterCode } from '@/composables/systemParameters'
import { useDebounceFn } from '@vueuse/core'
import { IconDashboard, IconStorage, IconCodeSquare, IconDriveFile, IconFolder, IconCheckCircleFill } from '@arco-design/web-vue/es/icon'
import { IconDashboard, IconStorage, IconCodeSquare, IconDriveFile, IconCheckCircleFill } from '@arco-design/web-vue/es/icon'
import Chart from '@/components/chart/index.vue'
import {
fetchStorageList,
fetchStorageMonitorList,
fetchStorageMonitorSummary,
fetchStorageMonitorCollectedMetrics,
fetchStorageMetricsLatest,
fetchStorageMetricsTimeseries,
type StorageItem,
type StorageMetricItem,
type StorageMonitorOptionItem,
type StorageMonitorSummaryPayload,
} from '@/api/ops/storage'
/** 趋势图当前选中的指标名(选项来自「按设备 id 最新一批」接口,无 id 时回退 latest) */
const storageMetricMeta = {
'storage.capacity.total_bytes': { label: '总容量', unit: 'B' },
'storage.capacity.used_bytes': { label: '已用容量', unit: 'B' },
'storage.capacity.free_bytes': { label: '可用容量', unit: 'B' },
'storage.cluster.host_count': { label: '存储集群主机数', unit: '台' },
'storage.pool.capacity_count': { label: '容量池数量', unit: '个' },
'storage.disk.ssd_used_count': { label: '已用 SSD 数量', unit: '个' },
'storage.network.manage_abnormal_count': { label: '管理网络异常数', unit: '个' },
'storage.object.read_bandwidth': { label: '对象存储读取带宽', unit: 'B/s' },
'storage.object.read_latency': { label: '对象存储读取延迟', unit: 'ms' },
} as const
type StorageMetricName = keyof typeof storageMetricMeta
const capacityMetricNames: StorageMetricName[] = [
'storage.capacity.total_bytes',
'storage.capacity.used_bytes',
'storage.capacity.free_bytes',
]
const keyMetricNames: StorageMetricName[] = [
'storage.cluster.host_count',
'storage.pool.capacity_count',
'storage.disk.ssd_used_count',
'storage.network.manage_abnormal_count',
]
const trendMetricNames: StorageMetricName[] = [
'storage.capacity.free_bytes',
'storage.capacity.total_bytes',
'storage.capacity.used_bytes',
'storage.object.read_bandwidth',
'storage.object.read_latency',
]
const trendSelectedMetric = ref('')
const trendMetricOptions = ref<{ label: string; value: string }[]>([])
const trendMetricOptions = ref<{ label: string; value: string; unit: string }[]>([])
const metricsOptionsLoading = ref(false)
const dropdownLoading = ref(false)
@@ -225,12 +295,53 @@ const storageOptions = ref<StorageMonitorOptionItem[]>([])
const selectedResourceUID = ref<string | undefined>(undefined)
const monitorSummary = ref<StorageMonitorSummaryPayload | null>(null)
const latestStorageMetrics = ref<StorageMetricItem[]>([])
const chartSeriesPoints = ref<{ time: string; value: number }[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
const activeStorage = computed(() => storageList.value.find((s) => s.resource_uid === selectedResourceUID.value))
const latestMetricByName = computed(() => new Map(latestStorageMetrics.value.map((metric) => [metric.metric_name, metric])))
const capacitySummary = computed(() => {
const totalBytes = latestMetricByName.value.get('storage.capacity.total_bytes')?.metric_value
const usedBytes = latestMetricByName.value.get('storage.capacity.used_bytes')?.metric_value
const freeBytes = latestMetricByName.value.get('storage.capacity.free_bytes')?.metric_value
if (
totalBytes === undefined ||
usedBytes === undefined ||
freeBytes === undefined ||
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
!Number.isFinite(freeBytes) ||
totalBytes <= 0
) {
return null
}
return {
totalBytes,
usedBytes,
freeBytes,
usagePercent: (usedBytes / totalBytes) * 100,
}
})
const keyMetrics = computed(() => {
const metrics: Array<{ name: StorageMetricName; label: string; unit: string; value: number }> = []
for (const name of keyMetricNames) {
const metric = latestMetricByName.value.get(name)
if (!metric || !Number.isFinite(metric.metric_value)) continue
const meta = storageMetricMeta[name]
metrics.push({
name,
label: meta.label,
unit: metric.metric_unit || meta.unit,
value: metric.metric_value,
})
}
return metrics
})
const globalHint = computed(() => {
const m = monitorSummary.value
@@ -252,6 +363,27 @@ function formatDateTime(v: string | undefined) {
return d.isValid() ? d.format('YYYY-MM-DD HH:mm') : v
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value < 0) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let unitIndex = 0
let result = value
while (result >= 1024 && unitIndex < units.length - 1) {
result /= 1024
unitIndex += 1
}
const digits = unitIndex === 0 ? 0 : result >= 10 ? 1 : 2
return `${result.toFixed(digits)} ${units[unitIndex]}`
}
function formatMetricValue(value: number, unit: string): string {
if (unit === 'B') return formatBytes(value)
if (unit === 'B/s') return `${formatBytes(value)}/s`
if (unit === '%') return `${value.toFixed(1)} %`
if (unit === 'ms') return `${value.toFixed(value >= 10 ? 0 : 1)} ms`
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${unit}`
}
const controllerStatusText = computed(() => {
const s = monitorSummary.value?.current?.status ?? activeStorage.value?.status
switch (s) {
@@ -277,7 +409,7 @@ const controllerStatusColor = computed(() => {
const chartHint = computed(() => {
if (!selectedResourceUID.value) return ''
if (!trendMetricOptions.value.length) {
return '该设备暂无已上报的指标批次,无法选择指标;请确认采集已写入。'
return '该设备暂无可展示的标准存储指标。'
}
if (!trendSelectedMetric.value) return '请选择要展示的指标。'
if (!chartSeriesPoints.value.length) {
@@ -295,14 +427,35 @@ const trendSeriesDisplayName = computed(() => {
return opt?.label ?? v
})
const trendSeriesUnit = computed(() => trendMetricOptions.value.find((o) => o.value === trendSelectedMetric.value)?.unit || '')
const trendYAxisName = computed(() => {
switch (trendSeriesUnit.value) {
case 'B':
return '容量'
case 'B/s':
return '带宽'
case '%':
return '使用率'
case 'ms':
return '延迟'
default:
return '指标值'
}
})
/** ECharts 折线图:单序列 type: 'line',由 @/components/chart 渲染 */
const ioChartOptions = computed(() => {
const pts = chartSeriesPoints.value
const labels = pts.map((p) => dayjs(p.time).format('MM-DD HH:mm'))
const values = pts.map((p) => p.value)
const name = trendSeriesDisplayName.value
const unit = trendSeriesUnit.value
return {
tooltip: { trigger: 'axis' },
tooltip: {
trigger: 'axis',
valueFormatter: (value: number | string) => formatMetricValue(Number(value), unit),
},
legend: { show: true, data: [name] },
grid: { left: '3%', right: '4%', bottom: '40px', containLabel: true },
xAxis: {
@@ -314,7 +467,13 @@ const ioChartOptions = computed(() => {
fontSize: 11,
},
},
yAxis: { type: 'value', name: '指标值' },
yAxis: {
type: 'value',
name: trendYAxisName.value,
axisLabel: {
formatter: (value: number) => formatMetricValue(value, unit),
},
},
series: [
{
name,
@@ -433,53 +592,39 @@ async function loadStorageList() {
}
}
async function refreshCollectedMetricNames() {
trendMetricOptions.value = []
async function refreshLatestStorageMetrics() {
latestStorageMetrics.value = []
const resourceUID = selectedResourceUID.value?.trim()
if (!resourceUID) {
trendSelectedMetric.value = ''
return
}
if (!resourceUID) return
metricsOptionsLoading.value = true
try {
type MRow = { metric_name: string; metric_unit?: string }
let list: MRow[] = []
const devId = activeStorage.value?.id
if (devId && devId > 0) {
const res: any = await fetchStorageMonitorCollectedMetrics(devId)
if (res.code === 0 && res.details?.metrics?.length) {
list = res.details.metrics
}
}
if (!list.length) {
const res: any = await fetchStorageMetricsLatest(resourceUID)
if (res.code === 0 && res.details?.metrics?.length) {
list = res.details.metrics
}
}
trendMetricOptions.value = list.map((m) => ({
value: m.metric_name,
label: m.metric_unit ? `${m.metric_name} (${m.metric_unit})` : m.metric_name,
}))
const names = new Set(list.map((m) => m.metric_name))
const cur = trendSelectedMetric.value.trim()
if (cur && names.has(cur)) {
// 保持用户选择
} else if (names.has('read_iops')) {
trendSelectedMetric.value = 'read_iops'
} else if (list.length) {
trendSelectedMetric.value = list[0].metric_name
} else {
trendSelectedMetric.value = ''
const res: any = await fetchStorageMetricsLatest(resourceUID)
if (res.code === 0 && res.details?.metrics?.length) {
latestStorageMetrics.value = res.details.metrics
}
} catch {
trendMetricOptions.value = []
trendSelectedMetric.value = ''
latestStorageMetrics.value = []
} finally {
metricsOptionsLoading.value = false
}
}
function refreshTrendMetricOptions() {
trendMetricOptions.value = trendMetricNames.flatMap((name) => {
const metric = latestMetricByName.value.get(name)
if (!metric) return []
const meta = storageMetricMeta[name]
return [{
value: name,
label: meta.label,
unit: metric.metric_unit || meta.unit,
}]
})
const current = trendSelectedMetric.value.trim()
if (trendMetricOptions.value.some((option) => option.value === current)) return
trendSelectedMetric.value = trendMetricOptions.value[0]?.value || ''
}
async function refreshMonitorSummary() {
summaryLoading.value = true
try {
@@ -533,7 +678,8 @@ async function refreshTimeseries() {
}
async function refreshSelectionDetails() {
await refreshCollectedMetricNames()
await refreshLatestStorageMetrics()
refreshTrendMetricOptions()
await Promise.all([refreshMonitorSummary(), refreshTimeseries()])
}
@@ -636,6 +782,13 @@ export default {
margin-bottom: 16px;
}
@media (min-width: 1200px) {
.stats-row :deep(.arco-col) {
flex: 1 1 0;
max-width: none;
}
}
.stats-card {
height: 100%;
@@ -678,11 +831,6 @@ export default {
color: #722ed1;
}
&-success {
background-color: rgba(0, 180, 42, 0.12);
color: rgb(var(--success-6));
}
&-warning {
background-color: rgba(255, 125, 0, 0.12);
color: rgb(var(--warning-6));
@@ -732,6 +880,160 @@ export default {
min-height: 400px;
}
.storage-metrics-stack {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.capacity-overview-card {
:deep(.arco-card-body) {
padding: 16px 20px;
}
}
.capacity-overview-content {
display: flex;
align-items: center;
gap: 28px;
}
.capacity-gauge {
position: relative;
flex: none;
width: 132px;
height: 132px;
svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
circle {
fill: none;
stroke-width: 10;
}
}
.capacity-gauge-track {
stroke: var(--color-fill-3);
}
.capacity-gauge-value {
stroke: rgb(var(--primary-6));
stroke-linecap: round;
}
.capacity-gauge-text {
position: absolute;
top: 50%;
left: 50%;
display: flex;
flex-direction: column;
align-items: center;
transform: translate(-50%, -50%);
white-space: nowrap;
strong {
font-size: 24px;
line-height: 1;
}
span {
margin-top: 6px;
color: var(--color-text-3);
font-size: 12px;
}
}
.capacity-values {
display: grid;
flex: 1;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.capacity-value-item {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 4px 20px;
border-left: 1px solid var(--color-border-2);
strong {
overflow: hidden;
color: var(--color-text-1);
font-size: 24px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.capacity-value-label {
color: var(--color-text-2);
font-size: 13px;
&::before {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 7px;
border-radius: 50%;
background: var(--color-text-3);
content: '';
}
&--used::before {
background: rgb(var(--primary-6));
}
&--free::before {
background: var(--color-neutral-4);
}
}
.key-metrics-card {
:deep(.arco-card-body) {
padding: 0;
}
}
.key-metrics-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.key-metric-item {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
padding: 14px 16px;
border-right: 1px solid var(--color-border-2);
&:last-child {
border-right: 0;
}
strong {
color: var(--color-text-1);
font-size: 20px;
line-height: 1.2;
}
}
.key-metric-name {
overflow: hidden;
color: var(--color-text-3);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.chart-pair-card {
flex: 1;
width: 100%;
@@ -747,6 +1049,20 @@ export default {
}
}
.storage-trend-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 400px;
:deep(.arco-card-body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
}
.detail-side-card :deep(.arco-card-body) {
overflow-y: auto;
}

View File

@@ -72,11 +72,11 @@
<a-form-item label="目标范围" required>
<a-textarea v-model="form.target_range" placeholder="CIDR、IP 段等,按后端约定填写" :auto-size="{ minRows: 2, maxRows: 6 }" />
</a-form-item>
<a-form-item label="端口范围">
<a-input v-model="form.port_range" placeholder="如 80,443 或 8080-8090,可选" allow-clear />
<a-form-item label="端口范围" :required="form.type === 'port' || form.type === 'full'">
<a-input v-model="form.port_range" placeholder="如 80,443 或 8080-8090" allow-clear />
</a-form-item>
<a-form-item label="执行采集节点" required>
<a-select v-model="form.collector_node_id" placeholder="选择中心或区域采集节点" allow-search>
<a-form-item label="发现后采集节点" required extra="IP 扫描由网络扫描服务执行;该节点用于发现设备后续自动纳管和监控采集。">
<a-select v-model="form.collector_node_id" placeholder="选择发现设备后使用的采集节点" allow-search>
<a-option v-for="node in nodes" :key="node.id" :value="node.id">{{ node.name }}({{ node.node_type === 'center' ? '中心' : node.region_code }})</a-option>
</a-select>
</a-form-item>
@@ -97,7 +97,7 @@
</a-col>
<a-col :span="12">
<a-form-item label="并发数">
<a-input-number v-model="form.concurrency" :min="1" :max="5000" style="width: 100%" />
<a-input-number v-model="form.concurrency" :min="1" :max="100" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
@@ -316,7 +316,11 @@ const submitForm = async () => {
return
}
if (!form.collector_node_id) {
Message.warning('请选择执行采集节点')
Message.warning('请选择发现设备后使用的采集节点')
return
}
if ((form.type === 'port' || form.type === 'full') && !form.port_range?.trim()) {
Message.warning('端口扫描和全量扫描必须填写端口范围')
return
}
if (!allowedProtocols.value.length) { Message.warning('请选择允许发现协议'); return }
@@ -440,7 +444,7 @@ const columns: TableColumnData[] = [
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
{ title: '类型', dataIndex: 'type', width: 88 },
{ title: '目标范围', slotName: 'target', minWidth: 200 },
{ title: '采集节点', slotName: 'node', width: 180, ellipsis: true, tooltip: true },
{ title: '发现后采集节点', slotName: 'node', width: 180, ellipsis: true, tooltip: true },
{ title: '状态', slotName: 'status', width: 100 },
{ title: '最近错误', slotName: 'latestError', minWidth: 260, ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'actions', width: 300, fixed: 'right' },

View File

@@ -33,6 +33,18 @@
@export="exportTopology"
@reset="resetTopology"
/>
<div v-if="discoveryRun" class="discovery-status">
<div class="discovery-status__header">
<span>拓扑发现 {{ discoveryRun.completed_tasks }}/{{ discoveryRun.total_tasks }}</span>
<a-tag :color="discoveryRun.status === 'running' ? 'arcoblue' : discoveryRun.status === 'succeeded' ? 'green' : 'red'">
{{ discoveryRun.status === 'running' ? '执行中' : discoveryRun.status === 'succeeded' ? '已完成' : '失败' }}
</a-tag>
</div>
<a-progress :percent="discoveryProgress" size="small" />
<div v-if="discoveryRun.status === 'failed' && discoveryRun.last_error" class="discovery-status__error">
{{ discoveryRun.last_error }}
</div>
</div>
<!-- Vue Flow 画布 -->
<div ref="reactFlowWrapper" class="flow-wrapper" :style="canvasBackgroundStyle">
@@ -137,19 +149,13 @@
:max-tag-count="5"
placeholder="请选择要加入当前拓扑的设备"
>
<a-option v-for="item in resourceOptions" :key="item.value" :value="item.value">
{{ item.label }}({{ item.value }})
</a-option>
<a-option v-for="item in resourceOptions" :key="item.value" :value="item.value">{{ item.label }}({{ item.value }})</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<delete-confirm-dialog
v-model:visible="deleteDialogOpen"
:message="deleteNodeMessage"
@confirm="handleDeleteNodeConfirm"
/>
<delete-confirm-dialog v-model:visible="deleteDialogOpen" :message="deleteNodeMessage" @confirm="handleDeleteNodeConfirm" />
<!-- ==================== 边对话框 ==================== -->
@@ -175,7 +181,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
@@ -305,9 +311,7 @@ const isAutoTopo = computed(() => {
return route.path.includes('/netarch/auto-topo')
})
const canManageResourceReferences = computed(
() => topology.value?.auto_discovery === false && !topology.value?.business_system_id,
)
const canManageResourceReferences = computed(() => topology.value?.auto_discovery === false && !topology.value?.business_system_id)
const deleteNodeMessage = computed(() => {
if (selectedNode.value?.data?.ref_type === 'resource') {
@@ -338,17 +342,22 @@ const displayEdges = computed(() => filteredResult.value.edges)
// 计算边样式
const styledEdges = useEdgeStyles(displayEdges, presentation)
const gradientDirection = computed(() => ({
top_to_bottom: 'to bottom',
left_to_right: 'to right',
diagonal_down_right: 'to bottom right',
diagonal_down_left: 'to bottom left',
}[presentation.value.gradient_direction]))
const gradientDirection = computed(
() =>
({
top_to_bottom: 'to bottom',
left_to_right: 'to right',
diagonal_down_right: 'to bottom right',
diagonal_down_left: 'to bottom left',
})[presentation.value.gradient_direction]
)
const canvasBackgroundStyle = computed(() => {
if (presentation.value.background_type === 'solid') return { background: presentation.value.background_color }
if (presentation.value.background_type === 'gradient') {
return { background: `linear-gradient(${gradientDirection.value}, ${presentation.value.gradient_start_color}, ${presentation.value.gradient_end_color})` }
return {
background: `linear-gradient(${gradientDirection.value}, ${presentation.value.gradient_start_color}, ${presentation.value.gradient_end_color})`,
}
}
return { background: 'transparent' }
})
@@ -360,6 +369,35 @@ const isNewEdge = computed(() => {
const impactLoading = ref(false)
const impactData = ref<TopoAPI.ImpactAnalysis | null>(null)
const discoveryRun = ref<TopoAPI.TopologyDiscoveryRun | null>(null)
const discoveryProgress = computed(() => {
if (!discoveryRun.value?.total_tasks) return 0
return discoveryRun.value.completed_tasks / discoveryRun.value.total_tasks
})
let discoveryTimer: number | undefined
const stopDiscoveryPolling = () => {
if (discoveryTimer !== undefined) window.clearTimeout(discoveryTimer)
discoveryTimer = undefined
}
const pollDiscoveryRun = async () => {
if (!currentTopologyId.value) return
const response = await TopoAPI.fetchLatestTopologyDiscoveryRun(currentTopologyId.value)
if (response.code !== 0) throw new Error(response.message || '读取拓扑发现进度失败')
discoveryRun.value = response.details || null
if (discoveryRun.value?.status === 'running') {
discoveryTimer = window.setTimeout(() => void pollDiscoveryRun(), 2000)
return
}
stopDiscoveryPolling()
if (discoveryRun.value?.status === 'succeeded') {
await loadData()
Message.success('拓扑发现已完成')
} else if (discoveryRun.value?.status === 'failed') {
Message.error('拓扑发现失败,已保留上一次成功拓扑')
}
}
// ==================== 生命周期 ====================
@@ -471,7 +509,6 @@ const loadData = async () => {
selected: Boolean(focusedResourceUID.value && node.data.resource_uid === focusedResourceUID.value),
}))
edges.value = edgesData
} catch (error) {
console.error('加载拓扑数据失败:', error)
Message.error('加载拓扑数据失败')
@@ -521,9 +558,15 @@ watch(
{ immediate: true }
)
onMounted(() => {
loadData()
onMounted(async () => {
await loadData()
if (topology.value?.auto_discovery && currentTopologyId.value) {
const response = await TopoAPI.fetchLatestTopologyDiscoveryRun(currentTopologyId.value)
discoveryRun.value = response.code === 0 ? response.details || null : null
if (discoveryRun.value?.status === 'running') discoveryTimer = window.setTimeout(() => void pollDiscoveryRun(), 2000)
}
})
onUnmounted(stopDiscoveryPolling)
// ==================== 事件处理 ====================
@@ -884,7 +927,13 @@ const setSelectedEdge = (edge: any) => {
const refreshTopology = async () => {
try {
if (currentTopologyId.value && topology.value?.auto_discovery) {
await TopoAPI.discoverTopology(currentTopologyId.value)
stopDiscoveryPolling()
const response = await TopoAPI.discoverTopology(currentTopologyId.value)
if (response.code !== 0 || !response.details) throw new Error(response.message || '启动拓扑发现失败')
discoveryRun.value = response.details
Message.info('拓扑发现已开始,实时监控会继续运行')
discoveryTimer = window.setTimeout(() => void pollDiscoveryRun(), 2000)
return
}
await loadData()
Message.success('拓扑已刷新')
@@ -941,9 +990,9 @@ const resetTopology = async () => {
position: node.data?.position || { x: Math.random() * 800, y: Math.random() * 600 },
data: {
label: node.label,
type: node.type,
resource_uid: node.resource_uid,
...node.data,
type: node.type,
resource_uid: node.resource_uid,
...node.data,
},
})) || []
} catch (e) {
@@ -1067,6 +1116,35 @@ const getNodeColor = (node: any) => {
height: 100%;
}
.discovery-status {
position: absolute;
top: 84px;
left: 16px;
z-index: 9;
width: 360px;
padding: 12px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.discovery-status__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.discovery-status__error {
max-height: 120px;
margin-top: 8px;
overflow: auto;
color: rgb(var(--red-6));
font-size: 12px;
white-space: pre-wrap;
}
:deep(.vue-flow) {
background: transparent;
}