fix
This commit is contained in:
@@ -71,6 +71,12 @@ export interface CollectorNode {
|
|||||||
capabilities: string
|
capabilities: string
|
||||||
version?: string
|
version?: string
|
||||||
queue_depth: number
|
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_heartbeat_at?: string
|
||||||
last_error?: string
|
last_error?: string
|
||||||
enrollment_expires_at?: string
|
enrollment_expires_at?: string
|
||||||
@@ -83,6 +89,8 @@ export interface CollectorNodeInput {
|
|||||||
region_code?: string
|
region_code?: string
|
||||||
description?: string
|
description?: string
|
||||||
capabilities: string
|
capabilities: string
|
||||||
|
max_concurrency: number
|
||||||
|
execution_timeout_seconds: number
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +106,7 @@ export interface CollectionBinding {
|
|||||||
target: string
|
target: string
|
||||||
port: number
|
port: number
|
||||||
interval_seconds: number
|
interval_seconds: number
|
||||||
|
inventory_interval_seconds: number
|
||||||
retry_policy: string
|
retry_policy: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
state: string
|
state: string
|
||||||
@@ -117,6 +126,7 @@ export interface CollectionBindingInput {
|
|||||||
target: string
|
target: string
|
||||||
port: number
|
port: number
|
||||||
interval_seconds: number
|
interval_seconds: number
|
||||||
|
inventory_interval_seconds: number
|
||||||
retry_policy?: string
|
retry_policy?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
@@ -168,6 +178,12 @@ export const fetchCollectionBindings = (params?: Record<string, unknown>) =>
|
|||||||
|
|
||||||
export const createCollectionBinding = (data: CollectionBindingInput) => request.post(`${PREFIX}/bindings`, data)
|
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 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 deleteCollectionBinding = (id: number) => request.delete(`${PREFIX}/bindings/${id}`)
|
||||||
|
|
||||||
export const fetchDiscoveryRuns = (params?: Record<string, string | number | boolean | undefined>) =>
|
export const fetchDiscoveryRuns = (params?: Record<string, string | number | boolean | undefined>) =>
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ export interface Topology {
|
|||||||
updated_at?: string
|
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 {
|
export interface Link {
|
||||||
id?: number
|
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 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`)
|
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[]) =>
|
export const addResourceNodes = (topologyId: number, resource_uids: string[]) =>
|
||||||
request.post<{ code: number; details?: { message: string; added: number } }>(
|
request.post<{ code: number; details?: { message: string; added: number } }>(`/DC-Control/v1/topologies/${topologyId}/resource-nodes`, {
|
||||||
`/DC-Control/v1/topologies/${topologyId}/resource-nodes`,
|
resource_uids,
|
||||||
{ resource_uids }
|
})
|
||||||
)
|
|
||||||
|
|
||||||
/** 更新节点 */
|
/** 更新节点 */
|
||||||
export const updateNode = (topologyId: number, nodeId: string, data: Partial<TopologyNode>) =>
|
export const updateNode = (topologyId: number, nodeId: string, data: Partial<TopologyNode>) =>
|
||||||
|
|||||||
@@ -1,17 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-card title="设备采集绑定" :bordered="false">
|
<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>
|
<template #extra>
|
||||||
<a-table row-key="id" :columns="columns" :data="rows" :loading="loading" :pagination="pagination" @page-change="onPageChange">
|
<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 #node="{ record }">{{ record.collector_node?.name || `#${record.collector_node_id}` }}</template>
|
||||||
<template #credential="{ record }">{{ record.credential?.name || `#${record.credential_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 #template="{ record }">{{ record.template?.name || `#${record.template_id}` }} / v{{ record.template_version }}</template>
|
||||||
<template #target="{ record }"><span class="mono">{{ displayTarget(record) }}</span></template>
|
<template #target="{ record }">
|
||||||
<template #state="{ record }"><a-tag :color="record.enabled && record.state === 'active' ? 'green' : 'gray'">{{ record.enabled ? record.state : '停用' }}</a-tag></template>
|
<span class="mono">{{ displayTarget(record) }}</span>
|
||||||
<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>
|
||||||
|
<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-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
<binding-form-modal v-model:visible="visible" :record="current" @success="loadData" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -19,7 +66,12 @@
|
|||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import type { TableColumnData } 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'
|
import BindingFormModal from '../components/BindingFormModal.vue'
|
||||||
|
|
||||||
const rows = ref<CollectionBinding[]>([])
|
const rows = ref<CollectionBinding[]>([])
|
||||||
@@ -27,22 +79,90 @@ const current = ref<CollectionBinding | null>(null)
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const keyword = ref('')
|
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 pagination = reactive({ current: 1, pageSize: 20, total: 0 })
|
||||||
const columns: TableColumnData[] = [
|
const columns: TableColumnData[] = [
|
||||||
{ title: '绑定名称', dataIndex: 'name' }, { title: '设备 UID', dataIndex: 'resource_uid', ellipsis: true, tooltip: true }, { title: '采集节点', slotName: 'node' },
|
{ title: '绑定名称', dataIndex: 'name' },
|
||||||
{ title: '凭证', slotName: 'credential' }, { title: '厂商模板', slotName: 'template' }, { title: '目标', slotName: 'target', minWidth: 210 },
|
{ title: '设备 UID', dataIndex: 'resource_uid', ellipsis: true, tooltip: true },
|
||||||
{ title: '间隔(秒)', dataIndex: 'interval_seconds', width: 100 }, { title: '状态', slotName: 'state', width: 100 }, { title: '操作', slotName: 'actions', width: 140, fixed: 'right' },
|
{ 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 displayTarget = (record: CollectionBinding) =>
|
||||||
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 } }
|
record.target.includes('://') ? `${record.target}(端口 ${record.port})` : `${record.protocol}://${record.target}:${record.port}`
|
||||||
const onPageChange = (page: number) => { pagination.current = page; loadData() }
|
const loadData = async () => {
|
||||||
const openCreate = () => { current.value = null; visible.value = true }
|
loading.value = true
|
||||||
const openEdit = (row: CollectionBinding) => { current.value = row; visible.value = true }
|
try {
|
||||||
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 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)
|
onMounted(loadData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.page-container { padding: 20px; }
|
.page-container {
|
||||||
.mono { font-family: monospace; }
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.mb-16 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
<template>
|
<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-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-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-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-form-item label="绑定名称" required><a-input v-model="form.name" /></a-form-item>
|
||||||
<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>
|
||||||
<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-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-form-item label="设备" required>
|
||||||
<a-col :span="12"><a-form-item label="协议"><a-input v-model="form.protocol" readonly /></a-form-item></a-col>
|
<a-select
|
||||||
<a-col :span="16"><a-form-item label="目标 IP 或域名" required><a-input v-model="form.target" /></a-form-item></a-col>
|
v-model="form.resource_uid"
|
||||||
<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>
|
allow-search
|
||||||
<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>
|
:disabled="Boolean(fixedResourceUid)"
|
||||||
<a-col :span="12"><a-form-item label="启用"><a-switch v-model="form.enabled" /></a-form-item></a-col>
|
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-row>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
@@ -22,7 +82,18 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { fetchControlResourceOptions, type OptionItem } from '@/api/ops/dcControl'
|
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'
|
import { collectionTemplateMatchesModel } from '@/utils/collectionTemplate'
|
||||||
|
|
||||||
const props = defineProps<{ visible: boolean; record?: CollectionBinding | null; fixedResourceUid?: string; defaultTarget?: string }>()
|
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 credentials = ref<CollectionCredential[]>([])
|
||||||
const allTemplates = ref<CollectionTemplate[]>([])
|
const allTemplates = ref<CollectionTemplate[]>([])
|
||||||
const selectedResource = computed(() => resources.value.find((item) => item.value === form.resource_uid))
|
const selectedResource = computed(() => resources.value.find((item) => item.value === form.resource_uid))
|
||||||
const templates = computed(() => allTemplates.value.filter((item) => {
|
const templates = computed(() =>
|
||||||
|
allTemplates.value.filter((item) => {
|
||||||
const resource = selectedResource.value
|
const resource = selectedResource.value
|
||||||
if (!resource) return false
|
if (!resource) return false
|
||||||
return item.resource_category === resource.resource_category
|
return (
|
||||||
&& (item.manufacturer_code === 'GENERIC' || item.manufacturer_code === resource.manufacturer_code)
|
item.resource_category === resource.resource_category &&
|
||||||
&& collectionTemplateMatchesModel(item, resource.model)
|
(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 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 form = reactive<CollectionBindingInput>(defaultForm())
|
||||||
const editing = computed(() => Boolean(props.record?.id))
|
const editing = computed(() => Boolean(props.record?.id))
|
||||||
const compatibleCredentials = computed(() => credentials.value.filter((item) => {
|
const compatibleCredentials = computed(() =>
|
||||||
|
credentials.value.filter((item) => {
|
||||||
if (form.protocol === 'snmp') return ['snmp_v1', 'snmp_v2c', 'snmp_v3'].includes(item.type)
|
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 === 'agent') return item.type === 'http_api'
|
||||||
if (form.protocol === 'middleware') return item.type === 'http_api'
|
if (form.protocol === 'middleware') return item.type === 'http_api'
|
||||||
return item.type === form.protocol
|
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 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 loadOptions = async () => {
|
||||||
const [resourceRes, nodeRes, credentialRes, templateRes] = await Promise.all([
|
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 || []
|
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 })
|
if (props.fixedResourceUid && !resources.value.some((item) => item.value === props.fixedResourceUid))
|
||||||
nodes.value = nodeRes.details?.data || []; credentials.value = credentialRes.details?.data || []; allTemplates.value = templateRes.details?.data || []
|
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 = () => {
|
const reset = () => {
|
||||||
Object.assign(form, defaultForm(), { resource_uid: props.fixedResourceUid || '', target: props.defaultTarget || '' })
|
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 close = () => emit('update:visible', false)
|
||||||
const save = async () => {
|
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
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = props.record?.id ? await updateCollectionBinding(props.record.id, form) : await createCollectionBinding(form)
|
const res: any = props.record?.id ? await updateCollectionBinding(props.record.id, form) : await createCollectionBinding(form)
|
||||||
if (res.code !== 0) { Message.error(res.message || '保存失败'); return }
|
if (res.code !== 0) {
|
||||||
Message.success('采集绑定已保存'); close(); emit('success')
|
Message.error(res.message || '保存失败')
|
||||||
} finally { saving.value = false }
|
return
|
||||||
}
|
}
|
||||||
watch(() => props.visible, async (value) => { if (!value) return; await loadOptions(); reset() })
|
Message.success('采集绑定已保存')
|
||||||
|
close()
|
||||||
|
emit('success')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (value) => {
|
||||||
|
if (!value) return
|
||||||
|
await loadOptions()
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.mb-16 { margin-bottom: 16px; }
|
.mb-16 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
<template #heartbeat="{ record }">
|
<template #heartbeat="{ record }">
|
||||||
{{ record.last_heartbeat_at ? new Date(record.last_heartbeat_at).toLocaleString() : '尚未连接' }}
|
{{ record.last_heartbeat_at ? new Date(record.last_heartbeat_at).toLocaleString() : '尚未连接' }}
|
||||||
</template>
|
</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 }">
|
<template #actions="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
|
<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-input v-model="form.region_code" placeholder="如 NORTH-HOSPITAL" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</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">
|
<a-col :span="24">
|
||||||
<section class="capability-section">
|
<section class="capability-section">
|
||||||
<div class="capability-section-header">
|
<div class="capability-section-header">
|
||||||
@@ -226,6 +239,8 @@ const defaultForm = (): CollectorNodeInput => ({
|
|||||||
region_code: '',
|
region_code: '',
|
||||||
description: '',
|
description: '',
|
||||||
capabilities: JSON.stringify(defaultCapabilities),
|
capabilities: JSON.stringify(defaultCapabilities),
|
||||||
|
max_concurrency: 32,
|
||||||
|
execution_timeout_seconds: 45,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
const form = reactive<CollectorNodeInput>(defaultForm())
|
const form = reactive<CollectorNodeInput>(defaultForm())
|
||||||
@@ -236,7 +251,10 @@ const columns: TableColumnData[] = [
|
|||||||
{ title: '区域', dataIndex: 'region_code' },
|
{ title: '区域', dataIndex: 'region_code' },
|
||||||
{ title: '状态', slotName: 'status', width: 100 },
|
{ title: '状态', slotName: 'status', width: 100 },
|
||||||
{ title: '版本', dataIndex: 'version', 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: '最近心跳', slotName: 'heartbeat', width: 180 },
|
||||||
{ title: '最近错误', dataIndex: 'last_error', ellipsis: true, tooltip: true },
|
{ title: '最近错误', dataIndex: 'last_error', ellipsis: true, tooltip: true },
|
||||||
{ title: '操作', slotName: 'actions', width: 250, fixed: 'right' },
|
{ title: '操作', slotName: 'actions', width: 250, fixed: 'right' },
|
||||||
@@ -256,6 +274,17 @@ const statusLabel = (status: string) =>
|
|||||||
({ pending: '待安装', enrolled: '等待心跳', active: '在线', degraded: '异常', offline: '离线' })[status] || status
|
({ pending: '待安装', enrolled: '等待心跳', active: '在线', degraded: '异常', offline: '离线' })[status] || status
|
||||||
const statusColor = (status: string) =>
|
const statusColor = (status: string) =>
|
||||||
({ pending: 'orange', enrolled: 'orange', active: 'green', degraded: 'red', offline: 'gray' })[status] || 'blue'
|
({ 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 () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -309,6 +338,8 @@ const openEdit = (row: CollectorNode) => {
|
|||||||
region_code: row.region_code || '',
|
region_code: row.region_code || '',
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
capabilities: JSON.stringify(capabilities),
|
capabilities: JSON.stringify(capabilities),
|
||||||
|
max_concurrency: row.max_concurrency,
|
||||||
|
execution_timeout_seconds: row.execution_timeout_seconds,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
})
|
})
|
||||||
selectedCapabilities.value = capabilities
|
selectedCapabilities.value = capabilities
|
||||||
@@ -335,6 +366,10 @@ const save = async () => {
|
|||||||
Message.warning('请至少选择一项节点能力')
|
Message.warning('请至少选择一项节点能力')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (form.max_concurrency < 1 || form.execution_timeout_seconds < 1) {
|
||||||
|
Message.warning('最大并发任务数和单任务执行超时必须大于 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
form.capabilities = JSON.stringify(selectedCapabilities.value)
|
form.capabilities = JSON.stringify(selectedCapabilities.value)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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="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="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.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-row>
|
||||||
<a-empty v-if="model.enabled && !templates.length" description="当前厂商、设备类型和型号没有适用的已启用采集模板,请先到供应商管理维护模板" />
|
<a-empty v-if="model.enabled && !templates.length" description="当前厂商、设备类型和型号没有适用的已启用采集模板,请先到供应商管理维护模板" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface PrimaryCollectionDraft {
|
|||||||
target: string
|
target: string
|
||||||
port: number
|
port: number
|
||||||
interval_seconds: number
|
interval_seconds: number
|
||||||
|
inventory_interval_seconds: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceMasterContext {
|
export interface DeviceMasterContext {
|
||||||
@@ -78,7 +79,7 @@ export function createDeviceMasterState(): DeviceMasterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPrimaryCollectionDraft(): PrimaryCollectionDraft {
|
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(
|
export async function persistPrimaryCollectionBinding(
|
||||||
@@ -100,6 +101,7 @@ export async function persistPrimaryCollectionBinding(
|
|||||||
target: draft.target.trim(),
|
target: draft.target.trim(),
|
||||||
port: draft.port,
|
port: draft.port,
|
||||||
interval_seconds: draft.interval_seconds,
|
interval_seconds: draft.interval_seconds,
|
||||||
|
inventory_interval_seconds: draft.inventory_interval_seconds,
|
||||||
retry_policy: '{"max_attempts":2}',
|
retry_policy: '{"max_attempts":2}',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,18 @@
|
|||||||
@export="exportTopology"
|
@export="exportTopology"
|
||||||
@reset="resetTopology"
|
@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 画布 -->
|
<!-- Vue Flow 画布 -->
|
||||||
<div ref="reactFlowWrapper" class="flow-wrapper" :style="canvasBackgroundStyle">
|
<div ref="reactFlowWrapper" class="flow-wrapper" :style="canvasBackgroundStyle">
|
||||||
@@ -137,19 +149,13 @@
|
|||||||
:max-tag-count="5"
|
:max-tag-count="5"
|
||||||
placeholder="请选择要加入当前拓扑的设备"
|
placeholder="请选择要加入当前拓扑的设备"
|
||||||
>
|
>
|
||||||
<a-option v-for="item in resourceOptions" :key="item.value" :value="item.value">
|
<a-option v-for="item in resourceOptions" :key="item.value" :value="item.value">{{ item.label }}({{ item.value }})</a-option>
|
||||||
{{ item.label }}({{ item.value }})
|
|
||||||
</a-option>
|
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<delete-confirm-dialog
|
<delete-confirm-dialog v-model:visible="deleteDialogOpen" :message="deleteNodeMessage" @confirm="handleDeleteNodeConfirm" />
|
||||||
v-model:visible="deleteDialogOpen"
|
|
||||||
:message="deleteNodeMessage"
|
|
||||||
@confirm="handleDeleteNodeConfirm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- ==================== 边对话框 ==================== -->
|
<!-- ==================== 边对话框 ==================== -->
|
||||||
|
|
||||||
@@ -175,7 +181,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute } from 'vue-router'
|
||||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
@@ -305,9 +311,7 @@ const isAutoTopo = computed(() => {
|
|||||||
return route.path.includes('/netarch/auto-topo')
|
return route.path.includes('/netarch/auto-topo')
|
||||||
})
|
})
|
||||||
|
|
||||||
const canManageResourceReferences = computed(
|
const canManageResourceReferences = computed(() => topology.value?.auto_discovery === false && !topology.value?.business_system_id)
|
||||||
() => topology.value?.auto_discovery === false && !topology.value?.business_system_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteNodeMessage = computed(() => {
|
const deleteNodeMessage = computed(() => {
|
||||||
if (selectedNode.value?.data?.ref_type === 'resource') {
|
if (selectedNode.value?.data?.ref_type === 'resource') {
|
||||||
@@ -338,17 +342,22 @@ const displayEdges = computed(() => filteredResult.value.edges)
|
|||||||
// 计算边样式
|
// 计算边样式
|
||||||
const styledEdges = useEdgeStyles(displayEdges, presentation)
|
const styledEdges = useEdgeStyles(displayEdges, presentation)
|
||||||
|
|
||||||
const gradientDirection = computed(() => ({
|
const gradientDirection = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
top_to_bottom: 'to bottom',
|
top_to_bottom: 'to bottom',
|
||||||
left_to_right: 'to right',
|
left_to_right: 'to right',
|
||||||
diagonal_down_right: 'to bottom right',
|
diagonal_down_right: 'to bottom right',
|
||||||
diagonal_down_left: 'to bottom left',
|
diagonal_down_left: 'to bottom left',
|
||||||
}[presentation.value.gradient_direction]))
|
})[presentation.value.gradient_direction]
|
||||||
|
)
|
||||||
|
|
||||||
const canvasBackgroundStyle = computed(() => {
|
const canvasBackgroundStyle = computed(() => {
|
||||||
if (presentation.value.background_type === 'solid') return { background: presentation.value.background_color }
|
if (presentation.value.background_type === 'solid') return { background: presentation.value.background_color }
|
||||||
if (presentation.value.background_type === 'gradient') {
|
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' }
|
return { background: 'transparent' }
|
||||||
})
|
})
|
||||||
@@ -360,6 +369,35 @@ const isNewEdge = computed(() => {
|
|||||||
|
|
||||||
const impactLoading = ref(false)
|
const impactLoading = ref(false)
|
||||||
const impactData = ref<TopoAPI.ImpactAnalysis | null>(null)
|
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),
|
selected: Boolean(focusedResourceUID.value && node.data.resource_uid === focusedResourceUID.value),
|
||||||
}))
|
}))
|
||||||
edges.value = edgesData
|
edges.value = edgesData
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载拓扑数据失败:', error)
|
console.error('加载拓扑数据失败:', error)
|
||||||
Message.error('加载拓扑数据失败')
|
Message.error('加载拓扑数据失败')
|
||||||
@@ -521,9 +558,15 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadData()
|
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 () => {
|
const refreshTopology = async () => {
|
||||||
try {
|
try {
|
||||||
if (currentTopologyId.value && topology.value?.auto_discovery) {
|
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()
|
await loadData()
|
||||||
Message.success('拓扑已刷新')
|
Message.success('拓扑已刷新')
|
||||||
@@ -1067,6 +1116,35 @@ const getNodeColor = (node: any) => {
|
|||||||
height: 100%;
|
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) {
|
:deep(.vue-flow) {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user