feat: add graph for topology

This commit is contained in:
Qiuxia Fan 2022-02-08 14:10:24 +08:00
parent a994a76ede
commit 1a6ab74be8
11 changed files with 405 additions and 51 deletions

View File

@ -0,0 +1,52 @@
/**
* 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 { defineStore } from "pinia";
import { store } from "@/store";
import { Node, Call } from "@/types/topology";
interface TopologyState {
node: Node | null;
call: Call | null;
calls: Call[];
nodes: Node[];
}
export const topologyStore = defineStore({
id: "topology",
state: (): TopologyState => ({
calls: [],
nodes: [],
node: null,
call: null,
}),
actions: {
setNode(node: Node) {
this.node = node;
},
setLink(link: Call) {
this.call = link;
},
setTopology(nodes: Node[], links: Call[]) {
this.nodes = nodes;
this.calls = links;
},
},
});
export function useTopologyStore(): any {
return topologyStore(store);
}

41
src/types/topology.d.ts vendored Normal file
View File

@ -0,0 +1,41 @@
/**
* 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 interface Call {
avgResponseTime: number;
cpm: number;
isAlert: boolean;
source: string | any;
target: string | any;
id: string;
detectPoints: string[];
type?: string;
sourceObj?: any;
}
export interface Node {
apdex: number;
avgResponseTime: number;
cpm: number;
id: string;
isAlarm: boolean;
name: string;
numOfServer: number;
numOfServerAlarm: number;
numOfServiceAlarm: number;
sla: number;
type: string;
isReal: boolean;
}

View File

@ -31,7 +31,7 @@ limitations under the License. -->
:destroy-on-close="true" :destroy-on-close="true"
@closed="dashboardStore.setTopology(false)" @closed="dashboardStore.setTopology(false)"
> >
topology <Graph />
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
@ -41,6 +41,7 @@ import GridLayout from "./panel/Layout.vue";
// import { LayoutConfig } from "@/types/dashboard"; // import { LayoutConfig } from "@/types/dashboard";
import Tool from "./panel/Tool.vue"; import Tool from "./panel/Tool.vue";
import ConfigEdit from "./configuration/ConfigEdit.vue"; import ConfigEdit from "./configuration/ConfigEdit.vue";
import Graph from "./related/topology/Graph.vue";
import { useDashboardStore } from "@/store/modules/dashboard"; import { useDashboardStore } from "@/store/modules/dashboard";
import { useAppStoreWithOut } from "@/store/modules/app"; import { useAppStoreWithOut } from "@/store/modules/app";

View File

@ -13,9 +13,252 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. --> limitations under the License. -->
<template> <template>
<div class="micro-topo-chart"></div> <div ref="chart" class="micro-topo-chart"></div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from "vue";
import type { PropType } from "vue";
import { useI18n } from "vue-i18n";
import * as d3 from "d3"; import * as d3 from "d3";
import d3tip from "d3-tip"; import d3tip from "d3-tip";
import zoom from "./utils/zoom";
import { simulationInit, simulationSkip } from "./utils/simulation";
import nodeElement from "./utils/nodeElement";
import { linkElement, anchorElement } from "./utils/linkElement";
import tool from "./utils/tool";
import topoLegend from "./utils/legend";
import { Node, Call } from "@/types/topology";
import { useTopologyStore } from "@/store/modules/topology";
/*global defineProps, Nullable */
const props = defineProps({
current: {
type: Object as PropType<{ [key: string]: number[] }>,
default: () => ({}),
},
nodes: { type: Array as PropType<Node[]>, default: () => [] },
links: { type: Array as PropType<Call[]>, default: () => [] },
});
const { t } = useI18n();
const topologyStore = useTopologyStore();
// const height = ref<number>(600);
const simulation = ref<any>("");
const svg = ref<Nullable<any>>(null);
const chart = ref<any>(null);
const tip = ref<any>(null);
const graph = ref<any>(null);
const node = ref<any>(null);
const link = ref<any>(null);
const anchor = ref<any>(null);
const tools = ref<any>(null);
onMounted(() => {
window.addEventListener("resize", resize);
svg.value = d3
.select(chart.value)
.append("svg")
.attr("class", "topo-svg")
.attr("height", chart.value.clientHeight);
tip.value = (d3tip as any)().attr("class", "d3-tip").offset([-8, 0]);
graph.value = svg.value.append("g").attr("class", "topo-svg_graph");
graph.value.call(tip.value);
simulation.value = simulationInit(d3, props.nodes, props.links, ticked);
node.value = graph.value.append("g").selectAll(".topo-node");
link.value = graph.value.append("g").selectAll(".topo-line");
anchor.value = graph.value.append("g").selectAll(".topo-line-anchor");
// tools.value = tool(graph.value, [
// { icon: "API", click: handleGoEndpoint },
// { icon: "INSTANCE", click: handleGoInstance },
// { icon: "TRACE", click: handleGoTrace },
// { icon: "ALARM", click: handleGoAlarm },
// { icon: "ENDPOINT", click: handleGoEndpointDependency },
// { icon: "" },
// ]);
});
function resize() {
svg.value.attr("height", chart.value.clientHeight);
}
function ticked() {
link.value.attr(
"d",
(d: Call | any) =>
`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: 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) {
node.value._groups[0].forEach((g: any) => {
g.__data__.fx = g.__data__.x;
g.__data__.fy = g.__data__.y;
});
if (!(d3 as any).event.active) {
simulation.value.alphaTarget(0.1).restart();
}
(d3 as any).event.sourceEvent.stopPropagation();
}
function dragged(d: any) {
d.fx = (d3 as any).event.x;
d.fy = (d3 as any).event.y;
d.x = d.fx;
d.y = d.fy;
}
function dragended() {
if (!(d3 as any).event.active) {
simulation.value.alphaTarget(0);
}
}
function handleNodeClick(d: any) {
const { x, y, vx, vy, fx, fy, index, ...rest } = d;
topologyStore.setNode(rest);
topologyStore.setLink({});
}
function handleLinkClick(event: any, d: any) {
event.stopPropagation();
topologyStore.setNode({});
topologyStore.setLink(d);
}
function update() {
// node element
node.value = node.value.data(props.nodes, (d: any) => d.id);
node.value.exit().remove();
node.value = nodeElement(
d3,
node.value.enter(),
tools.value,
{
dragstart: dragstart,
dragged: dragged,
dragended: dragended,
handleNodeClick: handleNodeClick,
},
tip.value
).merge(node.value);
// line element
link.value = link.value.data(props.links, (d: any) => d.id);
link.value.exit().remove();
link.value = linkElement(link.value.enter()).merge(link.value);
// anchorElement
anchor.value = anchor.value.data(props.links, (d: any) => d.id);
anchor.value.exit().remove();
anchor.value = anchorElement(
anchor.value.enter(),
{
handleLinkClick: handleLinkClick,
$tip: (data: any) =>
`
<div class="mb-5"><span class="grey">${t("cpm")}: </span>${
data.cpm
}</div>
<div class="mb-5"><span class="grey">${t("latency")}: </span>${
data.latency
}</div>
<div><span class="grey">${t(
"detectPoint"
)}:</span>${data.detectPoints.join(" | ")}</div>
`,
},
tip.value
).merge(anchor.value);
// force element
simulation.value.nodes(props.nodes);
simulation.value
.force("link")
.links(props.links)
.id((d: any) => d.id);
simulationSkip(d3, simulation.value, ticked);
const loopMap: any = {};
for (let i = 0; i < props.links.length; i++) {
const link: any = props.links[i];
link.loopFactor = 1;
for (let j = 0; j < props.links.length; j++) {
if (i === j || loopMap[i]) {
continue;
}
const otherLink = props.links[j];
if (
link.source.id === otherLink.target.id &&
link.target.id === otherLink.source.id
) {
link.loopFactor = -1;
loopMap[j] = 1;
break;
}
}
}
}
onBeforeUnmount(() => {
window.removeEventListener("resize", resize);
});
</script> </script>
<style lang="scss" scoped>
.micro-topo-chart {
height: 100%;
width: 100%;
.topo-svg {
display: block;
width: 100%;
}
.topo-line {
stroke-linecap: round;
stroke-width: 1.3px !important;
stroke-dasharray: 13 7;
fill: none;
animation: topo-dash 1s linear infinite !important;
}
.topo-line-anchor {
cursor: pointer;
}
.topo-text {
font-family: "Lato", "Source Han Sans CN", "Microsoft YaHei", sans-serif;
fill: #ddd;
font-size: 11px;
opacity: 0.8;
}
.topo-tool {
display: none;
}
.topo-tool-i {
cursor: pointer;
.tool-hexagon {
fill: #3f4450;
stroke: #217ef2;
stroke-width: 2;
stroke-opacity: 0.5;
}
&:hover {
.tool-hexagon {
stroke-opacity: 1;
}
}
}
}
@keyframes topo-dash {
from {
stroke-dashoffset: 20;
}
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@ -16,19 +16,22 @@
*/ */
const requireComponent = require.context("../../assets", false, /\.png$/); const requireComponent = require.context("../../assets", false, /\.png$/);
const result = {}; const result: { [key: string]: string } = {};
function capitalizeFirstLetter(str) { function capitalizeFirstLetter(str: string) {
return str.toUpperCase(); return str.toUpperCase();
} }
function validateFileName(str) { function validateFileName(str: string): string | undefined {
return ( if (/^\S+\.png$/.test(str)) {
/^\S+\.png$/.test(str) && return str.replace(/^\S+\/(\w+)\.png$/, (rs, $1) =>
str.replace(/^\S+\/(\w+)\.png$/, (rs, $1) => capitalizeFirstLetter($1)) capitalizeFirstLetter($1)
); );
} }
requireComponent.keys().forEach((filePath) => { }
requireComponent.keys().forEach((filePath: string) => {
const componentConfig = requireComponent(filePath); const componentConfig = requireComponent(filePath);
const fileName = validateFileName(filePath); const fileName = validateFileName(filePath);
if (fileName) {
result[fileName] = componentConfig; result[fileName] = componentConfig;
}
}); });
export default result; export default result;

View File

@ -16,7 +16,11 @@
*/ */
import icons from "./icons"; import icons from "./icons";
export default function topoLegend(graph, clientHeight, clientWidth) { export default function topoLegend(
graph: any,
clientHeight: number,
clientWidth: number
) {
for (const item of ["CUBE", "CUBEERROR"]) { for (const item of ["CUBE", "CUBEERROR"]) {
graph graph
.append("image") .append("image")

View File

@ -14,26 +14,28 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
export const linkElement = (graph) => { export const linkElement = (graph: any) => {
const linkEnter = graph const linkEnter = graph
.append("path") .append("path")
.attr("class", "topo-line") .attr("class", "topo-line")
.attr("stroke", (d) => (d.cpm ? "#217EF25f" : "#6a6d7777")); .attr("stroke", (d: { cpm: number }) =>
d.cpm ? "#217EF25f" : "#6a6d7777"
);
return linkEnter; return linkEnter;
}; };
export const anchorElement = (graph, funcs, tip) => { export const anchorElement = (graph: any, funcs: any, tip: any) => {
const linkEnter = graph const linkEnter = graph
.append("circle") .append("circle")
.attr("class", "topo-line-anchor") .attr("class", "topo-line-anchor")
.attr("r", 5) .attr("r", 5)
.attr("fill", (d) => (d.cpm ? "#217EF25f" : "#6a6d7777")) .attr("fill", (d: { cpm: number }) => (d.cpm ? "#217EF25f" : "#6a6d7777"))
.on("mouseover", function (d) { .on("mouseover", function (d: unknown) {
tip.html(funcs.$tip).show(d, this); tip.html(funcs.$tip).show(d, this);
}) })
.on("mouseout", function () { .on("mouseout", function () {
tip.hide(this); tip.hide(this);
}) })
.on("click", (d) => { .on("click", (d: unknown) => {
funcs.handleLinkClick(d); funcs.handleLinkClick(d);
}); });
return linkEnter; return linkEnter;

View File

@ -17,7 +17,7 @@
import icons from "./icons"; import icons from "./icons";
icons["KAFKA-CONSUMER"] = icons.KAFKA; icons["KAFKA-CONSUMER"] = icons.KAFKA;
export default (d3, graph, tool, funcs, tip) => { export default (d3: any, graph: any, tool: any, funcs: any, tip: any) => {
const nodeEnter = graph const nodeEnter = graph
.append("g") .append("g")
.call( .call(
@ -27,22 +27,22 @@ export default (d3, graph, tool, funcs, tip) => {
.on("drag", funcs.dragged) .on("drag", funcs.dragged)
.on("end", funcs.dragended) .on("end", funcs.dragended)
) )
.on("mouseover", function (d) { .on("mouseover", function (d: any) {
tip.html((data) => `<div>${data.name}</div>`).show(d, this); tip.html((data: any) => `<div>${data.name}</div>`).show(d, this);
}) })
.on("mouseout", function () { .on("mouseout", function () {
tip.hide(this); tip.hide(this);
}) })
.on("click", (d) => { .on("click", (event: any, d: any) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); // event.preventDefault();
tool.attr("style", "display: none"); // tool.attr("style", "display: none");
funcs.handleNodeClick(d); funcs.handleNodeClick(d);
if (d.isReal) { // if (d.isReal) {
tool // tool
.attr("transform", `translate(${d.x},${d.y - 20})`) // .attr("transform", `translate(${d.x},${d.y - 20})`)
.attr("style", "display: block"); // .attr("style", "display: block");
} // }
}); });
nodeEnter nodeEnter
.append("image") .append("image")
@ -51,7 +51,7 @@ export default (d3, graph, tool, funcs, tip) => {
.attr("x", 2) .attr("x", 2)
.attr("y", 10) .attr("y", 10)
.attr("style", "cursor: move;") .attr("style", "cursor: move;")
.attr("xlink:href", (d) => .attr("xlink:href", (d: { isReal: number; sla: number; cpm: number }) =>
d.sla < 95 && d.isReal && d.cpm > 1 ? icons.CUBEERROR : icons.CUBE d.sla < 95 && d.isReal && d.cpm > 1 ? icons.CUBEERROR : icons.CUBE
); );
nodeEnter nodeEnter
@ -68,7 +68,7 @@ export default (d3, graph, tool, funcs, tip) => {
.attr("height", 18) .attr("height", 18)
.attr("x", 13) .attr("x", 13)
.attr("y", -7) .attr("y", -7)
.attr("xlink:href", (d) => .attr("xlink:href", (d: { type: string }) =>
!d.type || d.type === "N/A" !d.type || d.type === "N/A"
? icons.UNDEFINED ? icons.UNDEFINED
: icons[d.type.toUpperCase().replace("-", "")] : icons[d.type.toUpperCase().replace("-", "")]
@ -79,7 +79,7 @@ export default (d3, graph, tool, funcs, tip) => {
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.attr("x", 22) .attr("x", 22)
.attr("y", 70) .attr("y", 70)
.text((d) => .text((d: { name: string }) =>
d.name.length > 20 ? `${d.name.substring(0, 20)}...` : d.name d.name.length > 20 ? `${d.name.substring(0, 20)}...` : d.name
); );
return nodeEnter; return nodeEnter;

View File

@ -14,7 +14,12 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
export const simulationInit = (d3, data_nodes, data_links, ticked) => { export const simulationInit = (
d3: any,
data_nodes: any,
dataLinks: any,
ticked: any
) => {
const simulation = d3 const simulation = d3
.forceSimulation(data_nodes) .forceSimulation(data_nodes)
.force( .force(
@ -26,7 +31,7 @@ export const simulationInit = (d3, data_nodes, data_links, ticked) => {
.force("charge", d3.forceManyBody().strength(-520)) .force("charge", d3.forceManyBody().strength(-520))
.force( .force(
"link", "link",
d3.forceLink(data_links).id((d) => d.id) d3.forceLink(dataLinks).id((d: { id: string }) => d.id)
) )
.force( .force(
"center", "center",
@ -38,7 +43,7 @@ export const simulationInit = (d3, data_nodes, data_links, ticked) => {
return simulation; return simulation;
}; };
export const simulationSkip = (d3, simulation, ticked) => { export const simulationSkip = (d3: any, simulation: any, ticked: any) => {
d3.timeout(() => { d3.timeout(() => {
const n = Math.ceil( const n = Math.ceil(
Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()) Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())

View File

@ -15,40 +15,43 @@
* limitations under the License. * limitations under the License.
*/ */
const requireComponent = require.context("./tool", false, /\.png$/); const requireComponent = require.context("./tool", false, /\.png$/);
const icons: { [key: string]: string } = {};
const icons = {}; function capitalizeFirstLetter(str: string) {
function capitalizeFirstLetter(str) {
return str.toUpperCase(); return str.toUpperCase();
} }
function validateFileName(str) { function validateFileName(str: string): string | undefined {
return ( if (/^\S+\.png$/.test(str)) {
/^\S+\.png$/.test(str) && return str.replace(/^\S+\/(\w+)\.png$/, (rs, $1) =>
str.replace(/^\S+\/(\w+)\.png$/, (rs, $1) => capitalizeFirstLetter($1)) capitalizeFirstLetter($1)
); );
} }
requireComponent.keys().forEach((filePath) => { }
requireComponent.keys().forEach((filePath: string) => {
const componentConfig = requireComponent(filePath); const componentConfig = requireComponent(filePath);
const fileName = validateFileName(filePath); const fileName = validateFileName(filePath);
if (fileName) {
icons[fileName] = componentConfig; icons[fileName] = componentConfig;
}
}); });
const Hexagon = (side, r, cx, cy) => { const Hexagon = (side: number, r: number, cx: number, cy: number) => {
let path = ""; let path = "";
for (let i = 0; i < side; i += 1) { for (let i = 0; i < side; i += 1) {
let x = Math.cos(((2 / side) * i + 1 / side) * Math.PI) * r + cx; const x = Math.cos(((2 / side) * i + 1 / side) * Math.PI) * r + cx;
let y = -Math.sin(((2 / side) * i + 1 / side) * Math.PI) * r + cy; const y = -Math.sin(((2 / side) * i + 1 / side) * Math.PI) * r + cy;
path += !i ? `M${x},${y} ` : `L${x},${y} `; path += !i ? `M${x},${y} ` : `L${x},${y} `;
if (i == side - 1) path += "Z"; if (i == side - 1) path += "Z";
} }
return path; return path;
}; };
export default (graph, data) => { export default (graph: any, data: any) => {
const tool = graph.append("g").attr("class", "topo-tool"); const tool = graph.append("g").attr("class", "topo-tool");
const side = 6; const side = 6;
for (let i = 0; i < data.length; i += 1) { for (let i = 0; i < data.length; i += 1) {
let x = Math.cos((2 / side) * i * Math.PI) * 34; const x = Math.cos((2 / side) * i * Math.PI) * 34;
let y = -Math.sin((2 / side) * i * Math.PI) * 34; const y = -Math.sin((2 / side) * i * Math.PI) * 34;
const tool_g = tool const tool_g = tool
.append("g") .append("g")
.attr("class", "topo-tool-i") .attr("class", "topo-tool-i")

View File

@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
export default (d3, graph) => export default (d3: any, graph: any) =>
d3 d3
.zoom() .zoom()
.scaleExtent([0.3, 10]) .scaleExtent([0.3, 10])