mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user