Files
fe.ems.vue3/src/views/traceManage/wireshark/index.vue
2025-05-15 16:30:24 +08:00

792 lines
20 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 { PageContainer } from 'antdv-pro-layout';
import { message, Modal } from 'ant-design-vue/es';
import DissectionTree from '../tshark/components/DissectionTree.vue';
import DissectionDump from '../tshark/components/DissectionDump.vue';
import {
RESULT_CODE_ERROR,
RESULT_CODE_SUCCESS,
} from '@/constants/result-constants';
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 saveAs from 'file-saver';
import {
packetDevices,
packetStart,
packetStop,
packetFilter,
packetKeep,
packetPCAPFile,
} from '@/api/trace/packet';
import { parseDateToStr } from '@/utils/date-utils';
import { ColumnsType } from 'ant-design-vue/es/table';
const ws = new wsUtil.WS();
const wk = new wkUtil.WK();
const { t } = useI18n();
// =========== 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;
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':
// 首行修改帧编号
const fristFrame = res.data.tree[0];
res.data.tree[0].label = fristFrame.label.replace(
'Frame 1:',
`Frame ${tableState.selectedNumber}:`
);
const item = fristFrame.tree.find(
(item: any) => item.label === 'Frame Number: 1'
);
if (item) {
item.label = `Frame Number: ${tableState.selectedNumber}:`;
}
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);
}
// =========== 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) {
tableState.data = [];
tableState.total = 0;
taskState.keepTimer = setInterval(() => {
if (ws.state() != WebSocket.OPEN) {
clearInterval(taskState.keepTimer);
return;
}
packetKeep(taskState.task.taskNo, 120);
}, 90 * 1000);
return;
}
// 订阅组信息
if (!data?.groupId) {
return;
}
if (data.groupId === `4_${taskState.task.taskNo}`) {
const packetData = data.data;
tableState.total = packetData.number;
tableState.data.unshift(packetData);
if (tableState.data.length > 100) {
tableState.data.pop();
}
}
}
/**建立WS连接 */
function fnWS() {
const options: wsUtil.OptionsType = {
url: '/ws',
params: {
/**订阅通道组
*
* 信令跟踪Packet (GroupID:4_taskNo)
*/
subGroupID: `4_${taskState.task.taskNo}`,
},
heartTimer: 30 * 1000,
onmessage: wsMessage,
onerror: (ev: any) => {
// 接收数据后回调
console.error(ev);
},
};
//建立连接
ws.connect(options);
}
// =========== 任务 ==============
type TaskStateType = {
/**网卡设备列表 */
devices: { id: string; label: string; children: any[] }[];
/**任务 */
task: {
taskNo: string;
device: string;
filter: string;
outputPCAP: boolean;
};
/**过滤条件 */
filter: string;
/**过滤条件错误信息 */
filterError: string | null;
/**保活调度器 */
keepTimer: any;
/**停止标记 */
stop: boolean;
stopOutputPCAP: boolean;
};
const taskState = reactive<TaskStateType>({
devices: [],
task: {
taskNo: '',
device: '192.168.5.58',
filter: '',
outputPCAP: false,
},
filter: '',
filterError: null,
keepTimer: null,
stop: false,
stopOutputPCAP: false,
});
/**开始跟踪 */
function fnStart() {
if (taskState.keepTimer) return;
taskState.keepTimer = true;
const hide = message.loading(t('common.loading'), 0);
// taskState.task.taskNo = 'laYlTbq';
taskState.task.taskNo = Number(Date.now()).toString(16);
taskState.task.filter = taskState.filter;
packetStart(toRaw(taskState.task))
.then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
// 清空选择帧的数据
state.selectedFrame = 0;
state.packetFrame = { tree: [], data_sources: [] };
state.packetFrameTreeMap = null;
state.selectedTree = NO_SELECTION;
state.selectedDataSourceIndex = 0;
// 开启
if (!state.initialized) {
fnWK();
}
clearInterval(taskState.keepTimer);
taskState.keepTimer = null;
fnWS();
// 记录状态
taskState.stop = false;
taskState.stopOutputPCAP = taskState.task.outputPCAP;
} else {
message.error(t('common.operateErr'), 3);
}
})
.finally(() => {
hide();
});
}
/**停止跟踪 */
function fnStop() {
if (typeof taskState.keepTimer !== 'number') return;
const hide = message.loading(t('common.loading'), 0);
packetStop(taskState.task.taskNo)
.then(res => {
if (res.code !== RESULT_CODE_SUCCESS) {
message.warning(res.msg, 3);
}
// 关闭监听
ws.close();
clearInterval(taskState.keepTimer);
taskState.keepTimer = null;
taskState.filter = '';
taskState.filterError = null;
taskState.stop = true;
})
.finally(() => {
hide();
});
}
/**跟踪数据表过滤 */
function handleFilterFrames() {
packetFilter(taskState.task.taskNo, taskState.filter).then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
taskState.task.filter = taskState.filter;
taskState.filterError = null;
} else {
taskState.filterError = res.msg;
}
});
}
/**选择网卡 */
function fnDevice(v: string) {
taskState.task.device = v;
}
/**下载触发等待 */
let downLoading = ref<boolean>(false);
/**信息文件下载 */
function fnDownloadPCAP() {
if (downLoading.value) return;
const fileName = `packet_${taskState.task.taskNo}.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);
packetPCAPFile(taskState.task.taskNo)
.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;
/**记录数据 */
data: Record<string, any>[];
/**总记录数 */
total: number;
/**选择帧编号 */
selectedNumber: number;
};
/**表格状态 */
let tableState: TabeStateType = reactive({
loading: false,
data: [],
total: 0,
selectedNumber: 0,
});
/**表格字段列 */
let tableColumns = ref<ColumnsType>([
{
title: 'Number',
dataIndex: 'number',
align: 'left',
width: 100,
},
{
title: 'Time',
dataIndex: 'time',
align: 'left',
width: 150,
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,
'HH:mm:ss.SSS'
)}.${nanosecondsPart}`;
},
},
{
title: 'Source',
dataIndex: 'source',
align: 'left',
width: 150,
ellipsis: true,
},
{
title: 'Destination',
dataIndex: 'destination',
align: 'left',
width: 150,
ellipsis: true,
},
{
title: 'Protocol',
dataIndex: 'protocol',
key: 'protocol',
align: 'left',
width: 100,
},
{
title: 'length',
dataIndex: 'length',
align: 'right',
width: 100,
},
{
title: 'Info',
dataIndex: 'info',
align: 'left',
ellipsis: true,
},
]);
/**
* 查看帧数据
* @param row 记录信息
*/
function fnVisible(row: Record<string, any>) {
// 选中行重复点击时显示隐藏
if (row.id === tableState.selectedNumber && state.selectedFrame !== 0) {
state.selectedFrame = 0;
return;
}
tableState.selectedNumber = row.number;
const blob = generatePCAP(row.time / 1e3, row.data);
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, // version_major (2)
0x04,
0x00, // version_minor (4)
0x00,
0x00,
0x00,
0x00, // thiszone (UTC)
0x00,
0x00,
0x00,
0x00, // sigfigs (固定0)
0xff,
0xff,
0x00,
0x00, // snaplen (最大捕获长度)
0x01,
0x00,
0x00,
0x00, // network (LINKTYPE_ETHERNET)
]);
// 3. 生成时间戳
const date = new Date(timestamp);
const secs = Math.floor(date.getTime() / 1000);
const usecs = (date.getTime() % 1000) * 1000;
// 4. 构造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, bytes.length, true); // 捕获长度
headerView.setUint32(12, bytes.length, true); // 原始长度
// 5. 合并所有数据
const finalData = new Uint8Array(
fileHeader.length + packetHeader.length + bytes.length
);
finalData.set(fileHeader);
finalData.set(packetHeader, fileHeader.length);
finalData.set(bytes, fileHeader.length + packetHeader.length);
// 6. 文件Blob对象
return new Blob([finalData], { type: 'application/octet-stream' });
}
onMounted(() => {
packetDevices().then(res => {
if (res.code === RESULT_CODE_SUCCESS) {
taskState.devices = res.data;
if (res.data.length === 0) return;
taskState.task.device = res.data[0].id;
}
});
fnWK();
});
onBeforeUnmount(() => {
clearInterval(taskState.keepTimer);
wk.send({ type: 'close' }) && wk.close();
if (ws.state() <= WebSocket.OPEN) ws.close();
});
</script>
<template>
<PageContainer>
<a-card :bordered="false" :body-style="{ padding: '12px' }">
<!-- 插槽-卡片左侧侧 -->
<template #title>
<a-space :size="8" align="end">
<a-dropdown-button
type="primary"
@click="fnStart"
:disabled="!taskState.stop && taskState.task.taskNo !== ''"
>
<PlayCircleOutlined />
Start Trace
<template #overlay>
<a-menu
@click="({ key }:any) => fnDevice(key)"
:selectedKeys="[taskState.task.device]"
>
<a-menu-item v-for="v in taskState.devices" :key="v.id">
<a-popover placement="rightTop" trigger="hover">
<template #content>
<div v-for="c in v.children">{{ c.id }}</div>
</template>
<template #title>
<span>IP Address</span>
</template>
<div>{{ v.label }}</div>
</a-popover>
</a-menu-item>
</a-menu>
</template>
<template #icon><DownOutlined /></template>
</a-dropdown-button>
<a-button
danger
@click.prevent="fnStop()"
:disabled="taskState.stop || taskState.task.taskNo === ''"
>
<template #icon><CloseCircleOutlined /></template>
Stop Trace
</a-button>
<a-checkbox
v-model:checked="taskState.task.outputPCAP"
:disabled="!taskState.stop && taskState.task.taskNo !== ''"
>
Output PCAP
</a-checkbox>
<a-tag color="processing" v-if="taskState.task.filter !== ''">
{{ taskState.task.filter }}
</a-tag>
</a-space>
</template>
<!-- 插槽-卡片右侧 -->
<template #extra>
<a-space :size="8" align="end">
<a-button
type="primary"
:loading="downLoading"
@click.prevent="fnDownloadPCAP()"
v-if="taskState.stop && taskState.stopOutputPCAP"
>
<template #icon><DownloadOutlined /></template>
{{ t('common.downloadText') }}
</a-button>
<span>
Packets:
<strong>{{ tableState.total }}</strong>
</span>
<span>
Task No:
<strong>{{ taskState.task.taskNo }}</strong>
</span>
</a-space>
</template>
<!-- 包数据表过滤 -->
<a-input-group compact>
<a-auto-complete
v-model:value="taskState.filter"
:options="[
{ value: 'tcp and port 33030 and greater 100' },
{
value:
'(src 192.168.5.58 and dst port 33030) or (src 192.168.9.59 and dst port 33030)',
},
{ value: 'src host 192.168.5.58 and dst host 192.168.5.58' },
{ value: 'host 192.168.5.58 and greater 100 and less 2500' },
]"
style="width: calc(100% - 100px)"
:allow-clear="true"
>
<a-input
placeholder="BPF Basic Filter, example: tcp"
@pressEnter="handleFilterFrames"
>
<template #prefix>
<FilterOutlined />
</template>
</a-input>
</a-auto-complete>
<a-button
type="primary"
html-type="submit"
style="width: 100px"
@click="handleFilterFrames"
:disabled="taskState.task.taskNo === '' || taskState.stop"
>
Filter
</a-button>
</a-input-group>
<a-alert
:message="taskState.filterError"
type="error"
v-if="taskState.filterError != null"
/>
<!-- 包数据表 -->
<a-table
class="table"
row-key="number"
:columns="tableColumns"
:loading="tableState.loading"
:data-source="tableState.data"
size="small"
:pagination="false"
:row-class-name="(record:any) => {
return `table-striped-${record.protocol}`
}"
:customRow="
(record:any) => {
return {
onClick: () => fnVisible(record),
};
}
"
:scroll="{ x: tableColumns.length * 150, y: '400px' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'protocol'">
<!-- <DictTag :options="dict.traceMsgType" :value="record.msgType" /> -->
</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>
.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>