mirror of
https://github.com/apache/skywalking-booster-ui.git
synced 2025-10-14 11:21:29 +00:00
feat: Implement Trace page (#500)
This commit is contained in:
16
src/assets/icons/link.svg
Normal file
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
104
src/hooks/useTheme.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@@ -14,15 +14,31 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License. -->
|
limitations under the License. -->
|
||||||
<template>
|
<template>
|
||||||
<div class="app-wrapper flex-h">
|
<div class="app-wrapper flex-h">
|
||||||
<SideBar />
|
<SideBar v-if="notTraceRoute" />
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<NavBar />
|
<NavBar v-if="notTraceRoute" />
|
||||||
<AppMain />
|
<AppMain />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { AppMain, SideBar, NavBar } from "./components";
|
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>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.app-wrapper {
|
.app-wrapper {
|
||||||
|
@@ -85,7 +85,6 @@ limitations under the License. -->
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Themes } from "@/constants/data";
|
|
||||||
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";
|
||||||
@@ -98,50 +97,26 @@ limitations under the License. -->
|
|||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
|
|
||||||
const { t, te } = useI18n();
|
const { t, te } = useI18n();
|
||||||
const appStore = useAppStoreWithOut();
|
const appStore = useAppStoreWithOut();
|
||||||
const dashboardStore = useDashboardStore();
|
const dashboardStore = useDashboardStore();
|
||||||
const traceStore = useTraceStore();
|
const traceStore = useTraceStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const { theme, themeSwitchRef, initializeTheme, handleChangeTheme } = useTheme();
|
||||||
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);
|
||||||
const pageTitle = ref<string>("");
|
const pageTitle = ref<string>("");
|
||||||
const theme = ref<boolean>(true);
|
|
||||||
const themeSwitchRef = ref<HTMLElement>();
|
|
||||||
const coldStage = ref<boolean>(false);
|
const coldStage = ref<boolean>(false);
|
||||||
|
|
||||||
const savedTheme = window.localStorage.getItem("theme-is-dark");
|
initializeTheme();
|
||||||
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();
|
|
||||||
resetDuration();
|
resetDuration();
|
||||||
getVersion();
|
getVersion();
|
||||||
getNavPaths();
|
getNavPaths();
|
||||||
setTTL();
|
setTTL();
|
||||||
traceStore.getHasQueryTracesV2Support();
|
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() {
|
function changeDataMode() {
|
||||||
appStore.setColdStageMode(coldStage.value);
|
appStore.setColdStageMode(coldStage.value);
|
||||||
if (coldStage.value) {
|
if (coldStage.value) {
|
||||||
@@ -160,35 +135,6 @@ limitations under the License. -->
|
|||||||
appStore.setDuration(InitializationDurationRow);
|
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[]) {
|
function getName(list: any[]) {
|
||||||
return list.find((d: any) => d.selected) || {};
|
return list.find((d: any) => d.selected) || {};
|
||||||
}
|
}
|
||||||
|
@@ -412,5 +412,6 @@ const msg = {
|
|||||||
totalSpans: "Total Spans",
|
totalSpans: "Total Spans",
|
||||||
spanName: "Span name",
|
spanName: "Span name",
|
||||||
parentId: "Parent ID",
|
parentId: "Parent ID",
|
||||||
|
shareTrace: "Share This Trace",
|
||||||
};
|
};
|
||||||
export default msg;
|
export default msg;
|
||||||
|
@@ -412,5 +412,6 @@ const msg = {
|
|||||||
totalSpans: "Total Lapso",
|
totalSpans: "Total Lapso",
|
||||||
spanName: "Nombre de Lapso",
|
spanName: "Nombre de Lapso",
|
||||||
parentId: "ID Padre",
|
parentId: "ID Padre",
|
||||||
|
shareTrace: "Compartir Traza",
|
||||||
};
|
};
|
||||||
export default msg;
|
export default msg;
|
||||||
|
@@ -410,5 +410,6 @@ const msg = {
|
|||||||
totalSpans: "总跨度",
|
totalSpans: "总跨度",
|
||||||
spanName: "跨度名称",
|
spanName: "跨度名称",
|
||||||
parentId: "父ID",
|
parentId: "父ID",
|
||||||
|
shareTrace: "分享Trace",
|
||||||
};
|
};
|
||||||
export default msg;
|
export default msg;
|
||||||
|
@@ -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
|
// Mock guards
|
||||||
vi.mock("../guards", () => ({
|
vi.mock("../guards", () => ({
|
||||||
applyGuards: vi.fn(),
|
applyGuards: vi.fn(),
|
||||||
@@ -144,6 +177,7 @@ describe("Router Index - Route Structure", () => {
|
|||||||
expect.objectContaining({ name: "Dashboard" }),
|
expect.objectContaining({ name: "Dashboard" }),
|
||||||
expect.objectContaining({ name: "Settings" }),
|
expect.objectContaining({ name: "Settings" }),
|
||||||
expect.objectContaining({ name: "NotFound" }),
|
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", () => {
|
describe("Route Export", () => {
|
||||||
|
55
src/router/__tests__/trace.spec.ts
Normal file
55
src/router/__tests__/trace.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
@@ -23,6 +23,7 @@ export const ROUTE_NAMES = {
|
|||||||
SETTINGS: "Settings",
|
SETTINGS: "Settings",
|
||||||
NOT_FOUND: "NotFound",
|
NOT_FOUND: "NotFound",
|
||||||
LAYER: "Layer",
|
LAYER: "Layer",
|
||||||
|
TRACE: "Trace",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Route Paths
|
// Route Paths
|
||||||
@@ -39,6 +40,7 @@ export const ROUTE_PATHS = {
|
|||||||
},
|
},
|
||||||
ALARM: "/alerting",
|
ALARM: "/alerting",
|
||||||
SETTINGS: "/settings",
|
SETTINGS: "/settings",
|
||||||
|
TRACE: "/traces/:traceId",
|
||||||
NOT_FOUND: "/:pathMatch(.*)*",
|
NOT_FOUND: "/:pathMatch(.*)*",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@@ -23,6 +23,7 @@ import { routesAlarm } from "./alarm";
|
|||||||
import routesLayers from "./layer";
|
import routesLayers from "./layer";
|
||||||
import { routesSettings } from "./settings";
|
import { routesSettings } from "./settings";
|
||||||
import { routesNotFound } from "./notFound";
|
import { routesNotFound } from "./notFound";
|
||||||
|
import { routesTrace } from "./trace";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combine all route configurations
|
* Combine all route configurations
|
||||||
@@ -34,6 +35,7 @@ export const routes: AppRouteRecordRaw[] = [
|
|||||||
...routesDashboard,
|
...routesDashboard,
|
||||||
...routesSettings,
|
...routesSettings,
|
||||||
...routesNotFound,
|
...routesNotFound,
|
||||||
|
...routesTrace,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
41
src/router/trace.ts
Normal file
41
src/router/trace.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
@@ -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";
|
const testText = "test text to copy";
|
||||||
|
|
||||||
// Set protocol to HTTP
|
// Set protocol to HTTP
|
||||||
@@ -81,7 +81,10 @@ describe("copy utility function", () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
copy(testText);
|
copy(testText);
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
|
||||||
expect(ElNotification).toHaveBeenCalledWith({
|
expect(ElNotification).toHaveBeenCalledWith({
|
||||||
title: "Warning",
|
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 = "";
|
const testText = "";
|
||||||
mockClipboard.writeText.mockResolvedValue(undefined);
|
mockClipboard.writeText.mockResolvedValue(undefined);
|
||||||
|
|
||||||
copy(testText);
|
copy(testText);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(mockClipboard.writeText).not.toHaveBeenCalled();
|
||||||
expect(mockClipboard.writeText).toHaveBeenCalledWith("");
|
expect(ElNotification).not.toHaveBeenCalled();
|
||||||
expect(ElNotification).toHaveBeenCalledWith({
|
|
||||||
title: "Success",
|
|
||||||
message: "Copied",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle long text", async () => {
|
it("should handle long text", async () => {
|
||||||
@@ -205,7 +203,7 @@ describe("copy utility function", () => {
|
|||||||
expect(ElNotification).toHaveBeenCalledTimes(3);
|
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";
|
const testText = "test text";
|
||||||
|
|
||||||
// Set protocol to HTTP
|
// Set protocol to HTTP
|
||||||
@@ -214,7 +212,10 @@ describe("copy utility function", () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
copy(testText);
|
copy(testText);
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
|
||||||
// Should show HTTP error, not clipboard error
|
// Should show HTTP error, not clipboard error
|
||||||
expect(ElNotification).toHaveBeenCalledWith({
|
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";
|
const testText = "test text";
|
||||||
|
|
||||||
// Set protocol to file and ensure clipboard is not available
|
// Set protocol to file and ensure clipboard is not available
|
||||||
|
@@ -18,7 +18,12 @@
|
|||||||
import { ElNotification } from "element-plus";
|
import { ElNotification } from "element-plus";
|
||||||
|
|
||||||
export default (text: string): void => {
|
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({
|
ElNotification({
|
||||||
title: "Warning",
|
title: "Warning",
|
||||||
message: "Clipboard is not supported in HTTP environments",
|
message: "Clipboard is not supported in HTTP environments",
|
||||||
|
@@ -16,12 +16,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// URL validation function to prevent XSS
|
// URL validation function to prevent XSS
|
||||||
export function validateAndSanitizeUrl(inputUrl: string): { isValid: boolean; sanitizedUrl: string; error: string } {
|
export function validateAndSanitizeUrl(url: string): { isValid: boolean; sanitizedUrl: string; error: string } {
|
||||||
if (!inputUrl.trim()) {
|
if (!url.trim()) {
|
||||||
return { isValid: true, sanitizedUrl: "", error: "" };
|
return { isValid: true, sanitizedUrl: "", error: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let inputUrl = url;
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
inputUrl = `${location.origin}${url}`;
|
||||||
|
}
|
||||||
// Create URL object to validate the URL format
|
// Create URL object to validate the URL format
|
||||||
const urlObj = new URL(inputUrl);
|
const urlObj = new URL(inputUrl);
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ export function validateAndSanitizeUrl(inputUrl: string): { isValid: boolean; sa
|
|||||||
error: "",
|
error: "",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
sanitizedUrl: "",
|
sanitizedUrl: "",
|
||||||
|
42
src/views/dashboard/Trace.vue
Normal file
42
src/views/dashboard/Trace.vue
Normal 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>
|
@@ -100,7 +100,7 @@ limitations under the License. -->
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="start-time">
|
<div class="start-time">
|
||||||
{{ dateFormat(data.startTime) }}
|
{{ data.startTime ? dateFormat(data.startTime) : "" }}
|
||||||
</div>
|
</div>
|
||||||
<div class="exec-ms">
|
<div class="exec-ms">
|
||||||
{{ data.endTime - data.startTime ? data.endTime - data.startTime : "0" }}
|
{{ data.endTime - data.startTime ? data.endTime - data.startTime : "0" }}
|
||||||
|
@@ -16,7 +16,7 @@ limitations under the License. -->
|
|||||||
<div class="timeline-tool flex-h">
|
<div class="timeline-tool flex-h">
|
||||||
<div class="flex-h trace-type item">
|
<div class="flex-h trace-type item">
|
||||||
<el-radio-group v-model="spansGraphType" size="small">
|
<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" />
|
<Icon :iconName="option.icon" />
|
||||||
{{ t(option.label) }}
|
{{ t(option.label) }}
|
||||||
</el-radio-button>
|
</el-radio-button>
|
||||||
@@ -39,7 +39,13 @@ limitations under the License. -->
|
|||||||
(e: "updateSpansGraphType", value: string): void;
|
(e: "updateSpansGraphType", value: string): void;
|
||||||
}>();
|
}>();
|
||||||
const { t } = useI18n();
|
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() {
|
function onToggleMinTimeline() {
|
||||||
emit("toggleMinTimeline");
|
emit("toggleMinTimeline");
|
||||||
|
@@ -17,7 +17,13 @@ limitations under the License. -->
|
|||||||
<div class="trace-info">
|
<div class="trace-info">
|
||||||
<div class="flex-h" style="justify-content: space-between">
|
<div class="flex-h" style="justify-content: space-between">
|
||||||
<h3>{{ trace.label }}</h3>
|
<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-dropdown @command="handleDownload" trigger="click">
|
||||||
<el-button size="small">
|
<el-button size="small">
|
||||||
{{ t("download") }}
|
{{ t("download") }}
|
||||||
@@ -44,9 +50,12 @@ limitations under the License. -->
|
|||||||
<span class="grey mr-5">{{ t("totalSpans") }}</span>
|
<span class="grey mr-5">{{ t("totalSpans") }}</span>
|
||||||
<span class="value">{{ trace.spans?.length || 0 }}</span>
|
<span class="value">{{ trace.spans?.length || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="trace-id-container flex-h" style="align-items: center">
|
||||||
<span class="grey mr-5">{{ t("traceID") }}</span>
|
<span class="grey mr-5">{{ t("traceID") }}</span>
|
||||||
<span class="value">{{ trace.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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,6 +100,8 @@ limitations under the License. -->
|
|||||||
import graphs from "../VisGraph/index";
|
import graphs from "../VisGraph/index";
|
||||||
import { WidgetType } from "@/views/dashboard/data";
|
import { WidgetType } from "@/views/dashboard/data";
|
||||||
import { GraphTypeOptions } from "../VisGraph/constant";
|
import { GraphTypeOptions } from "../VisGraph/constant";
|
||||||
|
import copy from "@/utils/copy";
|
||||||
|
import router from "@/router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trace: Trace;
|
trace: Trace;
|
||||||
@@ -148,6 +159,17 @@ limitations under the License. -->
|
|||||||
ElMessage.error("Failed to download file");
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
Reference in New Issue
Block a user