diff --git a/src/api/management/src/models/alerts/requests.rs b/src/api/management/src/models/alerts/requests.rs index 3e1deebb56..5270dead79 100644 --- a/src/api/management/src/models/alerts/requests.rs +++ b/src/api/management/src/models/alerts/requests.rs @@ -431,6 +431,13 @@ pub struct ListAlertsQuery { /// Sort direction: `desc` for descending, anything else ascending. pub sort_order: Option, + + /// When `true`, each list item also carries its notification `destinations` + /// and any alert-level `template` override — the fields the notification + /// dependency view cross-references. Only that view opts in; the default + /// (absent / false) keeps them off the hot list path so ordinary consumers + /// neither pay the extra bytes nor receive destination/template names. + pub include_dependencies: Option, } /// HTTP URL query component that contains parameters for enabling alerts. @@ -804,6 +811,7 @@ mod tests { tags: None, sort_by: None, sort_order: None, + include_dependencies: None, }; let params = q.into("my_org"); assert_eq!(params.org_id, "my_org"); @@ -830,6 +838,7 @@ mod tests { tags: None, sort_by: None, sort_order: None, + include_dependencies: None, }; let params = q.into("org2"); assert_eq!(params.org_id, "org2"); @@ -854,6 +863,7 @@ mod tests { tags: None, sort_by: None, sort_order: None, + include_dependencies: None, }; let params = q.into("org3"); assert_eq!(params.page_size_and_idx, Some((5, 0))); @@ -905,6 +915,7 @@ mod priority_tag_query_tests { tags: tags.map(|s| s.to_string()), sort_by: sort_by.map(|s| s.to_string()), sort_order: order.map(|s| s.to_string()), + include_dependencies: None, } } diff --git a/src/api/management/src/models/alerts/responses.rs b/src/api/management/src/models/alerts/responses.rs index d5d643f487..42eacb71dc 100644 --- a/src/api/management/src/models/alerts/responses.rs +++ b/src/api/management/src/models/alerts/responses.rs @@ -105,6 +105,16 @@ pub struct ListAlertsResponseBodyItem { /// Normalized selection tags (PT-6). Omitted when empty. #[serde(skip_serializing_if = "Vec::is_empty")] pub tags: Vec, + /// Names of the notification destinations this alert delivers to + /// (`Alert.destinations`). Lets the destination↔alert dependency view build + /// the linkage from the list alone; omitted when empty. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub destinations: Vec, + /// Alert-level template override (`Alert.template`): when set it replaces the + /// destination-level template for every destination above. Absent when the + /// alert defers to each destination's own template. + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, /// Multi-alerts only (§5.4): how many groups the last evaluation observed, /// counted **before** the M-6 cap truncated them. Absent for every alert /// that has not opted in. @@ -457,6 +467,8 @@ impl TryFrom<(meta_folders::Folder, meta_alerts::Alert, Option)> level_since: None, priority: alert.priority.map(|p| p.to_i32() as u8), tags: alert.tags, + destinations: alert.destinations, + template: alert.template, // Filled from the rollup state row by `enrich_with_run_state`, // alongside the other run-state fields above. groups_observed: None, @@ -558,6 +570,9 @@ pub fn anomaly_config_to_list_item(v: &serde_json::Value) -> Option>(t.clone()).ok()) .unwrap_or_default(), + // Anomaly configs deliver through their own path, not alert destinations. + destinations: Vec::new(), + template: None, // Anomaly configs have no grouping, so there is nothing to count. groups_observed: None, groups_firing: None, @@ -645,6 +660,8 @@ mod tests { level_since: None, priority: None, tags: vec![], + destinations: vec![], + template: None, groups_observed: None, groups_firing: None, groups_observed_is_lower_bound: None, @@ -659,6 +676,9 @@ mod tests { assert!(!obj.contains_key("last_trained_at")); assert!(!obj.contains_key("status")); assert!(!obj.contains_key("last_error")); + // Empty destinations / no override template are omitted from the wire. + assert!(!obj.contains_key("destinations")); + assert!(!obj.contains_key("template")); // §5.4: a non-multi alert must not advertise a group summary at all — // an absent field reads as "not a multi-alert", a zero would read as // "observed no groups". diff --git a/src/api/management/src/request/alerts/mod.rs b/src/api/management/src/request/alerts/mod.rs index 3e957861e2..68ccdf1db2 100644 --- a/src/api/management/src/request/alerts/mod.rs +++ b/src/api/management/src/request/alerts/mod.rs @@ -508,6 +508,8 @@ fn composite_list_item( level_since: None, priority: definition.priority.map(|value| value as u8), tags, + destinations: Vec::new(), + template: None, groups_observed: None, groups_firing: None, groups_observed_is_lower_bound: None, @@ -2353,6 +2355,13 @@ pub async fn list_alerts( // post-filtering an already-fetched page. let requested_tags = query.requested_tags(); + // Opt-in (dependency view only): destinations/template ride the response only + // when asked for. Captured before `query` is moved into `params` below. + let include_dependencies = query.include_dependencies.unwrap_or(false); + + #[cfg(not(feature = "enterprise"))] + let mut params = query.into(&org_id); + #[cfg(feature = "enterprise")] let mut params = query.into(&org_id); if !requested_tags.is_empty() { @@ -2595,6 +2604,15 @@ pub async fn list_alerts( enrich_with_run_state(&mut list).await; enrich_with_composite_metadata(&org_id, &visibility, &mut list).await; + // Feature-scoped fields: keep destinations/template off the default list path + // (bytes + module-scoped names) unless the dependency view explicitly opted in. + if !include_dependencies { + for item in &mut list { + item.destinations = Vec::new(); + item.template = None; + } + } + MetaHttpResponse::json(ListAlertsResponseBody { list }) } diff --git a/web/src/components/alerts/AlertList.vue b/web/src/components/alerts/AlertList.vue index 28a5ef310d..1d6c1ea657 100644 --- a/web/src/components/alerts/AlertList.vue +++ b/web/src/components/alerts/AlertList.vue @@ -855,6 +855,7 @@ import OIcon from "@/lib/core/Icon/OIcon.vue"; import FolderList from "../common/sidebar/FolderList.vue"; import MoveAcrossFolders from "../common/sidebar/MoveAcrossFolders.vue"; +import { invalidateDependencyGraphCache } from "@/composables/alerts/useDependencyGraph"; import { nextTick } from "vue"; import SelectFolderDropDown from "../common/sidebar/SelectFolderDropDown.vue"; import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue"; @@ -1512,7 +1513,7 @@ export default defineComponent({ header: t("alerts.actions"), isAction: true, sortable: false, - size: 150, + size: 160, meta: { align: "center", cellClass: "actions-column", actionCount: 4 }, }, ]; @@ -1668,6 +1669,10 @@ export default defineComponent({ //for a moment also so we are not filtering the alerts by the activeTab selectedAlerts.value = []; allSelectedAlerts.value = false; + // The alerts list is refreshed after every alert mutation — drop the shared + // dependency-graph cache so the destination/template impact dialogs reflect + // the change on next open. + invalidateDependencyGraphCache(); if (query) { //here we reset the filteredResults before fetching the filtered alerts filteredResults.value = []; @@ -2755,6 +2760,10 @@ export default defineComponent({ activeFolderToMove.value = activeFolderId.value; }; + // A delete inside the dependency popover — reload the current folder's alerts + // so the table drops the removed row. + const onDependencyDeleted = () => getAlertsFn(store, activeFolderId.value); + const updateAcrossFolders = async (activeFolderId: any, selectedFolderId: any) => { //here we are fetching the alerts of the selected folder first and then fetching the alerts of the active folder if (selectedFolderId === activeFolderId) { @@ -3221,6 +3230,7 @@ export default defineComponent({ t, store, router, + onDependencyDeleted, columns, recencyLevel, alertRowClass, diff --git a/web/src/components/alerts/AlertsDestinationList.vue b/web/src/components/alerts/AlertsDestinationList.vue index bb5542a4d1..3548906b1a 100644 --- a/web/src/components/alerts/AlertsDestinationList.vue +++ b/web/src/components/alerts/AlertsDestinationList.vue @@ -238,6 +238,14 @@ along with this program. If not, see . + + @@ -292,6 +300,10 @@ import { usePrebuiltDestinations } from "@/composables/usePrebuiltDestinations"; import type { Template } from "@/ts/interfaces/index"; import ImportDestination from "./ImportDestination.vue"; +import DependencyUsageCell from "./DependencyUsageCell.vue"; +import useDependencyGraph, { + invalidateDependencyGraphCache, +} from "@/composables/alerts/useDependencyGraph"; import useActions from "@/composables/useActions"; import { useReo } from "@/services/reodotdev_analytics"; import OIcon from "@/lib/core/Icon/OIcon.vue"; @@ -329,11 +341,13 @@ export default defineComponent({ OToggleGroup, OToggleGroupItem, OPageLayout, + DependencyUsageCell, }, setup() { const store = useStore(); const editingDestination: Ref = ref(null); const { t } = useI18nTyped(); + const { graph: depGraph, loadGraph: loadDepGraph } = useDependencyGraph(); const { getAllActions } = useActions(); const { track } = useReo(); @@ -390,12 +404,22 @@ export default defineComponent({ size: COL.method, meta: { align: "left" }, }, + { + id: "used_by", + header: t("alert_dependencies.usedByColumn"), + cell: " ", + sortable: false, + resizable: true, + hideable: true, + size: 200, + meta: { align: "left" }, + }, { id: "actions", header: t("alert_destinations.actions"), isAction: true, pinned: "right", - size: 120, + size: 130, meta: { align: "center", actionCount: 3 }, }, ]; @@ -491,6 +515,11 @@ export default defineComponent({ ); resultTotal.value = res.data.length; destinations.value = res.data; + // Any destination add/edit/delete lands here on refresh — drop the + // shared dependency-graph cache and rebuild it so the "Used by" counts + // (and the impact dialog) are current. + invalidateDependencyGraphCache(); + loadDepGraph(store.state.selectedOrganization.identifier); updateRoute(); }) .catch((err) => { @@ -815,6 +844,7 @@ export default defineComponent({ ]); return { t, + depGraph, showDestinationEditor, destinations, columns, diff --git a/web/src/components/alerts/DependencyEntityRow.vue b/web/src/components/alerts/DependencyEntityRow.vue new file mode 100644 index 0000000000..4ab0c0af5f --- /dev/null +++ b/web/src/components/alerts/DependencyEntityRow.vue @@ -0,0 +1,102 @@ + + + + + + diff --git a/web/src/components/alerts/DependencyImpactDialog.spec.ts b/web/src/components/alerts/DependencyImpactDialog.spec.ts new file mode 100644 index 0000000000..c4c91c1b0f --- /dev/null +++ b/web/src/components/alerts/DependencyImpactDialog.spec.ts @@ -0,0 +1,218 @@ +// 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, vi, beforeEach, afterEach } from "vitest"; +import { ref } from "vue"; +import { mount, flushPromises } from "@vue/test-utils"; +import router from "@/test/unit/helpers/router"; +import store from "@/test/unit/helpers/store"; +import i18n from "@/locales"; +import { + useDependencyGraph as realUseDependencyGraph, + type DepGraph, +} from "@/composables/alerts/useDependencyGraph"; + +const { buildGraph } = realUseDependencyGraph(); + +// slack: used by 3 alerts (one paused), uses tpl-http. +const graph: DepGraph = buildGraph( + [ + { alert_id: "a1", name: "cpu", destinations: ["slack"], enabled: true }, + { alert_id: "a2", name: "mem", destinations: ["slack"], enabled: false }, + { alert_id: "a3", name: "disk", destinations: ["slack"], enabled: true }, + ], + [{ name: "slack", type: "http", template: "tpl-http" }], + [{ name: "tpl-http", type: "http" }], +); + +const loadGraph = vi.fn(); +// Replace only the composable's fetch; keep the real graph-building helpers +// (buildFocusChain / focusSummary / kind helpers) so behaviour stays honest. +vi.mock("@/composables/alerts/useDependencyGraph", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: () => ({ + graph: ref(graph), + loading: ref(false), + error: ref(null), + loadGraph, + buildGraph: actual.useDependencyGraph().buildGraph, + }), + }; +}); + +import DependencyImpactDialog from "./DependencyImpactDialog.vue"; + +const rowNames = () => + [...document.querySelectorAll('[data-test^="dependency-impact-row-"]')].map((el) => + el.getAttribute("data-test"), + ); + +let wrapper: ReturnType | null = null; +const mountDialog = (focus: any) => { + wrapper = mount(DependencyImpactDialog, { + props: { open: true, focus }, + attachTo: document.body, + global: { provide: { store }, plugins: [i18n, router] }, + }); + return wrapper; +}; + +describe("DependencyImpactDialog", () => { + beforeEach(() => loadGraph.mockClear()); + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + document.body.innerHTML = ""; + }); + + it("loads the graph when opened", async () => { + mountDialog({ kind: "destination", name: "slack" }); + await flushPromises(); + expect(loadGraph).toHaveBeenCalled(); + }); + + it("destination focus: only the alerts lane (no self or destination lane)", async () => { + mountDialog({ kind: "destination", name: "slack" }); + await flushPromises(); + expect(document.querySelector('[data-test="dependency-impact-lane-alert"]')).toBeTruthy(); + // Neither the removed self/focus lane nor a destinations lane is shown. + expect(document.querySelector('[data-test="dependency-impact-lane-destination"]')).toBeFalsy(); + expect(document.querySelector('[data-test="dependency-impact-lane-template"]')).toBeFalsy(); + expect(rowNames()).toEqual( + expect.arrayContaining([ + "dependency-impact-row-cpu", + "dependency-impact-row-mem", + "dependency-impact-row-disk", + ]), + ); + }); + + it("template focus: destinations → alerts grouped by destination (no template lane)", async () => { + mountDialog({ kind: "template", name: "tpl-http" }); + await flushPromises(); + // The self/template lane is gone; the flow starts at Destinations. + expect(document.querySelector('[data-test="dependency-impact-lane-template"]')).toBeFalsy(); + expect(document.querySelector('[data-test="dependency-impact-lane-destination"]')).toBeTruthy(); + // The alerts lane carries a box grouped by the destination. + expect(document.querySelector('[data-test="dependency-impact-group-slack"]')).toBeTruthy(); + }); + + it("hovering a destination highlights its alert group; leaving clears it", async () => { + mountDialog({ kind: "template", name: "tpl-http" }); + await flushPromises(); + const group = document.querySelector( + '[data-test="dependency-impact-group-slack"]', + ) as HTMLElement; + const card = document.querySelector( + '[data-test="dependency-impact-lane-destination"] [data-test="dependency-impact-card-slack"]', + ) as HTMLElement; + expect(group.className).not.toContain("bg-surface-accent"); + card.dispatchEvent(new MouseEvent("mouseenter")); + await flushPromises(); + expect(group.className).toContain("bg-surface-accent"); + // Nothing stays highlighted once the cursor leaves. + card.dispatchEvent(new MouseEvent("mouseleave")); + await flushPromises(); + expect(group.className).not.toContain("bg-surface-accent"); + }); + + it("hovering an alert highlights its destination card, not the box", async () => { + mountDialog({ kind: "template", name: "tpl-http" }); + await flushPromises(); + const card = document.querySelector( + '[data-test="dependency-impact-lane-destination"] [data-test="dependency-impact-card-slack"]', + ) as HTMLElement; + const group = document.querySelector( + '[data-test="dependency-impact-group-slack"]', + ) as HTMLElement; + const alertRow = document.querySelector( + '[data-test="dependency-impact-group-slack"] [data-test="dependency-impact-row-cpu"]', + ) as HTMLElement; + alertRow.dispatchEvent(new MouseEvent("mouseenter")); + await flushPromises(); + // The alert's destination card lights up… + expect(card.className).toContain("bg-surface-accent"); + // …but the box it sits in is NOT filled (that's reserved for hovering the dest). + expect(group.className).not.toContain("bg-surface-accent"); + }); + + it("clicking a destination scrolls its alert group into view", async () => { + mountDialog({ kind: "template", name: "tpl-http" }); + await flushPromises(); + const group = document.querySelector( + '[data-test="dependency-impact-group-slack"]', + ) as HTMLElement; + // jsdom doesn't implement scrollIntoView, so provide it as a mock. + const scrollSpy = vi.fn(); + group.scrollIntoView = scrollSpy; + ( + document.querySelector( + '[data-test="dependency-impact-lane-destination"] [data-test="dependency-impact-card-slack"]', + ) as HTMLElement + ).click(); + await flushPromises(); + expect(scrollSpy).toHaveBeenCalled(); + }); + + it("search keeps the Destinations and Alerts lanes in sync", async () => { + // slack has alert 'cpu'; a search matching that alert must keep slack visible + // in BOTH the destinations lane and as an alerts box. + mountDialog({ kind: "template", name: "tpl-http" }); + await flushPromises(); + const input = document.querySelector( + '[data-test="dependency-impact-search"] input', + ) as HTMLInputElement; + input.value = "cpu"; + input.dispatchEvent(new Event("input")); + await flushPromises(); + expect( + document.querySelector( + '[data-test="dependency-impact-lane-destination"] [data-test="dependency-impact-card-slack"]', + ), + ).toBeTruthy(); + expect(document.querySelector('[data-test="dependency-impact-group-slack"]')).toBeTruthy(); + }); + + it("filters rows by the search box", async () => { + mountDialog({ kind: "destination", name: "slack" }); + await flushPromises(); + const input = document.querySelector( + '[data-test="dependency-impact-search"] input', + ) as HTMLInputElement; + input.value = "cpu"; + input.dispatchEvent(new Event("input")); + await flushPromises(); + expect(rowNames()).toContain("dependency-impact-row-cpu"); + expect(rowNames()).not.toContain("dependency-impact-row-mem"); + }); + + it("opens a redirect in a new browser tab", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + mountDialog({ kind: "destination", name: "slack" }); + await flushPromises(); + (document.querySelector('[data-test="dependency-impact-open-cpu"]') as HTMLElement).click(); + expect(openSpy).toHaveBeenCalledWith(expect.any(String), "_blank", "noopener,noreferrer"); + openSpy.mockRestore(); + }); + + it("emits update:open=false when Close is clicked", async () => { + const w = mountDialog({ kind: "destination", name: "slack" }); + await flushPromises(); + (document.querySelector('[data-test="dependency-impact-close"]') as HTMLElement).click(); + expect(w.emitted("update:open")?.at(-1)).toEqual([false]); + }); +}); diff --git a/web/src/components/alerts/DependencyImpactDialog.vue b/web/src/components/alerts/DependencyImpactDialog.vue new file mode 100644 index 0000000000..be6866276f --- /dev/null +++ b/web/src/components/alerts/DependencyImpactDialog.vue @@ -0,0 +1,463 @@ + + + + + + diff --git a/web/src/components/alerts/DependencyUsageCell.spec.ts b/web/src/components/alerts/DependencyUsageCell.spec.ts new file mode 100644 index 0000000000..4dda705ba7 --- /dev/null +++ b/web/src/components/alerts/DependencyUsageCell.spec.ts @@ -0,0 +1,92 @@ +// 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, flushPromises } from "@vue/test-utils"; +import DependencyUsageCell from "./DependencyUsageCell.vue"; +import DependencyImpactDialog from "./DependencyImpactDialog.vue"; +import { useDependencyGraph } from "@/composables/alerts/useDependencyGraph"; +import type { DepFocus, DepGraph } from "@/composables/alerts/useDependencyGraph"; +import router from "@/test/unit/helpers/router"; +import store from "@/test/unit/helpers/store"; +import i18n from "@/locales"; + +const { buildGraph } = useDependencyGraph(); + +// slack: used by cpu + mem, uses tpl-http. lonely: no alerts (orphan). +const graph: DepGraph = buildGraph( + [ + { alert_id: "a1", name: "cpu", destinations: ["slack"], enabled: true }, + { alert_id: "a2", name: "mem", destinations: ["slack"], enabled: false }, + ], + [ + { name: "slack", type: "http", template: "tpl-http" }, + { name: "lonely", type: "email" }, + ], + [{ name: "tpl-http", type: "http" }], +); + +const emptyGraph: DepGraph = buildGraph([], [], []); + +const mountCell = (g: DepGraph, focus: DepFocus) => + mount(DependencyUsageCell, { + props: { graph: g, focus }, + global: { provide: { store }, plugins: [i18n, router] }, + }); + +describe("DependencyUsageCell", () => { + it("a destination shows only its alert count (downstream), not its template", async () => { + const wrapper = mountCell(graph, { kind: "destination", name: "slack" }); + await flushPromises(); + expect(wrapper.find('[data-test="used-by-slack-alert"]').text()).toContain("2"); + // Upstream template is not what the destination is "used by". + expect(wrapper.find('[data-test="used-by-slack-template"]').exists()).toBe(false); + expect(wrapper.find('[data-test="used-by-slack-destination"]').exists()).toBe(false); + wrapper.unmount(); + }); + + it("a template shows its destinations and alerts (downstream)", async () => { + const wrapper = mountCell(graph, { kind: "template", name: "tpl-http" }); + await flushPromises(); + expect(wrapper.find('[data-test="used-by-tpl-http-destination"]').text()).toContain("1"); + expect(wrapper.find('[data-test="used-by-tpl-http-alert"]').text()).toContain("2"); + wrapper.unmount(); + }); + + it("shows the Unused chip for an orphan entity", async () => { + const wrapper = mountCell(graph, { kind: "destination", name: "lonely" }); + await flushPromises(); + expect(wrapper.find('[data-test="used-by-lonely-unused"]').exists()).toBe(true); + expect(wrapper.find('[data-test="used-by-lonely-alert"]').exists()).toBe(false); + wrapper.unmount(); + }); + + it("shows a neutral placeholder before the graph has loaded", async () => { + const wrapper = mountCell(emptyGraph, { kind: "destination", name: "slack" }); + await flushPromises(); + expect(wrapper.find('[data-test="used-by-slack-unused"]').exists()).toBe(false); + expect(wrapper.find('[data-test="used-by-slack-alert"]').exists()).toBe(false); + wrapper.unmount(); + }); + + it("opens the impact dialog on click", async () => { + const wrapper = mountCell(graph, { kind: "destination", name: "slack" }); + await flushPromises(); + expect(wrapper.findComponent(DependencyImpactDialog).props("open")).toBe(false); + await wrapper.find('[data-test="used-by-slack"]').trigger("click"); + expect(wrapper.findComponent(DependencyImpactDialog).props("open")).toBe(true); + wrapper.unmount(); + }); +}); diff --git a/web/src/components/alerts/DependencyUsageCell.vue b/web/src/components/alerts/DependencyUsageCell.vue new file mode 100644 index 0000000000..6e20401de1 --- /dev/null +++ b/web/src/components/alerts/DependencyUsageCell.vue @@ -0,0 +1,121 @@ + + + + + + diff --git a/web/src/components/alerts/TemplateList.vue b/web/src/components/alerts/TemplateList.vue index db40def439..3c72173850 100644 --- a/web/src/components/alerts/TemplateList.vue +++ b/web/src/components/alerts/TemplateList.vue @@ -209,6 +209,13 @@ along with this program. If not, see . +