642 lines
18 KiB
Vue
642 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { reactive, onMounted, ref, onBeforeUnmount, useTemplateRef } from 'vue';
|
||
import { Graph, GraphData, Menu, Tooltip, Util } from '@antv/g6';
|
||
import { listAMFNbStatelist } from '@/api/neData/amf';
|
||
import { parseBasePath } from '@/plugins/file-static-url';
|
||
import { edgeLineAnimateState } from '@/views/monitor/topologyBuild/hooks/registerEdge';
|
||
import { nodeImageAnimateState } from '@/views/monitor/topologyBuild/hooks/registerNode';
|
||
import useNeListStore from '@/store/modules/ne_list';
|
||
import useI18n from '@/hooks/useI18n';
|
||
import { RESULT_CODE_SUCCESS } from '@/constants/result-constants';
|
||
import { stateNeInfo } from '@/api/ne/neInfo';
|
||
import { parseDateToStr } from '@/utils/date-utils';
|
||
import { useFullscreen } from '@vueuse/core';
|
||
import { listMMENbStatelist } from '@/api/neData/mme';
|
||
const { t } = useI18n();
|
||
const neListStore = useNeListStore();
|
||
|
||
/**图DOM节点实例对象 */
|
||
const graphG6Dom = useTemplateRef('graphG6Dom');
|
||
|
||
/**图数据 */
|
||
const graphData = reactive<Record<string, any>>({
|
||
nodes: [
|
||
{
|
||
id: 'omc',
|
||
label: 'OMC',
|
||
img: parseBasePath('/svg/service_db.svg'),
|
||
},
|
||
{
|
||
id: 'amf1',
|
||
label: 'amf1',
|
||
img: parseBasePath('/svg/service.svg'),
|
||
},
|
||
{
|
||
id: 'amf2',
|
||
label: 'amf2',
|
||
img: parseBasePath('/svg/service.svg'),
|
||
},
|
||
{
|
||
id: 'base1',
|
||
label: 'base1',
|
||
img: parseBasePath('/svg/base.svg'),
|
||
},
|
||
{
|
||
id: 'base2',
|
||
label: 'base2',
|
||
img: parseBasePath('/svg/base.svg'),
|
||
},
|
||
],
|
||
edges: [
|
||
{
|
||
source: 'omc',
|
||
target: 'amf1',
|
||
},
|
||
{
|
||
source: 'omc',
|
||
target: 'amf2',
|
||
},
|
||
{
|
||
source: 'amf1',
|
||
target: 'base1',
|
||
},
|
||
{
|
||
source: 'amf2',
|
||
target: 'base1',
|
||
},
|
||
{
|
||
source: 'amf2',
|
||
target: 'base2',
|
||
},
|
||
],
|
||
});
|
||
|
||
/**图实例对象 */
|
||
const graphG6 = ref<any>(null);
|
||
|
||
/**图节点右击菜单 */
|
||
const graphNodeMenu = new Menu({
|
||
offsetX: 6,
|
||
offseY: 10,
|
||
itemTypes: ['node'],
|
||
getContent(evt) {
|
||
if (!evt) return t('views.monitor.topologyBuild.graphNotInfo');
|
||
const { id, label, nType, nInfo }: any = evt.item?.getModel();
|
||
if (['GNB', 'ENB'].includes(nType)) {
|
||
return `
|
||
<div
|
||
style="
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 140px;
|
||
"
|
||
>
|
||
<span>
|
||
${t('views.neData.baseStation.name')}:
|
||
${label ?? '--'}
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div
|
||
style="
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 140px;
|
||
"
|
||
>
|
||
<span>
|
||
${t('views.monitor.topology.name')}:
|
||
${label ?? '--'}
|
||
</span>
|
||
</div>
|
||
`;
|
||
},
|
||
});
|
||
|
||
/**图节点展示 */
|
||
const graphNodeTooltip = new Tooltip({
|
||
offsetX: 10,
|
||
offsetY: 20,
|
||
getContent(evt) {
|
||
if (!evt) return t('views.monitor.topologyBuild.graphNotInfo');
|
||
const { id, label, nType, nInfo }: any = evt.item?.getModel();
|
||
if (['GNB', 'ENB'].includes(nType)) {
|
||
return `
|
||
<div
|
||
style="
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 256px;
|
||
"
|
||
>
|
||
<div><strong>${t('views.neData.baseStation.state')}:</strong><span>
|
||
${nInfo.state === 'ON'
|
||
? t('views.neData.baseStation.online')
|
||
: t('views.neData.baseStation.offline')
|
||
}
|
||
</span></div>
|
||
<div><strong>${t('views.neData.baseStation.time')}:</strong><span>
|
||
${nInfo.state === 'ON' ? nInfo.onTime ?? '--' : nInfo.offTime ?? '--'}
|
||
</span></div>
|
||
<div>==============================</div>
|
||
<div><strong>ID:</strong><span>${nInfo.index}</span></div>
|
||
<div><strong>${t('views.neData.baseStation.address')}:</strong><span>
|
||
${nInfo.address ?? '--'}</span></div>
|
||
<div><strong>${t('views.neData.baseStation.nbName')}:</strong><span>
|
||
${nInfo.nbName ?? '--'}</span></div>
|
||
<div><strong>${t('views.neData.baseStation.nbId')}:</strong><span>
|
||
${nInfo.ranId ?? '--'}</span></div>
|
||
<div><strong>${t('views.neData.baseStation.ueNum')}:</strong><span>
|
||
${nInfo.ueNum ?? '--'}</span></div>
|
||
<div><strong>${t('views.neData.baseStation.name')}:</strong><span>
|
||
${nInfo.name ?? '--'}
|
||
</span></div>
|
||
<div><strong>${t(
|
||
'views.neData.baseStation.position'
|
||
)}:</strong><span style="word-wrap: break-word;">
|
||
${nInfo.position}
|
||
</span></div>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div
|
||
style="
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 200px;
|
||
"
|
||
>
|
||
<div><strong>${t('views.monitor.topology.state')}:</strong><span>
|
||
${nInfo.online
|
||
? t('views.monitor.topology.normalcy')
|
||
: t('views.monitor.topology.exceptions')
|
||
}
|
||
</span></div>
|
||
<div><strong>${t('views.monitor.topology.refreshTime')}:</strong><span>
|
||
${nInfo.refreshTime ?? '--'}
|
||
</span></div>
|
||
<div>========================</div>
|
||
<div><strong>ID:</strong><span>${nInfo.neId}</span></div>
|
||
<div><strong>${t('views.monitor.topology.name')}:</strong><span>
|
||
${nInfo.neName ?? '--'}
|
||
</span></div>
|
||
<div><strong>IP:</strong><span>${nInfo.neIP}</span></div>
|
||
<div><strong>${t('views.monitor.topology.version')}:</strong><span>
|
||
${nInfo.version ?? '--'}
|
||
</span></div>
|
||
<div><strong>${t('views.monitor.topology.serialNum')}:</strong><span>
|
||
${nInfo.sn ?? '--'}
|
||
</span></div>
|
||
<div><strong>${t('views.monitor.topology.expiryDate')}:</strong><span>
|
||
${nInfo.expire ?? '--'}
|
||
</span></div>
|
||
</div>
|
||
`;
|
||
},
|
||
itemTypes: ['node'],
|
||
});
|
||
|
||
/**注册自定义边或节点 */
|
||
function registerEdgeNode() {
|
||
// 边
|
||
edgeLineAnimateState();
|
||
// 节点
|
||
nodeImageAnimateState();
|
||
}
|
||
|
||
/**
|
||
* format the string
|
||
* @param {string} str The origin string
|
||
* @param {number} maxWidth max width
|
||
* @param {number} fontSize font size
|
||
* @return {string} the processed result
|
||
*/
|
||
function fittingString(str: string, maxWidth: number, fontSize: number) {
|
||
let currentWidth = 0;
|
||
let res = str;
|
||
const pattern = new RegExp('[\u4E00-\u9FA5]+'); // distinguish the Chinese charactors and letters
|
||
str.split('').forEach((letter, i) => {
|
||
if (currentWidth > maxWidth) return;
|
||
if (pattern.test(letter)) {
|
||
// Chinese charactors
|
||
currentWidth += fontSize;
|
||
} else {
|
||
// get the width of single letter according to the fontSize
|
||
currentWidth += Util.getLetterWidth(letter, fontSize);
|
||
}
|
||
if (currentWidth > maxWidth) {
|
||
res = `${str.substring(0, i)}\n${str.substring(i)}`;
|
||
}
|
||
});
|
||
return res;
|
||
}
|
||
|
||
/**图事件 */
|
||
function graphEvent(graph: Graph) {
|
||
graph.on('edge:mouseenter', evt => {
|
||
const { item } = evt;
|
||
if (!item) return;
|
||
graph.setItemState(item, 'circle-move', '#b5d6fb');
|
||
});
|
||
graph.on('edge:mouseleave', evt => {
|
||
const { item } = evt;
|
||
if (!item) return;
|
||
graph.setItemState(item, 'circle-move', false);
|
||
graph.setItemState(item, 'circle-move:#b5d6fb', false);
|
||
});
|
||
}
|
||
|
||
/**图数据渲染 */
|
||
function handleRanderGraph(container: HTMLElement | null, data: GraphData) {
|
||
if (!container) return;
|
||
const { clientHeight, clientWidth } = container;
|
||
|
||
// 注册自定义边或节点
|
||
registerEdgeNode();
|
||
|
||
const graph = new Graph({
|
||
container: container,
|
||
width: clientWidth,
|
||
height: clientHeight,
|
||
fitCenter: false,
|
||
fitView: true,
|
||
fitViewPadding: [40, 40, 40, 40],
|
||
modes: {
|
||
// default: ['drag-canvas', 'drag-node', 'zoom-canvas'],
|
||
default: [
|
||
'zoom-canvas',
|
||
'drag-canvas',
|
||
'drag-node',
|
||
{
|
||
type: 'drag-combo',
|
||
onlyChangeComboSize: true, // 不改变层级关系
|
||
},
|
||
{
|
||
type: 'collapse-expand-combo',
|
||
trigger: 'dblclick',
|
||
relayout: true, // 收缩展开后,不重新布局
|
||
},
|
||
],
|
||
},
|
||
groupByTypes: false,
|
||
plugins: [graphNodeMenu, graphNodeTooltip],
|
||
layout: {
|
||
type: 'dagre',
|
||
rankdir: 'TB', // 布局的方向,TB:从上到下,BT:从下到上,LR:从左到右,RL:从右到左
|
||
//align: 'UL', // 节点对齐方式 UL、UR、DL、DR
|
||
controlPoints: true,
|
||
nodesep: 20,
|
||
ranksep: 40,
|
||
},
|
||
animate: true,
|
||
defaultNode: {
|
||
type: 'image-animate-state',
|
||
labelCfg: {
|
||
offset: 8,
|
||
position: 'bottom',
|
||
style: { fill: '#ffffff', fontSize: 14, fontWeight: 500 },
|
||
},
|
||
size: 48,
|
||
img: parseBasePath('/svg/cloud.svg'),
|
||
width: 48,
|
||
height: 48,
|
||
},
|
||
defaultEdge: {
|
||
type: 'line-animate-state',
|
||
labelCfg: {
|
||
autoRotate: true,
|
||
refY: 10,
|
||
refX: 40,
|
||
},
|
||
style: {
|
||
stroke: '#fafafa',
|
||
lineWidth: 1.5,
|
||
},
|
||
},
|
||
defaultCombo: {
|
||
labelCfg: {
|
||
offset: 16,
|
||
position: 'bottom',
|
||
style: { fill: '#ffffff', fontSize: 14, fontWeight: 500 },
|
||
},
|
||
style: {
|
||
stroke: '#BDEFDB',
|
||
fill: '#BDEFDB',
|
||
opacity: 0.25,
|
||
},
|
||
collapsedSubstituteIcon: {
|
||
show: true,
|
||
img: parseBasePath('/svg/service.svg'),
|
||
width: 48,
|
||
height: 48,
|
||
},
|
||
},
|
||
});
|
||
graph.data(data);
|
||
graph.render();
|
||
graphEvent(graph);
|
||
|
||
// 创建 ResizeObserver 实例
|
||
const observer = new ResizeObserver(function (entries) {
|
||
// 当元素大小发生变化时触发回调函数
|
||
entries.forEach(function (entry) {
|
||
if (!graph) {
|
||
return;
|
||
}
|
||
graph.changeSize(entry.contentRect.width, entry.contentRect.height);
|
||
graph.fitCenter();
|
||
});
|
||
});
|
||
// 监听元素大小变化
|
||
observer.observe(container);
|
||
|
||
return graph;
|
||
}
|
||
|
||
/**
|
||
* 获取图组数据渲染到画布
|
||
*/
|
||
async function fnGraphDataLoad() {
|
||
const dataNe = await fnGraphDataBase();
|
||
Object.assign(graphData, dataNe);
|
||
graphG6.value = handleRanderGraph(graphG6Dom.value, dataNe);
|
||
// 添加基站
|
||
const dataNb = await fnGraphDataNb(dataNe);
|
||
Object.assign(graphData, dataNb);
|
||
// graphG6.value.clear();
|
||
graphG6.value.read(dataNb);
|
||
// 添加状态
|
||
interval.value = true;
|
||
repeatFn();
|
||
}
|
||
|
||
/**图数据网元构建 */
|
||
async function fnGraphDataBase() {
|
||
const data: GraphData = {
|
||
nodes: [],
|
||
edges: [],
|
||
};
|
||
// 添加基础网元
|
||
for (const item of neListStore.getNeCascaderOptions) {
|
||
if ('OMC' === item.value) {
|
||
if (item.children?.length === 0) continue;
|
||
// 是否存在OMC保证唯一
|
||
const hasOMC = data.nodes?.findIndex(v => v.neType === 'OMC');
|
||
if (hasOMC !== -1) continue;
|
||
// 根网元
|
||
const omcInfo = item.children[0];
|
||
const node = {
|
||
id: 'OMC',
|
||
label: omcInfo.neName,
|
||
img: parseBasePath('/svg/service_db.svg'),
|
||
nInfo: { online: false, neId: omcInfo.neId, neType: omcInfo.neType },
|
||
nType: 'OMC',
|
||
};
|
||
// 添加OMC节点
|
||
data.nodes?.push(node);
|
||
continue;
|
||
}
|
||
if (['AMF', 'MME'].includes(item.value)) {
|
||
if (item.children?.length === 0) continue;
|
||
for (const child of item.children) {
|
||
const id = `${child.neType}_${child.neId}`;
|
||
const node = {
|
||
id: id,
|
||
label: child.neName,
|
||
img: parseBasePath('/svg/service.svg'),
|
||
nInfo: { online: false, neId: child.neId, neType: child.neType },
|
||
nType: item.value,
|
||
};
|
||
// 添加节点
|
||
data.nodes?.push(node);
|
||
data.edges?.push({
|
||
source: 'OMC',
|
||
target: id,
|
||
});
|
||
}
|
||
item.children.forEach((v: any) => { });
|
||
continue;
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/**图数据基站构建 */
|
||
async function fnGraphDataNb(data: GraphData) {
|
||
const arr = data.nodes?.filter((v: any) => ['AMF', 'MME'].includes(v.nType));
|
||
if (arr === undefined || arr.length === 0) return data;
|
||
for (const item of arr) {
|
||
if (item.nType === 'AMF') {
|
||
const neId = (item.nInfo as any).neId;
|
||
const res = await listAMFNbStatelist({ neId });
|
||
if (res.code !== RESULT_CODE_SUCCESS || !Array.isArray(res.data)) {
|
||
continue;
|
||
}
|
||
for (const nb of res.data) {
|
||
const id = `${item.id}_${nb.index}`;
|
||
data.nodes?.push({
|
||
id: id,
|
||
label: fittingString(`${nb.name}`, 80, 14),
|
||
img: parseBasePath('/svg/base5G.svg'),
|
||
nInfo: nb,
|
||
nType: 'GNB',
|
||
});
|
||
data.edges?.push({
|
||
source: item.id,
|
||
target: id,
|
||
});
|
||
}
|
||
}
|
||
if (item.nType === 'MME') {
|
||
const neId = (item.nInfo as any).neId;
|
||
const res = await listMMENbStatelist({ neId });
|
||
if (res.code !== RESULT_CODE_SUCCESS || !Array.isArray(res.data)) {
|
||
continue;
|
||
}
|
||
for (const nb of res.data) {
|
||
const id = `${item.id}_${nb.index}`;
|
||
data.nodes?.push({
|
||
id: id,
|
||
label: fittingString(`${nb.name}`, 80, 14),
|
||
img: parseBasePath('/svg/base4G.svg'),
|
||
nInfo: nb,
|
||
nType: 'ENB',
|
||
});
|
||
data.edges?.push({
|
||
source: item.id,
|
||
target: id,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 图状态构建
|
||
* @param reload 是否重载状态
|
||
*/
|
||
async function fnGraphState(reload: boolean = false) {
|
||
// 节点状态
|
||
if (!Array.isArray(graphData.nodes)) return;
|
||
|
||
const onc = graphData.nodes.find((v: any) => v.nType === 'OMC');
|
||
if (onc) {
|
||
const { id, nInfo } = onc as any;
|
||
if (!nInfo) return;
|
||
const res = await stateNeInfo(nInfo.neType, nInfo.neId);
|
||
if (res.code === RESULT_CODE_SUCCESS) {
|
||
Object.assign(nInfo, res.data, {
|
||
refreshTime: parseDateToStr(res.data.refreshTime, 'HH:mm:ss'),
|
||
});
|
||
}
|
||
const stateColor = nInfo.online ? '#52c41a' : '#f5222d'; // 状态颜色
|
||
graphG6.value.setItemState(id, 'top-right-dot', stateColor);
|
||
}
|
||
|
||
graphData.nodes
|
||
.filter((v: any) => ['AMF', 'MME'].includes(v.nType))
|
||
.forEach(async (v: any) => {
|
||
const { id, nInfo } = v;
|
||
if (!nInfo) return;
|
||
const res = await stateNeInfo(nInfo.neType, nInfo.neId);
|
||
if (res.code === RESULT_CODE_SUCCESS) {
|
||
Object.assign(nInfo, res.data, {
|
||
refreshTime: parseDateToStr(res.data.refreshTime, 'HH:mm:ss'),
|
||
});
|
||
}
|
||
const stateColor = nInfo.online ? '#52c41a' : '#f5222d'; // 状态颜色
|
||
graphG6.value.setItemState(id, 'top-right-dot', stateColor);
|
||
|
||
// 重载时更新下级基站状态
|
||
if (reload && nInfo.neType === 'AMF') {
|
||
const res = await listAMFNbStatelist({ neId: nInfo.neId });
|
||
if (res.code == RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
|
||
for (const nb of res.data) {
|
||
const nbItem = graphData.nodes.find(
|
||
(v: any) => v.id === `${id}_${nb.index}`
|
||
);
|
||
if (nbItem) {
|
||
Object.assign(nbItem.nInfo, nb);
|
||
const stateColor = nb.state === 'ON' ? '#52c41a' : '#f5222d'; // 状态颜色
|
||
graphG6.value.setItemState(
|
||
nbItem.id,
|
||
'top-right-dot',
|
||
stateColor
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (reload && nInfo.neType === 'MME') {
|
||
const res = await listMMENbStatelist({ neId: nInfo.neId });
|
||
if (res.code == RESULT_CODE_SUCCESS && Array.isArray(res.data)) {
|
||
for (const nb of res.data) {
|
||
const nbItem = graphData.nodes.find(
|
||
(v: any) => v.id === `${id}_${nb.index}`
|
||
);
|
||
if (nbItem) {
|
||
Object.assign(nbItem.nInfo, nb);
|
||
const stateColor = nb.state === 'ON' ? '#52c41a' : '#f5222d'; // 状态颜色
|
||
graphG6.value.setItemState(
|
||
nbItem.id,
|
||
'top-right-dot',
|
||
stateColor
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (reload) {
|
||
await new Promise(resolve => setTimeout(resolve, 15_000));
|
||
return;
|
||
}
|
||
// 非重载时使用初始获取的状态
|
||
graphData.nodes
|
||
.filter((v: any) => ['GNB', 'ENB'].includes(v.nType))
|
||
.forEach(async (v: any) => {
|
||
const { id, nInfo } = v;
|
||
if (!nInfo) return;
|
||
const stateColor = nInfo.state === 'ON' ? '#52c41a' : '#f5222d'; // 状态颜色
|
||
graphG6.value.setItemState(id, 'top-right-dot', stateColor);
|
||
});
|
||
}
|
||
|
||
/**递归调度器 */
|
||
const interval = ref<boolean>(false);
|
||
|
||
/**递归刷新图状态 */
|
||
function repeatFn(reload: boolean = false) {
|
||
if (!interval.value || !graphG6Dom.value) {
|
||
return;
|
||
}
|
||
fnGraphState(reload)
|
||
.finally(() => {
|
||
repeatFn(true); // 递归调用自己
|
||
})
|
||
.catch(error => {
|
||
console.error(error);
|
||
});
|
||
}
|
||
|
||
const viewportDom = ref<HTMLElement | null>(null);
|
||
const { isFullscreen, toggle } = useFullscreen(viewportDom);
|
||
function fullscreen() {
|
||
toggle();
|
||
|
||
if (!graphG6Dom.value) return;
|
||
if (isFullscreen.value) {
|
||
graphG6Dom.value.style.height = 'calc(100vh - 300px)';
|
||
} else {
|
||
graphG6Dom.value.style.height = '100vh';
|
||
}
|
||
const { clientHeight, clientWidth } = graphG6Dom.value;
|
||
graphG6.value.changeSize(clientHeight, clientWidth);
|
||
graphG6.value.fitView(40);
|
||
}
|
||
|
||
onMounted(() => {
|
||
fnGraphDataLoad();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
interval.value = false;
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<a-card :bordered="false" :body-style="{ padding: '0' }" ref="viewportDom">
|
||
<!-- 插槽-卡片左侧侧 -->
|
||
<template #title>
|
||
<a-space :size="8" align="center">
|
||
{{ t('views.neData.baseStation.topologyTitle') }}
|
||
</a-space>
|
||
</template>
|
||
<!-- 插槽-卡片右侧 -->
|
||
<template #extra>
|
||
<a-button type="default" @click.prevent="fullscreen()">
|
||
<template #icon>
|
||
<FullscreenExitOutlined v-if="isFullscreen" />
|
||
<FullscreenOutlined v-else />
|
||
</template>
|
||
{{ t('loayouts.rightContent.fullscreen') }}
|
||
</a-button>
|
||
</template>
|
||
|
||
<div ref="graphG6Dom" class="chart"></div>
|
||
</a-card>
|
||
</template>
|
||
|
||
<style lang="less" scoped>
|
||
.chart {
|
||
width: 100%;
|
||
height: calc(100vh - 300px);
|
||
background-color: rgb(43, 47, 51);
|
||
}
|
||
</style>
|