feat: implement Topology on the dashboard (#14)

This commit is contained in:
Fine0830
2022-02-19 23:05:57 +08:00
committed by GitHub
parent 7472d70720
commit f53b422782
81 changed files with 4886 additions and 232 deletions

View File

@@ -0,0 +1,30 @@
<!-- 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. -->
<template>
<PodTopology v-if="isSankey" />
<Graph v-else />
</template>
<script lang="ts" setup>
import { ref } from "vue";
import Graph from "./components/Graph.vue";
import PodTopology from "./components/PodTopology.vue";
import { EntityType } from "../../data";
import { useDashboardStore } from "@/store/modules/dashboard";
const dashboardStore = useDashboardStore();
const isSankey = ref<boolean>(
[EntityType[2].value, EntityType[4].value].includes(dashboardStore.entity)
);
</script>

View File

@@ -0,0 +1,586 @@
<!-- 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. -->
<template>
<div
ref="chart"
class="micro-topo-chart"
v-loading="loading"
:style="`height: ${height}px`"
>
<div class="setting" v-show="showSetting">
<Settings @update="updateSettings" @updateNodes="freshNodes" />
</div>
<div class="tool">
<span class="label">{{ t("currentDepth") }}</span>
<Selector
class="inputs"
:value="depth"
:options="DepthList"
placeholder="Select a option"
@change="changeDepth"
/>
<span class="switch-icon ml-5" title="Settings" @click="setConfig">
<Icon size="middle" iconName="settings" />
</span>
<span
class="switch-icon ml-5"
title="Back to overview topology"
@click="backToTopology"
>
<Icon size="middle" iconName="keyboard_backspace" />
</span>
</div>
<div
class="operations-list"
v-if="topologyStore.node"
:style="{
top: operationsPos.y + 'px',
left: operationsPos.x + 'px',
}"
>
<span
v-for="(item, index) of items"
:key="index"
@click="item.func(item.dashboard)"
>
{{ item.title }}
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, reactive } from "vue";
import { useI18n } from "vue-i18n";
import * as d3 from "d3";
import d3tip from "d3-tip";
import zoom from "../utils/zoom";
import { simulationInit, simulationSkip } from "../utils/simulation";
import nodeElement from "../utils/nodeElement";
import { linkElement, anchorElement, arrowMarker } from "../utils/linkElement";
import topoLegend from "../utils/legend";
import { Node, Call } from "@/types/topology";
import { useSelectorStore } from "@/store/modules/selectors";
import { useTopologyStore } from "@/store/modules/topology";
import { useDashboardStore } from "@/store/modules/dashboard";
import { EntityType, DepthList } from "../../../data";
import router from "@/router";
import { ElMessage } from "element-plus";
import Settings from "./Settings.vue";
import { Option } from "@/types/app";
import { Service } from "@/types/selector";
/*global Nullable */
const { t } = useI18n();
const selectorStore = useSelectorStore();
const topologyStore = useTopologyStore();
const dashboardStore = useDashboardStore();
const height = ref<number>(document.body.clientHeight - 90);
const width = ref<number>(document.body.clientWidth - 40);
const loading = ref<boolean>(false);
const simulation = ref<any>(null);
const svg = ref<Nullable<any>>(null);
const chart = ref<Nullable<HTMLDivElement>>(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 arrow = ref<any>(null);
const legend = ref<any>(null);
const showSetting = ref<boolean>(false);
const settings = ref<any>({});
const operationsPos = reactive<{ x: number; y: number }>({ x: NaN, y: NaN });
const items = ref<
{ id: string; title: string; func: any; dashboard?: string }[]
>([
{ id: "inspect", title: "Inspect", func: handleInspect },
{ id: "alarm", title: "Alarm", func: handleGoAlarm },
]);
const depth = ref<string>(topologyStore.defaultDepth);
onMounted(async () => {
loading.value = true;
const resp = await getTopology();
loading.value = false;
if (resp && resp.errors) {
ElMessage.error(resp.errors);
}
window.addEventListener("resize", resize);
svg.value = d3
.select(chart.value)
.append("svg")
.attr("class", "topo-svg")
.attr("height", height.value)
.attr("width", width.value);
await init();
update();
});
async function init() {
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,
topologyStore.nodes,
topologyStore.calls,
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");
arrow.value = graph.value.append("g").selectAll(".topo-line-arrow");
svg.value.call(zoom(d3, graph.value));
// legend
legend.value = graph.value.append("g").attr("class", "topo-legend");
topoLegend(legend.value, height.value, width.value, settings.value.legend);
svg.value.on("click", (event: any) => {
event.stopPropagation();
event.preventDefault();
topologyStore.setNode(null);
topologyStore.setLink(null);
});
}
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: 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) {
node.value._groups[0].forEach((g: any) => {
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;
d.subject.fy = d.subject.y;
d.sourceEvent.stopPropagation();
}
function dragged(d: any) {
d.subject.fx = d.x;
d.subject.fy = d.y;
}
function dragended(d: any) {
if (!d.active) {
simulation.value.alphaTarget(0);
}
}
function handleNodeClick(d: Node & { x: number; y: number }) {
topologyStore.setNode(d);
topologyStore.setLink(null);
operationsPos.x = d.x;
operationsPos.y = d.y + 30;
if (d.layer === String(dashboardStore.layerId)) {
return;
}
items.value = [
{ id: "inspect", title: "Inspect", func: handleInspect },
{ id: "alarm", title: "Alarm", func: handleGoAlarm },
];
}
function handleLinkClick(event: any, 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 e =
dashboardStore.entity === EntityType[1].value
? EntityType[0].value
: dashboardStore.entity;
const path = `/dashboard/${dashboardStore.layerId}/${e}Relation/${d.source.id}/${d.target.id}/${settings.value.linkDashboard}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
}
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 html = nodeMetrics.map((m) => {
const metric =
topologyStore.nodeMetrics[m].values.filter(
(val: { id: string; value: unknown }) => val.id === data.id
)[0] || {};
const val = m.includes("_sla") ? metric.value / 100 : metric.value;
return ` <div class="mb-5"><span class="grey">${m}: </span>${val}</div>`;
});
return [
` <div class="mb-5"><span class="grey">name: </span>${data.name}</div>`,
...html,
].join(" ");
},
},
tip.value,
settings.value.legend
).merge(node.value);
// line element
link.value = link.value.data(topologyStore.calls, (d: Call) => d.id);
link.value.exit().remove();
link.value = linkElement(link.value.enter()).merge(link.value);
// anchorElement
anchor.value = anchor.value.data(topologyStore.calls, (d: Call) => d.id);
anchor.value.exit().remove();
anchor.value = anchorElement(
anchor.value.enter(),
{
handleLinkClick: handleLinkClick,
tipHtml: (data: Call) => {
const linkClientMetrics: string[] =
settings.value.linkClientMetrics || [];
const linkServerMetrics: string[] =
settings.value.linkServerMetrics || [];
const htmlServer = linkServerMetrics.map((m) => {
const metric = topologyStore.linkServerMetrics[m].values.filter(
(val: { id: string; value: unknown }) => val.id === data.id
)[0];
if (metric) {
const val = m.includes("_sla") ? metric.value / 100 : metric.value;
return ` <div class="mb-5"><span class="grey">${m}: </span>${val}</div>`;
}
});
const htmlClient = linkClientMetrics.map((m) => {
const metric = topologyStore.linkClientMetrics[m].values.filter(
(val: { id: string; value: unknown }) => val.id === data.id
)[0];
if (metric) {
const val = m.includes("_sla") ? metric.value / 100 : metric.value;
return ` <div class="mb-5"><span class="grey">${m}: </span>${val}</div>`;
}
});
const html = [
...htmlServer,
...htmlClient,
`<div><span class="grey">${t(
"detectPoint"
)}:</span>${data.detectPoints.join(" | ")}</div>`,
].join(" ");
return html;
},
},
tip.value
).merge(anchor.value);
// 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
) {
link.loopFactor = -1;
loopMap[j] = 1;
break;
}
}
}
}
async function handleInspect() {
svg.value.selectAll(".topo-svg-graph").remove();
const id = topologyStore.node.id;
topologyStore.setNode(null);
topologyStore.setLink(null);
loading.value = true;
const resp = await topologyStore.getServicesTopology([id]);
loading.value = false;
if (resp.errors) {
ElMessage.error(resp.errors);
}
await init();
update();
}
function handleGoEndpoint(name: string) {
const path = `/dashboard/${dashboardStore.layerId}/Endpoint/${topologyStore.node.id}/${name}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
}
function handleGoInstance(name: string) {
const path = `/dashboard/${dashboardStore.layerId}/ServiceInstance/${topologyStore.node.id}/${name}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
}
function handleGoDashboard(name: string) {
const path = `/dashboard/${dashboardStore.layerId}/Service/${topologyStore.node.id}/${name}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
}
function handleGoAlarm() {
const path = `/alarm`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
}
async function backToTopology() {
svg.value.selectAll(".topo-svg-graph").remove();
loading.value = true;
const resp = await getTopology();
loading.value = false;
if (resp.errors) {
ElMessage.error(resp.errors);
}
await init();
update();
topologyStore.setNode(null);
topologyStore.setLink(null);
}
async function getTopology() {
const ids = selectorStore.services.map((d: Service) => d.id);
const serviceIds =
dashboardStore.entity === EntityType[0].value
? [selectorStore.currentService.id]
: ids;
const resp = await topologyStore.getDepthServiceTopology(
serviceIds,
Number(depth.value)
);
return resp;
}
function setConfig() {
showSetting.value = !showSetting.value;
}
function resize() {
height.value = document.body.clientHeight - 90;
width.value = document.body.clientWidth - 40;
svg.value.attr("height", height.value).attr("width", width.value);
}
function updateSettings(config: any) {
items.value = [
{ id: "inspect", title: "Inspect", func: handleInspect },
{ id: "alarm", title: "Alarm", func: handleGoAlarm },
];
settings.value = config;
for (const item of config.nodeDashboard) {
if (item.scope === EntityType[0].value) {
items.value.push({
id: "dashboard",
title: "Dashboard",
func: handleGoDashboard,
...item,
});
}
if (item.scope === EntityType[2].value) {
items.value.push({
id: "endpoint",
title: "Endpoint",
func: handleGoEndpoint,
...item,
});
}
if (item.scope === EntityType[3].value) {
items.value.push({
id: "instance",
title: "Service Instance",
func: handleGoInstance,
...item,
});
}
}
}
async function freshNodes() {
svg.value.selectAll(".topo-svg-graph").remove();
await init();
update();
}
async function changeDepth(opt: Option[]) {
depth.value = opt[0].value;
await getTopology();
freshNodes();
}
onBeforeUnmount(() => {
window.removeEventListener("resize", resize);
});
</script>
<style lang="scss">
.micro-topo-chart {
position: relative;
.setting {
position: absolute;
top: 70px;
right: 0;
width: 400px;
height: 700px;
background-color: #2b3037;
overflow: auto;
padding: 0 15px;
border-radius: 3px;
color: #ccc;
transition: all 0.5ms linear;
}
.label {
color: #ccc;
display: inline-block;
margin-right: 5px;
}
.operations-list {
position: absolute;
padding: 10px;
color: #333;
cursor: pointer;
background-color: #fff;
border-radius: 3px;
span {
display: block;
height: 30px;
width: 140px;
line-height: 30px;
text-align: center;
}
span:hover {
color: #409eff;
background-color: #eee;
}
}
.tool {
position: absolute;
top: 22px;
right: 0;
}
.switch-icon {
cursor: pointer;
transition: all 0.5ms linear;
background-color: #252a2f99;
color: #ddd;
display: inline-block;
padding: 5px 8px 8px;
border-radius: 3px;
}
.topo-svg {
display: block;
width: 100%;
}
.topo-line {
stroke-linecap: round;
stroke-width: 3px;
stroke-dasharray: 13 7;
fill: none;
animation: topo-dash 0.5s linear infinite;
}
.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;
}
}
.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 {
from {
stroke-dashoffset: 20;
}
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@@ -0,0 +1,274 @@
<!-- 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. -->
<template>
<div class="tool">
<span v-show="dashboardStore.entity === EntityType[2].value">
<span class="label">{{ t("currentDepth") }}</span>
<Selector
class="inputs"
:value="depth"
:options="DepthList"
placeholder="Select a option"
@change="changeDepth"
/>
</span>
<span class="switch-icon ml-5" title="Settings" @click="setConfig">
<Icon size="middle" iconName="settings" />
</span>
<span
class="switch-icon ml-5"
title="Back to overview topology"
@click="backToTopology"
>
<Icon size="middle" iconName="keyboard_backspace" />
</span>
<div class="settings" v-show="showSettings">
<Settings @update="updateConfig" />
</div>
</div>
<div
class="sankey"
:style="`height:${height}px;width:${width}px;`"
v-loading="loading"
@click="handleClick"
>
<Sankey @click="selectNodeLink" />
</div>
<div
class="operations-list"
v-if="topologyStore.node"
:style="{
top: operationsPos.y + 'px',
left: operationsPos.x + 'px',
}"
>
<i v-for="(item, index) of items" :key="index" @click="item.func">
<span
v-if="
['alarm', 'inspect'].includes(item.id) ||
(item.id === 'dashboard' && settings.nodeDashboard)
"
>
{{ item.title }}
</span>
</i>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import { ref, onMounted, reactive } from "vue";
import { Option } from "@/types/app";
import { useTopologyStore } from "@/store/modules/topology";
import { useDashboardStore } from "@/store/modules/dashboard";
import { useSelectorStore } from "@/store/modules/selectors";
import { EntityType, DepthList } from "../../../data";
import { ElMessage } from "element-plus";
import Sankey from "./Sankey.vue";
import Settings from "./Settings.vue";
import router from "@/router";
const { t } = useI18n();
const dashboardStore = useDashboardStore();
const selectorStore = useSelectorStore();
const topologyStore = useTopologyStore();
const loading = ref<boolean>(false);
const height = ref<number>(document.body.clientHeight - 150);
const width = ref<number>(document.body.clientWidth - 40);
const showSettings = ref<boolean>(false);
const settings = ref<any>({});
const operationsPos = reactive<{ x: number; y: number }>({ x: NaN, y: NaN });
const depth = ref<string>(topologyStore.defaultDepth);
const items = [
{ id: "inspect", title: "Inspect", func: inspect },
{ id: "dashboard", title: "View Dashboard", func: goDashboard },
{ id: "alarm", title: "View Alarm", func: goAlarm },
];
onMounted(async () => {
loadTopology(selectorStore.currentPod && selectorStore.currentPod.id);
});
async function loadTopology(id: string) {
loading.value = true;
const resp = await getTopology(id);
loading.value = false;
if (resp && resp.errors) {
ElMessage.error(resp.errors);
}
}
function inspect() {
const id = topologyStore.node.id;
topologyStore.setNode(null);
topologyStore.setLink(null);
loadTopology(id);
}
function goAlarm() {
const path = `/alarm`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
topologyStore.setNode(null);
}
function goDashboard() {
const entity =
dashboardStore.entity === EntityType[2].value
? EntityType[2].value
: EntityType[3].value;
const path = `/dashboard/${dashboardStore.layerId}/${entity}/${topologyStore.node.serviceId}/${topologyStore.node.id}/${settings.value.nodeDashboard}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
topologyStore.setNode(null);
}
function setConfig() {
topologyStore.setNode(null);
showSettings.value = !showSettings.value;
}
function updateConfig(config: any) {
settings.value = config;
}
function backToTopology() {
loadTopology(selectorStore.currentPod.id);
topologyStore.setNode(null);
}
function selectNodeLink(d: any) {
if (d.dataType === "edge") {
topologyStore.setNode(null);
topologyStore.setLink(d.data);
if (!settings.value.linkDashboard) {
return;
}
const { sourceObj, targetObj } = d.data;
const entity =
dashboardStore.entity === EntityType[2].value
? EntityType[6].value
: EntityType[5].value;
const path = `/dashboard/${dashboardStore.layerId}/${entity}/${sourceObj.serviceId}/${sourceObj.id}/${targetObj.serviceId}/${targetObj.id}/${settings.value.linkDashboard}`;
const routeUrl = router.resolve({ path });
window.open(routeUrl.href, "_blank");
return;
}
topologyStore.setNode(d.data);
topologyStore.setLink(null);
operationsPos.x = d.event.event.clientX;
operationsPos.y = d.event.event.clientY;
}
async function changeDepth(opt: Option[]) {
depth.value = opt[0].value;
loadTopology(selectorStore.currentPod.id);
}
async function getTopology(id: string) {
let resp;
switch (dashboardStore.entity) {
case EntityType[2].value:
resp = await topologyStore.updateEndpointTopology(
[id],
Number(depth.value)
);
break;
case EntityType[4].value:
resp = await topologyStore.getInstanceTopology();
break;
}
return resp;
}
function handleClick(event: any) {
if (event.target.nodeName === "svg") {
topologyStore.setNode(null);
topologyStore.setLink(null);
}
}
</script>
<style lang="scss" scoped>
.sankey {
margin-top: 10px;
background-color: #333840;
color: #ddd;
}
.settings {
position: absolute;
top: 40px;
right: 0;
width: 400px;
height: 700px;
background-color: #2b3037;
overflow: auto;
padding: 0 15px;
border-radius: 3px;
color: #ccc;
transition: all 0.5ms linear;
z-index: 99;
text-align: left;
}
.tool {
text-align: right;
margin-top: 10px;
position: relative;
}
.switch-icon {
width: 30px;
height: 30px;
line-height: 30px;
text-align: center;
cursor: pointer;
transition: all 0.5ms linear;
background-color: #252a2f99;
color: #ddd;
display: inline-block;
border-radius: 3px;
}
.label {
color: #ccc;
display: inline-block;
margin-right: 5px;
}
.operations-list {
position: absolute;
padding: 10px;
color: #333;
cursor: pointer;
background-color: #fff;
border-radius: 3px;
span {
display: block;
height: 30px;
width: 140px;
line-height: 30px;
text-align: center;
}
span:hover {
color: #409eff;
background-color: #eee;
}
i {
font-style: normal;
}
}
</style>

View File

@@ -0,0 +1,131 @@
<!-- 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. -->
<template>
<Graph :option="option" @select="clickChart" />
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { useTopologyStore } from "@/store/modules/topology";
import { Node, Call } from "@/types/topology";
/*global defineEmits */
const emit = defineEmits(["click"]);
const topologyStore = useTopologyStore();
const option = computed(() => getOption());
function getOption() {
return {
tooltip: {
trigger: "item",
},
series: {
type: "sankey",
left: 40,
top: 20,
right: 300,
bottom: 40,
emphasis: { focus: "adjacency" },
data: topologyStore.nodes,
links: topologyStore.calls,
label: {
color: "#fff",
formatter: (param: any) => param.data.name,
},
color: [
"#3fe1da",
"#6be6c1",
"#3fcfdc",
"#626c91",
"#3fbcde",
"#a0a7e6",
"#3fa9e1",
"#96dee8",
"#bf99f8",
],
itemStyle: {
borderWidth: 0,
},
lineStyle: {
color: "source",
opacity: 0.12,
},
tooltip: {
position: "bottom",
formatter: (param: { data: any; dataType: string }) => {
if (param.dataType === "edge") {
return linkTooltip(param.data);
}
return nodeTooltip(param.data);
},
},
},
};
}
function linkTooltip(data: Call) {
const clientMetrics: string[] = Object.keys(topologyStore.linkClientMetrics);
const serverMetrics: string[] = Object.keys(topologyStore.linkServerMetrics);
const htmlServer = serverMetrics.map((m) => {
const metric = topologyStore.linkServerMetrics[m].values.filter(
(val: { id: string; value: unknown }) => val.id === data.id
)[0];
if (metric) {
const val = m.includes("_sla") ? metric.value / 100 : metric.value;
return ` <div><span>${m}: </span>${val}</div>`;
}
});
const htmlClient = clientMetrics.map((m) => {
const metric = topologyStore.linkClientMetrics[m].values.filter(
(val: { id: string; value: unknown }) => val.id === data.id
)[0];
if (metric) {
const val = m.includes("_sla") ? metric.value / 100 : metric.value;
return ` <div><span>${m}: </span>${val}</div>`;
}
});
const html = [
`<div>${data.sourceObj.serviceName} -> ${data.targetObj.serviceName}</div>`,
...htmlServer,
...htmlClient,
].join(" ");
return html;
}
function nodeTooltip(data: Node) {
const nodeMetrics: string[] = Object.keys(topologyStore.nodeMetrics);
const html = nodeMetrics.map((m) => {
const metric =
topologyStore.nodeMetrics[m].values.filter(
(val: { id: string; value: unknown }) => val.id === data.id
)[0] || {};
const val = m.includes("_sla") ? metric.value / 100 : metric.value;
return ` <div><span>${m}: </span>${val}</div>`;
});
return [` <div><span>name: </span>${data.serviceName}</div>`, ...html].join(
" "
);
}
function clickChart(param: any) {
emit("click", param);
}
</script>
<style lang="scss" scoped>
.sankey {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,402 @@
<!-- 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. -->
<template>
<div class="link-settings">
<h5 class="title">{{ t("callSettings") }}</h5>
<div class="label">{{ t("linkDashboard") }}</div>
<el-input
v-model="states.linkDashboard"
placeholder="Please input a dashboard name for calls"
@change="updateSettings"
size="small"
class="inputs"
/>
<div class="label">{{ t("linkServerMetrics") }}</div>
<Selector
class="inputs"
:multiple="true"
:value="states.linkServerMetrics"
:options="states.linkMetricList"
size="small"
placeholder="Select metrics"
@change="changeLinkServerMetrics"
/>
<span v-show="dashboardStore.entity !== EntityType[2].value">
<div class="label">
{{ t("linkClientMetrics") }}
</div>
<Selector
class="inputs"
:multiple="true"
:value="states.linkClientMetrics"
:options="states.linkMetricList"
size="small"
placeholder="Select metrics"
@change="changeLinkClientMetrics"
/>
</span>
</div>
<div class="node-settings">
<h5 class="title">{{ t("nodeSettings") }}</h5>
<div class="label">{{ t("nodeDashboard") }}</div>
<el-input
v-show="!isServer"
v-model="states.nodeDashboard"
placeholder="Please input a dashboard name for nodes"
@change="updateSettings"
size="small"
class="inputs"
/>
<div
v-show="isServer"
v-for="(item, index) in items"
:key="index"
class="metric-item"
>
<Selector
:value="item.scope"
:options="ScopeType"
size="small"
placeholder="Select a scope"
@change="changeScope(index, $event)"
class="item mr-5"
/>
<el-input
v-model="item.dashboard"
placeholder="Please input a dashboard name for nodes"
@change="updateNodeDashboards(index, $event)"
size="small"
class="item mr-5"
/>
<span>
<Icon
class="cp mr-5"
v-show="items.length > 1"
iconName="remove_circle_outline"
size="middle"
@click="deleteItem(index)"
/>
<Icon
class="cp"
v-show="index === items.length - 1 && items.length < 5"
iconName="add_circle_outlinecontrol_point"
size="middle"
@click="addItem"
/>
</span>
</div>
<div class="label">{{ t("nodeMetrics") }}</div>
<Selector
class="inputs"
:multiple="true"
:value="states.nodeMetrics"
:options="states.nodeMetricList"
size="small"
placeholder="Select metrics"
@change="changeNodeMetrics"
/>
</div>
<div class="legend-settings" v-show="isServer">
<h5 class="title">{{ t("legendSettings") }}</h5>
<div class="label">{{ t("conditions") }}</div>
<div v-for="(metric, index) of legend.metric" :key="metric.name + index">
<Selector
class="item"
:value="metric.name"
:options="states.nodeMetricList"
size="small"
placeholder="Select a metric"
@change="changeLegend(LegendOpt.NAME, $event, index)"
/>
<Selector
class="input-small"
:value="metric.condition"
:options="MetricConditions"
size="small"
placeholder="Select a condition"
@change="changeLegend(LegendOpt.CONDITION, $event, index)"
/>
<el-input
v-model="metric.value"
placeholder="Please input a value"
@change="changeLegend(LegendOpt.VALUE, $event, index)"
size="small"
class="item"
/>
<span>
<Icon
class="cp delete"
iconName="remove_circle_outline"
size="middle"
@click="deleteMetric(index)"
v-show="legend.metric.length > 1"
/>
<Icon
class="cp"
iconName="add_circle_outlinecontrol_point"
size="middle"
v-show="
index === legend.metric.length - 1 && legend.metric.length < 5
"
@click="addMetric"
/>
</span>
<div v-show="index !== legend.metric.length - 1">&&</div>
</div>
<!-- <div class="label">{{ t("conditions") }}</div>
<Selector
class="inputs"
:value="legend.condition"
:options="LegendConditions"
size="small"
placeholder="Select a condition"
@change="changeCondition"
/> -->
<el-button
@click="setLegend"
class="legend-btn"
size="small"
type="primary"
>
{{ t("setLegend") }}
</el-button>
</div>
</template>
<script lang="ts" setup>
import { reactive } from "vue";
import { useI18n } from "vue-i18n";
import { useDashboardStore } from "@/store/modules/dashboard";
import { useTopologyStore } from "@/store/modules/topology";
import { ElMessage } from "element-plus";
import { MetricCatalog, ScopeType, MetricConditions } from "../../../data";
import { Option } from "@/types/app";
import { useQueryTopologyMetrics } from "@/hooks/useProcessor";
import { Node, Call } from "@/types/topology";
import { EntityType, LegendOpt } from "../../../data";
/*global defineEmits */
const emit = defineEmits(["update", "updateNodes"]);
const { t } = useI18n();
const dashboardStore = useDashboardStore();
const topologyStore = useTopologyStore();
const items = reactive<
{
scope: string;
dashboard: string;
}[]
>([{ scope: "", dashboard: "" }]);
const states = reactive<{
linkDashboard: string;
nodeDashboard: {
scope: string;
dashboard: string;
}[];
linkServerMetrics: string[];
linkClientMetrics: string[];
nodeMetrics: string[];
nodeMetricList: Option[];
linkMetricList: Option[];
}>({
linkDashboard: "",
nodeDashboard: [],
linkServerMetrics: [],
linkClientMetrics: [],
nodeMetrics: [],
nodeMetricList: [],
linkMetricList: [],
});
const isServer = [EntityType[0].value, EntityType[1].value].includes(
dashboardStore.entity
);
const legend = reactive<{
metric: { name: string; condition: string; value: string }[];
}>({ metric: [{ name: "", condition: "", value: "" }] });
getMetricList();
async function getMetricList() {
const json = await dashboardStore.fetchMetricList();
if (json.errors) {
ElMessage.error(json.errors);
return;
}
const entity =
dashboardStore.entity === EntityType[1].value
? EntityType[0].value
: dashboardStore.entity === EntityType[4].value
? EntityType[3].value
: dashboardStore.entity;
states.nodeMetricList = (json.data.metrics || []).filter(
(d: { catalog: string }) => entity === (MetricCatalog as any)[d.catalog]
);
const e =
dashboardStore.entity === EntityType[1].value
? EntityType[0].value
: dashboardStore.entity === EntityType[4].value
? EntityType[3].value
: dashboardStore.entity;
states.linkMetricList = (json.data.metrics || []).filter(
(d: { catalog: string }) =>
e + "Relation" === (MetricCatalog as any)[d.catalog]
);
}
async function setLegend() {
const metrics = legend.metric.filter(
(d: any) => d.name && d.value && d.condition
);
const names = metrics.map((d: any) => d.name);
emit("update", {
linkDashboard: states.linkDashboard,
nodeDashboard: isServer
? items.filter((d: { scope: string; dashboard: string }) => d.dashboard)
: states.nodeDashboard,
linkServerMetrics: states.linkServerMetrics,
linkClientMetrics: states.linkClientMetrics,
nodeMetrics: states.nodeMetrics,
legend: metrics,
});
const ids = topologyStore.nodes.map((d: Node) => d.id);
const param = await useQueryTopologyMetrics(names, ids);
const res = await topologyStore.getLegendMetrics(param);
if (res.errors) {
ElMessage.error(res.errors);
}
emit("updateNodes");
}
function changeLegend(type: string, opt: any, index: number) {
(legend.metric[index] as any)[type] = opt[0].value || opt;
}
function changeScope(index: number, opt: Option[]) {
items[index].scope = opt[0].value;
items[index].dashboard = "";
}
function updateNodeDashboards(index: number, content: string) {
items[index].dashboard = content;
updateSettings();
}
function addItem() {
items.push({ scope: "", dashboard: "" });
}
function deleteItem(index: number) {
items.splice(index, 1);
updateSettings();
}
function updateSettings() {
emit("update", {
linkDashboard: states.linkDashboard,
nodeDashboard: isServer
? items.filter((d: { scope: string; dashboard: string }) => d.dashboard)
: states.nodeDashboard,
linkServerMetrics: states.linkServerMetrics,
linkClientMetrics: states.linkClientMetrics,
nodeMetrics: states.nodeMetrics,
legend: legend.metric,
});
}
async function changeLinkServerMetrics(options: Option[]) {
states.linkServerMetrics = options.map((d: Option) => d.value);
updateSettings();
if (!states.linkServerMetrics.length) {
topologyStore.setLinkServerMetrics({});
return;
}
const idsS = topologyStore.calls
.filter((i: Call) => i.detectPoints.includes("SERVER"))
.map((b: Call) => b.id);
const param = await useQueryTopologyMetrics(states.linkServerMetrics, idsS);
const res = await topologyStore.getCallServerMetrics(param);
if (res.errors) {
ElMessage.error(res.errors);
}
}
async function changeLinkClientMetrics(options: Option[]) {
states.linkClientMetrics = options.map((d: Option) => d.value);
updateSettings();
if (!states.linkClientMetrics.length) {
topologyStore.setLinkClientMetrics({});
return;
}
const idsC = topologyStore.calls
.filter((i: Call) => i.detectPoints.includes("CLIENT"))
.map((b: Call) => b.id);
const param = await useQueryTopologyMetrics(states.linkClientMetrics, idsC);
const res = await topologyStore.getCallClientMetrics(param);
if (res.errors) {
ElMessage.error(res.errors);
}
}
async function changeNodeMetrics(options: Option[]) {
states.nodeMetrics = options.map((d: Option) => d.value);
updateSettings();
if (!states.nodeMetrics.length) {
topologyStore.setNodeMetrics({});
return;
}
const ids = topologyStore.nodes.map((d: Node) => d.id);
const param = await useQueryTopologyMetrics(states.nodeMetrics, ids);
const res = await topologyStore.getNodeMetrics(param);
if (res.errors) {
ElMessage.error(res.errors);
}
}
function deleteMetric(index: number) {
legend.metric.splice(index, 1);
}
function addMetric() {
legend.metric.push({ name: "", condition: "", value: "" });
}
</script>
<style lang="scss" scoped>
.link-settings {
margin-bottom: 20px;
}
.inputs {
margin-top: 8px;
width: 370px;
}
.item {
width: 130px;
margin-top: 5px;
}
.input-small {
width: 45px;
margin: 0 3px;
}
.title {
margin-bottom: 0;
}
.label {
font-size: 12px;
margin-top: 10px;
}
.legend-btn {
margin: 20px 0;
cursor: pointer;
}
.delete {
margin: 0 3px;
}
</style>

View File

@@ -0,0 +1,53 @@
/**
* 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";
export default function topoLegend(
graph: any,
clientHeight: number,
clientWidth: number,
config: any
) {
for (const item of ["CUBE", "CUBEERROR"]) {
graph
.append("image")
.attr("width", 30)
.attr("height", 30)
.attr("x", clientWidth - (item === "CUBEERROR" ? 340 : 440))
.attr("y", clientHeight - 50)
.attr("xlink:href", () =>
item === "CUBEERROR" ? icons.CUBEERROR : icons.CUBE
);
graph
.append("text")
.attr("x", clientWidth - (item === "CUBEERROR" ? 310 : 410))
.attr("y", clientHeight - 30)
.text(() => {
const l = config || [];
const str = l
.map((d: any) => `${d.name} ${d.condition} ${d.value}`)
.join(" and ");
return item === "CUBEERROR"
? config
? `Unhealthy (${str})`
: "Unhealthy"
: "Healthy";
})
.style("fill", "#efeff1")
.style("font-size", "11px");
}
}

View File

@@ -0,0 +1,60 @@
/**
* 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

@@ -0,0 +1,96 @@
/**
* 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 { 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: any, d: Node) {
tip.html(funcs.tipHtml).show(d, this);
})
.on("mouseout", function () {
tip.hide(this);
})
.on("click", (event: any, d: Node | any) => {
event.stopPropagation();
event.preventDefault();
funcs.handleNodeClick(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) {
const val = l.name.includes("_sla") ? d[l.name] / 100 : d[l.name];
if (l.condition === "<") {
c = c && val < Number(l.value);
} else {
c = c && val > 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

@@ -0,0 +1,56 @@
/**
* 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,
dataNodes: any,
dataLinks: any,
ticked: any
) => {
const simulation = d3
.forceSimulation(dataNodes)
.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(dataLinks).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();
}
});
};

View File

@@ -0,0 +1,28 @@
/**
* 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 default (d3: any, graph: any) =>
d3
.zoom()
.scaleExtent([0.3, 10])
.on("zoom", (d: any) => {
graph
.attr("transform", d3.zoomTransform(graph.node()))
.attr(
`translate(${d.transform.x},${d.transform.y})scale(${d.transform.k})`
);
});