This commit is contained in:
zxr
2026-05-02 17:08:10 +08:00
parent ea3e60c17c
commit 27e1f335a6
20 changed files with 1401 additions and 1050 deletions

View File

@@ -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:拓扑/NetFlowsnmp_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 → 下载用 Blobxlsx 校验 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 中间态,
* 避免网络面板里已是合法 xlsxPK…但落盘文件损坏的情况。
*/
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'))
}
// ============ 监测指标类接口(旧版兼容) ============

View File

@@ -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 = {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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地址管理',

View File

@@ -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

View File

@@ -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

View File

@@ -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: '总出流量',

View File

@@ -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_engineincident 合并 -->
<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
}

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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],

View File

@@ -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>

View File

@@ -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 summarytotals 或单对象 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

View 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'
}
}

View 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,
}
}

View File

@@ -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,
}
}

View 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,
}
}

View 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,
}
}

View 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,
}
}