diff --git a/web/src/components/alerts/IncidentDetailDrawer.spec.ts b/web/src/components/alerts/IncidentDetailDrawer.spec.ts index f3e68fe97d..a32629e238 100644 --- a/web/src/components/alerts/IncidentDetailDrawer.spec.ts +++ b/web/src/components/alerts/IncidentDetailDrawer.spec.ts @@ -44,13 +44,14 @@ vi.mock("@/services/service_streams", () => ({ default: { getSemanticGroups: vi.fn(), }, + buildChipDimensionsFromFilters: vi.fn(() => ({})), })); import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { mount, flushPromises, VueWrapper } from "@vue/test-utils"; import IncidentDetailDrawer from "./IncidentDetailDrawer.vue"; import incidentsService, { Incident, IncidentWithAlerts, IncidentAlert } from "@/services/incidents"; -import serviceStreamsApi from "@/services/service_streams"; +import serviceStreamsApi, { buildChipDimensionsFromFilters } from "@/services/service_streams"; import { nextTick } from "vue"; import i18n from "@/locales"; import store from "@/test/unit/helpers/store"; @@ -123,7 +124,7 @@ describe("IncidentDetailDrawer.vue", () => { TelemetryCorrelationDashboard: { name: 'TelemetryCorrelationDashboard', template: '
', - props: ['mode', 'externalActiveTab', 'serviceName', 'matchedDimensions', 'additionalDimensions', 'logStreams', 'metricStreams', 'traceStreams', 'timeRange', 'hideDimensionFilters'] + props: ['mode', 'externalActiveTab', 'serviceName', 'matchedDimensions', 'additionalDimensions', 'matchedSetId', 'chipDimensions', 'logStreams', 'metricStreams', 'traceStreams', 'timeRange', 'hideDimensionFilters'] }, CorrelatedLogsTable: { name: 'CorrelatedLogsTable', @@ -1235,6 +1236,90 @@ describe("IncidentDetailDrawer.vue", () => { // Testing child component prop bindings through complex conditional rendering is problematic // These should be tested through computed properties and direct prop assertions + describe("subject tabs (View by) — matchedSetId / chipDimensions", () => { + // Regression: the embedded TelemetryCorrelationDashboard never received + // matchedSetId/chipDimensions, so its "View by" subject toggle never rendered. + const correlatedStreamsWithSet = () => ({ + serviceName: "checkout", + matchedDimensions: { "k8s-pod-name": "pod-abc" }, + additionalDimensions: {}, + logStreams: [], + metricStreams: [ + { stream_name: "k8s_metrics", stream_type: "Metrics", filters: { k8s_pod_name: "pod-abc" } }, + ], + traceStreams: [], + correlationData: { + service_name: "checkout", + matched_dimensions: { "k8s-pod-name": "pod-abc" }, + additional_dimensions: {}, + related_streams: { + logs: [], + metrics: [ + { stream_name: "k8s_metrics", stream_type: "Metrics", filters: { k8s_pod_name: "pod-abc" } }, + ], + traces: [], + }, + matched_set_id: "kubernetes", + }, + }); + + beforeEach(async () => { + (buildChipDimensionsFromFilters as any).mockReturnValue({ k8s_pod_name: "pod-abc" }); + wrapper = await createWrapper({}, {}, "1"); + await nextTick(); + await flushPromises(); + }); + + it("should expose matched_set_id as correlationMatchedSetId after correlation loads", async () => { + (incidentsService.getCorrelatedStreams as any).mockResolvedValue(correlatedStreamsWithSet()); + + await wrapper.vm.refreshCorrelation(); + await flushPromises(); + + expect(wrapper.vm.correlationMatchedSetId).toBe("kubernetes"); + }); + + it("should build chipDimensions from the correlation response and semantic groups", async () => { + (incidentsService.getCorrelatedStreams as any).mockResolvedValue(correlatedStreamsWithSet()); + + await wrapper.vm.refreshCorrelation(); + await flushPromises(); + + // Access the (lazy) computed first so it evaluates and invokes the helper. + expect(wrapper.vm.correlationChipDimensions).toEqual({ k8s_pod_name: "pod-abc" }); + + const resp = correlatedStreamsWithSet().correlationData; + expect(buildChipDimensionsFromFilters).toHaveBeenCalledWith( + expect.objectContaining({ matched_set_id: resp.matched_set_id }), + expect.any(Array), + ); + }); + + it("should yield undefined matchedSetId and empty chipDimensions when no correlation data", () => { + expect(wrapper.vm.correlationData).toBeNull(); + expect(wrapper.vm.correlationMatchedSetId).toBeUndefined(); + expect(wrapper.vm.correlationChipDimensions).toEqual({}); + }); + + it("should pass matchedSetId/chipDimensions to the metrics TelemetryCorrelationDashboard", async () => { + (incidentsService.getCorrelatedStreams as any).mockResolvedValue(correlatedStreamsWithSet()); + + await wrapper.vm.refreshCorrelation(); + // activeTab is the tabs v-model; set it directly since reaching the + // metrics panel via DOM tab clicks depends on brittle conditional markup. + wrapper.vm.activeTab = "metrics"; + await nextTick(); + await flushPromises(); + + const dashboard = wrapper + .findAllComponents({ name: "TelemetryCorrelationDashboard" }) + .find((c) => c.props("externalActiveTab") === "metrics"); + expect(dashboard?.exists()).toBe(true); + expect(dashboard?.props("matchedSetId")).toBe("kubernetes"); + expect(dashboard?.props("chipDimensions")).toEqual({ k8s_pod_name: "pod-abc" }); + }); + }); + // Note: Loading State Centering tests removed // Testing loading state UI presentation is better handled through visual regression tests // Component logic tests should focus on loading state management, not CSS/layout diff --git a/web/src/components/alerts/IncidentDetailDrawer.vue b/web/src/components/alerts/IncidentDetailDrawer.vue index 187b5791ba..d3a435551e 100644 --- a/web/src/components/alerts/IncidentDetailDrawer.vue +++ b/web/src/components/alerts/IncidentDetailDrawer.vue @@ -1082,6 +1082,8 @@ along with this program. If not, see . :serviceName="correlationData.serviceName" :matchedDimensions="correlationData.matchedDimensions" :additionalDimensions="correlationData.additionalDimensions" + :matched-set-id="correlationMatchedSetId" + :chip-dimensions="correlationChipDimensions" :logStreams="correlationData.logStreams" :metricStreams="correlationData.metricStreams" :traceStreams="correlationData.traceStreams" @@ -1174,7 +1176,9 @@ import incidentsService, { IncidentCorrelatedStreams, } from "@/services/incidents"; import streamService from "@/services/stream"; -import serviceStreamsApi from "@/services/service_streams"; +import serviceStreamsApi, { + buildChipDimensionsFromFilters, +} from "@/services/service_streams"; import { getImageURL } from "@/utils/zincutils"; import { marked } from 'marked'; import DOMPurify from 'dompurify'; @@ -1324,6 +1328,19 @@ export default defineComponent({ return semanticGroupDisplayMap.value.get(id) || id; }; + // Identity set + chip dimensions powering the "View by" subject tabs in the + // embedded TelemetryCorrelationDashboard (metrics tab). Without matchedSetId + // the dashboard cannot build subject chips, so the toggle never renders. + // Both derive from the correlate API response; chip dimensions stay reactive + // to semanticGroups (used only for dedup) so they refresh if groups load late. + const correlationMatchedSetId = computed( + () => correlationData.value?.correlationData?.matched_set_id ?? undefined, + ); + const correlationChipDimensions = computed>(() => { + const resp = correlationData.value?.correlationData; + return resp ? buildChipDimensionsFromFilters(resp, semanticGroups.value) : {}; + }); + // True when a background AI analysis run has started but not yet completed. // Updated whenever events are fetched (load, tab switch, reopen, etc.) — no polling. const analysisInFlight = ref(false); @@ -2867,6 +2884,8 @@ export default defineComponent({ expandedSections, formattedRcaContent, correlationData, + correlationMatchedSetId, + correlationChipDimensions, correlationLoading, correlationError, hasCorrelatedData, diff --git a/web/src/plugins/correlation/CorrelationEventHeader.spec.ts b/web/src/plugins/correlation/CorrelationEventHeader.spec.ts new file mode 100644 index 0000000000..1069df1c61 --- /dev/null +++ b/web/src/plugins/correlation/CorrelationEventHeader.spec.ts @@ -0,0 +1,125 @@ +// 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 } from "@vue/test-utils"; +import CorrelationEventHeader from "./CorrelationEventHeader.vue"; + +// Minimal O2 stubs — these specs assert structure/behaviour, not O2 internals. +const OBadgeStub = { + name: "OBadge", + props: ["variant", "size", "dot"], + template: ``, +}; +const OSeparatorStub = { name: "OSeparator", template: `
` }; +const OTooltipStub = { + name: "OTooltip", + props: ["content", "side", "disabled"], + template: ``, +}; +const OToggleGroupStub = { + name: "OToggleGroup", + props: ["modelValue", "type", "size"], + emits: ["update:modelValue"], + template: `
`, +}; +const OToggleGroupItemStub = { + name: "OToggleGroupItem", + props: ["value", "size", "disabled"], + template: ``, +}; + +const subjectChip = (over: Record = {}) => ({ + key: "k8s-pod-name", + label: "Pod", + value: "pod-abc", + kind: "subject" as const, + active: false, + ...over, +}); + +describe("CorrelationEventHeader.vue", () => { + let wrapper: VueWrapper; + + const createWrapper = (props = {}) => + mount(CorrelationEventHeader, { + props: { + subjectChips: [subjectChip()], + activeSubject: "k8s-pod-name", + ...props, + }, + global: { + stubs: { + OBadge: OBadgeStub, + OSeparator: OSeparatorStub, + OTooltip: OTooltipStub, + OToggleGroup: OToggleGroupStub, + OToggleGroupItem: OToggleGroupItemStub, + }, + }, + }); + + afterEach(() => { + wrapper?.unmount(); + vi.clearAllMocks(); + }); + + describe("selected-subject badge", () => { + it("should render a 'label = value' badge for the active subject when it has a value", () => { + wrapper = createWrapper(); + const badge = wrapper.find( + '[data-test="correlation-event-header-active-subject-k8s-pod-name"]', + ); + expect(badge.exists()).toBe(true); + expect(badge.text()).toBe("Pod = pod-abc"); + }); + + it("should not render the badge when the active subject has no value (incident case)", () => { + wrapper = createWrapper({ + subjectChips: [subjectChip({ value: "" })], + activeSubject: "k8s-pod-name", + }); + expect( + wrapper + .find('[data-test="correlation-event-header-active-subject-k8s-pod-name"]') + .exists(), + ).toBe(false); + }); + + it("should not render the badge when no subject is active", () => { + wrapper = createWrapper({ activeSubject: null }); + expect( + wrapper + .find('[data-test="correlation-event-header-active-subject-k8s-pod-name"]') + .exists(), + ).toBe(false); + }); + + it("should render the badge for whichever subject is active", () => { + wrapper = createWrapper({ + subjectChips: [ + subjectChip(), + subjectChip({ key: "k8s-node-name", label: "Node", value: "node-1" }), + ], + activeSubject: "k8s-node-name", + }); + const badge = wrapper.find( + '[data-test="correlation-event-header-active-subject-k8s-node-name"]', + ); + expect(badge.exists()).toBe(true); + expect(badge.text()).toBe("Node = node-1"); + }); + }); +}); diff --git a/web/src/plugins/correlation/CorrelationEventHeader.vue b/web/src/plugins/correlation/CorrelationEventHeader.vue index d2a80556f9..5a74005cb8 100644 --- a/web/src/plugins/correlation/CorrelationEventHeader.vue +++ b/web/src/plugins/correlation/CorrelationEventHeader.vue @@ -45,14 +45,15 @@ along with this program. If not, see .
- +
Correlated by:
@@ -74,13 +75,24 @@ along with this program. If not, see . class="tw:cursor-default" :data-test="`correlation-event-header-overflow-${hiddenChipCount}`" > - +{{ hiddenChipCount }} + {{ hiddenChipCount }} - + + +
@@ -118,6 +130,17 @@ along with this program. If not, see . + + + + {{ activeSubjectChip.label }} = {{ activeSubjectChip.value }} +
@@ -130,6 +153,7 @@ along with this program. If not, see .