mirror of
https://github.com/apache/skywalking-booster-ui.git
synced 2025-07-18 20:44:08 +00:00
draw line
This commit is contained in:
parent
7dfc87324d
commit
57e701ee4e
@ -70,7 +70,7 @@ export const networkProfilingStore = defineStore({
|
|||||||
},
|
},
|
||||||
setTopology(data: { nodes: ProcessNode[]; calls: Call[] }) {
|
setTopology(data: { nodes: ProcessNode[]; calls: Call[] }) {
|
||||||
const obj = {} as any;
|
const obj = {} as any;
|
||||||
const calls = (data.calls || []).reduce((prev: Call[], next: Call) => {
|
let calls = (data.calls || []).reduce((prev: Call[], next: Call) => {
|
||||||
if (!obj[next.id]) {
|
if (!obj[next.id]) {
|
||||||
obj[next.id] = true;
|
obj[next.id] = true;
|
||||||
next.value = next.value || 1;
|
next.value = next.value || 1;
|
||||||
@ -87,7 +87,16 @@ export const networkProfilingStore = defineStore({
|
|||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
}, []);
|
}, []);
|
||||||
|
calls = calls.map((d: any) => {
|
||||||
|
d.sourceId = d.source;
|
||||||
|
d.targetId = d.target;
|
||||||
|
d.source = d.sourceObj;
|
||||||
|
d.target = d.targetObj;
|
||||||
|
delete d.sourceObj;
|
||||||
|
delete d.targetObj;
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
console.log(calls);
|
||||||
this.calls = calls;
|
this.calls = calls;
|
||||||
this.nodes = data.nodes;
|
this.nodes = data.nodes;
|
||||||
},
|
},
|
||||||
|
@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 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-call")
|
||||||
|
.attr("marker-end", "url(#arrow)")
|
||||||
|
.attr("stroke", "#afc4dd")
|
||||||
|
.attr("d", (d: any) => {
|
||||||
|
const controlPos = computeControlPoint(
|
||||||
|
[d.source.x, d.source.y - 5],
|
||||||
|
[d.target.x, d.target.y - 5],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
"M" +
|
||||||
|
d.source.x +
|
||||||
|
" " +
|
||||||
|
(d.source.y - 5) +
|
||||||
|
" " +
|
||||||
|
"Q" +
|
||||||
|
controlPos[0] +
|
||||||
|
" " +
|
||||||
|
controlPos[1] +
|
||||||
|
" " +
|
||||||
|
d.target.x +
|
||||||
|
" " +
|
||||||
|
(d.target.y - 5)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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", "10")
|
||||||
|
.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", "#afc4dd");
|
||||||
|
return arrow;
|
||||||
|
};
|
||||||
|
function computeControlPoint(ps: number[], pe: number[], arc = 0.5) {
|
||||||
|
const deltaX = pe[0] - ps[0];
|
||||||
|
const deltaY = pe[1] - ps[1];
|
||||||
|
const theta = Math.atan(deltaY / deltaX);
|
||||||
|
const len = (Math.sqrt(deltaX * deltaX + deltaY * deltaY) / 2) * arc;
|
||||||
|
const newTheta = theta - Math.PI / 2;
|
||||||
|
return [
|
||||||
|
(ps[0] + pe[0]) / 2 + len * Math.cos(newTheta),
|
||||||
|
(ps[1] + pe[1]) / 2 + len * Math.sin(newTheta),
|
||||||
|
];
|
||||||
|
}
|
@ -37,16 +37,16 @@ export default (d3: any, graph: any, funcs: any, tip: any) => {
|
|||||||
.append("image")
|
.append("image")
|
||||||
.attr("width", 35)
|
.attr("width", 35)
|
||||||
.attr("height", 35)
|
.attr("height", 35)
|
||||||
.attr("x", (d: any) => d.x)
|
.attr("x", (d: any) => d.x - 15)
|
||||||
.attr("y", (d: any) => d.y)
|
.attr("y", (d: any) => d.y - 15)
|
||||||
.attr("style", "cursor: move;")
|
.attr("style", "cursor: move;")
|
||||||
.attr("xlink:href", icons.CUBE);
|
.attr("xlink:href", icons.CUBE);
|
||||||
nodeEnter
|
nodeEnter
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("fill", "#000")
|
.attr("fill", "#000")
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.attr("x", (d: any) => d.x + 15)
|
.attr("x", (d: any) => d.x + 5)
|
||||||
.attr("y", (d: any) => d.y + 42)
|
.attr("y", (d: any) => d.y + 28)
|
||||||
.text((d: { name: string }) =>
|
.text((d: { name: string }) =>
|
||||||
d.name.length > 10 ? `${d.name.substring(0, 10)}...` : d.name
|
d.name.length > 10 ? `${d.name.substring(0, 10)}...` : d.name
|
||||||
);
|
);
|
@ -13,16 +13,16 @@ 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 ref="chart" class="micro-topo-chart"></div>
|
<div ref="chart" class="process-topo"></div>
|
||||||
<div
|
<div
|
||||||
class="switch-icon ml-5"
|
class="switch-icon-edit ml-5"
|
||||||
title="Settings"
|
title="Settings"
|
||||||
@click="setConfig"
|
@click="setConfig"
|
||||||
v-if="dashboardStore.editMode"
|
v-if="dashboardStore.editMode"
|
||||||
>
|
>
|
||||||
<Icon size="middle" iconName="settings" />
|
<Icon size="middle" iconName="settings" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting" v-if="showSettings && dashboardStore.editMode">
|
<div class="process-setting" v-if="showSettings && dashboardStore.editMode">
|
||||||
<Settings @update="updateSettings" @updateNodes="freshNodes" />
|
<Settings @update="updateSettings" @updateNodes="freshNodes" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -37,12 +37,8 @@ import { useNetworkProfilingStore } from "@/store/modules/network-profiling";
|
|||||||
import { useDashboardStore } from "@/store/modules/dashboard";
|
import { useDashboardStore } from "@/store/modules/dashboard";
|
||||||
import { useSelectorStore } from "@/store/modules/selectors";
|
import { useSelectorStore } from "@/store/modules/selectors";
|
||||||
import d3tip from "d3-tip";
|
import d3tip from "d3-tip";
|
||||||
import {
|
import { linkElement, anchorElement, arrowMarker } from "./Graph/linkProcess";
|
||||||
linkElement,
|
import nodeElement from "./Graph/nodeProcess";
|
||||||
anchorElement,
|
|
||||||
arrowMarker,
|
|
||||||
} from "../../components/D3Graph/linkElement";
|
|
||||||
import nodeElement from "../../components/D3Graph/nodeProcess";
|
|
||||||
import { Call } from "@/types/topology";
|
import { Call } from "@/types/topology";
|
||||||
// import zoom from "../../components/D3Graph/zoom";
|
// import zoom from "../../components/D3Graph/zoom";
|
||||||
import { ProcessNode } from "@/types/ebpf";
|
import { ProcessNode } from "@/types/ebpf";
|
||||||
@ -109,7 +105,7 @@ function drawGraph() {
|
|||||||
.attr("transform", `translate(300, 300)`);
|
.attr("transform", `translate(300, 300)`);
|
||||||
graph.value.call(tip.value);
|
graph.value.call(tip.value);
|
||||||
node.value = graph.value.append("g").selectAll(".topo-node");
|
node.value = graph.value.append("g").selectAll(".topo-node");
|
||||||
link.value = graph.value.append("g").selectAll(".topo-line");
|
link.value = graph.value.append("g").selectAll(".topo-call");
|
||||||
anchor.value = graph.value.append("g").selectAll(".topo-line-anchor");
|
anchor.value = graph.value.append("g").selectAll(".topo-line-anchor");
|
||||||
arrow.value = graph.value.append("g").selectAll(".topo-line-arrow");
|
arrow.value = graph.value.append("g").selectAll(".topo-line-arrow");
|
||||||
// svg.value.call(zoom(d3, graph.value));
|
// svg.value.call(zoom(d3, graph.value));
|
||||||
@ -157,7 +153,7 @@ function update() {
|
|||||||
if (!node.value || !link.value) {
|
if (!node.value || !link.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const obj: any = (chart.value && chart.value.getBoundingClientRect()) || {};
|
// const obj: any = (chart.value && chart.value.getBoundingClientRect()) || {};
|
||||||
const p = {
|
const p = {
|
||||||
hexagonParam: [27, 0.04, 5, 0.04, 0],
|
hexagonParam: [27, 0.04, 5, 0.04, 0],
|
||||||
count: 1,
|
count: 1,
|
||||||
@ -180,7 +176,7 @@ function update() {
|
|||||||
const centers = hexGrid(p.count, 100, origin); // cube centers
|
const centers = hexGrid(p.count, 100, origin); // cube centers
|
||||||
const cubeCenters = [];
|
const cubeCenters = [];
|
||||||
for (let i = 0; i < centers.length; i++) {
|
for (let i = 0; i < centers.length; i++) {
|
||||||
// const polygon = createPolygon(90, 6, 0);
|
// const polygon = createPolygon(100, 6, 0);
|
||||||
// const vertices: any = []; // a hexagon vertices
|
// const vertices: any = []; // a hexagon vertices
|
||||||
// for (let v = 0; v < polygon.length; v++) {
|
// for (let v = 0; v < polygon.length; v++) {
|
||||||
// vertices.push([
|
// vertices.push([
|
||||||
@ -196,9 +192,27 @@ function update() {
|
|||||||
// .attr("stroke", "#ccc")
|
// .attr("stroke", "#ccc")
|
||||||
// .attr("stroke-width", 1)
|
// .attr("stroke-width", 1)
|
||||||
// .style("fill", "none");
|
// .style("fill", "none");
|
||||||
const c = hexGrid(p.count, 25, centers[i]);
|
const c = hexGrid(p.count, 30, centers[i]);
|
||||||
cubeCenters.push(...c);
|
cubeCenters.push(...c);
|
||||||
}
|
}
|
||||||
|
// for (let i = 0; i < cubeCenters.length; i++) {
|
||||||
|
// const polygon = createPolygon(30, 6, 0);
|
||||||
|
// const vertices: any = []; // a hexagon vertices
|
||||||
|
// for (let v = 0; v < polygon.length; v++) {
|
||||||
|
// vertices.push([
|
||||||
|
// cubeCenters[i][0] + polygon[v][0],
|
||||||
|
// cubeCenters[i][1] + polygon[v][1],
|
||||||
|
// ]);
|
||||||
|
// }
|
||||||
|
// const linePath = d3.line();
|
||||||
|
// linePath.curve(d3.curveLinearClosed);
|
||||||
|
// graph.value
|
||||||
|
// .append("path")
|
||||||
|
// .attr("d", linePath(vertices))
|
||||||
|
// .attr("stroke", "#ccc")
|
||||||
|
// .attr("stroke-width", 1)
|
||||||
|
// .style("fill", "none");
|
||||||
|
// }
|
||||||
shuffleArray(cubeCenters);
|
shuffleArray(cubeCenters);
|
||||||
const pos = hexGrid(p.count, 30, [p.radius * 2 + 20]); // cube centers
|
const pos = hexGrid(p.count, 30, [p.radius * 2 + 20]); // cube centers
|
||||||
const nodeArr = networkProfilingStore.nodes.filter(
|
const nodeArr = networkProfilingStore.nodes.filter(
|
||||||
@ -211,7 +225,6 @@ function update() {
|
|||||||
nodeArr[v].y = y;
|
nodeArr[v].y = y;
|
||||||
}
|
}
|
||||||
node.value = node.value.data(nodeArr, (d: ProcessNode) => d.id);
|
node.value = node.value.data(nodeArr, (d: ProcessNode) => d.id);
|
||||||
|
|
||||||
node.value.exit().remove();
|
node.value.exit().remove();
|
||||||
node.value = nodeElement(
|
node.value = nodeElement(
|
||||||
d3,
|
d3,
|
||||||
@ -223,6 +236,50 @@ function update() {
|
|||||||
},
|
},
|
||||||
tip.value
|
tip.value
|
||||||
).merge(node.value);
|
).merge(node.value);
|
||||||
|
// line element
|
||||||
|
const obj = {} as any;
|
||||||
|
const calls = networkProfilingStore.calls.reduce((prev: any[], next: any) => {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
obj[next.sourceId + next.targetId] && obj[next.targetId + next.sourceId]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
obj[next.sourceId + next.targetId] = true;
|
||||||
|
obj[next.targetId + next.sourceId] = true;
|
||||||
|
prev.push(next);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
link.value = link.value.data(calls, (d: Call) => d.id);
|
||||||
|
link.value.exit().remove();
|
||||||
|
link.value = linkElement(link.value.enter()).merge(link.value);
|
||||||
|
// anchorElement
|
||||||
|
// anchor.value = anchor.value.data(
|
||||||
|
// networkProfilingStore.calls,
|
||||||
|
// (d: Call) => d.id
|
||||||
|
// );
|
||||||
|
// anchor.value.exit().remove();
|
||||||
|
// anchor.value = anchorElement(
|
||||||
|
// anchor.value.enter(),
|
||||||
|
// {
|
||||||
|
// handleLinkClick: handleLinkClick,
|
||||||
|
// tipHtml: (data: Call) => {
|
||||||
|
// const html = `<div><span class="grey">${t(
|
||||||
|
// "detectPoint"
|
||||||
|
// )}:</span>${data.detectPoints.join(" | ")}</div>`;
|
||||||
|
// return html;
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// tip.value
|
||||||
|
// ).merge(anchor.value);
|
||||||
|
// arrow marker
|
||||||
|
arrow.value = arrow.value.data(
|
||||||
|
networkProfilingStore.calls,
|
||||||
|
(d: Call) => d.id
|
||||||
|
);
|
||||||
|
arrow.value.exit().remove();
|
||||||
|
arrow.value = arrowMarker(arrow.value.enter()).merge(arrow.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shuffleArray(array: number[][]) {
|
function shuffleArray(array: number[][]) {
|
||||||
@ -261,27 +318,6 @@ function handleLinkClick(event: any, d: Call) {
|
|||||||
window.open(routeUrl.href, "_blank");
|
window.open(routeUrl.href, "_blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 updateSettings(config: any) {
|
function updateSettings(config: any) {
|
||||||
config.value = config;
|
config.value = config;
|
||||||
}
|
}
|
||||||
@ -324,8 +360,8 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss">
|
||||||
.micro-topo-chart {
|
.process-topo {
|
||||||
width: calc(100% - 10px);
|
width: calc(100% - 10px);
|
||||||
margin: 0 5px 5px 0;
|
margin: 0 5px 5px 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -340,7 +376,7 @@ watch(
|
|||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-icon {
|
.switch-icon-edit {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.5ms linear;
|
transition: all 0.5ms linear;
|
||||||
background-color: #252a2f99;
|
background-color: #252a2f99;
|
||||||
@ -353,7 +389,7 @@ watch(
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting {
|
.process-setting {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 65px;
|
top: 65px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
@ -366,4 +402,22 @@ watch(
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
transition: all 0.5ms linear;
|
transition: all 0.5ms linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-call {
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-dasharray: 13 7;
|
||||||
|
fill: none;
|
||||||
|
animation: topo-dash 0.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes topo-dash {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -94,17 +94,10 @@ import {
|
|||||||
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 d3tip from "d3-tip";
|
||||||
import zoom from "../../components/D3Graph/zoom";
|
import zoom from "./utils/zoom";
|
||||||
import {
|
import { simulationInit, simulationSkip } from "./utils/simulation";
|
||||||
simulationInit,
|
import nodeElement from "./utils/nodeElement";
|
||||||
simulationSkip,
|
import { linkElement, anchorElement, arrowMarker } from "./utils/linkElement";
|
||||||
} from "../../components/D3Graph/simulation";
|
|
||||||
import nodeElement from "../../components/D3Graph/nodeElement";
|
|
||||||
import {
|
|
||||||
linkElement,
|
|
||||||
anchorElement,
|
|
||||||
arrowMarker,
|
|
||||||
} from "../../components/D3Graph/linkElement";
|
|
||||||
import { Node, Call } from "@/types/topology";
|
import { 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";
|
||||||
|
@ -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})`
|
||||||
|
);
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user