feat: enhance trace list graph (#459)

This commit is contained in:
Fine0830 2025-03-28 10:34:01 +08:00 committed by GitHub
parent 0ea8335fee
commit 39b4626317
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 213 additions and 73 deletions

View File

@ -50,7 +50,7 @@ export interface Span {
refs?: Ref[];
}
export type Ref = {
type: string;
type?: string;
parentSegmentId: string;
parentSpanId: number;
traceId: string;
@ -60,13 +60,6 @@ export interface log {
data: Map<string, string>;
}
export interface Ref {
traceId: string;
parentSegmentId: string;
parentSpanId: number;
type: string;
}
export interface StatisticsSpan {
groupRef: StatisticsGroupRef;
maxTime: number;

View File

@ -77,6 +77,7 @@ limitations under the License. -->
border-bottom: 1px solid $border-color-primary;
width: 100%;
overflow: auto;
min-height: 350px;
}
.log-header {

View File

@ -15,7 +15,24 @@ limitations under the License. -->
<Icon iconName="spinner" size="sm" />
</div>
<div ref="traceGraph" class="d3-graph"></div>
<el-dialog v-model="showDetail" :destroy-on-close="true" fullscreen @closed="showDetail = false">
<div id="trace-action-box">
<div @click="showDetail = true">Span details</div>
<div v-for="span in parentSpans" :key="span.segmentId" @click="viewParentSpan(span)">
{{ `Parent span: ${span.endpointName} -> Start time: ${visDate(span.startTime)}` }}
</div>
<div v-for="span in refParentSpans" :key="span.segmentId" @click="viewParentSpan(span)">
{{ `Ref to span: ${span.endpointName} -> Start time: ${visDate(span.startTime)}` }}
</div>
</div>
<el-dialog
v-model="showDetail"
width="60%"
center
align-center
:destroy-on-close="true"
@closed="showDetail = false"
v-if="currentSpan?.segmentId"
>
<SpanDetail :currentSpan="currentSpan" />
</el-dialog>
</template>
@ -23,6 +40,7 @@ limitations under the License. -->
import { ref, watch, onBeforeUnmount, onMounted } from "vue";
import type { PropType } from "vue";
import * as d3 from "d3";
import dayjs from "dayjs";
import ListGraph from "../../utils/d3-trace-list";
import TreeGraph from "../../utils/d3-trace-tree";
import type { Span, Ref } from "@/types/trace";
@ -42,11 +60,14 @@ limitations under the License. -->
const showDetail = ref<boolean>(false);
const fixSpansSize = ref<number>(0);
const segmentId = ref<Recordable[]>([]);
const currentSpan = ref<Array<Span>>([]);
const currentSpan = ref<Nullable<Span>>(null);
const refSpans = ref<Array<Ref>>([]);
const tree = ref<Nullable<any>>(null);
const traceGraph = ref<Nullable<HTMLDivElement>>(null);
const parentSpans = ref<Array<Span>>([]);
const refParentSpans = ref<Array<Span>>([]);
const debounceFunc = debounce(draw, 500);
const visDate = (date: number, pattern = "YYYY-MM-DD HH:mm:ss:SSS") => dayjs(date).format(pattern);
defineExpose({
tree,
@ -77,16 +98,54 @@ limitations under the License. -->
d3.selectAll(".d3-tip").remove();
if (props.type === "List") {
tree.value = new ListGraph(traceGraph.value, handleSelectSpan);
tree.value.init({ label: "TRACE_ROOT", children: segmentId.value }, props.data, fixSpansSize.value);
tree.value.init(
{ label: "TRACE_ROOT", children: segmentId.value },
getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }),
fixSpansSize.value,
);
tree.value.draw();
} else {
tree.value = new TreeGraph(traceGraph.value, handleSelectSpan);
tree.value.init({ label: `${props.traceId}`, children: segmentId.value }, props.data);
tree.value.init(
{ label: `${props.traceId}`, children: segmentId.value },
getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }),
);
}
}
function handleSelectSpan(i: Recordable) {
const spans = [];
const refSpans = [];
parentSpans.value = [];
refParentSpans.value = [];
currentSpan.value = i.data;
showDetail.value = true;
if (!currentSpan.value) {
return;
}
for (const ref of currentSpan.value.refs || []) {
refSpans.push(ref);
}
if (currentSpan.value.parentSpanId > -1) {
spans.push({
parentSegmentId: currentSpan.value.segmentId,
parentSpanId: currentSpan.value.parentSpanId,
traceId: currentSpan.value.traceId,
});
}
for (const span of refSpans) {
const item = props.data.find(
(d) => d.segmentId === span.parentSegmentId && d.spanId === span.parentSpanId && d.traceId === span.traceId,
);
item && refParentSpans.value.push(item);
}
for (const span of spans) {
const item = props.data.find(
(d) => d.segmentId === span.parentSegmentId && d.spanId === span.parentSpanId && d.traceId === span.traceId,
);
item && parentSpans.value.push(item);
}
}
function viewParentSpan(span: Recordable) {
tree.value.highlightParents(span);
}
function traverseTree(node: Recordable, spanId: string, segmentId: string, data: Recordable) {
if (!node || node.isBroken) {
@ -272,21 +331,12 @@ limitations under the License. -->
}
}
for (const i in segmentGroup) {
if (segmentGroup[i].refs.length) {
let exit = null;
for (const ref of segmentGroup[i].refs) {
const e = props.data.find(
(i: Recordable) =>
ref.traceId === i.traceId && ref.parentSegmentId === i.segmentId && ref.parentSpanId === i.spanId,
);
if (e) {
exit = e;
}
}
if (exit) {
for (const ref of segmentGroup[i].refs) {
if (!segmentGroup[ref.parentSegmentId]) {
segmentId.value.push(segmentGroup[i]);
}
} else {
}
if (!segmentGroup[i].refs.length && segmentGroup[i].parentSpanId === -1) {
segmentId.value.push(segmentGroup[i]);
}
}
@ -310,6 +360,23 @@ limitations under the License. -->
}
}
}
function getRefsAllNodes(tree: Recordable) {
let nodes = [];
let stack = [tree];
while (stack.length > 0) {
const node = stack.pop();
nodes.push(node);
if (node?.children && node.children.length > 0) {
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}
return nodes;
}
function compare(p: string) {
return (m: Recordable, n: Recordable) => {
const a = m[p];
@ -346,7 +413,7 @@ limitations under the License. -->
},
);
</script>
<style lang="scss" scoped>
<style lang="scss">
.d3-graph {
height: 100%;
}
@ -356,36 +423,41 @@ limitations under the License. -->
fill-opacity: 0;
}
.trace-node-container {
fill: rgb(0 0 0 / 0%);
stroke-width: 5px;
cursor: pointer;
&:hover {
fill: rgb(0 0 0 / 5%);
}
}
.trace-node .node-text {
font: 12.5px sans-serif;
font: 12px sans-serif;
pointer-events: none;
}
.domain {
.trace-node.highlighted .node-text {
font-weight: bold;
fill: #409eff;
}
.trace-node.highlightedParent .node-text {
font-weight: bold;
fill: #409eff;
}
#trace-action-box {
position: absolute;
color: $font-color;
cursor: pointer;
border: var(--sw-topology-border);
border-radius: 3px;
background-color: $theme-background;
padding: 10px 0;
display: none;
}
.time-charts-item {
display: inline-block;
padding: 2px 8px;
border: 1px solid;
font-size: 11px;
border-radius: 4px;
}
div {
height: 30px;
line-height: 30px;
text-align: left;
padding: 0 15px;
}
.dialog-c-text {
white-space: pre;
overflow: auto;
font-family: monospace;
div:hover {
color: $active-color;
background-color: $popper-hover-bg-color;
}
}
</style>

View File

@ -87,7 +87,14 @@ limitations under the License. -->
{{ t("relatedTraceLogs") }}
</el-button>
</div>
<el-dialog v-model="showEventDetail" :destroy-on-close="true" fullscreen @closed="showEventDetail = false">
<el-dialog
v-model="showEventDetail"
width="60%"
center
align-center
:destroy-on-close="true"
@closed="showEventDetail = false"
>
<div>
<div class="mb-10">
<span class="grey title">Name:</span>
@ -115,7 +122,14 @@ limitations under the License. -->
</div>
</div>
</el-dialog>
<el-dialog v-model="showRelatedLogs" :destroy-on-close="true" fullscreen @closed="showRelatedLogs = false">
<el-dialog
v-model="showRelatedLogs"
width="60%"
center
align-center
:destroy-on-close="true"
@closed="showRelatedLogs = false"
>
<el-pagination
v-model="pageNum"
:page-size="pageSize"
@ -295,4 +309,10 @@ limitations under the License. -->
.link {
text-decoration: underline;
}
.log-tips {
width: 100%;
text-align: center;
margin: 50px 0;
}
</style>

View File

@ -31,8 +31,10 @@ limitations under the License. -->
import type { PropType } from "vue";
import { useI18n } from "vue-i18n";
import * as d3 from "d3";
import { useAppStoreWithOut } from "@/store/modules/app";
import type { Span } from "@/types/trace";
import Graph from "./D3Graph/Index.vue";
import { Themes } from "@/constants/data";
/* global defineProps, Recordable*/
const props = defineProps({
@ -40,6 +42,7 @@ limitations under the License. -->
traceId: { type: String, default: "" },
});
const { t } = useI18n();
const appStore = useAppStoreWithOut();
const list = computed(() => Array.from(new Set(props.data.map((i: Span) => i.serviceCode))));
function computedScale(i: number) {
@ -52,13 +55,13 @@ limitations under the License. -->
function downloadTrace() {
const serializer = new XMLSerializer();
const svgNode: any = d3.select(".trace-list-dowanload").node();
const svgNode: any = d3.select(".trace-list").node();
const source = `<?xml version="1.0" standalone="no"?>\r\n${serializer.serializeToString(svgNode)}`;
const canvas = document.createElement("canvas");
const context: any = canvas.getContext("2d");
canvas.width = (d3.select(".trace-list-dowanload") as Recordable)._groups[0][0].clientWidth;
canvas.height = (d3.select(".trace-list-dowanload") as Recordable)._groups[0][0].clientHeight;
context.fillStyle = "#fff";
canvas.width = (d3.select(".trace-list") as Recordable)._groups[0][0].clientWidth;
canvas.height = (d3.select(".trace-list") as Recordable)._groups[0][0].clientHeight;
context.fillStyle = appStore.theme === Themes.Dark ? "#212224" : `#fff`;
context.fillRect(0, 0, canvas.width, canvas.height);
const image = new Image();
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
@ -93,6 +96,7 @@ limitations under the License. -->
.list {
height: calc(100% - 150px);
position: relative;
}
.event-tag {

View File

@ -89,12 +89,6 @@ limitations under the License. -->
);
</script>
<style lang="scss" scoped>
.dialog-c-text {
white-space: pre;
overflow: auto;
font-family: monospace;
}
.trace-tips {
width: 100%;
text-align: center;

View File

@ -42,16 +42,17 @@ export default class ListGraph {
private xAxis: any = null;
private sequentialScale: any = null;
private root: any = null;
private selectedNode: any = null;
constructor(el: HTMLDivElement, handleSelectSpan: (i: Trace) => void) {
this.handleSelectSpan = handleSelectSpan;
this.el = el;
this.width = el.getBoundingClientRect().width - 10;
this.height = el.getBoundingClientRect().height - 10;
d3.select(".trace-list-dowanload").remove();
d3.select(`.${this.el?.className} .trace-list`).remove();
this.svg = d3
.select(this.el)
.append("svg")
.attr("class", "trace-list-dowanload")
.attr("class", "trace-list")
.attr("width", this.width > 0 ? this.width : 10)
.attr("height", this.height > 0 ? this.height : 10)
.attr("transform", `translate(-5, 0)`);
@ -85,7 +86,8 @@ export default class ListGraph {
L${d.target.y} ${d.target.x - 5}`;
}
init(data: Recordable, row: Recordable[], fixSpansSize: number) {
d3.select(".trace-xaxis").remove();
d3.select(`.${this.el?.className} .trace-xaxis`).remove();
d3.select("#trace-action-box").style("display", "none");
this.row = row;
this.data = data;
this.min = d3.min(this.row.map((i) => i.startTime));
@ -142,19 +144,43 @@ export default class ListGraph {
.enter()
.append("g")
.attr("transform", `translate(${source.y0},${source.x0})`)
.attr("id", (d: Recordable) => `list-node-${d.id}`)
.attr("class", "trace-node")
.attr("style", "cursor: pointer")
.style("opacity", 0)
.on("mouseover", function (event: MouseEvent, d: Trace) {
t.tip.show(d, this);
})
.on("mouseout", function (event: MouseEvent, d: Trace) {
t.tip.hide(d, this);
})
.on("click", (event: MouseEvent, d: Trace) => {
if (this.handleSelectSpan) {
this.handleSelectSpan(d);
.on("click", function (event: MouseEvent, d: Trace & { id: string }) {
event.stopPropagation();
const hasClass = d3.select(this).classed("highlighted");
if (t.selectedNode) {
t.selectedNode.classed("highlighted", false);
d3.select("#trace-action-box").style("display", "none");
}
if (hasClass) {
t.selectedNode = null;
return;
}
d3.select(this).classed("highlighted", true);
const nodeBox = this.getBoundingClientRect();
const svgBox = (d3.select(`.${t.el?.className} .trace-list`) as any).node().getBoundingClientRect();
const offsetX = nodeBox.x - svgBox.x;
const offsetY = nodeBox.y - svgBox.y;
d3.select("#trace-action-box")
.style("display", "block")
.style("left", `${offsetX + 30}px`)
.style("top", `${offsetY + 40}px`);
t.selectedNode = d3.select(this);
if (t.handleSelectSpan) {
t.handleSelectSpan(d);
}
t.root.descendants().map((node: { id: number }) => {
d3.select(`#list-node-${node.id}`).classed("highlightedParent", false);
return node;
});
});
nodeEnter
.append("rect")
@ -246,7 +272,7 @@ export default class ListGraph {
})
.attr("cy", -5)
.attr("fill", "none")
.attr("stroke", appStore.theme === Themes.Dark ? "#666" : "#e66")
.attr("stroke", "#e66")
.style("opacity", (d: Recordable) => {
const events = d.data.attachedEvents;
if (events && events.length) {
@ -259,7 +285,7 @@ export default class ListGraph {
.append("text")
.attr("x", 267)
.attr("y", -1)
.attr("fill", appStore.theme === Themes.Dark ? "#666" : "#e66")
.attr("fill", "#e66")
.style("font-size", "10px")
.text((d: Recordable) => {
const events = d.data.attachedEvents;
@ -381,6 +407,36 @@ export default class ListGraph {
callback();
}
}
highlightParents(span: Recordable) {
if (!span) {
return;
}
const nodes = this.root.descendants().map((node: { id: number }) => {
d3.select(`#list-node-${node.id}`).classed("highlightedParent", false);
return node;
});
const parentSpan = nodes.find(
(node: Recordable) =>
span.spanId === node.data.spanId &&
span.segmentId === node.data.segmentId &&
span.traceId === node.data.traceId,
);
if (!parentSpan) return;
d3.select(`#list-node-${parentSpan.id}`).classed("highlightedParent", true);
d3.select("#trace-action-box").style("display", "none");
this.selectedNode.classed("highlighted", false);
const container = document.querySelector(".trace-chart .charts");
const containerRect = container?.getBoundingClientRect();
if (!containerRect) return;
const targetElement = document.querySelector(`#list-node-${parentSpan.id}`);
if (!targetElement) return;
const targetRect = targetElement.getBoundingClientRect();
container?.scrollTo({
left: targetRect.left - containerRect.left + container?.scrollLeft,
top: targetRect.top - containerRect.top + container?.scrollTop - 100,
behavior: "smooth",
});
}
visDate(date: number, pattern = "YYYY-MM-DD HH:mm:ss:SSS") {
return dayjs(date).format(pattern);
}

View File

@ -55,7 +55,7 @@ export default class TraceMap {
this.topChild = [];
this.width = el.clientWidth - 20;
this.height = el.clientHeight - 30;
d3.select(".d3-trace-tree").remove();
d3.select(`.${this.el?.className} .d3-trace-tree`).remove();
this.body = d3
.select(this.el)
.append("svg")