From 1c06082a8288aac869bfcae8fd4ccabd7d6ededb Mon Sep 17 00:00:00 2001 From: omkark06 Date: Mon, 2 Nov 2026 19:14:21 +0530 Subject: [PATCH] fix(traces): zoom controls on trace graphs, per-branch tree metrics, catalog errors, org switch with Auto Run off Trace Graph (Tree View and Graph View) and Flame Graph had no zoom buttons, so zooming needed a mouse wheel or trackpad gesture. They now share the zoom in / zoom out / fit control and zoom logic that Service Graph already had (useChartZoom + ChartZoomControls). Flame Graph zooms by narrowing the visible time range and pans by dragging or by horizontal / shift wheel; selecting a span outside the window brings it into view, and a new trace resets the zoom. Tree View gave every copy of a repeated service the same latency and error state, built from all of that service's spans. Each node now takes its numbers from the spans on its own ancestor path, and the hover tooltip resolves the node by its position in the tree the chart draws, so it stays right while the service search filters the tree. Service Catalog selected infer_service_system and infer_service_type whenever infer_service_name existed, so streams without them got a 400, and the failure was shown as "No services found" or as the previous stream's rows. Absent columns are now selected as NULL, and a failed query clears the rows and shows the load-error state with the server message and Retry. With Auto Run off, a header org switch still searched: the remounted page could read the previous org's URL, whose stream= counted as a shared link, and the Rate and Duration panels queried because searchApplied survived the reset. During an org switch a URL that still names the org being left is no longer treated as a shared link, and the reset clears searchApplied. A shared link for another org still searches. --- .../traces/ChartZoomControls.spec.ts | 44 +++ .../components/traces/ChartZoomControls.vue | 72 +++++ .../components/traces/FlameGraphView.spec.ts | 253 ++++++++++++++++++ web/src/components/traces/FlameGraphView.vue | 180 +++++++++++-- .../composables/traces/useChartZoom.spec.ts | 125 +++++++++ web/src/composables/traces/useChartZoom.ts | 74 +++++ .../composables/useTracePatternTree.spec.ts | 62 ++++- web/src/composables/useTracePatternTree.ts | 24 +- web/src/composables/useTraces.spec.ts | 7 + web/src/composables/useTraces.ts | 2 + web/src/plugins/traces/Index.spec.ts | 82 ++++++ web/src/plugins/traces/Index.vue | 20 +- web/src/plugins/traces/ServiceGraph.vue | 94 ++----- .../plugins/traces/ServicesCatalog.spec.ts | 106 +++++++- web/src/plugins/traces/ServicesCatalog.vue | 62 ++++- web/src/plugins/traces/TraceDetails.spec.ts | 56 ++++ web/src/plugins/traces/TraceDetails.vue | 50 +++- web/src/test/unit/mockData/traces.ts | 51 ++++ web/src/utils/traces/patternDetection.spec.ts | 45 +++- web/src/utils/traces/patternDetection.ts | 119 +++++--- .../traces/treeVisualizationEngine.spec.ts | 66 ++++- .../utils/traces/treeVisualizationEngine.ts | 57 ++-- 22 files changed, 1439 insertions(+), 212 deletions(-) create mode 100644 web/src/components/traces/ChartZoomControls.spec.ts create mode 100644 web/src/components/traces/ChartZoomControls.vue create mode 100644 web/src/composables/traces/useChartZoom.spec.ts create mode 100644 web/src/composables/traces/useChartZoom.ts diff --git a/web/src/components/traces/ChartZoomControls.spec.ts b/web/src/components/traces/ChartZoomControls.spec.ts new file mode 100644 index 0000000000..c4b644f7c0 --- /dev/null +++ b/web/src/components/traces/ChartZoomControls.spec.ts @@ -0,0 +1,44 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import i18n from "@/locales"; +import ChartZoomControls from "@/components/traces/ChartZoomControls.vue"; + +const mountControls = () => + mount(ChartZoomControls, { + props: { dataTestPrefix: "trace-graph" }, + global: { plugins: [i18n] }, + }); + +describe("ChartZoomControls", () => { + it("prefixes every data-test with dataTestPrefix", () => { + const wrapper = mountControls(); + for (const suffix of ["zoom-controls", "zoom-in", "zoom-out", "fit-screen"]) { + expect(wrapper.find(`[data-test="trace-graph-${suffix}"]`).exists()).toBe(true); + } + }); + + it.each([ + ["zoom-in", "zoom-in"], + ["zoom-out", "zoom-out"], + ["fit-screen", "fit"], + ])("clicking %s emits %s", async (suffix, event) => { + const wrapper = mountControls(); + await wrapper.find(`[data-test="trace-graph-${suffix}"]`).trigger("click"); + expect(wrapper.emitted(event)).toHaveLength(1); + }); +}); diff --git a/web/src/components/traces/ChartZoomControls.vue b/web/src/components/traces/ChartZoomControls.vue new file mode 100644 index 0000000000..35759c4f19 --- /dev/null +++ b/web/src/components/traces/ChartZoomControls.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/web/src/components/traces/FlameGraphView.spec.ts b/web/src/components/traces/FlameGraphView.spec.ts index 0cce033f4e..40e23205be 100644 --- a/web/src/components/traces/FlameGraphView.spec.ts +++ b/web/src/components/traces/FlameGraphView.spec.ts @@ -1617,6 +1617,259 @@ describe("FlameGraphView span event markers", () => { }); }); +describe("FlameGraphView zoom", () => { + const span = (span_id: string, startOffsetMs: number, durationMs: number) => + createMockSpan({ span_id, startOffsetMs, durationMs, events: [] }); + + beforeEach(() => { + config.global.stubs = { + ChartRenderer: ChartRendererStub, + TraceDetailsSidebar: TraceDetailsSidebarStub, + }; + }); + + afterEach(() => { + config.global.stubs = {}; + }); + + const mountView = () => + mount(FlameGraphView, { + props: { + spans: [span("left", 0, 5), span("edge", 5, 10), span("middle", 40, 20)], + traceDuration: 100, + selectedSpanId: null, + }, + }); + + const xRange = (wrapper: any) => { + const { min, max } = wrapper.vm.chartOptions.xAxis; + return [min, max]; + }; + + // Mirrors ECharts' value-to-pixel mapping for a 480px plot starting at x = 10. + const renderAt = (wrapper: any, dataIndex: number) => { + const [min, max] = xRange(wrapper); + const coordSys = { x: 10, y: 10, width: 480, height: 100 }; + const item = wrapper.vm.chartOptions.series[0].data[dataIndex]; + const api = { + value: (i: number) => item.value[i], + coord: ([xPct]: number[]) => [coordSys.x + ((xPct - min) / (max - min)) * coordSys.width, 0], + style: (s: any) => s, + }; + return wrapper.vm.chartOptions.series[0].renderItem({ dataIndex, coordSys }, api); + }; + + it("shows the zoom controls when there is data", () => { + const wrapper = mountView(); + expect(wrapper.find('[data-test="flame-graph-zoom-controls"]').exists()).toBe(true); + }); + + it("hides the zoom controls without data", () => { + const wrapper = mount(FlameGraphView, { + props: { spans: [], traceDuration: 0, selectedSpanId: null }, + }); + expect(wrapper.find('[data-test="flame-graph-zoom-controls"]').exists()).toBe(false); + }); + + it("zoom in narrows the x range around its centre and zoom out widens it", async () => { + const wrapper = mountView(); + expect(xRange(wrapper)).toEqual([0, 100]); + + await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click"); + expect(xRange(wrapper)).toEqual([10, 90]); + + await wrapper.find('[data-test="flame-graph-zoom-out"]').trigger("click"); + const [min, max] = xRange(wrapper); + expect(min).toBeCloseTo(0); + expect(max).toBeCloseTo(100); + }); + + it("stops zooming in at a 1% window", async () => { + const wrapper = mountView(); + for (let i = 0; i < 40; i++) { + await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click"); + } + const [min, max] = xRange(wrapper); + expect(max - min).toBeCloseTo(1); + }); + + it("fit resets the x range to the whole trace", async () => { + const wrapper = mountView(); + await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click"); + await wrapper.find('[data-test="flame-graph-fit-screen"]').trigger("click"); + expect(xRange(wrapper)).toEqual([0, 100]); + }); + + it("labels the ruler with the visible window", async () => { + const wrapper = mountView(); + await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click"); + const labels = wrapper.vm.timelineTicks.map((tick: any) => tick.label); + expect(labels[0]).toBe("10.00ms"); + expect(labels[4]).toBe("90.00ms"); + }); + + it("maps the cursor onto the visible window", async () => { + const wrapper = mountView(); + await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click"); + // offsetX 10 is the plot's left edge, so it reads the window start. + wrapper.vm.handleChartMouseMove({ + clientX: 10, + currentTarget: { getBoundingClientRect: () => ({ left: 0, width: 500 }) }, + }); + expect(wrapper.vm.cursorTimeLabel).toBe("10.00ms"); + }); + + it("hides the time cursor while the pointer is over the zoom controls", async () => { + const wrapper = mountView(); + const chartWrapper = wrapper.find('[data-test="flame-graph-view-chart-wrapper"]'); + const controls = wrapper.find('[data-test="flame-graph-zoom-controls"]'); + const rect = { left: 0, top: 0, width: 500, height: 300, right: 500, bottom: 300 }; + chartWrapper.element.getBoundingClientRect = () => rect as DOMRect; + + await chartWrapper.trigger("mousemove", { clientX: 100 }); + expect(wrapper.vm.cursorVisible).toBe(true); + + await controls.trigger("mouseenter"); + expect(wrapper.vm.cursorVisible).toBe(false); + await controls.trigger("mousemove", { clientX: 480 }); + expect(wrapper.vm.cursorVisible).toBe(false); + + await chartWrapper.trigger("mousemove", { clientX: 100 }); + expect(wrapper.vm.cursorVisible).toBe(true); + }); + + it("skips blocks outside the window and cuts blocks at its edge", async () => { + const wrapper = mountView(); + await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click"); + + expect(renderAt(wrapper, 0)).toBeNull(); + + const edge = renderAt(wrapper, 1); + expect(edge.shape.x).toBe(10); + expect(edge.shape.width).toBeCloseTo((5 / 80) * 480); + + const middle = renderAt(wrapper, 2); + expect(middle.shape.x).toBeCloseTo(10 + (30 / 80) * 480); + expect(middle.shape.width).toBeCloseTo((20 / 80) * 480); + }); + + const zoomInTimes = async (wrapper: any, times: number) => { + for (let i = 0; i < times; i++) { + await wrapper.find('[data-test="flame-graph-zoom-in"]').trigger("click"); + } + }; + + // A 500px scroll area leaves a 480px plot between the 10px grid insets. + const scrollArea = (wrapper: any) => { + const area = wrapper.find('[data-test="flame-graph-view-chart-scroll"]'); + Object.defineProperty(area.element, "clientWidth", { configurable: true, value: 500 }); + return area; + }; + + it("pans the zoomed window when the chart is dragged, and not on a click-sized move", async () => { + const wrapper = mountView(); + await zoomInTimes(wrapper, 1); + const area = scrollArea(wrapper); + + await area.trigger("mousedown", { button: 0, clientX: 200 }); + window.dispatchEvent(new MouseEvent("mousemove", { clientX: 197 })); + expect(xRange(wrapper)).toEqual([10, 90]); + + // Dragging left by 48px moves the window right by 48 / 480 of its 80% width. + window.dispatchEvent(new MouseEvent("mousemove", { clientX: 152 })); + expect(xRange(wrapper)[0]).toBeCloseTo(18); + expect(xRange(wrapper)[1]).toBeCloseTo(98); + + window.dispatchEvent(new MouseEvent("mousemove", { clientX: 0 })); + expect(xRange(wrapper)).toEqual([20, 100]); + + window.dispatchEvent(new MouseEvent("mouseup")); + window.dispatchEvent(new MouseEvent("mousemove", { clientX: 400 })); + expect(xRange(wrapper)).toEqual([20, 100]); + }); + + it("pans on horizontal and shift wheel but leaves a vertical wheel to scroll", async () => { + const wrapper = mountView(); + await zoomInTimes(wrapper, 1); + const area = scrollArea(wrapper); + + const vertical = new WheelEvent("wheel", { deltaY: 48, cancelable: true }); + area.element.dispatchEvent(vertical); + expect(vertical.defaultPrevented).toBe(false); + expect(xRange(wrapper)).toEqual([10, 90]); + + const horizontal = new WheelEvent("wheel", { deltaX: 48, cancelable: true }); + area.element.dispatchEvent(horizontal); + expect(horizontal.defaultPrevented).toBe(true); + expect(xRange(wrapper)[0]).toBeCloseTo(18); + + const shifted = new WheelEvent("wheel", { deltaY: -48, shiftKey: true, cancelable: true }); + area.element.dispatchEvent(shifted); + expect(shifted.defaultPrevented).toBe(true); + expect(xRange(wrapper)[0]).toBeCloseTo(10); + }); + + it("pans to a span selected from outside the chart when it lies outside the window", async () => { + const wrapper = mountView(); + await zoomInTimes(wrapper, 10); + const [min, max] = xRange(wrapper); + const width = max - min; + expect(min).toBeGreaterThan(5); + + await wrapper.setProps({ selectedSpanId: "left" }); + expect(xRange(wrapper)[0]).toBeCloseTo(0); + expect(xRange(wrapper)[1]).toBeCloseTo(width); + + // "middle" (40-60%) is wider than the window, so the window starts at the span. + await wrapper.setProps({ selectedSpanId: "middle" }); + expect(xRange(wrapper)[0]).toBeCloseTo(40); + + // "edge" (5-15%) no longer overlaps; a span already in view does not move the window. + await wrapper.setProps({ selectedSpanId: "edge" }); + const afterEdge = xRange(wrapper); + expect(afterEdge[0]).toBeLessThan(15); + await wrapper.setProps({ selectedSpanId: "left" }); + expect(xRange(wrapper)).toEqual(afterEdge); + }); + + it("resets to the whole trace when the spans change", async () => { + const wrapper = mountView(); + await zoomInTimes(wrapper, 2); + await wrapper.setProps({ spans: [span("other", 0, 50)] }); + expect(xRange(wrapper)).toEqual([0, 100]); + + await zoomInTimes(wrapper, 2); + await wrapper.setProps({ traceDuration: 200 }); + expect(xRange(wrapper)).toEqual([0, 100]); + }); + + it("keeps the event flag of a narrow block cut at the window's left edge", async () => { + const startNs = 1752490492843000000; + const wrapper = mount(FlameGraphView, { + props: { + spans: [ + createMockSpan({ + span_id: "cut", + start_time: startNs, + startOffsetMs: 8, + durationMs: 3, + events: [{ name: "a", _timestamp: startNs }], + }), + ], + traceDuration: 100, + selectedSpanId: null, + }, + }); + await zoomInTimes(wrapper, 1); + + const result = renderAt(wrapper, 0); + expect(result.type).toBe("group"); + const [block, flag] = result.children; + expect(block.shape.x).toBe(10); + expect(flag.shape.x).toBe(10); + }); +}); + describe("FlameGraphView severity colours", () => { // renderItem returns raw ECharts shapes, which cannot take Tailwind classes, // so this surface resolves token values at draw time via getComputedStyle. diff --git a/web/src/components/traces/FlameGraphView.vue b/web/src/components/traces/FlameGraphView.vue index bd5e008cb0..021ea39943 100644 --- a/web/src/components/traces/FlameGraphView.vue +++ b/web/src/components/traces/FlameGraphView.vue @@ -27,7 +27,7 @@
@@ -79,7 +79,13 @@
-
+
@@ -155,7 +171,7 @@