This is an automated email from the ASF dual-hosted git repository.

wusheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-booster-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new a834cdb2 feat: Implement Trace page (#500)
a834cdb2 is described below

commit a834cdb2eb9e065646e6ad41ff461356b4276d11
Author: Fine0830 <[email protected]>
AuthorDate: Mon Sep 29 17:36:31 2025 +0800

    feat: Implement Trace page (#500)
---
 src/assets/icons/link.svg                          |  16 ++++
 src/hooks/useTheme.ts                              | 104 +++++++++++++++++++++
 src/layout/Index.vue                               |  20 +++-
 src/layout/components/NavBar.vue                   |  60 +-----------
 src/locales/lang/en.ts                             |   1 +
 src/locales/lang/es.ts                             |   1 +
 src/locales/lang/zh.ts                             |   1 +
 src/router/__tests__/index.spec.ts                 |  42 +++++++++
 src/router/__tests__/trace.spec.ts                 |  55 +++++++++++
 src/router/constants.ts                            |   2 +
 src/router/index.ts                                |   2 +
 src/router/{index.ts => trace.ts}                  |  55 +++++------
 src/utils/__tests__/copy.spec.ts                   |  23 ++---
 src/utils/copy.ts                                  |   7 +-
 src/utils/validateAndSanitizeUrl.ts                |  10 +-
 src/views/dashboard/Trace.vue                      |  42 +++++++++
 .../related/trace/components/Table/TableItem.vue   |   2 +-
 .../trace/components/TraceQuery/TimelineTool.vue   |  10 +-
 .../trace/components/TraceQuery/TraceContent.vue   |  26 +++++-
 19 files changed, 367 insertions(+), 112 deletions(-)

diff --git a/src/assets/icons/link.svg b/src/assets/icons/link.svg
new file mode 100644
index 00000000..09c80683
--- /dev/null
+++ b/src/assets/icons/link.svg
@@ -0,0 +1,16 @@
+<!-- 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 class="icon" viewBox="0 0 1024 1024" version="1.1" 
xmlns="http://www.w3.org/2000/svg";><path d="M750.4 775.6h-119.6c-28 
0-50.6-22.6-50.6-50.6s22.6-50.6 50.6-50.6h119.6c2.4 0 5 0 7.4-0.2 2.4-0.2 5-0.4 
7.4-0.6 2.4-0.2 5-0.6 7.4-1 2.4-0.4 5-0.8 7.4-1.2 2.4-0.4 4.8-1 7.2-1.6 2.4-0.6 
4.8-1.2 7.2-2 2.4-0.8 4.8-1.6 7-2.4 2.4-0.8 4.6-1.8 7-2.6 2.2-1 4.6-2 
6.8-3s4.4-2.2 6.6-3.4c2.2-1.2 4.4-2.4 6.4-3.6 2.2-1.2 4.2-2.6 6.4-4 2-1.4 
4.2-2.8 6-4.2 2-1.4 4-3 5.8-4.6 2-1.6 3.8-3.2 5.6-4.8 1.8-1.6 3. [...]
\ No newline at end of file
diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts
new file mode 100644
index 00000000..b3a750a6
--- /dev/null
+++ b/src/hooks/useTheme.ts
@@ -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,
+  };
+}
diff --git a/src/layout/Index.vue b/src/layout/Index.vue
index 902d95e4..ef8f27f9 100644
--- a/src/layout/Index.vue
+++ b/src/layout/Index.vue
@@ -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 {
diff --git a/src/layout/components/NavBar.vue b/src/layout/components/NavBar.vue
index 7dc6ab75..9b03affe 100644
--- a/src/layout/components/NavBar.vue
+++ b/src/layout/components/NavBar.vue
@@ -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) || {};
   }
diff --git a/src/locales/lang/en.ts b/src/locales/lang/en.ts
index 8e8da3b1..792a5ba3 100644
--- a/src/locales/lang/en.ts
+++ b/src/locales/lang/en.ts
@@ -412,5 +412,6 @@ const msg = {
   totalSpans: "Total Spans",
   spanName: "Span name",
   parentId: "Parent ID",
+  shareTrace: "Share This Trace",
 };
 export default msg;
diff --git a/src/locales/lang/es.ts b/src/locales/lang/es.ts
index be08d7e0..156bdd43 100644
--- a/src/locales/lang/es.ts
+++ b/src/locales/lang/es.ts
@@ -412,5 +412,6 @@ const msg = {
   totalSpans: "Total Lapso",
   spanName: "Nombre de Lapso",
   parentId: "ID Padre",
+  shareTrace: "Compartir Traza",
 };
 export default msg;
diff --git a/src/locales/lang/zh.ts b/src/locales/lang/zh.ts
index d0e1dc0e..c68a406d 100644
--- a/src/locales/lang/zh.ts
+++ b/src/locales/lang/zh.ts
@@ -410,5 +410,6 @@ const msg = {
   totalSpans: "总跨度",
   spanName: "跨度名称",
   parentId: "父ID",
+  shareTrace: "分享Trace",
 };
 export default msg;
diff --git a/src/router/__tests__/index.spec.ts 
b/src/router/__tests__/index.spec.ts
index 5cae65f2..6dc887c9 100644
--- a/src/router/__tests__/index.spec.ts
+++ b/src/router/__tests__/index.spec.ts
@@ -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", () => {
diff --git a/src/router/__tests__/trace.spec.ts 
b/src/router/__tests__/trace.spec.ts
new file mode 100644
index 00000000..65c9c2af
--- /dev/null
+++ b/src/router/__tests__/trace.spec.ts
@@ -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);
+  });
+});
diff --git a/src/router/constants.ts b/src/router/constants.ts
index 84ab35b3..c822481c 100644
--- a/src/router/constants.ts
+++ b/src/router/constants.ts
@@ -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;
 
diff --git a/src/router/index.ts b/src/router/index.ts
index 523d97d0..dc4393a0 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -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,
 ];
 
 /**
diff --git a/src/router/index.ts b/src/router/trace.ts
similarity index 51%
copy from src/router/index.ts
copy to src/router/trace.ts
index 523d97d0..5ff996ac 100644
--- a/src/router/index.ts
+++ b/src/router/trace.ts
@@ -15,38 +15,27 @@
  * limitations under the License.
  */
 import type { AppRouteRecordRaw } from "@/types/router";
-import { createRouter, createWebHistory } from "vue-router";
-import { applyGuards } from "./guards";
-import { routesDashboard } from "./dashboard";
-import { routesMarketplace } from "./marketplace";
-import { routesAlarm } from "./alarm";
-import routesLayers from "./layer";
-import { routesSettings } from "./settings";
-import { routesNotFound } from "./notFound";
+import { ROUTE_NAMES, ROUTE_PATHS, META_KEYS } from "./constants";
+import Layout from "@/layout/Index.vue";
+import Trace from "@/views/dashboard/Trace.vue";
 
-/**
- * Combine all route configurations
- */
-export const routes: AppRouteRecordRaw[] = [
-  ...routesMarketplace,
-  ...routesLayers,
-  ...routesAlarm,
-  ...routesDashboard,
-  ...routesSettings,
-  ...routesNotFound,
+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,
+        },
+      },
+    ],
+  },
 ];
-
-/**
- * Create router instance
- */
-const router = createRouter({
-  history: createWebHistory(import.meta.env.BASE_URL),
-  routes: routes as any,
-});
-
-/**
- * Apply navigation guards
- */
-applyGuards(router, routes);
-
-export default router;
diff --git a/src/utils/__tests__/copy.spec.ts b/src/utils/__tests__/copy.spec.ts
index d6864368..20dd9e46 100644
--- a/src/utils/__tests__/copy.spec.ts
+++ b/src/utils/__tests__/copy.spec.ts
@@ -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
diff --git a/src/utils/copy.ts b/src/utils/copy.ts
index d3a5c446..76bfe0cf 100644
--- a/src/utils/copy.ts
+++ b/src/utils/copy.ts
@@ -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",
diff --git a/src/utils/validateAndSanitizeUrl.ts 
b/src/utils/validateAndSanitizeUrl.ts
index ab9edfa3..088e21cd 100644
--- a/src/utils/validateAndSanitizeUrl.ts
+++ b/src/utils/validateAndSanitizeUrl.ts
@@ -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: "",
diff --git a/src/views/dashboard/Trace.vue b/src/views/dashboard/Trace.vue
new file mode 100644
index 00000000..705e6875
--- /dev/null
+++ b/src/views/dashboard/Trace.vue
@@ -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>
diff --git a/src/views/dashboard/related/trace/components/Table/TableItem.vue 
b/src/views/dashboard/related/trace/components/Table/TableItem.vue
index 12fe8d39..541bc8c0 100644
--- a/src/views/dashboard/related/trace/components/Table/TableItem.vue
+++ b/src/views/dashboard/related/trace/components/Table/TableItem.vue
@@ -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" 
}}
diff --git 
a/src/views/dashboard/related/trace/components/TraceQuery/TimelineTool.vue 
b/src/views/dashboard/related/trace/components/TraceQuery/TimelineTool.vue
index 8db99b36..b967cee1 100644
--- a/src/views/dashboard/related/trace/components/TraceQuery/TimelineTool.vue
+++ b/src/views/dashboard/related/trace/components/TraceQuery/TimelineTool.vue
@@ -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");
diff --git 
a/src/views/dashboard/related/trace/components/TraceQuery/TraceContent.vue 
b/src/views/dashboard/related/trace/components/TraceQuery/TraceContent.vue
index f96d3d39..593a9b99 100644
--- a/src/views/dashboard/related/trace/components/TraceQuery/TraceContent.vue
+++ b/src/views/dashboard/related/trace/components/TraceQuery/TraceContent.vue
@@ -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>

Reply via email to