Compare commits
24 Commits
task-18-au
...
947fa2f9b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 947fa2f9b9 | |||
| e5df404aaa | |||
| 8a6ec3ef96 | |||
| 922f7dfc4b | |||
| ee101984f0 | |||
| 3f4d50b395 | |||
| 58365870ef | |||
| aa6db97457 | |||
| f5d0bae89b | |||
| cc6ea1acc6 | |||
| 7a20329594 | |||
| 83b713890b | |||
| c3f38f5e2a | |||
| c6d0df6fd2 | |||
| 93e8f3349f | |||
| fc5d653433 | |||
| fafdb0d830 | |||
| 99a5f8bd16 | |||
| 9f1c3eb660 | |||
| 1ad0323715 | |||
| b01a0f0979 | |||
| 8f3dd3e43e | |||
| 2107f77081 | |||
| fcaad4b3ae |
@@ -9,8 +9,9 @@ VITE_APP_DESCRIPTION="default standard template"
|
||||
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_API_BASE_URL=https://ops-api.apinb.com
|
||||
# 开发环境走 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({
|
||||
|
||||
24
dev/components.d.ts
vendored
Normal file
24
dev/components.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
import { GlobalComponents } from 'vue'
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
|
||||
// For TSX support
|
||||
declare global {
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能运维管理系统</title>
|
||||
</head>
|
||||
|
||||
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
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="33" height="33">
|
||||
<path d="M0 240.941176a240.941176 240.941176 0 0 1 240.941176-240.941176h542.117648a240.941176 240.941176 0 0 1 240.941176 240.941176v542.117648a240.941176 240.941176 0 0 1-240.941176 240.941176H240.941176a240.941176 240.941176 0 0 1-240.941176-240.941176V240.941176z" fill="#287BF7"></path>
|
||||
<path d="M340.931765 388.969412a120.621176 120.621176 0 1 0 115.260235 209.799529l70.776471 97.490824a241.091765 241.091765 0 1 1 82.522353-283.587765c15.751529 39.936 14.516706 83.546353 13.763764 110.290824-0.150588 4.879059-0.271059 9.216-0.271059 12.860235 0 22.648471 5.812706 48.519529 18.221177 66.831059 10.24 15.058824 26.654118 28.310588 61.108706 28.310588 31.804235 0 49.152-10.721882 59.331764-22.40753 11.264-12.950588 18.974118-33.370353 18.974118-59.452235 0-18.642824-9.396706-39.574588-27.166118-53.067294-16.022588-12.137412-43.550118-21.594353-86.768941-8.734118l-34.42447-115.440941c75.926588-22.618353 144.112941-9.637647 193.987764 28.190118 48.128 36.502588 74.842353 93.093647 74.842353 149.082353 0 48.489412-14.245647 99.087059-48.609882 138.541176-35.418353 40.719059-87.341176 63.728941-150.166588 63.728941-75.294118 0-128.662588-33.792-160.768-81.106823-29.936941-44.062118-39.032471-95.864471-39.032471-134.475294 0-9.396706 0.150588-17.588706 0.240941-24.847059 0.421647-25.057882 0.632471-38.972235-5.330823-54.091294a120.621176 120.621176 0 0 0-156.491294-67.915294z" fill="#FFFFFF"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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) =>
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface HostHardwareDevice {
|
||||
snmp_port?: number
|
||||
community?: string
|
||||
snmp_version?: string
|
||||
snmp_oids?: string
|
||||
redfish_base_url?: string
|
||||
redfish_tls_skip_verify?: boolean
|
||||
ipmi_timeout_seconds?: number
|
||||
@@ -65,6 +66,7 @@ export interface HostHardwareDeviceUpsert {
|
||||
snmp_port?: number
|
||||
community?: string
|
||||
snmp_version?: string
|
||||
snmp_oids?: string
|
||||
redfish_base_url?: string
|
||||
redfish_tls_skip_verify?: boolean
|
||||
location?: string
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -1,12 +1,4 @@
|
||||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="33" height="33">
|
||||
<path d="M0 240.941176a240.941176 240.941176 0 0 1 240.941176-240.941176h542.117648a240.941176 240.941176 0 0 1 240.941176 240.941176v542.117648a240.941176 240.941176 0 0 1-240.941176 240.941176H240.941176a240.941176 240.941176 0 0 1-240.941176-240.941176V240.941176z" fill="#287BF7"></path>
|
||||
<path d="M340.931765 388.969412a120.621176 120.621176 0 1 0 115.260235 209.799529l70.776471 97.490824a241.091765 241.091765 0 1 1 82.522353-283.587765c15.751529 39.936 14.516706 83.546353 13.763764 110.290824-0.150588 4.879059-0.271059 9.216-0.271059 12.860235 0 22.648471 5.812706 48.519529 18.221177 66.831059 10.24 15.058824 26.654118 28.310588 61.108706 28.310588 31.804235 0 49.152-10.721882 59.331764-22.40753 11.264-12.950588 18.974118-33.370353 18.974118-59.452235 0-18.642824-9.396706-39.574588-27.166118-53.067294-16.022588-12.137412-43.550118-21.594353-86.768941-8.734118l-34.42447-115.440941c75.926588-22.618353 144.112941-9.637647 193.987764 28.190118 48.128 36.502588 74.842353 93.093647 74.842353 149.082353 0 48.489412-14.245647 99.087059-48.609882 138.541176-35.418353 40.719059-87.341176 63.728941-150.166588 63.728941-75.294118 0-128.662588-33.792-160.768-81.106823-29.936941-44.062118-39.032471-95.864471-39.032471-134.475294 0-9.396706 0.150588-17.588706 0.240941-24.847059 0.421647-25.057882 0.632471-38.972235-5.330823-54.091294a120.621176 120.621176 0 0 0-156.491294-67.915294z" fill="#FFFFFF"></path>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -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')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<a-spin style="display: block" :loading="loading">
|
||||
<a-spin class="message-box" :class="{ 'is-empty': !renderList.length }" style="display: block" :loading="loading">
|
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
|
||||
<a-tab-pane v-for="item in tabList" :key="item.key">
|
||||
<template #title>
|
||||
<span>{{ item.title }}{{ formatUnreadLength(item.key) }}</span>
|
||||
</template>
|
||||
<a-result v-if="!renderList.length" status="404">
|
||||
<a-result v-if="!renderList.length" class="message-empty" status="404">
|
||||
<template #subtitle>{{ $t('messageBox.noContent') }}</template>
|
||||
</a-result>
|
||||
<List
|
||||
@@ -16,11 +16,6 @@
|
||||
@view-history="openHistory"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-button type="text" @click="emptyList">
|
||||
{{ $t('messageBox.tab.button') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
<a-drawer v-model:visible="historyVisible" width="760px" title="通知投递历史" :footer="false" unmount-on-close @open="fetchHistory">
|
||||
<a-table :columns="historyColumns" :data="historyList" :loading="historyLoading" :pagination="historyPagination" row-key="id" @page-change="onHistoryPageChange">
|
||||
@@ -48,8 +43,9 @@ 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 { computed, reactive, ref, toRefs, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import List from './list.vue'
|
||||
|
||||
@@ -58,9 +54,13 @@ interface TabItem {
|
||||
title: string
|
||||
avatar?: string
|
||||
}
|
||||
const visibleMessageTypes = ['message']
|
||||
const { loading, setLoading } = useLoading(true)
|
||||
const messageType = ref('message')
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
(event: 'unread-count-change', count: number): void
|
||||
}>()
|
||||
const messageData = reactive<{
|
||||
renderList: MessageRecord[]
|
||||
messageList: MessageRecord[]
|
||||
@@ -91,14 +91,6 @@ const tabList: TabItem[] = [
|
||||
key: 'message',
|
||||
title: t('messageBox.tab.title.message'),
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: t('messageBox.tab.title.notice'),
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: t('messageBox.tab.title.todo'),
|
||||
},
|
||||
]
|
||||
async function fetchSourceData() {
|
||||
setLoading(true)
|
||||
@@ -111,6 +103,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 })
|
||||
@@ -172,6 +167,9 @@ const getDeliveryColor = (status: string) => {
|
||||
const renderList = computed(() => {
|
||||
return messageData.messageList.filter((item) => messageType.value === item.type)
|
||||
})
|
||||
const totalUnreadCount = computed(() => {
|
||||
return messageData.messageList.filter((item) => visibleMessageTypes.includes(item.type) && !item.status).length
|
||||
})
|
||||
const unreadCount = computed(() => {
|
||||
return renderList.value.filter((item) => !item.status).length
|
||||
})
|
||||
@@ -186,13 +184,41 @@ const formatUnreadLength = (type: string) => {
|
||||
const handleItemClick = (items: MessageListType) => {
|
||||
if (renderList.value.length) readMessage([...items])
|
||||
}
|
||||
const emptyList = () => {
|
||||
messageData.messageList = []
|
||||
}
|
||||
watch(totalUnreadCount, (count) => emit('unread-count-change', count), { immediate: true })
|
||||
fetchSourceData()
|
||||
useNotificationSocket<MessageRecord>({
|
||||
onNotification: upsertMessageToTop,
|
||||
onReconnectAfter: fetchSourceData,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.message-box {
|
||||
width: 400px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.arco-spin-children) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-content) {
|
||||
max-height: 360px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.is-empty {
|
||||
:deep(.arco-tabs-content) {
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.message-empty) {
|
||||
padding: 34px 0 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arco-popover-popup-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
</a-list-item>
|
||||
<template #footer>
|
||||
<a-space fill :size="0" :class="{ 'add-border-top': renderList.length < showMax }">
|
||||
<a-space fill :size="0" class="message-footer-actions" :class="{ 'add-border-top': renderList.length < showMax }">
|
||||
<div class="footer-wrap">
|
||||
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
|
||||
</div>
|
||||
@@ -147,10 +147,20 @@ const showMax = 3
|
||||
.arco-list-footer {
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
line-height: normal;
|
||||
border-top: none;
|
||||
.arco-space-item {
|
||||
|
||||
.message-footer-actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.arco-space-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 50%;
|
||||
height: 50px;
|
||||
border-right: 1px solid rgb(var(--gray-3));
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
@@ -161,7 +171,20 @@ const showMax = 3
|
||||
}
|
||||
}
|
||||
.footer-wrap {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
|
||||
:deep(.arco-link) {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.arco-link:focus-visible) {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.arco-typography {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': 'Message',
|
||||
'messageBox.tab.title.notice': 'Notice',
|
||||
'messageBox.tab.title.todo': 'Todo',
|
||||
'messageBox.tab.button': 'empty',
|
||||
'messageBox.allRead': 'All Read',
|
||||
'messageBox.viewMore': 'View More',
|
||||
'messageBox.noContent': 'No Content',
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': '消息',
|
||||
'messageBox.tab.title.notice': '通知',
|
||||
'messageBox.tab.title.todo': '待办',
|
||||
'messageBox.tab.button': '清空',
|
||||
'messageBox.allRead': '全部已读',
|
||||
'messageBox.viewMore': '查看更多',
|
||||
'messageBox.noContent': '暂无内容',
|
||||
|
||||
@@ -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 :count="notificationUnreadCount">
|
||||
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="setPopoverVisible">
|
||||
<icon-notification />
|
||||
</a-button>
|
||||
@@ -62,15 +62,15 @@
|
||||
<a-popover
|
||||
trigger="click"
|
||||
:arrow-style="{ display: 'none' }"
|
||||
:content-style="{ padding: 0, minWidth: '400px' }"
|
||||
:content-style="{ padding: 0, minWidth: '400px', overflow: 'hidden' }"
|
||||
content-class="message-popover"
|
||||
>
|
||||
<div ref="refBtn" class="ref-btn"></div>
|
||||
<template #content>
|
||||
<message-box />
|
||||
<message-box @unread-count-change="handleMessageBoxUnreadCountChange" />
|
||||
</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">
|
||||
@@ -126,8 +126,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MessageRecord, queryMessageList } from '@/api/message'
|
||||
import useLocale from '@/hooks/locale'
|
||||
import useUser from '@/hooks/user'
|
||||
import useNotificationSocket from '@/hooks/useNotificationSocket'
|
||||
import { LOCALE_OPTIONS } from '@/locale'
|
||||
import { useAppStore, useUserStore } from '@/store'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
@@ -173,8 +175,24 @@ const setVisible = () => {
|
||||
}
|
||||
const refBtn = ref()
|
||||
const triggerBtn = ref()
|
||||
const notificationUnreadCount = ref(0)
|
||||
const visibleMessageTypes = ['message']
|
||||
const passwordChangeVisible = ref(false)
|
||||
const profileChangeVisible = ref(false)
|
||||
const updateNotificationUnreadCount = (list: MessageRecord[] = []) => {
|
||||
notificationUnreadCount.value = list.filter((item) => visibleMessageTypes.includes(item.type) && !item.status).length
|
||||
}
|
||||
const fetchNotificationUnreadCount = async () => {
|
||||
try {
|
||||
const { data } = await queryMessageList()
|
||||
updateNotificationUnreadCount(data)
|
||||
} catch (err) {
|
||||
updateNotificationUnreadCount()
|
||||
}
|
||||
}
|
||||
const handleMessageBoxUnreadCountChange = (count: number) => {
|
||||
notificationUnreadCount.value = count
|
||||
}
|
||||
const setPopoverVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
@@ -201,6 +219,12 @@ const setDropDownVisible = () => {
|
||||
triggerBtn.value.dispatchEvent(event)
|
||||
}
|
||||
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void
|
||||
|
||||
fetchNotificationUnreadCount()
|
||||
useNotificationSocket<MessageRecord>({
|
||||
onNotification: fetchNotificationUnreadCount,
|
||||
onReconnectAfter: fetchNotificationUnreadCount,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -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>
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,7 @@
|
||||
<div class="menu-wrapper">
|
||||
<div class="left-side">
|
||||
<a-space>
|
||||
<img
|
||||
alt="logo"
|
||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
|
||||
/>
|
||||
<img class="app-logo" alt="logo" src="@/assets/logo.svg?url" />
|
||||
<a-typography-title>智能运维管理系统</a-typography-title>
|
||||
</a-space>
|
||||
</div>
|
||||
@@ -183,6 +180,13 @@ onMounted(() => {
|
||||
font-size: 18px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
||||
@@ -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': '系统日志',
|
||||
|
||||
@@ -12,70 +12,60 @@ let isMenuLoading = false
|
||||
let isMenuLoaded = false
|
||||
|
||||
export default function setupPermissionGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const Permission = usePermission()
|
||||
const permissionsAllow = Permission.accessRouter(to)
|
||||
|
||||
if (appStore.menuFromServer) {
|
||||
// 针对来自服务端的菜单配置进行处理
|
||||
// Handle routing configuration from the server
|
||||
try {
|
||||
if (appStore.menuFromServer) {
|
||||
// 针对来自服务端的菜单配置进行处理
|
||||
// Handle routing configuration from the server
|
||||
|
||||
// 检查是否在白名单中
|
||||
if (WHITE_LIST.find((el) => el.name === to.name)) {
|
||||
next()
|
||||
NProgress.done()
|
||||
return
|
||||
}
|
||||
console.log('[Permission Guard] Menu not loaded, loading...')
|
||||
|
||||
// 检查动态路由是否已加载(使用标志位而非菜单长度,更可靠)
|
||||
if (!isMenuLoaded && !isMenuLoading) {
|
||||
console.log('[Permission Guard] Menu not loaded, loading...')
|
||||
// 设置加载标志
|
||||
isMenuLoading = true
|
||||
try {
|
||||
// 动态路由未加载,先获取菜单配置并注册路由
|
||||
await appStore.fetchServerMenuConfig()
|
||||
// 标记加载完成
|
||||
isMenuLoaded = true
|
||||
console.log('[Permission Guard] Menu loaded, redirecting to:', to.path)
|
||||
// 路由注册后需要重新导航到当前路径
|
||||
next({ path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw)
|
||||
} catch (error) {
|
||||
console.error('[Permission Guard] Failed to load menu:', error)
|
||||
isMenuLoading = false
|
||||
next(NOT_FOUND)
|
||||
} finally {
|
||||
isMenuLoading = false
|
||||
// 检查是否在白名单中
|
||||
if (WHITE_LIST.find((el) => el.name === to.name)) {
|
||||
return true
|
||||
}
|
||||
NProgress.done()
|
||||
return
|
||||
console.log('[Permission Guard] Menu not loaded, loading...')
|
||||
|
||||
// 检查动态路由是否已加载(使用标志位而非菜单长度,更可靠)
|
||||
if (!isMenuLoaded && !isMenuLoading) {
|
||||
console.log('[Permission Guard] Menu not loaded, loading...')
|
||||
// 设置加载标志
|
||||
isMenuLoading = true
|
||||
try {
|
||||
// 动态路由未加载,先获取菜单配置并注册路由
|
||||
await appStore.fetchServerMenuConfig()
|
||||
// 标记加载完成
|
||||
isMenuLoaded = true
|
||||
console.log('[Permission Guard] Menu loaded, redirecting to:', to.path)
|
||||
// 路由注册后需要重新导航到当前路径
|
||||
return { path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw
|
||||
} catch (error) {
|
||||
console.error('[Permission Guard] Failed to load menu:', error)
|
||||
return NOT_FOUND
|
||||
} finally {
|
||||
isMenuLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 如果正在加载菜单,等待加载完成
|
||||
if (isMenuLoading) {
|
||||
return { path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw
|
||||
}
|
||||
|
||||
// 动态路由已加载,直接放行
|
||||
// 因为路由已经通过 addRoute 注册,Vue Router 会自动匹配
|
||||
return permissionsAllow ? true : NOT_FOUND
|
||||
}
|
||||
|
||||
// 如果正在加载菜单,等待加载完成
|
||||
if (isMenuLoading) {
|
||||
next({ path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw)
|
||||
NProgress.done()
|
||||
return
|
||||
}
|
||||
|
||||
// 动态路由已加载,直接放行
|
||||
// 因为路由已经通过 addRoute 注册,Vue Router 会自动匹配
|
||||
if (permissionsAllow) {
|
||||
next()
|
||||
} else {
|
||||
next(NOT_FOUND)
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
if (permissionsAllow) next()
|
||||
else {
|
||||
const destination = Permission.findFirstPermissionRoute(appRoutes, userStore.role) || NOT_FOUND
|
||||
next(destination)
|
||||
return true
|
||||
}
|
||||
return Permission.findFirstPermissionRoute(appRoutes, userStore.role) || NOT_FOUND
|
||||
} finally {
|
||||
NProgress.done()
|
||||
}
|
||||
NProgress.done()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,40 +8,39 @@ import { isLogin } from '@/utils/auth'
|
||||
const whiteList = ['login', 'home']
|
||||
|
||||
export default function setupUserLoginInfoGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
router.beforeEach(async (to) => {
|
||||
NProgress.start()
|
||||
const userStore = useUserStore()
|
||||
if (isLogin()) {
|
||||
if (userStore.role) {
|
||||
next()
|
||||
return true
|
||||
} else {
|
||||
try {
|
||||
await userStore.info()
|
||||
next()
|
||||
return true
|
||||
} catch (error) {
|
||||
await userStore.logout()
|
||||
next({
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 在白名单中的路由不需要登录验证
|
||||
if (whiteList.includes(to.name as string)) {
|
||||
next()
|
||||
return
|
||||
return true
|
||||
}
|
||||
next({
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<header class="navbar">
|
||||
<div class="navbar-content">
|
||||
<div class="logo" @click="goToHome">
|
||||
<img src="@/assets/logo.png" alt="Logo" />
|
||||
<img src="@/assets/logo.svg?url" alt="Logo" />
|
||||
<span class="logo-text">智能运维管理系统</span>
|
||||
</div>
|
||||
<nav class="nav-links">
|
||||
@@ -699,7 +699,9 @@ defineExpose({
|
||||
|
||||
img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<header class="navbar">
|
||||
<div class="navbar-content">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" />
|
||||
<img src="@/assets/logo.svg?url" alt="Logo" />
|
||||
<span class="logo-text">智能运维管理系统</span>
|
||||
</div>
|
||||
<nav class="nav-links">
|
||||
@@ -158,7 +158,9 @@ const scrollToSection = (id: string) => {
|
||||
|
||||
img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="login-bg">
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<img alt="logo" src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image" />
|
||||
<img alt="logo" src="@/assets/logo.svg?url" />
|
||||
<div class="logo-text">智能运维管理系统</div>
|
||||
</div>
|
||||
<LoginBanner />
|
||||
@@ -104,6 +104,8 @@ const goToHome = () => {
|
||||
img {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
position: static;
|
||||
|
||||
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
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
|
||||
/>
|
||||
</a-form-item>
|
||||
<SnmpOidEditor
|
||||
ref="snmpOidEditorRef"
|
||||
v-model="formData.snmp_oids"
|
||||
:reset-key="snmpOidEditorResetKey"
|
||||
/>
|
||||
</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,13 +373,8 @@ 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')
|
||||
return
|
||||
}
|
||||
if (!snmpOidEditorRef.value?.validate()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" auto-label-width>
|
||||
<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,13 +367,15 @@ const handleOk = async () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!formData.ssh.username.trim()) {
|
||||
Message.warning('请填写 SSH 用户名')
|
||||
return
|
||||
}
|
||||
if (!isEdit.value && !formData.ssh.password.trim()) {
|
||||
Message.warning('请填写 SSH 密码')
|
||||
return
|
||||
if (formData.collect.fib_enabled) {
|
||||
if (!formData.ssh.username.trim()) {
|
||||
Message.warning('请填写 SSH 用户名')
|
||||
return
|
||||
}
|
||||
if (!isEdit.value && !formData.ssh.password.trim() && !formData.ssh.private_key.trim()) {
|
||||
Message.warning('请填写 SSH 密码或 SSH 私钥')
|
||||
return
|
||||
}
|
||||
}
|
||||
confirmLoading.value = true
|
||||
const payload: any = {
|
||||
|
||||
@@ -157,13 +157,11 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置(JSON数组)">
|
||||
<a-textarea
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空使用默认模板,或填写如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime","metric_unit":"timeticks"}]'
|
||||
/>
|
||||
</a-form-item>
|
||||
<SnmpOidEditor
|
||||
ref="snmpOidEditorRef"
|
||||
v-model="formData.snmp_oids"
|
||||
:reset-key="snmpOidEditorResetKey"
|
||||
/>
|
||||
</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,13 +405,8 @@ 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')
|
||||
return
|
||||
}
|
||||
if (!snmpOidEditorRef.value?.validate()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,11 @@
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<SnmpOidEditor
|
||||
ref="snmpOidEditorRef"
|
||||
v-model="form.snmp_oids"
|
||||
:reset-key="snmpOidEditorResetKey"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="form.protocol === 'redfish'">
|
||||
@@ -195,6 +200,7 @@ import {
|
||||
type HostHardwareDevice,
|
||||
type HostHardwareDeviceUpsert,
|
||||
} from '@/api/ops/host-hardware'
|
||||
import SnmpOidEditor from '../../components/SnmpOidEditor.vue'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -205,6 +211,8 @@ const props = defineProps<Props>()
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const snmpOidEditorRef = ref<InstanceType<typeof SnmpOidEditor>>()
|
||||
const snmpOidEditorResetKey = ref(0)
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const blockedNoIdentity = ref(false)
|
||||
@@ -254,6 +262,7 @@ const form = reactive({
|
||||
snmp_port: 161,
|
||||
community: '',
|
||||
snmp_version: 'v2c',
|
||||
snmp_oids: '',
|
||||
redfish_base_url: '',
|
||||
redfish_tls_skip_verify: false,
|
||||
description: '',
|
||||
@@ -283,6 +292,7 @@ function resetForm() {
|
||||
form.snmp_port = 161
|
||||
form.community = ''
|
||||
form.snmp_version = 'v2c'
|
||||
form.snmp_oids = ''
|
||||
form.redfish_base_url = ''
|
||||
form.redfish_tls_skip_verify = false
|
||||
form.description = ''
|
||||
@@ -296,6 +306,7 @@ function resetForm() {
|
||||
form.redfish_collect_enabled = true
|
||||
form.collect_interval = 0
|
||||
form.enabled = true
|
||||
snmpOidEditorResetKey.value += 1
|
||||
}
|
||||
|
||||
function applyFromServer(r: ServerItem) {
|
||||
@@ -315,6 +326,7 @@ function applyFromDevice(d: HostHardwareDevice) {
|
||||
form.snmp_port = d.snmp_port ?? 161
|
||||
form.community = d.community || ''
|
||||
form.snmp_version = d.snmp_version || 'v2c'
|
||||
form.snmp_oids = d.snmp_oids || ''
|
||||
form.redfish_base_url = d.redfish_base_url || ''
|
||||
form.redfish_tls_skip_verify = !!d.redfish_tls_skip_verify
|
||||
form.description = d.description || ''
|
||||
@@ -328,6 +340,7 @@ function applyFromDevice(d: HostHardwareDevice) {
|
||||
form.redfish_collect_enabled = d.redfish_collect_enabled !== false
|
||||
form.collect_interval = d.collect_interval ?? 0
|
||||
form.enabled = d.enabled !== false
|
||||
snmpOidEditorResetKey.value += 1
|
||||
}
|
||||
|
||||
function validateExtraConfigJson(): boolean {
|
||||
@@ -357,6 +370,7 @@ function buildPayload(): HostHardwareDeviceUpsert {
|
||||
snmp_port: form.snmp_port,
|
||||
community: form.community?.trim() || undefined,
|
||||
snmp_version: form.snmp_version?.trim() || undefined,
|
||||
snmp_oids: form.protocol === 'snmp' ? form.snmp_oids?.trim() || '[]' : '[]',
|
||||
redfish_base_url: form.redfish_base_url?.trim() || undefined,
|
||||
redfish_tls_skip_verify: form.redfish_tls_skip_verify,
|
||||
location: location || undefined,
|
||||
@@ -485,6 +499,9 @@ async function handleSubmit() {
|
||||
return
|
||||
}
|
||||
if (!validateExtraConfigJson()) return
|
||||
if (form.protocol === 'snmp' && !snmpOidEditorRef.value?.validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
const r = props.record
|
||||
console.log('r,', r)
|
||||
|
||||
@@ -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 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()
|
||||
})
|
||||
|
||||
@@ -173,13 +173,11 @@
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="snmp_oids" label="SNMP OID配置">
|
||||
<a-textarea
|
||||
v-model="formData.snmp_oids"
|
||||
:rows="3"
|
||||
placeholder='可留空用默认模板,或填写 JSON 数组如 [{"oid":"1.3.6.1.2.1.1.3.0","metric_name":"sys_uptime"}]'
|
||||
/>
|
||||
</a-form-item>
|
||||
<SnmpOidEditor
|
||||
ref="snmpOidEditorRef"
|
||||
v-model="formData.snmp_oids"
|
||||
:reset-key="snmpOidEditorResetKey"
|
||||
/>
|
||||
</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,13 +365,8 @@ 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')
|
||||
return
|
||||
}
|
||||
if (!snmpOidEditorRef.value?.validate()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a-tag v-if="card.title === '警告' || card.title === '工单' || card.title === '审核'" size="small" class="stat-tag">
|
||||
待处理/总数
|
||||
</a-tag>
|
||||
<a-tag v-else size="small" class="stat-tag">在线/总数</a-tag>
|
||||
<a-tag v-else size="small" class="stat-tag">启用/总数</a-tag>
|
||||
</div>
|
||||
<div class="stat-icon" :style="{ background: getIconBackground(card) }">
|
||||
<component :is="getIconComponent(card.title)" :style="{ fontSize: '24px', color: getIconColor(card) }" />
|
||||
@@ -41,13 +41,21 @@
|
||||
</div>
|
||||
<span class="title-text">告警趋势</span>
|
||||
</div>
|
||||
<a-select style="width: 200px" v-model="month" size="small" class="month-select" @change="handleChange">
|
||||
<a-option value="2025年3月">2025年3月</a-option>
|
||||
<a-option value="2025年2月">2025年2月</a-option>
|
||||
<a-select style="width: 160px" v-model="month" size="small" class="month-select" @change="handleChange">
|
||||
<a-option v-for="item in monthOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<v-chart v-if="!chartLoading" :option="alertChartOption" class="chart" autoresize />
|
||||
<div v-if="!chartLoading && isTrendEmpty" class="chart-empty">
|
||||
<a-empty>
|
||||
<template #description>
|
||||
<span>当前月份暂无告警趋势数据</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
<v-chart v-else-if="!chartLoading" :option="alertChartOption" class="chart" autoresize />
|
||||
<div v-else class="chart-loading">
|
||||
<a-spin size="large" />
|
||||
<div class="loading-text">加载图表数据...</div>
|
||||
@@ -131,16 +139,53 @@ import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { BarChart } from 'echarts/charts'
|
||||
import { TitleComponent, TooltipComponent, GridComponent } from 'echarts/components'
|
||||
import dayjs from 'dayjs'
|
||||
import { fetchAlertCount, fetchHistories } from '@/api/ops/alertHistory'
|
||||
import { fetchAlertCount, fetchAlertTrend, fetchHistories } from '@/api/ops/alertHistory'
|
||||
import { fetchCollectorStatistics } from '@/api/ops/dcControl'
|
||||
import { fetchFeedbackTicketStatistics } from '@/api/ops/feedbackTicket'
|
||||
import { fetchNetworkDeviceList } from '@/api/ops/network-device'
|
||||
import { fetchReviewStats } from '@/api/kb/review'
|
||||
|
||||
// 注册必要的 ECharts 组件
|
||||
use([CanvasRenderer, BarChart, TitleComponent, TooltipComponent, GridComponent])
|
||||
|
||||
const router = useRouter()
|
||||
const month = ref('2025年3月')
|
||||
const getCurrentMonthValue = () => dayjs().format('YYYY-MM')
|
||||
|
||||
const formatMonthLabel = (value: string) => dayjs(`${value}-01`).format('YYYY年M月')
|
||||
|
||||
const monthOptions = computed(() => {
|
||||
return Array.from({ length: 6 }, (_, index) => {
|
||||
const value = dayjs().subtract(index, 'month').format('YYYY-MM')
|
||||
return {
|
||||
label: formatMonthLabel(value),
|
||||
value,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const getMonthRange = (value: string) => {
|
||||
const start = dayjs(`${value}-01`).startOf('month')
|
||||
const end = start.endOf('month')
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const buildMonthDays = (value: string) => {
|
||||
const { start, end } = getMonthRange(value)
|
||||
const days: string[] = []
|
||||
let cursor = start
|
||||
while (cursor.isBefore(end) || cursor.isSame(end, 'day')) {
|
||||
days.push(cursor.format('YYYY-MM-DD'))
|
||||
cursor = cursor.add(1, 'day')
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
const month = ref(getCurrentMonthValue())
|
||||
const loading = ref(true)
|
||||
const chartLoading = ref(false)
|
||||
const numberAnimated = ref([false, false, false, false, false, false])
|
||||
@@ -149,7 +194,7 @@ const animatedNumbers = ref([0, 0, 0, 0, 0, 0])
|
||||
// 统计数据
|
||||
const statistics = reactive({
|
||||
serverPc: { pending: 0, total: 0 },
|
||||
cluster: { pending: 0, total: 0 },
|
||||
database: { pending: 0, total: 0 },
|
||||
network: { pending: 0, total: 0 },
|
||||
alert: { pending: 0, total: 0 },
|
||||
ticket: { pending: 0, total: 0 },
|
||||
@@ -157,6 +202,21 @@ const statistics = reactive({
|
||||
})
|
||||
|
||||
const pendingAlerts = ref<any[]>([])
|
||||
const trendDates = ref<string[]>([])
|
||||
const trendCounts = ref<number[]>([])
|
||||
const isTrendEmpty = computed(() => trendCounts.value.length > 0 && trendCounts.value.every((count) => count === 0))
|
||||
|
||||
const unwrapDetails = (payload: any) => payload?.details ?? payload?.data ?? payload ?? {}
|
||||
|
||||
const normalizeCountPair = (source: any) => ({
|
||||
pending: Number(source?.collect_enabled ?? source?.enabled ?? 0) || 0,
|
||||
total: Number(source?.total ?? 0) || 0,
|
||||
})
|
||||
|
||||
const getPageTotal = (payload: any) => {
|
||||
const body = unwrapDetails(payload)
|
||||
return Number(body?.total ?? payload?.total ?? 0) || 0
|
||||
}
|
||||
|
||||
// 统计卡片数据
|
||||
const statCards = computed(() => [
|
||||
@@ -169,9 +229,9 @@ const statCards = computed(() => [
|
||||
iconColor: '#409EFF',
|
||||
},
|
||||
{
|
||||
title: '集群采集',
|
||||
pending: statistics.cluster.pending,
|
||||
total: statistics.cluster.total,
|
||||
title: '数据库服务',
|
||||
pending: statistics.database.pending,
|
||||
total: statistics.database.total,
|
||||
lightColor: 'rgba(255, 153, 0, 0.1)',
|
||||
darkColor: 'rgba(255, 153, 0, 0.15)',
|
||||
iconColor: '#FF9900',
|
||||
@@ -236,7 +296,8 @@ const alertChartOption = computed(() => ({
|
||||
const data = params[0]
|
||||
const date = data.axisValue
|
||||
const value = data.value
|
||||
const percent = ((value / 200) * 100).toFixed(1)
|
||||
const maxValue = Math.max(...trendCounts.value, 0)
|
||||
const percent = maxValue > 0 ? ((value / maxValue) * 100).toFixed(1) : '0.0'
|
||||
return `
|
||||
<div style="margin-bottom: 4px; font-size: 12px; color: rgba(255, 255, 255, 0.7);">${date}</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
@@ -257,7 +318,7 @@ const alertChartOption = computed(() => ({
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['03/01', '03/02', '03/03', '03/04', '03/05', '03/06', '03/07'],
|
||||
data: trendDates.value,
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
@@ -281,7 +342,7 @@ const alertChartOption = computed(() => ({
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 250,
|
||||
max: (value: { max: number }) => Math.max(5, Math.ceil(value.max * 1.2)),
|
||||
splitNumber: 5,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
@@ -311,7 +372,7 @@ const alertChartOption = computed(() => ({
|
||||
{
|
||||
name: '告警数量',
|
||||
type: 'bar',
|
||||
data: [120, 200, 150, 80, 60, 110, 130],
|
||||
data: trendCounts.value,
|
||||
barWidth: '50%',
|
||||
itemStyle: {
|
||||
color: {
|
||||
@@ -371,31 +432,6 @@ const alertChartOption = computed(() => ({
|
||||
color: 'rgba(99, 102, 241, 0.05)',
|
||||
borderRadius: [12, 12, 0, 0],
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: 'rgba(245, 158, 11, 0.6)',
|
||||
width: 2,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
yAxis: 150,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'end',
|
||||
formatter: '预警值',
|
||||
fontSize: 12,
|
||||
color: 'rgba(245, 158, 11, 0.8)',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
padding: [4, 8],
|
||||
borderRadius: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
@@ -419,7 +455,7 @@ const getIconColor = (card: any) => {
|
||||
const getIconComponent = (title: string) => {
|
||||
const iconMap: any = {
|
||||
服务器及PC: IconDesktop,
|
||||
集群采集: IconCloud,
|
||||
数据库服务: IconCloud,
|
||||
网络设备: IconWifi,
|
||||
警告: IconExclamationCircle,
|
||||
工单: IconFile,
|
||||
@@ -428,21 +464,47 @@ const getIconComponent = (title: string) => {
|
||||
return iconMap[title] || IconDesktop
|
||||
}
|
||||
|
||||
const normalizeTrendItems = (payload: any) => {
|
||||
const body = unwrapDetails(payload)
|
||||
return Array.isArray(body?.data) ? body.data : []
|
||||
}
|
||||
|
||||
const loadAlertTrend = async (monthValue: string) => {
|
||||
chartLoading.value = true
|
||||
const days = buildMonthDays(monthValue)
|
||||
try {
|
||||
const { startTime, endTime } = getMonthRange(monthValue)
|
||||
const response = await fetchAlertTrend({
|
||||
granularity: 'day',
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
})
|
||||
const countMap = new Map<string, number>()
|
||||
normalizeTrendItems(response).forEach((item: any) => {
|
||||
countMap.set(String(item.date), Number(item.count) || 0)
|
||||
})
|
||||
trendDates.value = days.map((day) => dayjs(day).format('MM/DD'))
|
||||
trendCounts.value = days.map((day) => countMap.get(day) || 0)
|
||||
} catch (error) {
|
||||
console.warn('告警趋势数据加载失败:', error)
|
||||
trendDates.value = days.map((day) => dayjs(day).format('MM/DD'))
|
||||
trendCounts.value = days.map(() => 0)
|
||||
} finally {
|
||||
chartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理月份选择变更
|
||||
const handleChange = (value: string) => {
|
||||
month.value = value
|
||||
chartLoading.value = true
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
chartLoading.value = false
|
||||
}, 500)
|
||||
loadAlertTrend(value)
|
||||
}
|
||||
|
||||
// 处理统计卡片点击
|
||||
const handleCardClick = (cardTitle: string) => {
|
||||
const routeMap: any = {
|
||||
服务器及PC: '/ops/monitor/server',
|
||||
集群采集: '/ops/monitor/collector',
|
||||
数据库服务: '/ops/dc/database',
|
||||
网络设备: '/ops/monitor/network',
|
||||
警告: '/alert/tackle',
|
||||
工单: '/ops/ticket',
|
||||
@@ -489,30 +551,45 @@ const loadStatistics = async () => {
|
||||
loading.value = true
|
||||
|
||||
// 并行请求所有统计接口和待处理告警
|
||||
const [alertData, collectorData, ticketData, alertListData, reviewStatsData] = await Promise.allSettled([
|
||||
const [
|
||||
alertData,
|
||||
collectorData,
|
||||
ticketData,
|
||||
alertListData,
|
||||
reviewStatsData,
|
||||
networkTotalData,
|
||||
networkEnabledData,
|
||||
] = await Promise.allSettled([
|
||||
fetchAlertCount().catch((e) => ({ error: e, success: false })),
|
||||
fetchCollectorStatistics().catch((e) => ({ error: e, success: false })),
|
||||
fetchFeedbackTicketStatistics().catch((e) => ({ error: e, success: false })),
|
||||
fetchHistories({ page: 1, page_size: 5, status: 'pending' }).catch((e) => ({ error: e, success: false })),
|
||||
fetchReviewStats({ resource_type: 'all' }).catch((e) => ({ error: e, success: false })),
|
||||
fetchNetworkDeviceList({ page: 1, size: 1 }).catch((e) => ({ error: e, success: false })),
|
||||
fetchNetworkDeviceList({ page: 1, size: 1, enabled: true }).catch((e) => ({ error: e, success: false })),
|
||||
])
|
||||
|
||||
// 处理服务器及PC统计数据
|
||||
if (collectorData.status === 'fulfilled' && collectorData.value?.success !== false) {
|
||||
statistics.serverPc = {
|
||||
pending: collectorData.value?.details?.servers?.online || 0,
|
||||
total: collectorData.value?.details?.servers?.total || 0,
|
||||
}
|
||||
statistics.cluster = {
|
||||
pending: collectorData.value?.details?.collectors?.online || 0,
|
||||
total: collectorData.value?.details?.collectors?.total || 0,
|
||||
}
|
||||
const resourceStats = unwrapDetails(collectorData.value)
|
||||
statistics.serverPc = normalizeCountPair(resourceStats.servers)
|
||||
statistics.database = normalizeCountPair(resourceStats.database_services)
|
||||
} else {
|
||||
console.warn('资源统计数据加载失败:', collectorData.status === 'rejected' ? collectorData.reason : collectorData.value?.error)
|
||||
}
|
||||
|
||||
if (
|
||||
networkTotalData.status === 'fulfilled' &&
|
||||
networkTotalData.value?.success !== false &&
|
||||
networkEnabledData.status === 'fulfilled' &&
|
||||
networkEnabledData.value?.success !== false
|
||||
) {
|
||||
statistics.network = {
|
||||
pending: collectorData.value?.details?.network_devices?.online || 0,
|
||||
total: collectorData.value?.details?.network_devices?.total || 0,
|
||||
pending: getPageTotal(networkEnabledData.value),
|
||||
total: getPageTotal(networkTotalData.value),
|
||||
}
|
||||
} else {
|
||||
console.warn('采集器统计数据加载失败:', collectorData.status === 'rejected' ? collectorData.reason : collectorData.value?.error)
|
||||
console.warn('网络设备统计数据加载失败')
|
||||
}
|
||||
|
||||
// 处理告警统计数据
|
||||
@@ -559,7 +636,7 @@ const loadStatistics = async () => {
|
||||
// 启动数字动画
|
||||
const values = [
|
||||
statistics.serverPc.pending,
|
||||
statistics.cluster.pending,
|
||||
statistics.database.pending,
|
||||
statistics.network.pending,
|
||||
statistics.alert.pending,
|
||||
statistics.ticket.pending,
|
||||
@@ -646,6 +723,7 @@ const formatDate = (date: string) => {
|
||||
|
||||
onMounted(() => {
|
||||
loadStatistics()
|
||||
loadAlertTrend(month.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -874,6 +952,13 @@ onMounted(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
|
||||
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-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()
|
||||
|
||||
Reference in New Issue
Block a user