Compare commits

5 Commits

Author SHA1 Message Date
zxr
1ad0323715 fix 大屏管理 2026-07-03 15:48:23 +08:00
zxr
b01a0f0979 fix 报表数据无法查看与下载 2026-07-03 11:09:16 +08:00
zxr
8f3dd3e43e 新增 业务拓扑相关 2026-06-29 21:47:46 +08:00
zxr
2107f77081 修复 : 数据为空的时候,创建按钮不显示 2026-06-29 10:42:36 +08:00
zxr
fcaad4b3ae 修复 空数据时页面组件不显示 2026-06-29 10:09:28 +08:00
22 changed files with 2321 additions and 179 deletions

View File

@@ -10,7 +10,8 @@ VITE_USE_MOCK=false
# API 基础URL
# VITE_API_BASE_URL=https://ops-api.apinb.com
VITE_API_BASE_URL=http://127.0.0.1
# 开发环境走 Vite 同源代理,避免依赖本机 80/443 nginx
VITE_API_BASE_URL=
# Logs 本地调试地址(仅 logs 模块使用)
VITE_LOGS_API_BASE_URL=http://127.0.0.1:12440

View File

@@ -0,0 +1,47 @@
# Front - OPS 前端
`front` 是 OPS 平台的统一前端入口,基于 Vue 3、Vite、Arco Design Vue、Pinia 和 Vue Router。页面以动态菜单为主RBAC 菜单接口不可用时使用本地菜单数据兜底。
## 当前代码入口
| 类型 | 路径 |
| --- | --- |
| 应用入口 | `front/src/main.ts` |
| 路由入口 | `front/src/router/index.ts` |
| 静态路由 | `front/src/router/routes/modules/` |
| 动态菜单转换 | `front/src/router/menu-data.ts` |
| 本地菜单兜底 | `front/src/router/local-menu-items.ts``front/src/router/local-menu-flat.ts` |
| 页面目录 | `front/src/views/` |
| API 模块 | `front/src/api/module/` |
| 构建配置 | `front/config/` |
## 主要页面域
| 页面域 | 目录 |
| --- | --- |
| 首页与概览 | `front/src/views/home``front/src/views/ops/pages/overview` |
| 数据中心与资源 | `front/src/views/ops/pages/dc``datacenter``resource-context` |
| 告警与治理 | `front/src/views/ops/pages/alert``governance` |
| 工单闭环 | `front/src/views/ops/pages/feedback` |
| 知识库 | `front/src/views/ops/pages/kb` |
| 报表与自动化 | `front/src/views/ops/pages/report``automation` |
| 业务系统与拓扑 | `front/src/views/ops/pages/business-system``business-topology` |
| 大屏 | `front/src/views/ops/pages/big-screen``front/src/views/visualization` |
| 日志管理 | `front/src/views/ops/pages/log-mgmt` |
| 系统设置 | `front/src/views/ops/pages/system-settings` |
## 常用命令
```powershell
cd D:\work\ops\front
pnpm.cmd install
pnpm.cmd dev
pnpm.cmd build
```
## 相关文档
- `front/PAGES_README.md`:页面清单和路由说明。
- `front/QUICK_START.md`:本地启动说明。
- `front/docs/`:页面设计、适配验收和部分接口对接文档。
- `docs/模块文档索引.md`:全工作区模块边界与文档入口。

View File

@@ -2,6 +2,11 @@ import { mergeConfig } from 'vite'
// import eslint from 'vite-plugin-eslint'
import baseConfig from './vite.config.base'
const proxyTarget = (port: number) => ({
target: `http://127.0.0.1:${port}`,
changeOrigin: true,
})
export default mergeConfig(
{
mode: 'development',
@@ -11,6 +16,31 @@ export default mergeConfig(
fs: {
strict: true,
},
proxy: {
'/rbac2': proxyTarget(10001),
'/Alert': proxyTarget(12427),
'/alert': proxyTarget(12427),
'/DC-Control': proxyTarget(3031),
'/dc-control': proxyTarget(3031),
'/dc-network': proxyTarget(12429),
'/DC-Hardware': proxyTarget(12450),
'/dc-hardware': proxyTarget(12450),
'/dc-host': proxyTarget(9030),
'/dc-middleware': proxyTarget(12428),
'/dc-database': proxyTarget(12580),
'/Feedback': proxyTarget(12432),
'/feedback': proxyTarget(12432),
'/Assets': proxyTarget(12430),
'/assets': proxyTarget(12430),
'/Logs': proxyTarget(12440),
'/logs': proxyTarget(12440),
'/Kb': proxyTarget(12434),
'/kb': proxyTarget(12434),
'/Mgt': proxyTarget(12436),
'/mgt': proxyTarget(12436),
'/Visual': proxyTarget(12438),
'/visual': proxyTarget(12438),
},
},
plugins: [
// eslint({

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
allowBuilds:
esbuild: set this to true or false
less: set this to true or false
unrs-resolver: set this to true or false
vue-demi: set this to true or false

View File

@@ -6,7 +6,7 @@ const FtsUpload = (data: FormData, onUploadProgress?: (progress: number) => void
data.append('provider', 'local')
data.append('bucket', 'visual')
return request.post(`/fts/v1/uploader`, data, {
return request.post(`/Assets/v1/fts/uploader`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},

View File

@@ -71,6 +71,27 @@ export interface BusinessResourceMember {
description?: string
}
/** 业务拓扑资源 */
export interface BusinessTopologyResource {
id: number
resource_uid: string
resource_category: string
service_identity: string
display_name: string
description?: string
status: string
business_system_id?: number
owner_user_id?: number
department_id?: number
asset_id?: number
tags?: string
source_service?: string
source_table?: string
source_id?: number
created_at?: string
updated_at?: string
}
/** 业务拓扑节点 */
export interface BusinessTopologyNode {
id?: number
@@ -102,10 +123,10 @@ export interface BusinessDependency {
/** 业务拓扑 */
export interface BusinessTopology {
business_system_id: number
resources: unknown[]
dependencies: BusinessDependency[]
nodes: BusinessTopologyNode[]
links: BusinessTopologyEdge[]
resources: BusinessTopologyResource[] | null
dependencies: BusinessDependency[] | null
nodes: BusinessTopologyNode[] | null
links: BusinessTopologyEdge[] | null
}
/** 业务时间线项 */
@@ -146,7 +167,7 @@ export interface BusinessDocumentLink {
/** 列表响应 */
export interface ListResult<T> {
list: T[]
list: T[] | null
count: number
}
@@ -166,6 +187,21 @@ export const fetchBusinessHealth = (id: number) =>
export const fetchBusinessMembers = (id: number) =>
request.get<{ code?: number; details?: ListResult<BusinessResourceMember>; message?: string }>(`/DC-Control/v1/business-systems/${id}/resources`)
export interface BusinessResourceMemberPayload {
resource_uid?: string
resource_id?: number
role?: string
criticality?: string
weight?: number
description?: string
}
export const createBusinessMember = (id: number, data: BusinessResourceMemberPayload) =>
request.post<{ code?: number; details?: BusinessResourceMember; message?: string }>(`/DC-Control/v1/business-systems/${id}/resources`, data)
export const deleteBusinessMember = (id: number, memberId: number) =>
request.delete<{ code?: number; details?: { message: string }; message?: string }>(`/DC-Control/v1/business-systems/${id}/resources/${memberId}`)
/** 获取业务拓扑 */
export const fetchBusinessTopology = (id: number) =>
request.get<{ code?: number; details?: BusinessTopology; message?: string }>(`/DC-Control/v1/business-systems/${id}/topology`)

View File

@@ -80,6 +80,25 @@ export interface ControlResource {
updated_at?: string
}
export interface ControlResourcePayload {
resource_category?: string
service_identity?: string
display_name?: string
description?: string
status?: string
business_system_id?: number | null
owner_user_id?: number | null
department_id?: number | null
asset_id?: number | null
datacenter_id?: number | null
room_id?: number | null
rack_id?: number | null
unit_start?: number | null
unit_end?: number | null
location_ref?: string | null
tags?: string
}
export interface ControlResourceType {
id: number
resource_category: string
@@ -165,10 +184,10 @@ export const fetchControlResources = (params?: ResourceListParams) =>
export const fetchControlResourceOptions = (params?: { resource_category?: string }) =>
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>('/DC-Control/v1/resources/options', { params })
export const createControlResource = (data: Partial<ControlResource>) =>
export const createControlResource = (data: ControlResourcePayload) =>
request.post<{ code?: number; details?: ControlResource; message?: string }>('/DC-Control/v1/resources', data)
export const updateControlResource = (id: number, data: Partial<ControlResource>) =>
export const updateControlResource = (id: number, data: ControlResourcePayload) =>
request.put<{ code?: number; details?: ControlResource; message?: string }>(`/DC-Control/v1/resources/${id}`, data)
export const deleteControlResource = (id: number) =>

View File

@@ -162,6 +162,13 @@ export const fetchServerMetricsSummary = (serverIdentity: string) => {
})
}
/** 立即拉取一次主机指标并返回最新统计卡片 */
export const collectServerMetricsNow = (serverIdentity: string) => {
return request.post<{ code: number; details?: HostMetricsSummary; message?: string }>('/DC-Control/v1/servers/metrics/collect', {
server_identity: serverIdentity,
})
}
/** 近 N 小时网络收/发速率Mbps相邻采样字节差分 */
export const fetchServerNetworkTraffic = (serverIdentity: string, hours = 6) => {
return request.get<{ code: number; details?: HostNetworkTrafficPayload; message?: string }>(

View File

@@ -51,27 +51,37 @@
</a-col>
</a-row>
<!-- 表格 -->
<a-table
row-key="id"
:loading="loading"
:pagination="pagination"
:columns="cloneColumns"
:data="data"
:bordered="bordered"
:size="size"
:row-selection="rowSelection"
:scroll="scroll"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
@selection-change="onSelectionChange"
@row-click="onRowClick"
<!-- 表格主体状态占位与表格分离工具栏始终展示 -->
<PageState
:status="status"
:loading-text="loadingText"
:empty-text="emptyText"
:error-text="errorText"
:partial-text="partialText"
@retry="emit('retry')"
>
<!-- 动态插槽根据 columns slotName 动态渲染 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</a-table>
<a-table
v-if="status === 'success' || status === 'partial'"
row-key="id"
:loading="loading"
:pagination="pagination"
:columns="cloneColumns"
:data="data"
:bordered="bordered"
:size="size"
:row-selection="rowSelection"
:scroll="scroll"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
@selection-change="onSelectionChange"
@row-click="onRowClick"
>
<!-- 动态插槽根据 columns slotName 动态渲染 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</a-table>
</PageState>
</div>
</template>
@@ -140,6 +150,26 @@ const props = defineProps({
type: Boolean,
default: true,
},
status: {
type: String as PropType<'loading' | 'empty' | 'error' | 'success' | 'partial' | 'unauthorized'>,
default: 'success',
},
loadingText: {
type: String,
default: '加载中',
},
emptyText: {
type: String,
default: '暂无数据',
},
errorText: {
type: String,
default: '数据暂时不可用,请稍后重试。',
},
partialText: {
type: String,
default: '部分数据加载失败,当前页面展示可用数据。',
},
downloadButtonText: {
type: String,
default: '下载',
@@ -167,6 +197,7 @@ const emit = defineEmits<{
(e: 'download'): void
(e: 'density-change', size: SizeProps): void
(e: 'column-change', columns: TableColumnData[]): void
(e: 'retry'): void
}>()
const size = ref<SizeProps>('medium')

View File

@@ -19,54 +19,50 @@
<a-divider style="margin-top: 0" />
<!-- 数据表格 -->
<PageState
<!-- 数据表格工具栏始终展示表格主体随状态切换 -->
<DataTable
:data="data"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="bordered"
:row-selection="rowSelection"
:scroll="responsiveScroll"
:show-toolbar="showToolbar"
:show-download="showDownload"
:show-refresh="showRefresh"
:show-density="showDensity"
:show-column-setting="showColumnSetting"
:download-button-text="downloadButtonText"
:refresh-tooltip-text="refreshTooltipText"
:density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText"
:status="stateStatus"
:loading-text="loadingText"
:empty-text="emptyText"
:error-text="errorText"
:partial-text="partialText"
:loading-text="loadingText"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@refresh="handleRefresh"
@download="handleDownload"
@density-change="handleDensityChange"
@column-change="handleColumnChange"
@retry="handleRefresh"
>
<DataTable
v-if="stateStatus !== 'empty' && stateStatus !== 'error' && stateStatus !== 'unauthorized'"
:data="data"
:columns="columns"
:loading="loading"
:pagination="pagination"
:bordered="bordered"
:row-selection="rowSelection"
:scroll="responsiveScroll"
:show-toolbar="showToolbar"
:show-download="showDownload"
:show-refresh="showRefresh"
:show-density="showDensity"
:show-column-setting="showColumnSetting"
:download-button-text="downloadButtonText"
:refresh-tooltip-text="refreshTooltipText"
:density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@refresh="handleRefresh"
@download="handleDownload"
@density-change="handleDensityChange"
@column-change="handleColumnChange"
>
<template #toolbar-left>
<slot name="toolbar-left" />
</template>
<template #toolbar-right>
<slot name="toolbar-right" />
</template>
<!-- 动态插槽透传 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</DataTable>
</PageState>
<template #toolbar-left>
<slot name="toolbar-left" />
</template>
<template #toolbar-right>
<slot name="toolbar-right" />
</template>
<!-- 动态插槽透传 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
<slot :name="col.slotName" v-bind="slotProps" />
</template>
</DataTable>
</a-card>
</div>
</template>

View File

@@ -29,6 +29,7 @@ export default {
'menu.ops.netarch': 'Network Architecture',
'menu.ops.netarch.topoGroup': 'Topology Group Management',
'menu.ops.netarch.topo': 'Topology',
'menu.ops.businessTopology': 'Business Topology',
'menu.ops.systemSettings': 'System Settings',
'menu.ops.systemSettings.menuManagement': 'Menu Management',
'menu.ops.systemSettings.systemLogs': 'System Logs',

View File

@@ -29,6 +29,7 @@ export default {
'menu.ops.netarch': '网络架构',
'menu.ops.netarch.topoGroup': '拓扑组管理',
'menu.ops.netarch.topo': '拓扑图',
'menu.ops.businessTopology': '业务拓扑',
'menu.ops.systemSettings': '系统设置',
'menu.ops.systemSettings.menuManagement': '菜单管理',
'menu.ops.systemSettings.systemLogs': '系统日志',

View File

@@ -45,7 +45,7 @@ export const localMenuFlatItems: MenuItem[] = [
type: 1,
sort_key: 3,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/items',
web_url: 'https://ops2.apinb.com/view/#/project/items',
created_at: '2025-12-26T13:23:51.644296+08:00',
},
{
@@ -60,7 +60,7 @@ export const localMenuFlatItems: MenuItem[] = [
type: 1,
sort_key: 4,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/management',
web_url: 'https://ops2.apinb.com/view/#/project/management',
created_at: '2026-01-25T10:44:15.33024+08:00',
},
{
@@ -108,6 +108,22 @@ export const localMenuFlatItems: MenuItem[] = [
hide_menu: true,
created_at: '2026-06-25T10:00:00+08:00',
},
{
id: 12063,
identity: '019ca000-0001-7000-8000-000000000063',
title: '业务拓扑',
title_en: 'Business Topology',
code: 'ops:业务系统视图:业务拓扑',
description: '业务依赖、资源链路、影响范围和时间线拓扑',
app_id: 2,
parent_id: 12060,
menu_path: '/business-topology',
component: 'ops/pages/business-topology',
menu_icon: 'Cluster',
type: 1,
sort_key: 4.5,
created_at: '2026-06-29T10:00:00+08:00',
},
{
id: 16,
identity: '019b591d-00f4-73a0-bbdb-aa7da79ed390',

View File

@@ -78,7 +78,7 @@ export const localMenuItems: MenuItem[] = [
type: 1,
sort_key: 2,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/items',
web_url: 'https://ops2.apinb.com/view/#/project/items',
created_at: '2025-12-26T13:23:51.644296+08:00',
children: [],
},
@@ -94,7 +94,7 @@ export const localMenuItems: MenuItem[] = [
type: 1,
sort_key: 2,
is_web_page: true,
web_url: 'https://ops.apinb.com/view/#/project/management',
web_url: 'https://ops2.apinb.com/view/#/project/management',
created_at: '2026-01-25T10:44:15.33024+08:00',
children: [],
},
@@ -130,6 +130,23 @@ export const localMenuItems: MenuItem[] = [
created_at: '2026-06-25T10:00:00+08:00',
children: [],
},
{
id: 12063,
identity: '019ca000-0001-7000-8000-000000000063',
title: '业务拓扑',
title_en: 'Business Topology',
code: 'ops:业务系统视图:业务拓扑',
description: '业务依赖、资源链路、影响范围和时间线拓扑',
app_id: 2,
parent_id: 12060,
menu_path: '/business-topology',
component: 'ops/pages/business-topology',
menu_icon: 'Cluster',
type: 1,
sort_key: 3,
created_at: '2026-06-29T10:00:00+08:00',
children: [],
},
{
id: 12062,
identity: '019ca000-0001-7000-8000-000000000062',

View File

@@ -68,6 +68,17 @@ const OPS: AppRouteRecordRaw = {
hideInMenu: true,
},
},
{
path: 'business-topology',
alias: ['/business-topology'],
name: 'OpsBusinessTopology',
component: () => import('@/views/ops/pages/business-topology/index.vue'),
meta: {
locale: 'menu.ops.businessTopology',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'big-screen',
name: 'OpsBigScreen',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,418 @@
import type {
BusinessDependency,
BusinessHealth,
BusinessSystemItem,
BusinessTopology,
BusinessTopologyEdge,
BusinessTopologyNode,
} from '@/api/ops/businessSystem'
import type {
BusinessTopologyFlowEdge,
BusinessTopologyFlowNode,
BusinessTopologyGraph,
BusinessTopologyHealthFilter,
BusinessTopologyViewMode,
} from './types'
const HEALTHY_THRESHOLD = 90
const DEGRADED_THRESHOLD = 60
export function scoreValue(score?: number): number {
if (typeof score !== 'number' || Number.isNaN(score)) {
return 0
}
return Math.max(0, Math.min(100, Math.round(score)))
}
export function healthLevelFromScore(score?: number): 'healthy' | 'degraded' | 'critical' {
const value = scoreValue(score)
if (value >= HEALTHY_THRESHOLD) {
return 'healthy'
}
if (value >= DEGRADED_THRESHOLD) {
return 'degraded'
}
return 'critical'
}
export function statusColor(status?: string): string {
const normalized = status?.trim().toLowerCase()
if (!normalized) {
return '#8c8c8c'
}
if (['healthy', 'normal', 'ok', 'online', 'active', 'running', 'enabled'].includes(normalized)) {
return '#52c41a'
}
if (['degraded', 'warning', 'warn', 'pending', 'maintenance', 'medium'].includes(normalized)) {
return '#faad14'
}
if (['critical', 'error', 'failed', 'failure', 'offline', 'inactive', 'disabled', 'high'].includes(normalized)) {
return '#ff4d4f'
}
return '#1677ff'
}
export function healthFilterMatch(item: BusinessSystemItem, filter: BusinessTopologyHealthFilter): boolean {
if (filter === 'all') {
return true
}
const level = normalizedHealthLevel(item)
if (filter === 'unknown') {
return level === 'unknown'
}
return level === filter
}
export function buildBusinessTopologyGraph(input: {
currentBusiness: BusinessSystemItem
topology: BusinessTopology
health?: BusinessHealth | null
mode: BusinessTopologyViewMode
keyword?: string
}): BusinessTopologyGraph {
const { currentBusiness, topology, health, mode } = input
const keyword = input.keyword?.trim().toLowerCase()
const currentBusinessId = businessNodeId(currentBusiness.id)
const includeResourceTopology = mode === 'resource'
const emphasizeDependencyEdges = mode === 'dependency'
const dependencies = topology.dependencies ?? []
const topologyNodes = topology.nodes ?? []
const topologyLinks = topology.links ?? []
const nodes: BusinessTopologyFlowNode[] = []
const edges: BusinessTopologyFlowEdge[] = []
const businessNodeIds = new Set<string>()
const resourceNodeIds = new Map<string, string>()
const resourceFlowNodeIds = new Set<string>()
const resourceContainmentEdgeIds = new Set<string>()
const matchedNodeIds = new Set<string>()
const addBusinessNode = (node: BusinessTopologyFlowNode) => {
if (businessNodeIds.has(node.id)) {
return
}
businessNodeIds.add(node.id)
nodes.push(node)
if (keyword && nodeMatchesKeyword(node, keyword)) {
matchedNodeIds.add(node.id)
}
}
addBusinessNode(createCurrentBusinessNode(currentBusiness, health))
dependencies.forEach((dependency, index) => {
const sourceBusinessId = dependency.business_system_id
const targetBusinessId = dependency.depends_on_business_system_id
const externalIds = [sourceBusinessId, targetBusinessId].filter((id) => id !== currentBusiness.id)
externalIds.forEach((businessId, externalIndex) => {
addBusinessNode(createDependencyBusinessNode(businessId, dependency, index, externalIndex))
})
const edge: BusinessTopologyFlowEdge = {
id: `business-dependency:${dependency.id}:${sourceBusinessId}->${targetBusinessId}`,
source: businessNodeId(sourceBusinessId),
target: businessNodeId(targetBusinessId),
animated: false,
data: {
kind: 'business_dependency',
label: dependency.dependency_type || '依赖',
status: dependency.criticality,
dependency,
},
label: dependency.dependency_type || '依赖',
...(emphasizeDependencyEdges ? { style: dependencyEdgeStyle(dependency) } : {}),
}
edge.animated = shouldAnimateEdge(edge, matchedNodeIds, keyword) || (emphasizeDependencyEdges && !keyword)
edges.push(edge)
})
if (includeResourceTopology) {
topologyNodes.forEach((topologyNode, index) => {
const key = resourceKey(topologyNode)
if (!key) {
return
}
const id = resourceNodeId(key)
rememberResourceNodeIds(resourceNodeIds, topologyNode, id)
if (resourceFlowNodeIds.has(id)) {
return
}
resourceFlowNodeIds.add(id)
const node = createResourceNode(topologyNode, index)
nodes.push(node)
if (keyword && nodeMatchesKeyword(node, keyword)) {
matchedNodeIds.add(node.id)
}
})
topologyNodes.forEach((topologyNode) => {
const target = resourceNodeIds.get(resourceKey(topologyNode))
if (!target) {
return
}
const edgeId = `business-resource:${currentBusiness.id}->${target}`
if (resourceContainmentEdgeIds.has(edgeId)) {
return
}
resourceContainmentEdgeIds.add(edgeId)
const edge: BusinessTopologyFlowEdge = {
id: edgeId,
source: currentBusinessId,
target,
animated: false,
data: {
kind: 'resource_link',
label: '包含',
},
label: '包含',
}
edge.animated = shouldAnimateEdge(edge, matchedNodeIds, keyword)
edges.push(edge)
})
topologyLinks.forEach((link, index) => {
const source = resourceNodeIds.get(link.source_node_id)
const target = resourceNodeIds.get(link.target_node_id)
if (!source || !target) {
return
}
edges.push(createResourceLinkEdge(link, index, source, target, matchedNodeIds, keyword))
})
}
return { nodes, edges }
}
function normalizedHealthLevel(item: BusinessSystemItem): 'healthy' | 'degraded' | 'critical' | 'unknown' {
const level = item.health_level?.trim().toLowerCase()
if (level === 'healthy' || level === 'degraded' || level === 'critical') {
return level
}
if (typeof item.health_score === 'number' && !Number.isNaN(item.health_score)) {
return healthLevelFromScore(item.health_score)
}
return 'unknown'
}
function createCurrentBusinessNode(currentBusiness: BusinessSystemItem, health?: BusinessHealth | null): BusinessTopologyFlowNode {
const healthScore = health?.health.score ?? currentBusiness.health_score
const label = currentBusiness.name || currentBusiness.code || `业务系统 ${currentBusiness.id}`
return {
id: businessNodeId(currentBusiness.id),
type: 'businessTopology',
position: { x: 0, y: 0 },
data: {
kind: 'business',
label,
status: currentBusiness.status,
...(typeof healthScore === 'number' ? { healthScore } : {}),
businessSystem: currentBusiness,
},
}
}
function createDependencyBusinessNode(
businessId: number,
dependency: BusinessDependency,
index: number,
externalIndex: number,
): BusinessTopologyFlowNode {
const isUpstream = dependency.depends_on_business_system_id === businessId
const x = isUpstream ? 320 : -320
const y = (index + externalIndex) * 120 - 120
return {
id: businessNodeId(businessId),
type: 'businessTopology',
position: { x, y },
data: {
kind: 'business',
label: `业务系统 ${businessId}`,
status: dependency.criticality || dependency.dependency_type || 'unknown',
dependency,
},
}
}
function createResourceNode(topologyNode: BusinessTopologyNode, index: number): BusinessTopologyFlowNode {
const key = resourceKey(topologyNode)
return {
id: resourceNodeId(key),
type: 'businessTopology',
position: {
x: (index % 4) * 240 - 360,
y: 260 + Math.floor(index / 4) * 140,
},
data: {
kind: 'resource',
label: topologyNode.label || topologyNode.resource_uid || topologyNode.node_id,
status: topologyNode.status,
...(topologyNode.resource_uid ? { resourceUID: topologyNode.resource_uid } : {}),
...(topologyNode.type ? { resourceType: topologyNode.type } : {}),
topologyNode,
},
}
}
function createResourceLinkEdge(
link: BusinessTopologyEdge,
index: number,
source: string,
target: string,
matchedNodeIds: Set<string>,
keyword?: string,
): BusinessTopologyFlowEdge {
const edge: BusinessTopologyFlowEdge = {
id: `resource-link:${link.id ?? index}:${source}->${target}`,
source,
target,
animated: false,
data: {
kind: 'resource_link',
label: '关联',
topologyLink: link,
},
label: '关联',
}
edge.animated = shouldAnimateEdge(edge, matchedNodeIds, keyword)
return edge
}
function dependencyEdgeStyle(dependency: BusinessDependency): NonNullable<BusinessTopologyFlowEdge['style']> {
return {
stroke: dependencyCriticalityColor(dependency.criticality),
strokeWidth: 3,
}
}
function dependencyCriticalityColor(criticality?: string): string {
const normalized = criticality?.trim().toLowerCase()
if (!normalized) {
return '#f53f3f'
}
if (['critical', 'high', 'error', 'failed', 'failure'].includes(normalized)) {
return '#f53f3f'
}
if (['medium', 'warning', 'warn', 'degraded'].includes(normalized)) {
return '#faad14'
}
if (['low', 'healthy', 'normal', 'ok'].includes(normalized)) {
return '#1677ff'
}
return '#f53f3f'
}
function businessNodeId(id: number): string {
return `business:${id}`
}
function resourceNodeId(id: string): string {
return `resource:${id}`
}
function resourceKey(node: BusinessTopologyNode): string {
return node.node_id || node.resource_uid
}
function rememberResourceNodeIds(resourceNodeIds: Map<string, string>, node: BusinessTopologyNode, id: string) {
const aliases = [resourceKey(node), node.node_id, node.resource_uid]
aliases.forEach((alias) => {
if (alias && !resourceNodeIds.has(alias)) {
resourceNodeIds.set(alias, id)
}
})
}
function nodeMatchesKeyword(node: BusinessTopologyFlowNode, keyword: string): boolean {
const data = node.data
if (!data) {
return node.id.toLowerCase().includes(keyword)
}
const values: Array<string | number | undefined> = [node.id, data.label, data.status]
if (data.kind === 'business') {
values.push(data.businessSystem?.code, data.businessSystem?.name, data.dependency?.dependency_type, data.dependency?.description)
} else {
values.push(data.resourceUID, data.resourceType, data.topologyNode?.node_id)
}
return values.some((value) => value?.toString().toLowerCase().includes(keyword))
}
function shouldAnimateEdge(edge: BusinessTopologyFlowEdge, matchedNodeIds: Set<string>, keyword?: string): boolean {
if (!keyword) {
return false
}
return matchedNodeIds.has(edge.source) || matchedNodeIds.has(edge.target) || edgeMatchesKeyword(edge, keyword)
}
function edgeMatchesKeyword(edge: BusinessTopologyFlowEdge, keyword: string): boolean {
const data = edge.data
const values: Array<string | number | undefined> = [
edge.id,
edge.source,
edge.target,
typeof edge.label === 'string' ? edge.label : undefined,
data?.label,
data?.status,
]
if (data?.kind === 'business_dependency') {
values.push(
data.dependency?.id,
data.dependency?.business_system_id,
data.dependency?.depends_on_business_system_id,
data.dependency?.dependency_type,
data.dependency?.criticality,
data.dependency?.description,
)
}
if (data?.kind === 'resource_link') {
values.push(data.topologyLink?.id, data.topologyLink?.source_node_id, data.topologyLink?.target_node_id, data.topologyLink?.topology_id)
}
return values.some((value) => value?.toString().toLowerCase().includes(keyword))
}

View File

@@ -0,0 +1,67 @@
import type { Edge, Node } from '@vue-flow/core'
import type { BusinessDependency, BusinessSystemItem, BusinessTopologyEdge, BusinessTopologyNode } from '@/api/ops/businessSystem'
export type BusinessTopologyViewMode = 'business' | 'resource' | 'dependency'
export type BusinessTopologyHealthFilter = 'all' | 'healthy' | 'degraded' | 'critical' | 'unknown'
export type BusinessTopologyNodeKind = 'business' | 'resource'
export type BusinessTopologyEdgeKind = 'business_dependency' | 'resource_link'
export interface BusinessTopologyBusinessNodeData {
kind: 'business'
label: string
status: string
healthScore?: number
businessSystem?: BusinessSystemItem
dependency?: BusinessDependency
resourceUID?: never
resourceType?: never
topologyNode?: never
}
export interface BusinessTopologyResourceNodeData {
kind: 'resource'
label: string
status: string
resourceUID?: string
resourceType?: string
topologyNode?: BusinessTopologyNode
healthScore?: never
businessSystem?: never
dependency?: never
}
export type BusinessTopologyGraphNodeData = BusinessTopologyBusinessNodeData | BusinessTopologyResourceNodeData
export interface BusinessTopologyDependencyEdgeData {
kind: 'business_dependency'
label: string
status?: string
dependency?: BusinessDependency
topologyLink?: never
}
export interface BusinessTopologyResourceLinkEdgeData {
kind: 'resource_link'
label: string
status?: string
topologyLink?: BusinessTopologyEdge
dependency?: never
}
export type BusinessTopologyGraphEdgeData = BusinessTopologyDependencyEdgeData | BusinessTopologyResourceLinkEdgeData
export type BusinessTopologyFlowNode = Node<BusinessTopologyGraphNodeData, any, 'businessTopology'>
export type BusinessTopologyFlowEdge = Edge<BusinessTopologyGraphEdgeData>
export interface BusinessTopologyGraph {
nodes: BusinessTopologyFlowNode[]
edges: BusinessTopologyFlowEdge[]
}
export type BusinessTopologySelection =
| { type: 'node'; id: string; data: BusinessTopologyGraphNodeData }
| { type: 'edge'; id: string; data: BusinessTopologyGraphEdgeData }

View File

@@ -120,6 +120,12 @@
<icon-down />
</a-button>
<template #content>
<a-doption :disabled="isCollecting(record) || !record.server_identity" @click="handleCollectNow(record)">
<template #icon>
<icon-refresh />
</template>
{{ isCollecting(record) ? '采集中' : '立即采集' }}
</a-doption>
<a-doption @click="handleQuickConfig(record)">
<template #icon>
<icon-settings />
@@ -200,13 +206,7 @@ import ServerFormDialog from './components/ServerFormDialog.vue'
import QuickConfigDialog from './components/QuickConfigDialog.vue'
import HardwareDeviceConfigDialog from './components/HardwareDeviceConfigDialog.vue'
import { columns as columnsConfig } from './config/columns'
import { fetchServerList, deleteServer } from '@/api/ops/server'
import axios from 'axios'
// 创建独立的 axios 实例用于请求外部 agent绕过全局拦截器
const agentAxios = axios.create({
timeout: 5000,
})
import { fetchServerList, deleteServer, fetchServerMetricsSummary, collectServerMetricsNow, type HostMetricsSummary } from '@/api/ops/server'
const router = useRouter()
@@ -217,6 +217,7 @@ const formDialogVisible = ref(false)
const quickConfigVisible = ref(false)
const hardwareConfigVisible = ref(false)
const currentRecord = ref<any>(null)
const collectingMap = reactive<Record<string, boolean>>({})
const formModel = ref({
keyword: '',
collect_on: undefined as boolean | undefined,
@@ -394,6 +395,13 @@ const handleHardwareConfigSuccess = () => {
fetchServers()
}
const getCollectingKey = (record: any) => record.server_identity || String(record.id || '')
const isCollecting = (record: any) => {
const key = getCollectingKey(record)
return !!collectingMap[key]
}
// 编辑服务器
const handleEdit = (record: any) => {
currentRecord.value = record
@@ -465,74 +473,86 @@ const handleDelete = async (record: any) => {
})
}
/** dc-host `GET agent_config`(一般为 `/dc-host/stats`)返回裸 Metrics JSON */
// 获取所有服务器的监控指标
const resetMetrics = (record: any) => {
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
}
const applyMetricsSummary = (record: any, summary?: HostMetricsSummary) => {
if (!summary || !summary.has_data) {
resetMetrics(record)
return
}
record.cpu_info = {
value: Number((summary.cpu?.usage_percent || 0).toFixed(2)),
total: summary.cpu?.logical_cores_total ? `${summary.cpu.logical_cores_total}` : '',
used: '',
}
record.memory_info = {
value: Number((summary.memory?.used_percent || 0).toFixed(2)),
total: summary.memory?.total_bytes ? `${(summary.memory.total_bytes / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
used: summary.memory?.used_bytes ? `${(summary.memory.used_bytes / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
}
record.disk_info = {
value: Number((summary.disk_root?.used_percent || 0).toFixed(2)),
total: summary.disk_root?.total_bytes ? `${(summary.disk_root.total_bytes / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
used: summary.disk_root?.used_bytes ? `${(summary.disk_root.used_bytes / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
}
if (summary.timestamp) {
record.last_check_time = summary.timestamp
}
}
// 从 dc-control 获取所有服务器最新时序指标
const getAllMetrics = async () => {
try {
// 遍历每个服务器记录
const metricsPromises = tableData.value.map(async (record) => {
// 检查是否有 agent_config 配置
if (record.agent_config) {
try {
// 从 agent_config 中解析 URL
let metricsUrl = record.agent_config
// 验证 URL 是否合法
try {
new URL(metricsUrl)
} catch (urlError) {
console.warn(`服务器 ${record.name} 的 agent_config 不是合法的 URL:`, metricsUrl)
// 设置默认值 0
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
return
}
// 使用独立的 axios 实例请求外部 agent绕过全局拦截器
const response = await agentAxios.get(metricsUrl)
console.log('获取指标数据:', response.data)
if (response.data) {
// 更新记录的监控数据
record.cpu_info = {
value: Number((response.data.cpu_usage || 0).toFixed(2)),
total: response.data.cpu?.length ? `${response.data.cpu.length}` : '',
used: '',
}
record.memory_info = {
value: Number((response.data.mem_usage?.used_percent || 0).toFixed(2)),
total: response.data.mem_usage?.total ? `${(response.data.mem_usage.total / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
used: response.data.mem_usage?.used ? `${(response.data.mem_usage.used / 1024 / 1024 / 1024).toFixed(1)}GB` : '',
}
record.disk_info = {
value: Number((response.data.disk_usage?.used_percent || 0).toFixed(2)),
total: response.data.disk_usage?.total ? `${(response.data.disk_usage.total / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
used: response.data.disk_usage?.used ? `${(response.data.disk_usage.used / 1024 / 1024 / 1024).toFixed(0)}GB` : '',
}
}
} catch (error) {
console.warn(`获取服务器 ${record.name} 的监控指标失败:`, error)
// 初始化默认值
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
}
} else {
// 没有配置 agent设置默认值
record.cpu_info = { value: 0, total: '', used: '' }
record.memory_info = { value: 0, total: '', used: '' }
record.disk_info = { value: 0, total: '', used: '' }
if (!record.server_identity) {
resetMetrics(record)
return
}
try {
const response = await fetchServerMetricsSummary(record.server_identity)
applyMetricsSummary(record, response.details)
} catch (error) {
console.warn(`获取服务器 ${record.name} 的监控指标失败:`, error)
resetMetrics(record)
}
})
// 等待所有请求完成
await Promise.all(metricsPromises)
} catch (error) {
console.error('获取所有服务器监控指标失败:', error)
}
}
const handleCollectNow = async (record: any) => {
if (!record.server_identity) {
Message.warning('缺少服务器唯一标识,无法采集')
return
}
const key = getCollectingKey(record)
if (collectingMap[key]) {
return
}
collectingMap[key] = true
try {
const response = await collectServerMetricsNow(record.server_identity)
if (response.code === 0) {
applyMetricsSummary(record, response.details)
Message.success('采集完成')
} else {
Message.error(response.message || '采集失败')
}
} catch (error) {
console.error('立即采集失败:', error)
Message.error('采集失败')
} finally {
collectingMap[key] = false
}
}
onMounted(() => {
fetchServers()
})

View File

@@ -0,0 +1,54 @@
export type StatisticsReportOutputMode = 'scalar' | 'timeseries' | ''
export interface StatisticsReportViewContent {
outputMode: StatisticsReportOutputMode
scalarRows: Record<string, any>[]
seriesRows: Record<string, any>[]
hasContent: boolean
}
const toRows = (value: unknown): Record<string, any>[] => {
if (!Array.isArray(value)) return []
return value.filter((item): item is Record<string, any> => item !== null && typeof item === 'object' && !Array.isArray(item))
}
const normalizeOutputMode = (value: unknown): StatisticsReportOutputMode => {
if (value === 'scalar' || value === 'timeseries') return value
return ''
}
/** 将后端统计报表 payload 归一化为弹窗可渲染的数据结构。 */
export const normalizeStatisticsReportContent = (content: Record<string, any> | null | undefined): StatisticsReportViewContent => {
if (!content) {
return {
outputMode: '',
scalarRows: [],
seriesRows: [],
hasContent: false,
}
}
const scalarRows = toRows(content.rows)
const legacyScalarRows =
content.scalars && typeof content.scalars === 'object' && !Array.isArray(content.scalars) ? [content.scalars] : []
const seriesRows = toRows(content.series)
let outputMode = normalizeOutputMode(content.output_mode || content.params?.output_mode)
if (!outputMode) {
if (seriesRows.length > 0) {
outputMode = 'timeseries'
} else if (scalarRows.length > 0 || legacyScalarRows.length > 0) {
outputMode = 'scalar'
}
}
const normalizedScalarRows = scalarRows.length > 0 ? scalarRows : legacyScalarRows
const hasContent = outputMode === 'timeseries' ? seriesRows.length > 0 : normalizedScalarRows.length > 0
return {
outputMode,
scalarRows: normalizedScalarRows,
seriesRows,
hasContent,
}
}

View File

@@ -215,22 +215,23 @@
<div v-if="contentLoading" class="loading-container">
<a-spin />
</div>
<div v-else-if="reportContent">
<div v-else-if="normalizedReportContent.hasContent">
<!-- 标量结果 -->
<template v-if="reportContent.output_mode === 'scalar'">
<template v-if="normalizedReportContent.outputMode === 'scalar'">
<a-card title="统计结果" :bordered="false" class="summary-card">
<a-descriptions :column="3" bordered>
<a-descriptions-item v-for="(value, key) in reportContent.scalars" :key="key" :label="formatLabel(String(key))">
{{ formatValue(String(key), value) }}
</a-descriptions-item>
</a-descriptions>
<a-table
:data="normalizedReportContent.scalarRows"
:columns="scalarTableColumns"
:pagination="{ pageSize: 10 }"
stripe
/>
</a-card>
</template>
<!-- 时间序列结果 -->
<template v-else-if="reportContent.output_mode === 'timeseries'">
<template v-else-if="normalizedReportContent.outputMode === 'timeseries'">
<div ref="chartRef" class="chart-container"></div>
<a-table :data="reportContent.series || []" :columns="seriesTableColumns" :pagination="{ pageSize: 10 }" stripe />
<a-table :data="normalizedReportContent.seriesRows" :columns="seriesTableColumns" :pagination="{ pageSize: 10 }" stripe />
</template>
</div>
<a-empty v-else description="暂无数据" />
@@ -256,6 +257,7 @@ import * as echarts from 'echarts'
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions'
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
import { normalizeStatisticsReportContent } from './content'
const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } =
useReportTargetIdentityOptions()
@@ -399,19 +401,17 @@ const reportContent = ref<Record<string, any> | null>(null)
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
const normalizedReportContent = computed(() => normalizeStatisticsReportContent(reportContent.value))
// 标量结果表格列配置
const scalarTableColumns = computed(() =>
buildContentColumns(normalizedReportContent.value.scalarRows, ['target_identity', 'avg', 'max', 'min', 'sum', 'count']),
)
// 时间序列表格列配置
const seriesTableColumns = computed(() => [
{
title: '时间',
dataIndex: 'timestamp',
width: 180,
},
{
title: '值',
dataIndex: 'value',
width: 150,
},
])
const seriesTableColumns = computed(() =>
buildContentColumns(normalizedReportContent.value.seriesRows, ['target_identity', 'time', 'timestamp', 'value', 'count']),
)
// 获取报表列表
const fetchList = async () => {
@@ -604,17 +604,18 @@ const handleViewContent = async (record?: ReportRecord) => {
contentModalVisible.value = true
contentModalTitle.value = targetRecord.title
reportContent.value = null
let seriesToRender: Record<string, any>[] = []
try {
const res = await fetchReportContent(targetRecord.id)
if (res.code === 0 && res.details) {
reportContent.value = res.details
const normalized = normalizeStatisticsReportContent(res.details)
// 如果是时间序列模式,渲染图表
if (res.details.output_mode === 'timeseries' && res.details.series) {
await nextTick()
renderChart(res.details.series)
if (normalized.outputMode === 'timeseries' && normalized.seriesRows.length > 0) {
seriesToRender = normalized.seriesRows
}
} else {
Message.error(res.message || '获取报表内容失败')
@@ -625,6 +626,11 @@ const handleViewContent = async (record?: ReportRecord) => {
} finally {
contentLoading.value = false
}
if (seriesToRender.length > 0) {
await nextTick()
renderChart(seriesToRender)
}
}
// 导出报表
@@ -666,13 +672,15 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
}
// 格式化标签
const formatLabel = (key: string) => {
function formatLabel(key: string) {
const labelMap: Record<string, string> = {
avg: '平均值',
max: '最大值',
min: '最小值',
sum: '求和',
count: '计数',
target_identity: '目标标识',
time: '时间',
timestamp: '时间',
value: '值',
}
@@ -680,16 +688,18 @@ const formatLabel = (key: string) => {
return labelMap[key] || key
}
// 格式化数值
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '-'
function buildContentColumns(rows: Record<string, any>[], preferredKeys: string[]) {
const firstRow = rows[0]
if (!firstRow) return []
// 数字类型保留两位小数
if (typeof value === 'number') {
return value.toFixed(2)
}
const keys = Object.keys(firstRow)
const orderedKeys = [...preferredKeys.filter((key) => keys.includes(key)), ...keys.filter((key) => !preferredKeys.includes(key))]
return value
return orderedKeys.map((key) => ({
title: formatLabel(key),
dataIndex: key,
width: key === 'time' || key === 'timestamp' ? 180 : 150,
}))
}
// 渲染图表
@@ -715,7 +725,7 @@ const renderChart = (series: any[]) => {
xAxis: {
type: 'category',
boundaryGap: false,
data: series.map((item: any) => item.timestamp),
data: series.map((item: any) => item.time || item.timestamp),
},
yAxis: {
type: 'value',

View File

@@ -28,9 +28,13 @@
</a-row>
<a-card :bordered="false">
<template #title>资源上下文</template>
<template #title>资源</template>
<template #extra>
<a-space>
<a-button type="primary" :disabled="!canOperate || loading" @click="openCreateResource">
<template #icon><icon-plus /></template>
新增资源
</a-button>
<a-button :disabled="!canOperate || loading" @click="handleBackfill">
<template #icon><icon-refresh /></template>
回填资源
@@ -87,9 +91,17 @@
<a-tag :color="statusColor(record.status)">{{ record.status || 'unknown' }}</a-tag>
</template>
<template #actions="{ record }">
<a-button type="text" size="small" :disabled="pageUnauthorized" @click="openDetail(record)">
<template #icon><icon-eye /></template>
</a-button>
<a-space>
<a-button type="text" size="small" :disabled="pageUnauthorized" @click="openDetail(record)">
<template #icon><icon-eye /></template>
</a-button>
<a-button type="text" size="small" :disabled="!canOperate" @click="openEditResource(record)">
<template #icon><icon-edit /></template>
</a-button>
<a-button type="text" status="danger" size="small" :disabled="!canOperate" @click="handleDeleteResource(record)">
<template #icon><icon-delete /></template>
</a-button>
</a-space>
</template>
</a-table>
</PageState>
@@ -118,23 +130,71 @@
:pagination="false"
/>
</a-drawer>
<a-modal
v-model:visible="resourceFormVisible"
:title="resourceFormTitle"
:confirm-loading="resourceFormSubmitting"
:on-before-ok="submitResourceForm"
unmount-on-close
@cancel="resetResourceForm"
>
<a-form :model="resourceForm" layout="vertical">
<a-form-item field="resource_category" label="资源分类" required>
<a-select v-model="resourceForm.resource_category" allow-search placeholder="请选择资源分类" :options="resourceTypeOptions" />
</a-form-item>
<a-form-item field="service_identity" label="服务标识" required>
<a-input v-model="resourceForm.service_identity" placeholder="例如 his-app-01" />
</a-form-item>
<a-form-item field="display_name" label="展示名称" required>
<a-input v-model="resourceForm.display_name" placeholder="例如 HIS 应用服务 01" />
</a-form-item>
<a-form-item field="status" label="状态" required>
<a-select v-model="resourceForm.status" placeholder="请选择状态">
<a-option value="normal">normal</a-option>
<a-option value="online">online</a-option>
<a-option value="offline">offline</a-option>
<a-option value="error">error</a-option>
<a-option value="unknown">unknown</a-option>
</a-select>
</a-form-item>
<a-form-item field="business_system_id" label="业务系统">
<a-select v-model="resourceForm.business_system_id" allow-clear allow-search placeholder="可选">
<a-option v-for="item in businessSystems" :key="item.id" :value="item.id">{{ item.name }}</a-option>
</a-select>
</a-form-item>
<a-form-item field="asset_id" label="资产 ID">
<a-input-number v-model="resourceForm.asset_id" :min="1" :precision="0" placeholder="可选" />
</a-form-item>
<a-form-item field="tags" label="标签">
<a-input v-model="resourceForm.tags" placeholder="多个标签用逗号分隔" />
</a-form-item>
<a-form-item field="description" label="描述">
<a-textarea v-model="resourceForm.description" :auto-size="{ minRows: 3, maxRows: 6 }" placeholder="请输入资源说明" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconEye, IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
import { Message, Modal } from '@arco-design/web-vue'
import { IconDelete, IconEdit, IconEye, IconPlus, IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
backfillControlResources,
createControlResource,
deleteControlResource,
fetchControlBusinessSystems,
fetchControlMetricSeries,
fetchControlResources,
fetchControlResourceTypes,
updateControlResource,
type ControlBusinessSystem,
type ControlMetricSeries,
type ControlResource,
type ControlResourcePayload,
type ControlResourceType,
} from '@/api/ops/dcControl'
@@ -150,6 +210,28 @@ const resourceTypes = ref<ControlResourceType[]>([])
const businessSystems = ref<ControlBusinessSystem[]>([])
const currentResource = ref<ControlResource | null>(null)
const selectedMetricTotal = ref(0)
const resourceFormVisible = ref(false)
const resourceFormSubmitting = ref(false)
const editingResource = ref<ControlResource | null>(null)
const resourceForm = reactive<ControlResourcePayload>({
resource_category: '',
service_identity: '',
display_name: '',
status: 'normal',
business_system_id: undefined,
owner_user_id: undefined,
department_id: undefined,
asset_id: undefined,
datacenter_id: undefined,
room_id: undefined,
rack_id: undefined,
unit_start: undefined,
unit_end: undefined,
location_ref: '',
tags: '',
description: '',
})
const filters = reactive({
keyword: '',
@@ -173,7 +255,7 @@ const resourceColumns: TableColumnData[] = [
{ title: '责任人', dataIndex: 'owner_user_id', slotName: 'owner_user_id', width: 100 },
{ title: '资产', dataIndex: 'asset_id', slotName: 'asset_id', width: 100 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 110 },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 150, fixed: 'right' },
]
const metricColumns: TableColumnData[] = [
@@ -194,6 +276,62 @@ const resourceTypeOptions = computed(() =>
const canOperate = computed(() => !pageUnauthorized.value)
const resourceFormTitle = computed(() => (editingResource.value ? '编辑资源' : '新增资源'))
const resetResourceForm = () => {
editingResource.value = null
Object.assign(resourceForm, {
resource_category: '',
service_identity: '',
display_name: '',
status: 'normal',
business_system_id: undefined,
owner_user_id: undefined,
department_id: undefined,
asset_id: undefined,
datacenter_id: undefined,
room_id: undefined,
rack_id: undefined,
unit_start: undefined,
unit_end: undefined,
location_ref: '',
tags: '',
description: '',
})
}
const normalizeNullableNumber = (value: unknown) => {
const numberValue = Number(value)
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : null
}
const buildResourcePayload = (): ControlResourcePayload => {
const locationRef = resourceForm.location_ref?.trim()
const payload: ControlResourcePayload = {
resource_category: resourceForm.resource_category?.trim(),
service_identity: resourceForm.service_identity?.trim(),
display_name: resourceForm.display_name?.trim(),
status: resourceForm.status?.trim() || 'normal',
business_system_id: normalizeNullableNumber(resourceForm.business_system_id),
owner_user_id: normalizeNullableNumber(resourceForm.owner_user_id),
department_id: normalizeNullableNumber(resourceForm.department_id),
asset_id: normalizeNullableNumber(resourceForm.asset_id),
datacenter_id: normalizeNullableNumber(resourceForm.datacenter_id),
room_id: normalizeNullableNumber(resourceForm.room_id),
rack_id: normalizeNullableNumber(resourceForm.rack_id),
unit_start: normalizeNullableNumber(resourceForm.unit_start),
unit_end: normalizeNullableNumber(resourceForm.unit_end),
tags: resourceForm.tags?.trim(),
description: resourceForm.description?.trim(),
}
if (locationRef) {
payload.location_ref = locationRef
}
return payload
}
const pageStatus = computed(() => {
if (pageUnauthorized.value) return 'unauthorized'
if (loading.value) return 'loading'
@@ -313,6 +451,85 @@ const openDetail = (record: ControlResource) => {
fetchMetricSeries(record.resource_uid)
}
const openCreateResource = () => {
resetResourceForm()
resourceFormVisible.value = true
}
const openEditResource = (record: ControlResource) => {
editingResource.value = record
Object.assign(resourceForm, {
resource_category: record.resource_category || '',
service_identity: record.service_identity || '',
display_name: record.display_name || '',
status: record.status || 'normal',
business_system_id: record.business_system_id,
owner_user_id: record.owner_user_id,
department_id: record.department_id,
asset_id: record.asset_id,
datacenter_id: record.datacenter_id,
room_id: record.room_id,
rack_id: record.rack_id,
unit_start: record.unit_start,
unit_end: record.unit_end,
location_ref: record.location_ref || '',
tags: record.tags || '',
description: record.description || '',
})
resourceFormVisible.value = true
}
const submitResourceForm = async () => {
const payload = buildResourcePayload()
if (!payload.resource_category || !payload.service_identity || !payload.display_name) {
Message.warning('请填写资源分类、服务标识和展示名称')
return false
}
resourceFormSubmitting.value = true
try {
if (editingResource.value) {
await updateControlResource(editingResource.value.id, payload)
Message.success('资源已更新')
} else {
await createControlResource(payload)
Message.success('资源已新增')
}
await fetchResources()
return true
} catch (error) {
console.error('保存资源失败:', error)
Message.error('保存资源失败')
return false
} finally {
resourceFormSubmitting.value = false
}
}
const handleDeleteResource = (record: ControlResource) => {
Modal.warning({
title: '删除资源',
content: `确认删除资源「${record.display_name || '-'} / ${record.resource_uid} / ${resourceTypeName(record.resource_category)}」?这会删除资源库中的全局资源,可能影响业务拓扑、告警和指标序列。`,
hideCancel: false,
okText: '删除',
cancelText: '取消',
onOk: async () => {
try {
await deleteControlResource(record.id)
Message.success('资源已删除')
if (currentResource.value?.id === record.id) {
detailVisible.value = false
currentResource.value = null
}
await fetchResources()
} catch (error) {
console.error('删除资源失败:', error)
Message.error('删除资源失败')
}
},
})
}
const handleBackfill = async () => {
try {
await backfillControlResources()