feat: notification dependency view — where-used for templates, destinations & alerts (#13820)

## 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<br/>(OPopover + ConfirmDialog)"]
  Pop --> Panel["DependencyUsagePanel<br/>search · collapsible sections · pager"]
  Panel --> Rows["DependencyUsageRow ×N<br/>Open · Delete"]
  Panel --> Load["useDependencyGraph.loadGraph()<br/>per-org cache"]
  Load -->|"alerts?include_dependencies=true<br/>+ destinations + templates (3 list calls)"| Build["buildGraph() → nodes + edges"]
  Panel --> Focus["buildFocusChain(graph, focus)<br/>directional: a shared template never<br/>drags in sibling destinations"]
  Rows -. "requestDelete(node)" .-> Pop
  Rows -. "open(node) — same-kind, in-place" .-> Pop
  Pop -. "ConfirmDialog → service.delete()<br/>(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<String>`
- `template: Option<String>` (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 <abhay.padamani@kiara.tech>
This commit is contained in:
Shrinath Rao
2026-08-17 12:00:39 +00:00
committed by GitHub
co-authored by Abhay Padamani ktx-abhay
parent 42d4e2a203
commit 69bd367d9b
16 changed files with 1872 additions and 3 deletions
@@ -431,6 +431,13 @@ pub struct ListAlertsQuery {
/// Sort direction: `desc` for descending, anything else ascending.
pub sort_order: Option<String>,
/// 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<bool>,
}
/// 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,
}
}
@@ -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<String>,
/// 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<String>,
/// 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<String>,
/// 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<Trigger>)>
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<ListAlertsRe
.get("tags")
.and_then(|t| serde_json::from_value::<Vec<String>>(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".
@@ -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 })
}
+11 -1
View File
@@ -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,
@@ -238,6 +238,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</div>
</template>
<template #cell-used_by="{ row }">
<DependencyUsageCell
:graph="depGraph"
:focus="{ kind: 'destination', name: row.name }"
@deleted="getDestinations"
/>
</template>
</OTable>
</div>
</OPageLayout>
@@ -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<DestinationPayload | null> = 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,
@@ -0,0 +1,102 @@
<!-- 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/>.
-->
<!--
One entity row inside the impact dialog: an alert shows an enabled/paused status
dot, everything else its kind icon. Presentational — emits open/delete; the
dialog opens (new tab) or confirms.
-->
<template>
<div
class="group rounded-default flex items-center gap-2 px-2 py-1.5"
:class="{ 'hover:bg-interactive-hover-bg': !noHover }"
:data-test="`dependency-impact-row-${node.name}`"
>
<span
v-if="node.kind === 'alert'"
class="size-2 shrink-0 rounded-full"
:class="node.enabled ? 'bg-status-positive' : 'bg-text-muted'"
>
<OTooltip
side="top"
:content="node.enabled ? t('alert_dependencies.enabled') : t('alert_dependencies.paused')"
/>
</span>
<OIcon
v-else
:name="depKindIcon(node.kind)"
size="sm"
class="shrink-0"
:class="depKindColor(node)"
/>
<span class="text-compact min-w-0 flex-1 truncate">
{{ node.name }}
<OTooltip side="top" :content="raw(node.name)" />
</span>
<OTag v-if="node.missing" type="countChip" value="error" class="shrink-0">
{{ t("alert_dependencies.missingTag") }}
</OTag>
<OTag v-else-if="count" type="countChip" value="neutral" class="shrink-0">
{{ t("alert_dependencies.usedBy", { count }, count) }}
</OTag>
<div class="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100">
<OButton
v-if="!node.missing"
variant="ghost"
size="icon-xs"
:data-test="`dependency-impact-open-${node.name}`"
@click.stop="emit('open', node)"
>
<OIcon name="open-in-new" size="sm" />
<OTooltip side="top" :content="t('alert_dependencies.actionOpen')" />
</OButton>
<OButton
v-if="!node.missing"
variant="ghost"
size="icon-xs"
:data-test="`dependency-impact-delete-${node.name}`"
@click.stop="emit('delete', node)"
>
<OIcon name="delete" size="sm" class="text-status-negative" />
<OTooltip side="top" :content="t('alert_dependencies.actionDelete')" />
</OButton>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18nTyped, raw } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { depKindIcon, depKindColor } from "@/composables/alerts/useDependencyGraph";
import type { DepNode } from "@/composables/alerts/useDependencyGraph";
// noHover: suppress the row's own hover background where a wrapper already
// provides the highlight (the destination cards), so hover and selected read the
// same shade instead of stacking two tints.
defineProps<{ node: DepNode; count?: number; noHover?: boolean }>();
const emit = defineEmits<{
(e: "open", node: DepNode): void;
(e: "delete", node: DepNode): void;
}>();
const { t } = useI18nTyped();
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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<typeof import("@/composables/alerts/useDependencyGraph")>();
return {
...actual,
default: () => ({
graph: ref(graph),
loading: ref(false),
error: ref<string | null>(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<typeof mount> | 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]);
});
});
@@ -0,0 +1,463 @@
<!-- 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/>.
-->
<!--
"Impact" dialog — everything downstream of one entity as a left→right flow of
card lanes (delivery direction Template → Destination → Alert):
• a destination shows: this destination → the alerts that use it;
• a template shows: this template → its destinations → their alerts, with the
alerts grouped into a box per destination. Clicking or hovering a destination
highlights its alert box (and vice-versa), so the destination↔alert link is
visible even though both sides can be many.
Open jumps to the entity's editor in a NEW TAB; delete is confirmed here.
-->
<template>
<ODialog
:open="open"
:size="isTemplateFocus ? 'lg' : 'sm'"
:max-height="86"
@update:open="(v) => emit('update:open', v)"
>
<template #header>
<div class="flex min-w-0 items-center gap-2.5">
<OIcon
:name="depKindIcon(focus.kind)"
size="md"
:class="depKindColor(focusNode ?? { kind: focus.kind, orphan: false, missing: false })"
/>
<div class="min-w-0">
<div class="text-text-heading truncate text-base font-semibold" :title="entityName">
{{ entityName }}
</div>
<div class="text-text-secondary truncate text-xs" :title="impactLabel">
{{ impactLabel }}
</div>
</div>
</div>
</template>
<div class="flex flex-col" data-test="dependency-impact-body">
<div
v-if="loading"
class="text-text-secondary flex h-72 items-center justify-center gap-2 text-sm"
data-test="dependency-impact-loading"
>
<OSpinner size="sm" />
{{ t("alert_dependencies.loading") }}
</div>
<OBanner
v-else-if="error"
variant="error"
icon="error"
:content="t('alert_dependencies.failedToLoad', { error })"
data-test="dependency-impact-error"
/>
<template v-else>
<OSearchInput
v-model="search"
class="mb-3 shrink-0"
data-test="dependency-impact-search"
:placeholder="t('alert_dependencies.searchPlaceholder')"
/>
<div class="flex items-stretch justify-center gap-1" data-test="dependency-impact-flow">
<!-- Destinations lane (template focus only). -->
<template v-if="isTemplateFocus">
<section
class="flex max-w-96 min-w-0 flex-1 flex-col"
data-test="dependency-impact-lane-destination"
>
<div class="mb-1.5 flex items-center gap-1.5 px-1">
<OIcon name="location-on" size="sm" class="text-info" />
<span class="text-text-secondary text-2xs font-semibold tracking-wide uppercase">
{{ t("alert_dependencies.sectionDestinations") }}
</span>
<OTag type="countChip" value="neutral">{{ chain.destinations.length }}</OTag>
</div>
<div
class="border-border-default bg-surface-panel rounded-surface h-72 space-y-1 overflow-y-auto border p-1.5"
>
<div
v-if="!filteredDestinations.length"
class="text-text-muted flex h-full items-center justify-center px-2 text-center text-xs"
>
{{
search ? t("alert_dependencies.noMatches") : t("alert_dependencies.laneEmpty")
}}
</div>
<div
v-for="d in filteredDestinations"
:key="d.id"
class="rounded-default cursor-pointer transition-colors"
:class="isDestHighlighted(d.id) ? 'bg-surface-accent' : ''"
:data-test="`dependency-impact-card-${d.name}`"
@mouseenter="hoveredDest = d.id"
@mouseleave="hoveredDest = null"
@click="scrollToGroup(d)"
>
<DependencyEntityRow
:node="d"
:count="d.usageCount || undefined"
no-hover
@open="openInNewTab"
@delete="requestDelete"
/>
</div>
</div>
</section>
<OIcon
name="chevron-right"
size="md"
class="text-text-muted mt-8 shrink-0"
aria-hidden="true"
/>
</template>
<!-- Alerts lane: a box per destination (the group). Capped only in the
template (multi-lane) layout; on a destination it fills the width so
it lines up with the full-width search bar above. -->
<section
class="flex min-w-0 flex-1 flex-col"
:class="isTemplateFocus ? 'max-w-96' : ''"
data-test="dependency-impact-lane-alert"
>
<div class="mb-1.5 flex items-center gap-1.5 px-1">
<OIcon name="shield-alert-outline" size="sm" class="text-status-positive" />
<span class="text-text-secondary text-2xs font-semibold tracking-wide uppercase">
{{ t("alert_dependencies.sectionAlerts") }}
</span>
<OTag type="countChip" value="neutral">{{ chain.alerts.length }}</OTag>
</div>
<div
class="border-border-default bg-surface-panel rounded-surface h-72 overflow-y-auto border p-1.5"
>
<div
v-if="alertsEmpty"
class="text-text-muted flex h-full items-center justify-center px-2 text-center text-xs"
>
{{ search ? t("alert_dependencies.noMatches") : t("alert_dependencies.laneEmpty") }}
</div>
<!-- Destination focus: one destination, so a flat list — no boxes,
no highlighting (every alert obviously belongs to it). -->
<template v-if="!isTemplateFocus">
<DependencyEntityRow
v-for="a in flatAlerts"
:key="a.id"
:node="a"
@open="openInNewTab"
@delete="requestDelete"
/>
</template>
<!-- Template focus: one box per destination, click/hover to link.
The gap lives here (between boxes) so the destination-focus flat
list above stays as tight as the rows inside a box. -->
<div v-else class="space-y-2">
<div
v-for="g in alertGroups"
:key="g.dest.id"
:ref="(el) => setGroupEl(g.dest.id, el)"
class="rounded-default p-1 transition-colors"
:class="isBoxFilled(g.dest.id) ? 'bg-surface-accent' : ''"
:data-test="`dependency-impact-group-${g.dest.name}`"
>
<!-- Only the destination header drives the box highlight, so
hovering a single alert never lights up the whole box. -->
<div
class="text-text-secondary text-2xs mb-0.5 flex cursor-pointer items-center gap-1 px-1 font-medium"
@mouseenter="hoveredDest = g.dest.id"
@mouseleave="hoveredDest = null"
@click="scrollToGroup(g.dest)"
>
<OIcon name="location-on" size="xs" class="text-info shrink-0" />
<span class="truncate" :title="g.dest.name">{{ g.dest.name }}</span>
</div>
<!-- Hovering an alert highlights its destination card(s). -->
<DependencyEntityRow
v-for="a in g.alerts"
:key="a.id"
:node="a"
@mouseenter="hoveredAlert = a.id"
@mouseleave="hoveredAlert = null"
@open="openInNewTab"
@delete="requestDelete"
/>
<div v-if="!g.alerts.length" class="text-text-muted px-2 py-1 text-xs">
{{ t("alert_dependencies.laneEmpty") }}
</div>
</div>
<!-- Alerts that reference the template directly (overrides). -->
<div v-if="directAlerts.length" data-test="dependency-impact-direct">
<div
class="text-text-secondary text-2xs flex items-center gap-1 px-1.5 pt-1 font-medium"
>
<OIcon name="bolt" size="xs" class="text-text-muted shrink-0" />
{{ t("alert_dependencies.directOverrides") }}
</div>
<DependencyEntityRow
v-for="a in directAlerts"
:key="a.id"
:node="a"
@open="openInNewTab"
@delete="requestDelete"
/>
</div>
</div>
</div>
</section>
</div>
</template>
</div>
<template #footer>
<div class="flex w-full items-center">
<OButton
variant="outline"
size="sm"
class="ms-auto"
data-test="dependency-impact-close"
@click="emit('update:open', false)"
>
{{ t("common.close") }}
</OButton>
</div>
</template>
</ODialog>
<ConfirmDialog
v-model="confirm.visible"
:title="t('alert_dependencies.deleteTitle', { name: confirm.node?.name || '' })"
:message="t('alert_dependencies.deleteMessage')"
@update:ok="performDelete"
@update:cancel="cancelDelete"
/>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { useI18nTyped, raw } from "@/types/i18n";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
import DependencyEntityRow from "./DependencyEntityRow.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import destinationService from "@/services/alert_destination";
import templateService from "@/services/alert_templates";
import alertsService from "@/services/alerts";
import useDependencyGraph, {
buildFocusChain,
invalidateDependencyGraphCache,
depKindIcon,
depKindColor,
} from "@/composables/alerts/useDependencyGraph";
import type { DepFocus, DepNode } from "@/composables/alerts/useDependencyGraph";
const props = defineProps<{ open: boolean; focus: DepFocus }>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "deleted"): void;
}>();
const { t } = useI18nTyped();
const store = useStore();
const router = useRouter();
const { graph, loading, error, loadGraph } = useDependencyGraph();
const search = ref("");
const hoveredDest = ref<string | null>(null);
const hoveredAlert = ref<string | null>(null);
const confirm = ref<{ visible: boolean; node: DepNode | null }>({ visible: false, node: null });
const org = () => store.state.selectedOrganization.identifier;
watch(
() => props.open,
(isOpen) => {
if (!isOpen) return;
search.value = "";
hoveredDest.value = null;
hoveredAlert.value = null;
loadGraph(org());
},
{ immediate: true },
);
const isTemplateFocus = computed(() => props.focus.kind === "template");
const chain = computed(() => buildFocusChain(graph.value, props.focus));
const focusNode = computed(() => chain.value.focusNode);
const entityName = computed(() => focusNode.value?.name ?? props.focus.name ?? "");
// Header subtitle: what this entity is used by, downstream. A template names its
// destinations + alerts; a destination names its alerts.
const impactLabel = computed(() => {
const alerts = t(
"alert_dependencies.usedBy",
{ count: chain.value.alerts.length },
chain.value.alerts.length,
);
if (!isTemplateFocus.value) return t("alert_dependencies.impactDestination", { alerts });
const destinations = t(
"alert_dependencies.countDestinations",
{ count: chain.value.destinations.length },
chain.value.destinations.length,
);
return t("alert_dependencies.impactTemplate", { destinations, alerts });
});
const nameMatches = (name: string) => {
const term = search.value.trim().toLowerCase();
return !term || name.toLowerCase().includes(term);
};
// Destination focus: the alerts using it, as a flat list.
const flatAlerts = computed(() => chain.value.alerts.filter((a) => nameMatches(a.name)));
// The ONE filtered set both the Destinations lane and the Alerts lane render, so
// they never disagree: a destination is visible when its own name matches (then
// all its alerts show) OR when any of its alerts match (then only those show).
const alertGroups = computed(() => {
const hasTerm = !!search.value.trim();
return chain.value.destinations
.map((d) => {
const destHit = nameMatches(d.name);
const alerts = destHit ? d.alerts : d.alerts.filter((a) => nameMatches(a.name));
return { dest: d, alerts, show: !hasTerm || destHit || alerts.length > 0 };
})
.filter((g) => g.show);
});
const filteredDestinations = computed(() => alertGroups.value.map((g) => g.dest));
// Alerts referencing the template directly (an override), not via any destination.
const directAlerts = computed(() => {
const viaDest = new Set(chain.value.destinations.flatMap((d) => d.alerts.map((a) => a.id)));
return chain.value.alerts.filter((a) => !viaDest.has(a.id) && nameMatches(a.name));
});
const alertsEmpty = computed(() =>
isTemplateFocus.value
? alertGroups.value.length === 0 && directAlerts.value.length === 0
: flatAlerts.value.length === 0,
);
// alert id → the destinations that deliver it (an alert can use several), for the
// reverse highlight: hovering an alert lights up its destination card(s).
const destsOfAlert = computed(() => {
const m = new Map<string, Set<string>>();
for (const d of chain.value.destinations)
for (const a of d.alerts) (m.get(a.id) ?? m.set(a.id, new Set()).get(a.id)!).add(d.id);
return m;
});
const hoveredAlertDests = computed(() =>
hoveredAlert.value
? (destsOfAlert.value.get(hoveredAlert.value) ?? new Set<string>())
: new Set<string>(),
);
// Highlight is hover-driven, nothing sticks. A destination CARD lights up when
// hovered directly or when one of its alerts is hovered; the alert BOX fills only
// when the destination itself is hovered (so hovering a single alert never fills
// the whole box).
const isDestHighlighted = (id: string) =>
hoveredDest.value === id || hoveredAlertDests.value.has(id);
const isBoxFilled = (id: string) => hoveredDest.value === id;
// Group-box elements, keyed by destination id, so a click can scroll the alerts
// lane to the matching box (the highlight is useless if it's below the fold).
const groupEls = new Map<string, HTMLElement>();
const setGroupEl = (id: string, el: unknown) => {
if (el instanceof HTMLElement) groupEls.set(id, el);
else groupEls.delete(id);
};
const scrollToGroup = async (dest: DepNode) => {
await nextTick();
groupEls.get(dest.id)?.scrollIntoView({ block: "start", behavior: "smooth" });
};
// Every redirection opens the target's editor in a NEW browser tab, so the user
// keeps the impact view (and it sidesteps same-route remount issues entirely).
const openInNewTab = (n: DepNode) => {
if (n.missing) return;
const org_identifier = org();
let route;
if (n.kind === "destination") {
route = {
name: "alertDestinations",
query: { action: "update", name: n.name, org_identifier },
};
} else if (n.kind === "template") {
route = { name: "alertTemplates", query: { action: "update", name: n.name, org_identifier } };
} else if (n.kind === "alert" && n.alertId) {
route = {
name: "alertDetail",
params: { alert_id: n.alertId },
query: { org_identifier, ...(n.folderId ? { folder: n.folderId } : {}) },
};
} else {
return;
}
const href = router.resolve(route).href;
window.open(href, "_blank", "noopener,noreferrer");
};
const requestDelete = (node: DepNode) => {
confirm.value = { visible: true, node };
};
const cancelDelete = () => {
confirm.value = { visible: false, node: null };
};
const performDelete = async () => {
const n = confirm.value.node;
confirm.value = { visible: false, node: null };
if (!n) return;
const org_identifier = org();
try {
if (n.kind === "destination") {
await destinationService.delete({ org_identifier, destination_name: n.name });
} else if (n.kind === "template") {
await templateService.delete({ org_identifier, template_name: n.name });
} else if (n.kind === "alert" && n.alertId) {
await alertsService.delete_by_alert_id(org_identifier, n.alertId, n.folderId);
} else {
return;
}
invalidateDependencyGraphCache();
toast({ variant: "success", message: t("alert_dependencies.deletedToast", { name: n.name }) });
emit("deleted");
// Deleting the focused entity leaves nothing to show; close. Otherwise the
// reference just left the chain — refetch so the list is current.
if (n.id === focusNode.value?.id) emit("update:open", false);
else await loadGraph(org_identifier);
} catch (err: any) {
toast({
variant: "error",
message:
raw(err?.response?.data?.message) ||
t("alert_dependencies.deleteFailedToast", { name: n.name }),
});
}
};
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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();
});
});
@@ -0,0 +1,121 @@
<!-- 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/>.
-->
<!--
The list "Used by" cell: per-kind count badges for one row, sourced from the
shared dependency graph (so it can't disagree with the impact dialog it opens).
An orphan/missing entity collapses to a single Unused/Missing chip; clicking the
cell opens the impact dialog.
-->
<template>
<OButton
variant="ghost"
size="sm"
class="h-auto! gap-1! px-1.5! py-1! font-normal!"
:data-test="`used-by-${key}`"
@click.stop="open = true"
>
<OIcon v-if="!loaded" name="graph-1" size="sm" class="text-text-muted" />
<OTag v-else-if="isMissing" type="countChip" value="error">
{{ t("alert_dependencies.missingTag") }}
</OTag>
<OTag
v-else-if="isUnused"
type="countChip"
value="neutral"
:data-test="`used-by-${key}-unused`"
>
{{ t("alert_dependencies.orphanTag") }}
</OTag>
<template v-else>
<OTag
v-for="b in badges"
:key="b.kind"
type="countChip"
value="neutral"
:data-test="`used-by-${key}-${b.kind}`"
>
<OIcon :name="depKindIcon(b.kind)" size="xs" class="mr-0.5" :class="kindColor(b.kind)" />
{{ b.count }}
<OTooltip side="top" :content="b.label" />
</OTag>
</template>
</OButton>
<DependencyImpactDialog v-model:open="open" :focus="focus" @deleted="emit('deleted')" />
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18nTyped } from "@/types/i18n";
import type { I18nText } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import DependencyImpactDialog from "./DependencyImpactDialog.vue";
import { focusSummary, depKindIcon, depKindColor } from "@/composables/alerts/useDependencyGraph";
import type { DepGraph, DepFocus, DepNodeKind } from "@/composables/alerts/useDependencyGraph";
const props = defineProps<{ graph: DepGraph; focus: DepFocus }>();
const emit = defineEmits<{ (e: "deleted"): void }>();
const { t } = useI18nTyped();
const open = ref(false);
// The graph is fetched by the host page; until it lands, show the neutral graph
// icon (the dialog loads its own copy on open) instead of flashing a false "Unused".
const loaded = computed(() => props.graph.nodes.length > 0);
const summary = computed(() => focusSummary(props.graph, props.focus));
const key = computed(() => props.focus.name ?? props.focus.alertId ?? "");
const isMissing = computed(() => !!summary.value.node?.missing);
// Downstream only, in delivery order: a template shows its destinations then
// alerts; a destination shows just its alerts. Nothing "upstream" (a template a
// destination uses) — that isn't what this entity is used by.
const badges = computed(() => {
const c = summary.value.counts;
const out: { kind: DepNodeKind; count: number; label: I18nText }[] = [];
if (props.focus.kind === "template" && c.destinations)
out.push({
kind: "destination",
count: c.destinations,
label: t("alert_dependencies.countDestinations", { count: c.destinations }, c.destinations),
});
if (c.alerts)
out.push({
kind: "alert",
count: c.alerts,
label: t("alert_dependencies.usedBy", { count: c.alerts }, c.alerts),
});
return out;
});
// Orphan (nothing uses it) wins over any downstream badge — a destination with a
// template but zero alerts still reads "Unused". An entity with no neighbours at
// all falls here too.
const isUnused = computed(
() =>
loaded.value && !isMissing.value && (!!summary.value.node?.orphan || badges.value.length === 0),
);
const kindColor = (kind: DepNodeKind) => depKindColor({ kind, orphan: false, missing: false });
</script>
+28 -1
View File
@@ -209,6 +209,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon name="delete" size="sm" />
</OButton>
</template>
<template #cell-used_by="{ row }">
<DependencyUsageCell
:graph="depGraph"
:focus="{ kind: 'template', name: row.name }"
@deleted="getTemplates"
/>
</template>
<template v-if="selectedTemplates.length > 0" #bottom>
<span class="text-text-secondary text-xs">
{{ selectedTemplates.length }} {{ t("alert_templates.selected") }}
@@ -277,6 +284,10 @@ import OTag from "@/lib/core/Badge/OTag.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import ImportTemplate from "./ImportTemplate.vue";
import DependencyUsageCell from "./DependencyUsageCell.vue";
import useDependencyGraph, {
invalidateDependencyGraphCache,
} from "@/composables/alerts/useDependencyGraph";
import { useReo } from "@/services/reodotdev_analytics";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
@@ -288,6 +299,7 @@ const store = useStore();
const { t } = useI18nTyped();
const router = useRouter();
const { track } = useReo();
const { graph: depGraph, loadGraph: loadDepGraph } = useDependencyGraph();
const templates: Ref<Template[]> = ref([]);
const columns: OTableColumnDef[] = [
{
@@ -297,12 +309,22 @@ const columns: OTableColumnDef[] = [
sortable: true,
meta: { align: "left", autoWidth: true },
},
{
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_templates.actions"),
isAction: true,
pinned: "right",
size: 150,
size: 160,
meta: { align: "center", actionCount: 4 },
},
];
@@ -375,6 +397,11 @@ const getTemplates = () => {
.then((res) => {
resultTotal.value = res.data.length;
templates.value = res.data;
// Any template 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) reflect the change.
invalidateDependencyGraphCache();
loadDepGraph(store.state.selectedOrganization.identifier);
updateRoute();
})
.catch((err) => {
@@ -0,0 +1,242 @@
// 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 } from "vitest";
import {
useDependencyGraph,
buildFocusChain,
focusSummary,
depKindIcon,
depKindColor,
} from "@/composables/alerts/useDependencyGraph";
import type { DepNode } from "@/composables/alerts/useDependencyGraph";
const { buildGraph } = useDependencyGraph();
const byName = (nodes: DepNode[], name: string, kind: DepNode["kind"]) =>
nodes.find((n) => n.name === name && n.kind === kind)!;
describe("useDependencyGraph.buildGraph", () => {
it("links Template → Destination → Alert by name", () => {
const { nodes, edges, stats } = buildGraph(
[
{
alert_id: "a1",
name: "cpu",
destinations: ["slack"],
enabled: true,
folder_id: "default",
},
],
[{ name: "slack", type: "http", template: "tpl" }],
[{ name: "tpl", type: "http" }],
);
expect(stats).toMatchObject({ templates: 1, destinations: 1, alerts: 1 });
// Destination shows its alert usage; template feeds the destination.
expect(byName(nodes, "slack", "destination").usageCount).toBe(1);
expect(edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: "template",
source: "template:tpl",
target: "destination:slack",
}),
expect.objectContaining({
relation: "usage",
source: "destination:slack",
target: "alert:a1",
}),
]),
);
});
it("flags an unused destination and an orphan template", () => {
const { nodes, stats } = buildGraph(
[],
[{ name: "pager", type: "http", template: undefined }],
[{ name: "lonely", type: "email" }],
);
expect(byName(nodes, "pager", "destination").orphan).toBe(true);
expect(byName(nodes, "lonely", "template").orphan).toBe(true);
expect(stats.orphanDestinations).toBe(1);
expect(stats.orphanTemplates).toBe(1);
});
it("flags a dangling destination reference (alert points at a missing destination)", () => {
const { nodes, stats } = buildGraph(
[{ alert_id: "a1", name: "disk", destinations: ["ghost"], enabled: true }],
[],
[],
);
const ghost = byName(nodes, "ghost", "destination");
expect(ghost.missing).toBe(true);
expect(ghost.orphan).toBe(false);
expect(stats.danglingReferences).toBe(1);
});
it("draws a dashed override edge when the alert sets its own template", () => {
const { edges } = buildGraph(
[
{
alert_id: "a1",
name: "mem",
destinations: ["slack"],
template: "override-tpl",
enabled: true,
},
],
[{ name: "slack", type: "http", template: "dest-tpl" }],
[
{ name: "override-tpl", type: "http" },
{ name: "dest-tpl", type: "http" },
],
);
expect(edges).toEqual(
expect.arrayContaining([
expect.objectContaining({
relation: "override",
source: "template:override-tpl",
target: "alert:a1",
}),
]),
);
});
it("counts one destination shared by two alerts", () => {
const { nodes } = buildGraph(
[
{ alert_id: "a1", name: "cpu", destinations: ["slack"], enabled: true },
{ alert_id: "a2", name: "mem", destinations: ["slack"], enabled: true },
],
[{ name: "slack", type: "http" }],
[],
);
expect(byName(nodes, "slack", "destination").usageCount).toBe(2);
});
});
describe("buildFocusChain", () => {
// slack ← tpl-http, used by cpu + mem; pager ← tpl-http too (sibling branch).
const graph = buildGraph(
[
{ alert_id: "a1", name: "cpu", destinations: ["slack"], enabled: true },
{ alert_id: "a2", name: "mem", destinations: ["slack"], enabled: true },
{ alert_id: "a3", name: "disk", destinations: ["pager"], enabled: true },
],
[
{ name: "slack", type: "http", template: "tpl-http" },
{ name: "pager", type: "http", template: "tpl-http" },
],
[{ name: "tpl-http", type: "http" }],
);
it("destination focus = its template + its alerts, NOT sibling destinations", () => {
const chain = buildFocusChain(graph, { kind: "destination", name: "slack" });
expect(chain.focusNode?.name).toBe("slack");
expect(chain.templates.map((n) => n.name)).toEqual(["tpl-http"]);
expect(chain.alerts.map((n) => n.name).sort()).toEqual(["cpu", "mem"]);
// The shared template must NOT drag in 'pager' or its alert 'disk'.
expect(chain.destinations.map((n) => n.name)).toEqual(["slack"]);
expect(chain.alerts.map((n) => n.name)).not.toContain("disk");
});
it("template focus = destinations using it + their alerts", () => {
const chain = buildFocusChain(graph, { kind: "template", name: "tpl-http" });
expect(chain.destinations.map((n) => n.name).sort()).toEqual(["pager", "slack"]);
expect(chain.alerts.map((n) => n.name).sort()).toEqual(["cpu", "disk", "mem"]);
// The destination carries its own alert count.
expect(chain.destinations.find((d) => d.name === "slack")?.alerts.length).toBe(2);
});
it("alert focus = its destination + that destination's template", () => {
const chain = buildFocusChain(graph, { kind: "alert", alertId: "a1", name: "cpu" });
expect(chain.focusNode?.name).toBe("cpu");
expect(chain.destinations.map((n) => n.name)).toEqual(["slack"]);
expect(chain.templates.map((n) => n.name)).toEqual(["tpl-http"]);
const slack = chain.destinations.find((d) => d.name === "slack")!;
// The destination's badge shows its TOTAL usage (cpu + mem), not just the
// focused alert — so it never renders a misleading "0 alerts" on alert focus.
expect(slack.usageCount).toBe(2);
// …and its chain entry now records the focused alert rather than being empty.
expect(slack.alerts.map((n) => n.name)).toEqual(["cpu"]);
});
});
describe("focusSummary", () => {
const graph = 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: ["pager"], enabled: true },
],
[
{ name: "slack", type: "http", template: "tpl-http" },
{ name: "pager", type: "http", template: "tpl-http" },
{ name: "lonely", type: "email" },
],
[{ name: "tpl-http", type: "http" }],
);
it("counts a destination's neighbours (alerts + template), excluding itself", () => {
const { node, counts } = focusSummary(graph, { kind: "destination", name: "slack" });
expect(node?.name).toBe("slack");
expect(counts).toEqual({ templates: 1, destinations: 0, alerts: 2 });
});
it("counts a template's neighbours (destinations + alerts), excluding itself", () => {
const { counts } = focusSummary(graph, { kind: "template", name: "tpl-http" });
expect(counts).toEqual({ templates: 0, destinations: 2, alerts: 3 });
});
it("counts an alert's neighbours (destination + template), excluding itself", () => {
const { counts } = focusSummary(graph, { kind: "alert", alertId: "a1", name: "cpu" });
expect(counts).toEqual({ templates: 1, destinations: 1, alerts: 0 });
});
it("marks an orphan destination (no alerts) and keeps zero counts", () => {
const { node, counts } = focusSummary(graph, { kind: "destination", name: "lonely" });
expect(node?.orphan).toBe(true);
expect(counts.alerts).toBe(0);
});
});
describe("dependency kind helpers", () => {
it("maps each kind to its icon", () => {
expect(depKindIcon("template")).toBe("description");
expect(depKindIcon("destination")).toBe("location-on");
expect(depKindIcon("alert")).toBe("shield-alert-outline");
});
it("colours by state first, then kind", () => {
expect(depKindColor({ kind: "destination", orphan: false, missing: true })).toBe(
"text-status-negative",
);
expect(depKindColor({ kind: "destination", orphan: true, missing: false })).toBe(
"text-status-warning",
);
expect(depKindColor({ kind: "destination", orphan: false, missing: false })).toBe("text-info");
expect(depKindColor({ kind: "alert", orphan: false, missing: false })).toBe(
"text-status-positive",
);
expect(depKindColor({ kind: "template", orphan: false, missing: false })).toBe(
"text-text-secondary",
);
});
});
@@ -0,0 +1,481 @@
/* 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/>.
*/
// Builds the notification dependency graph (Template → Destination → Alert) by
// cross-referencing the three existing list APIs client-side. There is no
// reverse-lookup endpoint: the linkage lives only in `alert.destinations`
// (destination NAMES), `alert.template` (an alert-level template override) and a
// destination's own `template`. We fetch all three lists and match by name.
//
// The alert list DTO now carries `destinations` + `template` (an additive field
// on ListAlertsResponseBodyItem), so the whole graph is three list calls with no
// per-alert detail fetch.
//
// Output is an abstract, layout-free model (nodes carry kind + flags, edges carry
// the relation) so it stays unit-testable; the view owns positioning + rendering.
import { ref } from "vue";
import alertsService from "@/services/alerts";
import destinationService from "@/services/alert_destination";
import templateService from "@/services/alert_templates";
export type DepNodeKind = "template" | "destination" | "alert";
/** One entity to focus the graph on (its dependency chain), used by the popup. */
export type DepFocus = { kind: DepNodeKind; name?: string; alertId?: string };
export type DepRelation = "usage" | "template" | "override";
export interface DepNode {
/** `${kind}:${name}` — stable across rebuilds; alerts key on their id. */
id: string;
kind: DepNodeKind;
/** Display name; also the join key for destinations/templates. */
name: string;
/** Destination/template transport kind (http | email | sns | action). */
transport?: string;
/** Destinations: how many alerts deliver to it. Templates: dest + override refs. */
usageCount: number;
/** A destination with no alerts, or a template referenced by nothing. */
orphan: boolean;
/**
* A destination NAME referenced by an alert but absent from the destination
* list (broken alert), or a template referenced but not defined. This state is
* otherwise invisible today — it only surfaces as a runtime notification miss.
*/
missing: boolean;
/** Alert-only: enabled flag, for a muted look on disabled alerts. */
enabled?: boolean;
/** Alert-only: id + folder, so the view can deep-link to the alert. */
alertId?: string;
folderId?: string;
}
export interface DepEdge {
id: string;
source: string;
target: string;
relation: DepRelation;
}
export interface DepGraph {
nodes: DepNode[];
edges: DepEdge[];
stats: {
templates: number;
destinations: number;
alerts: number;
orphanDestinations: number;
danglingReferences: number;
orphanTemplates: number;
};
}
/** A destination in a focus chain, with the alerts it feeds. */
export type ChainDestination = DepNode & { alerts: DepNode[] };
/** One entity's dependency chain, grouped for the compact panel. */
export interface FocusChain {
focusNode: DepNode | null;
templates: DepNode[];
destinations: ChainDestination[];
/** Flat list of every alert in the chain (paginated by the panel). */
alerts: DepNode[];
}
/** The subset of each list-API row that the graph reads (rows carry more). */
export interface AlertRow {
alert_id?: string;
id?: string;
name: string;
destinations?: string[];
template?: string | null;
enabled?: boolean;
folder_id?: string;
}
export interface DestinationRow {
name: string;
type?: string;
template?: string | null;
}
export interface TemplateRow {
name: string;
type?: string;
}
// Resolve one entity's dependency chain from the full graph, traversed
// DIRECTIONALLY so a shared template pulls in only the focused branch (never
// sibling destinations). Pure + exported so the panel stays thin and testable.
export function buildFocusChain(graph: DepGraph, focus: DepFocus): FocusChain {
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
let start: string | null = null;
if (focus.kind === "alert") {
const n = graph.nodes.find(
(x) => x.kind === "alert" && (x.alertId === focus.alertId || x.name === focus.name),
);
start = n?.id ?? null;
} else {
const id = `${focus.kind}:${focus.name}`;
start = graph.nodes.some((x) => x.id === id) ? id : null;
}
// Build per-relation adjacency maps once (O(edges)) so each traversal below is a
// handful of O(1) lookups instead of rescanning every edge per node — the earlier
// nested scans were O(dests × edges) and grew badly on large orgs.
const push = (m: Map<string, string[]>, k: string, v: string) => {
const arr = m.get(k);
if (arr) arr.push(v);
else m.set(k, [v]);
};
const usageBySource = new Map<string, string[]>(); // destination → its alerts
const usageByTarget = new Map<string, string[]>(); // alert → its destinations
const templateBySource = new Map<string, string[]>(); // template → destinations
const templateByTarget = new Map<string, string[]>(); // destination → templates
const overrideBySource = new Map<string, string[]>(); // template → override alerts
const overrideByTarget = new Map<string, string[]>(); // alert → override templates
for (const e of graph.edges) {
if (e.relation === "usage") {
push(usageBySource, e.source, e.target);
push(usageByTarget, e.target, e.source);
} else if (e.relation === "template") {
push(templateBySource, e.source, e.target);
push(templateByTarget, e.target, e.source);
} else if (e.relation === "override") {
push(overrideBySource, e.source, e.target);
push(overrideByTarget, e.target, e.source);
}
}
const ids = new Set<string>();
const destAlerts = new Map<string, string[]>();
const addAlertToDest = (destId: string, alertId: string) => push(destAlerts, destId, alertId);
if (start) {
ids.add(start);
if (focus.kind === "destination") {
for (const tpl of templateByTarget.get(start) ?? []) ids.add(tpl);
for (const alert of usageBySource.get(start) ?? []) {
ids.add(alert);
addAlertToDest(start, alert);
for (const tpl of overrideByTarget.get(alert) ?? []) ids.add(tpl);
}
} else if (focus.kind === "template") {
for (const dest of templateBySource.get(start) ?? []) {
ids.add(dest);
for (const alert of usageBySource.get(dest) ?? []) {
ids.add(alert);
addAlertToDest(dest, alert);
}
}
for (const alert of overrideBySource.get(start) ?? []) ids.add(alert);
} else {
for (const dest of usageByTarget.get(start) ?? []) {
ids.add(dest);
// Record the focused alert against the destination so its chain entry is
// truthful (not empty); the row badge shows the destination's TOTAL usage.
addAlertToDest(dest, start);
for (const tpl of templateByTarget.get(dest) ?? []) ids.add(tpl);
}
for (const tpl of overrideByTarget.get(start) ?? []) ids.add(tpl);
}
}
const chain = graph.nodes.filter((n) => ids.has(n.id));
return {
focusNode: start ? (byId.get(start) ?? null) : null,
templates: chain.filter((n) => n.kind === "template"),
destinations: chain
.filter((n) => n.kind === "destination")
.map((d) => ({
...d,
alerts: (destAlerts.get(d.id) ?? []).map((id) => byId.get(id)!).filter(Boolean),
})),
alerts: chain.filter((n) => n.kind === "alert"),
};
}
/** Counts of an entity's chain neighbours, by kind, EXCLUDING the entity itself. */
export interface DepCounts {
templates: number;
destinations: number;
alerts: number;
}
// Per-kind icon + colour for a dependency node — the single source shared by the
// "where used" rows and the list "Used by" column so a kind reads the same in both.
export const depKindIcon = (kind: DepNodeKind): string =>
kind === "template"
? "description"
: kind === "destination"
? "location-on"
: "shield-alert-outline";
export const depKindColor = (node: Pick<DepNode, "kind" | "orphan" | "missing">): string => {
if (node.missing) return "text-status-negative";
if (node.orphan) return "text-status-warning";
return node.kind === "destination"
? "text-info"
: node.kind === "alert"
? "text-status-positive"
: "text-text-secondary";
};
/** The focus entity's node plus its chain-neighbour counts, in one pass. */
export interface FocusSummary {
node: DepNode | null;
counts: DepCounts;
}
// Everything the "Used by" cell needs: the entity's own node (for its
// orphan/missing state) and its chain-neighbour counts (what the popover shows),
// minus the entity itself. Reuses buildFocusChain so the column and the popover
// can never disagree.
export function focusSummary(graph: DepGraph, focus: DepFocus): FocusSummary {
const chain = buildFocusChain(graph, focus);
const selfId = chain.focusNode?.id ?? null;
const notSelf = (n: DepNode) => n.id !== selfId;
return {
node: chain.focusNode,
counts: {
templates: chain.templates.filter(notSelf).length,
destinations: chain.destinations.filter(notSelf).length,
alerts: chain.alerts.filter(notSelf).length,
},
};
}
const tplId = (name: string) => `template:${name}`;
const dstId = (name: string) => `destination:${name}`;
const alrId = (id: string) => `alert:${id}`;
const emptyGraph = (): DepGraph => ({
nodes: [],
edges: [],
stats: {
templates: 0,
destinations: 0,
alerts: 0,
orphanDestinations: 0,
danglingReferences: 0,
orphanTemplates: 0,
},
});
// The graph is identical for every popover in an org and costs three list calls
// (up to the org's full alert list), so build it once and reuse it across opens,
// rows and list pages instead of re-downloading it on every popover open.
// Correctness comes from explicit invalidation — the popover's delete AND every
// list page's refresh (post add/edit/delete) call invalidateDependencyGraphCache()
// — so this TTL only backstops changes made in another tab/session; kept long
// because same-session mutations already drop the cache immediately.
const GRAPH_TTL_MS = 300_000;
let graphCache: { org: string; graph: DepGraph; at: number } | null = null;
/** Drop the cached graph so the next open refetches (call after a mutation). */
export function invalidateDependencyGraphCache() {
graphCache = null;
}
export function useDependencyGraph() {
const graph = ref<DepGraph>(emptyGraph());
const loading = ref(false);
const error = ref<string | null>(null);
// Pure builder — separated from the fetch so it can be unit-tested with fixtures.
const buildGraph = (
alerts: AlertRow[],
destinations: DestinationRow[],
templates: TemplateRow[],
): DepGraph => {
const nodes = new Map<string, DepNode>();
const edges: DepEdge[] = [];
const templateNode = (name: string): DepNode => {
const id = tplId(name);
let node = nodes.get(id);
if (!node) {
node = { id, kind: "template", name, usageCount: 0, orphan: false, missing: true };
nodes.set(id, node);
}
return node;
};
const destinationNode = (name: string): DepNode => {
const id = dstId(name);
let node = nodes.get(id);
if (!node) {
node = { id, kind: "destination", name, usageCount: 0, orphan: false, missing: true };
nodes.set(id, node);
}
return node;
};
// Seed the DEFINED templates + destinations first, so `missing` starts false
// for anything that actually exists; still-missing nodes below are dangling
// references discovered only through an alert/destination pointing at them.
for (const tpl of templates) {
const node = templateNode(tpl.name);
node.missing = false;
node.transport = tpl.type;
}
for (const dst of destinations) {
const node = destinationNode(dst.name);
node.missing = false;
node.transport = dst.type;
// Destination's default template (used unless an alert overrides it).
if (dst.template) {
const tpl = templateNode(dst.template);
tpl.usageCount += 1;
edges.push({
id: `e:tpl:${dst.template}->dst:${dst.name}`,
source: tpl.id,
target: node.id,
relation: "template",
});
}
}
// Alerts are the demand side: each `destinations` name is a usage edge, and an
// `alert.template` is a dashed override edge straight to the template.
for (const alert of alerts) {
const id = alrId(alert.alert_id ?? alert.id ?? alert.name);
const alertNode: DepNode = {
id,
kind: "alert",
name: alert.name,
usageCount: 0,
orphan: false,
missing: false,
enabled: alert.enabled !== false,
alertId: alert.alert_id ?? alert.id,
folderId: alert.folder_id,
};
nodes.set(id, alertNode);
for (const destName of alert.destinations ?? []) {
const dst = destinationNode(destName);
dst.usageCount += 1;
edges.push({
id: `e:dst:${destName}->alr:${id}`,
source: dst.id,
target: id,
relation: "usage",
});
}
if (alert.template) {
const tpl = templateNode(alert.template);
tpl.usageCount += 1;
edges.push({
id: `e:tpl:${alert.template}->alr:${id}`,
source: tpl.id,
target: id,
relation: "override",
});
}
}
// Derive the flag states now that every reference has been counted.
let orphanDestinations = 0;
let danglingReferences = 0;
let orphanTemplates = 0;
for (const node of nodes.values()) {
if (node.kind === "destination") {
node.orphan = !node.missing && node.usageCount === 0;
if (node.orphan) orphanDestinations += 1;
if (node.missing) danglingReferences += 1;
} else if (node.kind === "template") {
node.orphan = !node.missing && node.usageCount === 0;
if (node.orphan) orphanTemplates += 1;
if (node.missing) danglingReferences += 1;
}
}
return {
nodes: [...nodes.values()],
edges,
stats: {
templates: templates.length,
destinations: destinations.length,
alerts: alerts.length,
orphanDestinations,
danglingReferences,
orphanTemplates,
},
};
};
const loadGraph = async (org: string) => {
if (graphCache && graphCache.org === org && Date.now() - graphCache.at < GRAPH_TTL_MS) {
graph.value = graphCache.graph;
loading.value = false;
error.value = null;
return;
}
loading.value = true;
error.value = null;
try {
const [alertsRes, destinationsRes, templatesRes] = await Promise.all([
// v2 list (no folder = every folder). The v1 GET /api/{org}/alerts is not
// registered in all builds (404), and only v2's item DTO carries the
// destinations/template fields we cross-reference.
// NB: this route does NOT paginate — the service never forwards page_num/
// page_size, so it returns the org's full alert list. That's intentional: a
// complete graph needs every alert. The leading 1/0 are placeholder args for
// the shared signature, not a real bound; the per-org cache above keeps this
// full fetch from repeating on every popover open. The trailing `true` opts
// in to destinations/template, which the backend omits from the default path.
alertsService.listByFolderId(
1,
0,
"name",
false,
"",
org,
undefined,
undefined,
undefined,
true,
),
destinationService.list({
page_num: 1,
page_size: 100000,
sort_by: "name",
desc: false,
org_identifier: org,
module: "alert",
}),
templateService.list({ org_identifier: org }),
]);
const alerts = alertsRes.data?.list ?? alertsRes.data ?? [];
const destinations = destinationsRes.data ?? [];
const templates = templatesRes.data ?? [];
graph.value = buildGraph(alerts, destinations, templates);
graphCache = { org, graph: graph.value, at: Date.now() };
} catch (err: any) {
error.value = err?.response?.data?.message || err?.message || "unknown";
graph.value = emptyGraph();
} finally {
loading.value = false;
}
};
return { graph, loading, error, loadGraph, buildGraph };
}
export default useDependencyGraph;
+2
View File
@@ -237,6 +237,7 @@ import HelpIcon from "~icons/material-symbols/help-outline";
import HistoryToggleOffIcon from "~icons/material-symbols/history-toggle-off";
import HomeIcon from "~icons/mdi/home-outline";
import OrganizationIcon from "~icons/octicon/organization-16";
import Graph1Icon from "~icons/material-symbols/graph-1";
import Graph2Icon from "~icons/material-symbols/graph-2";
import HubIcon from "~icons/material-symbols/hub-outline";
import ImageIcon from "~icons/material-symbols/image-outline";
@@ -487,6 +488,7 @@ export const iconRegistry = {
dns: Dns,
"fork-right": ForkRight,
"git-branch": ForkRight,
"graph-1": Graph1Icon,
"graph-2": Graph2Icon,
flame: Whatshot,
whatshot: Whatshot,
+26
View File
@@ -6483,6 +6483,32 @@
"sloShortWindow": "Short window",
"sloCloneDisabled": "SLO alerts are created on their SLO page, so they cannot be cloned from here."
},
"alert_dependencies": {
"actionOpen": "Open",
"actionDelete": "Delete",
"deleteTitle": "Delete {name}?",
"deleteMessage": "This permanently removes it. Deletion is blocked if an alert or destination still uses it.",
"deletedToast": "{name} deleted",
"deleteFailedToast": "Couldn't delete {name}",
"loading": "Building dependency graph…",
"failedToLoad": "Failed to load dependencies: {error}",
"usedBy": "{count} alert | {count} alerts",
"countDestinations": "{count} destination | {count} destinations",
"usedByColumn": "Used by",
"searchPlaceholder": "Search",
"noDependencies": "No dependencies.",
"noMatches": "No matches.",
"laneEmpty": "No alerts",
"directOverrides": "Uses this template directly",
"sectionDestinations": "Destinations",
"sectionAlerts": "Alerts",
"orphanTag": "Unused",
"missingTag": "Missing",
"enabled": "Enabled",
"paused": "Paused",
"impactTemplate": "Used by {destinations} · {alerts}",
"impactDestination": "Used by {alerts}"
},
"alert_templates": {
"exportTemplate": "Export Template",
"selected": "selected",
+6
View File
@@ -44,6 +44,7 @@ const alerts = {
folder_id?: string,
query?: string,
alert_type?: string,
include_dependencies?: boolean,
) => {
let url = `/api/v2/${org_identifier}/alerts?sort_by=${sort_by}&desc=${desc}&name=${name}`;
if (folder_id) {
@@ -55,6 +56,11 @@ const alerts = {
if (alert_type) {
url += `&alert_type=${alert_type}`;
}
// Opt in to the destinations/template fields (dependency view only) — the
// backend keeps them off the default list response otherwise.
if (include_dependencies) {
url += `&include_dependencies=true`;
}
return http().get(url);
},
create: (org_identifier: string, stream_name: string, stream_type: string, data: any) => {