fix: Telemetry correlation header UI fixes (#12709)

## What changed

- Passes `matchedSetId` and `chipDimensions` as new computed props from
`IncidentDetailDrawer.vue` to the embedded
`TelemetryCorrelationDashboard`, enabling the "View by" subject tabs in
the incident view
- Adds an `OBadge` showing the selected subject as `label = value` after
the subject toggle tabs in `CorrelationEventHeader.vue`
- Fixes the responsive overflow logic so the first context chip can
overflow when space runs out, and the overflow indicator shows "N
Fields" when all chips are hidden instead of a bare "+N"
- Renders hidden overflow chips as stacked `OBadge` components in
`OTooltip`'s `#content` slot instead of a `\n`-joined string

## Why

Incidents opened without a source trace or log event had no
`matchedSetId` / `chipDimensions` passed to the correlation dashboard,
so the "View by" subject toggle (Pod, Node, etc.) never rendered. The
badge was also missing to visually indicate which subject is active.
This commit is contained in:
Omkar Kesarkhane
2026-06-17 04:12:59 +00:00
committed by GitHub
parent 5be5a302d3
commit cec4fa5fe7
4 changed files with 280 additions and 26 deletions
@@ -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: '<div class="telemetry-stub"></div>',
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
@@ -1082,6 +1082,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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<Record<string, string>>(() => {
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,
@@ -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 <http://www.gnu.org/licenses/>.
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: `<span :data-test="$attrs['data-test']"><slot /></span>`,
};
const OSeparatorStub = { name: "OSeparator", template: `<hr />` };
const OTooltipStub = {
name: "OTooltip",
props: ["content", "side", "disabled"],
template: `<span data-test-stub="o-tooltip" :title="content"></span>`,
};
const OToggleGroupStub = {
name: "OToggleGroup",
props: ["modelValue", "type", "size"],
emits: ["update:modelValue"],
template: `<div><slot /></div>`,
};
const OToggleGroupItemStub = {
name: "OToggleGroupItem",
props: ["value", "size", "disabled"],
template: `<button :data-test="$attrs['data-test']"><slot /></button>`,
};
const subjectChip = (over: Record<string, unknown> = {}) => ({
key: "k8s-pod-name",
label: "Pod",
value: "pod-abc",
kind: "subject" as const,
active: false,
...over,
});
describe("CorrelationEventHeader.vue", () => {
let wrapper: VueWrapper<any>;
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");
});
});
});
@@ -45,14 +45,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Chips Row -->
<div
v-if="hasChips || $slots['chip-actions']"
ref="containerRef"
class="tw:flex tw:items-center tw:gap-6 tw:px-4 tw:border-b tw:border-solid tw:border-[var(--o2-border-color)]"
>
<!-- Context chips (Correlated by) -->
<!-- Context chips (Correlated by) — flex-1 so it occupies exactly the space
left after the shrink-0 subject section (toggles + dynamic badge). Its
measured width drives the responsive overflow math. -->
<div
v-if="contextChips && contextChips.length > 0"
class="tw:flex tw:items-center tw:gap-3 tw:py-2"
:class="showSubjectSection ? 'tw:max-w-[calc(100%-18.75rem)]' : 'tw:max-w-full'"
ref="containerRef"
class="tw:flex tw:items-center tw:gap-3 tw:py-2 tw:flex-1 tw:min-w-0"
>
<span class="tw:text-2! tw:m-0 tw:text-typography-meta tw:shrink-0">Correlated by:</span>
<div class="tw:flex tw:items-center tw:gap-2 tw:min-w-0 tw:overflow-hidden">
@@ -74,13 +75,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="tw:cursor-default"
:data-test="`correlation-event-header-overflow-${hiddenChipCount}`"
>
+{{ hiddenChipCount }}
<template v-if="hiddenChipCount !== contextChips.length">+</template>{{ hiddenChipCount }}<template v-if="hiddenChipCount === contextChips.length"> Fields</template>
</OBadge>
<OTooltip
:content="hiddenChipsTooltip"
side="top"
:disabled="hiddenChipCount === 0"
/>
<OTooltip side="top" :disabled="hiddenChipCount === 0">
<template #content>
<div class="tw:flex tw:flex-col tw:items-start tw:gap-1">
<OBadge
v-for="chip in hiddenChips"
:key="chip.key"
:variant="chipBadgeVariant(chip.key)"
:size="badgeSize"
dot
:data-test="`correlation-event-header-hidden-chip-${chip.key}`"
>
{{ chip.label }} = {{ chip.value }}
</OBadge>
</div>
</template>
</OTooltip>
</span>
</div>
</div>
@@ -118,6 +130,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</OToggleGroupItem>
</OToggleGroup>
<!-- Selected subject as a "label = value" badge (when it carries a value) -->
<OBadge
v-if="activeSubjectChip && activeSubjectChip.value"
variant="amber-outline"
:size="badgeSize"
dot
:data-test="`correlation-event-header-active-subject-${activeSubjectChip.key}`"
>
{{ activeSubjectChip.label }} = {{ activeSubjectChip.value }}
</OBadge>
</div>
<!-- Slot for actions co-located with the chip row (e.g. wrap-text button) -->
@@ -130,6 +153,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from "vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
@@ -200,7 +224,7 @@ const CHIP_VARIANTS = [
"indigo-outline",
] as const;
const chipBadgeVariant = (key: string): string =>
const chipBadgeVariant = (key: string): BadgeVariant =>
CHIP_VARIANTS[hashKey(key) % CHIP_VARIANTS.length];
// ── Responsive overflow (ResizeObserver) ─────────────────────────────────────
@@ -242,6 +266,13 @@ const showSubjectSection = computed(
() => (props.subjectChips?.length ?? 0) > 0,
);
// The currently-selected subject chip, shown as a "label = value" badge after the
// View-by toggles. Only meaningful when it carries a concrete value (trace/log);
// incidents' valueless subjects leave this null so no empty badge renders.
const activeSubjectChip = computed<DimensionChip | undefined>(() =>
props.subjectChips?.find((c) => c.key === props.activeSubject),
);
const hasChips = computed(
() =>
(props.contextChips?.length ?? 0) > 0 || showSubjectSection.value,
@@ -252,14 +283,12 @@ const displayedChips = computed<DimensionChip[]>(() => {
if (chips.length === 0) return [];
if (props.overflowMode === "responsive") {
// containerRef is the flex-1 context wrapper, so its measured width already
// excludes the (dynamic) subject section + badge — no need to reserve for them.
const fullWidth = containerWidth.value;
const reservedForSubjects = showSubjectSection.value ? 300 : 0;
const labelWidth = 90; // "Correlated by:" label
const paddingAndGaps = 32;
const available = Math.max(
150,
fullWidth - reservedForSubjects - labelWidth - paddingAndGaps,
);
const paddingAndGaps = 8;
const available = fullWidth - labelWidth - paddingAndGaps;
let usedWidth = 0;
let visibleCount = 0;
@@ -270,11 +299,11 @@ const displayedChips = computed<DimensionChip[]>(() => {
remaining > 0 ? OVERFLOW_INDICATOR_WIDTH + CHIP_GAP : 0;
const neededWidth =
chipWidth + (i > 0 ? CHIP_GAP : 0) + overflowSpace;
if (usedWidth + neededWidth > available && visibleCount > 0) break;
if (usedWidth + neededWidth > available) break;
usedWidth += chipWidth + (i > 0 ? CHIP_GAP : 0);
visibleCount++;
}
return chips.slice(0, Math.max(1, visibleCount));
return chips.slice(0, visibleCount);
}
return chips.slice(0, props.overflowThreshold);
@@ -287,10 +316,6 @@ const hiddenChips = computed<DimensionChip[]>(() => {
const hiddenChipCount = computed(() => hiddenChips.value.length);
const hiddenChipsTooltip = computed(() =>
hiddenChips.value.map((chip) => `${chip.label} = ${chip.value}`).join("\n"),
);
// ── Source event banner helpers ───────────────────────────────────────────────
const TS_NS_MIN = 1e17;