Files
fe.ems.vue3/src/views/traceManage/task/analyze.vue
2025-07-02 15:27:45 +08:00

727 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref, toRaw } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { PageContainer } from 'antdv-pro-layout';
import { message, Modal } from 'ant-design-vue';
import { SizeType } from 'ant-design-vue/es/config-provider';
import { ColumnsType } from 'ant-design-vue/es/table';
import DissectionTree from '../tshark/components/DissectionTree.vue';
import DissectionDump from '../tshark/components/DissectionDump.vue';
import TaskInfoIcon from './components/TaskInfoIcon.vue';
import {
RESULT_CODE_ERROR,
RESULT_CODE_SUCCESS,
} from '@/constants/result-constants';
import { filePullTask, getTraceData, listTraceData } from '@/api/trace/task';
import { scriptUrl as wkUrl } from '@/assets/js/wiregasm_worker';
import * as wkUtil from '@/plugins/wk-worker';
import * as wsUtil from '@/plugins/ws-websocket';
import useI18n from '@/hooks/useI18n';
import useDictStore from '@/store/modules/dict';
import useTabsStore from '@/store/modules/tabs';
import saveAs from 'file-saver';
import { parseDateToStr } from '@/utils/date-utils';
const { getDict } = useDictStore();
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const tabsStore = useTabsStore();
const ws = new wsUtil.WS();
const wk = new wkUtil.WK();
/**跟踪编号 */
const traceId = ref<string>(route.query.traceId as string);
/**任务编号 */
const id = ref<string>(route.query.id as string);
/**关闭跳转 */
function fnClose() {
const to = tabsStore.tabClose(route.path);
if (to) {
router.push(to);
} else {
router.back();
}
}
/**字典数据 */
let dict: {
/**跟踪消息类型 */
traceMsgType: DictType[];
/**跟踪消息方向 */
traceMsgDirect: DictType[];
} = reactive({
traceMsgType: [],
traceMsgDirect: [],
});
/**下载触发等待 */
let downLoading = ref<boolean>(false);
/**信息文件下载 */
function fnDownloadFile() {
if (downLoading.value) return;
const fileName = `trace_${traceId.value}.pcap`;
Modal.confirm({
title: t('common.tipTitle'),
content: t('views.logManage.neFile.downTip', { fileName }),
onOk() {
downLoading.value = true;
const hide = message.loading(t('common.loading'), 0);
filePullTask(traceId.value)
.then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
message.success({
content: t('common.msgSuccess', {
msg: t('common.downloadText'),
}),
duration: 2,
});
saveAs(res.data, `${fileName}`);
} else {
message.error({
content: t('views.logManage.neFile.downTipErr'),
duration: 2,
});
}
})
.finally(() => {
hide();
downLoading.value = false;
});
},
});
}
// =========== 表格数据 ==============
/**表格状态类型 */
type TabeStateType = {
/**加载等待 */
loading: boolean;
/**紧凑型 */
size: SizeType;
/**记录数据 */
data: object[];
/**点击选择行 */
row: Record<string, any>;
};
/**表格状态 */
let tableState: TabeStateType = reactive({
loading: false,
size: 'small',
data: [],
row: {},
});
/**表格字段列 */
let tableColumns: ColumnsType = [
{
title: t('views.traceManage.task.msgNe'),
dataIndex: 'msgNe',
align: 'left',
width: 150,
},
{
title: t('views.traceManage.task.rowTime'),
dataIndex: 'timestamp',
align: 'left',
width: 250,
customRender(opt) {
if (!opt.value) return '';
const nanoseconds = opt.value; // 纳秒时间戳
const milliseconds = Math.floor(nanoseconds / 1000000);
const nanosecondsPart = (nanoseconds % 1000000)
.toString()
.padStart(6, '0');
return `${parseDateToStr(
milliseconds,
'YYYY-MM-DD HH:mm:ss.SSS'
)}.${nanosecondsPart}`;
},
sorter: true,
},
{
title: t('views.traceManage.task.protocolOrInterface'),
dataIndex: 'ifType',
align: 'left',
width: 150,
},
{
title: t('views.traceManage.task.msgEvent'),
dataIndex: 'msgEvent',
align: 'left',
ellipsis: true,
},
{
title: t('views.traceManage.task.msgType'),
dataIndex: 'msgType',
key: 'msgType',
align: 'left',
width: 100,
},
{
title: t('views.traceManage.task.msgDirect'),
dataIndex: 'msgDirect',
key: 'msgDirect',
align: 'left',
width: 100,
},
{
title: t('views.traceManage.task.srcIp'),
dataIndex: 'srcAddr',
align: 'left',
width: 150,
},
{
title: t('views.traceManage.task.dstIp'),
dataIndex: 'dstAddr',
align: 'left',
width: 150,
},
];
/**表格分页器参数 */
let tablePagination = reactive({
/**当前页数 */
current: 1,
/**每页条数 */
pageSize: 10,
/**默认的每页条数 */
defaultPageSize: 10,
/**指定每页可以显示多少条 */
pageSizeOptions: ['10', '20', '50', '100'],
/**只有一页时是否隐藏分页器 */
hideOnSinglePage: false,
/**是否可以快速跳转至某页 */
showQuickJumper: true,
/**是否可以改变 pageSize */
showSizeChanger: true,
/**数据总数 */
total: 0,
showTotal: (total: number) => t('common.tablePaginationTotal', { total }),
onChange: (page: number, pageSize: number) => {
tablePagination.current = page;
tablePagination.pageSize = pageSize;
queryParams.pageNum = page;
queryParams.pageSize = pageSize;
fnGetList();
},
});
/**表格分页、排序、筛选变化时触发操作, 排序方式,取值为 ascend descend */
function fnTableChange(pagination: any, filters: any, sorter: any, extra: any) {
const { field, order } = sorter;
if (order) {
queryParams.sortBy = field;
queryParams.sortOrder = order.replace('end', '');
} else {
queryParams.sortOrder = 'asc';
}
fnGetList(1);
}
/**查询参数 */
let queryParams = reactive({
traceId: traceId.value,
sortBy: 'timestamp',
sortOrder: 'asc',
/**当前页数 */
pageNum: 1,
/**每页条数 */
pageSize: 10,
});
/**查询备份信息列表, pageNum初始页数 */
function fnGetList(pageNum?: number) {
if (tableState.loading) return;
tableState.loading = true;
if (pageNum) {
queryParams.pageNum = pageNum;
}
listTraceData(toRaw(queryParams)).then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
const { total, rows } = res.data;
tablePagination.total = total;
tableState.data = rows;
if (
tablePagination.total <=
(queryParams.pageNum - 1) * tablePagination.pageSize &&
queryParams.pageNum !== 1
) {
tableState.loading = false;
fnGetList(queryParams.pageNum - 1);
}
}
tableState.loading = false;
});
}
/**
* 查看帧数据
* @param row 记录信息
*/
function fnVisible(row: Record<string, any>) {
// 选中行重复点击时显示隐藏
if (row.id === tableState.row.id && state.selectedFrame !== 0) {
state.selectedFrame = 0;
return;
}
getTraceData(row.id).then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
Object.assign(tableState.row, res.data);
const blob = generatePCAP(res.data.timestamp / 1e3, res.data.rawMsg);
wk.send({ type: 'process', file: blob });
}
});
}
/**生成PCAP-blob */
function generatePCAP(timestamp: number, base64Data: string): Blob {
// 1. 转换数据
const binaryString = atob(base64Data);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 2. 创建PCAP文件头
const fileHeader = new Uint8Array([
0xd4,
0xc3,
0xb2,
0xa1, // magic_number (微秒级)
0x02,
0x00,
0x04,
0x00, // version_major(2) + version_minor(4)
0x00,
0x00,
0x00,
0x00, // thiszone (UTC)
0x00,
0x00,
0x00,
0x00, // sigfigs (固定0)
0x00,
0x00,
0x04,
0x00, // snaplen (1024)
0x71,
0x00,
0x00,
0x00, // network (LINKTYPE_LINUX_SLL)
]);
// 3. 构造Linux cooked头
const cookedHeader = new Uint8Array(16);
const view = new DataView(cookedHeader.buffer);
view.setUint16(0, 0x0000, false); // 数据包类型(主机→网络)
view.setUint16(2, 0x0304, false); // 地址类型ARPHRD_ETHER
view.setUint16(4, 0x0008, false); // 协议类型ETH_P_IP
view.setUint16(14, 0x0800, false); // 数据包类型PACKET_HOST
// 4. 合并链路层头与数据
const fullData = new Uint8Array(cookedHeader.length + bytes.length);
fullData.set(cookedHeader);
fullData.set(bytes, cookedHeader.length);
// 5. 生成时间戳
const date = new Date(timestamp);
const secs = Math.floor(date.getTime() / 1000);
const usecs = (date.getTime() % 1000) * 1000;
// 6. 构造PCAP报文头
const packetHeader = new Uint8Array(16);
const headerView = new DataView(packetHeader.buffer);
headerView.setUint32(0, secs, true); // 时间戳秒
headerView.setUint32(4, usecs, true); // 时间戳微秒
headerView.setUint32(8, fullData.length, true); // 捕获长度
headerView.setUint32(12, fullData.length, true); // 原始长度
// 7. 合并所有数据
const finalData = new Uint8Array(
fileHeader.length + packetHeader.length + fullData.length
);
finalData.set(fileHeader);
finalData.set(packetHeader, fileHeader.length);
finalData.set(fullData, fileHeader.length + packetHeader.length);
// 8. 文件Blob对象
return new Blob([finalData], { type: 'application/octet-stream' });
}
// =========== WS ==============
/**接收数据后回调 */
function wsMessage(res: Record<string, any>) {
const { code, requestId, data } = res;
if (code === RESULT_CODE_ERROR) {
console.warn(res.msg);
return;
}
// 建联时发送请求
if (!requestId && data.clientId) {
return;
}
// 订阅组信息
if (!data?.groupId) {
return;
}
if (data.groupId === `2_${traceId.value}`) {
// 第一页降序时实时添加记录
if (queryParams.pageNum === 1 && queryParams.sortOrder === 'desc') {
tableState.data.unshift(data);
}
tablePagination.total += 1;
}
}
/**建立WS连接 */
function fnWS() {
const options: wsUtil.OptionsType = {
url: '/ws',
params: {
/**订阅通道组
*
* 跟踪任务PCAP文件 (GroupID:2_traceId)
*/
subGroupID: `2_${traceId.value}`,
},
onmessage: wsMessage,
onerror: (ev: any) => {
// 接收数据后回调
console.error(ev);
},
};
//建立连接
ws.connect(options);
}
// =========== WK ==============
const NO_SELECTION = { id: '', idx: 0, start: 0, length: 0 };
type StateType = {
/**初始化 */
initialized: boolean;
/**当前选中的帧编号 */
selectedFrame: number;
/**当前选中的帧数据 */
packetFrame: { tree: any[]; data_sources: any[] };
/**pcap包帧数据 */
packetFrameTreeMap: Map<string, any> | null;
/**当前选中的帧数据 */
selectedTree: typeof NO_SELECTION;
/**选择帧的Dump数据标签 */
selectedDataSourceIndex: number;
};
const state = reactive<StateType>({
initialized: false,
selectedFrame: 0,
/**当前选中的帧数据 */
packetFrame: { tree: [], data_sources: [] },
packetFrameTreeMap: null, // 注意Map 需要额外处理
selectedTree: NO_SELECTION, // NO_SELECTION 需要定义
/**选择帧的Dump数据标签 */
selectedDataSourceIndex: 0,
});
/**解析帧数据为简单结构 */
function parseFrameTree(id: string, node: Record<string, any>) {
let map = new Map();
if (node.tree && node.tree.length > 0) {
for (let i = 0; i < node.tree.length; i++) {
const subMap = parseFrameTree(`${id}-${i}`, node.tree[i]);
subMap.forEach((value, key) => {
map.set(key, value);
});
}
} else if (node.length > 0) {
map.set(id, {
id: id,
idx: node.data_source_idx,
start: node.start,
length: node.length,
});
}
return map;
}
/**报文数据点击选中 */
function handleSelectedFindSelection(src_idx: number, pos: number) {
// console.log('fnSelectedFindSelection', pos);
if (state.packetFrameTreeMap == null) return;
// find the smallest one
let current = null;
for (let [k, pp] of state.packetFrameTreeMap) {
if (pp.idx !== src_idx) continue;
if (pos >= pp.start && pos <= pp.start + pp.length) {
if (
current != null &&
state.packetFrameTreeMap.get(current).length > pp.length
) {
current = k;
} else {
current = k;
}
}
}
if (current != null) {
state.selectedTree = state.packetFrameTreeMap.get(current);
}
}
/**帧数据点击选中 */
function handleSelectedTree(e: any) {
state.selectedTree = e;
}
/**接收数据后回调 */
function wkMessage(res: Record<string, any>) {
// console.log('wkMessage', res);
switch (res.type) {
case 'status':
console.info(res.status);
break;
case 'error':
console.warn(res.error);
break;
case 'init':
wk.send({ type: 'columns' });
state.initialized = true;
fnGetList();
break;
case 'frames':
const { frames } = res.data;
// 有匹配的选择第一个
if (frames.length > 0) {
state.selectedFrame = frames[0].number;
wk.send({ type: 'select', number: state.selectedFrame });
}
break;
case 'selected':
// 去掉两层
res.data.tree.shift();
res.data.tree.shift();
state.packetFrame = res.data;
state.packetFrameTreeMap = parseFrameTree('root', res.data);
state.selectedTree = NO_SELECTION;
state.selectedDataSourceIndex = 0;
break;
case 'processed':
// setStatus(`Error: non-zero return code (${e.data.code})`);
// 加载数据
wk.send({
type: 'frames',
filter: '',
skip: 0,
limit: 1,
});
break;
default:
console.warn(res);
break;
}
}
/**建立WK连接 */
function fnWK() {
const options: wkUtil.OptionsType = {
url: wkUrl,
onmessage: wkMessage,
onerror: (ev: any) => {
console.error(ev);
},
};
//建立连接
wk.connect(options);
}
onMounted(() => {
// 初始字典数据
Promise.allSettled([getDict('trace_msg_type'), getDict('trace_msg_direct')])
.then(resArr => {
if (resArr[0].status === 'fulfilled') {
dict.traceMsgType = resArr[0].value;
}
if (resArr[1].status === 'fulfilled') {
dict.traceMsgDirect = resArr[1].value;
}
})
.finally(() => {
fnWK();
fnWS();
});
});
onBeforeUnmount(() => {
wk.send({ type: 'close' }) && wk.close();
if (ws.state() <= WebSocket.OPEN) ws.close();
});
</script>
<template>
<PageContainer>
<a-card
:bordered="false"
:loading="!state.initialized"
:body-style="{ padding: '12px' }"
>
<!-- 插槽-卡片左侧侧 -->
<template #title>
<a-space :size="8" align="center">
<a-button type="default" @click.prevent="fnClose()">
<template #icon><CloseOutlined /></template>
{{ t('common.close') }}
</a-button>
<a-button
type="primary"
:loading="downLoading"
@click.prevent="fnDownloadFile()"
>
<template #icon><DownloadOutlined /></template>
{{ t('common.downloadText') }}
</a-button>
<span>
{{ t('views.traceManage.task.traceId') }}:&nbsp;
<strong>{{ traceId }}</strong>
</span>
<!-- 任务信息 -->
<TaskInfoIcon :id="id" />
</a-space>
</template>
<!-- 插槽-卡片右侧 -->
<template #extra>
<a-tooltip>
<template #title>{{ t('common.reloadText') }}</template>
<a-button type="text" @click.prevent="fnGetList()">
<template #icon><ReloadOutlined /></template>
</a-button>
</a-tooltip>
</template>
<!-- 包数据表 -->
<a-table
class="table"
row-key="id"
:columns="tableColumns"
:loading="tableState.loading"
:data-source="tableState.data"
:size="tableState.size"
:pagination="tablePagination"
:row-class-name="(record:any) => {
if (record.id === tableState.row.id) {
return 'table-striped-select';
}
return record.msgDirect === 0 ? 'table-striped-recv' : 'table-striped-send';
}"
:customRow="
(record:any) => {
return {
onClick: () => fnVisible(record),
};
}
"
@change="fnTableChange"
:scroll="{ x: tableColumns.length * 150, y: 'calc(100vh - 300px)' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'msgType'">
<DictTag :options="dict.traceMsgType" :value="record.msgType" />
</template>
<template v-if="column.key === 'msgDirect'">
<DictTag :options="dict.traceMsgDirect" :value="record.msgDirect" />
</template>
</template>
</a-table>
<!-- 帧数据 -->
<a-row
:gutter="16"
style="border: 2px rgb(217 217 217) solid; border-radius: 6px"
v-show="state.selectedFrame == 1"
>
<a-col :lg="12" :md="12" :xs="24" class="tree">
<!-- 帧数据 -->
<DissectionTree
id="root"
:select="handleSelectedTree"
:selected="state.selectedTree"
:tree="state.packetFrame.tree"
/>
</a-col>
<a-col :lg="12" :md="12" :xs="24" class="dump">
<!-- 报文数据 -->
<a-tabs
v-model:activeKey="state.selectedDataSourceIndex"
:tab-bar-gutter="16"
:tab-bar-style="{ marginBottom: '8px' }"
>
<a-tab-pane
:key="idx"
:tab="v.name"
v-for="(v, idx) in state.packetFrame.data_sources"
style="overflow: auto"
>
<DissectionDump
:base64="v.data"
:select="(pos:number)=>handleSelectedFindSelection(idx, pos)"
:selected="
idx === state.selectedTree.idx
? state.selectedTree
: NO_SELECTION
"
/>
</a-tab-pane>
</a-tabs>
</a-col>
</a-row>
</a-card>
</PageContainer>
</template>
<style scoped>
.table :deep(.ant-pagination) {
padding: 0 24px;
}
.table :deep(.table-striped-select) td {
background-color: #c2c2c2;
cursor: pointer;
}
.table :deep(.table-striped-recv) td {
background-color: #a9e2ff;
cursor: pointer;
}
.table :deep(.table-striped-send) td {
background-color: #bcfbb3;
cursor: pointer;
}
.tree {
font-size: 0.8125rem;
line-height: 1.5rem;
padding-bottom: 0.75rem;
padding-top: 0.75rem;
white-space: nowrap;
overflow-y: auto;
user-select: none;
height: 100%;
}
.tree > ul.tree {
min-height: 15rem;
}
.dump {
padding-bottom: 0.75rem;
padding-top: 0.75rem;
overflow-y: auto;
height: 100%;
}
.dump .ant-tabs-tabpane {
min-height: calc(15rem - 56px);
}
</style>