fix
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { request } from "@/api/request"
|
||||
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage"
|
||||
|
||||
// ============ 通用响应类型 ============
|
||||
|
||||
@@ -17,7 +18,6 @@ export enum ReportType {
|
||||
FAULT = 'fault',
|
||||
SERVER = 'server',
|
||||
NETWORK_DEVICE = 'network_device',
|
||||
HISTORY = 'history',
|
||||
}
|
||||
|
||||
export enum ReportStatus {
|
||||
@@ -31,7 +31,8 @@ export enum ReportStatus {
|
||||
|
||||
export interface ReportRecord {
|
||||
id: number
|
||||
report_type: ReportType
|
||||
/** 列表接口可能含已下线类型字符串,仅六类可再次生成 */
|
||||
report_type: ReportType | string
|
||||
title: string
|
||||
description?: string
|
||||
status: ReportStatus
|
||||
@@ -65,7 +66,11 @@ export interface PageResult<T> {
|
||||
// ============ 报表生成参数接口 ============
|
||||
|
||||
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
|
||||
node_id?: string
|
||||
granularity?: 'minute' | 'hour' | 'day' | 'month'
|
||||
@@ -102,9 +107,21 @@ export interface ServerReportParams {
|
||||
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 {
|
||||
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[]
|
||||
start_time: string
|
||||
end_time: string
|
||||
@@ -127,7 +144,8 @@ export interface HistoryReportParams {
|
||||
|
||||
export interface TopNReportParams {
|
||||
data_source: 'dc-host' | 'dc-network' | 'dc-database' | 'dc-middleware'
|
||||
metric_name: string
|
||||
metric_id?: string
|
||||
metric_name?: string
|
||||
target_identities: string[]
|
||||
start_time: string
|
||||
end_time: string
|
||||
@@ -142,7 +160,15 @@ export interface GenerateReportParams {
|
||||
report_type: ReportType
|
||||
title?: 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) =>
|
||||
request.get<ApiResponse<ReportRecord>>(`/DC-Control/v1/reports/${id}`)
|
||||
|
||||
/** 生成报表 */
|
||||
/** 同步生成报表(topn / statistics / traffic / fault / server / network_device) */
|
||||
export const generateReport = (data: GenerateReportParams) =>
|
||||
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) =>
|
||||
request.get<ApiResponse<Record<string, any>>>(`/DC-Control/v1/reports/${id}/content`)
|
||||
|
||||
/** 导出报表 */
|
||||
export const exportReport = (id: number, format: 'csv' | 'xlsx' = 'csv') =>
|
||||
request.get<Blob>(`/DC-Control/v1/reports/${id}/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
})
|
||||
/** 原始 ArrayBuffer → 下载用 Blob;xlsx 校验 ZIP 魔数 PK */
|
||||
function exportBufferToBlob(ab: ArrayBuffer, format: 'csv' | 'xlsx', contentType: string | null): Blob {
|
||||
const u8 = new Uint8Array(ab)
|
||||
if (format === 'xlsx') {
|
||||
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. 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(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)
|
||||
if (response.data.status === 401) {
|
||||
if (response.data?.status === 401) {
|
||||
// token过期处理
|
||||
SafeStorage.clearAppStorage();
|
||||
window.location.href = "/auth/login";
|
||||
@@ -59,6 +79,8 @@ import axios, {
|
||||
interface RequestConfig extends AxiosRequestConfig {
|
||||
data?: unknown;
|
||||
needWorkspace?: boolean;
|
||||
/** 为 true 时响应拦截器返回完整 AxiosResponse(用于 blob 等需自行取 data 的场景) */
|
||||
rawResponse?: boolean;
|
||||
}
|
||||
|
||||
export const request = {
|
||||
|
||||
@@ -1007,21 +1007,6 @@ export const localMenuFlatItems: MenuItem[] = [
|
||||
sort_key: 54,
|
||||
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,
|
||||
identity: '019b591d-0446-7270-b3a6-b0a0e4623962',
|
||||
|
||||
@@ -1084,22 +1084,6 @@ export const localMenuItems: MenuItem[] = [
|
||||
created_at: '2025-12-26T13:23:52.533693+08:00',
|
||||
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,
|
||||
identity: '019b591d-0446-7270-b3a6-b0a0e4623962',
|
||||
|
||||
@@ -494,13 +494,18 @@ const guides = ref([
|
||||
items: ['手动创建拓扑图', '自动发现网络拓扑', '编辑设备连接关系', '设置拓扑图展示样式']
|
||||
},
|
||||
{
|
||||
title: '自动感知拓扑图',
|
||||
title: '自动感知',
|
||||
text: '系统可自动发现网络设备及其连接关系,生成动态拓扑图,实时反映网络架构变化。'
|
||||
},
|
||||
{
|
||||
title: '流量分析管理',
|
||||
text: '在"流量分析管理"中查看网络流量数据。',
|
||||
items: ['查看端口流量统计', '分析流量趋势', '识别流量异常', '生成流量报表']
|
||||
items: [
|
||||
'查看端口流量统计',
|
||||
'分析流量趋势',
|
||||
'识别流量异常',
|
||||
'在「报表管理 → 流量统计」生成报表:拓扑模式走 NetFlow/拓扑汇总;多设备 SNMP 选 traffic_mode=snmp_devices,详见 dc-control 文档《报表管理接口文档》',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'IP地址管理',
|
||||
|
||||
@@ -30,19 +30,22 @@
|
||||
</template>
|
||||
|
||||
<template #enabled="{ record }">
|
||||
<a-tag :color="record.enabled ? 'green' : 'gray'">
|
||||
{{ record.enabled ? '已启用' : '已禁用' }}
|
||||
<a-tag :color="tagColorOnOff(record.enabled)">
|
||||
<template v-if="record.enabled">已启用</template>
|
||||
<template v-else>已禁用</template>
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #data_collection="{ record }">
|
||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
<a-tag :color="tagColorOnOff(record.collect_on)">
|
||||
<template v-if="record.collect_on">已启用</template>
|
||||
<template v-else>未启用</template>
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #collect_method="{ record }">
|
||||
<a-tag :color="record.collect_method === 'snmp' ? 'purple' : 'arcoblue'">
|
||||
{{ record.collect_method === 'snmp' ? 'SNMP' : 'API' }}
|
||||
<a-tag :color="tagColorCollectMethod(record.collect_method)">
|
||||
<template v-if="record.collect_method === 'snmp'">SNMP</template>
|
||||
<template v-else>API</template>
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
@@ -128,6 +131,11 @@ const pagination = reactive({
|
||||
const formItems = computed<FormItem[]>(() => searchFormConfig)
|
||||
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 () => {
|
||||
loading.value = true
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h2>自动感知拓扑图</h2>
|
||||
<h2>自动感知</h2>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a-select
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="reportStatusColor(record.status)">
|
||||
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operations="{ record }">
|
||||
<a-space>
|
||||
@@ -89,57 +95,16 @@
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<!-- 生成报表弹窗 -->
|
||||
<!-- 生成报表弹窗(与后端 network_device 参数一致:service_identities 或 network_device_service_ids) -->
|
||||
<a-modal
|
||||
v-model:visible="generateModalVisible"
|
||||
title="生成网络设备报表"
|
||||
:width="600"
|
||||
:width="640"
|
||||
: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="拓扑 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-range-picker
|
||||
v-model="generateForm.timeRange"
|
||||
@@ -150,78 +115,47 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">设备范围(二选一)</a-divider>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="报表形态" field="report_shape" :rules="[{ required: true, message: '请选择报表形态' }]">
|
||||
<a-select v-model="generateForm.report_shape" placeholder="请选择" style="width: 100%">
|
||||
<a-option value="summary">汇总</a-option>
|
||||
<a-option value="detail">明细</a-option>
|
||||
<a-option value="trend">趋势</a-option>
|
||||
<a-option value="top">Top 排名</a-option>
|
||||
</a-select>
|
||||
<a-form-item label="网络设备服务 ID" field="network_device_service_ids">
|
||||
<a-select
|
||||
v-model="generateForm.network_device_service_ids"
|
||||
multiple
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="networkDeviceOptionsLoading"
|
||||
:options="networkDeviceIdOptions"
|
||||
placeholder="按库表主键选择"
|
||||
:max-tag-count="3"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</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 label="服务标识 service_identity" field="service_identities">
|
||||
<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-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Top 排名额外参数 -->
|
||||
<template v-if="generateForm.report_shape === 'top'">
|
||||
<a-row :gutter="16">
|
||||
<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="include_daily_alerts">
|
||||
<a-switch v-model="generateForm.include_daily_alerts" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- 明细额外参数 -->
|
||||
<template v-if="generateForm.report_shape === 'detail'">
|
||||
<a-form-item label="明细条数限制" field="detail_limit">
|
||||
<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-item label="报表标题" field="title">
|
||||
<a-input v-model="generateForm.title" placeholder="可选,不填自动生成" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
@@ -236,8 +170,34 @@
|
||||
<a-spin />
|
||||
</div>
|
||||
<div v-else-if="reportContent">
|
||||
<!-- 汇总数据 -->
|
||||
<template v-if="reportContentType === 'summary'">
|
||||
<!-- 网络设备 / 通用行表(与引擎 payload.rows 一致) -->
|
||||
<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-descriptions :column="3" bordered>
|
||||
<a-descriptions-item
|
||||
@@ -299,9 +259,18 @@ import {
|
||||
exportReport,
|
||||
ReportType,
|
||||
type ReportRecord,
|
||||
type TrafficReportParams,
|
||||
type NetworkDeviceReportParams,
|
||||
} from '@/api/ops/report'
|
||||
import * as echarts from 'echarts'
|
||||
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||
import { useReportNetworkDevicePickOptions } from '../useReportNetworkDevicePickOptions'
|
||||
|
||||
const {
|
||||
networkDeviceIdOptions,
|
||||
networkDeviceIdentityOptions,
|
||||
networkDeviceOptionsLoading,
|
||||
loadNetworkDevicePickOptions,
|
||||
} = useReportNetworkDevicePickOptions()
|
||||
|
||||
// 页面标题
|
||||
const pageTitle = '网络设备报表'
|
||||
@@ -402,29 +371,17 @@ const tableColumns = computed(() => [
|
||||
// 生成报表弹窗
|
||||
const generateModalVisible = ref(false)
|
||||
const generateForm = ref<{
|
||||
topology_id: number | undefined
|
||||
link_id: number | undefined
|
||||
node_id: string
|
||||
granularity: string
|
||||
network_device_service_ids: number[]
|
||||
service_identities: string[]
|
||||
timeRange: string[]
|
||||
report_shape: string
|
||||
include_daily_alerts: boolean
|
||||
title: string
|
||||
top_order_by: string
|
||||
top_limit: number | undefined
|
||||
detail_limit: number | undefined
|
||||
trend_granularity: string
|
||||
}>({
|
||||
topology_id: undefined,
|
||||
link_id: undefined,
|
||||
node_id: '',
|
||||
granularity: 'hour',
|
||||
network_device_service_ids: [],
|
||||
service_identities: [],
|
||||
timeRange: [],
|
||||
report_shape: 'summary',
|
||||
include_daily_alerts: false,
|
||||
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 []
|
||||
|
||||
const data =
|
||||
reportContent.value.rows ||
|
||||
reportContent.value.by_node ||
|
||||
reportContent.value.items ||
|
||||
reportContent.value.ranking ||
|
||||
@@ -482,7 +440,7 @@ const fetchList = async () => {
|
||||
const res = await fetchReportList(params)
|
||||
|
||||
if (res.code === 0 && res.details) {
|
||||
tableData.value = res.details.data || []
|
||||
tableData.value = normalizeReportRows(res.details.data || [])
|
||||
pagination.total = res.details.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取报表列表失败')
|
||||
@@ -530,18 +488,13 @@ const handleRefresh = () => {
|
||||
// 打开生成报表弹窗
|
||||
const handleOpenGenerateModal = () => {
|
||||
generateForm.value = {
|
||||
topology_id: undefined,
|
||||
link_id: undefined,
|
||||
node_id: '',
|
||||
granularity: 'hour',
|
||||
network_device_service_ids: [],
|
||||
service_identities: [],
|
||||
timeRange: [],
|
||||
report_shape: 'summary',
|
||||
include_daily_alerts: false,
|
||||
title: '',
|
||||
top_order_by: 'total_bytes',
|
||||
top_limit: undefined,
|
||||
detail_limit: undefined,
|
||||
trend_granularity: 'hour',
|
||||
}
|
||||
void loadNetworkDevicePickOptions()
|
||||
generateModalVisible.value = true
|
||||
}
|
||||
|
||||
@@ -552,54 +505,37 @@ const handleCloseGenerateModal = () => {
|
||||
|
||||
// 生成报表
|
||||
const handleGenerate = async () => {
|
||||
// 验证必填项
|
||||
if (!generateForm.value.topology_id) {
|
||||
Message.warning('请输入拓扑 ID')
|
||||
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
||||
Message.warning('请选择时间范围')
|
||||
return
|
||||
}
|
||||
|
||||
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
||||
Message.warning('请选择时间范围')
|
||||
const ids = generateForm.value.network_device_service_ids || []
|
||||
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
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
|
||||
try {
|
||||
const params: TrafficReportParams = {
|
||||
topology_id: generateForm.value.topology_id,
|
||||
const params: NetworkDeviceReportParams = {
|
||||
start_time: generateForm.value.timeRange[0],
|
||||
end_time: generateForm.value.timeRange[1],
|
||||
report_shape: generateForm.value.report_shape as any,
|
||||
}
|
||||
|
||||
if (generateForm.value.link_id) {
|
||||
params.link_id = generateForm.value.link_id
|
||||
if (ids.length > 0) {
|
||||
params.network_device_service_ids = ids
|
||||
}
|
||||
|
||||
if (generateForm.value.node_id) {
|
||||
params.node_id = generateForm.value.node_id
|
||||
if (idents.length > 0) {
|
||||
params.service_identities = idents
|
||||
}
|
||||
|
||||
if (generateForm.value.granularity) {
|
||||
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
|
||||
}
|
||||
if (generateForm.value.include_daily_alerts) {
|
||||
params.include_daily_alerts = true
|
||||
}
|
||||
|
||||
const res = await generateReport({
|
||||
@@ -646,9 +582,13 @@ const handleViewContent = async (record?: ReportRecord) => {
|
||||
|
||||
if (res.code === 0 && 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) {
|
||||
await nextTick()
|
||||
renderChart(res.details.series)
|
||||
@@ -705,6 +645,15 @@ const handleExport = async (format: 'csv' | 'xlsx', record?: ReportRecord) => {
|
||||
// 格式化标签
|
||||
const formatLabel = (key: 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',
|
||||
total_in_bytes: '总入流量',
|
||||
total_out_bytes: '总出流量',
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="reportStatusColor(record.status)">
|
||||
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operations="{ record }">
|
||||
<a-space>
|
||||
@@ -131,9 +137,15 @@
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="服务标识" field="service_identities">
|
||||
<a-input-tag
|
||||
<a-select
|
||||
v-model="generateForm.service_identities"
|
||||
placeholder="输入后按回车添加"
|
||||
multiple
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="faultServiceOptionsLoading"
|
||||
:options="faultServiceIdentityOptions"
|
||||
placeholder="可选;不选表示不过滤服务"
|
||||
:max-tag-count="3"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
@@ -207,8 +219,20 @@
|
||||
<a-spin />
|
||||
</div>
|
||||
<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-descriptions :column="3" bordered>
|
||||
<a-descriptions-item
|
||||
@@ -257,6 +281,14 @@ import {
|
||||
type ReportRecord,
|
||||
type FaultReportParams,
|
||||
} from '@/api/ops/report'
|
||||
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||
import { useFaultReportServiceIdentityOptions } from '../useReportTargetIdentityOptions'
|
||||
|
||||
const {
|
||||
faultServiceIdentityOptions,
|
||||
faultServiceOptionsLoading,
|
||||
loadFaultServiceIdentityOptions,
|
||||
} = useFaultReportServiceIdentityOptions()
|
||||
|
||||
// 页面标题
|
||||
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 () => {
|
||||
loading.value = true
|
||||
@@ -444,7 +487,7 @@ const fetchList = async () => {
|
||||
const res = await fetchReportList(params)
|
||||
|
||||
if (res.code === 0 && res.details) {
|
||||
tableData.value = res.details.data || []
|
||||
tableData.value = normalizeReportRows(res.details.data || [])
|
||||
pagination.total = res.details.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取报表列表失败')
|
||||
@@ -503,6 +546,7 @@ const handleOpenGenerateModal = () => {
|
||||
alert_severities: [],
|
||||
include_raw_messages: false,
|
||||
}
|
||||
void loadFaultServiceIdentityOptions()
|
||||
generateModalVisible.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,699 +1,38 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="tableColumns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:title="pageTitle"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@refresh="handleRefresh"
|
||||
<div class="wrap">
|
||||
<a-result
|
||||
status="info"
|
||||
title="历史报表入口已下线"
|
||||
sub-title="多指标、多目标时序请使用「统计报告」:output_mode=timeseries,并按接口文档配置 interval 与 bucket_aggregation。旧类型记录仍可在各报表列表中按 report_type 筛选查看(若库中有数据)。"
|
||||
>
|
||||
<!-- 自定义表单项:创建时间范围 -->
|
||||
<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>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleOpenGenerateModal">
|
||||
<template #icon><icon-file-add /></template>
|
||||
生成报表
|
||||
</a-button>
|
||||
<a-button type="primary" @click="goStatistics">前往统计报告</a-button>
|
||||
<a-button @click="goTopn">前往 TopN</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 工具栏右侧:查看和导出按钮 -->
|
||||
<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>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, nextTick } from 'vue'
|
||||
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'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 页面标题
|
||||
const pageTitle = '历史报表'
|
||||
const router = useRouter()
|
||||
|
||||
// 列表筛选表单模型
|
||||
const formModel = ref<{
|
||||
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 goStatistics = () => {
|
||||
router.push('/report/statistics')
|
||||
}
|
||||
|
||||
// 处理表单模型更新
|
||||
const handleFormModelUpdate = (value: any) => {
|
||||
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',
|
||||
const goTopn = () => {
|
||||
router.push('/report/topn')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
padding: 20px;
|
||||
|
||||
.chart-container {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
.wrap {
|
||||
padding: 48px 24px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="reportStatusColor(record.status)">
|
||||
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operations="{ record }">
|
||||
<a-space>
|
||||
@@ -109,29 +115,21 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">服务器选择(二选一)</a-divider>
|
||||
<a-divider orientation="left">服务器标识</a-divider>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="服务器 ID" field="server_ids">
|
||||
<a-input-tag
|
||||
v-model="generateForm.server_ids_str"
|
||||
placeholder="输入ID后按回车"
|
||||
style="width: 100%"
|
||||
@change="handleServerIdsChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="服务器标识" field="server_identities">
|
||||
<a-input-tag
|
||||
v-model="generateForm.server_identities"
|
||||
placeholder="输入标识后按回车"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="服务器标识" field="server_identities">
|
||||
<a-select
|
||||
v-model="generateForm.server_identities"
|
||||
multiple
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="serverOptionsLoading"
|
||||
:options="serverIdentityOptions"
|
||||
placeholder="请选择服务器标识(可多选)"
|
||||
:max-tag-count="4"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">报表配置</a-divider>
|
||||
|
||||
@@ -199,12 +197,17 @@
|
||||
<a-spin />
|
||||
</div>
|
||||
<div v-else-if="reportContent">
|
||||
<!-- 汇总信息 -->
|
||||
<a-card v-if="reportContent.summary" title="服务器汇总" :bordered="false" class="summary-card">
|
||||
<a-descriptions :column="3" bordered>
|
||||
<!-- 元信息:后端为 result_meta;旧数据可能为 summary -->
|
||||
<a-card
|
||||
v-if="serverReportMeta && Object.keys(serverReportMeta).length"
|
||||
title="说明与汇总"
|
||||
:bordered="false"
|
||||
class="summary-card"
|
||||
>
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item
|
||||
v-for="(value, key) in reportContent.summary"
|
||||
:key="key"
|
||||
v-for="(value, key) in serverReportMeta"
|
||||
:key="String(key)"
|
||||
:label="formatLabel(String(key))"
|
||||
>
|
||||
{{ formatValue(String(key), value) }}
|
||||
@@ -212,9 +215,9 @@
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<!-- 服务器列表 -->
|
||||
<!-- 行数据:引擎字段为 rows;旧前端曾用 servers -->
|
||||
<a-table
|
||||
:data="reportContent.servers || []"
|
||||
:data="serverReportRows"
|
||||
:columns="contentTableColumns"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
stripe
|
||||
@@ -239,6 +242,10 @@ import {
|
||||
type ReportRecord,
|
||||
type ServerReportParams,
|
||||
} from '@/api/ops/report'
|
||||
import { normalizeReportRows, reportStatusColor, reportStatusLabel } from '../useReportListRow'
|
||||
import { useReportServerPickOptions } from '../useReportServerPickOptions'
|
||||
|
||||
const { serverIdentityOptions, serverOptionsLoading, loadServerPickOptions } = useReportServerPickOptions()
|
||||
|
||||
// 页面标题
|
||||
const pageTitle = '服务器报表'
|
||||
@@ -340,8 +347,6 @@ const tableColumns = computed(() => [
|
||||
const generateModalVisible = ref(false)
|
||||
const generateForm = ref<{
|
||||
timeRange: string[]
|
||||
server_ids_str: string[]
|
||||
server_ids: number[]
|
||||
server_identities: string[]
|
||||
columns: string[]
|
||||
availability_rule: string
|
||||
@@ -349,8 +354,6 @@ const generateForm = ref<{
|
||||
title: string
|
||||
}>({
|
||||
timeRange: [],
|
||||
server_ids_str: [],
|
||||
server_ids: [],
|
||||
server_identities: [],
|
||||
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
|
||||
availability_rule: 'metrics_presence',
|
||||
@@ -363,14 +366,25 @@ const contentModalVisible = ref(false)
|
||||
const contentModalTitle = ref('')
|
||||
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(() => {
|
||||
if (!reportContent.value) return []
|
||||
|
||||
const data = reportContent.value.servers || []
|
||||
|
||||
const data = serverReportRows.value
|
||||
if (data.length === 0) return []
|
||||
|
||||
const firstRecord = data[0]
|
||||
return Object.keys(firstRecord).map((key) => ({
|
||||
title: formatLabel(key),
|
||||
@@ -406,7 +420,7 @@ const fetchList = async () => {
|
||||
const res = await fetchReportList(params)
|
||||
|
||||
if (res.code === 0 && res.details) {
|
||||
tableData.value = res.details.data || []
|
||||
tableData.value = normalizeReportRows(res.details.data || [])
|
||||
pagination.total = res.details.total || 0
|
||||
} else {
|
||||
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 = () => {
|
||||
pagination.current = 1
|
||||
@@ -462,14 +469,13 @@ const handleRefresh = () => {
|
||||
const handleOpenGenerateModal = () => {
|
||||
generateForm.value = {
|
||||
timeRange: [],
|
||||
server_ids_str: [],
|
||||
server_ids: [],
|
||||
server_identities: [],
|
||||
columns: ['availability', 'cpu', 'memory_phys', 'disk_usage'],
|
||||
availability_rule: 'metrics_presence',
|
||||
include_daily_alerts: false,
|
||||
title: '',
|
||||
}
|
||||
loadServerPickOptions()
|
||||
generateModalVisible.value = true
|
||||
}
|
||||
|
||||
@@ -486,18 +492,8 @@ const handleGenerate = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证服务器选择
|
||||
if (
|
||||
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 和标识不能同时填写')
|
||||
if (generateForm.value.server_identities.length === 0) {
|
||||
Message.warning('请选择服务器标识')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -507,14 +503,7 @@ const handleGenerate = async () => {
|
||||
const params: ServerReportParams = {
|
||||
start_time: generateForm.value.timeRange[0],
|
||||
end_time: generateForm.value.timeRange[1],
|
||||
}
|
||||
|
||||
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
|
||||
server_identities: generateForm.value.server_identities,
|
||||
}
|
||||
|
||||
if (generateForm.value.columns.length > 0) {
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="reportStatusColor(record.status)">
|
||||
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operations="{ record }">
|
||||
<a-space>
|
||||
@@ -116,11 +122,20 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
label="指标名称"
|
||||
field="metric_name"
|
||||
:rules="[{ required: true, message: '请输入指标名称' }]"
|
||||
label="指标"
|
||||
field="metric_id"
|
||||
: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-col>
|
||||
</a-row>
|
||||
@@ -128,11 +143,20 @@
|
||||
<a-form-item
|
||||
label="目标标识"
|
||||
field="target_identities"
|
||||
:rules="[{ required: true, message: '请输入目标标识' }]"
|
||||
:rules="[{ required: true, message: '请选择目标标识' }]"
|
||||
>
|
||||
<a-input-tag
|
||||
<a-select
|
||||
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%"
|
||||
/>
|
||||
</a-form-item>
|
||||
@@ -258,7 +282,7 @@
|
||||
</template>
|
||||
|
||||
<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 SearchTable from '@/components/search-table/index.vue'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
@@ -272,6 +296,14 @@ import {
|
||||
type StatisticsReportParams,
|
||||
} from '@/api/ops/report'
|
||||
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 = '统计报表'
|
||||
@@ -373,7 +405,7 @@ const tableColumns = computed(() => [
|
||||
const generateModalVisible = ref(false)
|
||||
const generateForm = ref<{
|
||||
data_source: string
|
||||
metric_name: string
|
||||
metric_id: string
|
||||
target_identities: string[]
|
||||
timeRange: string[]
|
||||
output_mode: string
|
||||
@@ -383,7 +415,7 @@ const generateForm = ref<{
|
||||
title: string
|
||||
}>({
|
||||
data_source: '',
|
||||
metric_name: '',
|
||||
metric_id: '',
|
||||
target_identities: [],
|
||||
timeRange: [],
|
||||
output_mode: 'scalar',
|
||||
@@ -393,6 +425,16 @@ const generateForm = ref<{
|
||||
title: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => generateForm.value.data_source,
|
||||
(ds) => {
|
||||
generateForm.value.target_identities = []
|
||||
generateForm.value.metric_id = ''
|
||||
loadTargetIdentityOptions(ds)
|
||||
loadMetricRegistryOptions(ds)
|
||||
},
|
||||
)
|
||||
|
||||
// 查看内容弹窗
|
||||
const contentModalVisible = ref(false)
|
||||
const contentModalTitle = ref('')
|
||||
@@ -441,7 +483,7 @@ const fetchList = async () => {
|
||||
const res = await fetchReportList(params)
|
||||
|
||||
if (res.code === 0 && res.details) {
|
||||
tableData.value = res.details.data || []
|
||||
tableData.value = normalizeReportRows(res.details.data || [])
|
||||
pagination.total = res.details.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取报表列表失败')
|
||||
@@ -490,7 +532,7 @@ const handleRefresh = () => {
|
||||
const handleOpenGenerateModal = () => {
|
||||
generateForm.value = {
|
||||
data_source: '',
|
||||
metric_name: '',
|
||||
metric_id: '',
|
||||
target_identities: [],
|
||||
timeRange: [],
|
||||
output_mode: 'scalar',
|
||||
@@ -515,13 +557,13 @@ const handleGenerate = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!generateForm.value.metric_name) {
|
||||
Message.warning('请输入指标名称')
|
||||
if (!generateForm.value.metric_id) {
|
||||
Message.warning('请选择指标')
|
||||
return
|
||||
}
|
||||
|
||||
if (generateForm.value.target_identities.length === 0) {
|
||||
Message.warning('请输入目标标识')
|
||||
Message.warning('请选择目标标识')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -551,7 +593,7 @@ const handleGenerate = async () => {
|
||||
try {
|
||||
const params: StatisticsReportParams = {
|
||||
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,
|
||||
start_time: generateForm.value.timeRange[0],
|
||||
end_time: generateForm.value.timeRange[1],
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="reportStatusColor(record.status)">
|
||||
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operations="{ record }">
|
||||
<a-space>
|
||||
@@ -116,11 +122,20 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
label="指标名称"
|
||||
field="metric_name"
|
||||
:rules="[{ required: true, message: '请输入指标名称' }]"
|
||||
label="指标"
|
||||
field="metric_id"
|
||||
: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-col>
|
||||
</a-row>
|
||||
@@ -128,13 +143,25 @@
|
||||
<a-form-item
|
||||
label="目标标识"
|
||||
field="target_identities"
|
||||
:rules="[{ required: true, message: '请输入目标标识' }]"
|
||||
:rules="[{ required: true, message: '请选择目标标识' }]"
|
||||
>
|
||||
<a-input-tag
|
||||
<a-select
|
||||
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%"
|
||||
/>
|
||||
<div v-if="generateForm.data_source === 'dc-network'" class="target-id-hint">
|
||||
TopN 当前实现可能对「网络」数据源无法出数;选项来自网络设备服务列表,供对齐标识使用。
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="时间范围" field="timeRange" :rules="[{ required: true, message: '请选择时间范围' }]">
|
||||
@@ -195,7 +222,7 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<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-col>
|
||||
</a-row>
|
||||
@@ -221,9 +248,9 @@
|
||||
<!-- 图表展示 -->
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
|
||||
<!-- 排名表格 -->
|
||||
<!-- 排名表格(引擎字段为 identity + value,见 dc-control genTopN) -->
|
||||
<a-table
|
||||
:data="reportContent.ranking || []"
|
||||
:data="normalizedRankingRows"
|
||||
:columns="rankingTableColumns"
|
||||
:pagination="false"
|
||||
stripe
|
||||
@@ -235,7 +262,7 @@
|
||||
</template>
|
||||
|
||||
<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 SearchTable from '@/components/search-table/index.vue'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
@@ -249,6 +276,14 @@ import {
|
||||
type TopNReportParams,
|
||||
} from '@/api/ops/report'
|
||||
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 报表'
|
||||
@@ -350,7 +385,7 @@ const tableColumns = computed(() => [
|
||||
const generateModalVisible = ref(false)
|
||||
const generateForm = ref<{
|
||||
data_source: string
|
||||
metric_name: string
|
||||
metric_id: string
|
||||
target_identities: string[]
|
||||
timeRange: string[]
|
||||
n: number
|
||||
@@ -361,7 +396,7 @@ const generateForm = ref<{
|
||||
title: string
|
||||
}>({
|
||||
data_source: '',
|
||||
metric_name: '',
|
||||
metric_id: '',
|
||||
target_identities: [],
|
||||
timeRange: [],
|
||||
n: 10,
|
||||
@@ -372,6 +407,16 @@ const generateForm = ref<{
|
||||
title: '',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => generateForm.value.data_source,
|
||||
(ds) => {
|
||||
generateForm.value.target_identities = []
|
||||
generateForm.value.metric_id = ''
|
||||
loadTargetIdentityOptions(ds)
|
||||
loadMetricRegistryOptions(ds)
|
||||
},
|
||||
)
|
||||
|
||||
// 查看内容弹窗
|
||||
const contentModalVisible = ref(false)
|
||||
const contentModalTitle = ref('')
|
||||
@@ -379,6 +424,18 @@ const reportContent = ref<Record<string, any> | null>(null)
|
||||
const chartRef = ref<HTMLElement | 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(() => [
|
||||
{
|
||||
@@ -425,7 +482,7 @@ const fetchList = async () => {
|
||||
const res = await fetchReportList(params)
|
||||
|
||||
if (res.code === 0 && res.details) {
|
||||
tableData.value = res.details.data || []
|
||||
tableData.value = normalizeReportRows(res.details.data || [])
|
||||
pagination.total = res.details.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取报表列表失败')
|
||||
@@ -474,7 +531,7 @@ const handleRefresh = () => {
|
||||
const handleOpenGenerateModal = () => {
|
||||
generateForm.value = {
|
||||
data_source: '',
|
||||
metric_name: '',
|
||||
metric_id: '',
|
||||
target_identities: [],
|
||||
timeRange: [],
|
||||
n: 10,
|
||||
@@ -500,13 +557,13 @@ const handleGenerate = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!generateForm.value.metric_name) {
|
||||
Message.warning('请输入指标名称')
|
||||
if (!generateForm.value.metric_id) {
|
||||
Message.warning('请选择指标')
|
||||
return
|
||||
}
|
||||
|
||||
if (generateForm.value.target_identities.length === 0) {
|
||||
Message.warning('请输入目标标识')
|
||||
Message.warning('请选择目标标识')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -520,7 +577,7 @@ const handleGenerate = async () => {
|
||||
try {
|
||||
const params: TopNReportParams = {
|
||||
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,
|
||||
start_time: generateForm.value.timeRange[0],
|
||||
end_time: generateForm.value.timeRange[1],
|
||||
@@ -675,7 +732,9 @@ const renderChart = (ranking: any[]) => {
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: ranking.map((item: any) => item.target).reverse(),
|
||||
data: ranking
|
||||
.map((item: any) => item.target ?? item.identity ?? item.target_identity ?? '')
|
||||
.reverse(),
|
||||
},
|
||||
series: [
|
||||
{
|
||||
@@ -721,5 +780,12 @@ export default {
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.target-id-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -58,6 +58,12 @@
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="reportStatusColor(record.status)">
|
||||
{{ reportStatusLabel[record.status] || record.status || '—' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operations="{ record }">
|
||||
<a-space>
|
||||
@@ -99,24 +105,64 @@
|
||||
@cancel="handleCloseGenerateModal"
|
||||
>
|
||||
<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-col :span="12">
|
||||
<a-form-item label="拓扑 ID" field="topology_id" :rules="[{ required: true, message: '请输入拓扑 ID' }]">
|
||||
<a-input-number
|
||||
<a-form-item
|
||||
label="拓扑"
|
||||
field="topology_id"
|
||||
:rules="
|
||||
generateForm.traffic_mode === 'topology'
|
||||
? [{ required: true, message: '请选择拓扑' }]
|
||||
: []
|
||||
"
|
||||
>
|
||||
<a-select
|
||||
v-model="generateForm.topology_id"
|
||||
placeholder="请输入拓扑 ID"
|
||||
:min="1"
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="topologyLoading"
|
||||
:options="topologyOptions"
|
||||
placeholder="拓扑模式必选"
|
||||
style="width: 100%"
|
||||
:disabled="generateForm.traffic_mode === 'snmp_devices'"
|
||||
@change="onTrafficTopologyChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="链路 ID" field="link_id">
|
||||
<a-input-number
|
||||
<a-form-item label="链路" field="link_id">
|
||||
<a-select
|
||||
v-model="generateForm.link_id"
|
||||
placeholder="可选,0 表示整拓扑"
|
||||
:min="0"
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="linksLoading"
|
||||
:options="linkOptions"
|
||||
placeholder="可选,默认整拓扑"
|
||||
style="width: 100%"
|
||||
:disabled="!generateForm.topology_id || generateForm.traffic_mode === 'snmp_devices'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -124,8 +170,17 @@
|
||||
|
||||
<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 label="节点" field="node_id">
|
||||
<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-col>
|
||||
<a-col :span="12">
|
||||
@@ -236,13 +291,22 @@
|
||||
<a-spin />
|
||||
</div>
|
||||
<div v-else-if="reportContent">
|
||||
<!-- 汇总数据 -->
|
||||
<template v-if="reportContentType === 'summary'">
|
||||
<a-card v-if="reportContent.totals" title="流量汇总" :bordered="false" class="summary-card">
|
||||
<!-- SNMP 多设备汇总 -->
|
||||
<template v-if="reportContent.traffic_mode === 'snmp_devices'">
|
||||
<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-item
|
||||
v-for="(value, key) in reportContent.totals"
|
||||
:key="key"
|
||||
v-for="(value, key) in trafficSummaryKv"
|
||||
:key="String(key)"
|
||||
:label="formatLabel(String(key))"
|
||||
>
|
||||
{{ formatValue(String(key), value) }}
|
||||
@@ -250,17 +314,18 @@
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
<a-table
|
||||
:data="reportContent.by_node || []"
|
||||
v-if="trafficSummaryTableRows.length"
|
||||
:data="trafficSummaryTableRows"
|
||||
:columns="contentTableColumns"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
stripe
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 明细数据 -->
|
||||
<!-- 明细:引擎字段为 rows;兼容 items -->
|
||||
<template v-else-if="reportContentType === 'detail'">
|
||||
<a-table
|
||||
:data="reportContent.items || []"
|
||||
:data="trafficDetailRows"
|
||||
:columns="contentTableColumns"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
stripe
|
||||
@@ -272,10 +337,10 @@
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</template>
|
||||
|
||||
<!-- Top 排名数据 -->
|
||||
<!-- Top:引擎字段为 data([]map);兼容 ranking -->
|
||||
<template v-else-if="reportContentType === 'top'">
|
||||
<a-table
|
||||
:data="reportContent.ranking || []"
|
||||
:data="trafficTopRows"
|
||||
:columns="contentTableColumns"
|
||||
:pagination="false"
|
||||
stripe
|
||||
@@ -288,7 +353,7 @@
|
||||
</template>
|
||||
|
||||
<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 SearchTable from '@/components/search-table/index.vue'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
@@ -302,6 +367,38 @@ import {
|
||||
type TrafficReportParams,
|
||||
} from '@/api/ops/report'
|
||||
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 = '流量统计报表'
|
||||
@@ -402,6 +499,8 @@ const tableColumns = computed(() => [
|
||||
// 生成报表弹窗
|
||||
const generateModalVisible = ref(false)
|
||||
const generateForm = ref<{
|
||||
traffic_mode: 'topology' | 'snmp_devices'
|
||||
service_identities: string[]
|
||||
topology_id: number | undefined
|
||||
link_id: number | undefined
|
||||
node_id: string
|
||||
@@ -414,6 +513,8 @@ const generateForm = ref<{
|
||||
detail_limit: number | undefined
|
||||
trend_granularity: string
|
||||
}>({
|
||||
traffic_mode: 'topology',
|
||||
service_identities: [],
|
||||
topology_id: undefined,
|
||||
link_id: undefined,
|
||||
node_id: '',
|
||||
@@ -427,6 +528,15 @@ const generateForm = ref<{
|
||||
trend_granularity: 'hour',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => generateForm.value.traffic_mode,
|
||||
(mode) => {
|
||||
if (mode === 'snmp_devices') {
|
||||
void loadSnmpDeviceIdentities('dc-network')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 查看内容弹窗
|
||||
const contentModalVisible = ref(false)
|
||||
const contentModalTitle = ref('')
|
||||
@@ -435,14 +545,64 @@ const reportContentType = ref<string>('')
|
||||
const chartRef = ref<HTMLElement | 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(() => {
|
||||
if (!reportContent.value) return []
|
||||
|
||||
const data =
|
||||
reportContent.value.rows ||
|
||||
reportContent.value.by_node ||
|
||||
reportContent.value.items ||
|
||||
reportContent.value.ranking ||
|
||||
(Array.isArray(reportContent.value.data) ? reportContent.value.data : []) ||
|
||||
trafficSummaryTableRows.value ||
|
||||
[]
|
||||
|
||||
if (data.length === 0) return []
|
||||
@@ -482,7 +642,7 @@ const fetchList = async () => {
|
||||
const res = await fetchReportList(params)
|
||||
|
||||
if (res.code === 0 && res.details) {
|
||||
tableData.value = res.details.data || []
|
||||
tableData.value = normalizeReportRows(res.details.data || [])
|
||||
pagination.total = res.details.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取报表列表失败')
|
||||
@@ -530,6 +690,8 @@ const handleRefresh = () => {
|
||||
// 打开生成报表弹窗
|
||||
const handleOpenGenerateModal = () => {
|
||||
generateForm.value = {
|
||||
traffic_mode: 'topology',
|
||||
service_identities: [],
|
||||
topology_id: undefined,
|
||||
link_id: undefined,
|
||||
node_id: '',
|
||||
@@ -542,6 +704,9 @@ const handleOpenGenerateModal = () => {
|
||||
detail_limit: undefined,
|
||||
trend_granularity: 'hour',
|
||||
}
|
||||
linkOptions.value = []
|
||||
nodeOptions.value = []
|
||||
void loadTopologyOptions()
|
||||
generateModalVisible.value = true
|
||||
}
|
||||
|
||||
@@ -552,26 +717,40 @@ const handleCloseGenerateModal = () => {
|
||||
|
||||
// 生成报表
|
||||
const handleGenerate = async () => {
|
||||
// 验证必填项
|
||||
if (!generateForm.value.topology_id) {
|
||||
Message.warning('请输入拓扑 ID')
|
||||
return
|
||||
}
|
||||
|
||||
if (!generateForm.value.timeRange || generateForm.value.timeRange.length !== 2) {
|
||||
Message.warning('请选择时间范围')
|
||||
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
|
||||
|
||||
try {
|
||||
const params: TrafficReportParams = {
|
||||
topology_id: generateForm.value.topology_id,
|
||||
traffic_mode: mode,
|
||||
start_time: generateForm.value.timeRange[0],
|
||||
end_time: generateForm.value.timeRange[1],
|
||||
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) {
|
||||
params.link_id = generateForm.value.link_id
|
||||
@@ -646,12 +825,21 @@ const handleViewContent = async (record?: ReportRecord) => {
|
||||
|
||||
if (res.code === 0 && 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'
|
||||
}
|
||||
|
||||
// 如果是趋势报表,渲染图表
|
||||
if (reportContentType.value === 'trend' && res.details.series) {
|
||||
await nextTick()
|
||||
renderChart(res.details.series)
|
||||
await nextTick()
|
||||
if (reportContentType.value === 'trend') {
|
||||
const d = res.details?.data
|
||||
if (Array.isArray(d) && d.length) {
|
||||
renderTrafficTrendChart(d)
|
||||
} else if (Array.isArray(res.details?.series)) {
|
||||
renderChart(res.details.series)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Message.error(res.message || '获取报表内容失败')
|
||||
@@ -761,7 +949,39 @@ const formatValue = (key: string, value: any) => {
|
||||
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[]) => {
|
||||
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