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 @@