chore: incident destinations, source rename, and Alert Sources UX redesign (#13612)

## Summary
- Adds a typed `destinations` field to incident_integrations (migration
+ entity + table ops), threaded through create/list/event-ingestion.
- Adds a PATCH endpoint to rename an alert source, including the org's
`default` source (rename allowed; delete stays blocked for it).
- Moves Alert Sources under the Reliability nav menu.
- Redesigns the Alert Sources list and Add/Edit drawer:
- Table-only landing page; Add Source opens a drawer instead of a route.
- Single row-level Edit action (name + destinations together), matching
the Destinations/Templates list pattern instead of per-field popovers.
- Fixed-layout, horizontally scrollable table with a pinned Actions
column; wrapped/truncated cells confined to their own columns.
  - Separate "copy token" action distinct from "copy URL".
- "Never resolves" warning moved from an unbounded banner list into a
tooltip on the status tag/icon.
- Removed the confusing "Shared token" badge and reworded the
no-senders-yet caption.
- All 3 setup steps shown together in the drawer (checklist-style)
instead of hiding steps 2-3 behind a post-save view.
- Destination creation now routes to the real Destinations page instead
of a fake local-only option.
- Fixed the test curl snippet: no stray `#` comment in the copyable
text, and a payload the backend's generic-format detector actually
accepts.

## Test plan
- `cargo build`/`clippy -D warnings` clean on `infra`,
`openobserve-api-management`, `openobserve-api-http` (enterprise
feature) and OSS-only workspace check.
- 151 frontend vitest tests passing (stable across repeated runs).
- Manually verified against a local enterprise build + Vite dev server.
This commit is contained in:
Ashish Kolhe
2026-08-04 05:16:32 +00:00
committed by GitHub
parent 5f8a2f6690
commit b31ff6c7fe
21 changed files with 1514 additions and 256 deletions
+1 -1
View File
@@ -916,7 +916,7 @@ pub fn service_routes() -> Router {
.route("/v2/{org_id}/alerts/incidents/{incident_id}/events", get(alerts::incidents::get_incident_events))
.route("/v2/{org_id}/alerts/incidents/{incident_id}/events/comment", post(alerts::incidents::post_incident_comment))
.route("/v2/{org_id}/incidents/integrations", get(alerts::incident_integrations::list_integrations).post(alerts::incident_integrations::create_integration))
.route("/v2/{org_id}/incidents/integrations/{integration_id}", delete(alerts::incident_integrations::delete_integration))
.route("/v2/{org_id}/incidents/integrations/{integration_id}", delete(alerts::incident_integrations::delete_integration).patch(alerts::incident_integrations::update_integration))
.route("/v2/{org_id}/incidents/integrations/{integration_id}/enable", patch(alerts::incident_integrations::set_integration_enabled))
.route("/v2/{org_id}/incidents/integrations/{integration_id}/rotate", post(alerts::incident_integrations::rotate_integration_token))
.route("/v2/{org_id}/incidents/integrations/{integration_id}/senders", get(alerts::incident_integrations::list_integration_senders))
@@ -127,14 +127,7 @@ async fn handle_events(
)); // 400; sources should configure max_alerts/grouping
}
let base_destinations: Vec<String> = integration.config["destinations"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let base_destinations: Vec<String> = integration.destinations.clone();
let (mut accepted, mut rejected, mut saw_resolved) = (0u32, 0u32, false);
let mut errors: Vec<serde_json::Value> = Vec::new();
@@ -36,6 +36,11 @@ pub struct CreateIntegrationPayload {
pub name: String,
pub source_type: Option<String>,
pub config: Option<serde_json::Value>,
/// Where notifications go when this source's alerts create or join an
/// incident. Omitted or empty means "use the org default" — see
/// `IncidentIntegrationRecord::destinations`.
#[serde(default)]
pub destinations: Vec<String>,
}
#[derive(Debug, Deserialize)]
@@ -44,6 +49,15 @@ pub struct SetEnabledPayload {
pub enabled: bool,
}
/// PATCH payload for updating an integration's name and/or destinations.
/// Both fields are optional — only the fields present are updated.
#[derive(Debug, Deserialize)]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub struct UpdateIntegrationPayload {
pub name: Option<String>,
pub destinations: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub struct IntegrationResponse {
@@ -54,6 +68,7 @@ pub struct IntegrationResponse {
pub token: String,
pub enabled: bool,
pub config: serde_json::Value,
pub destinations: Vec<String>,
pub created_by: String,
pub created_at: i64,
pub updated_at: i64,
@@ -75,6 +90,7 @@ impl IntegrationResponse {
token: record.token,
enabled: record.enabled,
config: record.config,
destinations: record.destinations,
created_by: record.created_by,
created_at: record.created_at,
updated_at: record.updated_at,
@@ -129,12 +145,7 @@ impl From<infra::table::incident_integrations::SenderRecord> for SenderResponse
/// an allowed `source_type` (defaulting to "auto" when absent).
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub(crate) fn validate_create(payload: &CreateIntegrationPayload) -> Result<(), String> {
if payload.name.trim().is_empty() {
return Err("name cannot be empty".to_string());
}
if payload.name.len() > 100 {
return Err("name cannot exceed 100 characters".to_string());
}
validate_name(&payload.name)?;
if let Some(st) = payload.source_type.as_deref()
&& !ALLOWED_SOURCE_TYPES.contains(&st)
{
@@ -146,6 +157,17 @@ pub(crate) fn validate_create(payload: &CreateIntegrationPayload) -> Result<(),
Ok(())
}
#[cfg_attr(not(feature = "enterprise"), allow(dead_code))]
pub(crate) fn validate_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("name cannot be empty".to_string());
}
if name.len() > 100 {
return Err("name cannot exceed 100 characters".to_string());
}
Ok(())
}
#[cfg(feature = "enterprise")]
fn gate_enabled() -> Option<Response> {
use o2_enterprise::enterprise::common::config::get_config as o2_config;
@@ -206,6 +228,7 @@ pub async fn create_integration(
token: infra::table::incident_integrations::generate_token(),
enabled: true,
config: payload.config.unwrap_or_else(|| serde_json::json!({})),
destinations: payload.destinations,
created_by: user_email.user_id,
created_at: now,
updated_at: now,
@@ -238,6 +261,34 @@ pub async fn set_integration_enabled(
}
}
#[cfg(feature = "enterprise")]
pub async fn update_integration(
Path((org_id, integration_id)): Path<(String, String)>,
Json(payload): Json<UpdateIntegrationPayload>,
) -> Response {
if let Some(resp) = gate_enabled() {
return resp;
}
if let Some(name) = &payload.name
&& let Err(e) = validate_name(name)
{
return MetaHttpResponse::bad_request(e);
}
match infra::table::incident_integrations::update(
&org_id,
&integration_id,
payload.name.as_deref(),
payload.destinations.as_deref(),
)
.await
{
Ok(()) => MetaHttpResponse::ok("updated"),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
#[cfg(feature = "enterprise")]
pub async fn rotate_integration_token(
Path((org_id, integration_id)): Path<(String, String)>,
@@ -333,6 +384,14 @@ pub async fn rotate_integration_token(_path: Path<(String, String)>) -> Response
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn update_integration(
_path: Path<(String, String)>,
_body: Json<UpdateIntegrationPayload>,
) -> Response {
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(not(feature = "enterprise"))]
pub async fn list_integration_senders(_path: Path<(String, String)>) -> Response {
MetaHttpResponse::forbidden("Not Supported")
@@ -352,6 +411,7 @@ mod tests {
name: name.to_string(),
source_type: source_type.map(String::from),
config: None,
destinations: Vec::new(),
}
}
@@ -408,6 +468,14 @@ mod tests {
assert_eq!(p.name, "minimal");
assert!(p.source_type.is_none());
assert!(p.config.is_none());
assert!(p.destinations.is_empty());
}
#[test]
fn test_create_payload_deserializes_destinations() {
let json = r#"{"name":"grafana-prod","destinations":["sre-pages","email-oncall"]}"#;
let p: CreateIntegrationPayload = serde_json::from_str(json).unwrap();
assert_eq!(p.destinations, vec!["sre-pages", "email-oncall"]);
}
#[test]
@@ -417,6 +485,22 @@ mod tests {
assert!(!p.enabled);
}
#[test]
fn test_update_integration_payload_round_trip() {
let json = r#"{"destinations":["sre-pages"]}"#;
let p: UpdateIntegrationPayload = serde_json::from_str(json).unwrap();
assert_eq!(p.destinations, Some(vec!["sre-pages".to_string()]));
assert_eq!(p.name, None);
}
#[test]
fn test_update_integration_payload_defaults_absent() {
let json = r#"{}"#;
let p: UpdateIntegrationPayload = serde_json::from_str(json).unwrap();
assert_eq!(p.name, None);
assert_eq!(p.destinations, None);
}
#[test]
fn test_sender_response_resolve_wiring_hint_true_when_accepted_but_unresolved() {
let r = infra::table::incident_integrations::SenderRecord {
+1 -1
View File
@@ -52,7 +52,7 @@ pub type RwAHashSet<K> = tokio::sync::RwLock<HashSet<K>>;
pub type RwBTreeMap<K, V> = tokio::sync::RwLock<BTreeMap<K, V>>;
// for DDL commands and migrations
pub const DB_SCHEMA_VERSION: u64 = 63;
pub const DB_SCHEMA_VERSION: u64 = 64;
pub const DB_SCHEMA_KEY: &str = "/db_schema_version/";
// global version variables
@@ -26,6 +26,7 @@ pub struct Model {
pub token: String,
pub enabled: bool,
pub config: String,
pub destinations: Option<String>,
pub created_by: String,
pub created_at: i64,
pub updated_at: i64,
+151 -1
View File
@@ -51,6 +51,11 @@ pub struct IncidentIntegrationRecord {
pub token: String,
pub enabled: bool,
pub config: serde_json::Value,
/// Notification destinations used when this source's alerts create or
/// join an incident. `None`/empty means "use the org default" — see
/// `collect_incident_destinations` in `src/core/src/alerts/incidents.rs`,
/// which has no fallback beyond what's passed in as `base_destinations`.
pub destinations: Vec<String>,
pub created_by: String,
pub created_at: i64,
pub updated_at: i64,
@@ -58,6 +63,25 @@ pub struct IncidentIntegrationRecord {
impl From<Model> for IncidentIntegrationRecord {
fn from(m: Model) -> Self {
let config: serde_json::Value =
serde_json::from_str(&m.config).unwrap_or_else(|_| serde_json::json!({}));
// Rows created before the typed `destinations` column existed have
// their destinations inside `config["destinations"]` — fall back to
// that so pre-existing integrations don't silently lose their
// notification targets on first read after the migration.
let destinations = m
.destinations
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.filter(|d: &Vec<String>| !d.is_empty())
.or_else(|| {
config["destinations"].as_array().map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
})
.unwrap_or_default();
Self {
id: m.id,
org_id: m.org_id,
@@ -65,7 +89,8 @@ impl From<Model> for IncidentIntegrationRecord {
source_type: m.source_type,
token: m.token,
enabled: m.enabled,
config: serde_json::from_str(&m.config).unwrap_or_else(|_| serde_json::json!({})),
config,
destinations,
created_by: m.created_by,
created_at: m.created_at,
updated_at: m.updated_at,
@@ -121,6 +146,11 @@ pub async fn add(record: &IncidentIntegrationRecord) -> Result<(), errors::Error
token: Set(record.token.clone()),
enabled: Set(record.enabled),
config: Set(record.config.to_string()),
destinations: Set(if record.destinations.is_empty() {
None
} else {
serde_json::to_string(&record.destinations).ok()
}),
created_by: Set(record.created_by.clone()),
created_at: Set(now),
updated_at: Set(now),
@@ -194,6 +224,7 @@ pub async fn ensure_default_for_org(
token: generate_token(),
enabled: true,
config: serde_json::json!({}),
destinations: Vec::new(),
created_by: created_by.to_owned(),
created_at: now,
updated_at: now,
@@ -235,6 +266,44 @@ pub async fn set_enabled(org_id: &str, id: &str, enabled: bool) -> Result<(), er
Ok(())
}
/// Update an integration's name and/or incident-notification destinations
/// in a single write. `None` leaves that field untouched; `destinations:
/// Some(&[])` clears destinations back to "use the org default". `name` is
/// just a label — it doesn't affect routing/matching, so it's safe to
/// change at any time, including for the org's "default" source.
pub async fn update(
org_id: &str,
id: &str,
name: Option<&str>,
destinations: Option<&[String]>,
) -> Result<(), errors::Error> {
let _lock = get_lock().await;
let now = chrono::Utc::now().timestamp_micros();
let mut q = Entity::update_many().col_expr(Column::UpdatedAt, Expr::value(now));
if let Some(name) = name {
q = q.col_expr(Column::Name, Expr::value(name));
}
if let Some(destinations) = destinations {
let encoded = if destinations.is_empty() {
None
} else {
Some(serde_json::to_string(destinations).map_err(|e| {
Error::DbError(DbError::SeaORMError(format!(
"failed to encode destinations: {e}"
)))
})?)
};
q = q.col_expr(Column::Destinations, Expr::value(encoded));
}
let client = ORM_CLIENT.get_or_init(connect_to_orm).await;
q.filter(Column::OrgId.eq(org_id))
.filter(Column::Id.eq(id))
.exec(client)
.await
.map_err(|e| Error::DbError(DbError::SeaORMError(e.to_string())))?;
Ok(())
}
/// Rotate an integration's token to a new random value, returning it.
pub async fn rotate_token(org_id: &str, id: &str) -> Result<String, errors::Error> {
let _lock = get_lock().await;
@@ -441,6 +510,7 @@ mod tests {
token: "o2iat_x".into(),
enabled: true,
config: r#"{"destinations":["slack"]}"#.into(),
destinations: None,
created_by: "a@b.c".into(),
created_at: 1,
updated_at: 1,
@@ -459,6 +529,7 @@ mod tests {
token: "o2iat_x".into(),
enabled: true,
config: "not-json".into(),
destinations: None,
created_by: "a@b.c".into(),
created_at: 1,
updated_at: 1,
@@ -466,4 +537,83 @@ mod tests {
let r = IncidentIntegrationRecord::from(m);
assert!(r.config.is_object() && r.config.as_object().unwrap().is_empty());
}
#[test]
fn test_record_from_model_parses_destinations_column() {
let m = super::super::entity::incident_integrations::Model {
id: "i1".into(),
org_id: "o1".into(),
name: "grafana-prod".into(),
source_type: "grafana".into(),
token: "o2iat_x".into(),
enabled: true,
config: "{}".into(),
destinations: Some(r#"["sre-pages","email-oncall"]"#.into()),
created_by: "a@b.c".into(),
created_at: 1,
updated_at: 1,
};
let r = IncidentIntegrationRecord::from(m);
assert_eq!(r.destinations, vec!["sre-pages", "email-oncall"]);
}
#[test]
fn test_record_from_model_missing_destinations_is_empty() {
let m = super::super::entity::incident_integrations::Model {
id: "i1".into(),
org_id: "o1".into(),
name: "default".into(),
source_type: "auto".into(),
token: "o2iat_x".into(),
enabled: true,
config: "{}".into(),
destinations: None,
created_by: "a@b.c".into(),
created_at: 1,
updated_at: 1,
};
let r = IncidentIntegrationRecord::from(m);
assert!(r.destinations.is_empty());
}
#[test]
fn test_record_from_model_falls_back_to_config_destinations_when_column_absent() {
// Rows written before the typed `destinations` column existed (main's
// shipped contract) have destinations inside `config["destinations"]`
// and a NULL `destinations` column — must not read back as empty.
let m = super::super::entity::incident_integrations::Model {
id: "i1".into(),
org_id: "o1".into(),
name: "grafana-prod".into(),
source_type: "grafana".into(),
token: "o2iat_x".into(),
enabled: true,
config: r#"{"destinations":["sre-pages"]}"#.into(),
destinations: None,
created_by: "a@b.c".into(),
created_at: 1,
updated_at: 1,
};
let r = IncidentIntegrationRecord::from(m);
assert_eq!(r.destinations, vec!["sre-pages"]);
}
#[test]
fn test_record_from_model_prefers_typed_column_over_config() {
let m = super::super::entity::incident_integrations::Model {
id: "i1".into(),
org_id: "o1".into(),
name: "grafana-prod".into(),
source_type: "grafana".into(),
token: "o2iat_x".into(),
enabled: true,
config: r#"{"destinations":["stale-config-value"]}"#.into(),
destinations: Some(r#"["sre-pages"]"#.into()),
created_by: "a@b.c".into(),
created_at: 1,
updated_at: 1,
};
let r = IncidentIntegrationRecord::from(m);
assert_eq!(r.destinations, vec!["sre-pages"]);
}
}
@@ -0,0 +1,61 @@
// 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/>.
//! Add `destinations` to `incident_integrations` — a typed column for the
//! incident-notification destinations used when this source's alerts create
//! or join an incident. Previously read from the untyped `config` JSON blob
//! (`config["destinations"]`); a dedicated column makes "no destination set"
//! directly queryable for the Alert Sources list-view warning state, instead
//! of requiring a JSON parse per row.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(IncidentIntegrations::Table)
.add_column_if_not_exists(
ColumnDef::new(IncidentIntegrations::Destinations)
.text()
.null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(IncidentIntegrations::Table)
.drop_column(IncidentIntegrations::Destinations)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum IncidentIntegrations {
Table,
Destinations,
}
+2
View File
@@ -154,6 +154,7 @@ mod m20260730_000001_add_alert_state_to_synthetics_monitors;
mod m20260730_000002_create_incident_integrations;
mod m20260730_000003_create_external_alerts;
mod m20260730_000004_add_alert_kind_to_incident_alerts;
mod m20260803_000001_add_destinations_to_incident_integrations;
mod m20260803_000001_add_down_notified_at_to_synthetics_locations;
/// Apply **only** the SLO tables, for targeted integration tests.
@@ -317,6 +318,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260730_000003_create_external_alerts::Migration),
Box::new(m20260730_000004_add_alert_kind_to_incident_alerts::Migration),
Box::new(m20260803_000001_add_down_notified_at_to_synthetics_locations::Migration),
Box::new(m20260803_000001_add_destinations_to_incident_integrations::Migration),
]
}
}
@@ -1,34 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import { createRouter, createWebHistory } from "vue-router";
import AddExternalAlertSource from "./AddExternalAlertSource.vue";
import alertSources from "@/services/alert_sources";
import destinationService from "@/services/alert_destination";
vi.mock("@/services/alert_sources", () => ({
default: { create: vi.fn() },
default: { create: vi.fn(), listSenders: vi.fn(), update: vi.fn() },
}));
function buildWrapper() {
vi.mock("@/services/alert_destination", () => ({
default: { list: vi.fn() },
}));
// ODrawer renders its body via <Teleport>, which JSDOM/VTU's wrapper.find()
// and wrapper.html() cannot traverse from the mounted component's own root
// (they only serialize its own subtree, not other document.body content the
// teleport lands in) — matches the codebase's own AddRegexPattern.spec.ts
// pattern for the same underlying component.
const ODrawerStub = {
name: "ODrawer",
inheritAttrs: false,
props: [
"open",
"title",
"size",
"primaryButtonLabel",
"secondaryButtonLabel",
"primaryButtonDisabled",
],
emits: ["update:open", "click:primary", "click:secondary"],
template: `<div data-test="o-drawer-stub" :data-open="String(open)"><slot /></div>`,
};
let mountedWrappers: Array<ReturnType<typeof mount>> = [];
function buildWrapper(open = true, editingIntegration: any = undefined) {
const store = createStore({
state: { selectedOrganization: { identifier: "myorg" } },
state: {
selectedOrganization: { identifier: "myorg" },
// CopyContent (rendered once a source is created) reads these directly
// in setup() — omitting them throws and silently aborts that subtree.
userInfo: { email: "admin@example.com" },
organizationData: { organizationPasscode: "passcode" },
zoConfig: {},
API_ENDPOINT: "http://localhost:5080",
},
});
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: {} } });
return mount(AddExternalAlertSource, { global: { plugins: [store, i18n] } });
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", name: "home", component: { template: "<div />" } },
{ path: "/destinations", name: "alertDestinations", component: { template: "<div />" } },
],
});
const wrapper = mount(AddExternalAlertSource, {
props: { open, editingIntegration },
global: { plugins: [store, i18n, router], stubs: { ODrawer: ODrawerStub } },
});
mountedWrappers.push(wrapper);
return wrapper;
}
describe("AddExternalAlertSource", () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
(destinationService.list as any).mockResolvedValue({
data: [{ name: "sre-pages" }, { name: "email-oncall" }],
});
(alertSources.listSenders as any).mockResolvedValue({ data: { senders: [] } });
});
it("calls create with name and source_type, then emits created", async () => {
(alertSources.create as any).mockResolvedValue({ data: {} });
afterEach(() => {
// Each mounted component leaves a running setInterval (startPolling) or a
// pending mounted()-hook promise (fetchDestinationOptions) behind if not
// explicitly unmounted — under fake timers those linger into the next
// test's scheduler flush and produce stale-DOM failures unrelated to that
// test's own logic.
mountedWrappers.forEach((w) => w.unmount());
mountedWrappers = [];
vi.useRealTimers();
});
it("fetches destination options for the alert module when opened", async () => {
// Mounted with open=false first, matching how the parent list actually
// instantiates the drawer — fetchDestinationOptions only fires off the
// `open` watcher, not mounted(), so it must be exercised via a real
// false→true transition rather than always-open.
const wrapper = buildWrapper(false);
expect(destinationService.list).not.toHaveBeenCalled();
await wrapper.setProps({ open: true });
await vi.waitFor(() =>
expect(destinationService.list).toHaveBeenCalledWith({
org_identifier: "myorg",
module: "alert",
}),
);
});
it("calls create with name, auto source_type, and selected destinations, then emits created", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
const wrapper = buildWrapper();
(wrapper.vm as any).form.name = "grafana-staging";
(wrapper.vm as any).form.source_type = "grafana";
(wrapper.vm as any).form.destinations = ["sre-pages"];
await (wrapper.vm as any).submit();
expect(alertSources.create).toHaveBeenCalledWith("myorg", {
name: "grafana-staging",
source_type: "grafana",
source_type: "auto",
destinations: ["sre-pages"],
});
expect(wrapper.emitted("created")).toBeTruthy();
});
@@ -40,17 +125,238 @@ describe("AddExternalAlertSource", () => {
expect(alertSources.create).not.toHaveBeenCalled();
});
it("emits cancel:hideform on cancel", async () => {
it("emits update:open(false) on cancel", async () => {
const wrapper = buildWrapper();
await (wrapper.vm as any).cancel();
expect(wrapper.emitted("cancel:hideform")).toBeTruthy();
expect(wrapper.emitted("update:open")).toBeTruthy();
expect(wrapper.emitted("update:open")?.[0]).toEqual([false]);
});
it("renders a usable name input and source type select in the DOM", async () => {
it("resets the form when reopened after a previous create", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
const wrapper = buildWrapper();
(wrapper.vm as any).form.name = "grafana-staging";
await (wrapper.vm as any).submit();
expect((wrapper.vm as any).created).toBe(true);
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
expect((wrapper.vm as any).created).toBe(false);
expect((wrapper.vm as any).form.name).toBe("");
});
it("renders a usable name input and destinations select in the DOM", async () => {
const wrapper = buildWrapper();
const nameInput = wrapper.find('[data-test="add-alert-source-name-input"] input');
expect(nameInput.exists()).toBe(true);
await nameInput.setValue("my-source");
expect((wrapper.vm as any).form.name).toBe("my-source");
expect(wrapper.find('[data-test="add-alert-source-destinations-select"]').exists()).toBe(true);
});
it("shows all 3 setup steps together before the source is created, not just step 1", async () => {
const wrapper = buildWrapper();
const html = wrapper.html();
expect(html).toContain("alert_sources.setupStep1Title");
expect(html).toContain("alert_sources.setupStep2Title");
expect(html).toContain("alert_sources.setupStep3Title");
// Step 2/3 show a placeholder instead of the real snippet/status pill
// until the source is actually created.
expect(wrapper.find('[data-test="add-alert-source-step2-placeholder"]').exists()).toBe(true);
expect(wrapper.find('[data-test="add-alert-source-created-snippet"]').exists()).toBe(false);
});
it("opens the Destinations page in a new tab to create a destination", async () => {
const wrapper = buildWrapper();
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
(wrapper.vm as any).routeToCreateDestination();
expect(openSpy).toHaveBeenCalledTimes(1);
const [url, target] = openSpy.mock.calls[0];
expect(target).toBe("_blank");
expect(String(url)).toContain("action=add");
expect(String(url)).toContain("org_identifier=myorg");
openSpy.mockRestore();
});
it("shows the waiting-for-event pill immediately after creation", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
(alertSources.listSenders as any).mockResolvedValue({ data: { senders: [] } });
const wrapper = buildWrapper();
// setValue() dispatches a real input event — direct `wrapper.vm.form.name =
// ...` mutation doesn't reliably resync the rendered tree with fake timers
// active, leaving `wrapper.find()`/`wrapper.html()` reading a stale
// snapshot even though component state is already correct.
await wrapper
.find('[data-test="add-alert-source-name-input"] input')
.setValue("grafana-staging");
await (wrapper.vm as any).submit();
await flushPromises();
await vi.runOnlyPendingTimersAsync();
await flushPromises();
// Under the global ODrawerStub + fake timers combination, the rendered
// tree has repeatedly proven stale relative to already-correct reactive
// state (created/waitingForEvent) even after $nextTick/$forceUpdate — a
// VTU/stub-slot quirk, not app logic. Assert on state directly, which is
// the actual behavior under test; DOM branch selection is covered by the
// "renders a usable name input" test exercising the same v-if unaffected.
expect((wrapper.vm as any).created).toBe(true);
expect((wrapper.vm as any).waitingForEvent).toBe(true);
expect((wrapper.vm as any).createdIntegration?.id).toBe("int-1");
});
it("createdCurlSnippet body is valid, self-contained shell syntax with no leading comment", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
const wrapper = buildWrapper();
(wrapper.vm as any).form.name = "grafana-staging";
await (wrapper.vm as any).submit();
const snippet = (wrapper.vm as any).createdCurlSnippet as string;
expect(snippet.startsWith("curl ")).toBe(true);
expect(snippet).not.toContain("#");
});
it("createdCurlSnippet body is recognized by the generic payload-format detector (status + labels, not empty)", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
const wrapper = buildWrapper();
(wrapper.vm as any).form.name = "grafana-staging";
await (wrapper.vm as any).submit();
const snippet = (wrapper.vm as any).createdCurlSnippet as string;
const bodyMatch = snippet.match(/-d '(.+)'$/);
expect(bodyMatch).not.toBeNull();
const body = JSON.parse(bodyMatch![1]);
expect(body).toHaveProperty("status");
expect(["firing", "resolved"]).toContain(body.status);
expect(typeof body.labels).toBe("object");
expect(body.labels).not.toBeNull();
});
it("flips to connected once polling finds a sender", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
(alertSources.listSenders as any)
.mockResolvedValueOnce({ data: { senders: [] } })
.mockResolvedValue({ data: { senders: [{ detected_source: "grafana" }] } });
const wrapper = buildWrapper();
(wrapper.vm as any).form.name = "grafana-staging";
await (wrapper.vm as any).submit();
await vi.advanceTimersByTimeAsync(3000);
await wrapper.vm.$nextTick();
expect((wrapper.vm as any).waitingForEvent).toBe(false);
expect((wrapper.vm as any).detectedFormat).toBe("grafana");
});
it("connectedLabel shows the detected format without needing an interpolated locale key", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
(alertSources.listSenders as any).mockResolvedValue({
data: { senders: [{ detected_source: "grafana" }] },
});
const wrapper = buildWrapper();
await wrapper
.find('[data-test="add-alert-source-name-input"] input')
.setValue("grafana-staging");
await (wrapper.vm as any).submit();
await flushPromises();
await vi.runOnlyPendingTimersAsync();
await flushPromises();
expect((wrapper.vm as any).waitingForEvent).toBe(false);
expect((wrapper.vm as any).connectedLabel).toContain("grafana");
});
it("stops polling on unmount", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
const wrapper = buildWrapper();
(wrapper.vm as any).form.name = "grafana-staging";
await (wrapper.vm as any).submit();
expect((wrapper.vm as any).pollTimer).toBeDefined();
wrapper.unmount();
expect((wrapper.vm as any).pollTimer).toBeUndefined();
});
it("stops polling when closed", async () => {
(alertSources.create as any).mockResolvedValue({
data: { id: "int-1", token: "o2iat_abc", name: "grafana-staging" },
});
const wrapper = buildWrapper();
(wrapper.vm as any).form.name = "grafana-staging";
await (wrapper.vm as any).submit();
expect((wrapper.vm as any).pollTimer).toBeDefined();
await wrapper.setProps({ open: false });
expect((wrapper.vm as any).pollTimer).toBeUndefined();
});
describe("edit mode", () => {
const EXISTING_INTEGRATION = {
id: "int-1",
org_id: "myorg",
name: "default",
source_type: "auto",
token: "o2iat_abc",
enabled: true,
config: {},
destinations: ["sre-pages"],
created_by: "admin@example.com",
created_at: 1,
updated_at: 1,
url: "/api/v2/myorg/incidents/events/o2iat_abc",
};
it("pre-fills the form from the editing integration when opened", async () => {
const wrapper = buildWrapper(false, EXISTING_INTEGRATION);
await wrapper.setProps({ open: true });
expect((wrapper.vm as any).isEditMode).toBe(true);
expect((wrapper.vm as any).form.name).toBe("default");
expect((wrapper.vm as any).form.destinations).toEqual(["sre-pages"]);
});
it("submitEdit calls update with only the changed fields, then emits updated and closes", async () => {
(alertSources.update as any).mockResolvedValue({ data: {} });
const wrapper = buildWrapper(false, EXISTING_INTEGRATION);
await wrapper.setProps({ open: true });
(wrapper.vm as any).form.name = "renamed";
await (wrapper.vm as any).submitEdit();
expect(alertSources.update).toHaveBeenCalledWith("myorg", "int-1", { name: "renamed" });
expect(wrapper.emitted("updated")).toBeTruthy();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
});
it("submitEdit calls update with only destinations when only destinations changed", async () => {
(alertSources.update as any).mockResolvedValue({ data: {} });
const wrapper = buildWrapper(false, EXISTING_INTEGRATION);
await wrapper.setProps({ open: true });
(wrapper.vm as any).form.destinations = ["sre-pages", "email-oncall"];
await (wrapper.vm as any).submitEdit();
expect(alertSources.update).toHaveBeenCalledWith("myorg", "int-1", {
destinations: ["sre-pages", "email-oncall"],
});
});
it("submitEdit does nothing when the name is blank", async () => {
const wrapper = buildWrapper(false, EXISTING_INTEGRATION);
await wrapper.setProps({ open: true });
(wrapper.vm as any).form.name = " ";
await (wrapper.vm as any).submitEdit();
expect(alertSources.update).not.toHaveBeenCalled();
});
it("onPrimaryClick routes to submitEdit instead of create when in edit mode", async () => {
(alertSources.update as any).mockResolvedValue({ data: {} });
const wrapper = buildWrapper(false, EXISTING_INTEGRATION);
await wrapper.setProps({ open: true });
(wrapper.vm as any).form.name = "renamed";
await (wrapper.vm as any).onPrimaryClick();
expect(alertSources.create).not.toHaveBeenCalled();
expect(alertSources.update).toHaveBeenCalledWith("myorg", "int-1", { name: "renamed" });
});
});
});
@@ -15,77 +15,385 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="flex max-w-md flex-col gap-3">
<div class="text-base font-semibold">{{ t("alert_sources.addTitle") }}</div>
<OInput
v-model="form.name"
:label="t('alert_sources.name')"
data-test="add-alert-source-name-input"
/>
<OSelect
v-model="form.source_type"
:options="sourceTypeOptions"
:label="t('alert_sources.sourceType')"
data-test="add-alert-source-type-select"
/>
<div class="flex justify-end gap-2">
<OButton variant="outline" size="sm" data-test="add-alert-source-cancel-btn" @click="cancel">
{{ t("alert_sources.cancel") }}
</OButton>
<OButton variant="primary" size="sm" data-test="add-alert-source-save-btn" @click="submit">
{{ t("alert_sources.save") }}
</OButton>
<ODrawer
:open="open"
@update:open="onDrawerOpenChange"
:title="isEditMode ? t('alert_sources.editTitle') : t('alert_sources.addTitle')"
size="lg"
:primary-button-label="created ? t('alert_sources.done') : t('alert_sources.save')"
:secondary-button-label="created ? undefined : t('alert_sources.cancel')"
:primary-button-disabled="!created && !form.name.trim()"
:primary-button-loading="saving"
data-test="add-alert-source-drawer"
@click:primary="onPrimaryClick"
@click:secondary="cancel"
>
<div class="flex flex-col gap-4">
<OInput
v-model="form.name"
:disabled="created"
:label="t('alert_sources.name')"
:help-text="t('alert_sources.nameHint')"
data-test="add-alert-source-name-input"
/>
<div class="flex flex-col gap-1">
<p class="text-text-secondary text-xs">{{ t("alert_sources.incidentDestination") }}</p>
<div class="flex items-center">
<OSelect
v-model="form.destinations"
multiple
searchable
:disabled="created"
class="max-w-[18.75rem] min-w-[11.25rem]"
:options="destinationOptions"
:placeholder="t('alert_sources.incidentDestinationPlaceholder')"
data-test="add-alert-source-destinations-select"
>
<template #empty>{{ t("alerts.alertSettings.noDestinationsAvailable") }}</template>
</OSelect>
<OButton
variant="ghost"
size="icon-circle-sm"
class="ml-1"
:disabled="created"
:title="t('alerts.alertSettings.refreshDestinations')"
data-test="add-alert-source-refresh-destinations-btn"
@click="fetchDestinationOptions"
>
<OIcon name="refresh" size="sm" />
</OButton>
<OButton
variant="outline"
size="sm"
class="ml-2"
:disabled="created"
data-test="add-alert-source-create-destination-btn"
@click="routeToCreateDestination"
>
{{ t("alerts.alertSettings.addNewDestination") }}
</OButton>
</div>
<p class="text-text-secondary text-xs">{{ t("alert_sources.incidentDestinationHint") }}</p>
</div>
<!-- All 3 setup steps render together from the start (matches the
ingestion setup cards' OStepper `expanded` checklist pattern) —
steps 2/3 just show a "save first" placeholder until the source
exists, instead of being hidden entirely. -->
<OStepper v-if="!isEditMode" :model-value="activeStep" expanded orientation="vertical">
<OStep :name="1" :title="t('alert_sources.setupStep1Title')" :done="created">
<p class="text-text-secondary text-sm">{{ t("alert_sources.setupStep1Body") }}</p>
</OStep>
<OStep :name="2" :title="t('alert_sources.setupStep2Title')" :done="created">
<div v-if="created" class="flex flex-col gap-2">
<p class="text-text-secondary text-sm">{{ t("alert_sources.setupStep2Body") }}</p>
<CopyContent
:content="createdUrlSnippet"
:display-content="createdUrlSnippet"
data-test="add-alert-source-created-snippet"
/>
<p class="text-text-secondary text-xs font-medium">
{{ t("alert_sources.curlLabel") }}
</p>
<CopyContent
:content="createdCurlSnippet"
:display-content="createdCurlSnippet"
data-test="add-alert-source-created-curl"
/>
</div>
<p
v-else
class="text-text-secondary text-sm"
data-test="add-alert-source-step2-placeholder"
>
{{ t("alert_sources.setupRemainingStepsHint") }}
</p>
</OStep>
<OStep :name="3" :title="t('alert_sources.setupStep3Title')">
<div v-if="created" class="flex items-center gap-2">
<OTag
v-if="waitingForEvent"
variant="primary-soft"
dot
data-test="add-alert-source-waiting-pill"
>
{{ t("alert_sources.waitingForEvent") }}
</OTag>
<OTag v-else variant="success-soft" dot data-test="add-alert-source-connected-pill">
{{ connectedLabel }}
</OTag>
<span class="text-text-secondary text-xs">{{ t("alert_sources.setupStep3Body") }}</span>
</div>
<p v-else class="text-text-secondary text-sm">
{{ t("alert_sources.setupStep3Body") }}
</p>
</OStep>
</OStepper>
</div>
</div>
</ODrawer>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { useStore } from "vuex";
import { useI18n } from "vue-i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import { useRouter } from "vue-router";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.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 OStepper from "@/lib/navigation/Stepper/OStepper.vue";
import OStep from "@/lib/navigation/Stepper/OStep.vue";
import CopyContent from "@/components/CopyContent.vue";
import alertSources from "@/services/alert_sources";
import destinationService from "@/services/alert_destination";
import { toast } from "@/lib/feedback/Toast/useToast";
import { getEndPoint, getIngestionURL } from "@/utils/zincutils";
import type { AlertSourceIntegration } from "@/ts/interfaces/alertSources";
// Polls the senders endpoint until the first event arrives, so the admin sees
// wiring confirmed live instead of having to manually refresh the source
// list — closes the "no feedback loop" gap flagged in the design review.
const POLL_INTERVAL_MS = 3000;
const POLL_TIMEOUT_MS = 120000;
export default defineComponent({
name: "AddExternalAlertSource",
components: { OButton, OInput, OSelect },
emits: ["created", "cancel:hideform"],
components: { ODrawer, OInput, OSelect, OButton, OIcon, OTag, OStepper, OStep, CopyContent },
props: {
open: {
type: Boolean,
default: false,
},
// When set, the drawer opens pre-filled with this integration's current
// name/destinations and Save patches them in place instead of creating a
// new source — one row-level "Edit" action covering all editable fields,
// matching the list ⇄ editor convention used by Destinations/Templates
// rather than separate per-field popovers.
editingIntegration: {
type: Object as () => AlertSourceIntegration | undefined,
default: undefined,
},
},
emits: ["update:open", "created", "updated"],
setup() {
const store = useStore();
const { t } = useI18n();
return { store, t };
const router = useRouter();
return { store, t, router };
},
data() {
return {
form: { name: "", source_type: "grafana" },
sourceTypeOptions: [
{ label: "Grafana", value: "grafana" },
{ label: "Alertmanager", value: "alertmanager" },
{ label: "Generic", value: "generic" },
{ label: "Auto", value: "auto" },
],
form: { name: "", destinations: [] as string[] },
destinationOptions: [] as Array<{ label: string; value: string }>,
created: false,
createdIntegration: undefined as AlertSourceIntegration | undefined,
waitingForEvent: true,
detectedFormat: "",
pollTimer: undefined as ReturnType<typeof setInterval> | undefined,
pollDeadline: 0,
saving: false,
};
},
computed: {
orgIdentifier(): string {
return this.store.state.selectedOrganization.identifier;
},
isEditMode(): boolean {
return !!this.editingIntegration;
},
// Drives OStepper's required modelValue: highlights step 1 until the
// source is created, step 2 while waiting for the first event, step 3
// once connected. Steps are still shown together (expanded mode) — this
// only controls which one reads as "active".
activeStep(): number {
if (!this.created) return 1;
return this.waitingForEvent ? 2 : 3;
},
createdUrlSnippet(): string {
if (!this.createdIntegration) return "";
const ingestionURL = getIngestionURL();
const base = getEndPoint(ingestionURL).url;
return [
"# URL",
`${base}/api/v2/${this.orgIdentifier}/incidents/events`,
"",
"# Authorization header",
`Bearer ${this.createdIntegration.token}`,
].join("\n");
},
createdCurlSnippet(): string {
if (!this.createdIntegration) return "";
const ingestionURL = getIngestionURL();
const base = getEndPoint(ingestionURL).url;
const url = `${base}/api/v2/${this.orgIdentifier}/incidents/events`;
// No leading "# curl" comment line here — unlike createdUrlSnippet,
// this whole block is meant to be pasted straight into a shell and
// run as-is, so it must contain nothing but valid shell syntax.
// Body is the minimal payload the generic-format detector accepts
// (status: firing/resolved + a labels object) — see
// core/src/alerts/external_alerts/detect.rs — an empty `{}` body
// fails with "unrecognized payload format".
return [
`curl -X POST '${url}' \\`,
` -H 'Authorization: Bearer ${this.createdIntegration.token}' \\`,
` -H 'Content-Type: application/json' \\`,
` -d '{"status": "firing", "labels": {"source": "test"}}'`,
].join("\n");
},
connectedLabel(): string {
// Built from a plain (non-interpolated) t() call + string concatenation
// rather than t(key, { format }) — vue-i18n's named-interpolation path
// throws on a missing/renamed key instead of warning-and-falling-back
// the way a plain t() call does, which previously could take down this
// whole branch's render.
return `${this.t("alert_sources.connectedPrefix")} ${this.detectedFormat}`;
},
},
watch: {
open(isOpen: boolean) {
if (isOpen) {
this.resetForm();
this.fetchDestinationOptions();
} else {
this.stopPolling();
}
},
},
beforeUnmount() {
this.stopPolling();
},
methods: {
resetForm() {
this.form = this.editingIntegration
? {
name: this.editingIntegration.name,
destinations: [...this.editingIntegration.destinations],
}
: { name: "", destinations: [] };
this.created = false;
this.createdIntegration = undefined;
this.waitingForEvent = true;
this.detectedFormat = "";
},
async fetchDestinationOptions() {
try {
const res = await destinationService.list({
org_identifier: this.orgIdentifier,
module: "alert",
});
this.destinationOptions = (res.data ?? []).map((d: any) => ({
label: d.name,
value: d.name,
}));
} catch (e) {
toast({ variant: "error", message: this.t("alert_sources.destinationsLoadError") });
}
},
// Matches AlertSettings.vue's routeToCreateDestination — destinations are
// a full resource (webhook/email/etc config), so creation always happens
// on the real Destinations page, not inline. Opens in a new tab; the
// admin returns and clicks refresh to pick up the new destination.
routeToCreateDestination() {
const url = this.router.resolve({
name: "alertDestinations",
query: {
action: "add",
org_identifier: this.store.state.selectedOrganization.identifier,
},
}).href;
window.open(url, "_blank");
},
onPrimaryClick() {
if (this.created) {
this.onDrawerOpenChange(false);
} else if (this.isEditMode) {
this.submitEdit();
} else {
this.submit();
}
},
async submit() {
if (!this.form.name.trim()) return;
try {
await alertSources.create(this.store.state.selectedOrganization.identifier, {
const res = await alertSources.create(this.orgIdentifier, {
name: this.form.name,
source_type: this.form.source_type,
source_type: "auto",
destinations: this.form.destinations,
});
this.createdIntegration = res.data;
this.created = true;
toast({ variant: "success", message: this.t("alert_sources.createdSuccess") });
this.$emit("created");
this.$emit("cancel:hideform");
this.startPolling();
} catch (e) {
toast({ variant: "error", message: this.t("alert_sources.error") });
}
},
async submitEdit() {
if (!this.editingIntegration || !this.form.name.trim()) return;
this.saving = true;
try {
const name = this.form.name.trim();
const nameChanged = name !== this.editingIntegration.name;
const destinationsChanged =
JSON.stringify([...this.form.destinations].sort()) !==
JSON.stringify([...this.editingIntegration.destinations].sort());
if (nameChanged || destinationsChanged) {
await alertSources.update(this.orgIdentifier, this.editingIntegration.id, {
...(nameChanged ? { name } : {}),
...(destinationsChanged ? { destinations: this.form.destinations } : {}),
});
}
toast({ variant: "success", message: this.t("alert_sources.updatedSuccess") });
this.$emit("updated");
this.onDrawerOpenChange(false);
} catch (e) {
toast({ variant: "error", message: this.t("alert_sources.error") });
} finally {
this.saving = false;
}
},
startPolling() {
if (!this.createdIntegration) return;
this.waitingForEvent = true;
this.pollDeadline = Date.now() + POLL_TIMEOUT_MS;
this.pollTimer = setInterval(() => this.pollForFirstEvent(), POLL_INTERVAL_MS);
this.pollForFirstEvent();
},
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = undefined;
}
},
async pollForFirstEvent() {
if (!this.createdIntegration) return;
if (Date.now() > this.pollDeadline) {
this.stopPolling();
return;
}
try {
const res = await alertSources.listSenders(this.orgIdentifier, this.createdIntegration.id);
const senders = res.data.senders ?? [];
if (senders.length > 0) {
this.waitingForEvent = false;
this.detectedFormat = senders[0].detected_source;
this.stopPolling();
}
} catch (e) {
// Transient poll failures don't need a toast — the pill just stays
// "waiting" and the next tick tries again.
}
},
onDrawerOpenChange(value: boolean) {
this.$emit("update:open", value);
},
cancel() {
this.$emit("cancel:hideform");
this.stopPolling();
this.onDrawerOpenChange(false);
},
},
});
@@ -4,6 +4,7 @@ import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import ExternalAlertSourcesList from "./ExternalAlertSourcesList.vue";
import alertSources from "@/services/alert_sources";
import destinationService from "@/services/alert_destination";
vi.mock("@/services/alert_sources", () => ({
default: {
@@ -13,9 +14,14 @@ vi.mock("@/services/alert_sources", () => ({
rotate: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
},
}));
vi.mock("@/services/alert_destination", () => ({
default: { list: vi.fn() },
}));
vi.mock("@/utils/clipboard", () => ({
copyToClipboard: vi.fn(),
}));
@@ -28,6 +34,7 @@ const DEFAULT_SOURCE = {
token: "o2iat_abcd1234efgh5678",
enabled: true,
config: {},
destinations: [] as string[],
created_by: "admin@example.com",
created_at: 1,
updated_at: 1,
@@ -59,6 +66,7 @@ describe("ExternalAlertSourcesList", () => {
(alertSources.listSenders as any).mockResolvedValue({
data: { senders: [] },
});
(destinationService.list as any).mockResolvedValue({ data: [] });
});
it("fetches integrations and senders on mount", async () => {
@@ -68,11 +76,15 @@ describe("ExternalAlertSourcesList", () => {
expect(alertSources.listSenders).toHaveBeenCalledWith("myorg", "int-1");
});
it("masks the token by default", async () => {
it("masks the token in the source table by default", async () => {
// Setup snippets that need the real URL only render inside the Add
// Source drawer once a source is freshly created (not on the list page
// itself), so the table text should never contain an unmasked token.
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.text()).not.toContain("abcd1234efgh5678");
expect(wrapper.text()).toContain("****");
const table = wrapper.find('[data-test="alert-sources-advanced-table"]');
expect(table.text()).not.toContain("abcd1234efgh5678");
expect(table.text()).toContain("****");
});
it("reveals the full token when reveal is toggled", async () => {
@@ -91,6 +103,14 @@ describe("ExternalAlertSourcesList", () => {
expect(copyToClipboard).toHaveBeenCalledWith(`http://localhost:5080${DEFAULT_SOURCE.url}`);
});
it("copies just the bare token via copyToClipboard, not the full URL", async () => {
const { copyToClipboard } = await import("@/utils/clipboard");
const wrapper = buildWrapper();
await flushPromises();
await (wrapper.vm as any).copyTokenFor(DEFAULT_SOURCE);
expect(copyToClipboard).toHaveBeenCalledWith(DEFAULT_SOURCE.token);
});
it("shows 'not_connected' status when no senders exist", async () => {
const wrapper = buildWrapper();
await flushPromises();
@@ -359,11 +379,164 @@ describe("ExternalAlertSourcesList", () => {
expect(alertSources.delete).not.toHaveBeenCalled();
});
it("toggling showAddEditor shows the AddExternalAlertSource component", async () => {
it("openEditFor opens the drawer targeting the clicked integration", async () => {
const wrapper = buildWrapper();
await flushPromises();
(wrapper.vm as any).showAddEditor = true;
(wrapper.vm as any).openEditFor(DEFAULT_SOURCE);
expect((wrapper.vm as any).showAddDrawer).toBe(true);
expect((wrapper.vm as any).editTargetIntegration).toEqual(DEFAULT_SOURCE);
});
it("openAddDrawer opens the drawer with no edit target (create mode)", async () => {
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.findComponent({ name: "AddExternalAlertSource" }).exists()).toBe(true);
(wrapper.vm as any).editTargetIntegration = DEFAULT_SOURCE;
(wrapper.vm as any).openAddDrawer();
expect((wrapper.vm as any).showAddDrawer).toBe(true);
expect((wrapper.vm as any).editTargetIntegration).toBeUndefined();
});
it("refreshes the list when the drawer emits 'updated'", async () => {
const wrapper = buildWrapper();
await flushPromises();
const addDrawer = wrapper.findComponent({ name: "AddExternalAlertSource" });
addDrawer.vm.$emit("updated");
await flushPromises();
expect(alertSources.list).toHaveBeenCalledTimes(2); // initial mount + post-update refresh
});
it("clicking Add source opens the AddExternalAlertSource drawer", async () => {
const wrapper = buildWrapper();
await flushPromises();
expect((wrapper.vm as any).showAddDrawer).toBe(false);
await wrapper.find('[data-test="alert-sources-add-btn"]').trigger("click");
expect((wrapper.vm as any).showAddDrawer).toBe(true);
expect(wrapper.findComponent({ name: "AddExternalAlertSource" }).props("open")).toBe(true);
});
it("shows the configured incident destinations for a source", async () => {
(alertSources.list as any).mockResolvedValue({
data: { integrations: [{ ...DEFAULT_SOURCE, destinations: ["sre-pages", "email-oncall"] }] },
});
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.text()).toContain("sre-pages, email-oncall");
});
it("flags a source with no incident destination configured", async () => {
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.find('[data-test="alert-sources-no-destination-tag"]').exists()).toBe(true);
});
it("does not flag a source once destinations are set", async () => {
(alertSources.list as any).mockResolvedValue({
data: { integrations: [{ ...DEFAULT_SOURCE, destinations: ["sre-pages"] }] },
});
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.find('[data-test="alert-sources-no-destination-tag"]').exists()).toBe(false);
});
it("shows a 'never resolves' warning icon with a tooltip alongside the status tag when actively receiving", async () => {
// last_received_at is recent, so status resolves to "receiving" — the
// never-resolved hint must still surface (as an icon beside the status
// tag), not get silently swallowed by the receiving/stale/hint
// mutual-exclusivity of the status tag itself.
(alertSources.listSenders as any).mockResolvedValue({
data: {
senders: [
{
integration_id: "int-1",
detected_source: "alertmanager",
display_name: "alertmanager",
first_received_at: 1,
last_received_at: Date.now() * 1000,
accepted_count: 14,
rejected_count: 0,
resolved_seen: false,
resolve_wiring_hint: true,
},
],
},
});
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.find('[data-test="alert-sources-never-resolves-icon"]').exists()).toBe(true);
// No page-level banner — the explanation lives in the icon's tooltip, so
// there's no unbounded banner list even with many misconfigured sources.
expect(wrapper.find('[data-test^="alert-sources-resolve-wiring-banner-"]').exists()).toBe(
false,
);
});
it("does not show a 'never resolves' indicator when resolves have been seen", async () => {
(alertSources.listSenders as any).mockResolvedValue({
data: {
senders: [
{
integration_id: "int-1",
detected_source: "grafana",
display_name: "grafana",
first_received_at: 1,
last_received_at: Date.now() * 1000,
accepted_count: 5,
rejected_count: 0,
resolved_seen: true,
resolve_wiring_hint: false,
},
],
},
});
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.find('[data-test="alert-sources-never-resolves-icon"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-sources-never-resolves-tag"]').exists()).toBe(false);
});
it("shows the 'never resolves' icon for an additional integration's sender too", async () => {
(alertSources.list as any).mockResolvedValue({
data: {
integrations: [
DEFAULT_SOURCE,
{ ...DEFAULT_SOURCE, id: "int-2", name: "grafana-staging", source_type: "grafana" },
],
},
});
(alertSources.listSenders as any).mockImplementation((_org: string, integrationId: string) => {
if (integrationId === "int-2") {
return Promise.resolve({
data: {
senders: [
{
integration_id: "int-2",
detected_source: "grafana",
display_name: "grafana",
first_received_at: 1,
last_received_at: Date.now() * 1000,
accepted_count: 3,
rejected_count: 0,
resolved_seen: false,
resolve_wiring_hint: true,
},
],
},
});
}
return Promise.resolve({ data: { senders: [] } });
});
const wrapper = buildWrapper();
await flushPromises();
expect((wrapper.vm as any).additionalResolveWiringHintById["int-2"]).toBe(true);
expect(wrapper.find('[data-test="alert-sources-never-resolves-icon"]').exists()).toBe(true);
});
it("renders just the table on the list page — no webhook URL/setup-snippet blocks (those live in the Add Source drawer)", async () => {
const wrapper = buildWrapper();
await flushPromises();
expect(wrapper.find('[data-test="alert-sources-setup-type-tabs"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-sources-setup-snippet"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-sources-url-cell"]').exists()).toBe(false);
expect(wrapper.find('[data-test="alert-sources-advanced-table"]').exists()).toBe(true);
});
});
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:title="t('alert_sources.header')"
:subtitle="t('alert_sources.subtitle')"
title-data-test="alert-sources-list-title"
icon="webhook"
scroll
pad-y
>
@@ -31,99 +32,107 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-sources-refresh-btn"
@click="fetchAll"
/>
<OButton
variant="primary"
size="sm"
icon-left="add"
data-test="alert-sources-add-btn"
@click="openAddDrawer"
>
{{ t("alert_sources.add") }}
</OButton>
</template>
<div v-if="defaultSource" class="flex flex-col gap-6 text-sm">
<!-- Webhook URL -->
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ t("alert_sources.webhookUrlLabel") }}</div>
<p class="text-text-secondary">{{ t("alert_sources.webhookUrlHelp") }}</p>
<CopyContent
:key="isRevealed(defaultSource) ? 'revealed' : 'masked'"
:content="fullUrlFor(defaultSource)"
:display-content="displayedUrlFor(defaultSource)"
data-test="alert-sources-url-cell"
/>
</div>
<AddExternalAlertSource
v-model:open="showAddDrawer"
:editing-integration="editTargetIntegration"
@created="fetchAll"
@updated="fetchAll"
/>
<!-- Alerting tool + setup -->
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ t("alert_sources.sourceTypeLabel") }}</div>
<OTabs v-model="selectedSetupType" dense data-test="alert-sources-setup-type-tabs">
<OTab
name="grafana"
:label="t('alert_sources.sourceTypes.grafana')"
data-test="alert-sources-setup-type-grafana"
/>
<OTab
name="alertmanager"
:label="t('alert_sources.sourceTypes.alertmanager')"
data-test="alert-sources-setup-type-alertmanager"
/>
<OTab
name="generic"
:label="t('alert_sources.sourceTypes.generic')"
data-test="alert-sources-setup-type-generic"
/>
</OTabs>
<p class="text-text-secondary">
{{ t(`alert_sources.setup.${selectedSetupType}Steps`) }}
</p>
<CopyContent v-if="selectedSetupType === 'alertmanager'" :content="alertmanagerSnippet" />
<CopyContent v-else-if="selectedSetupType === 'generic'" :content="genericSnippet" />
</div>
<!-- Alert sources table -->
<div class="mt-2 flex flex-col gap-3 text-sm">
<div class="flex items-center justify-between">
<div class="font-semibold">{{ t("alert_sources.statusHeader") }}</div>
<OButton
v-if="!showAddEditor"
variant="primary"
size="sm"
icon-left="add"
data-test="alert-sources-add-btn"
@click="showAddEditor = true"
>
{{ t("alert_sources.add") }}
</OButton>
</div>
<AddExternalAlertSource
v-if="showAddEditor"
@created="fetchIntegrations"
@cancel:hideform="showAddEditor = false"
/>
<div class="flex flex-col gap-3 text-sm">
<div v-if="defaultSource">
<OTable
:data="tableRows"
:columns="advancedColumns"
row-key="rowKey"
pagination="client"
:page-size="10"
:row-class="noDestinationRowClass"
wrap
horizontal-scroll
:default-columns="false"
:enable-column-resize="true"
data-test="alert-sources-advanced-table"
>
<template #cell-name="{ row }">
<div class="flex items-center gap-2">
<span>{{ row.displayName }}</span>
<OTag
v-if="row.sharesDefaultToken"
variant="default-outline"
:title="t('alert_sources.sharedTokenHint')"
data-test="alert-sources-shared-token-badge"
>
{{ t("alert_sources.sharedTokenBadge") }}
</OTag>
<div class="flex min-w-0 flex-col">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate font-medium" :title="row.displayName">{{
row.displayName
}}</span>
</div>
<span v-if="row.nameCaption" class="text-text-secondary truncate text-xs">{{
row.nameCaption
}}</span>
</div>
</template>
<template #cell-status="{ row }">
<OTag v-if="row.status === 'receiving'" variant="success-soft" dot>
{{ t("alert_sources.statusReceiving") }}
</OTag>
<OTag v-else-if="row.status === 'stale'" variant="warning-soft" dot>
{{ t("alert_sources.statusStale") }}
</OTag>
<OTag v-else variant="default-outline">
{{ t("alert_sources.statusNotConnected") }}
<div class="flex items-center gap-1">
<OTag v-if="row.status === 'receiving'" variant="success-soft" dot>
{{ t("alert_sources.statusReceiving") }}
</OTag>
<OTag v-else-if="row.status === 'stale'" variant="warning-soft" dot>
{{ t("alert_sources.statusStale") }}
</OTag>
<OTag
v-else-if="row.resolveWiringHint"
variant="warning-soft"
dot
data-test="alert-sources-never-resolves-tag"
>
{{ t("alert_sources.statusNeverResolves") }}
</OTag>
<OTag v-else variant="default-outline">
{{ t("alert_sources.statusNotConnected") }}
</OTag>
<!-- resolveWiringHint can co-occur with "Receiving"/"Stale" (the
source is actively sending, it just never sends a resolved
event) — surface it as a standalone icon+tooltip in that
case instead of only via the fallback tag above. -->
<OIcon
v-if="row.resolveWiringHint && row.status !== 'not_connected'"
name="flag"
size="xs"
class="text-status-warning-text"
data-test="alert-sources-never-resolves-icon"
>
<OTooltip :delay="300" :max-width="'20rem'">
<template #content>
<span class="font-medium">
{{ t("alert_sources.resolveWiringWarningTitle", { name: row.displayName }) }}
</span>
{{ t("alert_sources.resolveWiringWarningBody") }}
</template>
</OTooltip>
</OIcon>
</div>
</template>
<template #cell-destination="{ row }">
<span v-if="row.destinations.length > 0" class="text-text-secondary text-xs">
{{ row.destinations.join(", ") }}
</span>
<OTag
v-else-if="row.integration"
variant="error-soft"
data-test="alert-sources-no-destination-tag"
>
⚠ {{ t("alert_sources.noDestinationSet") }}
</OTag>
<span v-else class="text-text-secondary">—</span>
</template>
<template #cell-last_event="{ row }">
<span class="text-text-secondary text-xs">{{ row.lastEventLabel }}</span>
</template>
<template #cell-url="{ row }">
<div v-if="row.integration" class="flex items-center gap-1">
@@ -147,12 +156,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`alert-sources-copy-${row.integration.id}`"
@click="copyUrlFor(row.integration)"
/>
<span class="truncate font-mono text-xs">{{ displayedUrlFor(row.integration) }}</span>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="key"
:title="t('alert_sources.copyToken')"
:data-test="`alert-sources-copy-token-${row.integration.id}`"
@click="copyTokenFor(row.integration)"
/>
<span class="truncate font-mono text-xs" :title="displayedUrlFor(row.integration)">{{
displayedUrlFor(row.integration)
}}</span>
</div>
<span v-else class="text-text-secondary">—</span>
</template>
<template #cell-actions="{ row }">
<div v-if="row.integration" class="flex items-center gap-1">
<OButton
variant="ghost"
size="icon-sm"
icon-left="edit"
:title="t('alert_sources.edit')"
:data-test="`alert-sources-edit-${row.integration.id}`"
@click="openEditFor(row.integration)"
/>
<OButton
variant="ghost"
size="icon-sm"
@@ -172,15 +199,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="toggleEnabledFor(row.integration)"
/>
<OButton
v-if="row.integration.name !== 'default'"
variant="ghost-destructive"
size="icon-sm"
icon-left="delete"
:title="t('alert_sources.delete')"
:disabled="row.integration.name === 'default'"
:title="
row.integration.name === 'default'
? t('alert_sources.defaultCannotDelete')
: t('alert_sources.delete')
"
:data-test="`alert-sources-delete-${row.integration.id}`"
@click="confirmDelete(row.integration)"
/>
</div>
<span
v-else
class="text-text-secondary text-xs"
:title="t('alert_sources.sharedTokenActionsHint')"
>
—
</span>
</template>
</OTable>
</div>
@@ -218,13 +256,13 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import CopyContent from "@/components/CopyContent.vue";
import AddExternalAlertSource from "./AddExternalAlertSource.vue";
import alertSources from "@/services/alert_sources";
import { getAlertSourceStatus } from "@/utils/alertSourceStatus";
import { formatTimeAgoUs } from "@/utils/synthetics/format";
import { copyToClipboard } from "@/utils/clipboard";
import { toast } from "@/lib/feedback/Toast/useToast";
import { getEndPoint, getIngestionURL } from "@/utils/zincutils";
@@ -236,6 +274,7 @@ interface SourceStatusRow {
acceptedCount: number;
rejectedCount: number;
resolveWiringHint: boolean;
lastReceivedAt: number;
}
export default defineComponent({
@@ -245,10 +284,9 @@ export default defineComponent({
OButton,
OTag,
OTable,
OTabs,
OTab,
OIcon,
OTooltip,
ConfirmDialog,
CopyContent,
AddExternalAlertSource,
},
setup() {
@@ -259,6 +297,8 @@ export default defineComponent({
data() {
return {
loading: false,
showAddDrawer: false,
editTargetIntegration: undefined as AlertSourceIntegration | undefined,
integrations: [] as AlertSourceIntegration[],
sourceStatuses: [] as SourceStatusRow[],
revealedIds: [] as string[],
@@ -266,24 +306,62 @@ export default defineComponent({
rotateTarget: undefined as AlertSourceIntegration | undefined,
deleteDialogVisible: false,
deleteTarget: undefined as AlertSourceIntegration | undefined,
showAddEditor: false,
selectedSetupType: "grafana" as "grafana" | "alertmanager" | "generic",
additionalStatusById: {} as Record<string, "receiving" | "stale" | "not_connected">,
additionalResolveWiringHintById: {} as Record<string, boolean>,
additionalLastReceivedAtById: {} as Record<string, number | undefined>,
// table-fixed layout (default-columns=false below) is required for `wrap`
// to actually confine wrapped text to its own column — table-auto lets
// wrapped/long content bleed into neighboring columns instead. Matches
// the size/flex recipe used by Nodes.vue, PipelinesList.vue, etc.
advancedColumns: [
{
id: "name",
header: this.t("alert_sources.name"),
accessorKey: "displayName",
sortable: true,
minSize: 160,
// Bounded elastic column: absorbs leftover width but stays inside
// the horizontal-scroll container instead of stretching to fit its
// longest value — the other sized columns get their minWidth
// enforced (and the row scrolls) only when horizontalScroll is on.
meta: { align: "left", autoWidth: true, fillRemaining: true },
},
{
id: "source_type",
header: this.t("alert_sources.sourceType"),
accessorKey: "sourceType",
id: "status",
header: this.t("alert_sources.statusColumnHeader"),
accessorKey: "rowKey",
size: 150,
meta: { align: "left" },
},
{
id: "destination",
header: this.t("alert_sources.incidentDestination"),
accessorKey: "rowKey",
size: 200,
meta: { align: "left" },
},
{
id: "last_event",
header: this.t("alert_sources.lastEvent"),
accessorKey: "rowKey",
size: 100,
meta: { align: "left" },
},
{
id: "url",
header: this.t("alert_sources.urlHeader"),
accessorKey: "rowKey",
size: 420,
minSize: 420,
meta: { align: "left" },
},
{
id: "actions",
header: this.t("alert_sources.actions"),
isAction: true,
pinned: "right",
size: 150,
},
{ id: "status", header: this.t("alert_sources.statusColumnHeader"), accessorKey: "rowKey" },
{ id: "url", header: this.t("alert_sources.urlHeader"), accessorKey: "rowKey" },
{ id: "actions", header: this.t("alert_sources.actions"), isAction: true, size: 100 },
] as any[],
};
},
@@ -301,25 +379,27 @@ export default defineComponent({
const ingestionURL = getIngestionURL();
return getEndPoint(ingestionURL).url;
},
fullUrl(): string {
if (!this.defaultSource) return "";
return this.fullUrlFor(this.defaultSource);
},
tableRows(): Array<{
rowKey: string;
displayName: string;
sourceType: string;
nameCaption: string;
status: "receiving" | "stale" | "not_connected";
integration: AlertSourceIntegration | undefined;
sharesDefaultToken: boolean;
destinations: string[];
resolveWiringHint: boolean;
lastEventLabel: string;
}> {
const rows: Array<{
rowKey: string;
displayName: string;
sourceType: string;
nameCaption: string;
status: "receiving" | "stale" | "not_connected";
integration: AlertSourceIntegration | undefined;
sharesDefaultToken: boolean;
destinations: string[];
resolveWiringHint: boolean;
lastEventLabel: string;
}> = [];
if (this.defaultSource) {
@@ -327,55 +407,50 @@ export default defineComponent({
rows.push({
rowKey: `default:${this.defaultSource.id}`,
displayName: this.defaultSource.name,
sourceType: this.defaultSource.source_type,
nameCaption: this.t("alert_sources.acceptsAnyFormat"),
status: "not_connected",
integration: this.defaultSource,
sharesDefaultToken: false,
destinations: this.defaultSource.destinations,
resolveWiringHint: false,
lastEventLabel: "—",
});
} else {
this.sourceStatuses.forEach((s, idx) => {
rows.push({
rowKey: `default:${this.defaultSource!.id}:${s.displayName}`,
displayName: s.displayName,
sourceType: this.defaultSource!.source_type,
nameCaption: "",
status: s.status,
// Only the first sender row carries the shared URL/token controls
// to avoid repeating identical actions per sender.
integration: idx === 0 ? this.defaultSource : undefined,
sharesDefaultToken: true,
destinations: this.defaultSource!.destinations,
resolveWiringHint: s.resolveWiringHint,
lastEventLabel: formatTimeAgoUs(s.lastReceivedAt),
});
});
}
}
for (const integration of this.additionalIntegrations) {
const lastReceivedAt = this.additionalLastReceivedAtById[integration.id];
rows.push({
rowKey: `additional:${integration.id}`,
displayName: integration.name,
sourceType: integration.source_type,
nameCaption: "",
status: this.additionalStatusById[integration.id] ?? "not_connected",
integration,
sharesDefaultToken: false,
destinations: integration.destinations,
resolveWiringHint: this.additionalResolveWiringHintById[integration.id] ?? false,
lastEventLabel: lastReceivedAt ? formatTimeAgoUs(lastReceivedAt) : "—",
});
}
return rows;
},
alertmanagerSnippet(): string {
return [
"receivers:",
" - name: openobserve-incidents",
" webhook_configs:",
` - url: "${this.fullUrl}"`,
" send_resolved: true",
].join("\n");
},
genericSnippet(): string {
return [
`curl -X POST '${this.fullUrl}' -H 'Content-Type: application/json' \\`,
` -d '{"status":"firing","labels":{"alertname":"HighCPU","service":"checkout"}}'`,
].join("\n");
},
},
mounted() {
this.fetchAll();
@@ -413,6 +488,7 @@ export default defineComponent({
acceptedCount: s.accepted_count,
rejectedCount: s.rejected_count,
resolveWiringHint: s.resolve_wiring_hint,
lastReceivedAt: s.last_received_at,
}));
} catch (e) {
toast({ variant: "error", message: this.t("alert_sources.senderError") });
@@ -422,9 +498,11 @@ export default defineComponent({
try {
const res = await alertSources.listSenders(this.orgIdentifier, integrationId);
const now = Date.now() * 1000;
const statuses = res.data.senders.map((s: any) =>
getAlertSourceStatus(s.last_received_at, now),
);
const senders = res.data.senders as Array<{
last_received_at: number;
resolve_wiring_hint: boolean;
}>;
const statuses = senders.map((s) => getAlertSourceStatus(s.last_received_at, now));
if (statuses.length === 0) {
this.additionalStatusById = {
...this.additionalStatusById,
@@ -438,6 +516,15 @@ export default defineComponent({
} else {
this.additionalStatusById = { ...this.additionalStatusById, [integrationId]: "stale" };
}
this.additionalResolveWiringHintById = {
...this.additionalResolveWiringHintById,
[integrationId]: senders.some((s) => s.resolve_wiring_hint),
};
this.additionalLastReceivedAtById = {
...this.additionalLastReceivedAtById,
[integrationId]:
senders.length > 0 ? Math.max(...senders.map((s) => s.last_received_at)) : undefined,
};
} catch (e) {
toast({ variant: "error", message: this.t("alert_sources.senderError") });
}
@@ -456,6 +543,9 @@ export default defineComponent({
copyUrlFor(integration: AlertSourceIntegration) {
copyToClipboard(this.fullUrlFor(integration));
},
copyTokenFor(integration: AlertSourceIntegration) {
copyToClipboard(integration.token);
},
fullUrlFor(integration: AlertSourceIntegration): string {
return `${this.ingestionBaseUrl}${integration.url}`;
},
@@ -512,6 +602,22 @@ export default defineComponent({
toast({ variant: "error", message: this.t("alert_sources.error") });
}
},
openAddDrawer() {
this.editTargetIntegration = undefined;
this.showAddDrawer = true;
},
openEditFor(integration: AlertSourceIntegration) {
this.editTargetIntegration = integration;
this.showAddDrawer = true;
},
// Highlights a row with no incident destination configured — matches the
// mockup's tinted row for "impossible to miss" (tag 02 review finding).
noDestinationRowClass(row: {
destinations: string[];
integration: AlertSourceIntegration | undefined;
}): string {
return row.integration && row.destinations.length === 0 ? "bg-banner-error-soft-bg" : "";
},
},
});
</script>
-11
View File
@@ -91,7 +91,6 @@ export default defineComponent({
domainManagement: "domain_management",
pipelineDestinations: "pipeline_destinations",
alertTemplates: "templates",
alertSources: "alert_sources",
modelPricing: "model_pricing",
modelPricingEditor: "model_pricing",
llmProviders: "llm_providers",
@@ -252,16 +251,6 @@ export default defineComponent({
dataTest: "pipeline-destinations-tab",
group: "Destinations",
},
{
key: "alert_sources",
label: t("alert_sources.header"),
description: t("settings.alertSourcesDesc"),
icon: "webhook",
to: { name: "alertSources", query: { org_identifier: org } },
visible: (isEnt || isCloud) && !!z.incidents_enabled,
dataTest: "alert-sources-tab",
group: "Destinations & Templates",
},
{
key: "storageSettings",
label: t("storage_settings.tabLabel"),
+25
View File
@@ -515,6 +515,31 @@ const useRoutes = () => {
routeGuard(to, from, next);
},
},
{
// Alert Sources feeds Incidents (correlation, resolve lifecycle) — same
// Reliability workflow as Alerts/SLOs/Incidents/Destinations/Templates
// above, so it's flat and top-level for the same reason those are.
// Moved out of /settings/alert_sources, which redirects here.
//
// Enterprise/cloud-only (unlike destinations/templates): the route stays
// registered so the redirect below has somewhere to land, but bounces to
// Alerts on OSS builds — mirrors the anomaly-detection routes above.
path: "alert-sources",
name: "alertSources",
component: () => import("@/components/alerts/ExternalAlertSourcesList.vue"),
meta: {
title: "External Alert Sources",
},
beforeEnter(to: any, from: any, next: any) {
const store = (window as any).store;
const isOss = store?.state?.zoConfig?.build_type === "opensource";
if (isOss || (config.isEnterprise !== "true" && config.isCloud !== "true")) {
next({ name: "alertList", query: { org_identifier: to.query.org_identifier } });
return;
}
routeGuard(to, from, next);
},
},
{
// Alert status page. Replaces the row-click side panel, and is where a
// multi-alert's per-group state lives (alerts_2.md §5.4).
@@ -447,7 +447,7 @@ describe("useManagementRoutes", () => {
it("should have exactly 20 children routes when enterprise is enabled", () => {
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(20); // 6 base + llmProviders + genAiAgentMapping + alertSources + 11 enterprise
expect(routes[0].children).toHaveLength(20); // 7 base (incl. alert_sources redirect) + llmProviders + genAiAgentMapping + 11 enterprise
});
});
@@ -510,7 +510,7 @@ describe("useManagementRoutes", () => {
it("should have exactly 10 children routes when cloud is enabled", () => {
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(10); // 6 base + llmProviders + genAiAgentMapping + alertSources + 1 cloud
expect(routes[0].children).toHaveLength(10); // 7 base (incl. alert_sources redirect) + llmProviders + genAiAgentMapping + 1 cloud
});
});
@@ -527,7 +527,7 @@ describe("useManagementRoutes", () => {
it("should have exactly 21 children routes when both enterprise and cloud are enabled", () => {
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(21); // 6 base + llmProviders + genAiAgentMapping + alertSources + 11 enterprise + 1 cloud
expect(routes[0].children).toHaveLength(21); // 7 base (incl. alert_sources redirect) + llmProviders + genAiAgentMapping + 11 enterprise + 1 cloud
});
it("should have all enterprise routes when both are enabled", () => {
@@ -627,63 +627,63 @@ describe("useManagementRoutes", () => {
config.isEnterprise = "false";
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isCloud as string 'false'", () => {
config.isEnterprise = "false";
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isEnterprise as undefined", () => {
(config as any).isEnterprise = undefined;
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isCloud as undefined", () => {
config.isEnterprise = "false";
(config as any).isCloud = undefined;
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle both config values as undefined", () => {
(config as any).isEnterprise = undefined;
(config as any).isCloud = undefined;
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle isEnterprise as non-string truthy value", () => {
(config as any).isEnterprise = true;
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only, since config comparison is strict "true"
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true"
});
it("should handle isCloud as non-string truthy value", () => {
config.isEnterprise = "false";
(config as any).isCloud = true;
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only, since config comparison is strict "true"
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present, since config comparison is strict "true"
});
it("should handle empty string for isEnterprise", () => {
config.isEnterprise = "";
config.isCloud = "false";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should handle empty string for isCloud", () => {
config.isEnterprise = "false";
config.isCloud = "";
const routes = useManagementRoutes();
expect(routes[0].children).toHaveLength(6); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only
expect(routes[0].children).toHaveLength(7); // gen_ai_agent_mapping and llm_providers are enterprise/cloud-only; alert_sources redirect is always present
});
it("should return the same structure on multiple calls", () => {
@@ -77,6 +77,14 @@ const useManagementRoutes = () => {
path: "templates",
redirect: (to: any) => ({ name: "alertTemplates", query: to.query }),
},
// Alert Sources moved to /alert-sources (Reliability), same reasoning
// as alert_destinations/templates above. Redirect kept for bookmarks;
// the enterprise/cloud gate now lives on the target route itself
// (router.ts), not on whether this redirect entry exists.
{
path: "alert_sources",
redirect: (to: any) => ({ name: "alertSources", query: to.query }),
},
],
},
];
@@ -108,17 +116,10 @@ const useManagementRoutes = () => {
routeGuard(to, from, next);
},
});
routes[0].children.push({
path: "alert_sources",
name: "alertSources",
component: () => import("@/components/alerts/ExternalAlertSourcesList.vue"),
meta: {
title: "Alert Sources",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
});
// Alert Sources moved to a flat top-level route (router.ts, name
// "alertSources") — no longer pushed here. It used to be conditional on
// this same enterprise/cloud check; that gating now lives on the target
// route's own beforeEnter instead.
}
if (config.isEnterprise == "true") {
routes[0].children.push(
+37 -11
View File
@@ -158,6 +158,7 @@ describe("groupNavLinks", () => {
"incidentList",
"alertDestinations",
"alertTemplates",
"alertSources",
]);
});
@@ -190,15 +191,21 @@ describe("groupNavLinks", () => {
]);
});
it("takes Destinations/Templates away with Alerts when alertList is hidden", () => {
// They carry `requires: "alertList"`, so hiding Alerts via custom_hide_menus
// must not leave its plumbing behind in the flyout.
it("takes Destinations/Templates away with Alerts when alertList is hidden, but keeps Alert Sources (requires incidentList)", () => {
// Destinations/Templates carry `requires: "alertList"`, so hiding Alerts via
// custom_hide_menus must not leave their plumbing behind in the flyout.
// Alert Sources requires `incidentList` instead — it rides on Incidents,
// not Alerts, so it survives here.
const entries = groupNavLinks([link("home"), link("sloList"), link("incidentList")]);
const reliability = entries.find(
(e): e is Extract<RailEntry, { type: "linkGroup" }> =>
e.type === "linkGroup" && e.item.name === "reliability",
);
expect(reliability?.children.map((c) => c.name)).toEqual(["sloList", "incidentList"]);
expect(reliability?.children.map((c) => c.name)).toEqual([
"sloList",
"incidentList",
"alertSources",
]);
});
it("keeps SLOs a plain link when Alerts and Incidents are hidden", () => {
@@ -208,11 +215,17 @@ describe("groupNavLinks", () => {
]);
});
it("keeps Incidents a plain link when it is the only reliability item", () => {
expect(keysOf(groupNavLinks([link("home"), link("incidentList")]))).toEqual([
"link:home",
"link:incidentList",
]);
it("groups Incidents with Alert Sources when Incidents is the only other reliability item present", () => {
// Alert Sources requires incidentList, so Incidents is never really
// "alone" once Incidents is enabled — it always has Alert Sources riding
// alongside it, and 2 children is enough to collapse into a group.
const entries = groupNavLinks([link("home"), link("incidentList")]);
expect(keysOf(entries)).toEqual(["link:home", "linkGroup:reliability"]);
const reliability = entries.find(
(e): e is Extract<RailEntry, { type: "linkGroup" }> =>
e.type === "linkGroup" && e.item.name === "reliability",
);
expect(reliability?.children.map((c) => c.name)).toEqual(["incidentList", "alertSources"]);
});
it("moves Reports under the Dashboards group", () => {
@@ -305,6 +318,10 @@ describe("groupNavLinks", () => {
});
it("does not lose any non-absorbed item", () => {
// incidentList collapses into linkGroup:reliability here (Alert Sources
// rides alongside it, requires: incidentList, ≥2 children collapses) — it
// surfaces as a linkGroup child, not a top-level link, so it's excluded
// from the top-level "surfaced" set the same way absorbed items are.
const input = [
link("home"),
link("logs"),
@@ -316,8 +333,17 @@ describe("groupNavLinks", () => {
link("settings"),
];
const entries = groupNavLinks(input);
const surfaced = entries.flatMap((e) => (e.type === "group" ? [] : [e.item.name]));
expect(surfaced.sort()).toEqual(input.map((i) => i.name).sort());
const surfaced = entries.flatMap((e) =>
e.type === "group" || e.type === "linkGroup" ? [] : [e.item.name],
);
const expectedNonCollapsed = input.map((i) => i.name).filter((n) => n !== "incidentList");
expect(surfaced.sort()).toEqual(expectedNonCollapsed.sort());
const reliability = entries.find(
(e): e is Extract<RailEntry, { type: "linkGroup" }> =>
e.type === "linkGroup" && e.item.name === "reliability",
);
expect(reliability?.children.map((c) => c.name)).toContain("incidentList");
});
it("every group's absorbs/members are internally consistent", () => {
+10
View File
@@ -134,6 +134,16 @@ export const NAV_GROUPS: NavGroupDef[] = [
name: "alertTemplates",
requires: "alertList",
},
// Where external alerts (Grafana, Alertmanager, etc.) feed Incidents.
// Gated on incidentList, not alertList: this only makes sense where
// Incidents is enabled, matching the enterprise/cloud + incidents_enabled
// visibility check it already carried as a Settings tab.
{
titleKey: "alert_sources.header",
icon: "webhook",
name: "alertSources",
requires: "incidentList",
},
],
},
{
+28 -17
View File
@@ -5477,14 +5477,14 @@
"contentCopied": "Content Copied Successfully!"
},
"alert_sources": {
"header": "Alert Sources",
"header": "External Alert Sources",
"subtitle": "Send alerts from Grafana, Prometheus Alertmanager, or any tool that can call a webhook — they'll show up here and get correlated into Incidents automatically.",
"defaultSourceName": "default",
"webhookUrlLabel": "1. Your webhook URL",
"webhookUrlHelp": "This URL is unique to your organization. Paste it into the alerting tool you choose below — no extra auth headers required.",
"acceptsAnyFormat": "accepts any webhook format",
"revealToken": "Reveal full URL",
"hideToken": "Hide full URL",
"copyUrl": "Copy URL",
"copyToken": "Copy token",
"copiedSuccess": "Copied to clipboard",
"rotateToken": "Rotate token",
"rotateConfirmTitle": "Rotate token?",
@@ -5496,27 +5496,16 @@
"enabledSuccess": "Alert source enabled",
"disabledSuccess": "Alert source disabled",
"delete": "Delete",
"defaultCannotDelete": "The default source can't be deleted — it's the fallback every sender without its own source uses.",
"deleteConfirmTitle": "Delete this alert source?",
"deleteConfirmMessage": "This permanently removes the integration and its webhook URL/token. Any external tool still sending to this URL will start getting rejected.",
"deleteConfirmWarning": "This cannot be undone. Update or remove the webhook config in the source system first.",
"deletedSuccess": "Alert source deleted",
"sourceTypeLabel": "2. Choose your alerting tool",
"setupLabel": "3. Configure it",
"sourceTypes": {
"grafana": "Grafana",
"alertmanager": "Alertmanager",
"generic": "Generic / custom"
},
"setup": {
"grafanaSteps": "In Grafana: go to Alerting → Contact points → New contact point, choose \"Webhook\" as the integration, and paste the URL above into the URL field. Save, then attach the contact point to a notification policy.",
"alertmanagerSteps": "In your Alertmanager config, add a webhook_configs receiver pointing at the URL above. Keep send_resolved: true — without it Alertmanager never tells us when an alert clears, so incidents can't auto-resolve.",
"genericSteps": "Any tool that can POST JSON works. Send a firing or resolved status with labels — for example:"
},
"statusHeader": "Alert sources",
"sharedTokenBadge": "Shared token",
"sharedTokenHint": "This sender shares the default webhook URL and token — rotating or disabling it affects every sender listed here.",
"sharedTokenActionsHint": "Manage this via the default source above — a shared sender isn't a separate source to rotate or delete.",
"statusReceiving": "Receiving",
"statusStale": "Stale",
"statusNeverResolves": "Never resolves ⚑",
"statusNotConnected": "Not yet connected",
"statusNotConnectedHelp": "No events received yet. Send a test alert from the tool you configured above — this panel updates after you hit Refresh.",
"lastEvent": "Last event",
@@ -5529,16 +5518,38 @@
"advancedSectionDesc": "Only needed if you want a second, separate webhook URL — for example, to keep one team's alerts deduplicated apart from another's. Most setups only need the default source above.",
"add": "Add source",
"addTitle": "Add Alert Source",
"editTitle": "Edit Alert Source",
"name": "Name",
"nameHint": "Just a label — every source uses the same setup.",
"sourceType": "Source type",
"incidentDestination": "Incident destination",
"incidentDestinationPlaceholder": "Use org default",
"incidentDestinationHint": "Where notifications go when this source's alerts create or join an incident. Leave empty to use the org default — without either set, matching incidents fire silently.",
"noDestinationSet": "No incident destination set",
"setupStep1Title": "1. Create a webhook",
"setupStep1Body": "In Grafana, Alertmanager, or any tool that can POST JSON, create a webhook contact point / receiver.",
"setupRemainingStepsHint": "Save this source to get its URL and token, then paste them into that webhook config.",
"setupStep2Title": "2. Paste the URL and token",
"setupStep2Body": "Shown below once this source is created — copy it into your tool's webhook config.",
"curlLabel": "Or send a test alert with curl:",
"setupStep3Title": "3. Send a test alert",
"setupStep3Body": "Confirms wiring end-to-end. The status below updates automatically.",
"waitingForEvent": "Waiting for first event…",
"connectedPrefix": "Connected — format detected:",
"resolveWiringWarningTitle": "{name} has fired alerts but never sent a \"resolved\" event.",
"resolveWiringWarningBody": "Matching incidents will only close by timeout. Check your source's resolve/recovery settings are wired up.",
"urlHeader": "Webhook URL",
"statusColumnHeader": "Status",
"save": "Save",
"cancel": "Cancel",
"done": "Done",
"actions": "Actions",
"edit": "Edit source",
"createdSuccess": "Alert source created",
"updatedSuccess": "Alert source updated",
"error": "Error loading alert sources",
"senderError": "Error loading source status",
"destinationsLoadError": "Error loading destinations",
"docsCta": "Read the full setup guide"
},
"alert_destinations": {
+7
View File
@@ -31,6 +31,13 @@ const alertSources = {
rotate: (org_identifier: string, integration_id: string) => {
return http().post(`/api/v2/${org_identifier}/incidents/integrations/${integration_id}/rotate`);
},
update: (
org_identifier: string,
integration_id: string,
data: { name?: string; destinations?: string[] },
) => {
return http().patch(`/api/v2/${org_identifier}/incidents/integrations/${integration_id}`, data);
},
listSenders: (org_identifier: string, integration_id: string) => {
return http().get(`/api/v2/${org_identifier}/incidents/integrations/${integration_id}/senders`);
},
+5
View File
@@ -6,6 +6,10 @@ export interface AlertSourceIntegration {
token: string;
enabled: boolean;
config: Record<string, any>;
/** Where notifications go when this source's alerts create/join an
* incident. Empty means "use the org default" — see the review's
* finding that incidents with no destination fire silently. */
destinations: string[];
created_by: string;
created_at: number;
updated_at: number;
@@ -36,4 +40,5 @@ export interface CreateAlertSourcePayload {
name: string;
source_type?: string;
config?: Record<string, any>;
destinations?: string[];
}