feat: Implement Trace page (#500)

This commit is contained in:
Fine0830
2025-09-29 17:36:31 +08:00
committed by GitHub
parent dd90ab5ea7
commit a834cdb2eb
19 changed files with 386 additions and 79 deletions

16
src/assets/icons/link.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

104
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* 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";
import { Themes } from "@/constants/data";
import { useAppStoreWithOut } from "@/store/modules/app";
export function useTheme() {
const appStore = useAppStoreWithOut();
const theme = ref<boolean>(true);
const themeSwitchRef = ref<HTMLElement>();
// Initialize theme from localStorage or system preference
function initializeTheme() {
const savedTheme = window.localStorage.getItem("theme-is-dark");
let isDark = true; // default to dark theme
if (savedTheme === "false") {
isDark = false;
} else if (savedTheme === "") {
// read the theme preference from system setting if there is no user setting
isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
}
theme.value = isDark;
applyTheme();
}
// Apply theme to DOM and store
function applyTheme() {
const root = document.documentElement;
if (theme.value) {
root.classList.add(Themes.Dark);
root.classList.remove(Themes.Light);
appStore.setTheme(Themes.Dark);
} else {
root.classList.add(Themes.Light);
root.classList.remove(Themes.Dark);
appStore.setTheme(Themes.Light);
}
window.localStorage.setItem("theme-is-dark", String(theme.value));
}
// Handle theme change with transition animation
function handleChangeTheme() {
const x = themeSwitchRef.value?.offsetLeft ?? 0;
const y = themeSwitchRef.value?.offsetTop ?? 0;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
// compatibility handling
if (!document.startViewTransition) {
applyTheme();
return;
}
// api: https://developer.chrome.com/docs/web-platform/view-transitions
const transition = document.startViewTransition(() => {
applyTheme();
});
transition.ready.then(() => {
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
document.documentElement.animate(
{
clipPath: !theme.value ? clipPath.reverse() : clipPath,
},
{
duration: 500,
easing: "ease-in",
pseudoElement: !theme.value ? "::view-transition-old(root)" : "::view-transition-new(root)",
},
);
});
}
// Computed properties
const isDark = computed(() => theme.value);
const isLight = computed(() => !theme.value);
return {
theme,
themeSwitchRef,
isDark,
isLight,
initializeTheme,
applyTheme,
handleChangeTheme,
};
}

View File

@@ -14,15 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License. -->
<template>
<div class="app-wrapper flex-h">
<SideBar />
<SideBar v-if="notTraceRoute" />
<div class="main-container">
<NavBar />
<NavBar v-if="notTraceRoute" />
<AppMain />
</div>
</div>
</template>
<script lang="ts" setup>
import { AppMain, SideBar, NavBar } from "./components";
import { useRoute } from "vue-router";
import { computed, onMounted } from "vue";
import { useTheme } from "@/hooks/useTheme";
const route = useRoute();
const { initializeTheme } = useTheme();
// Check if current route matches the trace route pattern
const notTraceRoute = computed(() => {
return !route.path.startsWith("/traces/");
});
// Initialize theme to preserve theme when NavBar is hidden
onMounted(() => {
initializeTheme();
});
</script>
<style lang="scss" scoped>
.app-wrapper {

View File

@@ -85,7 +85,6 @@ limitations under the License. -->
</div>
</template>
<script lang="ts" setup>
import { Themes } from "@/constants/data";
import router from "@/router";
import { useAppStoreWithOut, InitializationDurationRow } from "@/store/modules/app";
import { useDashboardStore } from "@/store/modules/dashboard";
@@ -98,50 +97,26 @@ limitations under the License. -->
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { useTheme } from "@/hooks/useTheme";
const { t, te } = useI18n();
const appStore = useAppStoreWithOut();
const dashboardStore = useDashboardStore();
const traceStore = useTraceStore();
const route = useRoute();
const { theme, themeSwitchRef, initializeTheme, handleChangeTheme } = useTheme();
const pathNames = ref<{ path?: string; name: string; selected: boolean }[][]>([]);
const showTimeRangeTips = ref<boolean>(false);
const pageTitle = ref<string>("");
const theme = ref<boolean>(true);
const themeSwitchRef = ref<HTMLElement>();
const coldStage = ref<boolean>(false);
const savedTheme = window.localStorage.getItem("theme-is-dark");
if (savedTheme === "false") {
theme.value = false;
}
if (savedTheme === "") {
// read the theme preference from system setting if there is no user setting
theme.value = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
}
changeTheme();
initializeTheme();
resetDuration();
getVersion();
getNavPaths();
setTTL();
traceStore.getHasQueryTracesV2Support();
function changeTheme() {
const root = document.documentElement;
if (theme.value) {
root.classList.add(Themes.Dark);
root.classList.remove(Themes.Light);
appStore.setTheme(Themes.Dark);
} else {
root.classList.add(Themes.Light);
root.classList.remove(Themes.Dark);
appStore.setTheme(Themes.Light);
}
window.localStorage.setItem("theme-is-dark", String(theme.value));
}
function changeDataMode() {
appStore.setColdStageMode(coldStage.value);
if (coldStage.value) {
@@ -160,35 +135,6 @@ limitations under the License. -->
appStore.setDuration(InitializationDurationRow);
}
function handleChangeTheme() {
const x = themeSwitchRef.value?.offsetLeft ?? 0;
const y = themeSwitchRef.value?.offsetTop ?? 0;
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
// compatibility handling
if (!document.startViewTransition) {
changeTheme();
return;
}
// api: https://developer.chrome.com/docs/web-platform/view-transitions
const transition = document.startViewTransition(() => {
changeTheme();
});
transition.ready.then(() => {
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
document.documentElement.animate(
{
clipPath: !theme.value ? clipPath.reverse() : clipPath,
},
{
duration: 500,
easing: "ease-in",
pseudoElement: !theme.value ? "::view-transition-old(root)" : "::view-transition-new(root)",
},
);
});
}
function getName(list: any[]) {
return list.find((d: any) => d.selected) || {};
}

View File

@@ -412,5 +412,6 @@ const msg = {
totalSpans: "Total Spans",
spanName: "Span name",
parentId: "Parent ID",
shareTrace: "Share This Trace",
};
export default msg;

View File

@@ -412,5 +412,6 @@ const msg = {
totalSpans: "Total Lapso",
spanName: "Nombre de Lapso",
parentId: "ID Padre",
shareTrace: "Compartir Traza",
};
export default msg;

View File

@@ -410,5 +410,6 @@ const msg = {
totalSpans: "总跨度",
spanName: "跨度名称",
parentId: "父ID",
shareTrace: "分享Trace",
};
export default msg;

View File

@@ -121,6 +121,39 @@ vi.mock("../notFound", () => ({
],
}));
vi.mock("../trace", () => ({
routesTrace: [
{
name: "Trace",
path: "",
meta: {
title: "Trace",
i18nKey: "trace",
icon: "timeline",
hasGroup: false,
activate: true,
breadcrumb: true,
notShow: false,
},
children: [
{
name: "ViewTrace",
path: "/traces/:traceId",
meta: {
title: "Trace View",
i18nKey: "traceView",
icon: "timeline",
hasGroup: false,
activate: true,
breadcrumb: true,
notShow: false,
},
},
],
},
],
}));
// Mock guards
vi.mock("../guards", () => ({
applyGuards: vi.fn(),
@@ -144,6 +177,7 @@ describe("Router Index - Route Structure", () => {
expect.objectContaining({ name: "Dashboard" }),
expect.objectContaining({ name: "Settings" }),
expect.objectContaining({ name: "NotFound" }),
expect.objectContaining({ name: "Trace" }),
]);
});
@@ -186,6 +220,14 @@ describe("Router Index - Route Structure", () => {
}),
);
});
it("should include trace routes", () => {
expect(routes).toContainEqual(
expect.objectContaining({
name: "Trace",
}),
);
});
});
describe("Route Export", () => {

View File

@@ -0,0 +1,55 @@
/**
* 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 { describe, it, expect, vi } from "vitest";
import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS } from "../constants";
// Mock Vue SFC imports used by the route module
vi.mock("@/layout/Index.vue", () => ({ default: {} }));
vi.mock("@/views/dashboard/Trace.vue", () => ({ default: {} }));
// Import after mocks
import { routesTrace } from "../trace";
describe("Trace Routes", () => {
it("should export trace routes array", () => {
expect(routesTrace).toBeDefined();
expect(Array.isArray(routesTrace)).toBe(true);
expect(routesTrace).toHaveLength(1);
});
it("should have correct root trace route structure", () => {
const rootRoute = routesTrace[0];
expect(rootRoute.name).toBe(ROUTE_NAMES.TRACE);
expect(rootRoute.path).toBe("");
expect(rootRoute.meta?.[META_KEYS.NOT_SHOW]).toBe(false);
expect(rootRoute.children).toBeDefined();
expect(rootRoute.children).toHaveLength(1);
});
it("should have child view trace route with correct path and meta", () => {
const rootRoute = routesTrace[0];
const childRoute = rootRoute.children?.[0];
expect(childRoute).toBeDefined();
expect(childRoute?.name).toBe("ViewTrace");
expect(childRoute?.path).toBe(ROUTE_PATHS.TRACE);
expect(childRoute?.meta?.[META_KEYS.NOT_SHOW]).toBe(false);
});
});

View File

@@ -23,6 +23,7 @@ export const ROUTE_NAMES = {
SETTINGS: "Settings",
NOT_FOUND: "NotFound",
LAYER: "Layer",
TRACE: "Trace",
} as const;
// Route Paths
@@ -39,6 +40,7 @@ export const ROUTE_PATHS = {
},
ALARM: "/alerting",
SETTINGS: "/settings",
TRACE: "/traces/:traceId",
NOT_FOUND: "/:pathMatch(.*)*",
} as const;

View File

@@ -23,6 +23,7 @@ import { routesAlarm } from "./alarm";
import routesLayers from "./layer";
import { routesSettings } from "./settings";
import { routesNotFound } from "./notFound";
import { routesTrace } from "./trace";
/**
* Combine all route configurations
@@ -34,6 +35,7 @@ export const routes: AppRouteRecordRaw[] = [
...routesDashboard,
...routesSettings,
...routesNotFound,
...routesTrace,
];
/**

41
src/router/trace.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { AppRouteRecordRaw } from "@/types/router";
import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS } from "./constants";
import Layout from "@/layout/Index.vue";
import Trace from "@/views/dashboard/Trace.vue";
export const routesTrace: AppRouteRecordRaw[] = [
{
path: "",
name: ROUTE_NAMES.TRACE,
meta: {
[META_KEYS.NOT_SHOW]: false,
},
component: Layout,
children: [
{
path: ROUTE_PATHS.TRACE,
name: "ViewTrace",
component: Trace,
meta: {
[META_KEYS.NOT_SHOW]: false,
},
},
],
},
];

View File

@@ -72,7 +72,7 @@ describe("copy utility function", () => {
});
});
it("should show error notification for HTTP protocol", () => {
it("should show error notification for HTTP protocol in production", () => {
const testText = "test text to copy";
// Set protocol to HTTP
@@ -81,7 +81,10 @@ describe("copy utility function", () => {
writable: true,
});
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "production";
copy(testText);
process.env.NODE_ENV = originalEnv;
expect(ElNotification).toHaveBeenCalledWith({
title: "Warning",
@@ -127,20 +130,15 @@ describe("copy utility function", () => {
});
});
it("should handle empty string", async () => {
it("should do nothing when text is empty", async () => {
const testText = "";
mockClipboard.writeText.mockResolvedValue(undefined);
copy(testText);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mockClipboard.writeText).toHaveBeenCalledWith("");
expect(ElNotification).toHaveBeenCalledWith({
title: "Success",
message: "Copied",
type: "success",
});
expect(mockClipboard.writeText).not.toHaveBeenCalled();
expect(ElNotification).not.toHaveBeenCalled();
});
it("should handle long text", async () => {
@@ -205,7 +203,7 @@ describe("copy utility function", () => {
expect(ElNotification).toHaveBeenCalledTimes(3);
});
it("should handle HTTP protocol and clipboard not available", () => {
it("should handle HTTP protocol and clipboard not available in production", () => {
const testText = "test text";
// Set protocol to HTTP
@@ -214,7 +212,10 @@ describe("copy utility function", () => {
writable: true,
});
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "production";
copy(testText);
process.env.NODE_ENV = originalEnv;
// Should show HTTP error, not clipboard error
expect(ElNotification).toHaveBeenCalledWith({
@@ -224,7 +225,7 @@ describe("copy utility function", () => {
});
});
it("should handle file protocol", () => {
it("should handle file protocol when clipboard is unavailable", () => {
const testText = "test text";
// Set protocol to file and ensure clipboard is not available

View File

@@ -18,7 +18,12 @@
import { ElNotification } from "element-plus";
export default (text: string): void => {
if (location.protocol === "http:") {
if (!text) {
return;
}
// Clipboard functionality is restricted in production HTTP environments for security reasons.
// In development, clipboard is allowed even over HTTP to ease testing.
if (process.env.NODE_ENV === "production" && location.protocol === "http:") {
ElNotification({
title: "Warning",
message: "Clipboard is not supported in HTTP environments",

View File

@@ -16,12 +16,15 @@
*/
// URL validation function to prevent XSS
export function validateAndSanitizeUrl(inputUrl: string): { isValid: boolean; sanitizedUrl: string; error: string } {
if (!inputUrl.trim()) {
export function validateAndSanitizeUrl(url: string): { isValid: boolean; sanitizedUrl: string; error: string } {
if (!url.trim()) {
return { isValid: true, sanitizedUrl: "", error: "" };
}
try {
let inputUrl = url;
if (!url.startsWith("http://") && !url.startsWith("https://")) {
inputUrl = `${location.origin}${url}`;
}
// Create URL object to validate the URL format
const urlObj = new URL(inputUrl);
@@ -55,6 +58,7 @@ export function validateAndSanitizeUrl(inputUrl: string): { isValid: boolean; sa
error: "",
};
} catch (error) {
console.error(error);
return {
isValid: false,
sanitizedUrl: "",

View File

@@ -0,0 +1,42 @@
<!-- 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 style="padding: 20px">
<TraceContent v-if="traceStore.currentTrace" :trace="traceStore.currentTrace" />
<div style="text-align: center; padding: 20px" v-if="!traceStore.loading && !traceStore.currentTrace"
>No trace found</div
>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from "vue-router";
import { computed, onMounted, provide } from "vue";
import { useTraceStore } from "@/store/modules/trace";
import TraceContent from "@/views/dashboard/related/trace/components/TraceQuery/TraceContent.vue";
const route = useRoute();
const traceStore = useTraceStore();
const traceId = computed(() => route.params.traceId as string);
provide("options", {});
onMounted(() => {
if (traceId.value) {
traceStore.setTraceCondition({
traceId: traceId.value,
});
traceStore.fetchV2Traces();
}
});
</script>

View File

@@ -100,7 +100,7 @@ limitations under the License. -->
</el-tooltip>
</div>
<div class="start-time">
{{ dateFormat(data.startTime) }}
{{ data.startTime ? dateFormat(data.startTime) : "" }}
</div>
<div class="exec-ms">
{{ data.endTime - data.startTime ? data.endTime - data.startTime : "0" }}

View File

@@ -16,7 +16,7 @@ limitations under the License. -->
<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">
<el-radio-button v-for="option in reorderedOptions" :key="option.value" :value="option.value">
<Icon :iconName="option.icon" />
{{ t(option.label) }}
</el-radio-button>
@@ -39,7 +39,13 @@ limitations under the License. -->
(e: "updateSpansGraphType", value: string): void;
}>();
const { t } = useI18n();
const spansGraphType = ref<string>(GraphTypeOptions[2].value);
const reorderedOptions = [
GraphTypeOptions[2], // Table
GraphTypeOptions[0], // List
GraphTypeOptions[1], // Tree
GraphTypeOptions[3], // Statistics
];
const spansGraphType = ref<string>(reorderedOptions[0].value);
function onToggleMinTimeline() {
emit("toggleMinTimeline");

View File

@@ -17,7 +17,13 @@ limitations under the License. -->
<div class="trace-info">
<div class="flex-h" style="justify-content: space-between">
<h3>{{ trace.label }}</h3>
<div>
<div class="flex-h">
<div class="mr-5 cp">
<el-button size="small" @click="viewTrace">
{{ t("shareTrace") }}
<Icon class="ml-5" size="small" iconName="link" />
</el-button>
</div>
<el-dropdown @command="handleDownload" trigger="click">
<el-button size="small">
{{ t("download") }}
@@ -44,9 +50,12 @@ limitations under the License. -->
<span class="grey mr-5">{{ t("totalSpans") }}</span>
<span class="value">{{ trace.spans?.length || 0 }}</span>
</div>
<div>
<div class="trace-id-container flex-h" style="align-items: center">
<span class="grey mr-5">{{ t("traceID") }}</span>
<span class="value">{{ trace.traceId }}</span>
<span class="value ml-5 cp" @click="handleCopyTraceId">
<Icon size="middle" iconName="copy" />
</span>
</div>
</div>
</div>
@@ -91,6 +100,8 @@ limitations under the License. -->
import graphs from "../VisGraph/index";
import { WidgetType } from "@/views/dashboard/data";
import { GraphTypeOptions } from "../VisGraph/constant";
import copy from "@/utils/copy";
import router from "@/router";
interface Props {
trace: Trace;
@@ -148,6 +159,17 @@ limitations under the License. -->
ElMessage.error("Failed to download file");
}
}
function viewTrace() {
if (!traceStore.currentTrace?.traceId) return;
const traceUrl = `/traces/${traceStore.currentTrace.traceId}`;
const routeUrl = router.resolve({ path: traceUrl });
window.open(routeUrl.href, "_blank");
}
function handleCopyTraceId() {
if (!traceStore.currentTrace?.traceId) return;
copy(traceStore.currentTrace?.traceId);
}
</script>
<style lang="scss" scoped>