feat: adapt new trace protocol and implement new trace view (#499)

This commit is contained in:
Fine0830
2025-09-28 19:01:23 +08:00
committed by GitHub
parent 730515e304
commit dd90ab5ea7
52 changed files with 2889 additions and 937 deletions

View File

@@ -0,0 +1,17 @@
<!-- Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. -->
<svg t="1758874892311" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M731.428571 341.333333h73.142858a73.142857 73.142857 0 0 1 73.142857 73.142857v414.476191a73.142857 73.142857 0 0 1-73.142857 73.142857H219.428571a73.142857 73.142857 0 0 1-73.142857-73.142857V414.47619a73.142857 73.142857 0 0 1 73.142857-73.142857h73.142858v73.142857H219.428571v414.476191h585.142858V414.47619h-73.142858v-73.142857z m-176.90819-242.590476l0.048762 397.092572 84.577524-84.601905 51.687619 51.712-172.373334 172.397714-172.397714-172.373333 51.712-51.736381 83.626667 83.626666V98.742857h73.142857z" p-id="4697"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -310,6 +310,9 @@ limitations under the License. -->
break; break;
} }
dates.value = [start, end]; dates.value = [start, end];
if (!props.showButtons) {
ok(true);
}
}; };
const submit = () => { const submit = () => {
inputDates.value = dates.value; inputDates.value = dates.value;

View File

@@ -163,3 +163,73 @@ export const TraceSpansFromColdStage = {
} }
`, `,
}; };
export const HasQueryTracesV2Support = {
query: `
hasQueryTracesV2Support
`,
};
export const QueryV2Traces = {
variable: "$condition: TraceQueryCondition",
query: `
queryTraces(condition: $condition) {
traces {
spans {
traceId
segmentId
spanId
parentSpanId
refs {
traceId
parentSegmentId
parentSpanId
type
}
serviceCode
serviceInstanceName
startTime
endTime
endpointName
type
peer
component
isError
layer
tags {
key
value
}
logs {
time
data {
key
value
}
}
attachedEvents {
startTime {
seconds
nanos
}
event
endTime {
seconds
nanos
}
tags {
key
value
}
summary {
key
value
}
}
}
}
retrievedTimeRange {
startTime
endTime
}
}`,
};

View File

@@ -17,11 +17,45 @@
import { httpQuery } from "../base"; import { httpQuery } from "../base";
import { HttpURL } from "./url"; import { HttpURL } from "./url";
export default async function fetchQuery({ method, json, path }: { method: string; json?: unknown; path: string }) { export default async function fetchQuery({
method,
json,
path,
}: {
method: string;
json?: Record<string, unknown>;
path: string;
}) {
const upperMethod = method.toUpperCase();
let url = (HttpURL as Record<string, string>)[path];
let body: unknown | undefined = json;
if (upperMethod === "GET" && json && typeof json === "object") {
const params = new URLSearchParams();
const stringifyValue = (val: unknown): string => {
if (val instanceof Date) return val.toISOString();
if (typeof val === "object") return JSON.stringify(val);
return String(val);
};
for (const [key, value] of Object.entries(json)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const v of value as unknown[]) params.append(key, stringifyValue(v));
continue;
}
params.append(key, stringifyValue(value));
}
const queryString = params.toString();
if (queryString) {
url += (url.includes("?") ? "&" : "?") + queryString;
}
body = undefined;
}
const response = await httpQuery({ const response = await httpQuery({
method, method: upperMethod,
json, json: body,
url: (HttpURL as { [key: string]: string })[path], url,
}); });
if (response.errors) { if (response.errors) {
response.errors = response.errors.map((e: { message: string }) => e.message).join(" "); response.errors = response.errors.map((e: { message: string }) => e.message).join(" ");

View File

@@ -15,7 +15,15 @@
* limitations under the License. * limitations under the License.
*/ */
import { Traces, TraceSpans, TraceTagKeys, TraceTagValues, TraceSpansFromColdStage } from "../fragments/trace"; import {
Traces,
TraceSpans,
TraceTagKeys,
TraceTagValues,
TraceSpansFromColdStage,
HasQueryTracesV2Support,
QueryV2Traces,
} from "../fragments/trace";
export const queryTraces = `query queryTraces(${Traces.variable}) {${Traces.query}}`; export const queryTraces = `query queryTraces(${Traces.variable}) {${Traces.query}}`;
@@ -26,3 +34,7 @@ export const queryTraceTagKeys = `query queryTraceTagKeys(${TraceTagKeys.variabl
export const queryTraceTagValues = `query queryTraceTagValues(${TraceTagValues.variable}) {${TraceTagValues.query}}`; export const queryTraceTagValues = `query queryTraceTagValues(${TraceTagValues.variable}) {${TraceTagValues.query}}`;
export const queryTraceSpansFromColdStage = `query queryTraceSpansFromColdStage(${TraceSpansFromColdStage.variable}) {${TraceSpansFromColdStage.query}}`; export const queryTraceSpansFromColdStage = `query queryTraceSpansFromColdStage(${TraceSpansFromColdStage.variable}) {${TraceSpansFromColdStage.query}}`;
export const queryHasQueryTracesV2Support = `query queryHasQueryTracesV2Support {${HasQueryTracesV2Support.query}}`;
export const queryV2Traces = `query queryV2Traces(${QueryV2Traces.variable}) {${QueryV2Traces.query}}`;

View File

@@ -89,6 +89,7 @@ limitations under the License. -->
import router from "@/router"; import router from "@/router";
import { useAppStoreWithOut, InitializationDurationRow } from "@/store/modules/app"; import { useAppStoreWithOut, InitializationDurationRow } from "@/store/modules/app";
import { useDashboardStore } from "@/store/modules/dashboard"; import { useDashboardStore } from "@/store/modules/dashboard";
import { useTraceStore } from "@/store/modules/trace";
import type { DashboardItem } from "@/types/dashboard"; import type { DashboardItem } from "@/types/dashboard";
import timeFormat from "@/utils/timeFormat"; import timeFormat from "@/utils/timeFormat";
import { MetricCatalog } from "@/views/dashboard/data"; import { MetricCatalog } from "@/views/dashboard/data";
@@ -98,10 +99,10 @@ limitations under the License. -->
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
/*global Indexable */
const { t, te } = useI18n(); const { t, te } = useI18n();
const appStore = useAppStoreWithOut(); const appStore = useAppStoreWithOut();
const dashboardStore = useDashboardStore(); const dashboardStore = useDashboardStore();
const traceStore = useTraceStore();
const route = useRoute(); const route = useRoute();
const pathNames = ref<{ path?: string; name: string; selected: boolean }[][]>([]); const pathNames = ref<{ path?: string; name: string; selected: boolean }[][]>([]);
const showTimeRangeTips = ref<boolean>(false); const showTimeRangeTips = ref<boolean>(false);
@@ -124,6 +125,7 @@ limitations under the License. -->
getVersion(); getVersion();
getNavPaths(); getNavPaths();
setTTL(); setTTL();
traceStore.getHasQueryTracesV2Support();
function changeTheme() { function changeTheme() {
const root = document.documentElement; const root = document.documentElement;
@@ -391,7 +393,7 @@ limitations under the License. -->
} }
function resetDuration() { function resetDuration() {
const { duration }: Indexable = route.params; const { duration } = route.params as { duration: string };
if (duration) { if (duration) {
const d = JSON.parse(duration); const d = JSON.parse(duration);

View File

@@ -17,7 +17,7 @@ limitations under the License. -->
<div :class="isCollapse ? 'logo-icon-collapse' : 'logo-icon'"> <div :class="isCollapse ? 'logo-icon-collapse' : 'logo-icon'">
<Icon :size="isCollapse ? 'xl' : 'logo'" :iconName="isCollapse ? 'logo' : 'logo-sw'" /> <Icon :size="isCollapse ? 'xl' : 'logo'" :iconName="isCollapse ? 'logo' : 'logo-sw'" />
</div> </div>
<div class="menu scroll_bar_dark" :style="isCollapse ? {} : { width: '220px' }"> <div class="menu scroll_bar_style" :style="isCollapse ? {} : { width: '220px' }">
<el-menu <el-menu
active-text-color="#448dfe" active-text-color="#448dfe"
background-color="#252a2f" background-color="#252a2f"

View File

@@ -31,7 +31,7 @@ const msg = {
profiles: "Profiles", profiles: "Profiles",
database: "Database", database: "Database",
mySQL: "MySQL/MariaDB", mySQL: "MySQL/MariaDB",
serviceName: "Service Name", serviceName: "Service name",
technologies: "Technologies", technologies: "Technologies",
health: "Health", health: "Health",
groupName: "Group Name", groupName: "Group Name",
@@ -406,5 +406,11 @@ const msg = {
minutes: "Minutes", minutes: "Minutes",
invalidProfilingDurationRange: "Please enter a valid duration between 1 and 900 seconds", invalidProfilingDurationRange: "Please enter a valid duration between 1 and 900 seconds",
taskCreatedSuccessfully: "Task created successfully", taskCreatedSuccessfully: "Task created successfully",
runQuery: "Run Query",
spansTable: "Spans Table",
download: "Download",
totalSpans: "Total Spans",
spanName: "Span name",
parentId: "Parent ID",
}; };
export default msg; export default msg;

View File

@@ -406,5 +406,11 @@ const msg = {
minutes: "Minutos", minutes: "Minutos",
invalidProfilingDurationRange: "Por favor ingrese una duración válida entre 1 y 900 segundos", invalidProfilingDurationRange: "Por favor ingrese una duración válida entre 1 y 900 segundos",
taskCreatedSuccessfully: "Tarea creada exitosamente", taskCreatedSuccessfully: "Tarea creada exitosamente",
runQuery: "Ejecutar Consulta",
spansTable: "Tabla de Lapso",
download: "Descargar",
totalSpans: "Total Lapso",
spanName: "Nombre de Lapso",
parentId: "ID Padre",
}; };
export default msg; export default msg;

View File

@@ -404,5 +404,11 @@ const msg = {
minutes: "分钟", minutes: "分钟",
invalidProfilingDurationRange: "请输入1到900秒之间的有效时长", invalidProfilingDurationRange: "请输入1到900秒之间的有效时长",
taskCreatedSuccessfully: "任务创建成功", taskCreatedSuccessfully: "任务创建成功",
runQuery: "运行查询",
spansTable: "Spans表格",
download: "下载",
totalSpans: "总跨度",
spanName: "跨度名称",
parentId: "父ID",
}; };
export default msg; export default msg;

View File

@@ -37,9 +37,15 @@ interface TraceState {
selectorStore: ReturnType<typeof useSelectorStore>; selectorStore: ReturnType<typeof useSelectorStore>;
selectedSpan: Nullable<Span>; selectedSpan: Nullable<Span>;
serviceList: string[]; serviceList: string[];
currentSpan: Nullable<Span>;
hasQueryTracesV2Support: boolean;
v2Traces: Trace[];
loading: boolean;
} }
const { getDurationTime } = useDuration(); const { getDurationTime } = useDuration();
export const PageSize = 20;
export const traceStore = defineStore({ export const traceStore = defineStore({
id: "trace", id: "trace",
state: (): TraceState => ({ state: (): TraceState => ({
@@ -54,24 +60,39 @@ export const traceStore = defineStore({
queryDuration: getDurationTime(), queryDuration: getDurationTime(),
traceState: "ALL", traceState: "ALL",
queryOrder: QueryOrders[0].value, queryOrder: QueryOrders[0].value,
paging: { pageNum: 1, pageSize: 20 }, paging: { pageNum: 1, pageSize: PageSize },
}, },
traceSpanLogs: [], traceSpanLogs: [],
selectorStore: useSelectorStore(), selectorStore: useSelectorStore(),
serviceList: [], serviceList: [],
currentSpan: null,
hasQueryTracesV2Support: false,
v2Traces: [],
loading: false,
}), }),
actions: { actions: {
setTraceCondition(data: Recordable) { setTraceCondition(data: Recordable) {
this.conditions = { ...this.conditions, ...data }; this.conditions = { ...this.conditions, ...data };
}, },
setCurrentTrace(trace: Trace) { setCurrentTrace(trace: Nullable<Trace>) {
this.currentTrace = trace; this.currentTrace = trace || {};
}, },
setTraceSpans(spans: Span[]) { setTraceSpans(spans: Span[]) {
this.traceSpans = spans; this.traceSpans = spans;
}, },
setSelectedSpan(span: Span) { setSelectedSpan(span: Nullable<Span>) {
this.selectedSpan = span; this.selectedSpan = span || {};
},
setCurrentSpan(span: Nullable<Span>) {
this.currentSpan = span || {};
},
setV2Spans(traceId: string) {
const trace = this.traceList.find((d: Trace) => d.traceId === traceId);
this.setTraceSpans(trace?.spans || []);
this.serviceList = Array.from(new Set(trace?.spans.map((i: Span) => i.serviceCode)));
},
setTraceList(traces: Trace[]) {
this.traceList = traces;
}, },
resetState() { resetState() {
this.traceSpans = []; this.traceSpans = [];
@@ -156,14 +177,20 @@ export const traceStore = defineStore({
return response; return response;
}, },
async getTraces() { async getTraces() {
if (this.hasQueryTracesV2Support) {
return this.fetchV2Traces();
}
this.loading = true;
const response = await graphql.query("queryTraces").params({ condition: this.conditions }); const response = await graphql.query("queryTraces").params({ condition: this.conditions });
if (response.errors) { if (response.errors) {
this.loading = false;
return response; return response;
} }
if (!response.data.data.traces.length) { if (!response.data.data.traces.length) {
this.traceList = []; this.traceList = [];
this.setCurrentTrace({}); this.setCurrentTrace({});
this.setTraceSpans([]); this.setTraceSpans([]);
this.loading = false;
return response; return response;
} }
this.getTraceSpans({ traceId: response.data.data.traces[0].traceIds[0] }); this.getTraceSpans({ traceId: response.data.data.traces[0].traceIds[0] });
@@ -177,8 +204,13 @@ export const traceStore = defineStore({
return response; return response;
}, },
async getTraceSpans(params: { traceId: string }) { async getTraceSpans(params: { traceId: string }) {
if (this.hasQueryTracesV2Support) {
this.setV2Spans(params.traceId);
return new Promise((resolve) => resolve({}));
}
const appStore = useAppStoreWithOut(); const appStore = useAppStoreWithOut();
let response; let response;
this.loading = true;
if (appStore.coldStageMode) { if (appStore.coldStageMode) {
response = await graphql response = await graphql
.query("queryTraceSpansFromColdStage") .query("queryTraceSpansFromColdStage")
@@ -186,6 +218,7 @@ export const traceStore = defineStore({
} else { } else {
response = await graphql.query("querySpans").params(params); response = await graphql.query("querySpans").params(params);
} }
this.loading = false;
if (response.errors) { if (response.errors) {
return response; return response;
} }
@@ -209,6 +242,61 @@ export const traceStore = defineStore({
async getTagValues(tagKey: string) { async getTagValues(tagKey: string) {
return await graphql.query("queryTraceTagValues").params({ tagKey, duration: useAppStoreWithOut().durationTime }); return await graphql.query("queryTraceTagValues").params({ tagKey, duration: useAppStoreWithOut().durationTime });
}, },
async getHasQueryTracesV2Support() {
const response = await graphql.query("queryHasQueryTracesV2Support").params({});
this.hasQueryTracesV2Support = response.data.hasQueryTracesV2Support;
return response;
},
async fetchV2Traces() {
this.loading = true;
const response = await graphql.query("queryV2Traces").params({ condition: this.conditions });
this.loading = false;
if (response.errors) {
this.traceList = [];
this.setCurrentTrace({});
this.setTraceSpans([]);
return response;
}
this.v2Traces = response.data.queryTraces.traces || [];
this.traceList = this.v2Traces
.map((d: Trace) => {
const newSpans = d.spans.map((span: Span) => {
return {
...span,
traceId: span.traceId,
duration: span.endTime - span.startTime,
label: `${span.serviceCode}: ${span.endpointName}`,
};
});
const trace =
newSpans.find((span: Span) => span.parentSpanId === -1 && span.refs.length === 0) || newSpans[0];
return {
endpointNames: trace.endpointName ? [trace.endpointName] : [],
traceIds: trace.traceId ? [{ value: trace.traceId, label: trace.traceId }] : [],
start: trace.startTime,
duration: trace.endTime - trace.startTime,
isError: trace.isError,
spans: newSpans,
traceId: trace.traceId,
key: trace.traceId,
serviceCode: trace.serviceCode,
label: `${trace.serviceCode}: ${trace.endpointName}`,
};
})
.sort((a: Trace, b: Trace) => b.duration - a.duration);
const trace = this.traceList[0];
if (!trace) {
this.traceList = [];
this.setCurrentTrace({});
this.setTraceSpans([]);
return response;
}
this.serviceList = Array.from(new Set(trace.spans.map((i: Span) => i.serviceCode)));
this.setTraceSpans(trace.spans);
this.setCurrentTrace(trace || {});
return response;
},
}, },
}); });

View File

@@ -185,13 +185,13 @@
} }
.scroll_bar_style::-webkit-scrollbar { .scroll_bar_style::-webkit-scrollbar {
width: 4px; width: 5px;
height: 4px; height: 5px;
background-color: #eee; background-color: var(--sw-scrollbar-track);
} }
.scroll_bar_style::-webkit-scrollbar-track { .scroll_bar_style::-webkit-scrollbar-track {
background-color: #eee; background-color: var(--sw-scrollbar-track);
border-radius: 3px; border-radius: 3px;
box-shadow: inset 0 0 6px $disabled-color; box-shadow: inset 0 0 6px $disabled-color;
} }
@@ -199,26 +199,9 @@
.scroll_bar_style::-webkit-scrollbar-thumb { .scroll_bar_style::-webkit-scrollbar-thumb {
border-radius: 3px; border-radius: 3px;
box-shadow: inset 0 0 6px $disabled-color; box-shadow: inset 0 0 6px $disabled-color;
background-color: #aaa; background-color: var(--sw-scrollbar-thumb);
} }
.scroll_bar_dark::-webkit-scrollbar {
width: 6px;
height: 6px;
background-color: #666;
}
.scroll_bar_dark::-webkit-scrollbar-track {
background-color: #252a2f;
border-radius: 3px;
box-shadow: inset 0 0 6px #999;
}
.scroll_bar_dark::-webkit-scrollbar-thumb {
border-radius: 3px;
box-shadow: inset 0 0 6px #888;
background-color: #999;
}
.d3-tip { .d3-tip {
line-height: 1; line-height: 1;
padding: 8px; padding: 8px;

View File

@@ -71,6 +71,11 @@ html {
--sw-marketplace-border: #dedfe0; --sw-marketplace-border: #dedfe0;
--sw-grid-item-active: #d4d7de; --sw-grid-item-active: #d4d7de;
--sw-trace-line: #999; --sw-trace-line: #999;
--sw-scrollbar-track: #eee;
--sw-scrollbar-thumb: #aaa;
--sw-font-grey-color: #a7aebb;
--sw-trace-list-path: rgba(0, 0, 0, 0.1);
--sw-trace-table-selected: rgba(0, 0, 0, 0.1);
} }
html.dark { html.dark {
@@ -81,7 +86,7 @@ html.dark {
--disabled-color: #999; --disabled-color: #999;
--dashboard-tool-bg: #000; --dashboard-tool-bg: #000;
--text-color-placeholder: #ccc; --text-color-placeholder: #ccc;
--border-color: #262629; --border-color: #333;
--border-color-primary: #4b4b52; --border-color-primary: #4b4b52;
--layout-background: #000; --layout-background: #000;
--box-shadow-color: #606266; --box-shadow-color: #606266;
@@ -114,6 +119,11 @@ html.dark {
--sw-marketplace-border: #606266; --sw-marketplace-border: #606266;
--sw-grid-item-active: #73767a; --sw-grid-item-active: #73767a;
--sw-trace-line: #e8e8e8; --sw-trace-line: #e8e8e8;
--sw-scrollbar-track: #252a2f;
--sw-scrollbar-thumb: #888;
--sw-font-grey-color: #a7aebb;
--sw-trace-list-path: rgba(244, 244, 244, 0.4);
--sw-trace-table-selected: rgba(255, 255, 255, 0.1);
} }
.el-drawer__header { .el-drawer__header {

View File

@@ -27,6 +27,10 @@ export interface Trace {
spans: Span[]; spans: Span[];
endpointNames: string[]; endpointNames: string[];
traceId: string; traceId: string;
serviceCode: string;
label: string;
id: string;
parentId: string;
} }
export interface Span { export interface Span {
@@ -46,6 +50,7 @@ export interface Span {
refs: Array<Ref>; refs: Array<Ref>;
startTime: number; startTime: number;
endTime: number; endTime: number;
duration?: number;
dur?: number; dur?: number;
children?: Span[]; children?: Span[];
tags?: { value: string; key: string }[]; tags?: { value: string; key: string }[];
@@ -63,6 +68,8 @@ export interface Span {
avgTime?: number; avgTime?: number;
count?: number; count?: number;
profiled?: boolean; profiled?: boolean;
startTimeNanos?: number;
event?: string;
} }
export type Ref = { export type Ref = {
type?: string; type?: string;

70
src/utils/color.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* 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.
*/
// Service color palette for consistent coloring across trace visualizations
export const ServicePalette = [
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
"#06b6d4", // cyan-500
"#84cc16", // lime-500
"#f97316", // orange-500
"#ec4899", // pink-500
"#6366f1", // indigo-500
"#14b8a6", // teal-500
"#a855f7", // purple-500
"#22c55e", // green-500
"#eab308", // yellow-500
"#f43f5e", // rose-500
"#0ea5e9", // sky-500
"#8b5a2b", // brown-500
"#64748b", // slate-500
"#dc2626", // red-600
"#059669", // emerald-600
"#d97706", // amber-600
"#7c3aed", // violet-600
"#0891b2", // cyan-600
"#65a30d", // lime-600
"#ea580ce6", // orange-600
"#db2777", // pink-600
"#4f46e5", // indigo-600
"#0d9488", // teal-600
"#9333ea", // purple-600
"#16a34a", // green-600
"#ca8a04", // yellow-600
"#e11d48", // rose-600
"#0284c7", // sky-600
"#92400e", // brown-600
"#475569", // slate-600
];
// Reuse the same service color function from TracesTable
function generateHash(str: string) {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i += 1) {
const c = str.charCodeAt(i);
hash = (hash << 5) - hash + c;
hash |= 0; // Convert to 32bit integer
}
return Math.abs(hash); // Only positive number.
}
export function getServiceColor(serviceName: string): string {
if (!serviceName) return "#eee";
const hash = generateHash(serviceName);
return ServicePalette[hash % ServicePalette.length];
}

View File

@@ -31,16 +31,15 @@ export const readFile = (event: any) => {
}; };
}); });
}; };
export const saveFile = (data: any, name: string) => { export const saveFileAsJSON = (data: unknown, filename: string) => {
const newData = JSON.stringify(data); const jsonContent = JSON.stringify(data, null, 2);
const tagA = document.createElement("a"); const blob = new Blob([jsonContent], { type: "application/json;charset=utf-8;" });
tagA.download = name; const link = document.createElement("a");
tagA.style.display = "none"; link.href = URL.createObjectURL(blob);
const blob = new Blob([newData]); link.download = filename;
const url = URL.createObjectURL(blob); link.style.display = "none";
tagA.href = url; document.body.appendChild(link);
document.body.appendChild(tagA); link.click();
tagA.click(); document.body.removeChild(link);
document.body.removeChild(tagA); URL.revokeObjectURL(link.href);
URL.revokeObjectURL(url);
}; };

View File

@@ -179,7 +179,7 @@ limitations under the License. -->
import { useDashboardStore } from "@/store/modules/dashboard"; import { useDashboardStore } from "@/store/modules/dashboard";
import router from "@/router"; import router from "@/router";
import type { DashboardItem, LayoutConfig } from "@/types/dashboard"; import type { DashboardItem, LayoutConfig } from "@/types/dashboard";
import { saveFile, readFile } from "@/utils/file"; import { saveFileAsJSON, readFile } from "@/utils/file";
import { EntityType } from "./data"; import { EntityType } from "./data";
import { isEmptyObject } from "@/utils/is"; import { isEmptyObject } from "@/utils/is";
import { WidgetType } from "@/views/dashboard/data"; import { WidgetType } from "@/views/dashboard/data";
@@ -356,7 +356,7 @@ limitations under the License. -->
optimizeTemplate(item.configuration.children); optimizeTemplate(item.configuration.children);
} }
const name = `dashboards.json`; const name = `dashboards.json`;
saveFile(templates, name); saveFileAsJSON(templates, name);
setTimeout(() => { setTimeout(() => {
multipleTableRef.value!.clearSelection(); multipleTableRef.value!.clearSelection();
}, 2000); }, 2000);

View File

@@ -24,36 +24,16 @@ limitations under the License. -->
<span>{{ t("delete") }}</span> <span>{{ t("delete") }}</span>
</div> </div>
</el-popover> </el-popover>
<div class="header"> <Content :data="data" />
<Filter :needQuery="needQuery" :data="data" @get="getService" @search="popSegmentList" />
</div>
<div class="trace flex-h">
<TraceList class="trace-list" :style="`width: ${currentWidth}px;`" />
<div
@mouseover="showIcon = true"
@mouseout="showIcon = false"
@mousedown="mousedown($event)"
@mouseup="mouseup($event)"
>
<div class="trace-line" />
<span class="trace-icon" v-show="showIcon" @mousedown="triggerArrow" @mouseup="stopObserve($event)">
<Icon class="trace-arrow" :icon-name="isLeft ? 'chevron-left' : 'chevron-right'" size="lg" />
</span>
</div>
<TraceDetail :serviceId="serviceId" />
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { provide, ref, onMounted, onUnmounted } from "vue"; import { provide } from "vue";
import type { PropType } from "vue"; import type { PropType } from "vue";
import Filter from "../related/trace/Filter.vue";
import TraceList from "../related/trace/TraceList.vue";
import TraceDetail from "../related/trace/Detail.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useDashboardStore } from "@/store/modules/dashboard"; import { useDashboardStore } from "@/store/modules/dashboard";
import { mutationObserver } from "@/utils/mutation";
import type { LayoutConfig } from "@/types/dashboard"; import type { LayoutConfig } from "@/types/dashboard";
import Content from "@/views/dashboard/related/trace/Content.vue";
/* global defineProps */ /* global defineProps */
const props = defineProps({ const props = defineProps({
@@ -65,76 +45,12 @@ limitations under the License. -->
needQuery: { type: Boolean, default: true }, needQuery: { type: Boolean, default: true },
}); });
provide("options", props.data); provide("options", props.data);
const defaultWidth = 280,
minArrowLeftWidth = 120;
const serviceId = ref<string>("");
const { t } = useI18n(); const { t } = useI18n();
const dashboardStore = useDashboardStore(); const dashboardStore = useDashboardStore();
const isLeft = ref<boolean>(true);
const showIcon = ref<boolean>(false);
const currentWidth = ref<number>(defaultWidth);
const isDrag = ref<boolean>(false);
function removeWidget() { function removeWidget() {
dashboardStore.removeControls(props.data); dashboardStore.removeControls(props.data);
} }
function getService(id: string) {
serviceId.value = id;
}
// When click the arrow, the width of the segment list is determined by the direction it points to.
function triggerArrow() {
currentWidth.value = isLeft.value ? 0 : defaultWidth;
isLeft.value = !isLeft.value;
startObserve();
}
function popSegmentList() {
if (currentWidth.value >= defaultWidth) {
return;
}
currentWidth.value = defaultWidth;
isLeft.value = true;
}
function startObserve() {
mutationObserver.observe("trigger-resize", document.querySelector(".trace-list")!, {
attributes: true,
attributeFilter: ["style"],
});
}
function stopObserve(event: MouseEvent) {
mutationObserver.disconnect("trigger-resize");
event.stopPropagation();
}
const mousemove = (event: MouseEvent) => {
if (!isDrag.value) {
return;
}
const diffX = event.clientX;
let leftWidth = document.querySelector(".trace-list")!.getBoundingClientRect();
currentWidth.value = diffX - leftWidth.left;
isLeft.value = currentWidth.value >= minArrowLeftWidth;
};
const mouseup = (event: MouseEvent) => {
showIcon.value = false;
isDrag.value = false;
stopObserve(event);
};
const mousedown = (event: MouseEvent) => {
if ((event.target as HTMLDivElement)?.className === "trace-line") {
isDrag.value = true;
startObserve();
event.stopPropagation();
}
};
onMounted(() => {
document.addEventListener("mousedown", mousedown);
document.addEventListener("mousemove", mousemove);
document.addEventListener("mouseup", mouseup);
});
onUnmounted(() => {
document.removeEventListener("mousedown", mousedown);
document.removeEventListener("mousemove", mousemove);
document.removeEventListener("mouseup", mouseup);
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.trace-wrapper { .trace-wrapper {
@@ -150,13 +66,6 @@ limitations under the License. -->
right: 3px; right: 3px;
} }
.header {
padding: 10px;
font-size: $font-size-smaller;
border-bottom: 1px solid $border-color;
min-width: 1000px;
}
.tools { .tools {
padding: 5px 0; padding: 5px 0;
color: #999; color: #999;
@@ -169,47 +78,4 @@ limitations under the License. -->
background-color: $popper-hover-bg-color; background-color: $popper-hover-bg-color;
} }
} }
.trace {
min-height: calc(100% - 150px);
width: 100%;
overflow: auto;
min-width: 1000px;
}
.trace-list {
max-width: 480px;
}
.trace-line {
position: relative;
width: 2px;
height: 100%;
background-color: var(--sw-trace-line);
cursor: ew-resize;
&:hover {
color: $active-color;
background-color: $active-background;
}
}
.trace-icon {
position: absolute;
cursor: pointer;
top: calc(50% - 15px);
text-align: center;
width: 24px;
height: 24px;
transform: translateX(-11px);
line-height: 24px;
border-radius: 50%;
background-color: $layout-background;
box-shadow: 0 3px 5px rgb(45 60 80 / 20%);
}
.trace-arrow {
padding-bottom: 1px;
color: $active-color;
}
</style> </style>

View File

@@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
export const dragIgnoreFrom = export const dragIgnoreFrom =
"svg.d3-trace-tree, .dragger, .micro-topo-chart, .schedules, .vis-item, .vis-timeline, .process-svg"; "svg.d3-trace-tree, .dragger, .micro-topo-chart, .schedules, .vis-item, .vis-timeline, .process-svg, .trace-query-content";
export const PodsChartTypes = ["EndpointList", "InstanceList"]; export const PodsChartTypes = ["EndpointList", "InstanceList"];

View File

@@ -16,7 +16,7 @@ limitations under the License. -->
<div class="flex-h content"> <div class="flex-h content">
<TaskList /> <TaskList />
<div class="vis-graph ml-5"> <div class="vis-graph ml-5">
<div class="mb-20"> <div class="mb-20 mt-10">
<Filter /> <Filter />
</div> </div>
<div class="stack" v-loading="asyncProfilingStore.loadingTree"> <div class="stack" v-loading="asyncProfilingStore.loadingTree">

View File

@@ -41,7 +41,7 @@ limitations under the License. -->
</div> </div>
<div class="profile-table"> <div class="profile-table">
<Table <Table
:data="(profileStore.segmentSpans as SegmentSpan[])" :tableData="(profileStore.segmentSpans as SegmentSpan[])"
:traceId="profileStore.currentSegment?.traceId" :traceId="profileStore.currentSegment?.traceId"
:headerType="WidgetType.Profile" :headerType="WidgetType.Profile"
@select="selectSpan" @select="selectSpan"
@@ -52,7 +52,7 @@ limitations under the License. -->
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from "vue"; import { ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import Table from "../../trace/components/Table.vue"; import Table from "@/views/dashboard/related/trace/components/Table/TableContainer.vue";
import { useProfileStore } from "@/store/modules/profile"; import { useProfileStore } from "@/store/modules/profile";
import Selector from "@/components/Selector.vue"; import Selector from "@/components/Selector.vue";
import type { SegmentSpan, ProfileTimeRange, ProfileAnalyzeParams } from "@/types/profile"; import type { SegmentSpan, ProfileTimeRange, ProfileAnalyzeParams } from "@/types/profile";

View File

@@ -0,0 +1,193 @@
<!-- 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="search-bar">
<Filter :needQuery="needQuery" :data="data" @get="getService" @search="popSegmentList" />
<div class="filter-row flex-h mt-10" v-if="traceStore.hasQueryTracesV2Support">
<div class="grey mr-10 label">{{ t("limit") }}</div>
<el-input-number size="small" v-model="limit" :min="10" @change="changeLimit" />
</div>
</div>
<TraceQuery v-if="traceStore.hasQueryTracesV2Support" style="height: 100%" />
<div v-else class="trace flex-h">
<SegmentList class="trace-list" :style="`width: ${currentWidth}px;`" />
<div
@mouseover="showIcon = true"
@mouseout="showIcon = false"
@mousedown="mousedown($event)"
@mouseup="mouseup($event)"
>
<div class="trace-line" />
<span class="trace-icon" v-show="showIcon" @mousedown="triggerArrow" @mouseup="stopObserve($event)">
<Icon class="trace-arrow" :icon-name="isLeft ? 'chevron-left' : 'chevron-right'" size="lg" />
</span>
</div>
<SpanList :serviceId="serviceId" />
</div>
</template>
<script lang="ts" setup>
import { provide, ref, onMounted, onUnmounted } from "vue";
import type { PropType } from "vue";
import { useI18n } from "vue-i18n";
import { useTraceStore, PageSize } from "@/store/modules/trace";
import Filter from "./components/TraceList/Filter.vue";
import SegmentList from "./components/TraceList/SegmentList.vue";
import SpanList from "./components/TraceList/SpanList.vue";
import type { LayoutConfig } from "@/types/dashboard";
import { mutationObserver } from "@/utils/mutation";
import TraceQuery from "./components/TraceQuery/Index.vue";
/*global defineProps */
const props = defineProps({
data: {
type: Object as PropType<LayoutConfig>,
default: () => ({}),
},
});
provide("options", props.data);
const { t } = useI18n();
const traceStore = useTraceStore();
const serviceId = ref<string>("");
const showIcon = ref<boolean>(false);
const isLeft = ref<boolean>(true);
const currentWidth = ref<number>(280);
const needQuery = ref<boolean>(true);
const isDrag = ref<boolean>(false);
const limit = ref(PageSize);
const defaultWidth = 280;
const minArrowLeftWidth = 120;
function getService(id: string) {
serviceId.value = id;
}
function changeLimit(val: number | undefined) {
if (!val) return;
traceStore.setTraceCondition({
paging: { pageNum: 1, pageSize: val },
});
}
// When click the arrow, the width of the segment list is determined by the direction it points to.
function triggerArrow() {
currentWidth.value = isLeft.value ? 0 : defaultWidth;
isLeft.value = !isLeft.value;
startObserve();
}
function popSegmentList() {
if (currentWidth.value >= defaultWidth) {
return;
}
currentWidth.value = defaultWidth;
isLeft.value = true;
}
function startObserve() {
mutationObserver.observe("trigger-resize", document.querySelector(".trace-list")!, {
attributes: true,
attributeFilter: ["style"],
});
}
function stopObserve(event: MouseEvent) {
mutationObserver.disconnect("trigger-resize");
event.stopPropagation();
}
const mousemove = (event: MouseEvent) => {
if (!isDrag.value) {
return;
}
const diffX = event.clientX;
let leftWidth = document.querySelector(".trace-list")!.getBoundingClientRect();
currentWidth.value = diffX - leftWidth.left;
isLeft.value = currentWidth.value >= minArrowLeftWidth;
};
const mouseup = (event: MouseEvent) => {
showIcon.value = false;
isDrag.value = false;
stopObserve(event);
};
const mousedown = (event: MouseEvent) => {
if ((event.target as HTMLDivElement)?.className === "trace-line") {
isDrag.value = true;
startObserve();
event.stopPropagation();
}
};
onMounted(() => {
document.addEventListener("mousedown", mousedown);
document.addEventListener("mousemove", mousemove);
document.addEventListener("mouseup", mouseup);
});
onUnmounted(() => {
document.removeEventListener("mousedown", mousedown);
document.removeEventListener("mousemove", mousemove);
document.removeEventListener("mouseup", mouseup);
});
</script>
<style lang="scss" scoped>
.search-bar {
padding: 10px;
font-size: $font-size-smaller;
border-bottom: 1px solid $border-color;
min-width: 1000px;
}
.trace {
min-height: calc(100% - 160px);
width: 100%;
overflow: auto;
min-width: 1000px;
}
.trace-list {
max-width: 480px;
}
.trace-line {
position: relative;
width: 2px;
height: 100%;
background-color: var(--sw-trace-line);
cursor: ew-resize;
&:hover {
color: $active-color;
background-color: $active-background;
}
}
.trace-icon {
position: absolute;
cursor: pointer;
top: calc(50% - 15px);
text-align: center;
width: 24px;
height: 24px;
transform: translateX(-11px);
line-height: 24px;
border-radius: 50%;
background-color: $layout-background;
box-shadow: 0 3px 5px rgb(45 60 80 / 20%);
}
.trace-arrow {
padding-bottom: 1px;
color: $active-color;
}
.label {
height: 28px;
line-height: 28px;
}
</style>

View File

@@ -14,27 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License. --> limitations under the License. -->
<template> <template>
<div class="trace-wrapper flex-v"> <div class="trace-wrapper flex-v">
<div class="header"> <div class="search-bar">
<Header :data="data" /> <SearchBar :data="data" />
</div> </div>
<div class="trace flex-h"> <div class="trace flex-h">
<TraceList /> <SegmentList />
<TraceDetail /> <SpanList />
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { provide } from "vue"; import { provide } from "vue";
import type { PropType } from "vue"; import type { PropType } from "vue";
import Header from "./Header.vue";
import TraceList from "./TraceList.vue";
import TraceDetail from "./Detail.vue";
import type { LayoutConfig } from "@/types/dashboard"; import type { LayoutConfig } from "@/types/dashboard";
import SearchBar from "./components/TraceList/SearchBar.vue";
import SegmentList from "./components/TraceList/SegmentList.vue";
import SpanList from "./components/TraceList/SpanList.vue";
/*global defineProps */ /*global defineProps */
const props = defineProps({ const props = defineProps({
data: { data: {
type: Object as PropType<LayoutConfig>, type: Object as PropType<LayoutConfig>,
default: () => ({ graph: {} }), default: () => ({}),
}, },
}); });
provide("options", props.data); provide("options", props.data);
@@ -47,18 +47,4 @@ limitations under the License. -->
position: relative; position: relative;
overflow: auto; overflow: auto;
} }
.header {
padding: 10px;
font-size: $font-size-smaller;
border-bottom: 1px solid $border-color;
min-width: 1200px;
}
.trace {
width: 100%;
overflow: auto;
min-width: 1200px;
height: calc(100% - 100px);
}
</style> </style>

View File

@@ -20,6 +20,8 @@ limitations under the License. -->
:type="type" :type="type"
:headerType="headerType" :headerType="headerType"
:traceId="traceId" :traceId="traceId"
:selectedMaxTimestamp="selectedMaxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
@select="handleSelectSpan" @select="handleSelectSpan"
> >
<div class="trace-tips" v-if="!segmentId.length">{{ $t("noData") }}</div> <div class="trace-tips" v-if="!segmentId.length">{{ $t("noData") }}</div>
@@ -47,47 +49,74 @@ limitations under the License. -->
</el-dialog> </el-dialog>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, onBeforeUnmount, onMounted } from "vue"; import { ref, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import type { PropType } from "vue";
import * as d3 from "d3"; import * as d3 from "d3";
import dayjs from "dayjs"; import dayjs from "dayjs";
import ListGraph from "../../utils/d3-trace-list"; import ListGraph from "./utils/d3-trace-list";
import TreeGraph from "../../utils/d3-trace-tree"; import TreeGraph from "./utils/d3-trace-tree";
import type { Span, Ref } from "@/types/trace"; import type { Span, Ref } from "@/types/trace";
import SpanDetail from "./SpanDetail.vue"; import SpanDetail from "./SpanDetail.vue";
import TableContainer from "../Table/TableContainer.vue"; import TableContainer from "../Table/TableContainer.vue";
import { useAppStoreWithOut } from "@/store/modules/app"; import { useAppStoreWithOut } from "@/store/modules/app";
import { useTraceStore } from "@/store/modules/trace";
import { debounce } from "@/utils/debounce"; import { debounce } from "@/utils/debounce";
import { mutationObserver } from "@/utils/mutation"; import { mutationObserver } from "@/utils/mutation";
import { TraceGraphType } from "../constant"; import { TraceGraphType } from "../VisGraph/constant";
import { Themes } from "@/constants/data";
import type { SegmentSpan } from "@/types/profile"; import type { SegmentSpan } from "@/types/profile";
import { buildSegmentForest, collapseTree, getRefsAllNodes } from "./utils/helper";
/* global Recordable, Nullable */ /* global Nullable */
const props = defineProps({ type Props = {
data: { type: Array as PropType<(Span | SegmentSpan)[]>, default: () => [] }, data: (Span | SegmentSpan)[];
traceId: { type: String, default: "" }, traceId: string;
type: { type: String, default: TraceGraphType.LIST }, type: string;
headerType: { type: String, default: "" }, headerType?: string;
}); selectedMaxTimestamp?: number;
const emits = defineEmits(["select"]); selectedMinTimestamp?: number;
minTimestamp: number;
maxTimestamp: number;
};
type Emits = {
(e: "select", value: Span): void;
};
const props = defineProps<Props>();
const emits = defineEmits<Emits>();
const appStore = useAppStoreWithOut(); const appStore = useAppStoreWithOut();
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
const showDetail = ref<boolean>(false); const showDetail = ref<boolean>(false);
const fixSpansSize = ref<number>(0); const fixSpansSize = ref<number>(0);
const segmentId = ref<Recordable[]>([]); const segmentId = ref<Span[]>([]);
const currentSpan = ref<Nullable<Span>>(null); const currentSpan = ref<Nullable<Span>>(null);
const refSpans = ref<Array<Ref>>([]); const refSpans = ref<Array<Ref>>([]);
const tree = ref<Nullable<any>>(null); const tree = ref<Nullable<any>>(null);
const traceGraph = ref<Nullable<HTMLDivElement>>(null); const traceGraph = ref<Nullable<HTMLDivElement>>(null);
const parentSpans = ref<Array<Span | SegmentSpan>>([]); const parentSpans = ref<Array<Span | SegmentSpan>>([]);
const refParentSpans = ref<Array<Span | SegmentSpan>>([]); const refParentSpans = ref<Array<Span | SegmentSpan>>([]);
const traceStore = useTraceStore();
const debounceFunc = debounce(draw, 500); const debounceFunc = debounce(draw, 500);
const visDate = (date: number, pattern = "YYYY-MM-DD HH:mm:ss:SSS") => dayjs(date).format(pattern); // Store previous timestamp values to check for significant changes
const prevSelectedMaxTimestamp = ref<number>(props.selectedMaxTimestamp || 0);
const prevSelectedMinTimestamp = ref<number>(props.selectedMinTimestamp || 0);
onMounted(() => { const visDate = (date: number, pattern = "YYYY-MM-DD HH:mm:ss:SSS") => dayjs(date).format(pattern);
// Debounced version of onSpanPanelToggled to prevent excessive re-renders
const debouncedOnSpanPanelToggled = debounce(draw, 150);
// Check if timestamp change is significant enough to warrant a redraw
function isTimestampChangeSignificant(newMax: number, newMin: number): boolean {
const maxDiff = Math.abs(newMax - prevSelectedMaxTimestamp.value);
const minDiff = Math.abs(newMin - prevSelectedMinTimestamp.value);
const totalRange = props.maxTimestamp - props.minTimestamp;
// Consider change significant if it's more than 0.1% of the total range
const threshold = totalRange * 0.001;
return maxDiff > threshold || minDiff > threshold;
}
onMounted(async () => {
loading.value = true; loading.value = true;
changeTree(); changeTree();
await nextTick();
draw(); draw();
loading.value = false; loading.value = false;
// monitor segment list width changes. // monitor segment list width changes.
@@ -96,11 +125,12 @@ limitations under the License. -->
debounceFunc(); debounceFunc();
}); });
window.addEventListener("resize", debounceFunc); window.addEventListener("resize", debounceFunc);
window.addEventListener("spanPanelToggled", draw);
}); });
function draw() { function draw() {
if (props.type === TraceGraphType.TABLE) { if (props.type === TraceGraphType.TABLE) {
segmentId.value = setLevel(segmentId.value); segmentId.value = setLevel(segmentId.value) as Span[];
return; return;
} }
if (!traceGraph.value) { if (!traceGraph.value) {
@@ -108,21 +138,35 @@ limitations under the License. -->
} }
d3.selectAll(".d3-tip").remove(); d3.selectAll(".d3-tip").remove();
if (props.type === TraceGraphType.LIST) { if (props.type === TraceGraphType.LIST) {
tree.value = new ListGraph(traceGraph.value, handleSelectSpan); tree.value = new ListGraph({ el: traceGraph.value, handleSelectSpan: handleSelectSpan });
tree.value.init( tree.value.init({
{ label: "TRACE_ROOT", children: segmentId.value }, data: { label: "TRACE_ROOT", children: segmentId.value },
getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }), row: getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }),
fixSpansSize.value, fixSpansSize: fixSpansSize.value,
); selectedMaxTimestamp: props.selectedMaxTimestamp,
selectedMinTimestamp: props.selectedMinTimestamp,
});
tree.value.draw(); tree.value.draw();
selectInitialSpan();
return; return;
} }
if (props.type === TraceGraphType.TREE) { if (props.type === TraceGraphType.TREE) {
tree.value = new TreeGraph(traceGraph.value, handleSelectSpan); tree.value = new TreeGraph({ el: traceGraph.value, handleSelectSpan });
tree.value.init( tree.value.init({
{ label: `${props.traceId}`, children: segmentId.value }, data: { label: `${props.traceId}`, children: segmentId.value },
getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }), row: getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }),
); selectedMaxTimestamp: props.selectedMaxTimestamp,
selectedMinTimestamp: props.selectedMinTimestamp,
});
}
}
function selectInitialSpan() {
if (segmentId.value && segmentId.value.length > 0) {
const root = segmentId.value[0];
traceStore.setCurrentSpan(root);
if (tree.value && typeof tree.value.highlightSpan === "function") {
tree.value.highlightSpan(root as any);
}
} }
} }
function handleSelectSpan(i: any) { function handleSelectSpan(i: any) {
@@ -162,7 +206,7 @@ limitations under the License. -->
item && parentSpans.value.push(item); item && parentSpans.value.push(item);
} }
} }
function viewParentSpan(span: Recordable) { function viewParentSpan(span: Span) {
if (props.type === TraceGraphType.TABLE) { if (props.type === TraceGraphType.TABLE) {
setTableSpanStyle(span); setTableSpanStyle(span);
return; return;
@@ -173,234 +217,34 @@ limitations under the License. -->
showDetail.value = true; showDetail.value = true;
hideActionBox(); hideActionBox();
} }
function setTableSpanStyle(span: Recordable) { function setTableSpanStyle(span: Span) {
const itemDom: any = document.querySelector(`.trace-item-${span.key}`); const itemDom: HTMLSpanElement | null = document.querySelector(`.trace-item-${span.key}`);
const items: any = document.querySelectorAll(".trace-item"); const items: HTMLSpanElement[] = Array.from(document.querySelectorAll(".trace-item")) as HTMLSpanElement[];
for (const item of items) { for (const item of items) {
item.style.background = appStore.theme === Themes.Dark ? "#212224" : "#fff"; item.style.background = "transparent";
}
if (itemDom) {
itemDom.style.background = "var(--sw-trace-table-selected)";
} }
itemDom.style.background = appStore.theme === Themes.Dark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)";
hideActionBox(); hideActionBox();
} }
function hideActionBox() { function hideActionBox() {
const box: any = document.querySelector("#trace-action-box"); const box: any = document.querySelector("#trace-action-box");
box.style.display = "none"; box.style.display = "none";
} }
function traverseTree(node: Recordable, spanId: string, segmentId: string, data: Recordable) {
if (!node || node.isBroken) {
return;
}
if (node.spanId === spanId && node.segmentId === segmentId) {
node.children.push(data);
return;
}
for (const nodeItem of node.children || []) {
traverseTree(nodeItem, spanId, segmentId, data);
}
}
function changeTree() { function changeTree() {
if (props.data.length === 0) { if (!props.data.length) {
return []; return [];
} }
segmentId.value = []; const { roots, fixSpansSize: fixSize, refSpans: refs } = buildSegmentForest(props.data as Span[], props.traceId);
const segmentGroup: Recordable = {}; segmentId.value = roots;
const segmentIdGroup: string[] = []; fixSpansSize.value = fixSize;
const fixSpans: Span[] = []; refSpans.value = refs;
const segmentHeaders: Span[] = []; for (const root of segmentId.value) {
for (const span of props.data) { collapseTree(root, refSpans.value);
if (span.refs.length) {
refSpans.value.push(...span.refs);
}
if (span.parentSpanId === -1) {
segmentHeaders.push(span);
} else {
const item = props.data.find(
(i: Span) => i.traceId === span.traceId && i.segmentId === span.segmentId && i.spanId === span.spanId - 1,
);
const content = fixSpans.find(
(i: Span) =>
i.traceId === span.traceId &&
i.segmentId === span.segmentId &&
i.spanId === span.spanId - 1 &&
i.parentSpanId === span.spanId - 2,
);
if (!item && !content) {
fixSpans.push({
traceId: span.traceId,
segmentId: span.segmentId,
spanId: span.spanId - 1,
parentSpanId: span.spanId - 2,
refs: [],
endpointName: `VNode: ${span.segmentId}`,
serviceCode: "VirtualNode",
type: `[Broken] ${span.type}`,
peer: "",
component: `VirtualNode: #${span.spanId - 1}`,
isError: true,
isBroken: true,
layer: "Broken",
tags: [],
logs: [],
startTime: 0,
endTime: 0,
});
}
}
}
for (const span of segmentHeaders) {
if (span.refs.length) {
let exit = null;
for (const ref of span.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) {
const ref = span.refs[0];
// create a known broken node.
const parentSpanId = ref.parentSpanId > -1 ? 0 : -1;
const content = fixSpans.find(
(i: Span) =>
i.traceId === ref.traceId &&
i.segmentId === ref.parentSegmentId &&
i.spanId === ref.parentSpanId &&
i.parentSpanId === parentSpanId,
);
if (!content) {
fixSpans.push({
traceId: ref.traceId,
segmentId: ref.parentSegmentId,
spanId: ref.parentSpanId,
parentSpanId,
refs: [],
endpointName: `VNode: ${ref.parentSegmentId}`,
serviceCode: "VirtualNode",
type: `[Broken] ${ref.type}`,
peer: "",
component: `VirtualNode: #${ref.parentSpanId}`,
isError: true,
isBroken: true,
layer: "Broken",
tags: [],
logs: [],
startTime: 0,
endTime: 0,
});
}
// if root broken node is not exist, create a root broken node.
if (parentSpanId > -1) {
const content = fixSpans.find(
(i: Span) =>
i.traceId === ref.traceId &&
i.segmentId === ref.parentSegmentId &&
i.spanId === 0 &&
i.parentSpanId === -1,
);
if (!content) {
fixSpans.push({
traceId: ref.traceId,
segmentId: ref.parentSegmentId,
spanId: 0,
parentSpanId: -1,
refs: [],
endpointName: `VNode: ${ref.parentSegmentId}`,
serviceCode: "VirtualNode",
type: `[Broken] ${ref.type}`,
peer: "",
component: `VirtualNode: #0`,
isError: true,
isBroken: true,
layer: "Broken",
tags: [],
logs: [],
startTime: 0,
endTime: 0,
});
}
}
}
}
}
for (const i of [...fixSpans, ...props.data]) {
i.label = i.endpointName || "no operation name";
i.key = Math.random().toString(36).substring(2, 36);
i.children = [];
if (segmentGroup[i.segmentId]) {
segmentGroup[i.segmentId].push(i);
} else {
segmentIdGroup.push(i.segmentId);
segmentGroup[i.segmentId] = [i];
}
}
fixSpansSize.value = fixSpans.length;
for (const id of segmentIdGroup) {
const currentSegment = segmentGroup[id].sort((a: Span, b: Span) => b.parentSpanId - a.parentSpanId);
for (const s of currentSegment) {
const index = currentSegment.findIndex((i: Span) => i.spanId === s.parentSpanId);
if (index > -1) {
if (
(currentSegment[index].isBroken && currentSegment[index].parentSpanId === -1) ||
!currentSegment[index].isBroken
) {
currentSegment[index].children.push(s);
currentSegment[index].children.sort((a: Span, b: Span) => a.spanId - b.spanId);
}
}
if (s.isBroken) {
const children = props.data.filter((span: Span) =>
span.refs.find(
(d) => d.traceId === s.traceId && d.parentSegmentId === s.segmentId && d.parentSpanId === s.spanId,
),
);
if (children.length) {
s.children.push(...children);
}
}
}
segmentGroup[id] = currentSegment[currentSegment.length - 1];
}
for (const id of segmentIdGroup) {
for (const ref of segmentGroup[id].refs) {
if (ref.traceId === props.traceId) {
traverseTree(segmentGroup[ref.parentSegmentId], ref.parentSpanId, ref.parentSegmentId, segmentGroup[id]);
}
}
}
for (const i in segmentGroup) {
for (const ref of segmentGroup[i].refs) {
if (!segmentGroup[ref.parentSegmentId]) {
segmentId.value.push(segmentGroup[i]);
}
}
if (!segmentGroup[i].refs.length && segmentGroup[i].parentSpanId === -1) {
segmentId.value.push(segmentGroup[i]);
}
}
for (const i of segmentId.value) {
collapse(i);
} }
} }
function collapse(d: Span | Recordable) { function setLevel(arr: Span[], level = 1, totalExec?: number) {
if (d.children) {
const item = refSpans.value.find((s: Ref) => s.parentSpanId === d.spanId && s.parentSegmentId === d.segmentId);
let dur = d.endTime - d.startTime;
for (const i of d.children) {
dur -= i.endTime - i.startTime;
}
d.dur = dur < 0 ? 0 : dur;
if (item) {
d.children = d.children.sort(compare("startTime"));
}
for (const i of d.children) {
collapse(i);
}
}
}
function setLevel(arr: Recordable[], level = 1, totalExec?: number) {
for (const item of arr) { for (const item of arr) {
item.level = level; item.level = level;
totalExec = totalExec || item.endTime - item.startTime; totalExec = totalExec || item.endTime - item.startTime;
@@ -411,34 +255,12 @@ limitations under the License. -->
} }
return arr; return arr;
} }
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];
const b = n[p];
return a - b;
};
}
onBeforeUnmount(() => { onBeforeUnmount(() => {
d3.selectAll(".d3-tip").remove(); d3.selectAll(".d3-tip").remove();
window.removeEventListener("resize", debounceFunc); window.removeEventListener("resize", debounceFunc);
mutationObserver.deleteObserve("trigger-resize"); mutationObserver.deleteObserve("trigger-resize");
window.removeEventListener("spanPanelToggled", draw);
}); });
watch( watch(
() => props.data, () => props.data,
@@ -455,7 +277,22 @@ limitations under the License. -->
watch( watch(
() => appStore.theme, () => appStore.theme,
() => { () => {
tree.value.init({ label: "TRACE_ROOT", children: segmentId.value }, props.data, fixSpansSize.value); if (props.type === TraceGraphType.LIST) {
tree.value.init({
data: { label: "TRACE_ROOT", children: segmentId.value },
row: getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }),
fixSpansSize: fixSpansSize.value,
selectedMaxTimestamp: props.selectedMaxTimestamp,
selectedMinTimestamp: props.selectedMinTimestamp,
});
} else if (props.type === TraceGraphType.TREE) {
tree.value.init({
data: { label: `${props.traceId}`, children: segmentId.value },
row: getRefsAllNodes({ label: "TRACE_ROOT", children: segmentId.value }),
selectedMaxTimestamp: props.selectedMaxTimestamp,
selectedMinTimestamp: props.selectedMinTimestamp,
});
}
tree.value.draw(() => { tree.value.draw(() => {
setTimeout(() => { setTimeout(() => {
loading.value = false; loading.value = false;
@@ -463,6 +300,20 @@ limitations under the License. -->
}); });
}, },
); );
watch(
() => [props.selectedMaxTimestamp, props.selectedMinTimestamp],
([newMax, newMin]) => {
// Only trigger redraw if the change is significant
if (isTimestampChangeSignificant(newMax as number, newMin as number)) {
// Update previous values
prevSelectedMaxTimestamp.value = newMax as number;
prevSelectedMinTimestamp.value = newMin as number;
// Use debounced version to prevent excessive re-renders
debouncedOnSpanPanelToggled();
}
},
);
</script> </script>
<style lang="scss"> <style lang="scss">
.d3-graph { .d3-graph {

View File

@@ -83,7 +83,7 @@ limitations under the License. -->
ref="eventGraph" ref="eventGraph"
v-if="currentSpan.attachedEvents && currentSpan.attachedEvents.length" v-if="currentSpan.attachedEvents && currentSpan.attachedEvents.length"
></div> ></div>
<el-button class="popup-btn" type="primary" @click="getTaceLogs"> <el-button class="popup-btn" @click="getTaceLogs">
{{ t("relatedTraceLogs") }} {{ t("relatedTraceLogs") }}
</el-button> </el-button>
</div> </div>
@@ -149,9 +149,8 @@ limitations under the License. -->
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted, inject } from "vue"; import { ref, computed, onMounted, inject } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type { PropType } from "vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import ListGraph from "../../utils/d3-trace-list"; import ListGraph from "./utils/d3-trace-list";
import copy from "@/utils/copy"; import copy from "@/utils/copy";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { dateFormat } from "@/utils/dateFormat"; import { dateFormat } from "@/utils/dateFormat";
@@ -163,10 +162,11 @@ limitations under the License. -->
import { WidgetType } from "@/views/dashboard/data"; import { WidgetType } from "@/views/dashboard/data";
import type { LayoutConfig, DashboardItem } from "@/types/dashboard"; import type { LayoutConfig, DashboardItem } from "@/types/dashboard";
/*global defineProps, Nullable, Recordable */ /*global defineProps, Nullable, Recordable */
const props = defineProps({ type Props = {
currentSpan: { type: Object as PropType<Span>, default: () => ({}) }, currentSpan: Span;
traceId: { type: String, default: "" }, traceId?: string;
}); };
const props = defineProps<Props>();
const options: LayoutConfig | null = inject("options") || null; const options: LayoutConfig | null = inject("options") || null;
const { t } = useI18n(); const { t } = useI18n();
const traceStore = useTraceStore(); const traceStore = useTraceStore();
@@ -233,7 +233,7 @@ limitations under the License. -->
return a.startTime - b.startTime; return a.startTime - b.startTime;
}); });
tree.value = new ListGraph(eventGraph.value, selectEvent); tree.value = new ListGraph({ el: eventGraph.value, handleSelectSpan: selectEvent });
tree.value.init( tree.value.init(
{ {
children: events, children: events,
@@ -287,9 +287,9 @@ limitations under the License. -->
} }
.popup-btn { .popup-btn {
margin-top: 40px; margin-top: 20px;
width: 100%;
text-align: center; text-align: center;
padding: 0 30px;
} }
.item span { .item span {

View File

@@ -17,13 +17,12 @@
import * as d3 from "d3"; import * as d3 from "d3";
import d3tip from "d3-tip"; import d3tip from "d3-tip";
import type { Trace } from "@/types/trace"; import type { Trace, Span } from "@/types/trace";
import dayjs from "dayjs"; import dayjs from "dayjs";
import icons from "@/assets/img/icons"; import icons from "@/assets/img/icons";
import { useAppStoreWithOut } from "@/store/modules/app"; import { getServiceColor } from "@/utils/color";
import { Themes } from "@/constants/data";
import { useTraceStore } from "@/store/modules/trace";
const xScaleWidth = 0.6;
export default class ListGraph { export default class ListGraph {
private barHeight = 48; private barHeight = 48;
private handleSelectSpan: Nullable<(i: Trace) => void> = null; private handleSelectSpan: Nullable<(i: Trace) => void> = null;
@@ -36,19 +35,16 @@ export default class ListGraph {
private prompt: any = null; private prompt: any = null;
private row: any[] = []; private row: any[] = [];
private data: any = []; private data: any = [];
private min = 0; private minTimestamp = 0;
private max = 0; private maxTimestamp = 0;
private list: any[] = [];
private xScale: any = null; private xScale: any = null;
private xAxis: any = null; private xAxis: any = null;
private sequentialScale: any = null;
private root: any = null; private root: any = null;
private selectedNode: any = null; private selectedNode: any = null;
constructor(el: HTMLDivElement, handleSelectSpan: (i: Trace) => void) { constructor({ el, handleSelectSpan }: { el: HTMLDivElement; handleSelectSpan: (i: Trace) => void }) {
this.handleSelectSpan = handleSelectSpan; this.handleSelectSpan = handleSelectSpan;
this.el = el; this.el = el;
this.width = el.getBoundingClientRect().width - 10; this.width = el.getBoundingClientRect().width - 10;
this.height = el.getBoundingClientRect().height - 10;
d3.select(`.${this.el?.className} .trace-list`).remove(); d3.select(`.${this.el?.className} .trace-list`).remove();
this.svg = d3 this.svg = d3
.select(this.el) .select(this.el)
@@ -80,39 +76,48 @@ export default class ListGraph {
this.svg.call(this.tip); this.svg.call(this.tip);
this.svg.call(this.prompt); this.svg.call(this.prompt);
} }
diagonal(d: Recordable) { diagonal(d: Indexable) {
return `M ${d.source.y} ${d.source.x + 5} return `M ${d.source.y} ${d.source.x + 5}
L ${d.source.y} ${d.target.x - 30} L ${d.source.y} ${d.target.x - 30}
L${d.target.y} ${d.target.x - 20} L${d.target.y} ${d.target.x - 20}
L${d.target.y} ${d.target.x - 5}`; L${d.target.y} ${d.target.x - 5}`;
} }
init(data: Recordable, row: Recordable[], fixSpansSize: number) { init({
data,
row,
fixSpansSize,
selectedMaxTimestamp,
selectedMinTimestamp,
}: {
data: Indexable;
row: Indexable[];
fixSpansSize: number;
selectedMaxTimestamp?: number;
selectedMinTimestamp?: number;
}) {
d3.select(`.${this.el?.className} .trace-xaxis`).remove(); d3.select(`.${this.el?.className} .trace-xaxis`).remove();
d3.select("#trace-action-box").style("display", "none"); d3.select("#trace-action-box").style("display", "none");
this.row = row; this.row = row;
this.data = data; this.data = data;
this.min = d3.min(this.row.map((i) => i.startTime)); const min = d3.min(this.row.map((i) => i.startTime));
this.max = d3.max(this.row.map((i) => i.endTime - this.min)) || 0; const max = d3.max(this.row.map((i) => i.endTime));
this.list = useTraceStore().serviceList || []; this.minTimestamp = selectedMinTimestamp ?? min;
this.maxTimestamp = selectedMaxTimestamp ?? max;
this.xScale = d3 this.xScale = d3
.scaleLinear() .scaleLinear()
.range([0, this.width * 0.387]) .range([0, this.width * xScaleWidth])
.domain([0, this.max]); .domain([this.minTimestamp - min, this.maxTimestamp - min]);
this.xAxis = d3.axisTop(this.xScale).tickFormat((d: any) => { this.xAxis = d3.axisTop(this.xScale).tickFormat((d: any) => {
if (d === 0) return 0; if (d === 0) return d.toFixed(2);
if (d >= 1000) return d / 1000 + "s"; if (d >= 1000) return (d / 1000).toFixed(2) + "s";
return d; return d.toFixed(2);
}); });
this.svg.attr("height", (this.row.length + fixSpansSize + 1) * this.barHeight); this.svg.attr("height", (this.row.length + fixSpansSize + 1) * this.barHeight);
this.svg this.svg
.append("g") .append("g")
.attr("class", "trace-xaxis") .attr("class", "trace-xaxis")
.attr("transform", `translate(${this.width * 0.618 - 20},${30})`) .attr("transform", `translate(${this.width * (1 - xScaleWidth) - 20},${30})`)
.call(this.xAxis); .call(this.xAxis);
this.sequentialScale = d3
.scaleSequential()
.domain([0, this.list.length + 1])
.interpolator(d3.interpolateCool);
this.root = d3.hierarchy(this.data, (d) => d.children); this.root = d3.hierarchy(this.data, (d) => d.children);
this.root.x0 = 0; this.root.x0 = 0;
this.root.y0 = 0; this.root.y0 = 0;
@@ -141,7 +146,6 @@ export default class ListGraph {
} }
update(source: Recordable, callback: Function) { update(source: Recordable, callback: Function) {
const t = this; const t = this;
const appStore = useAppStoreWithOut();
const nodes = this.root.descendants(); const nodes = this.root.descendants();
let index = -1; let index = -1;
this.root.eachBefore((n: Recordable) => { this.root.eachBefore((n: Recordable) => {
@@ -186,12 +190,12 @@ export default class ListGraph {
}); });
nodeEnter nodeEnter
.append("rect") .append("rect")
.attr("height", 42) .attr("height", this.barHeight)
.attr("ry", 2) .attr("ry", 2)
.attr("rx", 2) .attr("rx", 2)
.attr("y", -22) .attr("y", -this.barHeight / 2)
.attr("x", 20) .attr("x", 20)
.attr("width", "100%") .attr("width", "200px")
.attr("fill", "rgba(0,0,0,0)"); .attr("fill", "rgba(0,0,0,0)");
nodeEnter nodeEnter
.append("image") .append("image")
@@ -242,24 +246,18 @@ export default class ListGraph {
event.stopPropagation(); event.stopPropagation();
t.prompt.hide(d, this); t.prompt.hide(d, this);
}); });
nodeEnter
.append("text")
.attr("x", 13)
.attr("y", 5)
.attr("fill", `#e54c17`)
.html((d: Recordable) => (d.data.isError ? "◉" : ""));
nodeEnter nodeEnter
.append("text") .append("text")
.attr("class", "node-text") .attr("class", "node-text")
.attr("x", 35) .attr("x", 35)
.attr("y", -6) .attr("y", -6)
.attr("fill", (d: Recordable) => (d.data.isError ? `#e54c17` : appStore.theme === Themes.Dark ? "#eee" : "#333")) .attr("fill", (d: { data: Span }) => (d.data.isError ? `var(--el-color-danger)` : `var(--font-color)`))
.html((d: Recordable) => { .html((d: { data: Span }) => {
const { label } = d.data;
if (d.data.label === "TRACE_ROOT") { if (d.data.label === "TRACE_ROOT") {
return ""; return "";
} }
const label = d.data.label.length > 30 ? `${d.data.label.slice(0, 30)}...` : `${d.data.label}`; return (label || "").length > 30 ? `${label?.slice(0, 30)}...` : `${label}`;
return label;
}); });
nodeEnter nodeEnter
.append("circle") .append("circle")
@@ -289,7 +287,7 @@ export default class ListGraph {
.attr("y", -1) .attr("y", -1)
.attr("fill", "#e66") .attr("fill", "#e66")
.style("font-size", "10px") .style("font-size", "10px")
.text((d: Recordable) => { .text((d: { data: Span }) => {
const events = d.data.attachedEvents; const events = d.data.attachedEvents;
if (events && events.length) { if (events && events.length) {
return `${events.length}`; return `${events.length}`;
@@ -302,18 +300,15 @@ export default class ListGraph {
.attr("class", "node-text") .attr("class", "node-text")
.attr("x", 35) .attr("x", 35)
.attr("y", 12) .attr("y", 12)
.attr("fill", appStore.theme === Themes.Dark ? "#777" : "#ccc") .attr("fill", (d: { data: Span }) => (d.data.isError ? `var(--el-color-danger)` : `var(--disabled-color)`))
.style("font-size", "11px") .style("font-size", "11px")
.text( .text((d: { data: Span }) => {
(d: Recordable) => const { layer, component, event, startTime, startTimeNanos } = d.data;
`${d.data.layer || ""} ${ const text = `${layer || ""} ${
d.data.component component ? "- " + component : event ? this.visDate(startTime) + ":" + startTimeNanos : ""
? "- " + d.data.component }`;
: d.data.event return text.length > 20 ? `${text?.slice(0, 20)}...` : `${text}`;
? this.visDate(d.data.startTime) + ":" + d.data.startTimeNanos });
: ""
}`,
);
nodeEnter nodeEnter
.append("rect") .append("rect")
.attr("rx", 2) .attr("rx", 2)
@@ -321,15 +316,33 @@ export default class ListGraph {
.attr("height", 4) .attr("height", 4)
.attr("width", (d: Recordable) => { .attr("width", (d: Recordable) => {
if (!d.data.endTime || !d.data.startTime) return 0; if (!d.data.endTime || !d.data.startTime) return 0;
return this.xScale(d.data.endTime - d.data.startTime) + 1 || 0; // Calculate the actual start and end times within the visible range
let spanStart = d.data.startTime;
let spanEnd = d.data.endTime;
const isIn = d.data.startTime > this.maxTimestamp || d.data.endTime < this.minTimestamp;
if (isIn) return 0;
// If the span is completely outside the visible range, don't show it
if (spanStart >= spanEnd) return 0;
if (spanStart < this.minTimestamp) spanStart = this.minTimestamp;
if (spanEnd > this.maxTimestamp) spanEnd = this.maxTimestamp;
if (spanStart >= spanEnd) return 0;
const min = d3.min(this.row.map((i) => i.startTime));
return this.xScale(spanEnd - min) - this.xScale(spanStart - min) + 1 || 0;
})
.attr("x", (d: Recordable) => {
if (!d.data.endTime || !d.data.startTime) return 0;
const isIn = d.data.startTime > this.maxTimestamp || d.data.endTime < this.minTimestamp;
if (isIn) return 0;
// Calculate the actual start time within the visible range
let spanStart = d.data.startTime;
if (spanStart < this.minTimestamp) spanStart = this.minTimestamp;
const min = d3.min(this.row.map((i) => i.startTime));
return this.width * (1 - xScaleWidth) - d.y - 25 + this.xScale(spanStart - min) || 0;
}) })
.attr("x", (d: Recordable) =>
!d.data.endTime || !d.data.startTime
? 0
: this.width * 0.618 - 20 - d.y + this.xScale(d.data.startTime - this.min) || 0,
)
.attr("y", -2) .attr("y", -2)
.style("fill", (d: Recordable) => `${this.sequentialScale(this.list.indexOf(d.data.serviceCode))}`); .style("fill", (d: Recordable) => `${getServiceColor(d.data.serviceCode || "")}`);
const nodeUpdate = nodeEnter.merge(node); const nodeUpdate = nodeEnter.merge(node);
nodeUpdate nodeUpdate
.transition() .transition()
@@ -338,14 +351,12 @@ export default class ListGraph {
.style("opacity", 1); .style("opacity", 1);
nodeUpdate nodeUpdate
.append("circle") .append("circle")
.attr("r", appStore.theme === Themes.Dark ? 4 : 3) .attr("r", 3)
.style("cursor", "pointer") .style("cursor", "pointer")
.attr("stroke-width", appStore.theme === Themes.Dark ? 3 : 2.5) .attr("stroke-width", 3)
.style("fill", (d: Recordable) => .style("fill", (d: Recordable) => (d._children ? `${getServiceColor(d.data.serviceCode)}` : "#eee"))
d._children ? `${this.sequentialScale(this.list.indexOf(d.data.serviceCode))}` : "#eee",
)
.style("stroke", (d: Recordable) => .style("stroke", (d: Recordable) =>
d.data.label === "TRACE_ROOT" ? "" : `${this.sequentialScale(this.list.indexOf(d.data.serviceCode))}`, d.data.label === "TRACE_ROOT" ? "" : `${getServiceColor(d.data.serviceCode)}`,
) )
.on("click", (event: any, d: Recordable) => { .on("click", (event: any, d: Recordable) => {
event.stopPropagation(); event.stopPropagation();
@@ -368,8 +379,8 @@ export default class ListGraph {
.enter() .enter()
.insert("path", "g") .insert("path", "g")
.attr("class", "trace-link") .attr("class", "trace-link")
.attr("fill", appStore.theme === Themes.Dark ? "rgba(244,244,244,0)" : "rgba(0,0,0,0)") .attr("fill", "none")
.attr("stroke", appStore.theme === Themes.Dark ? "rgba(244,244,244,0.4)" : "rgba(0, 0, 0, 0.1)") .attr("stroke", "var(--sw-trace-list-path)")
.attr("stroke-width", 2) .attr("stroke-width", 2)
.attr("transform", `translate(5, 0)`) .attr("transform", `translate(5, 0)`)
.attr("d", () => { .attr("d", () => {
@@ -432,6 +443,23 @@ export default class ListGraph {
behavior: "smooth", behavior: "smooth",
}); });
} }
highlightSpan(span: Recordable) {
if (!span) {
return;
}
const nodes = this.root?.descendants ? this.root.descendants() : [];
const targetNode = nodes.find(
(node: { data: Span }) =>
span.spanId === node.data.spanId &&
span.segmentId === node.data.segmentId &&
span.traceId === node.data.traceId,
);
if (!targetNode) return;
this.selectedNode?.classed("highlighted", false);
const sel = d3.select(`#list-node-${targetNode.id}`);
sel.classed("highlighted", true);
this.selectedNode = sel;
}
visDate(date: number, pattern = "YYYY-MM-DD HH:mm:ss:SSS") { visDate(date: number, pattern = "YYYY-MM-DD HH:mm:ss:SSS") {
return dayjs(date).format(pattern); return dayjs(date).format(pattern);
} }

View File

@@ -19,8 +19,8 @@ import * as d3 from "d3";
import d3tip from "d3-tip"; import d3tip from "d3-tip";
import type { Trace, Span } from "@/types/trace"; import type { Trace, Span } from "@/types/trace";
import { useAppStoreWithOut } from "@/store/modules/app"; import { useAppStoreWithOut } from "@/store/modules/app";
import { useTraceStore } from "@/store/modules/trace";
import { Themes } from "@/constants/data"; import { Themes } from "@/constants/data";
import { getServiceColor } from "@/utils/color";
export default class TraceMap { export default class TraceMap {
private i = 0; private i = 0;
@@ -36,11 +36,9 @@ export default class TraceMap {
private treemap: Nullable<any> = null; private treemap: Nullable<any> = null;
private data: Nullable<any> = null; private data: Nullable<any> = null;
private row: Nullable<any> = null; private row: Nullable<any> = null;
private min = 0; private minTimestamp = 0;
private max = 0; private maxTimestamp = 0;
private list: string[] = [];
private xScale: Nullable<any> = null; private xScale: Nullable<any> = null;
private sequentialScale: Nullable<any> = null;
private root: Nullable<any> = null; private root: Nullable<any> = null;
private topSlowMax: number[] = []; private topSlowMax: number[] = [];
private topSlowMin: number[] = []; private topSlowMin: number[] = [];
@@ -49,14 +47,14 @@ export default class TraceMap {
private nodeUpdate: Nullable<any> = null; private nodeUpdate: Nullable<any> = null;
private selectedNode: any = null; private selectedNode: any = null;
constructor(el: HTMLDivElement, handleSelectSpan: (i: Trace) => void) { constructor({ el, handleSelectSpan }: { el: HTMLDivElement; handleSelectSpan: (i: Trace) => void }) {
this.el = el; this.el = el;
this.handleSelectSpan = handleSelectSpan; this.handleSelectSpan = handleSelectSpan;
this.i = 0; this.i = 0;
this.topSlow = []; this.topSlow = [];
this.topChild = []; this.topChild = [];
this.width = el.clientWidth - 20; this.width = el.getBoundingClientRect().width - 10;
this.height = el.clientHeight - 30; this.height = el.getBoundingClientRect().height - 10;
d3.select(`.${this.el?.className} .d3-trace-tree`).remove(); d3.select(`.${this.el?.className} .d3-trace-tree`).remove();
this.body = d3 this.body = d3
.select(this.el) .select(this.el)
@@ -81,19 +79,28 @@ export default class TraceMap {
this.svg = this.body.append("g").attr("transform", () => `translate(120, 0)`); this.svg = this.body.append("g").attr("transform", () => `translate(120, 0)`);
this.svg.call(this.tip); this.svg.call(this.tip);
} }
init(data: Recordable, row: Recordable) { init({
data,
row,
selectedMaxTimestamp,
selectedMinTimestamp,
}: {
data: Span;
row: Span[];
selectedMaxTimestamp?: number;
selectedMinTimestamp?: number;
}) {
d3.select("#trace-action-box").style("display", "none"); d3.select("#trace-action-box").style("display", "none");
this.treemap = d3.tree().size([row.length * 35, this.width]); this.treemap = d3.tree().size([row.length * 35, this.width]);
this.row = row; this.row = row;
this.data = data; this.data = data;
this.min = Number(d3.min(this.row.map((i: Span) => i.startTime))); this.minTimestamp = selectedMinTimestamp ?? Number(d3.min(this.row.map((i: Span) => i.startTime)));
this.max = Number(d3.max(this.row.map((i: Span) => i.endTime - this.min))); this.maxTimestamp =
this.list = useTraceStore().serviceList || []; selectedMaxTimestamp ?? Number(d3.max(this.row.map((i: Span) => i.endTime - this.minTimestamp)));
this.xScale = d3.scaleLinear().range([0, 100]).domain([0, this.max]); this.xScale = d3
this.sequentialScale = d3 .scaleLinear()
.scaleSequential() .range([0, 100])
.domain([0, this.list.length + 1]) .domain([0, this.maxTimestamp - this.minTimestamp]);
.interpolator(d3.interpolateCool);
this.body.call(this.getZoomBehavior(this.svg)); this.body.call(this.getZoomBehavior(this.svg));
this.root = d3.hierarchy(this.data, (d) => d.children); this.root = d3.hierarchy(this.data, (d) => d.children);
@@ -102,7 +109,7 @@ export default class TraceMap {
this.topSlow = []; this.topSlow = [];
this.topChild = []; this.topChild = [];
const that = this; const that = this;
this.root.children.forEach(collapse); this.root.children?.forEach(collapse);
this.topSlowMax = this.topSlow.sort((a: number, b: number) => b - a)[0]; this.topSlowMax = this.topSlow.sort((a: number, b: number) => b - a)[0];
this.topSlowMin = this.topSlow.sort((a: number, b: number) => b - a)[4]; this.topSlowMin = this.topSlow.sort((a: number, b: number) => b - a)[4];
this.topChildMax = this.topChild.sort((a: number, b: number) => b - a)[0]; this.topChildMax = this.topChild.sort((a: number, b: number) => b - a)[0];
@@ -218,11 +225,9 @@ export default class TraceMap {
.append("circle") .append("circle")
.attr("class", "node") .attr("class", "node")
.attr("r", 2) .attr("r", 2)
.attr("stroke", (d: Recordable) => this.sequentialScale(this.list.indexOf(d.data.serviceCode))) .attr("stroke", (d: Recordable) => getServiceColor(d.data.serviceCode))
.attr("stroke-width", 2.5) .attr("stroke-width", 2.5)
.attr("fill", (d: Recordable) => .attr("fill", (d: Recordable) => (d.data.children.length ? getServiceColor(d.data.serviceCode) : "#fff"));
d.data.children.length ? this.sequentialScale(this.list.indexOf(d.data.serviceCode)) : "#fff",
);
nodeEnter nodeEnter
.append("text") .append("text")
.attr("class", "trace-node-text") .attr("class", "trace-node-text")
@@ -276,21 +281,31 @@ export default class TraceMap {
.attr("rx", 1) .attr("rx", 1)
.attr("ry", 1) .attr("ry", 1)
.attr("height", 2) .attr("height", 2)
.attr("width", (d: Recordable) => { .attr("width", (d: { data: Span }) => {
if (!d.data.endTime || !d.data.startTime) return 0; let spanStart = d.data.startTime;
return this.xScale(d.data.endTime - d.data.startTime) + 1 || 0; let spanEnd = d.data.endTime;
if (!spanEnd || !spanStart) return 0;
if (spanStart > this.maxTimestamp || spanEnd < this.minTimestamp) return 0;
if (spanStart < this.minTimestamp) spanStart = this.minTimestamp;
if (spanEnd > this.maxTimestamp) spanEnd = this.maxTimestamp;
return this.xScale(spanEnd - spanStart) + 1 || 0;
}) })
.attr("x", (d: Recordable) => { .attr("x", (d: Indexable) => {
if (!d.data.endTime || !d.data.startTime) { let spanStart = d.data.startTime;
let spanEnd = d.data.endTime;
if (!spanEnd || !spanStart) {
return 0; return 0;
} }
if (spanStart > this.maxTimestamp || spanEnd < this.minTimestamp) return 0;
if (spanStart < this.minTimestamp) spanStart = this.minTimestamp;
if (spanEnd > this.maxTimestamp) spanEnd = this.maxTimestamp;
if (d.children || d._children) { if (d.children || d._children) {
return -110 + this.xScale(d.data.startTime - this.min); return -110 + this.xScale(spanStart - this.minTimestamp);
} }
return 10 + this.xScale(d.data.startTime - this.min); return 10 + this.xScale(spanStart - this.minTimestamp);
}) })
.attr("y", -1) .attr("y", -1)
.style("fill", (d: Recordable) => this.sequentialScale(this.list.indexOf(d.data.serviceCode))); .style("fill", (d: { data: Span }) => getServiceColor(d.data.serviceCode));
const nodeUpdate = nodeEnter.merge(node); const nodeUpdate = nodeEnter.merge(node);
this.nodeUpdate = nodeUpdate; this.nodeUpdate = nodeUpdate;
nodeUpdate nodeUpdate

View File

@@ -0,0 +1,281 @@
/**
* 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 type { Span, Ref } from "@/types/trace";
/* global Indexable */
export interface BuildTreeResult {
roots: Span[];
fixSpansSize: number;
refSpans: Ref[];
}
export function buildSegmentForest(data: Span[], traceId: string): BuildTreeResult {
const refSpans: Ref[] = [];
const segmentGroup: { [key: string]: any } = {};
const segmentIdGroup: string[] = [];
const fixSpans: Span[] = [];
const segmentHeaders: Span[] = [];
if (!data || data.length === 0) {
return { roots: [], fixSpansSize: 0, refSpans };
}
for (const span of data) {
if (span.refs && span.refs.length) {
refSpans.push(...span.refs);
}
if (span.parentSpanId === -1) {
segmentHeaders.push(span);
} else {
const item = data.find(
(i: Span) => i.traceId === span.traceId && i.segmentId === span.segmentId && i.spanId === span.spanId - 1,
);
const content = fixSpans.find(
(i: Span) =>
i.traceId === span.traceId &&
i.segmentId === span.segmentId &&
i.spanId === span.spanId - 1 &&
i.parentSpanId === span.spanId - 2,
);
if (!item && !content) {
fixSpans.push({
traceId: span.traceId,
segmentId: span.segmentId,
spanId: span.spanId - 1,
parentSpanId: span.spanId - 2,
refs: [],
endpointName: `VNode: ${span.segmentId}`,
serviceCode: "VirtualNode",
type: `[Broken] ${span.type}`,
peer: "",
component: `VirtualNode: #${span.spanId - 1}`,
isError: true,
isBroken: true,
layer: "Broken",
tags: [],
logs: [],
startTime: 0,
endTime: 0,
} as Span);
}
}
}
for (const span of segmentHeaders) {
if (span.refs && span.refs.length) {
let exit: Span | null = null;
for (const ref of span.refs) {
const e = data.find(
(i: Span) =>
ref.traceId === i.traceId && ref.parentSegmentId === i.segmentId && ref.parentSpanId === i.spanId,
);
if (e) {
exit = e;
}
}
if (!exit) {
const ref = span.refs[0];
const parentSpanId = ref.parentSpanId > -1 ? 0 : -1;
const content = fixSpans.find(
(i: Span) =>
i.traceId === ref.traceId &&
i.segmentId === ref.parentSegmentId &&
i.spanId === ref.parentSpanId &&
i.parentSpanId === parentSpanId,
);
if (!content) {
fixSpans.push({
traceId: ref.traceId,
segmentId: ref.parentSegmentId,
spanId: ref.parentSpanId,
parentSpanId,
refs: [],
endpointName: `VNode: ${ref.parentSegmentId}`,
serviceCode: "VirtualNode",
type: `[Broken] ${ref.type}`,
peer: "",
component: `VirtualNode: #${ref.parentSpanId}`,
isError: true,
isBroken: true,
layer: "Broken",
tags: [],
logs: [],
startTime: 0,
endTime: 0,
} as Span);
}
if (parentSpanId > -1) {
const exists = fixSpans.find(
(i: Span) =>
i.traceId === ref.traceId &&
i.segmentId === ref.parentSegmentId &&
i.spanId === 0 &&
i.parentSpanId === -1,
);
if (!exists) {
fixSpans.push({
traceId: ref.traceId,
segmentId: ref.parentSegmentId,
spanId: 0,
parentSpanId: -1,
refs: [],
endpointName: `VNode: ${ref.parentSegmentId}`,
serviceCode: "VirtualNode",
type: `[Broken] ${ref.type}`,
peer: "",
component: `VirtualNode: #0`,
isError: true,
isBroken: true,
layer: "Broken",
tags: [],
logs: [],
startTime: 0,
endTime: 0,
} as Span);
}
}
}
}
}
for (const i of [...fixSpans, ...data]) {
i.label = i.endpointName || "no operation name";
i.key = Math.random().toString(36).substring(2, 36);
i.children = [];
if (segmentGroup[i.segmentId]) {
segmentGroup[i.segmentId].push(i);
} else {
segmentIdGroup.push(i.segmentId);
segmentGroup[i.segmentId] = [i];
}
}
for (const id of segmentIdGroup) {
const currentSegment = segmentGroup[id].sort((a: Span, b: Span) => b.parentSpanId - a.parentSpanId);
for (const s of currentSegment) {
const index = currentSegment.findIndex((i: Span) => i.spanId === s.parentSpanId);
if (index > -1) {
if (
(currentSegment[index].isBroken && currentSegment[index].parentSpanId === -1) ||
!currentSegment[index].isBroken
) {
currentSegment[index].children?.push(s);
currentSegment[index].children?.sort((a: Span, b: Span) => a.spanId - b.spanId);
}
}
if (s.isBroken) {
const children = data.filter(
(span: Span) =>
!!span.refs?.find(
(d) => d.traceId === s.traceId && d.parentSegmentId === s.segmentId && d.parentSpanId === s.spanId,
),
);
if (children.length) {
s.children?.push(...children);
}
}
}
segmentGroup[id] = currentSegment[currentSegment.length - 1];
}
for (const id of segmentIdGroup) {
for (const ref of segmentGroup[id].refs || []) {
if (ref.traceId === traceId) {
traverseTree(segmentGroup[ref.parentSegmentId], ref.parentSpanId, ref.parentSegmentId, segmentGroup[id]);
}
}
}
const roots: Span[] = [];
for (const i in segmentGroup) {
let pushed = false;
for (const ref of segmentGroup[i].refs || []) {
if (!segmentGroup[ref.parentSegmentId]) {
roots.push(segmentGroup[i]);
pushed = true;
break;
}
}
if (
!pushed &&
(!segmentGroup[i].refs || segmentGroup[i].refs.length === 0) &&
segmentGroup[i].parentSpanId === -1
) {
roots.push(segmentGroup[i]);
}
}
return { roots, fixSpansSize: fixSpans.length, refSpans };
}
export function collapseTree(d: Span, refSpans: Ref[]): void {
if (d.children) {
const item = refSpans.find((s: Ref) => s.parentSpanId === d.spanId && s.parentSegmentId === d.segmentId);
let dur = d.endTime - d.startTime;
for (const i of d.children) {
dur -= i.endTime - i.startTime;
}
d.dur = dur < 0 ? 0 : dur;
if (item) {
d.children = d.children?.sort(compare("startTime"));
}
for (const i of d.children) {
collapseTree(i, refSpans);
}
}
}
function traverseTree(node: Span, spanId: number, segmentId: string, data: Span) {
if (!node || node.isBroken) {
return;
}
if (node.spanId === spanId && node.segmentId === segmentId) {
node.children?.push(data);
return;
}
for (const nodeItem of (node as Span).children || []) {
traverseTree(nodeItem, spanId, segmentId, data);
}
}
function compare(p: string) {
return (m: Span, n: Span) => {
const a = (m as Indexable)[p];
const b = (n as Indexable)[p];
return a - b;
};
}
export function getRefsAllNodes(tree: Indexable) {
const nodes = [];
const 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;
}

View File

@@ -16,11 +16,11 @@ limitations under the License. -->
<template> <template>
<div class="trace-table"> <div class="trace-table">
<div class="trace-table-header" v-if="type === TraceGraphType.STATISTICS"> <div class="trace-table-header" v-if="type === TraceGraphType.STATISTICS">
<div :class="item.label" v-for="(item, index) in headerData" :key="index"> <div :class="item.label" v-for="(item, index) in headerData as typeof StatisticsConstant" :key="index">
{{ item.value }} {{ item.value }}
<span <span
class="r cp" class="r cp"
@click="sortStatistics(item.key)" @click="sortStatistics(item.key || '')"
:key="componentKey" :key="componentKey"
v-if="item.key !== 'endpointName' && item.key !== 'type'" v-if="item.key !== 'endpointName' && item.key !== 'type'"
> >
@@ -47,34 +47,41 @@ limitations under the License. -->
:key="`key${index}`" :key="`key${index}`"
:type="type" :type="type"
:headerType="headerType" :headerType="headerType"
@click="selectItem" :selectedMaxTimestamp="selectedMaxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
@selectedSpan="selectItem"
/> />
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import type { PropType } from "vue"; import type { Span } from "@/types/trace";
import { useTraceStore } from "@/store/modules/trace";
import TableItem from "./TableItem.vue"; import TableItem from "./TableItem.vue";
import { ProfileConstant, TraceConstant, StatisticsConstant } from "./data"; import { ProfileConstant, TraceConstant, StatisticsConstant } from "./data";
import { TraceGraphType } from "../constant"; import { TraceGraphType } from "../VisGraph/constant";
import { WidgetType } from "@/views/dashboard/data"; import { WidgetType } from "@/views/dashboard/data";
/* global defineProps, Nullable, defineEmits, Recordable*/ /* global defineProps, Nullable, defineEmits*/
const props = defineProps({ type Props = {
tableData: { type: Array as PropType<Recordable>, default: () => [] }, tableData: Span[];
type: { type: String, default: "" }, type?: string;
headerType: { type: String, default: "" }, headerType?: string;
traceId: { type: String, default: "" }, traceId: string;
}); selectedMaxTimestamp?: number;
const emits = defineEmits(["select"]); selectedMinTimestamp?: number;
const traceStore = useTraceStore(); };
type Emits = {
(e: "select", value: Span): void;
};
const props = defineProps<Props>();
const emits = defineEmits<Emits>();
const method = ref<number>(300); const method = ref<number>(300);
const componentKey = ref<number>(300); const componentKey = ref<number>(300);
const flag = ref<boolean>(true); const flag = ref<boolean>(true);
const dragger = ref<Nullable<HTMLSpanElement>>(null); const dragger = ref<Nullable<HTMLSpanElement>>(null);
let headerData: Recordable[] = TraceConstant; let headerData: typeof TraceConstant | typeof ProfileConstant | typeof StatisticsConstant = TraceConstant;
if (props.headerType === WidgetType.Profile) { if (props.headerType === WidgetType.Profile) {
headerData = ProfileConstant; headerData = ProfileConstant;
@@ -104,31 +111,15 @@ limitations under the License. -->
}; };
}; };
}); });
function selectItem(event: MouseEvent) { function selectItem(span: Span) {
emits("select", traceStore.selectedSpan); emits("select", span);
if (props.headerType === WidgetType.Profile) {
return;
}
if (props.type === TraceGraphType.STATISTICS) {
return;
}
const item: any = document.querySelector("#trace-action-box");
const tableBox = document.querySelector(".trace-table-charts")?.getBoundingClientRect();
if (!tableBox) {
return;
}
const offsetX = event.x - tableBox.x;
const offsetY = event.y - tableBox.y;
item.style.display = "block";
item.style.top = `${offsetY + 20}px`;
item.style.left = `${offsetX + 10}px`;
} }
function sortStatistics(key: string) { function sortStatistics(key: string) {
const element = props.tableData; const element = props.tableData;
for (let i = 0; i < element.length; i++) { for (let i = 0; i < element.length; i++) {
for (let j = 0; j < element.length - i - 1; j++) { for (let j = 0; j < element.length - i - 1; j++) {
let val1; let val1: number | undefined;
let val2; let val2: number | undefined;
if (key === "maxTime") { if (key === "maxTime") {
val1 = element[j].maxTime; val1 = element[j].maxTime;
val2 = element[j + 1].maxTime; val2 = element[j + 1].maxTime;
@@ -150,13 +141,13 @@ limitations under the License. -->
val2 = element[j + 1].count; val2 = element[j + 1].count;
} }
if (flag.value) { if (flag.value) {
if (val1 < val2) { if (val1 && val2 && val1 < val2) {
const tmp = element[j]; const tmp = element[j];
element[j] = element[j + 1]; element[j] = element[j + 1];
element[j + 1] = tmp; element[j + 1] = tmp;
} }
} else { } else {
if (val1 > val2) { if (val1 && val2 && val1 > val2) {
const tmp = element[j]; const tmp = element[j];
element[j] = element[j + 1]; element[j] = element[j + 1];
element[j + 1] = tmp; element[j + 1] = tmp;

View File

@@ -49,15 +49,16 @@ limitations under the License. -->
</div> </div>
<div v-else> <div v-else>
<div <div
@click="selectSpan"
:class="[ :class="[
'trace-item', 'trace-item',
'level' + ((data.level || 0) - 1), 'level' + ((data.level || 0) - 1),
{ 'trace-item-error': data.isError }, { 'trace-item-error': data.isError },
{ profiled: data.profiled === false }, { profiled: data.profiled === false },
`trace-item-${data.key}`, `trace-item-${data.key}`,
{ highlighted: inTimeRange },
]" ]"
:data-text="data.profiled === false ? 'No Thread Dump' : ''" :data-text="data.profiled === false ? 'No Thread Dump' : ''"
@click="hideActionBox"
> >
<div <div
:class="['method', 'level' + ((data.level || 0) - 1)]" :class="['method', 'level' + ((data.level || 0) - 1)]"
@@ -65,6 +66,8 @@ limitations under the License. -->
'text-indent': ((data.level || 0) - 1) * 10 + 'px', 'text-indent': ((data.level || 0) - 1) * 10 + 'px',
width: `${method}px`, width: `${method}px`,
}" }"
@click="selectSpan"
@click.stop
> >
<Icon <Icon
:style="!displayChildren ? 'transform: rotate(-90deg);' : ''" :style="!displayChildren ? 'transform: rotate(-90deg);' : ''"
@@ -73,6 +76,7 @@ limitations under the License. -->
iconName="arrow-down" iconName="arrow-down"
size="sm" size="sm"
class="mr-5" class="mr-5"
@click="hideActionBox"
/> />
<el-tooltip <el-tooltip
:content="data.type === 'Entry' ? 'Entry' : 'Exit'" :content="data.type === 'Entry' ? 'Entry' : 'Exit'"
@@ -90,7 +94,7 @@ limitations under the License. -->
</span> </span>
</el-tooltip> </el-tooltip>
<el-tooltip :content="data.endpointName" placement="top" :show-after="300"> <el-tooltip :content="data.endpointName" placement="top" :show-after="300">
<span> <span class="link-span">
{{ data.endpointName }} {{ data.endpointName }}
</span> </span>
</el-tooltip> </el-tooltip>
@@ -116,7 +120,7 @@ limitations under the License. -->
</div> </div>
<div class="application"> <div class="application">
<el-tooltip :show-after="300" :content="data.serviceCode || '-'" placement="top"> <el-tooltip :show-after="300" :content="data.serviceCode || '-'" placement="top">
<span>{{ data.serviceCode }}</span> <span :style="{ color: getServiceColor(data.serviceCode || '') }">{{ data.serviceCode }}</span>
</el-tooltip> </el-tooltip>
</div> </div>
<div class="application" v-show="headerType === WidgetType.Profile" @click="viewSpan($event)"> <div class="application" v-show="headerType === WidgetType.Profile" @click="viewSpan($event)">
@@ -128,12 +132,15 @@ limitations under the License. -->
</div> </div>
<div v-show="data.children && data.children.length > 0 && displayChildren" class="children-trace"> <div v-show="data.children && data.children.length > 0 && displayChildren" class="children-trace">
<table-item <table-item
:method="method"
v-for="(child, index) in data.children" v-for="(child, index) in data.children"
:method="method"
:key="index" :key="index"
:data="child" :data="child"
:type="type" :type="type"
:headerType="headerType" :headerType="headerType"
:selectedMaxTimestamp="selectedMaxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
@selectedSpan="selectItem"
/> />
</div> </div>
<el-dialog v-model="showDetail" :destroy-on-close="true" fullscreen @closed="showDetail = false"> <el-dialog v-model="showDetail" :destroy-on-close="true" fullscreen @closed="showDetail = false">
@@ -141,136 +148,150 @@ limitations under the License. -->
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { ref, computed, defineComponent, watch } from "vue"; import { ref, computed, watch } from "vue";
import type { PropType } from "vue";
import SpanDetail from "../D3Graph/SpanDetail.vue"; import SpanDetail from "../D3Graph/SpanDetail.vue";
import { dateFormat } from "@/utils/dateFormat"; import { dateFormat } from "@/utils/dateFormat";
import { useAppStoreWithOut } from "@/store/modules/app"; import { useAppStoreWithOut } from "@/store/modules/app";
import { useTraceStore } from "@/store/modules/trace"; import { useTraceStore } from "@/store/modules/trace";
import { Themes } from "@/constants/data"; import { TraceGraphType } from "../VisGraph/constant";
import { TraceGraphType } from "../constant";
import { WidgetType } from "@/views/dashboard/data"; import { WidgetType } from "@/views/dashboard/data";
import type { Span, Ref } from "@/types/trace"; import type { Span, Ref } from "@/types/trace";
import { getServiceColor } from "@/utils/color";
/*global Recordable*/ interface Props {
const props = { data: Span;
data: { type: Object as PropType<Span>, default: () => ({}) }, method: number;
method: { type: Number, default: 0 }, type?: string;
type: { type: String, default: "" }, headerType?: string;
headerType: { type: String, default: "" }, traceId?: string;
traceId: { type: String, traceId: "" }, selectedMaxTimestamp?: number;
}; selectedMinTimestamp?: number;
export default defineComponent({ }
name: "TableItem", interface Emits {
props, (e: "selectedSpan", value: Span): void;
components: { SpanDetail }, }
setup(props) { const emits = defineEmits<Emits>();
const appStore = useAppStoreWithOut(); const props = defineProps<Props>();
const traceStore = useTraceStore(); const appStore = useAppStoreWithOut();
const displayChildren = ref<boolean>(true); const traceStore = useTraceStore();
const showDetail = ref<boolean>(false); const displayChildren = ref<boolean>(true);
const { t } = useI18n(); const showDetail = ref<boolean>(false);
const selfTime = computed(() => (props.data.dur ? props.data.dur : 0)); const { t } = useI18n();
const execTime = computed(() => const selfTime = computed(() => (props.data.dur ? props.data.dur : 0));
props.data.endTime - props.data.startTime ? props.data.endTime - props.data.startTime : 0, const execTime = computed(() =>
); props.data.endTime - props.data.startTime > 0 ? props.data.endTime - props.data.startTime : 0,
const outterPercent = computed(() => { );
if (props.data.level === 1) { const outterPercent = computed(() => {
return "100%"; if (props.data.level === 1) {
} else { return "100%";
const data = props.data; } else {
const exec = data.endTime - data.startTime ? data.endTime - data.startTime : 0; const { data } = props;
let result = (exec / (data.totalExec || 0)) * 100; let result = (execTime.value / (data.totalExec || 0)) * 100;
result = result > 100 ? 100 : result; result = result > 100 ? 100 : result;
const resultStr = result.toFixed(4) + "%"; const resultStr = result.toFixed(4) + "%";
return resultStr === "0.0000%" ? "0.9%" : resultStr; return resultStr === "0.0000%" ? "0.9%" : resultStr;
} }
});
const innerPercent = computed(() => {
const result = (selfTime.value / execTime.value) * 100;
const resultStr = result.toFixed(4) + "%";
return resultStr === "0.0000%" ? "0.9%" : resultStr;
});
const isCrossThread = computed(() => {
const key = props.data.refs?.findIndex((d: Ref) => d.type === "CROSS_THREAD") ?? -1;
return key > -1 ? true : false;
});
function toggle() {
displayChildren.value = !displayChildren.value;
}
function showSelectSpan(dom: HTMLSpanElement) {
if (!dom) {
return;
}
const items: any = document.querySelectorAll(".trace-item");
for (const item of items) {
item.style.background = appStore.theme === Themes.Dark ? "#212224" : "#fff";
}
dom.style.background = appStore.theme === Themes.Dark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)";
const p: any = document.getElementsByClassName("profiled")[0];
if (p) {
p.style.background = appStore.theme === Themes.Dark ? "#333" : "#eee";
}
}
function selectSpan(event: Recordable) {
const dom = event.composedPath().find((d: Recordable) => d.className.includes("trace-item"));
selectedItem(props.data);
if (props.headerType === WidgetType.Profile) {
showSelectSpan(dom);
return;
}
viewSpanDetail(dom);
}
function viewSpan(event: Recordable) {
showDetail.value = true;
const dom = event.composedPath().find((d: Recordable) => d.className.includes("trace-item"));
selectedItem(props.data);
viewSpanDetail(dom);
}
function selectedItem(span: Span) {
traceStore.setSelectedSpan(span);
}
function viewSpanDetail(dom: HTMLSpanElement) {
showSelectSpan(dom);
if (props.type === TraceGraphType.STATISTICS) {
showDetail.value = true;
}
}
watch(
() => appStore.theme,
() => {
const items: any = document.querySelectorAll(".trace-item");
for (const item of items) {
item.style.background = appStore.theme === Themes.Dark ? "#212224" : "#fff";
}
const p: any = document.getElementsByClassName("profiled")[0];
if (p) {
p.style.background = appStore.theme === Themes.Dark ? "#333" : "#eee";
}
},
);
return {
displayChildren,
outterPercent,
innerPercent,
isCrossThread,
viewSpanDetail,
toggle,
dateFormat,
showSelectSpan,
showDetail,
selectSpan,
selectedItem,
viewSpan,
t,
appStore,
TraceGraphType,
WidgetType,
};
},
}); });
const innerPercent = computed(() => {
const result = (selfTime.value / execTime.value) * 100;
const resultStr = result.toFixed(4) + "%";
return resultStr === "0.0000%" ? "0.9%" : resultStr;
});
const isCrossThread = computed(() => {
const key = props.data.refs?.findIndex((d: Ref) => d.type === "CROSS_THREAD") ?? -1;
return key > -1 ? true : false;
});
const inTimeRange = computed(() => {
if (props.selectedMinTimestamp === undefined || props.selectedMaxTimestamp === undefined) {
return true;
}
return props.data.startTime <= props.selectedMaxTimestamp && props.data.endTime >= props.selectedMinTimestamp;
});
function toggle() {
displayChildren.value = !displayChildren.value;
}
function selectItem(span: Span) {
emits("selectedSpan", span);
}
function showSelectSpan(dom: HTMLSpanElement) {
if (!dom) {
return;
}
const items: HTMLSpanElement[] = Array.from(document.querySelectorAll(".trace-item")) as HTMLSpanElement[];
for (const item of items) {
item.style.background = "transparent";
}
dom.style.background = "var(--sw-trace-table-selected)";
const p = document.getElementsByClassName("profiled")[0] as HTMLSpanElement | null;
if (p) {
p.style.background = "var(--border-color-primary)";
}
}
function selectSpan(event: MouseEvent) {
emits("selectedSpan", props.data);
const dom = event
.composedPath()
.find((d: EventTarget) => (d as HTMLElement).className.includes("trace-item")) as HTMLSpanElement;
selectedItem(props.data);
if (props.headerType === WidgetType.Profile) {
showSelectSpan(dom);
return;
}
viewSpanDetail(dom);
if (props.type === TraceGraphType.STATISTICS) {
return;
}
const item: HTMLSpanElement | null = document.querySelector("#trace-action-box");
const tableBox = document.querySelector(".trace-table-charts")?.getBoundingClientRect();
if (!tableBox || !item) {
return;
}
const offsetX = event.x - tableBox.x;
const offsetY = event.y - tableBox.y;
item.style.display = "block";
item.style.top = `${offsetY + 20}px`;
item.style.left = `${offsetX + 10}px`;
}
function viewSpan(event: MouseEvent) {
showDetail.value = true;
const dom = event
.composedPath()
.find((d: EventTarget) => (d as HTMLElement).className.includes("trace-item")) as HTMLSpanElement;
selectedItem(props.data);
viewSpanDetail(dom);
}
function selectedItem(span: Span) {
traceStore.setSelectedSpan(span);
emits("selectedSpan", span);
}
function viewSpanDetail(dom: HTMLSpanElement) {
showSelectSpan(dom);
if (props.type === TraceGraphType.STATISTICS) {
showDetail.value = true;
}
}
function hideActionBox() {
const item: HTMLSpanElement | null = document.querySelector("#trace-action-box");
if (item) {
item.style.display = "none";
}
}
watch(
() => appStore.theme,
() => {
const items: HTMLSpanElement[] = Array.from(document.querySelectorAll(".trace-item")) as HTMLSpanElement[];
for (const item of items) {
item.style.background = "transparent";
}
const p = document.getElementsByClassName("profiled")[0] as HTMLSpanElement | null;
if (p) {
p.style.background = "var(--border-color-primary)";
}
},
);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import url("./table.scss"); @import url("./table.scss");
@@ -290,6 +311,10 @@ limitations under the License. -->
} }
} }
.highlighted {
color: var(--el-color-primary);
}
.profiled { .profiled {
background-color: var(--sw-table-header); background-color: var(--sw-table-header);
position: relative; position: relative;
@@ -334,7 +359,6 @@ limitations under the License. -->
.trace-item { .trace-item {
white-space: nowrap; white-space: nowrap;
position: relative; position: relative;
cursor: pointer;
} }
.trace-item.selected { .trace-item.selected {
@@ -358,6 +382,7 @@ limitations under the License. -->
.trace-item > div.method { .trace-item > div.method {
padding-left: 10px; padding-left: 10px;
cursor: pointer;
} }
.trace-item div.exec-percent { .trace-item div.exec-percent {
@@ -385,4 +410,8 @@ limitations under the License. -->
top: 1px; top: 1px;
} }
} }
.link-span {
text-decoration: underline;
}
</style> </style>

View File

@@ -48,7 +48,7 @@ export const ProfileConstant = [
label: "application", label: "application",
value: "Operation", value: "Operation",
}, },
]; ] as const;
export const TraceConstant = [ export const TraceConstant = [
{ {
@@ -83,7 +83,7 @@ export const TraceConstant = [
label: "application", label: "application",
value: "Attached Events", value: "Attached Events",
}, },
]; ] as const;
export const StatisticsConstant = [ export const StatisticsConstant = [
{ {
@@ -121,4 +121,4 @@ export const StatisticsConstant = [
value: "Hits", value: "Hits",
key: "count", key: "count",
}, },
]; ] as const;

View File

@@ -13,52 +13,56 @@ 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="flex-h row"> <div class="flex-h row" style="justify-content: space-between">
<div class="mr-10 flex-h" v-if="dashboardStore.entity === EntityType[1].value"> <div class="flex-h">
<span class="grey mr-5 label">{{ t("service") }}:</span> <div class="mr-10 flex-h" v-if="dashboardStore.entity === EntityType[1].value">
<Selector <span class="grey mr-5 label">{{ t("service") }}:</span>
size="small" <Selector
:value="state.service.value" size="small"
:options="traceStore.services" :value="state.service.value"
placeholder="Select a service" :options="traceStore.services"
@change="changeField('service', $event)" placeholder="Select a service"
/> @change="changeField('service', $event)"
/>
</div>
<div class="mr-10 flex-h" v-if="dashboardStore.entity !== EntityType[3].value">
<span class="grey mr-5 label">{{ t("instance") }}:</span>
<Selector
size="small"
:value="state.instance.value"
:options="traceStore.instances"
placeholder="Select a instance"
@change="changeField('instance', $event)"
/>
</div>
<div class="mr-10 flex-h" v-if="dashboardStore.entity !== EntityType[2].value">
<span class="grey mr-5 label">{{ t("endpoint") }}:</span>
<Selector
size="small"
:value="state.endpoint.value"
:options="traceStore.endpoints"
placeholder="Select a endpoint"
:isRemote="true"
@change="changeField('endpoint', $event)"
@query="searchEndpoints"
/>
</div>
<div class="mr-10 flex-h">
<span class="grey mr-5 label">{{ t("status") }}:</span>
<Selector
size="small"
:value="state.status.value"
:options="Status"
placeholder="Select a status"
@change="changeField('status', $event)"
/>
</div>
</div> </div>
<div class="mr-10 flex-h" v-if="dashboardStore.entity !== EntityType[3].value"> <div class="mr-10">
<span class="grey mr-5 label">{{ t("instance") }}:</span> <el-button type="primary" @click="searchTraces" :loading="traceStore.loading">
<Selector {{ t("runQuery") }}
size="small" </el-button>
:value="state.instance.value"
:options="traceStore.instances"
placeholder="Select a instance"
@change="changeField('instance', $event)"
/>
</div> </div>
<div class="mr-10 flex-h" v-if="dashboardStore.entity !== EntityType[2].value">
<span class="grey mr-5 label">{{ t("endpoint") }}:</span>
<Selector
size="small"
:value="state.endpoint.value"
:options="traceStore.endpoints"
placeholder="Select a endpoint"
:isRemote="true"
@change="changeField('endpoint', $event)"
@query="searchEndpoints"
/>
</div>
<div class="mr-10 flex-h">
<span class="grey mr-5 label">{{ t("status") }}:</span>
<Selector
size="small"
:value="state.status.value"
:options="Status"
placeholder="Select a status"
@change="changeField('status', $event)"
/>
</div>
<el-button size="small" type="primary" @click="searchTraces" class="search-btn">
{{ t("search") }}
</el-button>
</div> </div>
<div class="flex-h row"> <div class="flex-h row">
<div class="mr-10"> <div class="mr-10">
@@ -72,7 +76,7 @@ limitations under the License. -->
<el-input size="small" class="inputs" v-model="maxTraceDuration" type="number" /> <el-input size="small" class="inputs" v-model="maxTraceDuration" type="number" />
</div> </div>
<div> <div>
<span class="sm b grey mr-5">{{ t("timeRange") }}:</span> <span class="sm b grey mr-5">{{ t("timeRange") }}</span>
<TimePicker <TimePicker
:value="[durationRow.start, durationRow.end]" :value="[durationRow.start, durationRow.end]"
:maxRange="maxRange" :maxRange="maxRange"
@@ -91,15 +95,15 @@ limitations under the License. -->
import type { PropType } from "vue"; import type { PropType } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type { Option, DurationTime, Duration } from "@/types/app"; import type { Option, DurationTime, Duration } from "@/types/app";
import { useTraceStore } from "@/store/modules/trace"; import { useTraceStore, PageSize } from "@/store/modules/trace";
import { useDashboardStore } from "@/store/modules/dashboard"; import { useDashboardStore } from "@/store/modules/dashboard";
import { useAppStoreWithOut, InitializationDurationRow } from "@/store/modules/app"; import { useAppStoreWithOut, InitializationDurationRow } from "@/store/modules/app";
import { useSelectorStore } from "@/store/modules/selectors"; import { useSelectorStore } from "@/store/modules/selectors";
import timeFormat from "@/utils/timeFormat"; import timeFormat from "@/utils/timeFormat";
import ConditionTags from "@/views/components/ConditionTags.vue"; import ConditionTags from "@/views/components/ConditionTags.vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { EntityType, QueryOrders, Status } from "../../data"; import { EntityType, QueryOrders, Status } from "@/views/dashboard/data";
import type { LayoutConfig } from "@/types/dashboard"; import type { LayoutConfig, FilterDuration } from "@/types/dashboard";
import { useDuration } from "@/hooks/useDuration"; import { useDuration } from "@/hooks/useDuration";
/*global defineProps, defineEmits, Recordable */ /*global defineProps, defineEmits, Recordable */
@@ -108,7 +112,7 @@ limitations under the License. -->
needQuery: { type: Boolean, default: true }, needQuery: { type: Boolean, default: true },
data: { data: {
type: Object as PropType<LayoutConfig>, type: Object as PropType<LayoutConfig>,
default: () => ({ graph: {} }), default: () => ({}),
}, },
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -117,20 +121,19 @@ limitations under the License. -->
const dashboardStore = useDashboardStore(); const dashboardStore = useDashboardStore();
const traceStore: ReturnType<typeof useTraceStore> = useTraceStore(); const traceStore: ReturnType<typeof useTraceStore> = useTraceStore();
const { setDurationRow, getDurationTime, getMaxRange } = useDuration(); const { setDurationRow, getDurationTime, getMaxRange } = useDuration();
const filters = reactive<Recordable>(props.data.filters || {}); const filters = computed(() => props.data.filters || {});
const traceId = ref<string>(filters.traceId || ""); const traceId = ref<string>(filters.value.traceId || "");
const { duration: filtersDuration } = filters; const { duration: filtersDuration } = filters.value;
const duration = ref<DurationTime>( const duration = ref<DurationTime | FilterDuration>(
filtersDuration filtersDuration
? { start: filtersDuration.startTime || "", end: filtersDuration.endTime || "", step: filtersDuration.step || "" } ? { start: filtersDuration.startTime || "", end: filtersDuration.endTime || "", step: filtersDuration.step || "" }
: getDurationTime(), : getDurationTime(),
); );
const minTraceDuration = ref<number>(); const minTraceDuration = ref<number>();
const maxTraceDuration = ref<number>(); const maxTraceDuration = ref<number>();
const tagsList = ref<string[]>([]);
const tagsMap = ref<Option[]>([]); const tagsMap = ref<Option[]>([]);
const state = reactive<Recordable>({ const state = reactive<Recordable>({
status: filters.status === "ERROR" ? Status[2] : Status[0], status: filters.value.status === "ERROR" ? Status[2] : Status[0],
instance: { value: "0", label: "All" }, instance: { value: "0", label: "All" },
endpoint: { value: "0", label: "All" }, endpoint: { value: "0", label: "All" },
service: { value: "", label: "" }, service: { value: "", label: "" },
@@ -139,9 +142,9 @@ limitations under the License. -->
const maxRange = computed(() => const maxRange = computed(() =>
getMaxRange(appStore.coldStageMode ? appStore.recordsTTL?.coldTrace || 0 : appStore.recordsTTL?.trace || 0), getMaxRange(appStore.coldStageMode ? appStore.recordsTTL?.coldTrace || 0 : appStore.recordsTTL?.trace || 0),
); );
if (filters.queryOrder) { if (filters.value.queryOrder) {
traceStore.setTraceCondition({ traceStore.setTraceCondition({
queryOrder: filters.queryOrder, queryOrder: filters.value.queryOrder,
}); });
} }
if (props.needQuery) { if (props.needQuery) {
@@ -149,7 +152,7 @@ limitations under the License. -->
} }
async function init() { async function init() {
duration.value = filters.duration || appStore.durationTime; duration.value = filters.value.duration || appStore.durationTime;
if (dashboardStore.entity === EntityType[1].value) { if (dashboardStore.entity === EntityType[1].value) {
await getServices(); await getServices();
} }
@@ -221,8 +224,10 @@ limitations under the License. -->
minTraceDuration: Number(minTraceDuration.value), minTraceDuration: Number(minTraceDuration.value),
maxTraceDuration: Number(maxTraceDuration.value), maxTraceDuration: Number(maxTraceDuration.value),
traceId: traceId.value || undefined, traceId: traceId.value || undefined,
paging: { pageNum: 1, pageSize: 20 },
}; };
if (!traceStore.hasQueryTracesV2Support) {
param.paging = { pageNum: 1, pageSize: PageSize };
}
if (props.data.filters && props.data.filters.id) { if (props.data.filters && props.data.filters.id) {
param = { param = {
...param, ...param,
@@ -267,8 +272,7 @@ limitations under the License. -->
emits("get", state.service.id); emits("get", state.service.id);
} }
} }
function updateTags(data: { tagsMap: Array<Option>; tagsList: string[] }) { function updateTags(data: { tagsMap: Array<Option> }) {
tagsList.value = data.tagsList;
tagsMap.value = data.tagsMap; tagsMap.value = data.tagsMap;
} }
async function searchEndpoints(keyword: string) { async function searchEndpoints(keyword: string) {
@@ -345,21 +349,12 @@ limitations under the License. -->
.row { .row {
margin-bottom: 5px; margin-bottom: 5px;
position: relative;
} }
.traceId { .traceId {
width: 270px; width: 270px;
} }
.search-btn {
cursor: pointer;
width: 80px;
position: absolute;
top: 0;
right: 10px;
}
.label { .label {
line-height: 24px; line-height: 24px;
} }

View File

@@ -91,7 +91,7 @@ limitations under the License. -->
import timeFormat from "@/utils/timeFormat"; import timeFormat from "@/utils/timeFormat";
import ConditionTags from "@/views/components/ConditionTags.vue"; import ConditionTags from "@/views/components/ConditionTags.vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { EntityType, QueryOrders, Status } from "../../data"; import { EntityType, QueryOrders, Status } from "@/views/dashboard/data";
import type { LayoutConfig } from "@/types/dashboard"; import type { LayoutConfig } from "@/types/dashboard";
const FiltersKeys: { [key: string]: string } = { const FiltersKeys: { [key: string]: string } = {

View File

@@ -83,9 +83,9 @@ limitations under the License. -->
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTraceStore } from "@/store/modules/trace"; import { useTraceStore, PageSize } from "@/store/modules/trace";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { QueryOrders } from "../../data"; import { QueryOrders } from "@/views/dashboard/data";
import type { Option } from "@/types/app"; import type { Option } from "@/types/app";
import type { Trace } from "@/types/trace"; import type { Trace } from "@/types/trace";
import { dateFormat } from "@/utils/dateFormat"; import { dateFormat } from "@/utils/dateFormat";
@@ -94,7 +94,7 @@ limitations under the License. -->
const traceStore = useTraceStore(); const traceStore = useTraceStore();
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
const selectedKey = ref<string>(""); const selectedKey = ref<string>("");
const pageSize = 20; const pageSize = PageSize;
const total = computed(() => const total = computed(() =>
traceStore.traceList.length === pageSize traceStore.traceList.length === pageSize
? pageSize * traceStore.conditions.paging.pageNum + 1 ? pageSize * traceStore.conditions.paging.pageNum + 1

View File

@@ -13,14 +13,16 @@ limitations under the License. -->
<template> <template>
<div class="trace-detail" v-loading="loading"> <div class="trace-detail" v-loading="loading">
<div class="trace-detail-wrapper clear" v-if="traceStore.currentTrace?.endpointNames"> <div class="trace-detail-wrapper clear" v-if="traceStore.currentTrace?.endpointNames">
<h5 class="mb-5 mt-0"> <div class="flex-h" style="justify-content: space-between">
<span class="vm">{{ traceStore.currentTrace?.endpointNames?.[0] }}</span> <h5 class="mb-5 mt-0">
<div class="trace-log-btn"> <span class="vm">{{ traceStore.currentTrace?.endpointNames?.[0] }}</span>
<el-button size="small" class="mr-10" type="primary" @click="searchTraceLogs"> </h5>
<div>
<el-button size="small" class="mr-10" @click="searchTraceLogs">
{{ t("viewLogs") }} {{ t("viewLogs") }}
</el-button> </el-button>
</div> </div>
</h5> </div>
<div class="mb-5 blue"> <div class="mb-5 blue">
<Selector <Selector
size="small" size="small"
@@ -46,33 +48,13 @@ limitations under the License. -->
<div class="tag mr-5">{{ t("spans") }}</div> <div class="tag mr-5">{{ t("spans") }}</div>
<span class="sm">{{ traceStore.traceSpans.length }}</span> <span class="sm">{{ traceStore.traceSpans.length }}</span>
</div> </div>
<div> <div class="flex-h trace-type item">
<el-button class="grey" size="small" :class="{ ghost: displayMode !== 'List' }" @click="displayMode = 'List'"> <el-radio-group v-model="spansGraphType" size="small">
<Icon class="mr-5" size="sm" iconName="list-bulleted" /> <el-radio-button v-for="option in GraphTypeOptions" :key="option.value" :value="option.value">
{{ t("list") }} <Icon :iconName="option.icon" />
</el-button> {{ t(option.label) }}
<el-button class="grey" size="small" :class="{ ghost: displayMode !== 'Tree' }" @click="displayMode = 'Tree'"> </el-radio-button>
<Icon class="mr-5" size="sm" iconName="issue-child" /> </el-radio-group>
{{ t("tree") }}
</el-button>
<el-button
class="grey"
size="small"
:class="{ ghost: displayMode !== 'Table' }"
@click="displayMode = 'Table'"
>
<Icon class="mr-5" size="sm" iconName="table" />
{{ t("table") }}
</el-button>
<el-button
class="grey"
size="small"
:class="{ ghost: displayMode !== 'Statistics' }"
@click="displayMode = 'Statistics'"
>
<Icon class="mr-5" size="sm" iconName="statistics-bulleted" />
{{ t("statistics") }}
</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -80,7 +62,7 @@ limitations under the License. -->
<div class="trace-chart"> <div class="trace-chart">
<component <component
v-if="traceStore.currentTrace?.endpointNames" v-if="traceStore.currentTrace?.endpointNames"
:is="displayMode" :is="spansGraphType"
:data="traceStore.traceSpans" :data="traceStore.traceSpans"
:traceId="traceStore.currentTrace?.traceIds?.[0]?.value" :traceId="traceStore.currentTrace?.traceIds?.[0]?.value"
:showBtnDetail="false" :showBtnDetail="false"
@@ -95,13 +77,14 @@ limitations under the License. -->
import { useTraceStore } from "@/store/modules/trace"; import { useTraceStore } from "@/store/modules/trace";
import type { Option } from "@/types/app"; import type { Option } from "@/types/app";
import copy from "@/utils/copy"; import copy from "@/utils/copy";
import graphs from "./components/index"; import graphs from "../VisGraph/index";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import getDashboard from "@/hooks/useDashboardsSession"; import getDashboard from "@/hooks/useDashboardsSession";
import type { LayoutConfig } from "@/types/dashboard"; import type { LayoutConfig } from "@/types/dashboard";
import { dateFormat } from "@/utils/dateFormat"; import { dateFormat } from "@/utils/dateFormat";
import { useAppStoreWithOut } from "@/store/modules/app"; import { useAppStoreWithOut } from "@/store/modules/app";
import { WidgetType } from "@/views/dashboard/data"; import { WidgetType } from "@/views/dashboard/data";
import { GraphTypeOptions } from "../VisGraph/constant";
const props = { const props = {
serviceId: { type: String, default: "" }, serviceId: { type: String, default: "" },
@@ -120,7 +103,7 @@ limitations under the License. -->
const traceStore = useTraceStore(); const traceStore = useTraceStore();
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
const traceId = ref<string>(""); const traceId = ref<string>("");
const displayMode = ref<string>("List"); const spansGraphType = ref<string>("List");
function handleClick() { function handleClick() {
copy(traceId.value || traceStore.currentTrace?.traceIds?.[0]?.value); copy(traceId.value || traceStore.currentTrace?.traceIds?.[0]?.value);
@@ -150,7 +133,6 @@ limitations under the License. -->
} }
return { return {
traceStore, traceStore,
displayMode,
dateFormat, dateFormat,
changeTraceId, changeTraceId,
handleClick, handleClick,
@@ -160,6 +142,8 @@ limitations under the License. -->
loading, loading,
traceId, traceId,
WidgetType, WidgetType,
spansGraphType,
GraphTypeOptions,
}; };
}, },
}); });
@@ -196,6 +180,7 @@ limitations under the License. -->
.item { .item {
justify-content: space-between; justify-content: space-between;
align-items: center;
} }
.trace-detail-ids { .trace-detail-ids {
@@ -208,10 +193,6 @@ limitations under the License. -->
width: 300px; width: 300px;
} }
.trace-log-btn {
float: right;
}
.tag { .tag {
display: inline-block; display: inline-block;
border-radius: 4px; border-radius: 4px;
@@ -225,4 +206,16 @@ limitations under the License. -->
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
.trace-type {
// padding: 10px 0 10px 10px;
border-bottom: 1px solid $border-color-primary;
/* Make radio buttons content align nicely with icons */
:deep(.el-radio-button__inner) {
display: inline-flex;
align-items: center;
gap: 5px;
}
}
</style> </style>

View File

@@ -0,0 +1,47 @@
<!-- 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="trace-query flex-v">
<TracesTable />
</div>
</template>
<script lang="ts" setup>
import { onUnmounted } from "vue";
import type { PropType } from "vue";
import type { LayoutConfig } from "@/types/dashboard";
import { useTraceStore } from "@/store/modules/trace";
import TracesTable from "./TracesTable.vue";
/*global defineProps */
defineProps({
data: {
type: Object as PropType<LayoutConfig>,
default: () => ({}),
},
});
const traceStore = useTraceStore();
onUnmounted(() => {
traceStore.setTraceList([]);
traceStore.setSelectedSpan(null);
});
</script>
<style lang="scss" scoped>
.trace-query {
width: 100%;
font-size: $font-size-smaller;
overflow: auto;
height: 100%;
}
</style>

View File

@@ -0,0 +1,107 @@
<!-- 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="trace-min-timeline">
<div class="timeline-marker-fixed">
<svg width="100%" height="20px">
<MinTimelineMarker :minTimestamp="minTimestamp" :maxTimestamp="maxTimestamp" :lineHeight="20" />
</svg>
</div>
<div
class="timeline-content scroll_bar_style"
:style="{ paddingRight: (trace.spans.length + 1) * rowHeight < 200 ? '20px' : '14px' }"
>
<svg ref="svgEle" width="100%" :height="`${(trace.spans.length + 1) * rowHeight}px`">
<MinTimelineOverlay
:minTimestamp="minTimestamp"
:maxTimestamp="maxTimestamp"
@setSelectedMinTimestamp="setSelectedMinTimestamp"
@setSelectedMaxTimestamp="setSelectedMaxTimestamp"
/>
<MinTimelineSelector
:minTimestamp="minTimestamp"
:maxTimestamp="maxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
:selectedMaxTimestamp="selectedMaxTimestamp"
@setSelectedMinTimestamp="setSelectedMinTimestamp"
@setSelectedMaxTimestamp="setSelectedMaxTimestamp"
/>
<g
v-for="(item, index) in trace.spans"
:key="index"
:transform="`translate(0, ${(index + 1) * rowHeight + 3})`"
>
<SpanNode :span="item" :minTimestamp="minTimestamp" :maxTimestamp="maxTimestamp" :depth="index + 1" />
</g>
</svg>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import type { Trace } from "@/types/trace";
import SpanNode from "./SpanNode.vue";
import MinTimelineMarker from "./MinTimelineMarker.vue";
import MinTimelineOverlay from "./MinTimelineOverlay.vue";
import MinTimelineSelector from "./MinTimelineSelector.vue";
interface Props {
trace: Trace;
minTimestamp: number;
maxTimestamp: number;
}
const props = defineProps<Props>();
const svgEle = ref<SVGSVGElement | null>(null);
const rowHeight = 12;
const selectedMinTimestamp = ref<number>(props.minTimestamp);
const selectedMaxTimestamp = ref<number>(props.maxTimestamp);
const emit = defineEmits(["updateSelectedMaxTimestamp", "updateSelectedMinTimestamp"]);
const setSelectedMinTimestamp = (value: number) => {
selectedMinTimestamp.value = value;
emit("updateSelectedMinTimestamp", value);
};
const setSelectedMaxTimestamp = (value: number) => {
selectedMaxTimestamp.value = value;
emit("updateSelectedMaxTimestamp", value);
};
</script>
<style lang="scss" scoped>
.trace-min-timeline {
width: 100%;
max-height: 200px;
border-bottom: 1px solid var(--el-border-color-light);
display: flex;
flex-direction: column;
}
.timeline-marker-fixed {
width: 100%;
padding-right: 20px;
padding-top: 5px;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
z-index: 1;
}
.timeline-content {
flex: 1;
width: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,57 @@
<!-- 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>
<g v-for="(marker, index) in markers" :key="marker.duration">
<line
:x1="`${marker.position}%`"
:y1="0"
:x2="`${marker.position}%`"
:y2="lineHeight ? `${lineHeight}` : '100%'"
stroke="var(--el-border-color-light)"
/>
<text
:key="`label-${marker.duration}`"
:x="`${marker.position}%`"
:y="12"
font-size="10"
fill="var(--sw-font-grey-color)"
text-anchor="right"
:transform="`translate(${index === markers.length - 1 ? -50 : 5}, 0)`"
>
{{ marker.duration }}ms
</text>
</g>
</template>
<script lang="ts" setup>
import { computed } from "vue";
interface Props {
minTimestamp: number;
maxTimestamp: number;
lineHeight?: number | string;
}
const props = defineProps<Props>();
const markers = computed(() => {
const maxDuration = props.maxTimestamp - props.minTimestamp;
const markerDurations = [0, (maxDuration * 1) / 3, (maxDuration * 2) / 3, maxDuration];
return markerDurations.map((duration) => ({
duration: duration.toFixed(2),
position: maxDuration > 0 ? (duration / maxDuration) * 100 : 0,
}));
});
</script>
<style scoped></style>

View File

@@ -0,0 +1,147 @@
<!-- 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>
<g>
<rect
v-if="mouseDownX !== undefined && currentX !== undefined"
:x="`${Math.min(mouseDownX, currentX)}%`"
y="0"
:width="`${Math.abs(mouseDownX - currentX)}%`"
height="100%"
fill="var(--el-color-primary-light-5)"
fill-opacity="0.2"
pointer-events="none"
/>
<rect
ref="rootEl"
x="0"
y="0"
width="100%"
height="100%"
@mousedown="handleMouseDown"
@mousemove="handleMouseHoverMove"
@mouseleave="handleMouseHoverLeave"
fill-opacity="0"
cursor="col-resize"
/>
<line
v-if="hoverX"
:x1="`${hoverX}%`"
:y1="0"
:x2="`${hoverX}%`"
y2="100%"
stroke="var(--el-color-primary-light-5)"
stroke-width="1"
pointer-events="none"
/>
</g>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, ref } from "vue";
interface Props {
minTimestamp: number;
maxTimestamp: number;
}
interface Emits {
(e: "setSelectedMaxTimestamp", value: number): void;
(e: "setSelectedMinTimestamp", value: number): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const rootEl = ref<SVGRectElement | null>(null);
const mouseDownX = ref<number | undefined>(undefined);
const currentX = ref<number | undefined>(undefined);
const hoverX = ref<number | undefined>(undefined);
const mouseDownXRef = ref<number | undefined>(undefined);
const isDragging = ref(false);
function handleMouseMove(e: MouseEvent) {
if (!rootEl.value) {
return;
}
const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX);
currentX.value = x;
}
function handleMouseUp(e: MouseEvent) {
if (!isDragging.value || !rootEl.value || mouseDownXRef.value === undefined) {
return;
}
const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX);
const adjustedX = Math.abs(x - mouseDownXRef.value) < 1 ? x + 1 : x;
const t1 = (mouseDownXRef.value / 100) * (props.maxTimestamp - props.minTimestamp) + props.minTimestamp;
const t2 = (adjustedX / 100) * (props.maxTimestamp - props.minTimestamp) + props.minTimestamp;
const newMinTimestmap = Math.min(t1, t2);
const newMaxTimestamp = Math.max(t1, t2);
emit("setSelectedMinTimestamp", newMinTimestmap);
emit("setSelectedMaxTimestamp", newMaxTimestamp);
currentX.value = undefined;
mouseDownX.value = undefined;
mouseDownXRef.value = undefined;
isDragging.value = false;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
const calculateX = (parentRect: DOMRect, x: number) => {
const value = ((x - parentRect.left) / (parentRect.right - parentRect.left)) * 100;
if (value <= 0) {
return 0;
}
if (value >= 100) {
return 100;
}
return value;
};
function handleMouseDown(e: MouseEvent) {
if (!rootEl.value) {
return;
}
const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX);
currentX.value = x;
mouseDownX.value = x;
mouseDownXRef.value = x;
isDragging.value = true;
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
function handleMouseHoverMove(e: MouseEvent) {
if (e.buttons !== 0 || !rootEl.value) {
return;
}
const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX);
hoverX.value = x;
}
function handleMouseHoverLeave() {
hoverX.value = undefined;
}
onBeforeUnmount(() => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
isDragging.value = false;
});
</script>

View File

@@ -0,0 +1,157 @@
<!-- 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>
<g>
<rect
x="0"
y="0"
:width="`${boundaryLeft}%`"
height="100%"
fill="var(--el-color-primary-light-8)"
fill-opacity="0.6"
pointer-events="none"
/>
<rect
:x="`${boundaryRight}%`"
y="0"
:width="`${100 - boundaryRight}%`"
height="100%"
fill="var(--el-color-primary-light-8)"
fill-opacity="0.6"
pointer-events="none"
/>
<rect
:x="`${boundaryLeft}%`"
y="0"
width="3"
height="100%"
fill="var(--el-color-primary-light-5)"
transform="translate(-1)"
pointer-events="none"
/>
<rect
:x="`${boundaryRight}%`"
y="0"
width="3"
height="100%"
fill="var(--el-color-primary-light-5)"
transform="translate(-1)"
pointer-events="none"
/>
<rect
v-if="minMouseDownX !== undefined && minCurrentX !== undefined"
:x="`${Math.min(minMouseDownX, minCurrentX)}%`"
y="0"
:width="`${Math.abs(minMouseDownX - minCurrentX)}%`"
height="100%"
fill="var(--el-color-primary-light-6)"
fill-opacity="0.4"
pointer-events="none"
/>
<rect
v-if="maxMouseDownX !== undefined && maxCurrentX !== undefined"
:x="`${Math.min(maxMouseDownX, maxCurrentX)}%`"
y="0"
:width="`${Math.abs(maxMouseDownX - maxCurrentX)}%`"
height="100%"
fill="var(--el-color-primary-light-6)"
fill-opacity="0.4"
pointer-events="none"
/>
<rect
:x="`${boundaryLeft}%`"
y="0"
width="6"
height="40%"
fill="var(--el-color-primary)"
@mousedown="minRangeHandler.onMouseDown"
cursor="pointer"
transform="translate(-3)"
/>
<rect
:x="`${boundaryRight}%`"
y="0"
width="6"
height="40%"
fill="var(--el-color-primary)"
@mousedown="maxRangeHandler.onMouseDown"
cursor="pointer"
transform="translate(-3)"
/>
</g>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted } from "vue";
import { useRangeTimestampHandler, adjustPercentValue } from "./useHooks";
interface Props {
minTimestamp: number;
maxTimestamp: number;
selectedMinTimestamp: number;
selectedMaxTimestamp: number;
}
const emit = defineEmits<{
(e: "setSelectedMinTimestamp", value: number): void;
(e: "setSelectedMaxTimestamp", value: number): void;
}>();
const props = defineProps<Props>();
const svgEle = ref<SVGSVGElement | null>(null);
onMounted(() => {
const element = document.querySelector(".trace-min-timeline svg") as SVGSVGElement;
if (element) {
svgEle.value = element;
}
});
const maxOpositeX = computed(
() => ((props.selectedMaxTimestamp - props.minTimestamp) / (props.maxTimestamp - props.minTimestamp)) * 100,
);
const minOpositeX = computed(
() => ((props.selectedMinTimestamp - props.minTimestamp) / (props.maxTimestamp - props.minTimestamp)) * 100,
);
const minRangeHandler = computed(() => {
return useRangeTimestampHandler({
rootEl: svgEle.value,
minTimestamp: props.minTimestamp,
maxTimestamp: props.maxTimestamp,
selectedTimestamp: props.selectedMaxTimestamp,
isSmallerThanOpositeX: true,
setTimestamp: (value) => emit("setSelectedMinTimestamp", value),
});
});
const maxRangeHandler = computed(() =>
useRangeTimestampHandler({
rootEl: svgEle.value,
minTimestamp: props.minTimestamp,
maxTimestamp: props.maxTimestamp,
selectedTimestamp: props.selectedMinTimestamp,
isSmallerThanOpositeX: false,
setTimestamp: (value) => emit("setSelectedMaxTimestamp", value),
}),
);
const boundaryLeft = computed(() => {
return adjustPercentValue(minOpositeX.value);
});
const boundaryRight = computed(() => adjustPercentValue(maxOpositeX.value));
const minMouseDownX = computed(() => minRangeHandler.value.mouseDownX.value);
const minCurrentX = computed(() => minRangeHandler.value.currentX.value);
const maxMouseDownX = computed(() => maxRangeHandler.value.mouseDownX.value);
const maxCurrentX = computed(() => maxRangeHandler.value.currentX.value);
</script>

View File

@@ -0,0 +1,120 @@
<!-- 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>
<rect :x="`${startPct}%`" :y="0" :width="`${widthPct}%`" :height="barHeight" :fill="barColor" rx="2" ry="2" />
<!-- Label and Duration Text -->
<text
v-if="showLabel"
:x="0"
:y="barHeight - 7"
font-size="10"
fill="var(--sw-font-grey-color)"
text-anchor="start"
class="span-label"
>
{{ span.label || "Unknown" }}
</text>
<text
v-if="showDuration"
:x="`${100}%`"
:y="barHeight - 7"
font-size="10"
fill="var(--sw-font-grey-color)"
text-anchor="end"
class="span-duration"
>
{{ span.duration }}ms
</text>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import type { Span } from "@/types/trace";
import { getServiceColor } from "@/utils/color";
interface Props {
span: Span;
minTimestamp: number;
maxTimestamp: number;
depth: number;
showDuration?: boolean;
showLabel?: boolean;
selectedMaxTimestamp?: number;
selectedMinTimestamp?: number;
}
const props = defineProps<Props>();
const barHeight = 3;
const widthScale = computed(() => {
const { selectedMinTimestamp, selectedMaxTimestamp, minTimestamp, maxTimestamp } = props;
let max = maxTimestamp - minTimestamp;
if (selectedMaxTimestamp !== undefined && selectedMinTimestamp !== undefined) {
max = selectedMaxTimestamp - selectedMinTimestamp;
}
return (duration: number | undefined | null) => {
const d = Math.max(0, duration || 0);
return (d / max) * 100;
};
});
const startPct = computed(() => {
const { span, selectedMinTimestamp, minTimestamp } = props;
const end = span.endTime;
let start = span.startTime;
if (selectedMinTimestamp !== undefined) {
start = selectedMinTimestamp > start ? (end < selectedMinTimestamp ? 0 : selectedMinTimestamp) : start;
}
const dur = start - (selectedMinTimestamp || minTimestamp);
return Math.max(0, widthScale.value(dur));
});
const widthPct = computed(() => {
const { span, selectedMinTimestamp, selectedMaxTimestamp } = props;
let start = span.startTime;
let end = span.endTime;
if (selectedMinTimestamp !== undefined) {
start = selectedMinTimestamp > start ? selectedMinTimestamp : start;
if (end < selectedMinTimestamp) {
return 0;
}
}
if (selectedMaxTimestamp !== undefined) {
end = selectedMaxTimestamp < end ? selectedMaxTimestamp : end;
if (span.startTime > selectedMaxTimestamp) {
return 0;
}
}
const dur = end - start;
return Math.max(0, widthScale.value(dur));
});
const barColor = computed(() => {
const serviceName = props.span.serviceCode || "";
return getServiceColor(serviceName);
});
</script>
<style lang="scss" scoped>
.span-label {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-weight: 500;
}
.span-duration {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-weight: 400;
}
</style>

View File

@@ -0,0 +1,59 @@
<!-- 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="timeline-tool flex-h">
<div class="flex-h trace-type item">
<el-radio-group v-model="spansGraphType" size="small">
<el-radio-button v-for="option in GraphTypeOptions" :key="option.value" :value="option.value">
<Icon :iconName="option.icon" />
{{ t(option.label) }}
</el-radio-button>
</el-radio-group>
</div>
<div>
<el-button size="small" @click="onToggleMinTimeline">
<Icon iconName="sort" size="middle" />
</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { GraphTypeOptions } from "../VisGraph/constant";
const emit = defineEmits<{
(e: "toggleMinTimeline"): void;
(e: "updateSpansGraphType", value: string): void;
}>();
const { t } = useI18n();
const spansGraphType = ref<string>(GraphTypeOptions[2].value);
function onToggleMinTimeline() {
emit("toggleMinTimeline");
}
// Watch for changes in spansGraphType and emit to parent
watch(spansGraphType, (newValue) => {
emit("updateSpansGraphType", newValue);
});
</script>
<style lang="scss" scoped>
.timeline-tool {
justify-content: space-between;
padding: 10px 5px;
border-bottom: 1px solid var(--el-border-color-light);
}
</style>

View File

@@ -0,0 +1,175 @@
<!-- 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="trace-query-content">
<div class="trace-info">
<div class="flex-h" style="justify-content: space-between">
<h3>{{ trace.label }}</h3>
<div>
<el-dropdown @command="handleDownload" trigger="click">
<el-button size="small">
{{ t("download") }}
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">Download JSON</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div class="trace-meta flex-h">
<div>
<span class="grey mr-5">{{ t("duration") }}</span>
<span class="value">{{ trace.duration }}ms</span>
</div>
<div>
<span class="grey mr-5">{{ t("services") }}</span>
<span class="value">{{ trace.serviceCode || "Unknown" }}</span>
</div>
<div>
<span class="grey mr-5">{{ t("totalSpans") }}</span>
<span class="value">{{ trace.spans?.length || 0 }}</span>
</div>
<div>
<span class="grey mr-5">{{ t("traceID") }}</span>
<span class="value">{{ trace.traceId }}</span>
</div>
</div>
</div>
<div class="flex-h">
<div class="detail-section-timeline flex-v">
<MinTimeline
v-show="minTimelineVisible"
:trace="trace"
:minTimestamp="minTimestamp"
:maxTimestamp="maxTimestamp"
@updateSelectedMaxTimestamp="handleSelectedMaxTimestamp"
@updateSelectedMinTimestamp="handleSelectedMinTimestamp"
/>
<TimelineTool @toggleMinTimeline="toggleMinTimeline" @updateSpansGraphType="handleSpansGraphTypeUpdate" />
<component
v-if="traceStore.currentTrace?.endpointNames"
:is="graphs[spansGraphType as keyof typeof graphs]"
:data="trace.spans"
:traceId="traceStore.currentTrace?.traceId"
:showBtnDetail="false"
:headerType="WidgetType.Trace"
:selectedMaxTimestamp="selectedMaxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
:minTimestamp="minTimestamp"
:maxTimestamp="maxTimestamp"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import { ElMessage } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
import type { Trace } from "@/types/trace";
import MinTimeline from "./MinTimeline.vue";
import { saveFileAsJSON } from "@/utils/file";
import { useTraceStore } from "@/store/modules/trace";
import TimelineTool from "./TimelineTool.vue";
import graphs from "../VisGraph/index";
import { WidgetType } from "@/views/dashboard/data";
import { GraphTypeOptions } from "../VisGraph/constant";
interface Props {
trace: Trace;
}
const { t } = useI18n();
const props = defineProps<Props>();
const traceStore = useTraceStore();
// Time range like xScale domain [0, max]
const minTimestamp = computed(() => {
if (!props.trace.spans.length) return 0;
return Math.min(...props.trace.spans.map((s) => s.startTime || 0));
});
const maxTimestamp = computed(() => {
const timestamps = props.trace.spans.map((span) => span.endTime || 0);
if (timestamps.length === 0) return 0;
return Math.max(...timestamps);
});
const selectedMaxTimestamp = ref<number>(maxTimestamp.value);
const selectedMinTimestamp = ref<number>(minTimestamp.value);
const minTimelineVisible = ref<boolean>(true);
const spansGraphType = ref<string>(GraphTypeOptions[2].value);
function handleSelectedMaxTimestamp(value: number) {
selectedMaxTimestamp.value = value;
}
function handleSelectedMinTimestamp(value: number) {
selectedMinTimestamp.value = value;
}
function toggleMinTimeline() {
minTimelineVisible.value = !minTimelineVisible.value;
}
function handleSpansGraphTypeUpdate(value: string) {
spansGraphType.value = value;
}
function handleDownload() {
const trace = props.trace;
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const baseFilename = `trace-${trace.traceId}-${timestamp}`;
const spans = trace.spans.map((span) => {
const { duration, label, ...newSpan } = span;
return newSpan;
});
try {
saveFileAsJSON(spans, `${baseFilename}.json`);
ElMessage.success("Trace data downloaded as JSON");
} catch (error) {
console.error("Download error:", error);
ElMessage.error("Failed to download file");
}
}
</script>
<style lang="scss" scoped>
.trace-info {
padding-bottom: 15px;
border-bottom: 1px solid var(--el-border-color-light);
}
.trace-info h3 {
margin: 0 0 10px;
color: var(--el-text-color-primary);
font-size: 18px;
font-weight: 600;
}
.trace-meta {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.detail-section-timeline {
width: 100%;
}
</style>

View File

@@ -0,0 +1,379 @@
<!-- 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="flex-h result-header">
<div style="align-items: center"> {{ filteredTraces.length }} of {{ totalTraces }} Results </div>
<div class="flex-h" style="align-items: center">
<el-switch
v-model="expandAll"
size="large"
active-text="Expand All"
inactive-text="Collapse All"
class="mr-20"
@change="toggleAllExpansion"
/>
<Selector
placeholder="Service filters"
@change="changeServiceFilters"
:value="selectedServiceNames"
:options="serviceNames"
:multiple="true"
style="width: 500px"
/>
</div>
</div>
<div class="trace-query-table scroll_bar_style">
<el-table
ref="tableRef"
:data="filteredTraces"
:border="false"
:preserve-expanded-content="true"
:default-sort="{ prop: 'duration', order: 'descending' }"
style="width: 100%"
v-loading="traceStore.loading"
>
<el-table-column type="expand">
<template #default="props">
<div class="flex-h service-tags">
<el-tag
v-for="(value, key) in getEndpoints(props.row.spans)"
:key="key"
class="mr-5 cp"
disable-transitions
:style="{ backgroundColor: getServiceColor(value[0]), color: 'white', border: 'none' }"
@click="toggleServiceTags(value[0], props.row)"
>
{{ value[0] }}: {{ value[1] }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="Root" prop="label" />
<el-table-column label="Start Time" prop="timestamp" width="220">
<template #default="props">
{{ dateFormat(props.row.start) }}
</template>
</el-table-column>
<el-table-column label="Spans" prop="spans.length" width="100" />
<el-table-column label="Duration (ms)" prop="duration" width="200" sortable>
<template #default="props">
<div class="duration-cell">
<el-progress
:percentage="getDurationProgress(props.row.duration)"
:stroke-width="22"
:text-inside="true"
color="rgba(64, 158, 255, 0.4)"
>
<div class="duration-value">{{ props.row.duration || 0 }}ms</div>
</el-progress>
</div>
</template>
</el-table-column>
<el-table-column fixed="right" label="Operations" width="120">
<template #default="props">
<el-button size="small" @click="(e: MouseEvent) => handleShowTrace(e, props.row)"> Show </el-button>
</template>
</el-table-column>
</el-table>
<!-- Loading indicator -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>Loading more traces...</span>
</div>
<!-- Load more button (fallback) -->
<div v-else-if="hasMoreItems" class="load-more-container">
<el-button @click="loadMoreItems" :loading="loading">
Load More ({{ totalTraces - filteredTraces.length }} remaining)
</el-button>
</div>
</div>
<!-- Trace Details Dialog -->
<el-dialog
v-model="dialogVisible"
fullscreen
:close-on-click-modal="false"
:close-on-press-escape="true"
destroy-on-close
>
<TraceContent v-if="selectedTrace" :trace="selectedTrace" />
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue";
import { ElTable, ElDialog, ElIcon } from "element-plus";
import { Loading } from "@element-plus/icons-vue";
import { dateFormat } from "@/utils/dateFormat";
import { useTraceStore } from "@/store/modules/trace";
import type { Trace } from "@/types/trace";
import type { Option } from "@/types/app";
import { getServiceColor } from "@/utils/color";
import TraceContent from "./TraceContent.vue";
const PageSize = 10;
const traceStore = useTraceStore();
const expandAll = ref<boolean>(true);
const tableRef = ref<InstanceType<typeof ElTable>>();
const selectedServiceNames = ref<string[]>([]);
const dialogVisible = ref<boolean>(false);
const selectedTrace = ref<Trace | null>(null);
// Infinite scroll state
const loadedItemsCount = ref<number>(PageSize);
const loading = ref<boolean>(false);
const loadMoreThreshold = 100; // pixels from bottom to trigger load
// Calculate max duration for progress bar scaling
const maxDuration = computed(() => {
if (!traceStore.traceList.length) return 1;
const durations = traceStore.traceList.map((trace: Trace) => trace.duration || 0);
return Math.max(...durations);
});
// All filtered traces based on selected service names
const allFilteredTraces = computed<Trace[]>(() => {
const rows = traceStore.traceList as Trace[];
if (!selectedServiceNames.value.length) return rows;
const selected = new Set(selectedServiceNames.value);
return rows.filter((row) => {
const rowService = row?.serviceCode;
if (rowService && selected.has(rowService)) return true;
for (const s of row.spans || []) {
const name = s?.serviceCode;
if (name && selected.has(name)) return true;
}
return false;
});
});
const filteredTraces = computed<Trace[]>(() => {
return allFilteredTraces.value.slice(0, loadedItemsCount.value);
});
const totalTraces = computed<number>(() => allFilteredTraces.value.length);
const hasMoreItems = computed<boolean>(() => {
return loadedItemsCount.value < allFilteredTraces.value.length;
});
// Service names options for the header Selector
const serviceNames = computed<Option[]>(() => {
const names = new Set<string>();
const rows = traceStore.traceList as Trace[];
for (const row of rows) {
if (row?.serviceCode) {
names.add(row.serviceCode);
}
for (const s of row.spans || []) {
if (s?.serviceCode) {
names.add(s.serviceCode);
}
}
}
return Array.from(names)
.sort((a, b) => a.localeCompare(b))
.map((n) => ({ label: n, value: n }));
});
function getEndpoints(spans: Trace[]) {
const endpoints = new Map<string, number>();
for (const d of spans) {
endpoints.set(d.serviceCode, (endpoints.get(d.serviceCode) || 0) + 1);
}
return endpoints;
}
function handleShowTrace(e: MouseEvent, row: Trace) {
traceStore.setCurrentTrace(row);
selectedTrace.value = row;
dialogVisible.value = true;
}
function getDurationProgress(duration: number): number {
if (maxDuration.value === 0) return 0;
return Math.round((duration / maxDuration.value) * 100);
}
function toggleServiceTags(serviceName: string, row: Trace) {
selectedServiceNames.value = selectedServiceNames.value.includes(serviceName)
? selectedServiceNames.value.filter((name) => name !== serviceName)
: [...selectedServiceNames.value, serviceName];
// Expand the row to show the service tags
nextTick(() => {
if (tableRef.value) {
tableRef.value.toggleRowExpansion(row, true);
}
});
}
function changeServiceFilters(selected: Option[]) {
selectedServiceNames.value = selected.map((o) => String(o.value));
}
// Infinite scroll handlers
function loadMoreItems() {
if (loading.value || !hasMoreItems.value) return;
loading.value = true;
// Simulate loading delay for better UX
setTimeout(() => {
const nextBatch = Math.min(PageSize, allFilteredTraces.value.length - loadedItemsCount.value);
loadedItemsCount.value += nextBatch;
// Apply current expand state to all currently visible items
nextTick(() => {
if (tableRef.value) {
const allVisibleItems = filteredTraces.value;
for (const row of allVisibleItems) {
tableRef.value.toggleRowExpansion(row, expandAll.value);
}
}
});
loading.value = false;
}, 300);
}
function handleScroll(event: Event) {
const target = event.target as HTMLElement;
const { scrollTop, scrollHeight, clientHeight } = target;
// Check if user is near the bottom
if (scrollHeight - scrollTop - clientHeight < loadMoreThreshold && hasMoreItems.value && !loading.value) {
loadMoreItems();
}
}
// Reset loaded items when filters change
watch(selectedServiceNames, () => {
loadedItemsCount.value = PageSize;
// Apply expand state to newly visible items after filter change
nextTick(() => {
if (tableRef.value) {
const visibleItems = filteredTraces.value;
for (const row of visibleItems) {
tableRef.value.toggleRowExpansion(row, expandAll.value);
}
}
});
});
// Watch for expandAll state changes to apply to all visible items
watch(expandAll, () => {
nextTick(() => {
if (tableRef.value) {
const visibleItems = filteredTraces.value;
for (const row of visibleItems) {
tableRef.value.toggleRowExpansion(row, expandAll.value);
}
}
});
});
// Watch for trace list changes (when new query is executed) to reapply expand state
watch(
() => traceStore.traceList,
() => {
// Reset loaded items count when new data is loaded
loadedItemsCount.value = PageSize;
// Reapply expand state to newly loaded traces
nextTick(() => {
if (tableRef.value) {
const visibleItems = filteredTraces.value;
for (const row of visibleItems) {
tableRef.value.toggleRowExpansion(row, expandAll.value);
}
}
});
},
{ deep: true },
);
// Setup scroll listener
onMounted(() => {
const tableContainer = document.querySelector(".trace-query-table");
if (tableContainer) {
tableContainer.addEventListener("scroll", handleScroll);
}
});
onUnmounted(() => {
const tableContainer = document.querySelector(".trace-query-table");
if (tableContainer) {
tableContainer.removeEventListener("scroll", handleScroll);
}
});
// Toggle all table row expansions (only for loaded items)
function toggleAllExpansion() {
// The expandAll watcher will handle applying the state to all visible items
// This function just needs to trigger the change
}
</script>
<style lang="scss" scoped>
.trace-query-table {
width: 100%;
font-size: $font-size-smaller;
overflow: auto;
height: calc(100% - 100px);
}
.service-tags {
width: 100%;
padding-left: 60px;
}
.duration-cell {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
}
.duration-value {
font-size: $font-size-smaller;
font-weight: 500;
color: var(--el-text-color-primary);
}
.result-header {
justify-content: space-between;
padding: 20px 10px;
font-size: 14px;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 0;
color: var(--el-text-color-secondary);
font-size: $font-size-smaller;
}
.load-more-container {
display: flex;
justify-content: center;
padding: 20px 0;
border-top: 1px solid var(--el-border-color-light);
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,128 @@
/**
* 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 { ref, computed } from "vue";
export const adjustPercentValue = (value: number) => {
if (value <= 0) {
return 0;
}
if (value >= 100) {
return 100;
}
return value;
};
const calculateX = ({
parentRect,
x,
opositeX,
isSmallerThanOpositeX,
}: {
parentRect: DOMRect;
x: number;
opositeX: number;
isSmallerThanOpositeX: boolean;
}) => {
let value = ((x - parentRect.left) / (parentRect.right - parentRect.left)) * 100;
if (isSmallerThanOpositeX) {
if (value >= opositeX) {
value = opositeX - 1;
}
} else if (value <= opositeX) {
value = opositeX + 1;
}
return adjustPercentValue(value);
};
export const useRangeTimestampHandler = ({
rootEl,
minTimestamp,
maxTimestamp,
selectedTimestamp,
isSmallerThanOpositeX,
setTimestamp,
}: {
rootEl: SVGSVGElement | null;
minTimestamp: number;
maxTimestamp: number;
selectedTimestamp: number;
isSmallerThanOpositeX: boolean;
setTimestamp: (value: number) => void;
}) => {
const currentX = ref<number>();
const mouseDownX = ref<number>();
const isDragging = ref(false);
const selectedTimestampComputed = ref(selectedTimestamp);
const opositeX = computed(() => {
return ((selectedTimestampComputed.value - minTimestamp) / (maxTimestamp - minTimestamp)) * 100;
});
const onMouseMove = (e: MouseEvent) => {
if (!rootEl) {
return;
}
const x = calculateX({
parentRect: rootEl.getBoundingClientRect(),
x: e.pageX,
opositeX: opositeX.value,
isSmallerThanOpositeX,
});
currentX.value = x;
};
const onMouseUp = (e: MouseEvent) => {
if (!rootEl) {
return;
}
const x = calculateX({
parentRect: rootEl.getBoundingClientRect(),
x: e.pageX,
opositeX: opositeX.value,
isSmallerThanOpositeX,
});
const timestamp = (x / 100) * (maxTimestamp - minTimestamp) + minTimestamp;
selectedTimestampComputed.value = timestamp;
setTimestamp(timestamp);
currentX.value = undefined;
mouseDownX.value = undefined;
isDragging.value = false;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
const onMouseDown = (e: MouseEvent) => {
if (!rootEl) {
return;
}
const x = calculateX({
parentRect: rootEl.getBoundingClientRect(),
x: (e.currentTarget as SVGRectElement).getBoundingClientRect().x + 3,
opositeX: opositeX.value,
isSmallerThanOpositeX,
});
currentX.value = x;
mouseDownX.value = x;
isDragging.value = true;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
return { currentX, mouseDownX, onMouseDown, isDragging };
};

View File

@@ -17,56 +17,59 @@ limitations under the License. -->
class="charts-item mr-5" class="charts-item mr-5"
v-for="(i, index) in traceStore.serviceList" v-for="(i, index) in traceStore.serviceList"
:key="index" :key="index"
:style="`color:${computedScale(index)}`" :style="`color:${getServiceColor(i)}`"
> >
<Icon iconName="issue-open-m" class="mr-5" size="sm" /> <Icon iconName="issue-open-m" class="mr-5" size="sm" />
<span>{{ i }}</span> <span>{{ i }}</span>
</span> </span>
<el-button class="btn" size="small" type="primary" @click="downloadTrace"> <el-button class="btn" size="small" @click="downloadTrace">
{{ t("exportImage") }} <Icon iconName="download" size="sm" />
</el-button> </el-button>
</div> </div>
<div class="list"> <div class="list">
<Graph :data="data" :traceId="traceId" :type="TraceGraphType.LIST" /> <Graph
:data="data"
:traceId="traceId"
:type="TraceGraphType.LIST"
:selectedMaxTimestamp="selectedMaxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
:minTimestamp="minTimestamp"
:maxTimestamp="maxTimestamp"
/>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { PropType } from "vue";
import { useI18n } from "vue-i18n";
import * as d3 from "d3"; import * as d3 from "d3";
import { useAppStoreWithOut } from "@/store/modules/app"; import { useAppStoreWithOut } from "@/store/modules/app";
import { useTraceStore } from "@/store/modules/trace"; import { useTraceStore } from "@/store/modules/trace";
import type { Span } from "@/types/trace"; import type { Span } from "@/types/trace";
import Graph from "./D3Graph/Index.vue"; import Graph from "../D3Graph/Index.vue";
import { Themes } from "@/constants/data"; import { Themes } from "@/constants/data";
import { TraceGraphType } from "./constant"; import { TraceGraphType } from "./constant";
import { getServiceColor } from "@/utils/color";
/* global defineProps, Recordable*/ /* global defineProps, Indexable*/
defineProps({ type Props = {
data: { type: Array as PropType<Span[]>, default: () => [] }, data: Span[];
traceId: { type: String, default: "" }, traceId: string;
}); selectedMaxTimestamp?: number;
const { t } = useI18n(); selectedMinTimestamp?: number;
minTimestamp: number;
maxTimestamp: number;
};
defineProps<Props>();
const appStore = useAppStoreWithOut(); const appStore = useAppStoreWithOut();
const traceStore = useTraceStore(); const traceStore = useTraceStore();
function computedScale(i: number) {
const sequentialScale = d3
.scaleSequential()
.domain([0, traceStore.serviceList.length + 1])
.interpolator(d3.interpolateCool);
return sequentialScale(i);
}
function downloadTrace() { function downloadTrace() {
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const svgNode: any = d3.select(".trace-list").node(); const svgNode: any = d3.select(".trace-list").node();
const source = `<?xml version="1.0" standalone="no"?>\r\n${serializer.serializeToString(svgNode)}`; const source = `<?xml version="1.0" standalone="no"?>\r\n${serializer.serializeToString(svgNode)}`;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context: any = canvas.getContext("2d"); const context: any = canvas.getContext("2d");
canvas.width = (d3.select(".trace-list") as Recordable)._groups[0][0].clientWidth; canvas.width = (d3.select(".trace-list") as Indexable)._groups[0][0].clientWidth;
canvas.height = (d3.select(".trace-list") as Recordable)._groups[0][0].clientHeight; canvas.height = (d3.select(".trace-list") as Indexable)._groups[0][0].clientHeight;
context.fillStyle = appStore.theme === Themes.Dark ? "#212224" : `#fff`; context.fillStyle = appStore.theme === Themes.Dark ? "#212224" : `#fff`;
context.fillRect(0, 0, canvas.width, canvas.height); context.fillRect(0, 0, canvas.width, canvas.height);
const image = new Image(); const image = new Image();
@@ -94,6 +97,7 @@ limitations under the License. -->
border: 1px solid; border: 1px solid;
font-size: 11px; font-size: 11px;
border-radius: 4px; border-radius: 4px;
margin: 3px;
} }
.btn { .btn {

View File

@@ -17,29 +17,39 @@ limitations under the License. -->
<div class="trace-t-loading" v-show="loading"> <div class="trace-t-loading" v-show="loading">
<Icon iconName="spinner" size="sm" /> <Icon iconName="spinner" size="sm" />
</div> </div>
<TableContainer :tableData="tableData" :type="TraceGraphType.STATISTICS" :headerType="headerType"> <TableContainer
:tableData="tableData"
:type="TraceGraphType.STATISTICS"
:headerType="headerType"
:traceId="traceId"
>
<div class="trace-tips" v-if="!tableData.length">{{ $t("noData") }}</div> <div class="trace-tips" v-if="!tableData.length">{{ $t("noData") }}</div>
</TableContainer> </TableContainer>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, onMounted } from "vue"; import { ref, watch, onMounted } from "vue";
import type { PropType } from "vue"; import TableContainer from "../Table/TableContainer.vue";
import TableContainer from "./Table/TableContainer.vue"; import traceTable from "../D3Graph/utils/trace-table";
import traceTable from "../utils/trace-table";
import type { StatisticsSpan, Span, StatisticsGroupRef } from "@/types/trace"; import type { StatisticsSpan, Span, StatisticsGroupRef } from "@/types/trace";
import { TraceGraphType } from "./constant"; import { TraceGraphType } from "./constant";
/* global defineProps, defineEmits, Recordable*/ /* global defineProps, defineEmits*/
const props = defineProps({ type Props = {
data: { type: Array as PropType<Span[]>, default: () => [] }, data: Span[];
traceId: { type: String, default: "" }, traceId: string;
showBtnDetail: { type: Boolean, default: false }, showBtnDetail: boolean;
headerType: { type: String, default: "" }, headerType: string;
}); selectedMaxTimestamp: number;
const emit = defineEmits(["load"]); selectedMinTimestamp: number;
};
type Emits = {
(e: "load", callback: () => void): void;
};
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const loading = ref<boolean>(true); const loading = ref<boolean>(true);
const tableData = ref<Recordable>([]); const tableData = ref<any[]>([]);
const list = ref<any[]>([]); const list = ref<any[]>([]);
onMounted(() => { onMounted(() => {

View File

@@ -18,24 +18,34 @@ limitations under the License. -->
:type="TraceGraphType.TABLE" :type="TraceGraphType.TABLE"
:headerType="headerType" :headerType="headerType"
@select="getSelectedSpan" @select="getSelectedSpan"
:selectedMaxTimestamp="selectedMaxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
:minTimestamp="minTimestamp"
:maxTimestamp="maxTimestamp"
/> />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { PropType } from "vue";
import type { Span } from "@/types/trace";
import type { SegmentSpan } from "@/types/profile"; import type { SegmentSpan } from "@/types/profile";
import Graph from "./D3Graph/Index.vue"; import Graph from "../D3Graph/Index.vue";
import { TraceGraphType } from "./constant"; import { TraceGraphType } from "../VisGraph/constant";
defineProps({ type Props = {
data: { type: Array as PropType<(Span | SegmentSpan)[]>, default: () => [] }, data: SegmentSpan[];
traceId: { type: String, default: "" }, traceId: string;
headerType: { type: String, default: "" }, selectedMaxTimestamp?: number;
}); selectedMinTimestamp?: number;
const emits = defineEmits(["select"]); minTimestamp: number;
maxTimestamp: number;
headerType?: string;
};
type Emits = {
(e: "select", value: SegmentSpan): void;
};
defineProps<Props>();
const emits = defineEmits<Emits>();
function getSelectedSpan(span: Span) { function getSelectedSpan(span: SegmentSpan) {
emits("select", span); emits("select", span);
} }
</script> </script>

View File

@@ -17,7 +17,7 @@ limitations under the License. -->
class="time-charts-item mr-5" class="time-charts-item mr-5"
v-for="(i, index) in traceStore.serviceList" v-for="(i, index) in traceStore.serviceList"
:key="index" :key="index"
:style="`color:${computedScale(index)}`" :style="`color:${getServiceColor(i)}`"
> >
<Icon iconName="issue-open-m" class="mr-5" size="sm" /> <Icon iconName="issue-open-m" class="mr-5" size="sm" />
<span>{{ i }}</span> <span>{{ i }}</span>
@@ -35,40 +35,44 @@ limitations under the License. -->
</a> </a>
</div> </div>
<div class="trace-tree"> <div class="trace-tree">
<Graph ref="charts" :data="data" :traceId="traceId" :type="TraceGraphType.TREE" /> <Graph
ref="charts"
:data="data"
:traceId="traceId"
:type="TraceGraphType.TREE"
:selectedMaxTimestamp="selectedMaxTimestamp"
:selectedMinTimestamp="selectedMinTimestamp"
:minTimestamp="minTimestamp"
:maxTimestamp="maxTimestamp"
/>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as d3 from "d3"; import Graph from "../D3Graph/Index.vue";
import Graph from "./D3Graph/Index.vue";
import type { PropType } from "vue";
import type { Span } from "@/types/trace"; import type { Span } from "@/types/trace";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { ref } from "vue"; import { ref } from "vue";
import { TraceGraphType } from "./constant"; import { TraceGraphType } from "./constant";
import { useTraceStore } from "@/store/modules/trace"; import { useTraceStore } from "@/store/modules/trace";
import { getServiceColor } from "@/utils/color";
/* global defineProps */ type Props = {
defineProps({ data: Span[];
data: { type: Array as PropType<Span[]>, default: () => [] }, traceId: string;
traceId: { type: String, default: "" }, selectedMaxTimestamp?: number;
}); selectedMinTimestamp?: number;
minTimestamp: number;
maxTimestamp: number;
};
defineProps<Props>();
const { t } = useI18n(); const { t } = useI18n();
const traceStore = useTraceStore(); const traceStore = useTraceStore();
const charts = ref<any>(null); const charts = ref<any>(null);
function computedScale(i: number) {
const sequentialScale = d3
.scaleSequential()
.domain([0, traceStore.serviceList.length + 1])
.interpolator(d3.interpolateCool);
return sequentialScale(i);
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.trace-tree { .trace-tree {
height: 100%; min-height: 400px;
overflow: auto; overflow: auto;
} }
@@ -84,7 +88,7 @@ limitations under the License. -->
.trace-tree-charts { .trace-tree-charts {
overflow: auto; overflow: auto;
padding: 10px; padding: 10px;
position: relative; // position: relative;
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
@@ -95,5 +99,6 @@ limitations under the License. -->
border: 1px solid; border: 1px solid;
font-size: 11px; font-size: 11px;
border-radius: 4px; border-radius: 4px;
margin: 3px;
} }
</style> </style>

View File

@@ -21,3 +21,9 @@ export enum TraceGraphType {
TABLE = "Table", TABLE = "Table",
STATISTICS = "Statistics", STATISTICS = "Statistics",
} }
export const GraphTypeOptions = [
{ value: "List", icon: "list-bulleted", label: "list" },
{ value: "Tree", icon: "issue-child", label: "tree" },
{ value: "Table", icon: "table", label: "table" },
{ value: "Statistics", icon: "statistics-bulleted", label: "statistics" },
] as const;