From 7f5215744dd7bc5fc1af4579d9745b06df6776e8 Mon Sep 17 00:00:00 2001 From: Sai Nikhil Date: Mon, 16 Feb 2026 12:48:28 +0530 Subject: [PATCH] fix: clear cache when we load the data (#10528) This PR fixes the issue when user changes the stream on servicegraph it should render the actual data from api not from cache --- web/src/plugins/traces/ServiceGraph.spec.ts | 1594 +++++++++++++++++++ web/src/plugins/traces/ServiceGraph.vue | 4 + 2 files changed, 1598 insertions(+) create mode 100644 web/src/plugins/traces/ServiceGraph.spec.ts diff --git a/web/src/plugins/traces/ServiceGraph.spec.ts b/web/src/plugins/traces/ServiceGraph.spec.ts new file mode 100644 index 0000000000..75774cc30b --- /dev/null +++ b/web/src/plugins/traces/ServiceGraph.spec.ts @@ -0,0 +1,1594 @@ +// 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, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mount, VueWrapper, flushPromises } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers"; +import { Notify } from "quasar"; +import { nextTick } from "vue"; +import ServiceGraph from "./ServiceGraph.vue"; + +installQuasar({ + plugins: [Notify], +}); + +// Create a persistent mock for router push +const mockRouterPush = vi.fn(); + +// Mock dependencies +vi.mock("@/services/service_graph", () => ({ + default: { + getCurrentTopology: vi.fn(), + }, +})); + +vi.mock("@/composables/useStreams", () => ({ + default: () => ({ + getStreams: vi.fn().mockResolvedValue({ + list: [ + { name: "default" }, + { name: "stream1" }, + { name: "stream2" }, + ], + }), + }), +})); + +vi.mock("@/composables/useTraces", () => ({ + default: () => ({ + searchObj: { + data: { + datetime: { + startTime: Date.now() - 3600000, + endTime: Date.now(), + relativeTimePeriod: "15m", + type: "relative", + }, + }, + }, + }), +})); + +vi.mock("vue-router", () => ({ + useRouter: () => ({ + push: mockRouterPush, + }), +})); + +vi.mock("quasar", async () => { + const actual: any = await vi.importActual("quasar"); + return { + ...actual, + useQuasar: () => ({ + dark: { + isActive: false, + }, + }), + }; +}); + +import serviceGraphService from "@/services/service_graph"; + +// Mock store +const createMockStore = (overrides = {}) => ({ + state: { + theme: "dark", + selectedOrganization: { + identifier: "test-org", + }, + ...overrides, + }, + dispatch: vi.fn(), + commit: vi.fn(), +}); + +describe("ServiceGraph.vue - Cache Invalidation & Data Refresh", () => { + let wrapper: VueWrapper; + let mockStore: any; + + const mockApiResponse = { + data: { + nodes: [ + { + id: "service-a", + label: "Service A", + requests: 1000, + errors: 10, + error_rate: 1.0, + is_virtual: false, + }, + { + id: "service-b", + label: "Service B", + requests: 2000, + errors: 20, + error_rate: 1.0, + is_virtual: false, + }, + ], + edges: [ + { + from: "service-a", + to: "service-b", + connection_type: "standard", + total_requests: 1000, + failed_requests: 10, + error_rate: 1.0, + p50_latency_ns: 50000000, + p95_latency_ns: 100000000, + p99_latency_ns: 150000000, + }, + ], + availableStreams: ["default", "stream1", "stream2"], + }, + }; + + const createWrapper = (storeOverrides = {}) => { + mockStore = createMockStore(storeOverrides); + + return mount(ServiceGraph, { + global: { + mocks: { + $store: mockStore, + }, + provide: { + store: mockStore, + }, + stubs: { + AppTabs: true, + ChartRenderer: true, + DateTime: { + template: '
', + }, + ServiceGraphSidePanel: true, + ServiceGraphEdgePanel: true, + QCard: false, + QCardSection: false, + QSelect: false, + QInput: false, + QBtn: false, + QIcon: false, + QTooltip: false, + QSpinner: false, + QDialog: false, + }, + }, + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + mockApiResponse + ); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + describe("Initialization", () => { + it("should mount component successfully", async () => { + wrapper = createWrapper(); + await flushPromises(); + expect(wrapper.exists()).toBe(true); + }); + + it("should load service graph data on mount", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(serviceGraphService.getCurrentTopology).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + startTime: expect.any(Number), + endTime: expect.any(Number), + }) + ); + }); + + it("should set initial chartKey to 0", () => { + wrapper = createWrapper(); + expect(wrapper.vm.chartKey).toBe(0); + }); + + it("should set initial lastChartOptions to be falsy", () => { + wrapper = createWrapper(); + // lastChartOptions is initialized as undefined/null + expect(wrapper.vm.lastChartOptions).toBeFalsy(); + }); + }); + + describe("Cache Invalidation on Stream Filter Change", () => { + it("should invalidate cache when stream filter changes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Change stream filter + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + // Verify chartKey was incremented (which invalidates any cached data) + // This forces chart to regenerate with fresh data + expect(wrapper.vm.chartKey).toBeGreaterThan(initialChartKey); + }); + + it("should increment chartKey when stream filter changes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Change stream filter + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + // Verify chartKey was incremented + expect(wrapper.vm.chartKey).toBe(initialChartKey + 1); + }); + + it("should call API with new stream parameter", async () => { + wrapper = createWrapper(); + await flushPromises(); + + vi.mocked(serviceGraphService.getCurrentTopology).mockClear(); + + // Change stream filter + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + // Verify API was called with new stream + expect(serviceGraphService.getCurrentTopology).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + streamName: "stream1", + startTime: expect.any(Number), + endTime: expect.any(Number), + }) + ); + }); + + it("should update graphData with new data from API", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const newApiResponse = { + data: { + nodes: [ + { + id: "service-c", + label: "Service C", + requests: 3000, + errors: 30, + error_rate: 1.0, + is_virtual: false, + }, + ], + edges: [], + availableStreams: ["stream1"], + }, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + newApiResponse + ); + + // Change stream filter + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + // Verify graphData was updated + expect(wrapper.vm.graphData.nodes).toHaveLength(1); + expect(wrapper.vm.graphData.nodes[0].id).toBe("service-c"); + }); + + it("should persist stream filter to localStorage", async () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + wrapper = createWrapper(); + await flushPromises(); + + await wrapper.vm.onStreamFilterChange("stream1"); + + expect(setItemSpy).toHaveBeenCalledWith( + "serviceGraph_streamFilter", + "stream1" + ); + + setItemSpy.mockRestore(); + }); + + it("should handle 'all' streams filter", async () => { + wrapper = createWrapper(); + await flushPromises(); + + vi.mocked(serviceGraphService.getCurrentTopology).mockClear(); + + await wrapper.vm.onStreamFilterChange("all"); + await flushPromises(); + + // Verify API was called without streamName parameter + expect(serviceGraphService.getCurrentTopology).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + streamName: undefined, + startTime: expect.any(Number), + endTime: expect.any(Number), + }) + ); + }); + }); + + describe("Cache Invalidation on Refresh Button Click", () => { + it("should invalidate cache when refresh button is clicked", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Click refresh button + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify chartKey was incremented (which invalidates any cached data) + // This forces chart to regenerate with fresh data + expect(wrapper.vm.chartKey).toBeGreaterThan(initialChartKey); + }); + + it("should increment chartKey when refresh button is clicked", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Click refresh button + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify chartKey was incremented + expect(wrapper.vm.chartKey).toBe(initialChartKey + 1); + }); + + it("should call API to get fresh data", async () => { + wrapper = createWrapper(); + await flushPromises(); + + vi.mocked(serviceGraphService.getCurrentTopology).mockClear(); + + // Click refresh button + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify API was called + expect(serviceGraphService.getCurrentTopology).toHaveBeenCalledTimes(1); + }); + + it("should update graphData with fresh data from API", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const newApiResponse = { + data: { + nodes: [ + { + id: "service-d", + label: "Service D", + requests: 4000, + errors: 40, + error_rate: 1.0, + is_virtual: false, + }, + ], + edges: [], + availableStreams: ["default"], + }, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + newApiResponse + ); + + // Click refresh button + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify graphData was updated with new data + expect(wrapper.vm.graphData.nodes).toHaveLength(1); + expect(wrapper.vm.graphData.nodes[0].id).toBe("service-d"); + }); + + it("should show loading state during refresh", async () => { + wrapper = createWrapper(); + await flushPromises(); + + let resolvePromise: any; + vi.mocked(serviceGraphService.getCurrentTopology).mockImplementation( + () => + new Promise((resolve) => { + resolvePromise = () => resolve(mockApiResponse); + }) + ); + + // Start refresh + const loadPromise = wrapper.vm.loadServiceGraph(); + await nextTick(); + + // Verify loading state is true + expect(wrapper.vm.loading).toBe(true); + + // Resolve the API call + resolvePromise(); + await loadPromise; + await flushPromises(); + + // Verify loading state is false + expect(wrapper.vm.loading).toBe(false); + }); + + it("should handle API errors gracefully", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(serviceGraphService.getCurrentTopology).mockRejectedValue( + new Error("API Error") + ); + + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify error state is set + expect(wrapper.vm.error).toBeTruthy(); + expect(wrapper.vm.loading).toBe(false); + + consoleError.mockRestore(); + }); + }); + + describe("Cache Invalidation on Time Range Change", () => { + it("should invalidate cache when time range changes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Change time range + await wrapper.vm.updateTimeRange({ + startTime: Date.now() - 7200000, + endTime: Date.now(), + relativeTimePeriod: "30m", + }); + await flushPromises(); + + // Verify chartKey was incremented (which invalidates any cached data) + // This forces chart to regenerate with fresh data + expect(wrapper.vm.chartKey).toBeGreaterThan(initialChartKey); + }); + + it("should increment chartKey when time range changes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Change time range + await wrapper.vm.updateTimeRange({ + startTime: Date.now() - 7200000, + endTime: Date.now(), + relativeTimePeriod: "30m", + }); + await flushPromises(); + + // Verify chartKey was incremented + expect(wrapper.vm.chartKey).toBe(initialChartKey + 1); + }); + + it("should call API with new time range parameters", async () => { + wrapper = createWrapper(); + await flushPromises(); + + vi.mocked(serviceGraphService.getCurrentTopology).mockClear(); + + const newStartTime = Date.now() - 7200000; + const newEndTime = Date.now(); + + // Change time range + await wrapper.vm.updateTimeRange({ + startTime: newStartTime, + endTime: newEndTime, + relativeTimePeriod: "30m", + }); + await flushPromises(); + + // Verify API was called with new time range + expect(serviceGraphService.getCurrentTopology).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + startTime: newStartTime, + endTime: newEndTime, + }) + ); + }); + + it("should update searchObj datetime with new time range", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const newStartTime = Date.now() - 7200000; + const newEndTime = Date.now(); + + await wrapper.vm.updateTimeRange({ + startTime: newStartTime, + endTime: newEndTime, + relativeTimePeriod: "30m", + }); + + // Verify searchObj was updated + expect(wrapper.vm.searchObj.data.datetime.startTime).toBe(newStartTime); + expect(wrapper.vm.searchObj.data.datetime.endTime).toBe(newEndTime); + expect(wrapper.vm.searchObj.data.datetime.relativeTimePeriod).toBe("30m"); + }); + + it("should update graphData with data from new time range", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const newApiResponse = { + data: { + nodes: [ + { + id: "service-e", + label: "Service E", + requests: 5000, + errors: 50, + error_rate: 1.0, + is_virtual: false, + }, + ], + edges: [], + availableStreams: ["default"], + }, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + newApiResponse + ); + + await wrapper.vm.updateTimeRange({ + startTime: Date.now() - 7200000, + endTime: Date.now(), + relativeTimePeriod: "30m", + }); + await flushPromises(); + + // Verify graphData was updated + expect(wrapper.vm.graphData.nodes).toHaveLength(1); + expect(wrapper.vm.graphData.nodes[0].id).toBe("service-e"); + }); + }); + + describe("Chart Re-rendering", () => { + it("should trigger chart re-render when chartKey changes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Change stream filter to trigger chartKey increment + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + // Verify chartKey changed + expect(wrapper.vm.chartKey).not.toBe(initialChartKey); + }); + + it("should regenerate chartData computed property when cache is cleared", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Get initial chartData + const initialChartData = wrapper.vm.chartData; + + // Set cached options + wrapper.vm.lastChartOptions = { + key: wrapper.vm.chartKey, + data: { options: {} }, + }; + + // Clear cache and increment chartKey + wrapper.vm.lastChartOptions = null; + wrapper.vm.chartKey++; + await nextTick(); + + // Get new chartData + const newChartData = wrapper.vm.chartData; + + // Verify chartData was regenerated (not using cache) + expect(newChartData).not.toBe(initialChartData); + }); + + it("should use cached chartData when chartKey hasn't changed", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Trigger chart data generation + const firstChartData = wrapper.vm.chartData; + await nextTick(); + + // Set cached options + if (wrapper.vm.lastChartOptions) { + const cachedOptions = wrapper.vm.lastChartOptions; + + // Get chartData again without changing chartKey + const secondChartData = wrapper.vm.chartData; + + // Verify it returned cached data (same reference) + expect(secondChartData.options).toBe(cachedOptions.data.options); + } + }); + }); + + describe("Multiple Sequential Changes", () => { + it("should handle multiple stream changes in sequence", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Make multiple stream changes + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + await wrapper.vm.onStreamFilterChange("stream2"); + await flushPromises(); + + await wrapper.vm.onStreamFilterChange("all"); + await flushPromises(); + + // Verify chartKey was incremented for each change + expect(wrapper.vm.chartKey).toBe(initialChartKey + 3); + }); + + it("should handle stream change followed by refresh", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + // Change stream + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + // Then refresh + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify chartKey was incremented twice (once for stream change, once for refresh) + expect(wrapper.vm.chartKey).toBe(initialChartKey + 2); + }); + + it("should handle time range change followed by stream change", async () => { + wrapper = createWrapper(); + await flushPromises(); + + vi.mocked(serviceGraphService.getCurrentTopology).mockClear(); + + // Change time range + await wrapper.vm.updateTimeRange({ + startTime: Date.now() - 7200000, + endTime: Date.now(), + relativeTimePeriod: "30m", + }); + await flushPromises(); + + // Then change stream + await wrapper.vm.onStreamFilterChange("stream1"); + await flushPromises(); + + // Verify API was called twice with correct parameters + expect(serviceGraphService.getCurrentTopology).toHaveBeenCalledTimes(2); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty API response", async () => { + wrapper = createWrapper(); + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue({ + data: { + nodes: [], + edges: [], + availableStreams: [], + }, + }); + + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + expect(wrapper.vm.graphData.nodes).toHaveLength(0); + expect(wrapper.vm.graphData.edges).toHaveLength(0); + }); + + it("should handle missing organization identifier", async () => { + wrapper = createWrapper({ + selectedOrganization: { + identifier: "", + }, + }); + + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify error state is set + expect(wrapper.vm.error).toBeTruthy(); + }); + + it("should filter out edges with invalid node references", async () => { + wrapper = createWrapper(); + + const invalidApiResponse = { + data: { + nodes: [ + { + id: "service-a", + label: "Service A", + requests: 1000, + errors: 10, + error_rate: 1.0, + }, + ], + edges: [ + { + from: "service-a", + to: "non-existent-service", // Invalid reference + total_requests: 100, + }, + ], + availableStreams: ["default"], + }, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + invalidApiResponse + ); + + await wrapper.vm.loadServiceGraph(); + await flushPromises(); + + // Verify invalid edge was filtered out + expect(wrapper.vm.graphData.edges).toHaveLength(0); + }); + + it("should handle rapid consecutive refreshes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Trigger multiple refreshes rapidly + const promises = [ + wrapper.vm.loadServiceGraph(), + wrapper.vm.loadServiceGraph(), + wrapper.vm.loadServiceGraph(), + ]; + + await Promise.all(promises); + await flushPromises(); + + // Verify component is in a valid state + expect(wrapper.vm.loading).toBe(false); + expect(wrapper.vm.graphData).toBeTruthy(); + }); + }); + + describe("Data Persistence", () => { + it("should restore stream filter from localStorage on mount", () => { + const getItemSpy = vi + .spyOn(Storage.prototype, "getItem") + .mockReturnValue("stream1"); + + wrapper = createWrapper(); + + expect(wrapper.vm.streamFilter).toBe("stream1"); + + getItemSpy.mockRestore(); + }); + + it("should restore visualization type from localStorage on mount", () => { + const getItemSpy = vi + .spyOn(Storage.prototype, "getItem") + .mockReturnValue("tree"); + + wrapper = createWrapper(); + + expect(wrapper.vm.visualizationType).toBe("tree"); + + getItemSpy.mockRestore(); + }); + + it("should restore layout type from localStorage on mount", () => { + const getItemSpy = vi + .spyOn(Storage.prototype, "getItem") + .mockReturnValue("circular"); + + wrapper = createWrapper(); + + expect(wrapper.vm.layoutType).toBe("circular"); + + getItemSpy.mockRestore(); + }); + }); + + describe("Search Filtering", () => { + it("should filter nodes by search term", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Set search filter (searching for "Service A" which exists in mock data) + wrapper.vm.searchFilter = "Service A"; + wrapper.vm.applyFilters(); + + // Verify filtered nodes + expect(wrapper.vm.filteredGraphData.nodes.length).toBeGreaterThan(0); + expect( + wrapper.vm.filteredGraphData.nodes.some((n: any) => + n.label.toLowerCase().includes("service a") + ) + ).toBe(true); + }); + + it("should filter edges connected to matching nodes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.searchFilter = "service-a"; + wrapper.vm.applyFilters(); + + // Verify all edges are connected to filtered nodes + const nodeIds = new Set(wrapper.vm.filteredGraphData.nodes.map((n: any) => n.id)); + wrapper.vm.filteredGraphData.edges.forEach((edge: any) => { + expect( + nodeIds.has(edge.from) || nodeIds.has(edge.to) + ).toBe(true); + }); + }); + + it("should handle case-insensitive search", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Search with different case - should still find "Service A" + wrapper.vm.searchFilter = "SERVICE A"; + wrapper.vm.applyFilters(); + + expect(wrapper.vm.filteredGraphData.nodes.length).toBeGreaterThan(0); + }); + + it("should trim search filter whitespace", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Search with whitespace - should trim and find "Service A" + wrapper.vm.searchFilter = " Service A "; + wrapper.vm.applyFilters(); + + expect(wrapper.vm.filteredGraphData.nodes.length).toBeGreaterThan(0); + }); + + it("should show all nodes when search is empty", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const allNodes = wrapper.vm.graphData.nodes.length; + + wrapper.vm.searchFilter = ""; + wrapper.vm.applyFilters(); + + expect(wrapper.vm.filteredGraphData.nodes.length).toBe(allNodes); + }); + + it("should handle search with no matches", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.searchFilter = "nonexistent-service"; + wrapper.vm.applyFilters(); + + expect(wrapper.vm.filteredGraphData.nodes.length).toBe(0); + expect(wrapper.vm.filteredGraphData.edges.length).toBe(0); + }); + }); + + describe("Connection Type Filtering", () => { + it("should filter edges by connection type", async () => { + const multiTypeResponse = { + data: { + nodes: [ + { id: "a", label: "A", requests: 100, errors: 0, error_rate: 0 }, + { id: "b", label: "B", requests: 100, errors: 0, error_rate: 0 }, + { id: "c", label: "C", requests: 100, errors: 0, error_rate: 0 }, + ], + edges: [ + { from: "a", to: "b", connection_type: "database", total_requests: 100 }, + { from: "b", to: "c", connection_type: "standard", total_requests: 100 }, + ], + availableStreams: ["default"], + }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + multiTypeResponse + ); + + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.connectionTypeFilter = "database"; + wrapper.vm.applyFilters(); + + // Should only show database edges + expect(wrapper.vm.filteredGraphData.edges.length).toBe(1); + expect(wrapper.vm.filteredGraphData.edges[0].connection_type).toBe("database"); + }); + + it("should filter nodes connected by filtered edges", async () => { + const multiTypeResponse = { + data: { + nodes: [ + { id: "a", label: "A", requests: 100, errors: 0, error_rate: 0 }, + { id: "b", label: "B", requests: 100, errors: 0, error_rate: 0 }, + { id: "c", label: "C", requests: 100, errors: 0, error_rate: 0 }, + ], + edges: [ + { from: "a", to: "b", connection_type: "database", total_requests: 100 }, + { from: "b", to: "c", connection_type: "standard", total_requests: 100 }, + ], + availableStreams: ["default"], + }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + multiTypeResponse + ); + + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.connectionTypeFilter = "database"; + wrapper.vm.applyFilters(); + + // Should only show nodes connected by database edges (a and b) + const nodeIds = wrapper.vm.filteredGraphData.nodes.map((n: any) => n.id); + expect(nodeIds).toContain("a"); + expect(nodeIds).toContain("b"); + expect(nodeIds).not.toContain("c"); + }); + + it("should show all edges when connection type is 'all'", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const allEdges = wrapper.vm.graphData.edges.length; + + wrapper.vm.connectionTypeFilter = "all"; + wrapper.vm.applyFilters(); + + expect(wrapper.vm.filteredGraphData.edges.length).toBe(allEdges); + }); + + it("should combine search and connection type filters", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.searchFilter = "service-a"; + wrapper.vm.connectionTypeFilter = "standard"; + wrapper.vm.applyFilters(); + + // Both filters should be applied + expect(wrapper.vm.filteredGraphData.nodes.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe("Layout and Visualization Changes", () => { + it("should change layout type", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.setLayout("circular"); + + expect(wrapper.vm.layoutType).toBe("circular"); + }); + + it("should persist layout type to localStorage", async () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.setLayout("circular"); + + expect(setItemSpy).toHaveBeenCalledWith("serviceGraph_layoutType", "circular"); + setItemSpy.mockRestore(); + }); + + it("should increment chartKey when layout changes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + wrapper.vm.setLayout("circular"); + + expect(wrapper.vm.chartKey).toBe(initialChartKey + 1); + }); + + it("should change visualization type", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.setVisualizationType("tree"); + + expect(wrapper.vm.visualizationType).toBe("tree"); + }); + + it("should persist visualization type to localStorage", async () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.setVisualizationType("tree"); + + expect(setItemSpy).toHaveBeenCalledWith("serviceGraph_visualizationType", "tree"); + setItemSpy.mockRestore(); + }); + + it("should set default layout when switching to tree view", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.setVisualizationType("tree"); + + expect(wrapper.vm.layoutType).toBe("horizontal"); + }); + + it("should set default layout when switching to graph view", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.visualizationType = "tree"; + wrapper.vm.setVisualizationType("graph"); + + expect(wrapper.vm.layoutType).toBe("force"); + }); + + it("should increment chartKey when visualization type changes", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const initialChartKey = wrapper.vm.chartKey; + + wrapper.vm.setVisualizationType("tree"); + + expect(wrapper.vm.chartKey).toBe(initialChartKey + 1); + }); + }); + + describe("Node and Edge Click Handling", () => { + it("should open side panel when node is clicked", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const nodeData = wrapper.vm.graphData.nodes[0]; + + wrapper.vm.handleNodeClick({ + dataType: "node", + data: nodeData, + }); + + expect(wrapper.vm.showSidePanel).toBe(true); + expect(wrapper.vm.selectedNode).toEqual(nodeData); + }); + + it("should close side panel when same node is clicked twice", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const nodeData = wrapper.vm.graphData.nodes[0]; + + // First click - open panel + wrapper.vm.handleNodeClick({ + dataType: "node", + data: nodeData, + }); + + expect(wrapper.vm.showSidePanel).toBe(true); + + // Second click - close panel + wrapper.vm.handleNodeClick({ + dataType: "node", + data: nodeData, + }); + + expect(wrapper.vm.showSidePanel).toBe(false); + }); + + it("should open edge panel when edge is clicked", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const edgeData = wrapper.vm.graphData.edges[0]; + + wrapper.vm.handleNodeClick({ + dataType: "edge", + data: { + source: edgeData.from, + target: edgeData.to, + }, + }); + + expect(wrapper.vm.showEdgePanel).toBe(true); + expect(wrapper.vm.selectedEdge).toBeTruthy(); + }); + + it("should close node panel when edge is clicked", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // First open node panel + wrapper.vm.handleNodeClick({ + dataType: "node", + data: wrapper.vm.graphData.nodes[0], + }); + + expect(wrapper.vm.showSidePanel).toBe(true); + + // Then click edge + const edgeData = wrapper.vm.graphData.edges[0]; + wrapper.vm.handleNodeClick({ + dataType: "edge", + data: { + source: edgeData.from, + target: edgeData.to, + }, + }); + + // Node panel should be closed + expect(wrapper.vm.showSidePanel).toBe(false); + expect(wrapper.vm.selectedNode).toBeNull(); + }); + + it("should handle tree node click", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const nodeData = wrapper.vm.graphData.nodes[0]; + + wrapper.vm.handleNodeClick({ + componentType: "series", + data: { name: nodeData.label }, + }); + + expect(wrapper.vm.showSidePanel).toBe(true); + expect(wrapper.vm.selectedNode?.id).toBe(nodeData.id); + }); + + it("should ignore invalid click events", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + + wrapper.vm.handleNodeClick({ + dataType: "unknown", + data: null, + }); + + expect(consoleLog).toHaveBeenCalled(); + expect(wrapper.vm.showSidePanel).toBe(false); + + consoleLog.mockRestore(); + }); + + it("should warn when edge data is not found", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + wrapper.vm.handleNodeClick({ + dataType: "edge", + data: { + source: "nonexistent", + target: "nonexistent", + }, + }); + + expect(consoleWarn).toHaveBeenCalled(); + expect(wrapper.vm.showEdgePanel).toBe(false); + + consoleWarn.mockRestore(); + }); + }); + + describe("Side Panel Operations", () => { + it("should close side panel", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.showSidePanel = true; + wrapper.vm.selectedNode = wrapper.vm.graphData.nodes[0]; + + wrapper.vm.handleCloseSidePanel(); + + expect(wrapper.vm.showSidePanel).toBe(false); + }); + + it("should clear selectedNode after animation delay", async () => { + vi.useFakeTimers(); + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.selectedNode = wrapper.vm.graphData.nodes[0]; + wrapper.vm.handleCloseSidePanel(); + + expect(wrapper.vm.selectedNode).toBeTruthy(); + + vi.advanceTimersByTime(300); + + expect(wrapper.vm.selectedNode).toBeNull(); + + vi.useRealTimers(); + }); + + it("should close edge panel", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.showEdgePanel = true; + wrapper.vm.selectedEdge = wrapper.vm.graphData.edges[0]; + + wrapper.vm.handleCloseEdgePanel(); + + expect(wrapper.vm.showEdgePanel).toBe(false); + }); + + it("should clear selectedEdge after animation delay", async () => { + vi.useFakeTimers(); + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.selectedEdge = wrapper.vm.graphData.edges[0]; + wrapper.vm.handleCloseEdgePanel(); + + expect(wrapper.vm.selectedEdge).toBeTruthy(); + + vi.advanceTimersByTime(300); + + expect(wrapper.vm.selectedEdge).toBeNull(); + + vi.useRealTimers(); + }); + }); + + describe("View Logs and Traces Navigation", () => { + beforeEach(() => { + mockRouterPush.mockClear(); + }); + + it("should not navigate when no node is selected", () => { + wrapper = createWrapper(); + wrapper.vm.selectedNode = null; + + wrapper.vm.handleViewLogs(); + + // Router push should not be called + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it("should emit view-traces event with service data", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.selectedNode = wrapper.vm.graphData.nodes[0]; + wrapper.vm.streamFilter = "test-stream"; + + wrapper.vm.handleViewTraces(); + + expect(wrapper.emitted("view-traces")).toBeTruthy(); + expect(wrapper.emitted("view-traces")?.[0]).toEqual([ + expect.objectContaining({ + stream: "test-stream", + serviceName: expect.any(String), + timeRange: expect.objectContaining({ + startTime: expect.any(Number), + endTime: expect.any(Number), + }), + }), + ]); + }); + + it("should not emit view-traces when no node is selected", () => { + wrapper = createWrapper(); + wrapper.vm.selectedNode = null; + + wrapper.vm.handleViewTraces(); + + expect(wrapper.emitted("view-traces")).toBeFalsy(); + }); + }); + + describe("Utility Functions", () => { + it("should format large numbers with M suffix", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.formatNumber(1000000)).toBe("1.0M"); + expect(wrapper.vm.formatNumber(2500000)).toBe("2.5M"); + expect(wrapper.vm.formatNumber(10000000)).toBe("10.0M"); + }); + + it("should format medium numbers with K suffix", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.formatNumber(1000)).toBe("1.0K"); + expect(wrapper.vm.formatNumber(1500)).toBe("1.5K"); + expect(wrapper.vm.formatNumber(999999)).toBe("1000.0K"); + }); + + it("should format small numbers as-is", () => { + wrapper = createWrapper(); + + expect(wrapper.vm.formatNumber(0)).toBe("0"); + expect(wrapper.vm.formatNumber(100)).toBe("100"); + expect(wrapper.vm.formatNumber(999)).toBe("999"); + }); + }); + + describe("Stats Calculation", () => { + it("should calculate total services count", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.stats?.services).toBe(mockApiResponse.data.nodes.length); + }); + + it("should calculate total connections count", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.stats?.connections).toBe(mockApiResponse.data.edges.length); + }); + + it("should calculate total requests", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const expectedRequests = mockApiResponse.data.edges.reduce( + (sum, e) => sum + e.total_requests, + 0 + ); + + expect(wrapper.vm.stats?.totalRequests).toBe(expectedRequests); + }); + + it("should calculate total errors", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const expectedErrors = mockApiResponse.data.edges.reduce( + (sum, e) => sum + e.failed_requests, + 0 + ); + + expect(wrapper.vm.stats?.totalErrors).toBe(expectedErrors); + }); + + it("should calculate error rate", async () => { + wrapper = createWrapper(); + await flushPromises(); + + const totalRequests = mockApiResponse.data.edges.reduce( + (sum, e) => sum + e.total_requests, + 0 + ); + const totalErrors = mockApiResponse.data.edges.reduce( + (sum, e) => sum + e.failed_requests, + 0 + ); + const expectedRate = totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0; + + expect(wrapper.vm.stats?.errorRate).toBe(expectedRate); + }); + + it("should handle zero requests for error rate", async () => { + const noRequestsResponse = { + data: { + nodes: [{ id: "a", label: "A", requests: 0, errors: 0, error_rate: 0 }], + edges: [], + availableStreams: ["default"], + }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + noRequestsResponse + ); + + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.stats?.errorRate).toBe(0); + }); + }); + + describe("Settings", () => { + it("should reset settings", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.showSettings = true; + wrapper.vm.resetSettings(); + + expect(wrapper.vm.showSettings).toBe(false); + }); + }); + + describe("Error Handling", () => { + it("should set error state on 404", async () => { + const error404 = new Error("Not Found"); + (error404 as any).response = { status: 404 }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockRejectedValue(error404); + + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.error).toContain("API endpoint not found"); + }); + + it("should set error state on 403", async () => { + const error403 = new Error("Forbidden"); + (error403 as any).response = { status: 403 }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockRejectedValue(error403); + + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.error).toContain("Access denied"); + }); + + it("should set error state on 500", async () => { + const error500 = new Error("Internal Server Error"); + (error500 as any).response = { status: 500 }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockRejectedValue(error500); + + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.error).toContain("Server error"); + }); + + it("should set error state on timeout", async () => { + const timeoutError = new Error("Request timeout"); + + vi.mocked(serviceGraphService.getCurrentTopology).mockRejectedValue( + timeoutError + ); + + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.error).toContain("timed out"); + }); + + it("should set error state on network error", async () => { + const networkError = new Error("Network Error"); + + vi.mocked(serviceGraphService.getCurrentTopology).mockRejectedValue( + networkError + ); + + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.error).toContain("Network error"); + }); + + it("should set generic error state for unknown errors", async () => { + const unknownError = new Error("Unknown error"); + (unknownError as any).response = { status: 418, data: { message: "I'm a teapot" } }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockRejectedValue( + unknownError + ); + + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.error).toBeTruthy(); + }); + }); + + describe("Graph Data Validation", () => { + it("should ensure all nodes have required fields", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.graphData.nodes.forEach((node: any) => { + expect(node).toHaveProperty("id"); + expect(node).toHaveProperty("label"); + expect(node).toHaveProperty("requests"); + expect(node).toHaveProperty("errors"); + expect(node).toHaveProperty("error_rate"); + }); + }); + + it("should ensure all edges have required fields", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.graphData.edges.forEach((edge: any) => { + expect(edge).toHaveProperty("from"); + expect(edge).toHaveProperty("to"); + expect(edge).toHaveProperty("total_requests"); + expect(edge).toHaveProperty("failed_requests"); + }); + }); + + it("should filter out edges with invalid endpoints", async () => { + const invalidDataResponse = { + data: { + nodes: [{ id: "a", label: "A", requests: 100, errors: 0, error_rate: 0 }], + edges: [ + { from: "a", to: "b", total_requests: 100 }, // 'b' doesn't exist + { from: "c", to: "a", total_requests: 100 }, // 'c' doesn't exist + ], + availableStreams: ["default"], + }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }; + + vi.mocked(serviceGraphService.getCurrentTopology).mockResolvedValue( + invalidDataResponse + ); + + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + wrapper = createWrapper(); + await flushPromises(); + + // Both invalid edges should be filtered out + expect(wrapper.vm.graphData.edges.length).toBe(0); + expect(consoleWarn).toHaveBeenCalled(); + + consoleWarn.mockRestore(); + }); + }); +}); diff --git a/web/src/plugins/traces/ServiceGraph.vue b/web/src/plugins/traces/ServiceGraph.vue index f623d11146..e68c72bc0b 100644 --- a/web/src/plugins/traces/ServiceGraph.vue +++ b/web/src/plugins/traces/ServiceGraph.vue @@ -499,6 +499,10 @@ export default defineComponent({ loading.value = true; error.value = null; + // Clear cache to force chart regeneration with fresh data + lastChartOptions.value = null; + chartKey.value++; + try { const orgId = store.state.selectedOrganization.identifier;