fix
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { request } from "@/api/request"
|
import { request } from "@/api/request"
|
||||||
|
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage"
|
||||||
|
|
||||||
// ============ 通用响应类型 ============
|
// ============ 通用响应类型 ============
|
||||||
|
|
||||||
@@ -17,7 +18,6 @@ export enum ReportType {
|
|||||||
FAULT = 'fault',
|
FAULT = 'fault',
|
||||||
SERVER = 'server',
|
SERVER = 'server',
|
||||||
NETWORK_DEVICE = 'network_device',
|
NETWORK_DEVICE = 'network_device',
|
||||||
HISTORY = 'history',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReportStatus {
|
export enum ReportStatus {
|
||||||
@@ -31,7 +31,8 @@ export enum ReportStatus {
|
|||||||
|
|
||||||
export interface ReportRecord {
|
export interface ReportRecord {
|
||||||
id: number
|
id: number
|
||||||
report_type: ReportType
|
/** 列表接口可能含已下线类型字符串,仅六类可再次生成 */
|
||||||
|
report_type: ReportType | string
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
status: ReportStatus
|
status: ReportStatus
|
||||||
@@ -65,7 +66,11 @@ export interface PageResult<T> {
|
|||||||
// ============ 报表生成参数接口 ============
|
// ============ 报表生成参数接口 ============
|
||||||
|
|
||||||
export interface TrafficReportParams {
|
export interface TrafficReportParams {
|
||||||
topology_id: number
|
/** topology:拓扑/NetFlow;snmp_devices:多设备 SNMP 接口流量汇总 */
|
||||||
|
traffic_mode?: 'topology' | 'snmp_devices'
|
||||||
|
/** traffic_mode=snmp_devices 时必填,service_identity 列表 */
|
||||||
|
service_identities?: string[]
|
||||||
|
topology_id?: number
|
||||||
link_id?: number
|
link_id?: number
|
||||||
node_id?: string
|
node_id?: string
|
||||||
granularity?: 'minute' | 'hour' | 'day' | 'month'
|
granularity?: 'minute' | 'hour' | 'day' | 'month'
|
||||||
@@ -102,9 +107,21 @@ export interface ServerReportParams {
|
|||||||
include_daily_alerts?: boolean
|
include_daily_alerts?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /reports/generate report_type=network_device */
|
||||||
|
export interface NetworkDeviceReportParams {
|
||||||
|
network_device_service_ids?: number[]
|
||||||
|
service_identities?: string[]
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
columns?: string[]
|
||||||
|
include_daily_alerts?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatisticsReportParams {
|
export interface StatisticsReportParams {
|
||||||
data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware'
|
data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware'
|
||||||
metric_name: string
|
/** 与 metric_name 二选一;网络等指标优先用 metric_id */
|
||||||
|
metric_id?: string
|
||||||
|
metric_name?: string
|
||||||
target_identities: string[]
|
target_identities: string[]
|
||||||
start_time: string
|
start_time: string
|
||||||
end_time: string
|
end_time: string
|
||||||
@@ -127,7 +144,8 @@ export interface HistoryReportParams {
|
|||||||
|
|
||||||
export interface TopNReportParams {
|
export interface TopNReportParams {
|
||||||
data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware'
|
data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware'
|
||||||
metric_name: string
|
metric_id?: string
|
||||||
|
metric_name?: string
|
||||||
target_identities: string[]
|
target_identities: string[]
|
||||||
start_time: string
|
start_time: string
|
||||||
end_time: string
|
end_time: string
|
||||||
@@ -142,7 +160,15 @@ export interface GenerateReportParams {
|
|||||||
report_type: ReportType
|
report_type: ReportType
|
||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
params: TrafficReportParams | FaultReportParams | ServerReportParams | StatisticsReportParams | HistoryReportParams | TopNReportParams | Record<string, any>
|
params:
|
||||||
|
| TrafficReportParams
|
||||||
|
| FaultReportParams
|
||||||
|
| ServerReportParams
|
||||||
|
| NetworkDeviceReportParams
|
||||||
|
| StatisticsReportParams
|
||||||
|
| HistoryReportParams
|
||||||
|
| TopNReportParams
|
||||||
|
| Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 报表生成接口(新版) ============
|
// ============ 报表生成接口(新版) ============
|
||||||
@@ -155,20 +181,91 @@ export const fetchReportList = (params: ReportListParams) =>
|
|||||||
export const fetchReportDetail = (id: number) =>
|
export const fetchReportDetail = (id: number) =>
|
||||||
request.get<ApiResponse<ReportRecord>>(`/DC-Control/v1/reports/${id}`)
|
request.get<ApiResponse<ReportRecord>>(`/DC-Control/v1/reports/${id}`)
|
||||||
|
|
||||||
/** 生成报表 */
|
/** 同步生成报表(topn / statistics / traffic / fault / server / network_device) */
|
||||||
export const generateReport = (data: GenerateReportParams) =>
|
export const generateReport = (data: GenerateReportParams) =>
|
||||||
request.post<ApiResponse<ReportRecord>>('/DC-Control/v1/reports/generate', data)
|
request.post<ApiResponse<ReportRecord>>('/DC-Control/v1/reports/generate', data)
|
||||||
|
|
||||||
|
/** 异步生成报表任务 */
|
||||||
|
export const createReportAsyncJob = (data: GenerateReportParams) =>
|
||||||
|
request.post<ApiResponse<ReportRecord>>('/DC-Control/v1/reports/jobs', data)
|
||||||
|
|
||||||
|
/** 指标发现(时间窗内有样本的 metric_name) */
|
||||||
|
export const fetchReportMetricsAvailable = (params: {
|
||||||
|
data_source: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
identities?: string
|
||||||
|
keyword?: string
|
||||||
|
limit?: number
|
||||||
|
}) => request.get<ApiResponse<{ data_source: string; items: any[]; registry: any[] }>>('/DC-Control/v1/reports/metrics/available', { params })
|
||||||
|
|
||||||
|
/** 逻辑指标目录(Registry) */
|
||||||
|
export const fetchReportMetricsRegistry = (params: { data_source: string }) =>
|
||||||
|
request.get<ApiResponse<{ data_source: string; metrics: any[] }>>('/DC-Control/v1/reports/metrics/registry', { params })
|
||||||
|
|
||||||
/** 查看报表内容 */
|
/** 查看报表内容 */
|
||||||
export const fetchReportContent = (id: number) =>
|
export const fetchReportContent = (id: number) =>
|
||||||
request.get<ApiResponse<Record<string, any>>>(`/DC-Control/v1/reports/${id}/content`)
|
request.get<ApiResponse<Record<string, any>>>(`/DC-Control/v1/reports/${id}/content`)
|
||||||
|
|
||||||
/** 导出报表 */
|
/** 原始 ArrayBuffer → 下载用 Blob;xlsx 校验 ZIP 魔数 PK */
|
||||||
export const exportReport = (id: number, format: 'csv' | 'xlsx' = 'csv') =>
|
function exportBufferToBlob(ab: ArrayBuffer, format: 'csv' | 'xlsx', contentType: string | null): Blob {
|
||||||
request.get<Blob>(`/DC-Control/v1/reports/${id}/export`, {
|
const u8 = new Uint8Array(ab)
|
||||||
params: { format },
|
if (format === 'xlsx') {
|
||||||
responseType: 'blob',
|
const okZip = u8.length >= 4 && u8[0] === 0x50 && u8[1] === 0x4b
|
||||||
})
|
if (!okZip) {
|
||||||
|
const head = new TextDecoder('utf-8', { fatal: false }).decode(u8.slice(0, 4096)).trim()
|
||||||
|
let detail = '服务器返回的不是有效的 xlsx(应为 ZIP,文件头 PK)。'
|
||||||
|
if (head.startsWith('{') || head.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(head) as { message?: string }
|
||||||
|
if (j.message) detail = j.message
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} else if (head.startsWith('<')) {
|
||||||
|
detail = '服务器返回了 HTML 而非文件,请检查网关、反向代理或登录态。'
|
||||||
|
}
|
||||||
|
throw new Error(`导出失败:${detail}`)
|
||||||
|
}
|
||||||
|
const mime =
|
||||||
|
contentType && /spreadsheet|zip|octet-stream/i.test(contentType)
|
||||||
|
? contentType
|
||||||
|
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
return new Blob([ab], { type: mime })
|
||||||
|
}
|
||||||
|
const mime =
|
||||||
|
contentType && /csv|text|plain/i.test(contentType) ? contentType : 'text/csv;charset=utf-8'
|
||||||
|
return new Blob([ab], { type: mime })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报表文件导出:使用 fetch + arrayBuffer,绕过 axios 拦截器与 Blob 中间态,
|
||||||
|
* 避免网络面板里已是合法 xlsx(PK…)但落盘文件损坏的情况。
|
||||||
|
*/
|
||||||
|
export const exportReport = async (id: number, format: 'csv' | 'xlsx' = 'csv'): Promise<Blob> => {
|
||||||
|
const base = String(import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '')
|
||||||
|
const url = `${base}/DC-Control/v1/reports/${id}/export?format=${encodeURIComponent(format)}`
|
||||||
|
const token = SafeStorage.get(AppStorageKey.TOKEN)
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Workspace: String(import.meta.env.VITE_APP_WORKSPACE || ''),
|
||||||
|
}
|
||||||
|
if (token) headers.Authorization = String(token)
|
||||||
|
|
||||||
|
const r = await fetch(url, { method: 'GET', headers })
|
||||||
|
const ab = await r.arrayBuffer()
|
||||||
|
if (!r.ok) {
|
||||||
|
const head = new TextDecoder('utf-8', { fatal: false }).decode(new Uint8Array(ab).slice(0, 4096))
|
||||||
|
let msg = `导出失败 (HTTP ${r.status})`
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(head.trim()) as { message?: string }
|
||||||
|
if (j.message) msg = j.message
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
return exportBufferToBlob(ab, format, r.headers.get('content-type'))
|
||||||
|
}
|
||||||
|
|
||||||
// ============ 监测指标类接口(旧版兼容) ============
|
// ============ 监测指标类接口(旧版兼容) ============
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,28 @@ import axios, {
|
|||||||
// 3. 响应拦截器
|
// 3. 响应拦截器
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
|
const cfg = response.config as RequestConfig
|
||||||
|
if (cfg.rawResponse) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
// 二进制流:只返回 data,且勿对 Blob 访问 .status
|
||||||
|
if (cfg.responseType === 'blob' || cfg.responseType === 'arraybuffer') {
|
||||||
|
const body = response.data as unknown
|
||||||
|
if (body instanceof Blob) return body
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
return new Blob([body], {
|
||||||
|
type: (response.headers['content-type'] as string) || 'application/octet-stream',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (body instanceof ArrayBuffer) {
|
||||||
|
return new Blob([body], {
|
||||||
|
type: (response.headers['content-type'] as string) || 'application/octet-stream',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
// 统一处理响应数据格式[2](@ref)
|
// 统一处理响应数据格式[2](@ref)
|
||||||
if (response.data.status === 401) {
|
if (response.data?.status === 401) {
|
||||||
// token过期处理
|
// token过期处理
|
||||||
SafeStorage.clearAppStorage();
|
SafeStorage.clearAppStorage();
|
||||||
window.location.href = "/auth/login";
|
window.location.href = "/auth/login";
|
||||||
@@ -59,6 +79,8 @@ import axios, {
|
|||||||
interface RequestConfig extends AxiosRequestConfig {
|
interface RequestConfig extends AxiosRequestConfig {
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
needWorkspace?: boolean;
|
needWorkspace?: boolean;
|
||||||
|
/** 为 true 时响应拦截器返回完整 AxiosResponse(用于 blob 等需自行取 data 的场景) */
|
||||||
|
rawResponse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const request = {
|
export const request = {
|
||||||
|
|||||||
@@ -1007,21 +1007,6 @@ export const localMenuFlatItems: MenuItem[] = [
|
|||||||
sort_key: 54,
|
sort_key: 54,
|
||||||
created_at: '2025-12-26T13:23:52.533693+08:00',
|
created_at: '2025-12-26T13:23:52.533693+08:00',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 75,
|
|
||||||
identity: '019b591d-0495-7891-8893-5f93b073c4ba',
|
|
||||||
title: '历史报告',
|
|
||||||
title_en: 'History Report',
|
|
||||||
code: 'ops:报表管理:历史报表',
|
|
||||||
description: '报表管理 - 历史报表',
|
|
||||||
app_id: 2,
|
|
||||||
parent_id: 69,
|
|
||||||
menu_path: '/report/history',
|
|
||||||
menu_icon: 'appstore',
|
|
||||||
type: 1,
|
|
||||||
sort_key: 55,
|
|
||||||
created_at: '2025-12-26T13:23:52.597561+08:00',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 70,
|
id: 70,
|
||||||
identity: '019b591d-0446-7270-b3a6-b0a0e4623962',
|
identity: '019b591d-0446-7270-b3a6-b0a0e4623962',
|
||||||
|
|||||||
@@ -1084,22 +1084,6 @@ export const localMenuItems: MenuItem[] = [
|
|||||||
created_at: '2025-12-26T13:23:52.533693+08:00',
|
created_at: '2025-12-26T13:23:52.533693+08:00',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 75,
|
|
||||||
identity: '019b591d-0495-7891-8893-5f93b073c4ba',
|
|
||||||
title: '历史报告',
|
|
||||||
title_en: 'History Report',
|
|
||||||
code: 'ops:报表管理:历史报表',
|
|
||||||
description: '报表管理 - 历史报表',
|
|
||||||
app_id: 2,
|
|
||||||
parent_id: 69,
|
|
||||||
menu_path: '/report/history',
|
|
||||||
menu_icon: 'appstore',
|
|
||||||
type: 1,
|
|
||||||
sort_key: 12,
|
|
||||||
created_at: '2025-12-26T13:23:52.597561+08:00',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 70,
|
id: 70,
|
||||||
identity: '019b591d-0446-7270-b3a6-b0a0e4623962',
|
identity: '019b591d-0446-7270-b3a6-b0a0e4623962',
|
||||||
|
|||||||
@@ -494,13 +494,18 @@ const guides = ref([
|
|||||||
items: ['手动创建拓扑图', '自动发现网络拓扑', '编辑设备连接关系', '设置拓扑图展示样式']
|
items: ['手动创建拓扑图', '自动发现网络拓扑', '编辑设备连接关系', '设置拓扑图展示样式']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '自动感知拓扑图',
|
title: '自动感知',
|
||||||
text: '系统可自动发现网络设备及其连接关系,生成动态拓扑图,实时反映网络架构变化。'
|
text: '系统可自动发现网络设备及其连接关系,生成动态拓扑图,实时反映网络架构变化。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '流量分析管理',
|
title: '流量分析管理',
|
||||||
text: '在"流量分析管理"中查看网络流量数据。',
|
text: '在"流量分析管理"中查看网络流量数据。',
|
||||||
items: ['查看端口流量统计', '分析流量趋势', '识别流量异常', '生成流量报表']
|
items: [
|
||||||
|
'查看端口流量统计',
|
||||||
|
'分析流量趋势',
|
||||||
|
'识别流量异常',
|
||||||
|
'在「报表管理 → 流量统计」生成报表:拓扑模式走 NetFlow/拓扑汇总;多设备 SNMP 选 traffic_mode=snmp_devices,详见 dc-control 文档《报表管理接口文档》',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'IP地址管理',
|
title: 'IP地址管理',
|
||||||
|
|||||||
@@ -30,19 +30,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #enabled="{ record }">
|
<template #enabled="{ record }">
|
||||||
<a-tag :color="record.enabled ? 'green' : 'gray'">
|
<a-tag :color="tagColorOnOff(record.enabled)">
|
||||||
{{ record.enabled ? '已启用' : '已禁用' }}
|
<template v-if="record.enabled">已启用</template>
|
||||||
|
<template v-else>已禁用</template>
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #data_collection="{ record }">
|
<template #data_collection="{ record }">
|
||||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
<a-tag :color="tagColorOnOff(record.collect_on)">
|
||||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
<template v-if="record.collect_on">已启用</template>
|
||||||
|
<template v-else>未启用</template>
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template #collect_method="{ record }">
|
<template #collect_method="{ record }">
|
||||||
<a-tag :color="record.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
<a-tag :color="tagColorCollectMethod(record.collect_method)">
|
||||||
{{ record.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
<template v-if="record.collect_method === 'snmp'">SNMP</template>
|
||||||
|
<template v-else>API</template>
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -128,6 +131,11 @@ const pagination = reactive({
|
|||||||
const formItems = computed<FormItem[]>(() => searchFormConfig)
|
const formItems = computed<FormItem[]>(() => searchFormConfig)
|
||||||
const columns = computed(() => columnsConfig)
|
const columns = computed(() => columnsConfig)
|
||||||
|
|
||||||
|
/** 避免在模板 :color 中写 ? : 三元,部分 Vue/Vite 版本会误解析冒号 */
|
||||||
|
const tagColorOnOff = (on: boolean | undefined) => (on ? 'green' : 'gray')
|
||||||
|
const tagColorCollectMethod = (m: string | undefined) =>
|
||||||
|
m === 'snmp' ? 'purple' : 'arcoblue'
|
||||||
|
|
||||||
const fetchRoomDeviceData = async () => {
|
const fetchRoomDeviceData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<h2>自动感知拓扑图</h2>
|
<h2>自动感知</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<a-select
|
<a-select
|
||||||
|
|||||||
@@ -58,6 +58,12 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="reportStatusColor(record.status)">
|
||||||
|
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template #operations="{ record }">
|
<template #operations="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -89,57 +95,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</search-table>
|
</search-table>
|
||||||
|
|
||||||
<!-- 生成报表弹窗 -->
|
<!-- 生成报表弹窗(与后端 network_device 参数一致:service_identities 或 network_device_service_ids) -->
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:visible="generateModalVisible"
|
v-model:visible="generateModalVisible"
|
||||||
title="生成网络设备报表"
|
title="生成网络设备报表"
|
||||||
:width="600"
|
:width="640"
|
||||||
:ok-loading="generating"
|
:ok-loading="generating"
|
||||||
@ok="handleGenerate"
|
@ok="handleGenerate"
|
||||||
@cancel="handleCloseGenerateModal"
|
@cancel="handleCloseGenerateModal"
|
||||||
>
|
>
|
||||||
<a-form :model="generateForm" layout="vertical">
|
<a-form :model="generateForm" layout="vertical">
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="拓扑 ID" field="topology_id" :rules="[{ required: true, message: '请输入拓扑 ID' }]">
|
|
||||||
<a-input-number
|
|
||||||
v-model="generateForm.topology_id"
|
|
||||||
placeholder="请输入拓扑 ID"
|
|
||||||
:min="1"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="链路 ID" field="link_id">
|
|
||||||
<a-input-number
|
|
||||||
v-model="generateForm.link_id"
|
|
||||||
placeholder="可选,0 表示整拓扑"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="节点 ID" field="node_id">
|
|
||||||
<a-input v-model="generateForm.node_id" placeholder="可选" style="width: 100%" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="时间粒度" field="granularity">
|
|
||||||
<a-select v-model="generateForm.granularity" placeholder="请选择" style="width: 100%">
|
|
||||||
<a-option value="minute">分钟</a-option>
|
|
||||||
<a-option value="hour">小时</a-option>
|
|
||||||
<a-option value="day">天</a-option>
|
|
||||||
<a-option value="month">月</a-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
|
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
|
||||||
<a-range-picker
|
<a-range-picker
|
||||||
v-model="generateForm.timeRange"
|
v-model="generateForm.timeRange"
|
||||||
@@ -150,78 +115,47 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider orientation="left">设备范围(二选一)</a-divider>
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="报表形态" field="report_shape" :rules="[{ required: true, message: '请选择报表形态' }]">
|
<a-form-item label="网络设备服务 ID" field="network_device_service_ids">
|
||||||
<a-select v-model="generateForm.report_shape" placeholder="请选择" style="width: 100%">
|
<a-select
|
||||||
<a-option value="summary">汇总</a-option>
|
v-model="generateForm.network_device_service_ids"
|
||||||
<a-option value="detail">明细</a-option>
|
multiple
|
||||||
<a-option value="trend">趋势</a-option>
|
allow-clear
|
||||||
<a-option value="top">Top 排名</a-option>
|
allow-search
|
||||||
</a-select>
|
:loading="networkDeviceOptionsLoading"
|
||||||
|
:options="networkDeviceIdOptions"
|
||||||
|
placeholder="按库表主键选择"
|
||||||
|
:max-tag-count="3"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="报表标题" field="title">
|
<a-form-item label="服务标识 service_identity" field="service_identities">
|
||||||
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
|
<a-select
|
||||||
|
v-model="generateForm.service_identities"
|
||||||
|
multiple
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="networkDeviceOptionsLoading"
|
||||||
|
:options="networkDeviceIdentityOptions"
|
||||||
|
placeholder="按采集标识选择"
|
||||||
|
:max-tag-count="3"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<!-- Top 排名额外参数 -->
|
<a-form-item label="包含每日告警统计" field="include_daily_alerts">
|
||||||
<template v-if="generateForm.report_shape === 'top'">
|
<a-switch v-model="generateForm.include_daily_alerts" />
|
||||||
<a-row :gutter="16">
|
</a-form-item>
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="排序字段" field="top_order_by">
|
|
||||||
<a-select v-model="generateForm.top_order_by" placeholder="请选择" style="width: 100%">
|
|
||||||
<a-option value="total_bytes">总流量</a-option>
|
|
||||||
<a-option value="total_in_bytes">总入流量</a-option>
|
|
||||||
<a-option value="total_out_bytes">总出流量</a-option>
|
|
||||||
<a-option value="total_packets">总包数</a-option>
|
|
||||||
<a-option value="avg_latency">平均延迟</a-option>
|
|
||||||
<a-option value="avg_packet_loss">平均丢包率</a-option>
|
|
||||||
<a-option value="total_connections">总连接数</a-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="Top 数量" field="top_limit">
|
|
||||||
<a-input-number
|
|
||||||
v-model="generateForm.top_limit"
|
|
||||||
placeholder="1-100"
|
|
||||||
:min="1"
|
|
||||||
:max="100"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 明细额外参数 -->
|
<a-form-item label="报表标题" field="title">
|
||||||
<template v-if="generateForm.report_shape === 'detail'">
|
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
|
||||||
<a-form-item label="明细条数限制" field="detail_limit">
|
</a-form-item>
|
||||||
<a-input-number
|
|
||||||
v-model="generateForm.detail_limit"
|
|
||||||
placeholder="1-50000"
|
|
||||||
:min="1"
|
|
||||||
:max="50000"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 趋势额外参数 -->
|
|
||||||
<template v-if="generateForm.report_shape === 'trend'">
|
|
||||||
<a-form-item label="趋势粒度" field="trend_granularity">
|
|
||||||
<a-select v-model="generateForm.trend_granularity" placeholder="请选择" style="width: 100%">
|
|
||||||
<a-option value="minute">分钟</a-option>
|
|
||||||
<a-option value="hour">小时</a-option>
|
|
||||||
<a-option value="day">天</a-option>
|
|
||||||
<a-option value="month">月</a-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</template>
|
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
@@ -236,8 +170,34 @@
|
|||||||
<a-spin />
|
<a-spin />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="reportContent">
|
<div v-else-if="reportContent">
|
||||||
<!-- 汇总数据 -->
|
<!-- 网络设备 / 通用行表(与引擎 payload.rows 一致) -->
|
||||||
<template v-if="reportContentType === 'summary'">
|
<template v-if="reportContentType === 'rows'">
|
||||||
|
<a-card
|
||||||
|
v-if="reportContent.result_meta && Object.keys(reportContent.result_meta).length"
|
||||||
|
title="说明"
|
||||||
|
:bordered="false"
|
||||||
|
class="summary-card"
|
||||||
|
>
|
||||||
|
<a-descriptions :column="1" bordered size="small">
|
||||||
|
<a-descriptions-item
|
||||||
|
v-for="(value, key) in reportContent.result_meta"
|
||||||
|
:key="String(key)"
|
||||||
|
:label="formatLabel(String(key))"
|
||||||
|
>
|
||||||
|
{{ formatValue(String(key), value) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-card>
|
||||||
|
<a-table
|
||||||
|
:data="reportContent.rows || []"
|
||||||
|
:columns="contentTableColumns"
|
||||||
|
:pagination="{ pageSize: 10 }"
|
||||||
|
stripe
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 汇总数据(旧流量形态占位) -->
|
||||||
|
<template v-else-if="reportContentType === 'summary'">
|
||||||
<a-card v-if="reportContent.totals" title="设备汇总" :bordered="false" class="summary-card">
|
<a-card v-if="reportContent.totals" title="设备汇总" :bordered="false" class="summary-card">
|
||||||
<a-descriptions :column="3" bordered>
|
<a-descriptions :column="3" bordered>
|
||||||
<a-descriptions-item
|
<a-descriptions-item
|
||||||
@@ -299,9 +259,18 @@ import {
|
|||||||
exportReport,
|
exportReport,
|
||||||
ReportType,
|
ReportType,
|
||||||
type ReportRecord,
|
type ReportRecord,
|
||||||
type TrafficReportParams,
|
type NetworkDeviceReportParams,
|
||||||
} from '@/api/ops/report'
|
} from '@/api/ops/report'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||||
|
import { useReportNetworkDevicePickOptions } from '../useReportNetworkDevicePickOptions'
|
||||||
|
|
||||||
|
const {
|
||||||
|
networkDeviceIdOptions,
|
||||||
|
networkDeviceIdentityOptions,
|
||||||
|
networkDeviceOptionsLoading,
|
||||||
|
loadNetworkDevicePickOptions,
|
||||||
|
} = useReportNetworkDevicePickOptions()
|
||||||
|
|
||||||
// 页面标题
|
// 页面标题
|
||||||
const pageTitle = '网络设备报表'
|
const pageTitle = '网络设备报表'
|
||||||
@@ -402,29 +371,17 @@ const tableColumns = computed(() => [
|
|||||||
// 生成报表弹窗
|
// 生成报表弹窗
|
||||||
const generateModalVisible = ref(false)
|
const generateModalVisible = ref(false)
|
||||||
const generateForm = ref<{
|
const generateForm = ref<{
|
||||||
topology_id: number | undefined
|
network_device_service_ids: number[]
|
||||||
link_id: number | undefined
|
service_identities: string[]
|
||||||
node_id: string
|
|
||||||
granularity: string
|
|
||||||
timeRange: string[]
|
timeRange: string[]
|
||||||
report_shape: string
|
include_daily_alerts: boolean
|
||||||
title: string
|
title: string
|
||||||
top_order_by: string
|
|
||||||
top_limit: number | undefined
|
|
||||||
detail_limit: number | undefined
|
|
||||||
trend_granularity: string
|
|
||||||
}>({
|
}>({
|
||||||
topology_id: undefined,
|
network_device_service_ids: [],
|
||||||
link_id: undefined,
|
service_identities: [],
|
||||||
node_id: '',
|
|
||||||
granularity: 'hour',
|
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
report_shape: 'summary',
|
include_daily_alerts: false,
|
||||||
title: '',
|
title: '',
|
||||||
top_order_by: 'total_bytes',
|
|
||||||
top_limit: undefined,
|
|
||||||
detail_limit: undefined,
|
|
||||||
trend_granularity: 'hour',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 查看内容弹窗
|
// 查看内容弹窗
|
||||||
@@ -440,6 +397,7 @@ const contentTableColumns = computed(() => {
|
|||||||
if (!reportContent.value) return []
|
if (!reportContent.value) return []
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
|
reportContent.value.rows ||
|
||||||
reportContent.value.by_node ||
|
reportContent.value.by_node ||
|
||||||
reportContent.value.items ||
|
reportContent.value.items ||
|
||||||
reportContent.value.ranking ||
|
reportContent.value.ranking ||
|
||||||
@@ -482,7 +440,7 @@ const fetchList = async () => {
|
|||||||
const res = await fetchReportList(params)
|
const res = await fetchReportList(params)
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
tableData.value = res.details.data || []
|
tableData.value = normalizeReportRows(res.details.data || [])
|
||||||
pagination.total = res.details.total || 0
|
pagination.total = res.details.total || 0
|
||||||
} else {
|
} else {
|
||||||
Message.error(res.message || '获取报表列表失败')
|
Message.error(res.message || '获取报表列表失败')
|
||||||
@@ -530,18 +488,13 @@ const handleRefresh = () => {
|
|||||||
// 打开生成报表弹窗
|
// 打开生成报表弹窗
|
||||||
const handleOpenGenerateModal = () => {
|
const handleOpenGenerateModal = () => {
|
||||||
generateForm.value = {
|
generateForm.value = {
|
||||||
topology_id: undefined,
|
network_device_service_ids: [],
|
||||||
link_id: undefined,
|
service_identities: [],
|
||||||
node_id: '',
|
|
||||||
granularity: 'hour',
|
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
report_shape: 'summary',
|
include_daily_alerts: false,
|
||||||
title: '',
|
title: '',
|
||||||
top_order_by: 'total_bytes',
|
|
||||||
top_limit: undefined,
|
|
||||||
detail_limit: undefined,
|
|
||||||
trend_granularity: 'hour',
|
|
||||||
}
|
}
|
||||||
|
void loadNetworkDevicePickOptions()
|
||||||
generateModalVisible.value = true
|
generateModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,54 +505,37 @@ const handleCloseGenerateModal = () => {
|
|||||||
|
|
||||||
// 生成报表
|
// 生成报表
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
// 验证必填项
|
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
||||||
if (!generateForm.value.topology_id) {
|
Message.warning('请选择时间范围')
|
||||||
Message.warning('请输入拓扑 ID')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
const ids = generateForm.value.network_device_service_ids || []
|
||||||
Message.warning('请选择时间范围')
|
const idents = generateForm.value.service_identities || []
|
||||||
|
if (ids.length === 0 && idents.length === 0) {
|
||||||
|
Message.warning('请选择网络设备(服务 ID 或 service_identity)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ids.length > 0 && idents.length > 0) {
|
||||||
|
Message.warning('服务 ID 与 service_identity 请勿同时选择')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
generating.value = true
|
generating.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params: TrafficReportParams = {
|
const params: NetworkDeviceReportParams = {
|
||||||
topology_id: generateForm.value.topology_id,
|
|
||||||
start_time: generateForm.value.timeRange[0],
|
start_time: generateForm.value.timeRange[0],
|
||||||
end_time: generateForm.value.timeRange[1],
|
end_time: generateForm.value.timeRange[1],
|
||||||
report_shape: generateForm.value.report_shape as any,
|
|
||||||
}
|
}
|
||||||
|
if (ids.length > 0) {
|
||||||
if (generateForm.value.link_id) {
|
params.network_device_service_ids = ids
|
||||||
params.link_id = generateForm.value.link_id
|
|
||||||
}
|
}
|
||||||
|
if (idents.length > 0) {
|
||||||
if (generateForm.value.node_id) {
|
params.service_identities = idents
|
||||||
params.node_id = generateForm.value.node_id
|
|
||||||
}
|
}
|
||||||
|
if (generateForm.value.include_daily_alerts) {
|
||||||
if (generateForm.value.granularity) {
|
params.include_daily_alerts = true
|
||||||
params.granularity = generateForm.value.granularity as any
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateForm.value.report_shape === 'detail' && generateForm.value.detail_limit) {
|
|
||||||
params.detail_limit = generateForm.value.detail_limit
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateForm.value.report_shape === 'trend' && generateForm.value.trend_granularity) {
|
|
||||||
params.trend_granularity = generateForm.value.trend_granularity as any
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateForm.value.report_shape === 'top') {
|
|
||||||
if (generateForm.value.top_order_by) {
|
|
||||||
params.top_order_by = generateForm.value.top_order_by as any
|
|
||||||
}
|
|
||||||
if (generateForm.value.top_limit) {
|
|
||||||
params.top_limit = generateForm.value.top_limit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await generateReport({
|
const res = await generateReport({
|
||||||
@@ -646,9 +582,13 @@ const handleViewContent = async (record?: ReportRecord) => {
|
|||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
reportContent.value = res.details
|
reportContent.value = res.details
|
||||||
reportContentType.value = targetRecord.params_json?.report_shape || 'summary'
|
const rows = res.details?.rows
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
reportContentType.value = 'rows'
|
||||||
|
} else {
|
||||||
|
reportContentType.value = targetRecord.params_json?.report_shape || 'summary'
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是趋势报表,渲染图表
|
|
||||||
if (reportContentType.value === 'trend' && res.details.series) {
|
if (reportContentType.value === 'trend' && res.details.series) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
renderChart(res.details.series)
|
renderChart(res.details.series)
|
||||||
@@ -705,6 +645,15 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
|
|||||||
// 格式化标签
|
// 格式化标签
|
||||||
const formatLabel = (key: string) => {
|
const formatLabel = (key: string) => {
|
||||||
const labelMap: Record<string, string> = {
|
const labelMap: Record<string, string> = {
|
||||||
|
service_identity: '服务标识',
|
||||||
|
availability: '可用性',
|
||||||
|
cpu_avg: 'CPU 均值',
|
||||||
|
memory_avg: '内存均值',
|
||||||
|
success_rate: '接口 up 占比(%)',
|
||||||
|
jitter_bps_stddev: '速率抖动(标准差)',
|
||||||
|
daily_alerts: '当日告警数',
|
||||||
|
avg_response_time_ms: '平均响应(ms)',
|
||||||
|
uptime_seconds: '运行时长(秒)',
|
||||||
node_id: '节点 ID',
|
node_id: '节点 ID',
|
||||||
total_in_bytes: '总入流量',
|
total_in_bytes: '总入流量',
|
||||||
total_out_bytes: '总出流量',
|
total_out_bytes: '总出流量',
|
||||||
|
|||||||
@@ -58,6 +58,12 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="reportStatusColor(record.status)">
|
||||||
|
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template #operations="{ record }">
|
<template #operations="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -131,9 +137,15 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="服务标识" field="service_identities">
|
<a-form-item label="服务标识" field="service_identities">
|
||||||
<a-input-tag
|
<a-select
|
||||||
v-model="generateForm.service_identities"
|
v-model="generateForm.service_identities"
|
||||||
placeholder="输入后按回车添加"
|
multiple
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="faultServiceOptionsLoading"
|
||||||
|
:options="faultServiceIdentityOptions"
|
||||||
|
placeholder="可选;不选表示不过滤服务"
|
||||||
|
:max-tag-count="3"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -207,8 +219,20 @@
|
|||||||
<a-spin />
|
<a-spin />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="reportContent">
|
<div v-else-if="reportContent">
|
||||||
|
<!-- report_engine:incident 合并 -->
|
||||||
|
<template v-if="reportContent.incidents && reportContent.incidents.length">
|
||||||
|
<a-alert type="info" style="margin-bottom: 12px">
|
||||||
|
基于 dedupe_key 聚合的故障 incident(起止为同键内首末 created_at)
|
||||||
|
</a-alert>
|
||||||
|
<a-table
|
||||||
|
:data="reportContent.incidents"
|
||||||
|
:columns="incidentColumns"
|
||||||
|
:pagination="{ pageSize: 15 }"
|
||||||
|
stripe
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<!-- 分组统计 -->
|
<!-- 分组统计 -->
|
||||||
<template v-if="reportContent.grouped">
|
<template v-else-if="reportContent.grouped">
|
||||||
<a-card v-if="reportContent.summary" title="故障汇总" :bordered="false" class="summary-card">
|
<a-card v-if="reportContent.summary" title="故障汇总" :bordered="false" class="summary-card">
|
||||||
<a-descriptions :column="3" bordered>
|
<a-descriptions :column="3" bordered>
|
||||||
<a-descriptions-item
|
<a-descriptions-item
|
||||||
@@ -257,6 +281,14 @@ import {
|
|||||||
type ReportRecord,
|
type ReportRecord,
|
||||||
type FaultReportParams,
|
type FaultReportParams,
|
||||||
} from '@/api/ops/report'
|
} from '@/api/ops/report'
|
||||||
|
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||||
|
import { useFaultReportServiceIdentityOptions } from '../useReportTargetIdentityOptions'
|
||||||
|
|
||||||
|
const {
|
||||||
|
faultServiceIdentityOptions,
|
||||||
|
faultServiceOptionsLoading,
|
||||||
|
loadFaultServiceIdentityOptions,
|
||||||
|
} = useFaultReportServiceIdentityOptions()
|
||||||
|
|
||||||
// 页面标题
|
// 页面标题
|
||||||
const pageTitle = '故障报表'
|
const pageTitle = '故障报表'
|
||||||
@@ -417,6 +449,17 @@ const groupTableColumns = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const incidentColumns = [
|
||||||
|
{ title: 'DedupeKey', dataIndex: 'dedupe_key', width: 200, ellipsis: true, tooltip: true },
|
||||||
|
{ title: '告警名', dataIndex: 'alert_name', width: 160 },
|
||||||
|
{ title: '类型', dataIndex: 'category', width: 100 },
|
||||||
|
{ title: '级别', dataIndex: 'severity_code', width: 90 },
|
||||||
|
{ title: '次数', dataIndex: 'occurrences', width: 80 },
|
||||||
|
{ title: '开始', dataIndex: 'started_at', width: 170 },
|
||||||
|
{ title: '结束', dataIndex: 'ended_at', width: 170 },
|
||||||
|
{ title: '持续(s)', dataIndex: 'duration_sec', width: 90 },
|
||||||
|
]
|
||||||
|
|
||||||
// 获取报表列表
|
// 获取报表列表
|
||||||
const fetchList = async () => {
|
const fetchList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -444,7 +487,7 @@ const fetchList = async () => {
|
|||||||
const res = await fetchReportList(params)
|
const res = await fetchReportList(params)
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
tableData.value = res.details.data || []
|
tableData.value = normalizeReportRows(res.details.data || [])
|
||||||
pagination.total = res.details.total || 0
|
pagination.total = res.details.total || 0
|
||||||
} else {
|
} else {
|
||||||
Message.error(res.message || '获取报表列表失败')
|
Message.error(res.message || '获取报表列表失败')
|
||||||
@@ -503,6 +546,7 @@ const handleOpenGenerateModal = () => {
|
|||||||
alert_severities: [],
|
alert_severities: [],
|
||||||
include_raw_messages: false,
|
include_raw_messages: false,
|
||||||
}
|
}
|
||||||
|
void loadFaultServiceIdentityOptions()
|
||||||
generateModalVisible.value = true
|
generateModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,699 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="wrap">
|
||||||
<search-table
|
<a-result
|
||||||
:form-model="formModel"
|
status="info"
|
||||||
:form-items="formItems"
|
title="历史报表入口已下线"
|
||||||
:data="tableData"
|
sub-title="多指标、多目标时序请使用「统计报告」:output_mode=timeseries,并按接口文档配置 interval 与 bucket_aggregation。旧类型记录仍可在各报表列表中按 report_type 筛选查看(若库中有数据)。"
|
||||||
:columns="tableColumns"
|
|
||||||
:loading="loading"
|
|
||||||
:pagination="pagination"
|
|
||||||
:title="pageTitle"
|
|
||||||
@update:form-model="handleFormModelUpdate"
|
|
||||||
@search="handleSearch"
|
|
||||||
@reset="handleReset"
|
|
||||||
@refresh="handleRefresh"
|
|
||||||
>
|
>
|
||||||
<!-- 自定义表单项:创建时间范围 -->
|
<template #extra>
|
||||||
<template #form-items>
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-form-item label="创建时间" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
|
|
||||||
<a-range-picker
|
|
||||||
v-model="formModel.timeRange"
|
|
||||||
show-time
|
|
||||||
format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 工具栏左侧:生成报表按钮 -->
|
|
||||||
<template #toolbar-left>
|
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="primary" @click="handleOpenGenerateModal">
|
<a-button type="primary" @click="goStatistics">前往统计报告</a-button>
|
||||||
<template #icon><icon-file-add /></template>
|
<a-button @click="goTopn">前往 TopN</a-button>
|
||||||
生成报表
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
</a-result>
|
||||||
<!-- 工具栏右侧:查看和导出按钮 -->
|
|
||||||
<template #toolbar-right>
|
|
||||||
<a-space>
|
|
||||||
<a-button v-if="selectedRecord" type="outline" @click="handleViewContent()">
|
|
||||||
<template #icon><icon-eye /></template>
|
|
||||||
查看内容
|
|
||||||
</a-button>
|
|
||||||
<a-dropdown v-if="selectedRecord">
|
|
||||||
<a-button type="outline">
|
|
||||||
<template #icon><icon-download /></template>
|
|
||||||
导出
|
|
||||||
</a-button>
|
|
||||||
<template #content>
|
|
||||||
<a-doption @click="handleExport('csv')">导出 CSV</a-doption>
|
|
||||||
<a-doption @click="handleExport('xlsx')">导出 Excel</a-doption>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 操作列 -->
|
|
||||||
<template #operations="{ record }">
|
|
||||||
<a-space>
|
|
||||||
<a-button
|
|
||||||
v-if="record.status === 'success'"
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
@click="handleViewContent(record)"
|
|
||||||
>
|
|
||||||
查看
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
v-if="record.status === 'success'"
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
@click="handleExport('csv', record)"
|
|
||||||
>
|
|
||||||
CSV
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
v-if="record.status === 'success'"
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
@click="handleExport('xlsx', record)"
|
|
||||||
>
|
|
||||||
Excel
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</search-table>
|
|
||||||
|
|
||||||
<!-- 生成报表弹窗 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:visible="generateModalVisible"
|
|
||||||
title="生成历史报表"
|
|
||||||
:width="600"
|
|
||||||
:ok-loading="generating"
|
|
||||||
@ok="handleGenerate"
|
|
||||||
@cancel="handleCloseGenerateModal"
|
|
||||||
>
|
|
||||||
<a-form :model="generateForm" layout="vertical">
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item
|
|
||||||
label="数据源"
|
|
||||||
field="data_source"
|
|
||||||
:rules="[{ required: true, message: '请选择数据源' }]"
|
|
||||||
>
|
|
||||||
<a-select v-model="generateForm.data_source" placeholder="请选择" style="width: 100%">
|
|
||||||
<a-option value="dc-host">主机</a-option>
|
|
||||||
<a-option value="dc-network">网络</a-option>
|
|
||||||
<a-option value="dc-database">数据库</a-option>
|
|
||||||
<a-option value="dc-middleware">中间件</a-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="报表标题" field="title">
|
|
||||||
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<a-form-item
|
|
||||||
label="指标名称"
|
|
||||||
field="metric_names"
|
|
||||||
:rules="[{ required: true, message: '请输入指标名称' }]"
|
|
||||||
>
|
|
||||||
<a-input-tag
|
|
||||||
v-model="generateForm.metric_names"
|
|
||||||
placeholder="输入后按回车添加(1-20个)"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item
|
|
||||||
label="目标标识"
|
|
||||||
field="target_identities"
|
|
||||||
:rules="[{ required: true, message: '请输入目标标识' }]"
|
|
||||||
>
|
|
||||||
<a-input-tag
|
|
||||||
v-model="generateForm.target_identities"
|
|
||||||
placeholder="输入后按回车添加"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
|
|
||||||
<a-range-picker
|
|
||||||
v-model="generateForm.timeRange"
|
|
||||||
show-time
|
|
||||||
format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item
|
|
||||||
label="时间间隔"
|
|
||||||
field="interval"
|
|
||||||
:rules="[{ required: true, message: '请输入时间间隔' }]"
|
|
||||||
>
|
|
||||||
<a-input v-model="generateForm.interval" placeholder="如: 1 hour, 5 minutes" style="width: 100%" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item
|
|
||||||
label="桶聚合"
|
|
||||||
field="bucket_aggregation"
|
|
||||||
:rules="[{ required: true, message: '请选择桶聚合' }]"
|
|
||||||
>
|
|
||||||
<a-select v-model="generateForm.bucket_aggregation" placeholder="请选择" style="width: 100%">
|
|
||||||
<a-option value="avg">平均值</a-option>
|
|
||||||
<a-option value="max">最大值</a-option>
|
|
||||||
<a-option value="min">最小值</a-option>
|
|
||||||
<a-option value="sum">求和</a-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
|
|
||||||
<!-- 查看内容弹窗 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:visible="contentModalVisible"
|
|
||||||
:title="contentModalTitle"
|
|
||||||
:width="900"
|
|
||||||
:footer="false"
|
|
||||||
>
|
|
||||||
<div v-if="contentLoading" class="loading-container">
|
|
||||||
<a-spin />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="reportContent">
|
|
||||||
<!-- 图表展示 -->
|
|
||||||
<div ref="chartRef" class="chart-container"></div>
|
|
||||||
|
|
||||||
<!-- 数据表格 -->
|
|
||||||
<a-table
|
|
||||||
:data="reportContent.series || []"
|
|
||||||
:columns="seriesTableColumns"
|
|
||||||
:pagination="{ pageSize: 10 }"
|
|
||||||
stripe
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<a-empty v-else description="暂无数据" />
|
|
||||||
</a-modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { useRouter } from 'vue-router'
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
import SearchTable from '@/components/search-table/index.vue'
|
|
||||||
import type { FormItem } from '@/components/search-form/types'
|
|
||||||
import {
|
|
||||||
fetchReportList,
|
|
||||||
generateReport,
|
|
||||||
fetchReportContent,
|
|
||||||
exportReport,
|
|
||||||
ReportType,
|
|
||||||
type ReportRecord,
|
|
||||||
type HistoryReportParams,
|
|
||||||
} from '@/api/ops/report'
|
|
||||||
import * as echarts from 'echarts'
|
|
||||||
|
|
||||||
// 页面标题
|
const router = useRouter()
|
||||||
const pageTitle = '历史报表'
|
|
||||||
|
|
||||||
// 列表筛选表单模型
|
const goStatistics = () => {
|
||||||
const formModel = ref<{
|
router.push('/report/statistics')
|
||||||
report_type: string
|
|
||||||
status: string
|
|
||||||
keyword: string
|
|
||||||
timeRange: string[]
|
|
||||||
}>({
|
|
||||||
report_type: ReportType.HISTORY,
|
|
||||||
status: '',
|
|
||||||
keyword: '',
|
|
||||||
timeRange: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单项配置
|
|
||||||
const formItems = computed<FormItem[]>(() => [
|
|
||||||
{
|
|
||||||
field: 'status',
|
|
||||||
label: '状态',
|
|
||||||
type: 'select',
|
|
||||||
span: 8,
|
|
||||||
placeholder: '请选择',
|
|
||||||
options: [
|
|
||||||
{ value: '', label: '全部' },
|
|
||||||
{ value: 'pending', label: '等待中' },
|
|
||||||
{ value: 'running', label: '生成中' },
|
|
||||||
{ value: 'success', label: '成功' },
|
|
||||||
{ value: 'failed', label: '失败' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'keyword',
|
|
||||||
label: '标题',
|
|
||||||
type: 'input',
|
|
||||||
span: 8,
|
|
||||||
placeholder: '请输入标题关键字',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
const loading = ref(false)
|
|
||||||
const generating = ref(false)
|
|
||||||
const contentLoading = ref(false)
|
|
||||||
const exporting = ref(false)
|
|
||||||
const tableData = ref<ReportRecord[]>([])
|
|
||||||
const selectedRecord = ref<ReportRecord | null>(null)
|
|
||||||
|
|
||||||
// 分页配置
|
|
||||||
const pagination = reactive({
|
|
||||||
current: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
showTotal: true,
|
|
||||||
showJumper: true,
|
|
||||||
showPageSize: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const tableColumns = computed(() => [
|
|
||||||
{
|
|
||||||
title: 'ID',
|
|
||||||
dataIndex: 'id',
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标题',
|
|
||||||
dataIndex: 'title',
|
|
||||||
width: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 100,
|
|
||||||
slotName: 'status',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创建时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '更新时间',
|
|
||||||
dataIndex: 'updated_at',
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
dataIndex: 'operations',
|
|
||||||
width: 180,
|
|
||||||
slotName: 'operations',
|
|
||||||
fixed: 'right' as const,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 生成报表弹窗
|
|
||||||
const generateModalVisible = ref(false)
|
|
||||||
const generateForm = ref<{
|
|
||||||
data_source: string
|
|
||||||
metric_names: string[]
|
|
||||||
target_identities: string[]
|
|
||||||
timeRange: string[]
|
|
||||||
interval: string
|
|
||||||
bucket_aggregation: string
|
|
||||||
title: string
|
|
||||||
}>({
|
|
||||||
data_source: '',
|
|
||||||
metric_names: [],
|
|
||||||
target_identities: [],
|
|
||||||
timeRange: [],
|
|
||||||
interval: '',
|
|
||||||
bucket_aggregation: 'avg',
|
|
||||||
title: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 查看内容弹窗
|
|
||||||
const contentModalVisible = ref(false)
|
|
||||||
const contentModalTitle = ref('')
|
|
||||||
const reportContent = ref<Record<string, any> | null>(null)
|
|
||||||
const chartRef = ref<HTMLElement | null>(null)
|
|
||||||
let chartInstance: echarts.ECharts | null = null
|
|
||||||
|
|
||||||
// 时间序列表格列配置
|
|
||||||
const seriesTableColumns = computed(() => [
|
|
||||||
{
|
|
||||||
title: '指标名称',
|
|
||||||
dataIndex: 'metric_name',
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '时间',
|
|
||||||
dataIndex: 'timestamp',
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '值',
|
|
||||||
dataIndex: 'value',
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 获取报表列表
|
|
||||||
const fetchList = async () => {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params: any = {
|
|
||||||
page: pagination.current,
|
|
||||||
size: pagination.pageSize,
|
|
||||||
report_type: ReportType.HISTORY,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formModel.value.status) {
|
|
||||||
params.status = formModel.value.status
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formModel.value.keyword) {
|
|
||||||
params.keyword = formModel.value.keyword
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formModel.value.timeRange && formModel.value.timeRange.length === 2) {
|
|
||||||
params.created_from = formModel.value.timeRange[0]
|
|
||||||
params.created_to = formModel.value.timeRange[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetchReportList(params)
|
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
|
||||||
tableData.value = res.details.data || []
|
|
||||||
pagination.total = res.details.total || 0
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '获取报表列表失败')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('获取报表列表失败:', error)
|
|
||||||
Message.error(error.message || '获取报表列表失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理表单模型更新
|
const goTopn = () => {
|
||||||
const handleFormModelUpdate = (value: any) => {
|
router.push('/report/topn')
|
||||||
formModel.value = {
|
|
||||||
...formModel.value,
|
|
||||||
...value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询
|
|
||||||
const handleSearch = () => {
|
|
||||||
pagination.current = 1
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
const handleReset = () => {
|
|
||||||
formModel.value = {
|
|
||||||
report_type: ReportType.HISTORY,
|
|
||||||
status: '',
|
|
||||||
keyword: '',
|
|
||||||
timeRange: [],
|
|
||||||
}
|
|
||||||
pagination.current = 1
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新
|
|
||||||
const handleRefresh = () => {
|
|
||||||
fetchList()
|
|
||||||
Message.success('数据已刷新')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开生成报表弹窗
|
|
||||||
const handleOpenGenerateModal = () => {
|
|
||||||
generateForm.value = {
|
|
||||||
data_source: '',
|
|
||||||
metric_names: [],
|
|
||||||
target_identities: [],
|
|
||||||
timeRange: [],
|
|
||||||
interval: '',
|
|
||||||
bucket_aggregation: 'avg',
|
|
||||||
title: '',
|
|
||||||
}
|
|
||||||
generateModalVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭生成报表弹窗
|
|
||||||
const handleCloseGenerateModal = () => {
|
|
||||||
generateModalVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成报表
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
// 验证必填项
|
|
||||||
if (!generateForm.value.data_source) {
|
|
||||||
Message.warning('请选择数据源')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateForm.value.metric_names.length === 0) {
|
|
||||||
Message.warning('请输入指标名称')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateForm.value.metric_names.length > 20) {
|
|
||||||
Message.warning('指标名称最多20个')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateForm.value.target_identities.length === 0) {
|
|
||||||
Message.warning('请输入目标标识')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
|
||||||
Message.warning('请选择时间范围')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!generateForm.value.interval) {
|
|
||||||
Message.warning('请输入时间间隔')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!generateForm.value.bucket_aggregation) {
|
|
||||||
Message.warning('请选择桶聚合')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
generating.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params: HistoryReportParams = {
|
|
||||||
data_source: generateForm.value.data_source as any,
|
|
||||||
metric_names: generateForm.value.metric_names,
|
|
||||||
target_identities: generateForm.value.target_identities,
|
|
||||||
start_time: generateForm.value.timeRange[0],
|
|
||||||
end_time: generateForm.value.timeRange[1],
|
|
||||||
interval: generateForm.value.interval,
|
|
||||||
bucket_aggregation: generateForm.value.bucket_aggregation as any,
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await generateReport({
|
|
||||||
report_type: ReportType.HISTORY,
|
|
||||||
title: generateForm.value.title || undefined,
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
|
||||||
if (res.details.status === 'success') {
|
|
||||||
Message.success('报表生成成功')
|
|
||||||
generateModalVisible.value = false
|
|
||||||
fetchList()
|
|
||||||
} else if (res.details.status === 'failed') {
|
|
||||||
Message.error(res.details.error_message || '报表生成失败')
|
|
||||||
} else {
|
|
||||||
Message.info('报表正在生成中,请稍后刷新查看')
|
|
||||||
generateModalVisible.value = false
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '报表生成失败')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('生成报表失败:', error)
|
|
||||||
Message.error(error.message || '生成报表失败')
|
|
||||||
} finally {
|
|
||||||
generating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看内容
|
|
||||||
const handleViewContent = async (record?: ReportRecord) => {
|
|
||||||
const targetRecord = record || selectedRecord.value
|
|
||||||
if (!targetRecord) return
|
|
||||||
|
|
||||||
contentLoading.value = true
|
|
||||||
contentModalVisible.value = true
|
|
||||||
contentModalTitle.value = targetRecord.title
|
|
||||||
reportContent.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetchReportContent(targetRecord.id)
|
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
|
||||||
reportContent.value = res.details
|
|
||||||
|
|
||||||
// 渲染图表
|
|
||||||
if (res.details.series) {
|
|
||||||
await nextTick()
|
|
||||||
renderChart(res.details.series)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '获取报表内容失败')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('获取报表内容失败:', error)
|
|
||||||
Message.error(error.message || '获取报表内容失败')
|
|
||||||
} finally {
|
|
||||||
contentLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出报表
|
|
||||||
const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
|
|
||||||
const targetRecord = record || selectedRecord.value
|
|
||||||
if (!targetRecord) {
|
|
||||||
Message.warning('请选择要导出的报表')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetRecord.status !== 'success') {
|
|
||||||
Message.warning('只能导出生成成功的报表')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
exporting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const blob = await exportReport(targetRecord.id, format)
|
|
||||||
|
|
||||||
// 创建下载链接
|
|
||||||
const url = window.URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = `report_${targetRecord.id}.${format}`
|
|
||||||
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
Message.success('导出成功')
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('导出失败:', error)
|
|
||||||
Message.error(error.message || '导出失败')
|
|
||||||
} finally {
|
|
||||||
exporting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染图表
|
|
||||||
const renderChart = (series: any[]) => {
|
|
||||||
if (!chartRef.value || series.length === 0) return
|
|
||||||
|
|
||||||
if (chartInstance) {
|
|
||||||
chartInstance.dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
chartInstance = echarts.init(chartRef.value)
|
|
||||||
|
|
||||||
// 按指标名称分组
|
|
||||||
const groupedData: Record<string, any[]> = {}
|
|
||||||
series.forEach((item: any) => {
|
|
||||||
const name = item.metric_name || 'value'
|
|
||||||
if (!groupedData[name]) {
|
|
||||||
groupedData[name] = []
|
|
||||||
}
|
|
||||||
groupedData[name].push(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取所有时间点
|
|
||||||
const timestamps = [...new Set(series.map((item: any) => item.timestamp))].sort()
|
|
||||||
|
|
||||||
const seriesData = Object.keys(groupedData).map((name) => ({
|
|
||||||
name,
|
|
||||||
type: 'line' as const,
|
|
||||||
smooth: true,
|
|
||||||
data: timestamps.map((t) => {
|
|
||||||
const item = groupedData[name].find((d: any) => d.timestamp === t)
|
|
||||||
return item ? item.value : null
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const option: echarts.EChartsOption = {
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
data: Object.keys(groupedData),
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: '3%',
|
|
||||||
right: '4%',
|
|
||||||
bottom: '3%',
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
boundaryGap: false,
|
|
||||||
data: timestamps,
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
},
|
|
||||||
series: seriesData as any,
|
|
||||||
}
|
|
||||||
|
|
||||||
chartInstance.setOption(option)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
fetchList()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: 'HistoryReport',
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.container {
|
.wrap {
|
||||||
padding: 20px;
|
padding: 48px 24px;
|
||||||
|
max-width: 720px;
|
||||||
.chart-container {
|
margin: 0 auto;
|
||||||
height: 400px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 200px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -58,6 +58,12 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="reportStatusColor(record.status)">
|
||||||
|
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template #operations="{ record }">
|
<template #operations="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -109,29 +115,21 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-divider orientation="left">服务器选择(二选一)</a-divider>
|
<a-divider orientation="left">服务器标识</a-divider>
|
||||||
|
|
||||||
<a-row :gutter="16">
|
<a-form-item label="服务器标识" field="server_identities">
|
||||||
<a-col :span="12">
|
<a-select
|
||||||
<a-form-item label="服务器 ID" field="server_ids">
|
v-model="generateForm.server_identities"
|
||||||
<a-input-tag
|
multiple
|
||||||
v-model="generateForm.server_ids_str"
|
allow-clear
|
||||||
placeholder="输入ID后按回车"
|
allow-search
|
||||||
style="width: 100%"
|
:loading="serverOptionsLoading"
|
||||||
@change="handleServerIdsChange"
|
:options="serverIdentityOptions"
|
||||||
/>
|
placeholder="请选择服务器标识(可多选)"
|
||||||
</a-form-item>
|
:max-tag-count="4"
|
||||||
</a-col>
|
style="width: 100%"
|
||||||
<a-col :span="12">
|
/>
|
||||||
<a-form-item label="服务器标识" field="server_identities">
|
</a-form-item>
|
||||||
<a-input-tag
|
|
||||||
v-model="generateForm.server_identities"
|
|
||||||
placeholder="输入标识后按回车"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<a-divider orientation="left">报表配置</a-divider>
|
<a-divider orientation="left">报表配置</a-divider>
|
||||||
|
|
||||||
@@ -199,12 +197,17 @@
|
|||||||
<a-spin />
|
<a-spin />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="reportContent">
|
<div v-else-if="reportContent">
|
||||||
<!-- 汇总信息 -->
|
<!-- 元信息:后端为 result_meta;旧数据可能为 summary -->
|
||||||
<a-card v-if="reportContent.summary" title="服务器汇总" :bordered="false" class="summary-card">
|
<a-card
|
||||||
<a-descriptions :column="3" bordered>
|
v-if="serverReportMeta && Object.keys(serverReportMeta).length"
|
||||||
|
title="说明与汇总"
|
||||||
|
:bordered="false"
|
||||||
|
class="summary-card"
|
||||||
|
>
|
||||||
|
<a-descriptions :column="2" bordered size="small">
|
||||||
<a-descriptions-item
|
<a-descriptions-item
|
||||||
v-for="(value, key) in reportContent.summary"
|
v-for="(value, key) in serverReportMeta"
|
||||||
:key="key"
|
:key="String(key)"
|
||||||
:label="formatLabel(String(key))"
|
:label="formatLabel(String(key))"
|
||||||
>
|
>
|
||||||
{{ formatValue(String(key), value) }}
|
{{ formatValue(String(key), value) }}
|
||||||
@@ -212,9 +215,9 @@
|
|||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 服务器列表 -->
|
<!-- 行数据:引擎字段为 rows;旧前端曾用 servers -->
|
||||||
<a-table
|
<a-table
|
||||||
:data="reportContent.servers || []"
|
:data="serverReportRows"
|
||||||
:columns="contentTableColumns"
|
:columns="contentTableColumns"
|
||||||
:pagination="{ pageSize: 10 }"
|
:pagination="{ pageSize: 10 }"
|
||||||
stripe
|
stripe
|
||||||
@@ -239,6 +242,10 @@ import {
|
|||||||
type ReportRecord,
|
type ReportRecord,
|
||||||
type ServerReportParams,
|
type ServerReportParams,
|
||||||
} from '@/api/ops/report'
|
} from '@/api/ops/report'
|
||||||
|
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||||
|
import { useReportServerPickOptions } from '../useReportServerPickOptions'
|
||||||
|
|
||||||
|
const { serverIdentityOptions, serverOptionsLoading, loadServerPickOptions } = useReportServerPickOptions()
|
||||||
|
|
||||||
// 页面标题
|
// 页面标题
|
||||||
const pageTitle = '服务器报表'
|
const pageTitle = '服务器报表'
|
||||||
@@ -340,8 +347,6 @@ const tableColumns = computed(() => [
|
|||||||
const generateModalVisible = ref(false)
|
const generateModalVisible = ref(false)
|
||||||
const generateForm = ref<{
|
const generateForm = ref<{
|
||||||
timeRange: string[]
|
timeRange: string[]
|
||||||
server_ids_str: string[]
|
|
||||||
server_ids: number[]
|
|
||||||
server_identities: string[]
|
server_identities: string[]
|
||||||
columns: string[]
|
columns: string[]
|
||||||
availability_rule: string
|
availability_rule: string
|
||||||
@@ -349,8 +354,6 @@ const generateForm = ref<{
|
|||||||
title: string
|
title: string
|
||||||
}>({
|
}>({
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
server_ids_str: [],
|
|
||||||
server_ids: [],
|
|
||||||
server_identities: [],
|
server_identities: [],
|
||||||
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
|
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
|
||||||
availability_rule: 'metrics_presence',
|
availability_rule: 'metrics_presence',
|
||||||
@@ -363,14 +366,25 @@ const contentModalVisible = ref(false)
|
|||||||
const contentModalTitle = ref('')
|
const contentModalTitle = ref('')
|
||||||
const reportContent = ref<Record<string, any> | null>(null)
|
const reportContent = ref<Record<string, any> | null>(null)
|
||||||
|
|
||||||
|
/** 与 dc-control genServer 返回一致:rows;兼容历史 payload.servers */
|
||||||
|
const serverReportRows = computed(() => {
|
||||||
|
const p = reportContent.value
|
||||||
|
if (!p) return []
|
||||||
|
const rows = p.rows ?? p.servers
|
||||||
|
return Array.isArray(rows) ? rows : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverReportMeta = computed(() => {
|
||||||
|
const p = reportContent.value
|
||||||
|
if (!p) return null
|
||||||
|
const m = p.result_meta ?? p.summary
|
||||||
|
return m && typeof m === 'object' ? m : null
|
||||||
|
})
|
||||||
|
|
||||||
// 内容表格列配置
|
// 内容表格列配置
|
||||||
const contentTableColumns = computed(() => {
|
const contentTableColumns = computed(() => {
|
||||||
if (!reportContent.value) return []
|
const data = serverReportRows.value
|
||||||
|
|
||||||
const data = reportContent.value.servers || []
|
|
||||||
|
|
||||||
if (data.length === 0) return []
|
if (data.length === 0) return []
|
||||||
|
|
||||||
const firstRecord = data[0]
|
const firstRecord = data[0]
|
||||||
return Object.keys(firstRecord).map((key) => ({
|
return Object.keys(firstRecord).map((key) => ({
|
||||||
title: formatLabel(key),
|
title: formatLabel(key),
|
||||||
@@ -406,7 +420,7 @@ const fetchList = async () => {
|
|||||||
const res = await fetchReportList(params)
|
const res = await fetchReportList(params)
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
tableData.value = res.details.data || []
|
tableData.value = normalizeReportRows(res.details.data || [])
|
||||||
pagination.total = res.details.total || 0
|
pagination.total = res.details.total || 0
|
||||||
} else {
|
} else {
|
||||||
Message.error(res.message || '获取报表列表失败')
|
Message.error(res.message || '获取报表列表失败')
|
||||||
@@ -427,13 +441,6 @@ const handleFormModelUpdate = (value: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理服务器 ID 输入变化
|
|
||||||
const handleServerIdsChange = (value: string[]) => {
|
|
||||||
generateForm.value.server_ids = value
|
|
||||||
.map((v) => parseInt(v, 10))
|
|
||||||
.filter((n) => !isNaN(n))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询
|
// 查询
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
pagination.current = 1
|
pagination.current = 1
|
||||||
@@ -462,14 +469,13 @@ const handleRefresh = () => {
|
|||||||
const handleOpenGenerateModal = () => {
|
const handleOpenGenerateModal = () => {
|
||||||
generateForm.value = {
|
generateForm.value = {
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
server_ids_str: [],
|
|
||||||
server_ids: [],
|
|
||||||
server_identities: [],
|
server_identities: [],
|
||||||
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
|
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
|
||||||
availability_rule: 'metrics_presence',
|
availability_rule: 'metrics_presence',
|
||||||
include_daily_alerts: false,
|
include_daily_alerts: false,
|
||||||
title: '',
|
title: '',
|
||||||
}
|
}
|
||||||
|
loadServerPickOptions()
|
||||||
generateModalVisible.value = true
|
generateModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,18 +492,8 @@ const handleGenerate = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证服务器选择
|
if (generateForm.value.server_identities.length === 0) {
|
||||||
if (
|
Message.warning('请选择服务器标识')
|
||||||
generateForm.value.server_ids.length === 0 &&
|
|
||||||
generateForm.value.server_identities.length === 0
|
|
||||||
) {
|
|
||||||
Message.warning('请选择服务器(ID 或标识)')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证不能同时选择
|
|
||||||
if (generateForm.value.server_ids.length > 0 && generateForm.value.server_identities.length > 0) {
|
|
||||||
Message.warning('服务器 ID 和标识不能同时填写')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,14 +503,7 @@ const handleGenerate = async () => {
|
|||||||
const params: ServerReportParams = {
|
const params: ServerReportParams = {
|
||||||
start_time: generateForm.value.timeRange[0],
|
start_time: generateForm.value.timeRange[0],
|
||||||
end_time: generateForm.value.timeRange[1],
|
end_time: generateForm.value.timeRange[1],
|
||||||
}
|
server_identities: generateForm.value.server_identities,
|
||||||
|
|
||||||
if (generateForm.value.server_ids.length > 0) {
|
|
||||||
params.server_ids = generateForm.value.server_ids
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generateForm.value.server_identities.length > 0) {
|
|
||||||
params.server_identities = generateForm.value.server_identities
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generateForm.value.columns.length > 0) {
|
if (generateForm.value.columns.length > 0) {
|
||||||
|
|||||||
@@ -58,6 +58,12 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="reportStatusColor(record.status)">
|
||||||
|
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template #operations="{ record }">
|
<template #operations="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -116,11 +122,20 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item
|
<a-form-item
|
||||||
label="指标名称"
|
label="指标"
|
||||||
field="metric_name"
|
field="metric_id"
|
||||||
:rules="[{ required: true, message: '请输入指标名称' }]"
|
:rules="[{ required: true, message: '请选择指标' }]"
|
||||||
>
|
>
|
||||||
<a-input v-model="generateForm.metric_name" placeholder="请输入指标名称" style="width: 100%" />
|
<a-select
|
||||||
|
v-model="generateForm.metric_id"
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="metricOptionsLoading"
|
||||||
|
:options="metricOptions"
|
||||||
|
:placeholder="generateForm.data_source ? '请选择逻辑指标' : '请先选择数据源'"
|
||||||
|
:disabled="!generateForm.data_source"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
@@ -128,11 +143,20 @@
|
|||||||
<a-form-item
|
<a-form-item
|
||||||
label="目标标识"
|
label="目标标识"
|
||||||
field="target_identities"
|
field="target_identities"
|
||||||
:rules="[{ required: true, message: '请输入目标标识' }]"
|
:rules="[{ required: true, message: '请选择目标标识' }]"
|
||||||
>
|
>
|
||||||
<a-input-tag
|
<a-select
|
||||||
v-model="generateForm.target_identities"
|
v-model="generateForm.target_identities"
|
||||||
placeholder="输入后按回车添加"
|
multiple
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="targetOptionsLoading"
|
||||||
|
:options="targetIdentityOptions"
|
||||||
|
:placeholder="
|
||||||
|
generateForm.data_source ? '请选择或搜索目标(可多选)' : '请先选择数据源'
|
||||||
|
"
|
||||||
|
:disabled="!generateForm.data_source"
|
||||||
|
:max-tag-count="3"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -258,7 +282,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import SearchTable from '@/components/search-table/index.vue'
|
import SearchTable from '@/components/search-table/index.vue'
|
||||||
import type { FormItem } from '@/components/search-form/types'
|
import type { FormItem } from '@/components/search-form/types'
|
||||||
@@ -272,6 +296,14 @@ import {
|
|||||||
type StatisticsReportParams,
|
type StatisticsReportParams,
|
||||||
} from '@/api/ops/report'
|
} from '@/api/ops/report'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
|
||||||
|
import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions'
|
||||||
|
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||||
|
|
||||||
|
const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } =
|
||||||
|
useReportTargetIdentityOptions()
|
||||||
|
|
||||||
|
const { metricOptions, metricOptionsLoading, loadMetricRegistryOptions } = useReportMetricRegistryOptions()
|
||||||
|
|
||||||
// 页面标题
|
// 页面标题
|
||||||
const pageTitle = '统计报表'
|
const pageTitle = '统计报表'
|
||||||
@@ -373,7 +405,7 @@ const tableColumns = computed(() => [
|
|||||||
const generateModalVisible = ref(false)
|
const generateModalVisible = ref(false)
|
||||||
const generateForm = ref<{
|
const generateForm = ref<{
|
||||||
data_source: string
|
data_source: string
|
||||||
metric_name: string
|
metric_id: string
|
||||||
target_identities: string[]
|
target_identities: string[]
|
||||||
timeRange: string[]
|
timeRange: string[]
|
||||||
output_mode: string
|
output_mode: string
|
||||||
@@ -383,7 +415,7 @@ const generateForm = ref<{
|
|||||||
title: string
|
title: string
|
||||||
}>({
|
}>({
|
||||||
data_source: '',
|
data_source: '',
|
||||||
metric_name: '',
|
metric_id: '',
|
||||||
target_identities: [],
|
target_identities: [],
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
output_mode: 'scalar',
|
output_mode: 'scalar',
|
||||||
@@ -393,6 +425,16 @@ const generateForm = ref<{
|
|||||||
title: '',
|
title: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => generateForm.value.data_source,
|
||||||
|
(ds) => {
|
||||||
|
generateForm.value.target_identities = []
|
||||||
|
generateForm.value.metric_id = ''
|
||||||
|
loadTargetIdentityOptions(ds)
|
||||||
|
loadMetricRegistryOptions(ds)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 查看内容弹窗
|
// 查看内容弹窗
|
||||||
const contentModalVisible = ref(false)
|
const contentModalVisible = ref(false)
|
||||||
const contentModalTitle = ref('')
|
const contentModalTitle = ref('')
|
||||||
@@ -441,7 +483,7 @@ const fetchList = async () => {
|
|||||||
const res = await fetchReportList(params)
|
const res = await fetchReportList(params)
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
tableData.value = res.details.data || []
|
tableData.value = normalizeReportRows(res.details.data || [])
|
||||||
pagination.total = res.details.total || 0
|
pagination.total = res.details.total || 0
|
||||||
} else {
|
} else {
|
||||||
Message.error(res.message || '获取报表列表失败')
|
Message.error(res.message || '获取报表列表失败')
|
||||||
@@ -490,7 +532,7 @@ const handleRefresh = () => {
|
|||||||
const handleOpenGenerateModal = () => {
|
const handleOpenGenerateModal = () => {
|
||||||
generateForm.value = {
|
generateForm.value = {
|
||||||
data_source: '',
|
data_source: '',
|
||||||
metric_name: '',
|
metric_id: '',
|
||||||
target_identities: [],
|
target_identities: [],
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
output_mode: 'scalar',
|
output_mode: 'scalar',
|
||||||
@@ -515,13 +557,13 @@ const handleGenerate = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!generateForm.value.metric_name) {
|
if (!generateForm.value.metric_id) {
|
||||||
Message.warning('请输入指标名称')
|
Message.warning('请选择指标')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generateForm.value.target_identities.length === 0) {
|
if (generateForm.value.target_identities.length === 0) {
|
||||||
Message.warning('请输入目标标识')
|
Message.warning('请选择目标标识')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +593,7 @@ const handleGenerate = async () => {
|
|||||||
try {
|
try {
|
||||||
const params: StatisticsReportParams = {
|
const params: StatisticsReportParams = {
|
||||||
data_source: generateForm.value.data_source as any,
|
data_source: generateForm.value.data_source as any,
|
||||||
metric_name: generateForm.value.metric_name,
|
metric_id: generateForm.value.metric_id,
|
||||||
target_identities: generateForm.value.target_identities,
|
target_identities: generateForm.value.target_identities,
|
||||||
start_time: generateForm.value.timeRange[0],
|
start_time: generateForm.value.timeRange[0],
|
||||||
end_time: generateForm.value.timeRange[1],
|
end_time: generateForm.value.timeRange[1],
|
||||||
|
|||||||
@@ -58,6 +58,12 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="reportStatusColor(record.status)">
|
||||||
|
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template #operations="{ record }">
|
<template #operations="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -116,11 +122,20 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item
|
<a-form-item
|
||||||
label="指标名称"
|
label="指标"
|
||||||
field="metric_name"
|
field="metric_id"
|
||||||
:rules="[{ required: true, message: '请输入指标名称' }]"
|
:rules="[{ required: true, message: '请选择指标' }]"
|
||||||
>
|
>
|
||||||
<a-input v-model="generateForm.metric_name" placeholder="请输入指标名称" style="width: 100%" />
|
<a-select
|
||||||
|
v-model="generateForm.metric_id"
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="metricOptionsLoading"
|
||||||
|
:options="metricOptions"
|
||||||
|
:placeholder="generateForm.data_source ? '请选择逻辑指标' : '请先选择数据源'"
|
||||||
|
:disabled="!generateForm.data_source"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
@@ -128,13 +143,25 @@
|
|||||||
<a-form-item
|
<a-form-item
|
||||||
label="目标标识"
|
label="目标标识"
|
||||||
field="target_identities"
|
field="target_identities"
|
||||||
:rules="[{ required: true, message: '请输入目标标识' }]"
|
:rules="[{ required: true, message: '请选择目标标识' }]"
|
||||||
>
|
>
|
||||||
<a-input-tag
|
<a-select
|
||||||
v-model="generateForm.target_identities"
|
v-model="generateForm.target_identities"
|
||||||
placeholder="输入后按回车添加"
|
multiple
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="targetOptionsLoading"
|
||||||
|
:options="targetIdentityOptions"
|
||||||
|
:placeholder="
|
||||||
|
generateForm.data_source ? '请选择或搜索目标(可多选)' : '请先选择数据源'
|
||||||
|
"
|
||||||
|
:disabled="!generateForm.data_source"
|
||||||
|
:max-tag-count="3"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="generateForm.data_source === 'dc-network'" class="target-id-hint">
|
||||||
|
TopN 当前实现可能对「网络」数据源无法出数;选项来自网络设备服务列表,供对齐标识使用。
|
||||||
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
|
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
|
||||||
@@ -195,7 +222,7 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="采集器标识" field="collector_identity">
|
<a-form-item label="采集器标识" field="collector_identity">
|
||||||
<a-input v-model="generateForm.collector_identity" placeholder="可选" style="width: 100%" />
|
<a-input v-model="generateForm.collector_identity" placeholder="可选,手填标识" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
@@ -221,9 +248,9 @@
|
|||||||
<!-- 图表展示 -->
|
<!-- 图表展示 -->
|
||||||
<div ref="chartRef" class="chart-container"></div>
|
<div ref="chartRef" class="chart-container"></div>
|
||||||
|
|
||||||
<!-- 排名表格 -->
|
<!-- 排名表格(引擎字段为 identity + value,见 dc-control genTopN) -->
|
||||||
<a-table
|
<a-table
|
||||||
:data="reportContent.ranking || []"
|
:data="normalizedRankingRows"
|
||||||
:columns="rankingTableColumns"
|
:columns="rankingTableColumns"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
stripe
|
stripe
|
||||||
@@ -235,7 +262,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import SearchTable from '@/components/search-table/index.vue'
|
import SearchTable from '@/components/search-table/index.vue'
|
||||||
import type { FormItem } from '@/components/search-form/types'
|
import type { FormItem } from '@/components/search-form/types'
|
||||||
@@ -249,6 +276,14 @@ import {
|
|||||||
type TopNReportParams,
|
type TopNReportParams,
|
||||||
} from '@/api/ops/report'
|
} from '@/api/ops/report'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
|
||||||
|
import { useReportMetricRegistryOptions } from '../useReportMetricRegistryOptions'
|
||||||
|
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||||
|
|
||||||
|
const { targetIdentityOptions, targetOptionsLoading, loadTargetIdentityOptions } =
|
||||||
|
useReportTargetIdentityOptions()
|
||||||
|
|
||||||
|
const { metricOptions, metricOptionsLoading, loadMetricRegistryOptions } = useReportMetricRegistryOptions()
|
||||||
|
|
||||||
// 页面标题
|
// 页面标题
|
||||||
const pageTitle = 'TopN 报表'
|
const pageTitle = 'TopN 报表'
|
||||||
@@ -350,7 +385,7 @@ const tableColumns = computed(() => [
|
|||||||
const generateModalVisible = ref(false)
|
const generateModalVisible = ref(false)
|
||||||
const generateForm = ref<{
|
const generateForm = ref<{
|
||||||
data_source: string
|
data_source: string
|
||||||
metric_name: string
|
metric_id: string
|
||||||
target_identities: string[]
|
target_identities: string[]
|
||||||
timeRange: string[]
|
timeRange: string[]
|
||||||
n: number
|
n: number
|
||||||
@@ -361,7 +396,7 @@ const generateForm = ref<{
|
|||||||
title: string
|
title: string
|
||||||
}>({
|
}>({
|
||||||
data_source: '',
|
data_source: '',
|
||||||
metric_name: '',
|
metric_id: '',
|
||||||
target_identities: [],
|
target_identities: [],
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
n: 10,
|
n: 10,
|
||||||
@@ -372,6 +407,16 @@ const generateForm = ref<{
|
|||||||
title: '',
|
title: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => generateForm.value.data_source,
|
||||||
|
(ds) => {
|
||||||
|
generateForm.value.target_identities = []
|
||||||
|
generateForm.value.metric_id = ''
|
||||||
|
loadTargetIdentityOptions(ds)
|
||||||
|
loadMetricRegistryOptions(ds)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 查看内容弹窗
|
// 查看内容弹窗
|
||||||
const contentModalVisible = ref(false)
|
const contentModalVisible = ref(false)
|
||||||
const contentModalTitle = ref('')
|
const contentModalTitle = ref('')
|
||||||
@@ -379,6 +424,18 @@ const reportContent = ref<Record<string, any> | null>(null)
|
|||||||
const chartRef = ref<HTMLElement | null>(null)
|
const chartRef = ref<HTMLElement | null>(null)
|
||||||
let chartInstance: echarts.ECharts | null = null
|
let chartInstance: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
// 将引擎 ranking 规范为表格行:rank、target(展示用)、value
|
||||||
|
const normalizedRankingRows = computed(() => {
|
||||||
|
const raw = reportContent.value?.ranking
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw.map((item: any, idx: number) => ({
|
||||||
|
...item,
|
||||||
|
rank: item.rank ?? idx + 1,
|
||||||
|
target: item.target ?? item.identity ?? item.target_identity ?? '',
|
||||||
|
value: item.value,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
// 排名表格列配置
|
// 排名表格列配置
|
||||||
const rankingTableColumns = computed(() => [
|
const rankingTableColumns = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -425,7 +482,7 @@ const fetchList = async () => {
|
|||||||
const res = await fetchReportList(params)
|
const res = await fetchReportList(params)
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
tableData.value = res.details.data || []
|
tableData.value = normalizeReportRows(res.details.data || [])
|
||||||
pagination.total = res.details.total || 0
|
pagination.total = res.details.total || 0
|
||||||
} else {
|
} else {
|
||||||
Message.error(res.message || '获取报表列表失败')
|
Message.error(res.message || '获取报表列表失败')
|
||||||
@@ -474,7 +531,7 @@ const handleRefresh = () => {
|
|||||||
const handleOpenGenerateModal = () => {
|
const handleOpenGenerateModal = () => {
|
||||||
generateForm.value = {
|
generateForm.value = {
|
||||||
data_source: '',
|
data_source: '',
|
||||||
metric_name: '',
|
metric_id: '',
|
||||||
target_identities: [],
|
target_identities: [],
|
||||||
timeRange: [],
|
timeRange: [],
|
||||||
n: 10,
|
n: 10,
|
||||||
@@ -500,13 +557,13 @@ const handleGenerate = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!generateForm.value.metric_name) {
|
if (!generateForm.value.metric_id) {
|
||||||
Message.warning('请输入指标名称')
|
Message.warning('请选择指标')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generateForm.value.target_identities.length === 0) {
|
if (generateForm.value.target_identities.length === 0) {
|
||||||
Message.warning('请输入目标标识')
|
Message.warning('请选择目标标识')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +577,7 @@ const handleGenerate = async () => {
|
|||||||
try {
|
try {
|
||||||
const params: TopNReportParams = {
|
const params: TopNReportParams = {
|
||||||
data_source: generateForm.value.data_source as any,
|
data_source: generateForm.value.data_source as any,
|
||||||
metric_name: generateForm.value.metric_name,
|
metric_id: generateForm.value.metric_id,
|
||||||
target_identities: generateForm.value.target_identities,
|
target_identities: generateForm.value.target_identities,
|
||||||
start_time: generateForm.value.timeRange[0],
|
start_time: generateForm.value.timeRange[0],
|
||||||
end_time: generateForm.value.timeRange[1],
|
end_time: generateForm.value.timeRange[1],
|
||||||
@@ -675,7 +732,9 @@ const renderChart = (ranking: any[]) => {
|
|||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: ranking.map((item: any) => item.target).reverse(),
|
data: ranking
|
||||||
|
.map((item: any) => item.target ?? item.identity ?? item.target_identity ?? '')
|
||||||
|
.reverse(),
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -721,5 +780,12 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.target-id-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -58,6 +58,12 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #status="{ record }">
|
||||||
|
<a-tag :color="reportStatusColor(record.status)">
|
||||||
|
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template #operations="{ record }">
|
<template #operations="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
@@ -99,24 +105,64 @@
|
|||||||
@cancel="handleCloseGenerateModal"
|
@cancel="handleCloseGenerateModal"
|
||||||
>
|
>
|
||||||
<a-form :model="generateForm" layout="vertical">
|
<a-form :model="generateForm" layout="vertical">
|
||||||
|
<a-form-item label="流量模式" field="traffic_mode">
|
||||||
|
<a-radio-group v-model="generateForm.traffic_mode" type="button">
|
||||||
|
<a-radio value="topology">拓扑 / NetFlow</a-radio>
|
||||||
|
<a-radio value="snmp_devices">多设备 SNMP</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
|
v-if="generateForm.traffic_mode === 'snmp_devices'"
|
||||||
|
label="网络设备(service_identity)"
|
||||||
|
field="service_identities"
|
||||||
|
>
|
||||||
|
<a-select
|
||||||
|
v-model="generateForm.service_identities"
|
||||||
|
multiple
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="snmpDeviceOptionsLoading"
|
||||||
|
:options="snmpDeviceIdentityOptions"
|
||||||
|
placeholder="请选择设备(可多选)"
|
||||||
|
:max-tag-count="3"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="拓扑 ID" field="topology_id" :rules="[{ required: true, message: '请输入拓扑 ID' }]">
|
<a-form-item
|
||||||
<a-input-number
|
label="拓扑"
|
||||||
|
field="topology_id"
|
||||||
|
:rules="
|
||||||
|
generateForm.traffic_mode === 'topology'
|
||||||
|
? [{ required: true, message: '请选择拓扑' }]
|
||||||
|
: []
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a-select
|
||||||
v-model="generateForm.topology_id"
|
v-model="generateForm.topology_id"
|
||||||
placeholder="请输入拓扑 ID"
|
allow-clear
|
||||||
:min="1"
|
allow-search
|
||||||
|
:loading="topologyLoading"
|
||||||
|
:options="topologyOptions"
|
||||||
|
placeholder="拓扑模式必选"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
:disabled="generateForm.traffic_mode === 'snmp_devices'"
|
||||||
|
@change="onTrafficTopologyChange"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="链路 ID" field="link_id">
|
<a-form-item label="链路" field="link_id">
|
||||||
<a-input-number
|
<a-select
|
||||||
v-model="generateForm.link_id"
|
v-model="generateForm.link_id"
|
||||||
placeholder="可选,0 表示整拓扑"
|
allow-clear
|
||||||
:min="0"
|
allow-search
|
||||||
|
:loading="linksLoading"
|
||||||
|
:options="linkOptions"
|
||||||
|
placeholder="可选,默认整拓扑"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
:disabled="!generateForm.topology_id || generateForm.traffic_mode === 'snmp_devices'"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -124,8 +170,17 @@
|
|||||||
|
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item label="节点 ID" field="node_id">
|
<a-form-item label="节点" field="node_id">
|
||||||
<a-input v-model="generateForm.node_id" placeholder="可选" style="width: 100%" />
|
<a-select
|
||||||
|
v-model="generateForm.node_id"
|
||||||
|
allow-clear
|
||||||
|
allow-search
|
||||||
|
:loading="nodesLoading"
|
||||||
|
:options="nodeOptions"
|
||||||
|
placeholder="可选"
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="!generateForm.topology_id || generateForm.traffic_mode === 'snmp_devices'"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
@@ -236,13 +291,22 @@
|
|||||||
<a-spin />
|
<a-spin />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="reportContent">
|
<div v-else-if="reportContent">
|
||||||
<!-- 汇总数据 -->
|
<!-- SNMP 多设备汇总 -->
|
||||||
<template v-if="reportContentType === 'summary'">
|
<template v-if="reportContent.traffic_mode === 'snmp_devices'">
|
||||||
<a-card v-if="reportContent.totals" title="流量汇总" :bordered="false" class="summary-card">
|
<a-table
|
||||||
|
:data="reportContent.rows || []"
|
||||||
|
:columns="contentTableColumns"
|
||||||
|
:pagination="{ pageSize: 15 }"
|
||||||
|
stripe
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- 汇总:引擎字段为 data(标量 map);兼容旧 totals / by_node -->
|
||||||
|
<template v-else-if="reportContentType === 'summary'">
|
||||||
|
<a-card v-if="trafficSummaryKv" title="流量汇总" :bordered="false" class="summary-card">
|
||||||
<a-descriptions :column="3" bordered>
|
<a-descriptions :column="3" bordered>
|
||||||
<a-descriptions-item
|
<a-descriptions-item
|
||||||
v-for="(value, key) in reportContent.totals"
|
v-for="(value, key) in trafficSummaryKv"
|
||||||
:key="key"
|
:key="String(key)"
|
||||||
:label="formatLabel(String(key))"
|
:label="formatLabel(String(key))"
|
||||||
>
|
>
|
||||||
{{ formatValue(String(key), value) }}
|
{{ formatValue(String(key), value) }}
|
||||||
@@ -250,17 +314,18 @@
|
|||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
</a-card>
|
</a-card>
|
||||||
<a-table
|
<a-table
|
||||||
:data="reportContent.by_node || []"
|
v-if="trafficSummaryTableRows.length"
|
||||||
|
:data="trafficSummaryTableRows"
|
||||||
:columns="contentTableColumns"
|
:columns="contentTableColumns"
|
||||||
:pagination="{ pageSize: 10 }"
|
:pagination="{ pageSize: 10 }"
|
||||||
stripe
|
stripe
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 明细数据 -->
|
<!-- 明细:引擎字段为 rows;兼容 items -->
|
||||||
<template v-else-if="reportContentType === 'detail'">
|
<template v-else-if="reportContentType === 'detail'">
|
||||||
<a-table
|
<a-table
|
||||||
:data="reportContent.items || []"
|
:data="trafficDetailRows"
|
||||||
:columns="contentTableColumns"
|
:columns="contentTableColumns"
|
||||||
:pagination="{ pageSize: 10 }"
|
:pagination="{ pageSize: 10 }"
|
||||||
stripe
|
stripe
|
||||||
@@ -272,10 +337,10 @@
|
|||||||
<div ref="chartRef" class="chart-container"></div>
|
<div ref="chartRef" class="chart-container"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Top 排名数据 -->
|
<!-- Top:引擎字段为 data([]map);兼容 ranking -->
|
||||||
<template v-else-if="reportContentType === 'top'">
|
<template v-else-if="reportContentType === 'top'">
|
||||||
<a-table
|
<a-table
|
||||||
:data="reportContent.ranking || []"
|
:data="trafficTopRows"
|
||||||
:columns="contentTableColumns"
|
:columns="contentTableColumns"
|
||||||
:pagination="false"
|
:pagination="false"
|
||||||
stripe
|
stripe
|
||||||
@@ -288,7 +353,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, nextTick } from 'vue'
|
import { ref, reactive, computed, nextTick, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import SearchTable from '@/components/search-table/index.vue'
|
import SearchTable from '@/components/search-table/index.vue'
|
||||||
import type { FormItem } from '@/components/search-form/types'
|
import type { FormItem } from '@/components/search-form/types'
|
||||||
@@ -302,6 +367,38 @@ import {
|
|||||||
type TrafficReportParams,
|
type TrafficReportParams,
|
||||||
} from '@/api/ops/report'
|
} from '@/api/ops/report'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
|
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||||
|
import { useReportTopologyOptions } from '../useReportTopologyOptions'
|
||||||
|
import { useReportTargetIdentityOptions } from '../useReportTargetIdentityOptions'
|
||||||
|
|
||||||
|
const {
|
||||||
|
topologyOptions,
|
||||||
|
topologyLoading,
|
||||||
|
linkOptions,
|
||||||
|
linksLoading,
|
||||||
|
nodeOptions,
|
||||||
|
nodesLoading,
|
||||||
|
loadTopologyOptions,
|
||||||
|
loadLinksAndNodes,
|
||||||
|
} = useReportTopologyOptions()
|
||||||
|
|
||||||
|
const {
|
||||||
|
targetIdentityOptions: snmpDeviceIdentityOptions,
|
||||||
|
targetOptionsLoading: snmpDeviceOptionsLoading,
|
||||||
|
loadTargetIdentityOptions: loadSnmpDeviceIdentities,
|
||||||
|
} = useReportTargetIdentityOptions()
|
||||||
|
|
||||||
|
function onTrafficTopologyChange() {
|
||||||
|
const tid = generateForm.value.topology_id
|
||||||
|
generateForm.value.link_id = undefined
|
||||||
|
generateForm.value.node_id = ''
|
||||||
|
if (tid) {
|
||||||
|
void loadLinksAndNodes(tid)
|
||||||
|
} else {
|
||||||
|
linkOptions.value = []
|
||||||
|
nodeOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 页面标题
|
// 页面标题
|
||||||
const pageTitle = '流量统计报表'
|
const pageTitle = '流量统计报表'
|
||||||
@@ -402,6 +499,8 @@ const tableColumns = computed(() => [
|
|||||||
// 生成报表弹窗
|
// 生成报表弹窗
|
||||||
const generateModalVisible = ref(false)
|
const generateModalVisible = ref(false)
|
||||||
const generateForm = ref<{
|
const generateForm = ref<{
|
||||||
|
traffic_mode: 'topology' | 'snmp_devices'
|
||||||
|
service_identities: string[]
|
||||||
topology_id: number | undefined
|
topology_id: number | undefined
|
||||||
link_id: number | undefined
|
link_id: number | undefined
|
||||||
node_id: string
|
node_id: string
|
||||||
@@ -414,6 +513,8 @@ const generateForm = ref<{
|
|||||||
detail_limit: number | undefined
|
detail_limit: number | undefined
|
||||||
trend_granularity: string
|
trend_granularity: string
|
||||||
}>({
|
}>({
|
||||||
|
traffic_mode: 'topology',
|
||||||
|
service_identities: [],
|
||||||
topology_id: undefined,
|
topology_id: undefined,
|
||||||
link_id: undefined,
|
link_id: undefined,
|
||||||
node_id: '',
|
node_id: '',
|
||||||
@@ -427,6 +528,15 @@ const generateForm = ref<{
|
|||||||
trend_granularity: 'hour',
|
trend_granularity: 'hour',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => generateForm.value.traffic_mode,
|
||||||
|
(mode) => {
|
||||||
|
if (mode === 'snmp_devices') {
|
||||||
|
void loadSnmpDeviceIdentities('dc-network')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 查看内容弹窗
|
// 查看内容弹窗
|
||||||
const contentModalVisible = ref(false)
|
const contentModalVisible = ref(false)
|
||||||
const contentModalTitle = ref('')
|
const contentModalTitle = ref('')
|
||||||
@@ -435,14 +545,64 @@ const reportContentType = ref<string>('')
|
|||||||
const chartRef = ref<HTMLElement | null>(null)
|
const chartRef = ref<HTMLElement | null>(null)
|
||||||
let chartInstance: echarts.ECharts | null = null
|
let chartInstance: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
/** 列表里的 params_json 可能是字符串 */
|
||||||
|
function parsedParamsJson(rec: ReportRecord | null): Record<string, any> {
|
||||||
|
if (!rec?.params_json) return {}
|
||||||
|
const raw = rec.params_json as unknown
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Record<string, any>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof raw === 'object') return raw as Record<string, any>
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** topology summary:totals 或单对象 data */
|
||||||
|
const trafficSummaryKv = computed(() => {
|
||||||
|
const p = reportContent.value
|
||||||
|
if (!p) return null
|
||||||
|
const o = p.totals ?? (p.data && !Array.isArray(p.data) ? p.data : null)
|
||||||
|
if (o && typeof o === 'object' && !Array.isArray(o)) return o as Record<string, any>
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const trafficSummaryTableRows = computed(() => {
|
||||||
|
const p = reportContent.value
|
||||||
|
if (!p) return []
|
||||||
|
if (Array.isArray(p.by_node) && p.by_node.length) return p.by_node
|
||||||
|
const flat = trafficSummaryKv.value
|
||||||
|
if (flat && Object.keys(flat).length) return [flat]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const trafficDetailRows = computed(() => {
|
||||||
|
const p = reportContent.value
|
||||||
|
if (!p) return []
|
||||||
|
return p.rows || p.items || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const trafficTopRows = computed(() => {
|
||||||
|
const p = reportContent.value
|
||||||
|
if (!p) return []
|
||||||
|
if (Array.isArray(p.ranking) && p.ranking.length) return p.ranking
|
||||||
|
if (Array.isArray(p.data)) return p.data
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
// 内容表格列配置
|
// 内容表格列配置
|
||||||
const contentTableColumns = computed(() => {
|
const contentTableColumns = computed(() => {
|
||||||
if (!reportContent.value) return []
|
if (!reportContent.value) return []
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
|
reportContent.value.rows ||
|
||||||
reportContent.value.by_node ||
|
reportContent.value.by_node ||
|
||||||
reportContent.value.items ||
|
reportContent.value.items ||
|
||||||
reportContent.value.ranking ||
|
reportContent.value.ranking ||
|
||||||
|
(Array.isArray(reportContent.value.data) ? reportContent.value.data : []) ||
|
||||||
|
trafficSummaryTableRows.value ||
|
||||||
[]
|
[]
|
||||||
|
|
||||||
if (data.length === 0) return []
|
if (data.length === 0) return []
|
||||||
@@ -482,7 +642,7 @@ const fetchList = async () => {
|
|||||||
const res = await fetchReportList(params)
|
const res = await fetchReportList(params)
|
||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
tableData.value = res.details.data || []
|
tableData.value = normalizeReportRows(res.details.data || [])
|
||||||
pagination.total = res.details.total || 0
|
pagination.total = res.details.total || 0
|
||||||
} else {
|
} else {
|
||||||
Message.error(res.message || '获取报表列表失败')
|
Message.error(res.message || '获取报表列表失败')
|
||||||
@@ -530,6 +690,8 @@ const handleRefresh = () => {
|
|||||||
// 打开生成报表弹窗
|
// 打开生成报表弹窗
|
||||||
const handleOpenGenerateModal = () => {
|
const handleOpenGenerateModal = () => {
|
||||||
generateForm.value = {
|
generateForm.value = {
|
||||||
|
traffic_mode: 'topology',
|
||||||
|
service_identities: [],
|
||||||
topology_id: undefined,
|
topology_id: undefined,
|
||||||
link_id: undefined,
|
link_id: undefined,
|
||||||
node_id: '',
|
node_id: '',
|
||||||
@@ -542,6 +704,9 @@ const handleOpenGenerateModal = () => {
|
|||||||
detail_limit: undefined,
|
detail_limit: undefined,
|
||||||
trend_granularity: 'hour',
|
trend_granularity: 'hour',
|
||||||
}
|
}
|
||||||
|
linkOptions.value = []
|
||||||
|
nodeOptions.value = []
|
||||||
|
void loadTopologyOptions()
|
||||||
generateModalVisible.value = true
|
generateModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,26 +717,40 @@ const handleCloseGenerateModal = () => {
|
|||||||
|
|
||||||
// 生成报表
|
// 生成报表
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
// 验证必填项
|
|
||||||
if (!generateForm.value.topology_id) {
|
|
||||||
Message.warning('请输入拓扑 ID')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
||||||
Message.warning('请选择时间范围')
|
Message.warning('请选择时间范围')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mode = generateForm.value.traffic_mode || 'topology'
|
||||||
|
if (mode === 'topology' && !generateForm.value.topology_id) {
|
||||||
|
Message.warning('请选择拓扑')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode === 'snmp_devices') {
|
||||||
|
const ids = generateForm.value.service_identities || []
|
||||||
|
if (ids.length === 0) {
|
||||||
|
Message.warning('请至少选择一个网络设备')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
generating.value = true
|
generating.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params: TrafficReportParams = {
|
const params: TrafficReportParams = {
|
||||||
topology_id: generateForm.value.topology_id,
|
traffic_mode: mode,
|
||||||
start_time: generateForm.value.timeRange[0],
|
start_time: generateForm.value.timeRange[0],
|
||||||
end_time: generateForm.value.timeRange[1],
|
end_time: generateForm.value.timeRange[1],
|
||||||
report_shape: generateForm.value.report_shape as any,
|
report_shape: generateForm.value.report_shape as any,
|
||||||
}
|
}
|
||||||
|
if (mode === 'topology') {
|
||||||
|
params.topology_id = generateForm.value.topology_id!
|
||||||
|
} else {
|
||||||
|
params.service_identities = [...(generateForm.value.service_identities || [])]
|
||||||
|
params.topology_id = 1
|
||||||
|
params.report_shape = 'summary'
|
||||||
|
}
|
||||||
|
|
||||||
if (generateForm.value.link_id) {
|
if (generateForm.value.link_id) {
|
||||||
params.link_id = generateForm.value.link_id
|
params.link_id = generateForm.value.link_id
|
||||||
@@ -646,12 +825,21 @@ const handleViewContent = async (record?: ReportRecord) => {
|
|||||||
|
|
||||||
if (res.code === 0 && res.details) {
|
if (res.code === 0 && res.details) {
|
||||||
reportContent.value = res.details
|
reportContent.value = res.details
|
||||||
reportContentType.value = targetRecord.params_json?.report_shape || 'summary'
|
const pj = parsedParamsJson(targetRecord)
|
||||||
|
if (res.details?.traffic_mode === 'snmp_devices') {
|
||||||
|
reportContentType.value = 'snmp_devices'
|
||||||
|
} else {
|
||||||
|
reportContentType.value = (pj.report_shape as string) || 'summary'
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是趋势报表,渲染图表
|
await nextTick()
|
||||||
if (reportContentType.value === 'trend' && res.details.series) {
|
if (reportContentType.value === 'trend') {
|
||||||
await nextTick()
|
const d = res.details?.data
|
||||||
renderChart(res.details.series)
|
if (Array.isArray(d) && d.length) {
|
||||||
|
renderTrafficTrendChart(d)
|
||||||
|
} else if (Array.isArray(res.details?.series)) {
|
||||||
|
renderChart(res.details.series)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Message.error(res.message || '获取报表内容失败')
|
Message.error(res.message || '获取报表内容失败')
|
||||||
@@ -761,7 +949,39 @@ const formatValue = (key: string, value: any) => {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染图表
|
/** 趋势:引擎 payload.data 为时间点数组(time / total_bytes 等),非 series 结构 */
|
||||||
|
const renderTrafficTrendChart = (rows: any[]) => {
|
||||||
|
if (!chartRef.value || rows.length === 0) return
|
||||||
|
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance = echarts.init(chartRef.value)
|
||||||
|
|
||||||
|
const times = rows.map((r) => String(r.time ?? r.timestamp ?? ''))
|
||||||
|
const totals = rows.map((r) => Number(r.total_bytes) || 0)
|
||||||
|
|
||||||
|
const option: echarts.EChartsOption = {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: { type: 'category', boundaryGap: false, data: times },
|
||||||
|
yAxis: { type: 'value', name: '字节' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'total_bytes',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: totals,
|
||||||
|
itemStyle: { color: '#188df0' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance.setOption(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染图表(旧 series 结构兼容)
|
||||||
const renderChart = (series: any[]) => {
|
const renderChart = (series: any[]) => {
|
||||||
if (!chartRef.value || series.length === 0) return
|
if (!chartRef.value || series.length === 0) return
|
||||||
|
|
||||||
|
|||||||
38
src/views/ops/pages/report/useReportListRow.ts
Normal file
38
src/views/ops/pages/report/useReportListRow.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/** 列表行状态:兼容大小写/空值,并在缺省时根据时间戳推断 */
|
||||||
|
export function inferReportStatus(row: Record<string, any>): string {
|
||||||
|
const raw = (row.status ?? row.Status ?? '').toString().trim().toLowerCase()
|
||||||
|
if (raw) return raw
|
||||||
|
if (row.error_message) return 'failed'
|
||||||
|
if (row.finished_at) return 'success'
|
||||||
|
if (row.started_at) return 'running'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReportRows<T extends Record<string, any>>(rows: T[]): (T & { status: string })[] {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
status: inferReportStatus(row),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportStatusLabel: Record<string, string> = {
|
||||||
|
pending: '等待中',
|
||||||
|
running: '生成中',
|
||||||
|
success: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'green'
|
||||||
|
case 'failed':
|
||||||
|
return 'red'
|
||||||
|
case 'running':
|
||||||
|
return 'blue'
|
||||||
|
case 'pending':
|
||||||
|
return 'gray'
|
||||||
|
default:
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/views/ops/pages/report/useReportMetricRegistryOptions.ts
Normal file
47
src/views/ops/pages/report/useReportMetricRegistryOptions.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchReportMetricsRegistry } from '@/api/ops/report'
|
||||||
|
|
||||||
|
export type MetricRegistryOption = { label: string; value: string }
|
||||||
|
|
||||||
|
/** 统计 / TopN:按数据源加载逻辑指标目录下拉 */
|
||||||
|
export function useReportMetricRegistryOptions() {
|
||||||
|
const metricOptions = ref<MetricRegistryOption[]>([])
|
||||||
|
const metricOptionsLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadMetricRegistryOptions(dataSource: string) {
|
||||||
|
metricOptions.value = []
|
||||||
|
if (!dataSource) return
|
||||||
|
|
||||||
|
metricOptionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchReportMetricsRegistry({ data_source: dataSource })
|
||||||
|
if (res.code !== 0 || !res.details) {
|
||||||
|
metricOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(res.details.metrics) ? res.details.metrics : []
|
||||||
|
const acc: MetricRegistryOption[] = []
|
||||||
|
for (const m of rows) {
|
||||||
|
const id = (m?.ID ?? m?.id ?? '') as string
|
||||||
|
const dn = (m?.DisplayName ?? m?.display_name ?? '') as string
|
||||||
|
const metricName = (m?.MetricName ?? m?.metric_name ?? '') as string
|
||||||
|
const key = String(id || metricName).trim()
|
||||||
|
if (!key) continue
|
||||||
|
const label = dn ? `${dn} (${key})` : key
|
||||||
|
acc.push({ label, value: key })
|
||||||
|
}
|
||||||
|
metricOptions.value = acc
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载指标目录失败:', e)
|
||||||
|
metricOptions.value = []
|
||||||
|
} finally {
|
||||||
|
metricOptionsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
metricOptions,
|
||||||
|
metricOptionsLoading,
|
||||||
|
loadMetricRegistryOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchNetworkDeviceList } from '@/api/ops/network-device'
|
||||||
|
|
||||||
|
export type LabeledNumberOption = { label: string; value: number }
|
||||||
|
export type LabeledStringOption = { label: string; value: string }
|
||||||
|
|
||||||
|
function unwrapListRes(res: unknown): { data: any[]; total: number } {
|
||||||
|
const r = res as Record<string, any> | null | undefined
|
||||||
|
const d = r?.details ?? r
|
||||||
|
return {
|
||||||
|
data: Array.isArray(d?.data) ? d.data : [],
|
||||||
|
total: typeof d?.total === 'number' ? d.total : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网络设备报表:按服务表 ID / 按 service_identity 下拉选项 */
|
||||||
|
export function useReportNetworkDevicePickOptions() {
|
||||||
|
const networkDeviceIdOptions = ref<LabeledNumberOption[]>([])
|
||||||
|
const networkDeviceIdentityOptions = ref<LabeledStringOption[]>([])
|
||||||
|
const networkDeviceOptionsLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadNetworkDevicePickOptions() {
|
||||||
|
networkDeviceIdOptions.value = []
|
||||||
|
networkDeviceIdentityOptions.value = []
|
||||||
|
networkDeviceOptionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = 500
|
||||||
|
let page = 1
|
||||||
|
const idAcc: LabeledNumberOption[] = []
|
||||||
|
const identAcc: LabeledStringOption[] = []
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = await fetchNetworkDeviceList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
const chunk = u.data
|
||||||
|
const total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
const id = s?.id
|
||||||
|
const name = (s?.name as string) || ''
|
||||||
|
const ident = (s?.service_identity as string) || ''
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
idAcc.push({
|
||||||
|
label: ident ? `${name || ident} (ID: ${id})` : `网络设备 #${id}`,
|
||||||
|
value: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (ident) {
|
||||||
|
identAcc.push({
|
||||||
|
label: name ? `${name} (${ident})` : ident,
|
||||||
|
value: ident,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunk.length === 0 || chunk.length < pageSize) break
|
||||||
|
if (total > 0 && idAcc.length >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
networkDeviceIdOptions.value = idAcc
|
||||||
|
networkDeviceIdentityOptions.value = identAcc
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载网络设备选项失败:', e)
|
||||||
|
networkDeviceIdOptions.value = []
|
||||||
|
networkDeviceIdentityOptions.value = []
|
||||||
|
} finally {
|
||||||
|
networkDeviceOptionsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
networkDeviceIdOptions,
|
||||||
|
networkDeviceIdentityOptions,
|
||||||
|
networkDeviceOptionsLoading,
|
||||||
|
loadNetworkDevicePickOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/views/ops/pages/report/useReportServerPickOptions.ts
Normal file
75
src/views/ops/pages/report/useReportServerPickOptions.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchServerList } from '@/api/ops/server'
|
||||||
|
|
||||||
|
export type LabeledNumberOption = { label: string; value: number }
|
||||||
|
export type LabeledStringOption = { label: string; value: string }
|
||||||
|
|
||||||
|
function unwrapListRes(res: unknown): { data: any[]; total: number } {
|
||||||
|
const r = res as Record<string, any> | null | undefined
|
||||||
|
const d = r?.details ?? r
|
||||||
|
return {
|
||||||
|
data: Array.isArray(d?.data) ? d.data : [],
|
||||||
|
total: typeof d?.total === 'number' ? d.total : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务器报表:按 ID / 按标识 下拉选项 */
|
||||||
|
export function useReportServerPickOptions() {
|
||||||
|
const serverIdOptions = ref<LabeledNumberOption[]>([])
|
||||||
|
const serverIdentityOptions = ref<LabeledStringOption[]>([])
|
||||||
|
const serverOptionsLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadServerPickOptions() {
|
||||||
|
serverIdOptions.value = []
|
||||||
|
serverIdentityOptions.value = []
|
||||||
|
serverOptionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = 500
|
||||||
|
let page = 1
|
||||||
|
const idAcc: LabeledNumberOption[] = []
|
||||||
|
const identAcc: LabeledStringOption[] = []
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = await fetchServerList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
const chunk = u.data
|
||||||
|
const total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
const id = s?.id
|
||||||
|
const name = (s?.name as string) || ''
|
||||||
|
const ident = (s?.server_identity as string) || ''
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
idAcc.push({
|
||||||
|
label: ident ? `${name || ident} (ID: ${id})` : `服务器 #${id}`,
|
||||||
|
value: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (ident) {
|
||||||
|
identAcc.push({
|
||||||
|
label: name ? `${name} (${ident})` : ident,
|
||||||
|
value: ident,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunk.length === 0 || chunk.length < pageSize) break
|
||||||
|
if (total > 0 && idAcc.length >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
serverIdOptions.value = idAcc
|
||||||
|
serverIdentityOptions.value = identAcc
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载服务器选项失败:', e)
|
||||||
|
serverIdOptions.value = []
|
||||||
|
serverIdentityOptions.value = []
|
||||||
|
} finally {
|
||||||
|
serverOptionsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverIdOptions,
|
||||||
|
serverIdentityOptions,
|
||||||
|
serverOptionsLoading,
|
||||||
|
loadServerPickOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/views/ops/pages/report/useReportTargetIdentityOptions.ts
Normal file
245
src/views/ops/pages/report/useReportTargetIdentityOptions.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchServerList } from '@/api/ops/server'
|
||||||
|
import { fetchDatabaseList } from '@/api/ops/database'
|
||||||
|
import { fetchMiddlewareList } from '@/api/ops/middleware'
|
||||||
|
import { fetchNetworkDeviceList } from '@/api/ops/network-device'
|
||||||
|
|
||||||
|
export type ReportTargetIdentityOption = { label: string; value: string }
|
||||||
|
|
||||||
|
function unwrapListRes(res: unknown): { data: any[]; total: number } {
|
||||||
|
const r = res as Record<string, any> | null | undefined
|
||||||
|
const d = r?.details ?? r
|
||||||
|
return {
|
||||||
|
data: Array.isArray(d?.data) ? d.data : [],
|
||||||
|
total: typeof d?.total === 'number' ? d.total : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按报表「数据源」加载目标标识下拉选项(复用各模块已有列表接口,分页拉全量) */
|
||||||
|
export function useReportTargetIdentityOptions() {
|
||||||
|
const targetIdentityOptions = ref<ReportTargetIdentityOption[]>([])
|
||||||
|
const targetOptionsLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadTargetIdentityOptions(dataSource: string) {
|
||||||
|
targetIdentityOptions.value = []
|
||||||
|
if (!dataSource) return
|
||||||
|
|
||||||
|
targetOptionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = 500
|
||||||
|
let page = 1
|
||||||
|
const acc: ReportTargetIdentityOption[] = []
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let chunk: any[] = []
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
switch (dataSource) {
|
||||||
|
case 'dc-host': {
|
||||||
|
const res = await fetchServerList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
chunk = u.data
|
||||||
|
total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
if (s?.server_identity) {
|
||||||
|
acc.push({
|
||||||
|
label: `${s.name || s.server_identity} (${s.server_identity})`,
|
||||||
|
value: s.server_identity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'dc-database': {
|
||||||
|
const res = await fetchDatabaseList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
chunk = u.data
|
||||||
|
total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
if (s?.service_identity) {
|
||||||
|
acc.push({
|
||||||
|
label: `${s.name || s.service_identity} (${s.service_identity})`,
|
||||||
|
value: s.service_identity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'dc-middleware': {
|
||||||
|
const res = await fetchMiddlewareList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
chunk = u.data
|
||||||
|
total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
if (s?.service_identity) {
|
||||||
|
acc.push({
|
||||||
|
label: `${s.name || s.service_identity} (${s.service_identity})`,
|
||||||
|
value: s.service_identity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'dc-network': {
|
||||||
|
const res = await fetchNetworkDeviceList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
chunk = u.data
|
||||||
|
total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
if (s?.service_identity) {
|
||||||
|
acc.push({
|
||||||
|
label: `${s.name || s.service_identity} (${s.service_identity})`,
|
||||||
|
value: s.service_identity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
targetIdentityOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.length === 0) break
|
||||||
|
if (chunk.length < pageSize) break
|
||||||
|
if (total > 0 && acc.length >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIdentityOptions.value = acc
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载目标标识选项失败:', e)
|
||||||
|
targetIdentityOptions.value = []
|
||||||
|
} finally {
|
||||||
|
targetOptionsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetIdentityOptions,
|
||||||
|
targetOptionsLoading,
|
||||||
|
loadTargetIdentityOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 故障报表服务标识:合并各资源类型的 service_identity / server_identity(去重) */
|
||||||
|
export function useFaultReportServiceIdentityOptions() {
|
||||||
|
const faultServiceIdentityOptions = ref<ReportTargetIdentityOption[]>([])
|
||||||
|
const faultServiceOptionsLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadFaultServiceIdentityOptions() {
|
||||||
|
faultServiceIdentityOptions.value = []
|
||||||
|
faultServiceOptionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = 500
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const acc: ReportTargetIdentityOption[] = []
|
||||||
|
|
||||||
|
async function pullServers() {
|
||||||
|
let page = 1
|
||||||
|
while (true) {
|
||||||
|
const res = await fetchServerList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
const chunk = u.data
|
||||||
|
const total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
const ident = (s?.server_identity as string) || ''
|
||||||
|
if (!ident || seen.has(ident)) continue
|
||||||
|
seen.add(ident)
|
||||||
|
acc.push({
|
||||||
|
label: `[主机] ${s.name || ident} (${ident})`,
|
||||||
|
value: ident,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (chunk.length === 0 || chunk.length < pageSize) break
|
||||||
|
if (total > 0 && page * pageSize >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullNetwork() {
|
||||||
|
let page = 1
|
||||||
|
while (true) {
|
||||||
|
const res = await fetchNetworkDeviceList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
const chunk = u.data
|
||||||
|
const total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
const ident = (s?.service_identity as string) || ''
|
||||||
|
if (!ident || seen.has(ident)) continue
|
||||||
|
seen.add(ident)
|
||||||
|
acc.push({
|
||||||
|
label: `[网络] ${s.name || ident} (${ident})`,
|
||||||
|
value: ident,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (chunk.length === 0 || chunk.length < pageSize) break
|
||||||
|
if (total > 0 && page * pageSize >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullDb() {
|
||||||
|
let page = 1
|
||||||
|
while (true) {
|
||||||
|
const res = await fetchDatabaseList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
const chunk = u.data
|
||||||
|
const total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
const ident = (s?.service_identity as string) || ''
|
||||||
|
if (!ident || seen.has(ident)) continue
|
||||||
|
seen.add(ident)
|
||||||
|
acc.push({
|
||||||
|
label: `[数据库] ${s.name || ident} (${ident})`,
|
||||||
|
value: ident,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (chunk.length === 0 || chunk.length < pageSize) break
|
||||||
|
if (total > 0 && page * pageSize >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullMw() {
|
||||||
|
let page = 1
|
||||||
|
while (true) {
|
||||||
|
const res = await fetchMiddlewareList({ page, size: pageSize })
|
||||||
|
const u = unwrapListRes(res)
|
||||||
|
const chunk = u.data
|
||||||
|
const total = u.total
|
||||||
|
for (const s of chunk) {
|
||||||
|
const ident = (s?.service_identity as string) || ''
|
||||||
|
if (!ident || seen.has(ident)) continue
|
||||||
|
seen.add(ident)
|
||||||
|
acc.push({
|
||||||
|
label: `[中间件] ${s.name || ident} (${ident})`,
|
||||||
|
value: ident,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (chunk.length === 0 || chunk.length < pageSize) break
|
||||||
|
if (total > 0 && page * pageSize >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await pullServers()
|
||||||
|
await pullNetwork()
|
||||||
|
await pullDb()
|
||||||
|
await pullMw()
|
||||||
|
|
||||||
|
faultServiceIdentityOptions.value = acc
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载故障报表服务标识失败:', e)
|
||||||
|
faultServiceIdentityOptions.value = []
|
||||||
|
} finally {
|
||||||
|
faultServiceOptionsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
faultServiceIdentityOptions,
|
||||||
|
faultServiceOptionsLoading,
|
||||||
|
loadFaultServiceIdentityOptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/views/ops/pages/report/useReportTopologyOptions.ts
Normal file
121
src/views/ops/pages/report/useReportTopologyOptions.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { fetchTopologies, fetchLinks, fetchTopologyGraph } from '@/api/ops/netarchTopo'
|
||||||
|
|
||||||
|
export type LabeledNumberOption = { label: string; value: number }
|
||||||
|
export type LabeledStringOption = { label: string; value: string }
|
||||||
|
|
||||||
|
function unwrapDetails(res: unknown): Record<string, any> | null {
|
||||||
|
const r = res as Record<string, any> | null | undefined
|
||||||
|
if (!r || r.code !== 0) return null
|
||||||
|
return (r.details ?? r.data ?? null) as Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报表表单:拓扑 / 链路 / 节点下拉(与流量、网络设备报表共用) */
|
||||||
|
export function useReportTopologyOptions() {
|
||||||
|
const topologyOptions = ref<LabeledNumberOption[]>([])
|
||||||
|
const topologyLoading = ref(false)
|
||||||
|
const linkOptions = ref<LabeledNumberOption[]>([])
|
||||||
|
const linksLoading = ref(false)
|
||||||
|
const nodeOptions = ref<LabeledStringOption[]>([])
|
||||||
|
const nodesLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadTopologyOptions() {
|
||||||
|
topologyOptions.value = []
|
||||||
|
topologyLoading.value = true
|
||||||
|
try {
|
||||||
|
const pageSize = 200
|
||||||
|
let page = 1
|
||||||
|
const acc: LabeledNumberOption[] = []
|
||||||
|
while (true) {
|
||||||
|
const res = await fetchTopologies({ page, size: pageSize })
|
||||||
|
const d = unwrapDetails(res)
|
||||||
|
const rows = Array.isArray(d?.data) ? d.data : []
|
||||||
|
const total = typeof d?.total === 'number' ? d.total : 0
|
||||||
|
for (const t of rows) {
|
||||||
|
const id = t?.id
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
acc.push({
|
||||||
|
label: `${t.name || `拓扑 #${id}`} (ID: ${id})`,
|
||||||
|
value: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.length === 0 || rows.length < pageSize) break
|
||||||
|
if (total > 0 && acc.length >= total) break
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
topologyOptions.value = acc
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载拓扑列表失败:', e)
|
||||||
|
topologyOptions.value = []
|
||||||
|
} finally {
|
||||||
|
topologyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLinksAndNodes(topologyId: number | undefined) {
|
||||||
|
linkOptions.value = []
|
||||||
|
nodeOptions.value = []
|
||||||
|
if (!topologyId) return
|
||||||
|
|
||||||
|
linksLoading.value = true
|
||||||
|
nodesLoading.value = true
|
||||||
|
try {
|
||||||
|
const [linkRes, graphRes] = await Promise.all([
|
||||||
|
fetchLinks(topologyId),
|
||||||
|
fetchTopologyGraph(topologyId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const linkD = unwrapDetails(linkRes)
|
||||||
|
const list = Array.isArray(linkD?.list) ? linkD.list : []
|
||||||
|
const linkAcc: LabeledNumberOption[] = [
|
||||||
|
{ label: '整拓扑(不指定链路)', value: 0 },
|
||||||
|
]
|
||||||
|
for (const e of list) {
|
||||||
|
const id = e?.id
|
||||||
|
if (typeof id !== 'number') continue
|
||||||
|
const label = (e.label as string) || `${e.source} → ${e.target}`
|
||||||
|
linkAcc.push({
|
||||||
|
label: `链路 #${id}:${label}`,
|
||||||
|
value: id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
linkOptions.value = linkAcc
|
||||||
|
|
||||||
|
const g = unwrapDetails(graphRes)
|
||||||
|
const nodesFromGraph =
|
||||||
|
g?.nodes || g?.data || (Array.isArray(g) ? g : []) || []
|
||||||
|
const nodeAcc: LabeledStringOption[] = [{ label: '不指定节点', value: '' }]
|
||||||
|
if (Array.isArray(nodesFromGraph)) {
|
||||||
|
for (const node of nodesFromGraph) {
|
||||||
|
const nid = node?.id != null ? String(node.id) : ''
|
||||||
|
if (!nid) continue
|
||||||
|
const nl = (node.label as string) || nid
|
||||||
|
nodeAcc.push({
|
||||||
|
label: `${nl} (${nid})`,
|
||||||
|
value: nid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodeOptions.value = nodeAcc
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载链路/节点失败:', e)
|
||||||
|
linkOptions.value = [{ label: '整拓扑(不指定链路)', value: 0 }]
|
||||||
|
nodeOptions.value = [{ label: '不指定节点', value: '' }]
|
||||||
|
} finally {
|
||||||
|
linksLoading.value = false
|
||||||
|
nodesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topologyOptions,
|
||||||
|
topologyLoading,
|
||||||
|
linkOptions,
|
||||||
|
linksLoading,
|
||||||
|
nodeOptions,
|
||||||
|
nodesLoading,
|
||||||
|
loadTopologyOptions,
|
||||||
|
loadLinksAndNodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user