Compare commits
21 Commits
task-18-au
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 922f7dfc4b | |||
| ee101984f0 | |||
| 3f4d50b395 | |||
| 58365870ef | |||
| aa6db97457 | |||
| f5d0bae89b | |||
| cc6ea1acc6 | |||
| 7a20329594 | |||
| 83b713890b | |||
| c3f38f5e2a | |||
| c6d0df6fd2 | |||
| 93e8f3349f | |||
| fc5d653433 | |||
| fafdb0d830 | |||
| 99a5f8bd16 | |||
| 9f1c3eb660 | |||
| 1ad0323715 | |||
| b01a0f0979 | |||
| 8f3dd3e43e | |||
| 2107f77081 | |||
| fcaad4b3ae |
@@ -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
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Architect Mode Rules (Non-Obvious Only)
|
||||
|
||||
## Development Constraints
|
||||
|
||||
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- Vue 3 SPA with dynamic route loading from server
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Code Mode Rules (Non-Obvious Only)
|
||||
|
||||
## Development Constraints
|
||||
|
||||
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
|
||||
|
||||
## API Layer
|
||||
|
||||
- Two axios instances exist: [`request.ts`](src/api/request.ts) (custom with workspace header) and [`interceptor.ts`](src/api/interceptor.ts) (global with Bearer token). Choose based on whether you need workspace support.
|
||||
|
||||
@@ -13,6 +13,10 @@ This file provides guidance to agents when working with code in this repository.
|
||||
|
||||
## Critical Architecture Notes
|
||||
|
||||
### Development Constraints
|
||||
|
||||
- Code is not deployed yet; do not spend effort preserving backward compatibility with old code.
|
||||
|
||||
### Vite Config Location
|
||||
|
||||
Config files are in `config/` directory, NOT root. All vite commands reference `./config/vite.config.*.ts`.
|
||||
|
||||
47
README.md
47
README.md
@@ -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`:全工作区模块边界与文档入口。
|
||||
|
||||
@@ -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
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
less: true
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -51,8 +51,17 @@
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 表格 -->
|
||||
<!-- 表格主体:状态占位与表格分离,工具栏始终展示 -->
|
||||
<PageState
|
||||
:status="status"
|
||||
:loading-text="loadingText"
|
||||
:empty-text="emptyText"
|
||||
:error-text="errorText"
|
||||
:partial-text="partialText"
|
||||
@retry="emit('retry')"
|
||||
>
|
||||
<a-table
|
||||
v-if="status === 'success' || status === 'partial'"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@@ -72,6 +81,7 @@
|
||||
<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')
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
setMessageStatus,
|
||||
} from '@/api/message'
|
||||
import useLoading from '@/hooks/loading'
|
||||
import useNotificationSocket from '@/hooks/useNotificationSocket'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { computed, reactive, ref, toRefs } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -111,6 +112,9 @@ async function fetchSourceData() {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const upsertMessageToTop = (message: MessageRecord) => {
|
||||
messageData.messageList = [message, ...messageData.messageList.filter((item) => item.id !== message.id)]
|
||||
}
|
||||
async function readMessage(data: MessageListType) {
|
||||
const ids = data.map((item) => item.id)
|
||||
await setMessageStatus({ ids })
|
||||
@@ -190,6 +194,10 @@ const emptyList = () => {
|
||||
messageData.messageList = []
|
||||
}
|
||||
fetchSourceData()
|
||||
useNotificationSocket<MessageRecord>({
|
||||
onNotification: upsertMessageToTop,
|
||||
onReconnectAfter: fetchSourceData,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<!-- <li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.navbar.alerts')">
|
||||
<div class="message-box-trigger">
|
||||
<a-badge :count="9" dot>
|
||||
<a-badge dot>
|
||||
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="setPopoverVisible">
|
||||
<icon-notification />
|
||||
</a-button>
|
||||
@@ -70,7 +70,7 @@
|
||||
<message-box />
|
||||
</template>
|
||||
</a-popover>
|
||||
</li> -->
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="isFullscreen ? t('settings.navbar.screen.toExit') : t('settings.navbar.screen.toFull')">
|
||||
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="toggleFullScreen">
|
||||
|
||||
@@ -19,17 +19,8 @@
|
||||
|
||||
<a-divider style="margin-top: 0" />
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<PageState
|
||||
:status="stateStatus"
|
||||
:empty-text="emptyText"
|
||||
:error-text="errorText"
|
||||
:partial-text="partialText"
|
||||
:loading-text="loadingText"
|
||||
@retry="handleRefresh"
|
||||
>
|
||||
<!-- 数据表格:工具栏始终展示,表格主体随状态切换 -->
|
||||
<DataTable
|
||||
v-if="stateStatus !== 'empty' && stateStatus !== 'error' && stateStatus !== 'unauthorized'"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
@@ -46,6 +37,11 @@
|
||||
: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"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
@selection-change="handleSelectionChange"
|
||||
@@ -54,6 +50,7 @@
|
||||
@download="handleDownload"
|
||||
@density-change="handleDensityChange"
|
||||
@column-change="handleColumnChange"
|
||||
@retry="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<slot name="toolbar-left" />
|
||||
@@ -66,7 +63,6 @@
|
||||
<slot :name="col.slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</PageState>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
126
src/hooks/useNotificationSocket.ts
Normal file
126
src/hooks/useNotificationSocket.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
interface NotificationSocketMessage<T> {
|
||||
type: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
interface UseNotificationSocketOptions<T> {
|
||||
onNotification: (data: T) => void
|
||||
onReconnectAfter?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const MAX_RECONNECT_ATTEMPTS = 8
|
||||
const MAX_RECONNECT_DELAY = 30_000
|
||||
const RECONNECT_STEP_DELAY = 1_000
|
||||
|
||||
function buildNotificationSocketUrl() {
|
||||
const token = SafeStorage.get<string>(AppStorageKey.TOKEN)
|
||||
if (!token) return null
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin
|
||||
const url = new URL(baseUrl, window.location.origin)
|
||||
const basePath = url.pathname.replace(/\/$/, '')
|
||||
|
||||
url.pathname = `${basePath}/Alert/v1/message/ws`
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
|
||||
// WebSocket 无法直接设置 Authorization header;如后端仍依赖 header 认证,
|
||||
// 需要后端适配 query token 或签发一次性 ws ticket。
|
||||
url.searchParams.set('token', token)
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export default function useNotificationSocket<T>(options: UseNotificationSocketOptions<T>) {
|
||||
let socket: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
let isManualClose = false
|
||||
let isReconnecting = false
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (!reconnectTimer) return
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
const closeSocket = () => {
|
||||
if (!socket) return
|
||||
socket.onopen = null
|
||||
socket.onmessage = null
|
||||
socket.onerror = null
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (isManualClose || reconnectTimer || reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) return
|
||||
|
||||
reconnectAttempt += 1
|
||||
isReconnecting = true
|
||||
|
||||
const delay = Math.min(reconnectAttempt * RECONNECT_STEP_DELAY, MAX_RECONNECT_DELAY)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (isManualClose) return
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as NotificationSocketMessage<T>
|
||||
if (message.type === 'notification' && message.data) {
|
||||
options.onNotification(message.data)
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略无法识别的消息,避免单条异常数据中断后续通知接收。
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (isManualClose) return
|
||||
|
||||
const socketUrl = buildNotificationSocketUrl()
|
||||
if (!socketUrl) return
|
||||
|
||||
closeSocket()
|
||||
socket = new WebSocket(socketUrl)
|
||||
|
||||
socket.onopen = () => {
|
||||
reconnectAttempt = 0
|
||||
if (isReconnecting) {
|
||||
isReconnecting = false
|
||||
options.onReconnectAfter?.()
|
||||
}
|
||||
}
|
||||
socket.onmessage = handleMessage
|
||||
socket.onerror = () => {
|
||||
socket?.close()
|
||||
}
|
||||
socket.onclose = () => {
|
||||
socket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
isManualClose = true
|
||||
clearReconnectTimer()
|
||||
closeSocket()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isManualClose = false
|
||||
connect()
|
||||
})
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '系统日志',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
1138
src/views/ops/pages/business-topology/index.vue
Normal file
1138
src/views/ops/pages/business-topology/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
418
src/views/ops/pages/business-topology/transform.ts
Normal file
418
src/views/ops/pages/business-topology/transform.ts
Normal 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))
|
||||
}
|
||||
67
src/views/ops/pages/business-topology/types.ts
Normal file
67
src/views/ops/pages/business-topology/types.ts
Normal 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 }
|
||||
343
src/views/ops/pages/dc/components/SnmpOidEditor.vue
Normal file
343
src/views/ops/pages/dc/components/SnmpOidEditor.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<a-form-item field="snmp_oids">
|
||||
<template #label>
|
||||
<div class="snmp-oid-label">
|
||||
<span>SNMP OID配置</span>
|
||||
<div class="snmp-oid-actions">
|
||||
<a-button size="mini" type="text" @click="switchEditMode">
|
||||
{{ editMode === 'table' ? 'JSON' : '表格' }}
|
||||
</a-button>
|
||||
<a-button v-if="editMode === 'table'" size="mini" type="primary" @click="addRow">添加</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="editMode === 'table'">
|
||||
<a-table :data="rows" :pagination="false" size="small" class="snmp-oid-table">
|
||||
<template #columns>
|
||||
<a-table-column title="OID" data-index="oid" :width="220">
|
||||
<template #cell="{ record }">
|
||||
<a-input v-model="record.oid" @input="syncJsonFromRows" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="指标名称" data-index="metric_name" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-input v-model="record.metric_name" @input="syncJsonFromRows" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="指标unit" data-index="metric_unit" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<a-input v-model="record.metric_unit" @input="syncJsonFromRows" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="类型" data-index="type" :width="100">
|
||||
<template #cell="{ record }">
|
||||
<a-input v-model="record.type" @input="syncJsonFromRows" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="80">
|
||||
<template #cell="{ rowIndex }">
|
||||
<a-button size="mini" type="text" status="danger" @click="removeRow(rowIndex)">删除</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<a-textarea
|
||||
v-else
|
||||
v-model="jsonText"
|
||||
:rows="5"
|
||||
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
|
||||
@input="emitJsonText"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
resetKey?: number
|
||||
}
|
||||
|
||||
type EditMode = 'table' | 'json'
|
||||
|
||||
interface SnmpOidRow {
|
||||
oid: string
|
||||
metric_name: string
|
||||
metric_unit: string
|
||||
type: string
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const tableFields = ['oid', 'metric_name', 'metric_unit', 'type']
|
||||
const editMode = ref<EditMode>('table')
|
||||
const rows = ref<SnmpOidRow[]>([])
|
||||
const jsonText = ref('')
|
||||
|
||||
const createEmptyRow = (): SnmpOidRow => ({
|
||||
oid: '',
|
||||
metric_name: '',
|
||||
metric_unit: '',
|
||||
type: '',
|
||||
})
|
||||
|
||||
const isJsonRecord = (value: unknown): value is JsonRecord =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const parseJsonArray = (raw: string): unknown[] => {
|
||||
const text = raw.trim()
|
||||
if (!text) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('SNMP OID 配置必须是合法 JSON 数组')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const hasUnsupportedFields = (items: unknown[]) =>
|
||||
items.some((item) => {
|
||||
if (!isJsonRecord(item)) {
|
||||
return true
|
||||
}
|
||||
return Object.keys(item).some((key) => !tableFields.includes(key))
|
||||
})
|
||||
|
||||
const normalizeRows = (sourceRows: SnmpOidRow[]) =>
|
||||
sourceRows
|
||||
.map((row) => ({
|
||||
oid: row.oid.trim(),
|
||||
metric_name: row.metric_name.trim(),
|
||||
metric_unit: row.metric_unit.trim(),
|
||||
type: row.type.trim(),
|
||||
}))
|
||||
.filter((row) => row.oid || row.metric_name || row.metric_unit || row.type)
|
||||
|
||||
const findIncompleteRowIndex = (sourceRows: SnmpOidRow[]) =>
|
||||
sourceRows.findIndex((row) => {
|
||||
const normalizedRow = {
|
||||
oid: row.oid.trim(),
|
||||
metric_name: row.metric_name.trim(),
|
||||
metric_unit: row.metric_unit.trim(),
|
||||
type: row.type.trim(),
|
||||
}
|
||||
const hasAnyValue =
|
||||
normalizedRow.oid || normalizedRow.metric_name || normalizedRow.metric_unit || normalizedRow.type
|
||||
return !!hasAnyValue && (!normalizedRow.oid || !normalizedRow.metric_name)
|
||||
})
|
||||
|
||||
const findInvalidJsonRowIndex = (items: unknown[]) =>
|
||||
items.findIndex((item) => {
|
||||
if (!isJsonRecord(item)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
typeof item.oid !== 'string' ||
|
||||
typeof item.metric_name !== 'string' ||
|
||||
!item.oid.trim() ||
|
||||
!item.metric_name.trim()
|
||||
)
|
||||
})
|
||||
|
||||
const normalizeJsonItems = (items: unknown[]) =>
|
||||
items.map((item) => {
|
||||
const record = item as JsonRecord
|
||||
return {
|
||||
...record,
|
||||
oid: String(record.oid ?? '').trim(),
|
||||
metric_name: String(record.metric_name ?? '').trim(),
|
||||
metric_unit: typeof record.metric_unit === 'string' ? record.metric_unit.trim() : record.metric_unit,
|
||||
type: typeof record.type === 'string' ? record.type.trim() : record.type,
|
||||
}
|
||||
})
|
||||
|
||||
const parseRows = (raw: string): SnmpOidRow[] =>
|
||||
parseJsonArray(raw).map((item) => {
|
||||
const record = isJsonRecord(item) ? item : {}
|
||||
return {
|
||||
oid: typeof record.oid === 'string' ? record.oid.trim() : '',
|
||||
metric_name: typeof record.metric_name === 'string' ? record.metric_name.trim() : '',
|
||||
metric_unit: typeof record.metric_unit === 'string' ? record.metric_unit.trim() : '',
|
||||
type: typeof record.type === 'string' ? record.type.trim() : '',
|
||||
}
|
||||
})
|
||||
|
||||
const stringifyRows = (sourceRows: SnmpOidRow[]) => {
|
||||
const normalizedRows = normalizeRows(sourceRows)
|
||||
return normalizedRows.length ? JSON.stringify(normalizedRows, null, 2) : ''
|
||||
}
|
||||
|
||||
const emitValue = (value: string) => {
|
||||
jsonText.value = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const syncRowsFromJson = () => {
|
||||
rows.value = parseRows(jsonText.value)
|
||||
}
|
||||
|
||||
const syncJsonFromRows = () => {
|
||||
emitValue(stringifyRows(rows.value))
|
||||
}
|
||||
|
||||
const emitJsonText = () => {
|
||||
emit('update:modelValue', jsonText.value)
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
if (editMode.value !== 'table') {
|
||||
return
|
||||
}
|
||||
|
||||
rows.value.push(createEmptyRow())
|
||||
syncJsonFromRows()
|
||||
}
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
rows.value.splice(index, 1)
|
||||
syncJsonFromRows()
|
||||
}
|
||||
|
||||
const switchEditMode = () => {
|
||||
if (editMode.value === 'table') {
|
||||
syncJsonFromRows()
|
||||
editMode.value = 'json'
|
||||
return
|
||||
}
|
||||
|
||||
let items: unknown[]
|
||||
try {
|
||||
items = parseJsonArray(jsonText.value)
|
||||
} catch {
|
||||
Message.warning('SNMP OID 配置必须是合法 JSON 数组,修正后才能切换到表格模式')
|
||||
return
|
||||
}
|
||||
|
||||
if (hasUnsupportedFields(items)) {
|
||||
Message.warning('当前 SNMP OID JSON 包含表格不支持的字段,请在 JSON 模式下编辑')
|
||||
return
|
||||
}
|
||||
|
||||
syncRowsFromJson()
|
||||
editMode.value = 'table'
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
if (editMode.value === 'table') {
|
||||
const invalidIndex = findIncompleteRowIndex(rows.value)
|
||||
if (invalidIndex >= 0) {
|
||||
Message.warning(`SNMP OID 第 ${invalidIndex + 1} 行请填写 OID 和指标名称`)
|
||||
return false
|
||||
}
|
||||
|
||||
syncJsonFromRows()
|
||||
return true
|
||||
}
|
||||
|
||||
if (!jsonText.value.trim()) {
|
||||
rows.value = []
|
||||
emitValue('')
|
||||
return true
|
||||
}
|
||||
|
||||
let parsedItems: unknown[]
|
||||
try {
|
||||
parsedItems = parseJsonArray(jsonText.value)
|
||||
} catch {
|
||||
Message.warning('SNMP OID 配置必须是合法 JSON 数组')
|
||||
return false
|
||||
}
|
||||
|
||||
const invalidIndex = findInvalidJsonRowIndex(parsedItems)
|
||||
if (invalidIndex >= 0) {
|
||||
Message.warning(`SNMP OID 第 ${invalidIndex + 1} 行请填写 OID 和指标名称`)
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedItems = normalizeJsonItems(parsedItems)
|
||||
rows.value = parseRows(JSON.stringify(normalizedItems))
|
||||
emitValue(normalizedItems.length ? JSON.stringify(normalizedItems, null, 2) : '')
|
||||
return true
|
||||
}
|
||||
|
||||
const resetFromModelValue = () => {
|
||||
jsonText.value = props.modelValue ?? ''
|
||||
|
||||
if (!jsonText.value.trim()) {
|
||||
editMode.value = 'table'
|
||||
rows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const items = parseJsonArray(jsonText.value)
|
||||
if (hasUnsupportedFields(items)) {
|
||||
editMode.value = 'json'
|
||||
rows.value = []
|
||||
return
|
||||
}
|
||||
|
||||
rows.value = parseRows(jsonText.value)
|
||||
editMode.value = 'table'
|
||||
} catch {
|
||||
editMode.value = 'json'
|
||||
rows.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if ((value ?? '') !== jsonText.value) {
|
||||
resetFromModelValue()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
resetFromModelValue()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
resetFromModelValue()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
validate,
|
||||
resetFromModelValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.snmp-oid-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.snmp-oid-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snmp-oid-table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -140,13 +140,11 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置(JSON数组)">
|
||||
<a-textarea
|
||||
<SnmpOidEditor
|
||||
ref="snmpOidEditorRef"
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
|
||||
:reset-key="snmpOidEditorResetKey"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-row :gutter="20">
|
||||
@@ -194,6 +192,7 @@ import {
|
||||
} from '@/api/ops/room-device'
|
||||
import { fetchPolicyOptions, type PolicyOptionItem } from '@/api/ops/alertPolicy'
|
||||
import { fetchRoomOptions, type RoomOptionItem } from '@/api/ops/room'
|
||||
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -207,6 +206,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
|
||||
const snmpOidEditorResetKey = ref(0)
|
||||
const confirmLoading = ref(false)
|
||||
const policyOptions = ref<PolicyOptionItem[]>([])
|
||||
const roomOptions = ref<RoomOptionItem[]>([])
|
||||
@@ -309,6 +310,7 @@ watch(
|
||||
collect_interval: props.record.collect_interval || 60,
|
||||
policy_ids: props.record.policy_ids || [],
|
||||
})
|
||||
snmpOidEditorResetKey.value += 1
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
@@ -336,6 +338,7 @@ watch(
|
||||
collect_interval: 60,
|
||||
policy_ids: [],
|
||||
})
|
||||
snmpOidEditorResetKey.value += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,15 +373,10 @@ const handleOk = async () => {
|
||||
Message.warning('SNMP v2c 模式下请填写 community')
|
||||
return
|
||||
}
|
||||
if (formData.snmp_oids?.trim()) {
|
||||
try {
|
||||
JSON.parse(formData.snmp_oids)
|
||||
} catch {
|
||||
Message.warning('SNMP OID 配置必须是合法 JSON')
|
||||
if (!snmpOidEditorRef.value?.validate()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" auto-label-width>
|
||||
<a-alert type="info" show-icon class="mb-12">
|
||||
采集协议固定为 SNMP + SSH 双协议,请同时完成两部分配置。编辑时密码不回显,留空表示不修改。
|
||||
采集协议固定为 SNMP;仅在勾选“采集FIB信息”时需要填写 SSH。编辑时密码不回显,留空表示不修改。
|
||||
</a-alert>
|
||||
<a-divider orientation="left">基础信息</a-divider>
|
||||
<a-row :gutter="20">
|
||||
@@ -75,7 +75,7 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="采集协议"><a-tag color="purple">SNMP + SSH(固定)</a-tag></a-form-item>
|
||||
<a-form-item label="采集协议"><a-tag color="purple">SNMP + SSH(FIB可选)</a-tag></a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
@@ -152,20 +152,24 @@
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="left">SSH 配置(必填)</a-divider>
|
||||
<a-divider orientation="left">
|
||||
{{ fibEnabled ? 'SSH 配置(采集 FIB 时必填)' : 'SSH 配置(未采集 FIB 时可选)' }}
|
||||
</a-divider>
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="ssh_port" label="SSH端口" required>
|
||||
<a-form-item field="ssh_port" label="SSH端口" :required="fibEnabled">
|
||||
<a-input-number v-model="formData.ssh.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="ssh_username" label="SSH用户名" required><a-input v-model="formData.ssh.username" /></a-form-item>
|
||||
<a-form-item field="ssh_username" label="SSH用户名" :required="fibEnabled">
|
||||
<a-input v-model="formData.ssh.username" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="ssh_password" label="SSH密码" required>
|
||||
<a-form-item field="ssh_password" label="SSH密码" :required="fibEnabled">
|
||||
<a-input-password v-model="formData.ssh.password" :placeholder="isEdit ? '留空表示不修改' : '请输入 SSH 密码'" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -259,12 +263,23 @@ const defaults = () => ({
|
||||
collect: { interface_enabled: true, arp_enabled: true, route_enabled: true, fib_enabled: true, timeout_sec: 5, retries: 1 },
|
||||
})
|
||||
const formData = reactive(defaults())
|
||||
const fibEnabled = computed(() => formData.collect.fib_enabled)
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入设备名称' }],
|
||||
category: [{ required: true, message: '请选择设备类型' }],
|
||||
host: [{ required: true, message: '请输入设备地址' }],
|
||||
community: [{ required: true, message: '请输入 SNMP community' }],
|
||||
ssh_username: [{ required: true, message: '请输入 SSH 用户名' }],
|
||||
ssh_username: [
|
||||
{
|
||||
validator: (value: string | undefined, callback: (error?: string) => void) => {
|
||||
if (formData.collect.fib_enabled && !String(value || '').trim()) {
|
||||
callback('请输入 SSH 用户名')
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -322,6 +337,15 @@ watch(tagList, (tags) => {
|
||||
formData.tags = tags.join(',')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => formData.collect.fib_enabled,
|
||||
(enabled) => {
|
||||
if (!enabled) {
|
||||
;(formRef.value as any)?.clearValidate?.(['ssh_port', 'ssh_username', 'ssh_password'])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
@@ -343,14 +367,16 @@ const handleOk = async () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (formData.collect.fib_enabled) {
|
||||
if (!formData.ssh.username.trim()) {
|
||||
Message.warning('请填写 SSH 用户名')
|
||||
return
|
||||
}
|
||||
if (!isEdit.value && !formData.ssh.password.trim()) {
|
||||
Message.warning('请填写 SSH 密码')
|
||||
if (!isEdit.value && !formData.ssh.password.trim() && !formData.ssh.private_key.trim()) {
|
||||
Message.warning('请填写 SSH 密码或 SSH 私钥')
|
||||
return
|
||||
}
|
||||
}
|
||||
confirmLoading.value = true
|
||||
const payload: any = {
|
||||
...formData,
|
||||
|
||||
@@ -157,13 +157,11 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置(JSON数组)">
|
||||
<a-textarea
|
||||
<SnmpOidEditor
|
||||
ref="snmpOidEditorRef"
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime","metric_unit":"timeticks"}]'
|
||||
:reset-key="snmpOidEditorResetKey"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-row :gutter="20">
|
||||
@@ -214,6 +212,7 @@ import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { createSecurityService, updateSecurityService, SECURITY_TYPE_OPTIONS, type SecurityServiceFormData } from '@/api/ops/security'
|
||||
import { fetchPolicyOptions, type PolicyOptionItem } from '@/api/ops/alertPolicy'
|
||||
import { fetchServerList, type ServerItem } from '@/api/ops/server'
|
||||
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -227,6 +226,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
|
||||
const snmpOidEditorResetKey = ref(0)
|
||||
const confirmLoading = ref(false)
|
||||
const policyOptions = ref<PolicyOptionItem[]>([])
|
||||
const serverOptions = ref<ServerItem[]>([])
|
||||
@@ -336,6 +337,7 @@ watch(
|
||||
extra: props.record.extra || '',
|
||||
policy_ids: props.record.policy_ids || [],
|
||||
})
|
||||
snmpOidEditorResetKey.value += 1
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
service_identity: '',
|
||||
@@ -368,6 +370,7 @@ watch(
|
||||
extra: '',
|
||||
policy_ids: [],
|
||||
})
|
||||
snmpOidEditorResetKey.value += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,15 +405,10 @@ const handleOk = async () => {
|
||||
Message.warning('SNMP v2c 模式下请填写 community')
|
||||
return
|
||||
}
|
||||
if (formData.snmp_oids?.trim()) {
|
||||
try {
|
||||
JSON.parse(formData.snmp_oids)
|
||||
} catch {
|
||||
Message.warning('SNMP OID配置必须是合法 JSON')
|
||||
if (!snmpOidEditorRef.value?.validate()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export const columns = [
|
||||
title: '类型',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
dataIndex: 'asset_id',
|
||||
title: '关联资产',
|
||||
width: 100,
|
||||
slotName: 'asset_id',
|
||||
},
|
||||
{
|
||||
dataIndex: 'location',
|
||||
title: '位置',
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
{{ record.id }}
|
||||
</template>
|
||||
|
||||
<!-- 关联资产 -->
|
||||
<template #asset_id="{ record }">
|
||||
<a-tag :color="record.asset_id ? 'green' : 'gray'">
|
||||
{{ record.asset_id ? `已关联 #${record.asset_id}` : '未关联' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 远程访问 -->
|
||||
<template #remote_access="{ record }">
|
||||
<a-tag :color="record.remote_access ? 'green' : 'gray'">
|
||||
@@ -120,6 +127,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 +213,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 +224,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 +402,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 +480,86 @@ const handleDelete = async (record: any) => {
|
||||
})
|
||||
}
|
||||
|
||||
/** dc-host `GET agent_config`(一般为 `/dc-host/stats`)返回裸 Metrics JSON */
|
||||
// 获取所有服务器的监控指标
|
||||
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
|
||||
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
|
||||
}
|
||||
// 使用独立的 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}核` : '',
|
||||
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((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` : '',
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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` : '',
|
||||
}
|
||||
// 从 dc-control 获取所有服务器最新时序指标
|
||||
const getAllMetrics = async () => {
|
||||
try {
|
||||
const metricsPromises = tableData.value.map(async (record) => {
|
||||
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)
|
||||
// 初始化默认值
|
||||
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: '' }
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -173,13 +173,11 @@
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置">
|
||||
<a-textarea
|
||||
<SnmpOidEditor
|
||||
ref="snmpOidEditorRef"
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空用默认模板,或填写 JSON 数组如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
|
||||
:reset-key="snmpOidEditorResetKey"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item field="collect_args" label="采集参数">
|
||||
@@ -208,6 +206,7 @@ import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { createStorage, updateStorage } from '@/api/ops/storage'
|
||||
import type { StorageCreateData, StorageItem } from '@/api/ops/storage'
|
||||
import { fetchServerList, type ServerItem } from '@/api/ops/server'
|
||||
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -221,6 +220,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
|
||||
const snmpOidEditorResetKey = ref(0)
|
||||
const confirmLoading = ref(false)
|
||||
const serverOptions = ref<ServerItem[]>([])
|
||||
|
||||
@@ -297,6 +298,7 @@ watch(
|
||||
collect_args: props.record.collect_args || '',
|
||||
collect_interval: props.record.collect_interval || 60,
|
||||
})
|
||||
snmpOidEditorResetKey.value += 1
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
@@ -328,6 +330,7 @@ watch(
|
||||
collect_args: '',
|
||||
collect_interval: 60,
|
||||
})
|
||||
snmpOidEditorResetKey.value += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,15 +365,10 @@ const handleOk = async () => {
|
||||
Message.warning('SNMP v2c 模式下请填写 community')
|
||||
return
|
||||
}
|
||||
if (formData.snmp_oids?.trim()) {
|
||||
try {
|
||||
JSON.parse(formData.snmp_oids)
|
||||
} catch {
|
||||
Message.warning('SNMP OID 配置必须是合法 JSON')
|
||||
if (!snmpOidEditorRef.value?.validate()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
|
||||
@@ -60,6 +60,16 @@
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert
|
||||
v-if="showVirtualEmptyTip"
|
||||
class="empty-tip"
|
||||
type="info"
|
||||
show-icon
|
||||
banner
|
||||
>
|
||||
当前暂无已关联资产的虚拟服务器。请在服务器管理中将虚拟服务器关联到资产;宿主机趋势还需要配置已关联资产的物理服务器。
|
||||
</a-alert>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<a-row :gutter="16" class="chart-row">
|
||||
<a-col :xs="24" :lg="8">
|
||||
@@ -188,7 +198,7 @@ const stats = ref({
|
||||
memoryUsage: 0,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
// 宿主机状态
|
||||
const hostStatus = ref<
|
||||
@@ -317,6 +327,7 @@ const cpuPhysicalText = computed(() => formatMaxTwoDecimals(cpuPhysical.value))
|
||||
const memoryTotalText = computed(() => formatMaxTwoDecimals(memVirtualGb.value + memPhysicalGb.value))
|
||||
const memoryVirtualText = computed(() => formatMaxTwoDecimals(memVirtualGb.value))
|
||||
const memoryPhysicalText = computed(() => formatMaxTwoDecimals(memPhysicalGb.value))
|
||||
const showVirtualEmptyTip = computed(() => !loading.value && stats.value.total === 0)
|
||||
const toGb = (bytes: number) => bytes / 1024 / 1024 / 1024
|
||||
const safeNum = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
|
||||
const hourLabel = (v: string) => {
|
||||
@@ -410,6 +421,10 @@ export default {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
height: 100%;
|
||||
|
||||
|
||||
54
src/views/ops/pages/report/statistics/content.ts
Normal file
54
src/views/ops/pages/report/statistics/content.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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-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()
|
||||
|
||||
Reference in New Issue
Block a user