From 69bd367d9bb18a0b45e1878019bba178fd6380ae Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Mon, 17 Aug 2026 12:00:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20notification=20dependency=20view=20?= =?UTF-8?q?=E2=80=94=20where-used=20for=20templates,=20destinations=20&=20?= =?UTF-8?q?alerts=20(#13820)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a **"View dependencies"** affordance to the **Templates**, **Notification Destinations**, and **Alerts** lists. A small `graph-1` icon on each row opens a compact popover that shows everything that entity connects to along the **Template → Destination → Alert** chain, with search, pagination, and per-row **Open** / **Delete**. ## Why The linkage between alert templates, destinations, and alerts is invisible today. There is **no reverse-lookup API** — the only way to learn "which alerts use this destination" is to try deleting it and hit the backend's `409 "Destination is currently used by alert: X"`. That's a dead end, not a map. This surfaces the dependency proactively so you can see the blast-radius *before* you change or delete something, and act on it in place (open or delete a related entity, with the same `409` guard still protecting in-use items). ## Design The three entities are linked **by name** (there are no ID foreign keys): - `Alert.destinations: string[]` → `Destination.name` - `Destination.template` → `Template.name` (a destination's default) - `Alert.template` → `Template.name` (an alert-level override) ```mermaid flowchart LR T["Template"] -->|"Destination.template"| D["Destination"] D -->|"Alert.destinations[]"| A["Alert"] T -. "Alert.template (override)" .-> A ``` Because the alerts **list** DTO previously omitted `destinations`, the whole graph would have needed N per-alert detail fetches. Instead a small **opt-in additive field** on the list item makes the entire graph **three list calls**, cross-referenced client-side and cached per-org: ```mermaid flowchart TD Row["List row · graph-1 icon"] --> Pop["DependencyChainPopover
(OPopover + ConfirmDialog)"] Pop --> Panel["DependencyUsagePanel
search · collapsible sections · pager"] Panel --> Rows["DependencyUsageRow ×N
Open · Delete"] Panel --> Load["useDependencyGraph.loadGraph()
per-org cache"] Load -->|"alerts?include_dependencies=true
+ destinations + templates (3 list calls)"| Build["buildGraph() → nodes + edges"] Panel --> Focus["buildFocusChain(graph, focus)
directional: a shared template never
drags in sibling destinations"] Rows -. "requestDelete(node)" .-> Pop Rows -. "open(node) — same-kind, in-place" .-> Pop Pop -. "ConfirmDialog → service.delete()
(409 'used by' → toast)" .-> Done["emit deleted → list refreshes"] ``` Why the ConfirmDialog lives in the popover **wrapper**, not the panel: a modal can't render inside the popover without dismissing it, so the row/panel only *request* a delete and the wrapper owns the real confirmation. **Open** works the same way for the in-place editors: templates/destinations edit on their own list route (which only opens from the page's mount-time handler), so a same-page open is bubbled to the host page to invoke its native editor rather than a no-op `router.push` to the same route. ## UX / behaviour - **Where-used, grouped:** collapsible **Templates · Destinations · Alerts** sections, each a clickable row (kind-coloured icon, name, and for destinations an "N alerts" **total-usage** badge). - **Search-first:** one search box filters all groups — the key affordance when a destination feeds thousands of alerts. - **Paged:** the alerts group renders **50 at a time** with a pager pinned beside its (sticky) header, so 4,000-alert destinations stay responsive. - **Act in place:** every row has **Open** (templates/destinations → their editor; alerts → the alert's detail page) and **Delete** (the same `ConfirmDialog` the list pages use; the backend `409` still blocks in-use items). - Consistent trigger on all three lists; **purely additive** — no existing button, icon, or column was moved or replaced. ## Backend change `ListAlertsResponseBodyItem` (`src/api/management/src/models/alerts/responses.rs`) gains two additive fields — the linkage the dependency view cross-references: - `destinations: Vec` - `template: Option` (the alert-level override) They serialize **only when the caller passes `?include_dependencies=true`** (which just the dependency popover does); the default paginated alerts-list path is unchanged, so ordinary consumers pay no extra bytes and receive no module-scoped destination/template names. Both are also `skip_serializing_if`-empty. Backward-compatible, OSS, no new endpoint. ## Testing - **Unit (vitest) — 17 tests:** - `useDependencyGraph.spec.ts` — `buildGraph` (linkage, unused/orphan, dangling refs, override edges, shared destinations) and directional `buildFocusChain` per focus kind (incl. the alert-focus total-usage count). - `DependencyUsagePanel.spec.ts` — sections render every kind, search + 50/page paging, collapse, `requestDelete`, the same-kind `open` bubble, and the `include_dependencies` opt-in. - `DependencyChainPopover.spec.ts` — trigger + lazy panel mount, `requestDelete` → ConfirmDialog → API + `deleted`, and the `open` bubble + close. - **Backend:** serialization test asserts `destinations`/`template` are omitted when empty. - `type-check`, `lint:design:strict`, and `prettier` all clean. - A popover e2e (Playwright) is a follow-up in a separate PR — the earlier spec targeted an interim dedicated page that this PR does not ship, so it was removed. ## Notes - Net change is popover-only: an earlier iteration added a dedicated Reliability page + route + rail entry; those were dropped in favour of the on-the-fly popover, so `router.ts` / `navGroups.ts` are untouched vs `main`. - Icon `graph-1` was added to the icon registry (additive) for the popover trigger. - **Deferred (see design doc Non-goals):** a server-side where-used / `?destinations=` endpoint so the popover fetches only the neighbourhood instead of the full alert list; and enterprise RBAC name-filtering on the gated fields. Design at: https://github.com/openobserve/designs/blob/main/alerts/notification-dependencies/design.md (designs#8: `alerts/notification-dependencies/design.md`) --------- Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> Co-authored-by: ktx-abhay --- .../management/src/models/alerts/requests.rs | 11 + .../management/src/models/alerts/responses.rs | 20 + src/api/management/src/request/alerts/mod.rs | 18 + web/src/components/alerts/AlertList.vue | 12 +- .../alerts/AlertsDestinationList.vue | 32 +- .../components/alerts/DependencyEntityRow.vue | 102 ++++ .../alerts/DependencyImpactDialog.spec.ts | 218 ++++++++ .../alerts/DependencyImpactDialog.vue | 463 +++++++++++++++++ .../alerts/DependencyUsageCell.spec.ts | 92 ++++ .../components/alerts/DependencyUsageCell.vue | 121 +++++ web/src/components/alerts/TemplateList.vue | 29 +- .../alerts/useDependencyGraph.spec.ts | 242 +++++++++ .../composables/alerts/useDependencyGraph.ts | 481 ++++++++++++++++++ web/src/lib/core/Icon/OIcon.icons.ts | 2 + web/src/locales/languages/en-US.json | 26 + web/src/services/alerts.ts | 6 + 16 files changed, 1872 insertions(+), 3 deletions(-) create mode 100644 web/src/components/alerts/DependencyEntityRow.vue create mode 100644 web/src/components/alerts/DependencyImpactDialog.spec.ts create mode 100644 web/src/components/alerts/DependencyImpactDialog.vue create mode 100644 web/src/components/alerts/DependencyUsageCell.spec.ts create mode 100644 web/src/components/alerts/DependencyUsageCell.vue create mode 100644 web/src/composables/alerts/useDependencyGraph.spec.ts create mode 100644 web/src/composables/alerts/useDependencyGraph.ts 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 . +