refactor: redesign and implement new topology (#243)

This commit is contained in:
Fine0830 2023-03-22 17:00:24 +08:00 committed by GitHub
parent 8031c1b463
commit 449dccdf36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 571 additions and 447 deletions

22
package-lock.json generated
View File

@ -49,6 +49,7 @@
"husky": "^8.0.2", "husky": "^8.0.2",
"jsdom": "^20.0.3", "jsdom": "^20.0.3",
"lint-staged": "^12.1.3", "lint-staged": "^12.1.3",
"mockjs": "^1.1.0",
"node-sass": "^8.0.0", "node-sass": "^8.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss-html": "^1.3.0", "postcss-html": "^1.3.0",
@ -10168,6 +10169,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/mockjs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mockjs/-/mockjs-1.1.0.tgz",
"integrity": "sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==",
"dev": true,
"dependencies": {
"commander": "*"
},
"bin": {
"random": "bin/random"
}
},
"node_modules/moment": { "node_modules/moment": {
"version": "2.29.4", "version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
@ -23559,6 +23572,15 @@
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"dev": true "dev": true
}, },
"mockjs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mockjs/-/mockjs-1.1.0.tgz",
"integrity": "sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==",
"dev": true,
"requires": {
"commander": "*"
}
},
"moment": { "moment": {
"version": "2.29.4", "version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",

View File

@ -58,6 +58,7 @@
"husky": "^8.0.2", "husky": "^8.0.2",
"jsdom": "^20.0.3", "jsdom": "^20.0.3",
"lint-staged": "^12.1.3", "lint-staged": "^12.1.3",
"mockjs": "^1.1.0",
"node-sass": "^8.0.0", "node-sass": "^8.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss-html": "^1.3.0", "postcss-html": "^1.3.0",

50
src/mock/index.ts Normal file
View File

@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Mock from "mockjs";
const Random = Mock.Random;
const nodes = Mock.mock({
"nodes|500": [
{
//id
id: "@guid",
name: "@name",
"type|1": ["ActiveMQ", "activemq-consumer", "H2", "APISIX", "Express", "USER", "Flash"],
"isReal|1": [true, false],
},
],
});
const calls = Mock.mock({
"links|500": [
{
//id
id: "@guid",
detectPoints: ["SERVER", "CLIENT"],
source: function () {
const d = Random.integer(0, 250);
return nodes.nodes[d].id;
},
target: function () {
const d = Random.integer(250, 499);
return nodes.nodes[d].id;
},
},
],
});
const callsMock = calls.links;
const nodesMock = nodes.nodes;
export { callsMock, nodesMock };

17
src/types/mock.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare module "mockjs";

View File

@ -26,6 +26,10 @@ export interface Call {
lowerArc?: boolean; lowerArc?: boolean;
sourceComponents: string[]; sourceComponents: string[];
targetComponents: string[]; targetComponents: string[];
sourceX?: number;
sourceY?: number;
targetY?: number;
targetX?: number;
} }
export interface Node { export interface Node {
id: string; id: string;
@ -34,4 +38,8 @@ export interface Node {
isReal: boolean; isReal: boolean;
layer?: string; layer?: string;
serviceName?: string; serviceName?: string;
height?: number;
x?: number;
y?: number;
level?: number;
} }

View File

@ -59,7 +59,7 @@ limitations under the License. -->
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.topology { .topology {
background-color: #333840; // background-color: #333840;
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 12px; font-size: 12px;

View File

@ -20,6 +20,69 @@ limitations under the License. -->
element-loading-background="rgba(0, 0, 0, 0)" element-loading-background="rgba(0, 0, 0, 0)"
:style="`height: ${height}px`" :style="`height: ${height}px`"
> >
<svg class="svg-topology" :width="width - 100" :height="height" style="background-color: #fff" @click="svgEvent">
<g class="svg-graph" :transform="`translate(${diff[0]}, ${diff[1]})`">
<g
class="topo-node"
v-for="(n, index) in topologyLayout.nodes"
:key="index"
@mouseout="hideTip"
@mouseover="showNodeTip($event, n)"
@click="handleNodeClick($event, n)"
@mousedown="startMoveNode($event, n)"
@mouseup="stopMoveNode($event)"
>
<image width="36" height="36" :x="n.x - 15" :y="n.y - 18" :href="getNodeStatus(n)" />
<!-- <circle :cx="n.x" :cy="n.y" r="12" fill="none" stroke="red"/> -->
<image width="28" height="25" :x="n.x - 14" :y="n.y - 43" :href="icons.LOCAL" style="opacity: 0.8" />
<image
width="12"
height="12"
:x="n.x - 6"
:y="n.y - 38"
:href="!n.type || n.type === `N/A` ? icons.UNDEFINED : icons[n.type.toUpperCase().replace('-', '')]"
/>
<text :x="n.x - (n.name.length * 6) / 2 + 6" :y="n.y + n.height + 8" style="pointer-events: none">
{{ n.name.length > 20 ? `${n.name.substring(0, 20)}...` : n.name }}
</text>
</g>
<g v-for="(l, index) in topologyLayout.calls" :key="index">
<path
class="topo-line"
:d="`M${l.sourceX} ${l.sourceY} L${l.targetX} ${l.targetY}`"
stroke="#97B0F8"
marker-end="url(#arrow)"
/>
<circle
class="topo-line-anchor"
:cx="(l.sourceX + l.targetX) / 2"
:cy="(l.sourceY + l.targetY) / 2"
r="4"
fill="#97B0F8"
@click="handleLinkClick($event, l)"
@mouseover="showLinkTip($event, l)"
@mouseout="hideTip"
/>
</g>
<g class="arrows">
<defs v-for="(_, index) in topologyLayout.calls" :key="index">
<marker
id="arrow"
markerUnits="strokeWidth"
markerWidth="16"
markerHeight="16"
viewBox="0 0 12 12"
refX="10"
refY="6"
orient="auto"
>
<path d="M2,2 L10,6 L2,10 L6,6 L2,2" fill="#97B0F8" />
</marker>
</defs>
</g>
</g>
</svg>
<div id="tooltip"></div>
<div class="legend"> <div class="legend">
<div> <div>
<img :src="icons.CUBE" /> <img :src="icons.CUBE" />
@ -53,8 +116,8 @@ limitations under the License. -->
class="operations-list" class="operations-list"
v-if="topologyStore.node" v-if="topologyStore.node"
:style="{ :style="{
top: operationsPos.y + 'px', top: operationsPos.y + 5 + 'px',
left: operationsPos.x + 'px', left: operationsPos.x + 5 + 'px',
}" }"
> >
<span v-for="(item, index) of items" :key="index" @click="item.func(item.dashboard)"> <span v-for="(item, index) of items" :key="index" @click="item.func(item.dashboard)">
@ -68,11 +131,6 @@ limitations under the License. -->
import { ref, onMounted, onBeforeUnmount, reactive, watch, computed, nextTick } from "vue"; import { ref, onMounted, onBeforeUnmount, reactive, watch, computed, nextTick } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import * as d3 from "d3"; import * as d3 from "d3";
import d3tip from "d3-tip";
import zoom from "../../components/utils/zoom";
import { simulationInit, simulationSkip } from "./utils/simulation";
import nodeElement from "./utils/nodeElement";
import { linkElement, anchorElement, arrowMarker } from "./utils/linkElement";
import type { Node, Call } from "@/types/topology"; import type { Node, Call } from "@/types/topology";
import { useSelectorStore } from "@/store/modules/selectors"; import { useSelectorStore } from "@/store/modules/selectors";
import { useTopologyStore } from "@/store/modules/topology"; import { useTopologyStore } from "@/store/modules/topology";
@ -89,6 +147,8 @@ limitations under the License. -->
import { aggregation } from "@/hooks/useMetricsProcessor"; import { aggregation } from "@/hooks/useMetricsProcessor";
import icons from "@/assets/img/icons"; import icons from "@/assets/img/icons";
import { useQueryTopologyMetrics } from "@/hooks/useMetricsProcessor"; import { useQueryTopologyMetrics } from "@/hooks/useMetricsProcessor";
import { layout, circleIntersection, computeCallPos } from "./utils/layout";
import zoom from "../../components/utils/zoom";
/*global Nullable, defineProps */ /*global Nullable, defineProps */
const props = defineProps({ const props = defineProps({
@ -105,70 +165,186 @@ limitations under the License. -->
const height = ref<number>(100); const height = ref<number>(100);
const width = ref<number>(100); const width = ref<number>(100);
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
const simulation = ref<any>(null);
const svg = ref<Nullable<any>>(null); const svg = ref<Nullable<any>>(null);
const graph = ref<Nullable<any>>(null);
const chart = ref<Nullable<HTMLDivElement>>(null); const chart = ref<Nullable<HTMLDivElement>>(null);
const tip = ref<Nullable<HTMLDivElement>>(null);
const graph = ref<any>(null);
const node = ref<any>(null);
const link = ref<any>(null);
const anchor = ref<any>(null);
const arrow = ref<any>(null);
const showSetting = ref<boolean>(false); const showSetting = ref<boolean>(false);
const settings = ref<any>(props.config); const settings = ref<any>(props.config);
const operationsPos = reactive<{ x: number; y: number }>({ x: NaN, y: NaN }); const operationsPos = reactive<{ x: number; y: number }>({ x: NaN, y: NaN });
const items = ref<{ id: string; title: string; func: any; dashboard?: string }[]>([]); const items = ref<{ id: string; title: string; func: any; dashboard?: string }[]>([]);
const graphConfig = computed(() => props.config.graph || {}); const graphConfig = computed(() => props.config.graph || {});
const depth = ref<number>(graphConfig.value.depth || 2); const depth = ref<number>(graphConfig.value.depth || 2);
const topologyLayout = ref<any>({});
const tooltip = ref<Nullable<any>>(null);
const graphWidth = ref<number>(100);
const currentNode = ref<Nullable<Node>>();
const diff = computed(() => [(width.value - graphWidth.value - 130) / 2, 100]);
const radius = 8;
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
init();
});
async function init() {
const dom = document.querySelector(".topology")?.getBoundingClientRect() || { const dom = document.querySelector(".topology")?.getBoundingClientRect() || {
height: 40, height: 40,
width: 0, width: 0,
}; };
height.value = dom.height - 40; height.value = dom.height - 40;
width.value = dom.width; width.value = dom.width;
svg.value = d3.select(".svg-topology");
graph.value = d3.select(".svg-graph");
loading.value = true; loading.value = true;
const json = await selectorStore.fetchServices(dashboardStore.layerId); const json = await selectorStore.fetchServices(dashboardStore.layerId);
if (json.errors) { if (json.errors) {
ElMessage.error(json.errors); ElMessage.error(json.errors);
return; return;
} }
await freshNodes();
svg.value.call(zoom(d3, graph.value, diff.value));
}
async function freshNodes() {
topologyStore.setNode(null);
topologyStore.setLink(null);
const resp = await getTopology(); const resp = await getTopology();
loading.value = false; loading.value = false;
if (resp && resp.errors) { if (resp && resp.errors) {
ElMessage.error(resp.errors); ElMessage.error(resp.errors);
} }
await update();
}
async function update() {
topologyStore.queryNodeMetrics(settings.value.nodeMetrics || []);
topologyStore.getLinkClientMetrics(settings.value.linkClientMetrics || []); topologyStore.getLinkClientMetrics(settings.value.linkClientMetrics || []);
topologyStore.getLinkServerMetrics(settings.value.linkServerMetrics || []); topologyStore.getLinkServerMetrics(settings.value.linkServerMetrics || []);
topologyStore.queryNodeMetrics(settings.value.nodeMetrics || []);
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
svg.value = d3.select(chart.value).append("svg").attr("class", "topo-svg");
await initLegendMetrics(); await initLegendMetrics();
await init(); draw();
update(); tooltip.value = d3.select("#tooltip");
setNodeTools(settings.value.nodeDashboard); setNodeTools(settings.value.nodeDashboard);
}
function draw() {
const node = findMostFrequent(topologyStore.calls);
const levels = [];
const nodes = topologyStore.nodes.sort((a: Node, b: Node) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
return 0;
}); });
async function init() { const index = nodes.findIndex((n: Node) => n.type === "USER");
tip.value = (d3tip as any)().attr("class", "d3-tip").offset([-8, 0]); let key = index;
graph.value = svg.value.append("g").attr("class", "topo-svg-graph").attr("transform", `translate(-100, -100)`); if (index < 0) {
graph.value.call(tip.value); const idx = nodes.findIndex((n: Node) => n.id === node.id);
simulation.value = simulationInit(d3, topologyStore.nodes, topologyStore.calls, ticked); key = idx;
node.value = graph.value.append("g").selectAll(".topo-node"); }
link.value = graph.value.append("g").selectAll(".topo-line"); levels.push([nodes[key]]);
anchor.value = graph.value.append("g").selectAll(".topo-line-anchor"); nodes.splice(key, 1);
arrow.value = graph.value.append("g").selectAll(".topo-line-arrow"); for (const level of levels) {
svg.value.call(zoom(d3, graph.value, [-100, -100])); const a = [];
svg.value.on("click", (event: PointerEvent) => { for (const l of level) {
for (const n of topologyStore.calls) {
if (n.target === l.id) {
const i = nodes.findIndex((d: Node) => d.id === n.source);
if (i > -1) {
a.push(nodes[i]);
nodes.splice(i, 1);
}
}
if (n.source === l.id) {
const i = nodes.findIndex((d: Node) => d.id === n.target);
if (i > -1) {
a.push(nodes[i]);
nodes.splice(i, 1);
}
}
}
}
if (a.length) {
levels.push(a);
}
}
topologyLayout.value = layout(levels, topologyStore.calls, radius);
graphWidth.value = topologyLayout.value.layout.width;
const drag: any = d3.drag().on("drag", (d: { x: number; y: number }) => {
moveNode(d);
});
setTimeout(() => {
d3.selectAll(".topo-node").call(drag);
}, 1000);
}
function moveNode(d: { x: number; y: number }) {
if (!currentNode.value) {
return;
}
for (const node of topologyLayout.value.nodes) {
if (node.id === currentNode.value.id) {
node.x = d.x;
node.y = d.y;
}
}
for (const call of topologyLayout.value.calls) {
if (call.sourceObj.id === currentNode.value.id) {
call.sourceObj.x = d.x;
call.sourceObj.y = d.y;
}
if (call.targetObj.id === currentNode.value.id) {
call.targetObj.x = d.x;
call.targetObj.y = d.y;
}
if (call.targetObj.id === currentNode.value.id || call.sourceObj.id === currentNode.value.id) {
const pos: any = circleIntersection(
call.sourceObj.x,
call.sourceObj.y,
radius,
call.targetObj.x,
call.targetObj.y,
radius,
);
call.sourceX = pos[0].x;
call.sourceY = pos[0].y;
call.targetX = pos[1].x;
call.targetY = pos[1].y;
}
}
topologyLayout.value.calls = computeCallPos(topologyLayout.value.calls, radius);
}
function startMoveNode(event: MouseEvent, d: Node) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); currentNode.value = d;
topologyStore.setNode(null); }
topologyStore.setLink(null); function stopMoveNode(event: MouseEvent) {
dashboardStore.selectWidget(props.config); event.stopPropagation();
}); currentNode.value = null;
}
function findMostFrequent(arr: Call[]) {
let count: any = {};
let maxCount = 0;
let maxItem = null;
for (let i = 0; i < arr.length; i++) {
let item = arr[i];
count[item.sourceObj.id] = (count[item.sourceObj.id] || 0) + 1;
if (count[item.sourceObj.id] > maxCount) {
maxCount = count[item.sourceObj.id];
maxItem = item.sourceObj;
}
count[item.targetObj.id] = (count[item.targetObj.id] || 0) + 1;
if (count[item.targetObj.id] > maxCount) {
maxCount = count[item.targetObj.id];
maxItem = item.targetObj;
}
}
return maxItem;
} }
async function initLegendMetrics() { async function initLegendMetrics() {
@ -182,127 +358,48 @@ limitations under the License. -->
} }
} }
} }
function ticked() { function getNodeStatus(d: any) {
link.value.attr( const legend = settings.value.legend;
"d", if (!legend) {
(d: Call | any) => return icons.CUBE;
`M${d.source.x} ${d.source.y} Q ${(d.source.x + d.target.x) / 2} ${
(d.target.y + d.source.y) / 2 - d.loopFactor * 90
} ${d.target.x} ${d.target.y}`,
);
anchor.value.attr(
"transform",
(d: Call | any) =>
`translate(${(d.source.x + d.target.x) / 2}, ${(d.target.y + d.source.y) / 2 - d.loopFactor * 45})`,
);
node.value.attr("transform", (d: Node | any) => `translate(${d.x - 22},${d.y - 22})`);
} }
function dragstart(d: any) { if (!legend.length) {
node.value._groups[0].forEach((g: any) => { return icons.CUBE;
g.__data__.fx = g.__data__.x;
g.__data__.fy = g.__data__.y;
});
if (!d.active) {
simulation.value.alphaTarget(0.1).restart();
} }
d.subject.fx = d.subject.x; let c = true;
d.subject.fy = d.subject.y; for (const l of legend) {
d.sourceEvent.stopPropagation(); if (l.condition === "<") {
} c = c && d[l.name] < Number(l.value);
function dragged(d: any) { } else {
d.subject.fx = d.x; c = c && d[l.name] > Number(l.value);
d.subject.fy = d.y;
}
function dragended(d: any) {
if (!d.active) {
simulation.value.alphaTarget(0);
} }
} }
function handleNodeClick(event: PointerEvent, d: Node & { x: number; y: number }) { return c && d.isReal ? icons.CUBEERROR : icons.CUBE;
topologyStore.setNode(d);
topologyStore.setLink(null);
operationsPos.x = event.offsetX;
operationsPos.y = event.offsetY;
if (d.layer === String(dashboardStore.layerId)) {
return;
} }
items.value = [ function showNodeTip(event: MouseEvent, data: Node) {
{ id: "inspect", title: "Inspect", func: handleInspect },
{ id: "alerting", title: "Alerting", func: handleGoAlerting },
];
}
function handleLinkClick(event: PointerEvent, d: Call) {
if (d.source.layer !== dashboardStore.layerId || d.target.layer !== dashboardStore.layerId) {
return;
}
event.stopPropagation();
topologyStore.setNode(null);
topologyStore.setLink(d);
if (!settings.value.linkDashboard) {
return;
}
const origin = dashboardStore.entity;
const e = dashboardStore.entity === EntityType[1].value ? EntityType[0].value : dashboardStore.entity;
const { dashboard } = getDashboard({
name: settings.value.linkDashboard,
layer: dashboardStore.layerId,
entity: `${e}Relation`,
});
if (!dashboard) {
ElMessage.error(`The dashboard named ${settings.value.linkDashboard} doesn't exist`);
return;
}
dashboardStore.setEntity(dashboard.entity);
const path = `/dashboard/related/${dashboard.layer}/${e}Relation/${d.source.id}/${d.target.id}/${dashboard.name}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
dashboardStore.setEntity(origin);
}
function update() {
// node element
if (!node.value || !link.value) {
return;
}
node.value = node.value.data(topologyStore.nodes, (d: Node) => d.id);
node.value.exit().remove();
node.value = nodeElement(
d3,
node.value.enter(),
{
dragstart: dragstart,
dragged: dragged,
dragended: dragended,
handleNodeClick: handleNodeClick,
tipHtml: (data: Node) => {
const nodeMetrics: string[] = settings.value.nodeMetrics || []; const nodeMetrics: string[] = settings.value.nodeMetrics || [];
const nodeMetricConfig = settings.value.nodeMetricConfig || []; const nodeMetricConfig = settings.value.nodeMetricConfig || [];
const html = nodeMetrics.map((m, index) => { const html = nodeMetrics.map((m, index) => {
const metric = const metric =
topologyStore.nodeMetricValue[m].values.find( topologyStore.nodeMetricValue[m].values.find((val: { id: string; value: unknown }) => val.id === data.id) || {};
(val: { id: string; value: unknown }) => val.id === data.id,
) || {};
const opt: MetricConfigOpt = nodeMetricConfig[index] || {}; const opt: MetricConfigOpt = nodeMetricConfig[index] || {};
const v = aggregation(metric.value, opt); const v = aggregation(metric.value, opt);
return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || ""}</div>`; return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || "unknown"}</div>`;
}); });
return [` <div class="mb-5"><span class="grey">name: </span>${data.name}</div>`, ...html].join(" "); const tipHtml = [
}, `<div class="mb-5"><span class="grey">name: </span>${
}, data.name
tip.value, }</div><div class="mb-5"><span class="grey">type: </span>${data.type || "UNKNOWN"}</div>`,
settings.value.legend, ...html,
).merge(node.value); ].join(" ");
// line element
link.value = link.value.data(topologyStore.calls, (d: Call) => d.id); tooltip.value
link.value.exit().remove(); .style("top", event.offsetY + 10 + "px")
link.value = linkElement(link.value.enter()).merge(link.value); .style("left", event.offsetX + 10 + "px")
// anchorElement .style("visibility", "visible")
anchor.value = anchor.value.data(topologyStore.calls, (d: Call) => d.id); .html(tipHtml);
anchor.value.exit().remove(); }
anchor.value = anchorElement( function showLinkTip(event: MouseEvent, data: Call) {
anchor.value.enter(),
{
handleLinkClick: handleLinkClick,
tipHtml: (data: Call) => {
const linkClientMetrics: string[] = settings.value.linkClientMetrics || []; const linkClientMetrics: string[] = settings.value.linkClientMetrics || [];
const linkServerMetricConfig: MetricConfigOpt[] = settings.value.linkServerMetricConfig || []; const linkServerMetricConfig: MetricConfigOpt[] = settings.value.linkServerMetricConfig || [];
const linkClientMetricConfig: MetricConfigOpt[] = settings.value.linkClientMetricConfig || []; const linkClientMetricConfig: MetricConfigOpt[] = settings.value.linkClientMetricConfig || [];
@ -333,52 +430,69 @@ limitations under the License. -->
`<div><span class="grey">${t("detectPoint")}:</span>${data.detectPoints.join(" | ")}</div>`, `<div><span class="grey">${t("detectPoint")}:</span>${data.detectPoints.join(" | ")}</div>`,
].join(" "); ].join(" ");
return html; tooltip.value
}, .style("top", event.offsetY + "px")
}, .style("left", event.offsetX + "px")
tip.value, .style("visibility", "visible")
).merge(anchor.value); .html(html);
// arrow marker
arrow.value = arrow.value.data(topologyStore.calls, (d: Call) => d.id);
arrow.value.exit().remove();
arrow.value = arrowMarker(arrow.value.enter()).merge(arrow.value);
// force element
simulation.value.nodes(topologyStore.nodes);
simulation.value
.force("link")
.links(topologyStore.calls)
.id((d: Call) => d.id);
simulationSkip(d3, simulation.value, ticked);
const loopMap: any = {};
for (let i = 0; i < topologyStore.calls.length; i++) {
const link: any = topologyStore.calls[i];
link.loopFactor = 1;
for (let j = 0; j < topologyStore.calls.length; j++) {
if (i === j || loopMap[i]) {
continue;
} }
const otherLink = topologyStore.calls[j];
if (link.source.id === otherLink.target.id && link.target.id === otherLink.source.id) { function hideTip() {
link.loopFactor = -1; tooltip.value.style("visibility", "hidden");
loopMap[j] = 1;
break;
} }
function handleNodeClick(event: MouseEvent, d: Node & { x: number; y: number }) {
event.stopPropagation();
hideTip();
topologyStore.setNode(d);
topologyStore.setLink(null);
operationsPos.x = event.offsetX;
operationsPos.y = event.offsetY;
if (d.layer === String(dashboardStore.layerId)) {
return;
} }
items.value = [
{ id: "inspect", title: "Inspect", func: handleInspect },
{ id: "alerting", title: "Alerting", func: handleGoAlerting },
];
} }
function handleLinkClick(event: MouseEvent, d: Call) {
event.stopPropagation();
if (d.sourceObj.layer !== dashboardStore.layerId || d.targetObj.layer !== dashboardStore.layerId) {
return;
}
topologyStore.setNode(null);
topologyStore.setLink(d);
if (!settings.value.linkDashboard) {
return;
}
const origin = dashboardStore.entity;
const e = dashboardStore.entity === EntityType[1].value ? EntityType[0].value : dashboardStore.entity;
const { dashboard } = getDashboard({
name: settings.value.linkDashboard,
layer: dashboardStore.layerId,
entity: `${e}Relation`,
});
if (!dashboard) {
ElMessage.error(`The dashboard named ${settings.value.linkDashboard} doesn't exist`);
return;
}
dashboardStore.setEntity(dashboard.entity);
const path = `/dashboard/related/${dashboard.layer}/${e}Relation/${d.sourceObj.id}/${d.targetObj.id}/${dashboard.name}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
dashboardStore.setEntity(origin);
} }
async function handleInspect() { async function handleInspect() {
svg.value.selectAll(".topo-svg-graph").remove();
const id = topologyStore.node.id; const id = topologyStore.node.id;
topologyStore.setNode(null);
topologyStore.setLink(null);
loading.value = true; loading.value = true;
const resp = await topologyStore.getDepthServiceTopology([id], Number(depth.value)); const resp = await topologyStore.getDepthServiceTopology([id], Number(depth.value));
loading.value = false; loading.value = false;
if (resp && resp.errors) { if (resp && resp.errors) {
ElMessage.error(resp.errors); ElMessage.error(resp.errors);
} }
await init(); await update();
update(); topologyStore.setNode(null);
topologyStore.setLink(null);
} }
function handleGoEndpoint(name: string) { function handleGoEndpoint(name: string) {
const path = `/dashboard/${dashboardStore.layerId}/${EntityType[2].value}/${topologyStore.node.id}/${name}`; const path = `/dashboard/${dashboardStore.layerId}/${EntityType[2].value}/${topologyStore.node.id}/${name}`;
@ -408,16 +522,8 @@ limitations under the License. -->
window.open(routeUrl.href, "_blank"); window.open(routeUrl.href, "_blank");
} }
async function backToTopology() { async function backToTopology() {
svg.value.selectAll(".topo-svg-graph").remove();
loading.value = true; loading.value = true;
const resp = await getTopology(); await freshNodes();
loading.value = false;
if (resp && resp.errors) {
ElMessage.error(resp.errors);
}
await init();
update();
topologyStore.setNode(null); topologyStore.setNode(null);
topologyStore.setLink(null); topologyStore.setLink(null);
} }
@ -438,7 +544,6 @@ limitations under the License. -->
}; };
height.value = dom.height - 40; height.value = dom.height - 40;
width.value = dom.width; width.value = dom.width;
svg.value.attr("height", height.value).attr("width", width.value);
} }
function updateSettings(config: any) { function updateSettings(config: any) {
settings.value = config; settings.value = config;
@ -479,18 +584,14 @@ limitations under the License. -->
} }
} }
} }
async function freshNodes() { function svgEvent() {
if (!svg.value) { topologyStore.setNode(null);
return; topologyStore.setLink(null);
} dashboardStore.selectWidget(props.config);
svg.value.selectAll(".topo-svg-graph").remove();
await init();
update();
} }
async function changeDepth(opt: Option[] | any) { async function changeDepth(opt: Option[] | any) {
depth.value = opt[0].value; depth.value = opt[0].value;
await getTopology();
freshNodes(); freshNodes();
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -498,7 +599,13 @@ limitations under the License. -->
}); });
watch( watch(
() => [selectorStore.currentService, selectorStore.currentDestService], () => [selectorStore.currentService, selectorStore.currentDestService],
() => { (newVal, oldVal) => {
if (oldVal[0].id === newVal[0].id && !oldVal[1]) {
return;
}
if (oldVal[0].id === newVal[0].id && oldVal[1].id === newVal[1].id) {
return;
}
freshNodes(); freshNodes();
}, },
); );
@ -512,23 +619,20 @@ limitations under the License. -->
); );
</script> </script>
<style lang="scss"> <style lang="scss">
.topo-svg {
width: 100%;
height: calc(100% - 5px);
cursor: move;
}
.micro-topo-chart { .micro-topo-chart {
position: relative; position: relative;
height: calc(100% - 30px);
overflow: auto; overflow: auto;
margin-top: 30px; margin-top: 30px;
.svg-topology {
cursor: move;
}
.legend { .legend {
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 15px; left: 25px;
color: #ccc; color: #666;
div { div {
margin-bottom: 8px; margin-bottom: 8px;
@ -553,16 +657,22 @@ limitations under the License. -->
right: 10px; right: 10px;
width: 400px; width: 400px;
height: 600px; height: 600px;
background-color: #2b3037;
overflow: auto; overflow: auto;
padding: 0 15px; padding: 0 15px;
border-radius: 3px; border-radius: 3px;
color: #ccc; color: #ccc;
border: 1px solid #ccc;
background-color: #fff;
box-shadow: #eee 1px 2px 10px;
transition: all 0.5ms linear; transition: all 0.5ms linear;
&.dark {
background-color: #2b3037;
}
} }
.label { .label {
color: #ccc; color: #666;
display: inline-block; display: inline-block;
margin-right: 5px; margin-right: 5px;
} }
@ -572,8 +682,10 @@ limitations under the License. -->
color: #333; color: #333;
cursor: pointer; cursor: pointer;
background-color: #fff; background-color: #fff;
border-radius: 3px; border-radius: 5px;
padding: 10px 0; padding: 10px 0;
border: 1px solid #999;
box-shadow: #ddd 1px 2px 10px;
span { span {
display: block; display: block;
@ -598,22 +710,23 @@ limitations under the License. -->
.switch-icon { .switch-icon {
cursor: pointer; cursor: pointer;
transition: all 0.5ms linear; transition: all 0.5ms linear;
background-color: #252a2f99; background: rgba(0, 0, 0, 0.3);
color: #ddd; color: #fff;
display: inline-block; display: inline-block;
padding: 5px 8px 8px; padding: 2px 4px;
border-radius: 3px; border-radius: 3px;
} }
.topo-line { .topo-line {
stroke-linecap: round; stroke-linecap: round;
stroke-width: 3px; stroke-width: 1px;
stroke-dasharray: 13 7; stroke-dasharray: 10 10;
fill: none; fill: none;
animation: topo-dash 0.5s linear infinite; animation: topo-dash 0.3s linear infinite;
} }
.topo-line-anchor { .topo-line-anchor,
.topo-node {
cursor: pointer; cursor: pointer;
} }
@ -624,37 +737,9 @@ limitations under the License. -->
opacity: 0.8; opacity: 0.8;
} }
} }
.d3-tip {
line-height: 1;
padding: 8px;
color: #eee;
border-radius: 4px;
font-size: 12px;
z-index: 9999;
background: #252a2f;
}
.d3-tip:after {
box-sizing: border-box;
display: block;
font-size: 10px;
width: 100%;
line-height: 0.8;
color: #252a2f;
content: "\25BC";
position: absolute;
text-align: center;
}
.d3-tip.n:after {
margin: -2px 0 0 0;
top: 100%;
left: 0;
}
@keyframes topo-dash { @keyframes topo-dash {
from { from {
stroke-dashoffset: 20; stroke-dashoffset: 10;
} }
to { to {
@ -665,4 +750,13 @@ limitations under the License. -->
.el-loading-spinner { .el-loading-spinner {
top: 30%; top: 30%;
} }
#tooltip {
position: absolute;
visibility: hidden;
padding: 5px;
border: 1px solid #000;
border-radius: 3px;
background-color: #fff;
}
</style> </style>

View File

@ -27,7 +27,7 @@ limitations under the License. -->
/> />
<div class="label"> <div class="label">
<span>{{ t("linkServerMetrics") }}</span> <span>{{ t("linkServerMetrics") }}</span>
<el-popover placement="left" :width="400" trigger="click" effect="dark" v-if="states.linkServerMetrics.length"> <el-popover placement="left" :width="400" trigger="click" v-if="states.linkServerMetrics.length">
<template #reference> <template #reference>
<span @click="setConfigType('linkServerMetricConfig')"> <span @click="setConfigType('linkServerMetricConfig')">
<Icon class="cp ml-5" iconName="mode_edit" size="middle" /> <Icon class="cp ml-5" iconName="mode_edit" size="middle" />
@ -48,7 +48,7 @@ limitations under the License. -->
<span v-show="dashboardStore.entity !== EntityType[2].value"> <span v-show="dashboardStore.entity !== EntityType[2].value">
<div class="label"> <div class="label">
<span>{{ t("linkClientMetrics") }}</span> <span>{{ t("linkClientMetrics") }}</span>
<el-popover placement="left" :width="400" trigger="click" effect="dark" v-if="states.linkClientMetrics.length"> <el-popover placement="left" :width="400" trigger="click" v-if="states.linkClientMetrics.length">
<template #reference> <template #reference>
<span @click="setConfigType('linkClientMetricConfig')"> <span @click="setConfigType('linkClientMetricConfig')">
<Icon class="cp ml-5" iconName="mode_edit" size="middle" /> <Icon class="cp ml-5" iconName="mode_edit" size="middle" />
@ -110,7 +110,7 @@ limitations under the License. -->
</div> </div>
<div class="label"> <div class="label">
<span>{{ t("nodeMetrics") }}</span> <span>{{ t("nodeMetrics") }}</span>
<el-popover placement="left" :width="400" trigger="click" effect="dark" v-if="states.nodeMetrics.length"> <el-popover placement="left" :width="400" trigger="click" v-if="states.nodeMetrics.length">
<template #reference> <template #reference>
<span @click="setConfigType('nodeMetricConfig')"> <span @click="setConfigType('nodeMetricConfig')">
<Icon class="cp ml-5" iconName="mode_edit" size="middle" /> <Icon class="cp ml-5" iconName="mode_edit" size="middle" />
@ -454,6 +454,7 @@ limitations under the License. -->
.title { .title {
margin-bottom: 0; margin-bottom: 0;
color: #666;
} }
.label { .label {

View File

@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as d3 from "d3";
import type { Node, Call } from "@/types/topology";
export function layout(levels: Node[][], calls: Call[], radius: number) {
// precompute level depth
levels.forEach((l: Node[], i: number) => l.forEach((n: any) => (n.level = i)));
const nodes: Node[] = levels.reduce((a, x) => a.concat(x), []);
// layout
const padding = 30;
const node_height = 120;
const node_width = 100;
const bundle_width = 14;
const metro_d = 4;
for (const n of nodes) {
n.height = 5 * metro_d;
}
let x_offset = padding;
let y_offset = 0;
for (const level of levels) {
y_offset = 0;
x_offset += 5 * bundle_width;
for (const l of level) {
const n: any = l;
for (const call of calls) {
if (call.source === n.id) {
call.sourceObj = n;
}
if (call.target === n.id) {
call.targetObj = n;
}
}
n.x = n.level * node_width + x_offset;
n.y = node_height + y_offset + n.height / 2;
y_offset += node_height + n.height;
}
}
const layout = {
width: d3.max(nodes as any, (n: { x: number }) => n.x) || 0 + node_width + 2 * padding,
height: d3.max(nodes as any, (n: { y: number }) => n.y) || 0 + node_height / 2 + 2 * padding,
};
return { nodes, layout, calls: computeCallPos(calls, radius) };
}
export function computeCallPos(calls: Call[], radius: number) {
for (const [index, call] of calls.entries()) {
const centrePoints = [call.sourceObj.x, call.sourceObj.y, call.targetObj.x, call.targetObj.y];
for (const [idx, link] of calls.entries()) {
if (
index < idx &&
call.id !== link.id &&
call.sourceObj.x === link.targetObj.x &&
call.sourceObj.y === link.targetObj.y &&
call.targetObj.x === link.sourceObj.x &&
call.targetObj.y === link.sourceObj.y
) {
if (call.targetObj.y === call.sourceObj.y) {
centrePoints[1] = centrePoints[1] - 8;
centrePoints[3] = centrePoints[3] - 8;
} else if (call.targetObj.x === call.sourceObj.x) {
centrePoints[0] = centrePoints[0] - 8;
centrePoints[2] = centrePoints[2] - 8;
} else {
centrePoints[1] = centrePoints[1] + 6;
centrePoints[3] = centrePoints[3] + 6;
centrePoints[0] = centrePoints[0] - 6;
centrePoints[2] = centrePoints[2] - 6;
}
}
}
const pos: { x: number; y: number }[] = circleIntersection(
centrePoints[0],
centrePoints[1],
radius,
centrePoints[2],
centrePoints[3],
radius,
);
call.sourceX = pos[0].x;
call.sourceY = pos[0].y;
call.targetX = pos[1].x;
call.targetY = pos[1].y;
}
return calls;
}
export function circleIntersection(ax: number, ay: number, ar: number, bx: number, by: number, br: number) {
const dab = Math.sqrt(Math.pow(ax - bx, 2) + Math.pow(ay - by, 2));
const dfx = (ar * Math.abs(ax - bx)) / dab;
const dfy = (ar * Math.abs(ay - by)) / dab;
const fx = bx > ax ? ax + dfx : ax - dfx;
const fy = ay > by ? ay - dfy : ay + dfy;
const dgx = (br * Math.abs(ax - bx)) / dab;
const dgy = (br * Math.abs(ay - by)) / dab;
const gx = bx > ax ? bx - dgx : bx + dgx;
const gy = ay > by ? by + dgy : by - dgy;
return [
{ x: fx, y: fy },
{ x: gx, y: gy },
];
}

View File

@ -1,60 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const linkElement = (graph: any) => {
const linkEnter = graph
.append("path")
.attr("class", "topo-line")
.attr("marker-end", "url(#arrow)")
.attr("stroke", "#217EF25f");
return linkEnter;
};
export const anchorElement = (graph: any, funcs: any, tip: any) => {
const linkEnter = graph
.append("circle")
.attr("class", "topo-line-anchor")
.attr("r", 5)
.attr("fill", "#217EF25f")
.on("mouseover", function (event: unknown, d: unknown) {
tip.html(funcs.tipHtml).show(d, this);
})
.on("mouseout", function () {
tip.hide(this);
})
.on("click", (event: unknown, d: unknown) => {
funcs.handleLinkClick(event, d);
});
return linkEnter;
};
export const arrowMarker = (graph: any) => {
const defs = graph.append("defs");
const arrow = defs
.append("marker")
.attr("id", "arrow")
.attr("class", "topo-line-arrow")
.attr("markerUnits", "strokeWidth")
.attr("markerWidth", "6")
.attr("markerHeight", "6")
.attr("viewBox", "0 0 12 12")
.attr("refX", "5")
.attr("refY", "6")
.attr("orient", "auto");
const arrowPath = "M2,2 L10,6 L2,10 L6,6 L2,2";
arrow.append("path").attr("d", arrowPath).attr("fill", "#217EF25f");
return arrow;
};

View File

@ -1,85 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import icons from "@/assets/img/icons";
import type { Node } from "@/types/topology";
icons["KAFKA-CONSUMER"] = icons.KAFKA;
export default (d3: any, graph: any, funcs: any, tip: any, legend?: any) => {
const nodeEnter = graph
.append("g")
.call(d3.drag().on("start", funcs.dragstart).on("drag", funcs.dragged).on("end", funcs.dragended))
.on("mouseover", function (event: PointerEvent, d: Node) {
tip.html(funcs.tipHtml).show(d, this);
})
.on("mouseout", function () {
tip.hide(this);
})
.on("click", (event: PointerEvent, d: Node | any) => {
event.stopPropagation();
event.preventDefault();
funcs.handleNodeClick(event, d);
});
nodeEnter
.append("image")
.attr("width", 49)
.attr("height", 49)
.attr("x", 2)
.attr("y", 10)
.attr("style", "cursor: move;")
.attr("xlink:href", (d: { [key: string]: number }) => {
if (!legend) {
return icons.CUBE;
}
if (!legend.length) {
return icons.CUBE;
}
let c = true;
for (const l of legend) {
if (l.condition === "<") {
c = c && d[l.name] < Number(l.value);
} else {
c = c && d[l.name] > Number(l.value);
}
}
return c && d.isReal ? icons.CUBEERROR : icons.CUBE;
});
nodeEnter
.append("image")
.attr("width", 32)
.attr("height", 32)
.attr("x", 6)
.attr("y", -10)
.attr("style", "opacity: 0.5;")
.attr("xlink:href", icons.LOCAL);
nodeEnter
.append("image")
.attr("width", 18)
.attr("height", 18)
.attr("x", 13)
.attr("y", -7)
.attr("xlink:href", (d: { type: string }) =>
!d.type || d.type === "N/A" ? icons.UNDEFINED : icons[d.type.toUpperCase().replace("-", "")],
);
nodeEnter
.append("text")
.attr("class", "topo-text")
.attr("text-anchor", "middle")
.attr("x", 22)
.attr("y", 70)
.text((d: { name: string }) => (d.name.length > 20 ? `${d.name.substring(0, 20)}...` : d.name));
return nodeEnter;
};

View File

@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const simulationInit = (d3: any, nodes: any, links: any, ticked: any) => {
const simulation = d3
.forceSimulation(nodes)
.force(
"collide",
d3.forceCollide().radius(() => 60),
)
.force("yPos", d3.forceY().strength(1))
.force("xPos", d3.forceX().strength(1))
.force("charge", d3.forceManyBody().strength(-520))
.force(
"link",
d3.forceLink(links).id((d: { id: string }) => d.id),
)
.force("center", d3.forceCenter(window.innerWidth / 2, window.innerHeight / 2 - 20))
.on("tick", ticked)
.stop();
simulationSkip(d3, simulation, ticked);
return simulation;
};
export const simulationSkip = (d3: any, simulation: any, ticked: any) => {
d3.timeout(() => {
const n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()));
for (let i = 0; i < n; i += 1) {
simulation.tick();
ticked();
}
});
};