From f2b6eb6312bce12a0f48b0b8a7ef211e79ef1c3f Mon Sep 17 00:00:00 2001 From: Mike Glenn Date: Wed, 22 Jul 2026 21:47:17 +0100 Subject: [PATCH] Added Pinglet Support --- bin/core/src/alert/mod.rs | 12 + bin/core/src/alert/pinglet.rs | 700 +++++++++++++++++++ client/core/rs/src/entities/alerter.rs | 36 + client/core/ts/src/types.ts | 19 +- ui/public/client/types.d.ts | 19 + ui/src/resources/alerter/config/endpoint.tsx | 20 +- 6 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 bin/core/src/alert/pinglet.rs diff --git a/bin/core/src/alert/mod.rs b/bin/core/src/alert/mod.rs index c015c41d05..4a78810e6e 100644 --- a/bin/core/src/alert/mod.rs +++ b/bin/core/src/alert/mod.rs @@ -19,6 +19,7 @@ use crate::{config::core_config, state::db_client}; mod discord; mod ntfy; +mod pinglet; mod pushover; mod slack; @@ -148,6 +149,17 @@ pub async fn send_alert_to_alerter( ) }) } + AlerterEndpoint::Pinglet(PingletAlerterEndpoint { + url, + token, + }) => pinglet::send_alert(url, token.as_deref(), alert) + .await + .with_context(|| { + format!( + "Failed to send alert to Pinglet Alerter {}", + alerter.name + ) + }), } } diff --git a/bin/core/src/alert/pinglet.rs b/bin/core/src/alert/pinglet.rs new file mode 100644 index 0000000000..7c35446358 --- /dev/null +++ b/bin/core/src/alert/pinglet.rs @@ -0,0 +1,700 @@ +use std::sync::OnceLock; + +use serde::Serialize; +use serde_json::{Map, Value, json}; + +use super::*; + +/// Pinglet renders at most 3 badges, with values up to 32 +/// characters. Over-length values are rejected by the API +/// (not truncated), so trim them here. +const MAX_BADGE_VALUE_LEN: usize = 32; +/// Pinglet `data` values are capped at 256 characters, +/// over-length values are rejected by the API. +const MAX_DATA_VALUE_LEN: usize = 256; + +/// The body of a Pinglet publish request. +#[derive(Serialize)] +struct PingletMessage { + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + message: String, + /// `silent` | `normal` | `urgent` + priority: &'static str, + /// `info` | `success` | `warning` | `error` + level: &'static str, + /// Key / value pills shown on the notification card. + #[serde(skip_serializing_if = "Option::is_none")] + badges: Option, + /// Key / value metadata shown in the notification detail sheet. + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +pub async fn send_alert( + url: &str, + token: Option<&str>, + alert: &Alert, +) -> anyhow::Result<()> { + let Some(message) = build_message(alert) else { + return Ok(()); + }; + + let VariablesAndSecrets { variables, secrets } = + get_variables_and_secrets().await?; + + let mut url_interpolated = url.to_string(); + let mut token_interpolated = token.unwrap_or_default().to_string(); + + let mut interpolator = + Interpolator::new(Some(&variables), &secrets); + + interpolator.interpolate_string(&mut url_interpolated)?; + interpolator.interpolate_string(&mut token_interpolated)?; + + send_message(&url_interpolated, &token_interpolated, &message) + .await + .map_err(|e| { + let replacers = interpolator + .secret_replacers + .into_iter() + .collect::>(); + let sanitized_error = + svi::replace_in_string(&format!("{e:?}"), &replacers); + anyhow::Error::msg(format!( + "Error with request to Pinglet: {sanitized_error}" + )) + }) +} + +async fn send_message( + url: &str, + token: &str, + message: &PingletMessage, +) -> anyhow::Result<()> { + let mut request = http_client().post(url).json(message); + + if !token.is_empty() { + request = request.bearer_auth(token); + } + + let response = + request.send().await.context("Failed to send message")?; + + let status = response.status(); + if status.is_success() { + debug!("pinglet alert sent successfully: {}", status); + Ok(()) + } else { + let text = response.text().await.with_context(|| { + format!( + "Failed to send message to Pinglet | {status} | failed to get response text" + ) + })?; + Err(anyhow!( + "Failed to send message to Pinglet | {status} | {text}", + )) + } +} + +fn build_message(alert: &Alert) -> Option { + let rendered = render(alert)?; + + let title = if alert.resolved { + rendered.title.map(|title| format!("Resolved: {title}")) + } else { + rendered.title + }; + + Some(PingletMessage { + title, + message: rendered.message, + priority: priority(alert), + level: level(alert), + badges: rendered + .badge + .map(|(key, value)| json!({ key: truncate(&value, MAX_BADGE_VALUE_LEN) })), + data: data_bag(alert, rendered.data), + }) +} + +/// A resolved alert is good news regardless of its original +/// severity, so it always shows as `success`. +fn level(alert: &Alert) -> &'static str { + if alert.resolved { + return "success"; + } + match alert.level { + SeverityLevel::Critical => "error", + SeverityLevel::Warning => "warning", + SeverityLevel::Ok => "info", + } +} + +/// Only an unresolved CRITICAL is intrusive enough to break +/// through Do Not Disturb, everything else is a standard +/// notification. +fn priority(alert: &Alert) -> &'static str { + if !alert.resolved && alert.level == SeverityLevel::Critical { + "urgent" + } else { + "normal" + } +} + +/// The per-variant parts of the notification, before the +/// alert-level fields (resolution prefix, level, priority) +/// are applied. +struct Rendered { + title: Option, + message: String, + /// At most one metric pill per alert, eg `("CPU", "95%")`. + badge: Option<(&'static str, String)>, + /// Variant specific detail-sheet entries. + data: Map, +} + +fn render(alert: &Alert) -> Option { + let mut data = Map::new(); + let rendered = match &alert.data { + AlertData::None {} => return None, + AlertData::Test { id, name } => { + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Alerter, id), + ); + Rendered { + title: Some(String::from("Test alert")), + message: format!( + "If you see this message, then Alerter {name} is working" + ), + badge: None, + data, + } + } + AlertData::SwarmUnhealthy { id, name, err } => { + insert(&mut data, "name", name); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Swarm, id), + ); + if let Some(err) = err { + insert(&mut data, "error", format!("{err:#?}")); + } + Rendered { + title: Some(String::from("Swarm unhealthy")), + message: if alert.resolved { + format!("Swarm {name} is now healthy") + } else { + format!("Swarm {name} is unhealthy") + }, + badge: None, + data, + } + } + AlertData::ServerUnreachable { + id, + name, + region, + err, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "region", region); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Server, id), + ); + if let Some(err) = err { + insert(&mut data, "error", format!("{err:#?}")); + } + let region = fmt_region(region); + Rendered { + title: Some(String::from("Server unreachable")), + message: if alert.resolved { + format!("Server {name}{region} is now connected") + } else { + format!("Server {name}{region} is unreachable") + }, + badge: None, + data, + } + } + AlertData::ServerCpu { + id, + name, + region, + percentage, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "region", region); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Server, id), + ); + let region = fmt_region(region); + Rendered { + title: Some(String::from("High CPU usage")), + message: format!( + "Server {name}{region} CPU usage at {percentage:.1}%" + ), + badge: Some(("CPU", format!("{percentage:.0}%"))), + data, + } + } + AlertData::ServerMem { + id, + name, + region, + used_gb, + total_gb, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "region", region); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Server, id), + ); + let region = fmt_region(region); + let percentage = 100.0 * used_gb / total_gb; + Rendered { + title: Some(String::from("High memory usage")), + message: format!( + "Server {name}{region} memory usage at {percentage:.1}%, using {used_gb:.1} GiB / {total_gb:.1} GiB" + ), + badge: Some(("Memory", format!("{percentage:.0}%"))), + data, + } + } + AlertData::ServerDisk { + id, + name, + region, + path, + used_gb, + total_gb, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "region", region); + insert(&mut data, "path", format!("{path:?}")); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Server, id), + ); + let region = fmt_region(region); + let percentage = 100.0 * used_gb / total_gb; + Rendered { + title: Some(String::from("High disk usage")), + message: format!( + "Server {name}{region} disk usage at {percentage:.1}% on {path:?}, using {used_gb:.1} GiB / {total_gb:.1} GiB" + ), + badge: Some(("Disk", format!("{percentage:.0}%"))), + data, + } + } + AlertData::ServerVersionMismatch { + id, + name, + region, + server_version, + core_version, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "region", region); + insert(&mut data, "server_version", server_version); + insert(&mut data, "core_version", core_version); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Server, id), + ); + let region = fmt_region(region); + Rendered { + title: Some(String::from("Server version mismatch")), + message: if alert.resolved { + format!( + "Server {name}{region} Periphery version now matches Core version" + ) + } else { + format!( + "Server {name}{region} is running Periphery {server_version}, Core is {core_version}" + ) + }, + badge: None, + data, + } + } + AlertData::ContainerStateChange { + id, + name, + server_id: _, + server_name, + swarm_id: _, + swarm_name, + from, + to, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "server", server_name); + insert_opt(&mut data, "swarm", swarm_name); + insert(&mut data, "from", from.to_string()); + insert(&mut data, "to", to.to_string()); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Deployment, id), + ); + Rendered { + title: Some(String::from("Deployment state change")), + message: format!( + "Deployment {name} is now {to}{}, previously {from}", + host_suffix(server_name, swarm_name) + ), + badge: Some(("State", to.to_string())), + data, + } + } + AlertData::DeploymentImageUpdateAvailable { + id, + name, + server_id: _, + server_name, + swarm_id: _, + swarm_name, + image, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "server", server_name); + insert_opt(&mut data, "swarm", swarm_name); + insert(&mut data, "image", image); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Deployment, id), + ); + Rendered { + title: Some(String::from("Deployment update available")), + message: format!( + "Deployment {name}{} has an image update available: {image}", + host_suffix(server_name, swarm_name) + ), + badge: None, + data, + } + } + AlertData::DeploymentAutoUpdated { + id, + name, + server_id: _, + server_name, + swarm_id: _, + swarm_name, + image, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "server", server_name); + insert_opt(&mut data, "swarm", swarm_name); + insert(&mut data, "image", image); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Deployment, id), + ); + Rendered { + title: Some(String::from("Deployment auto-updated")), + message: format!( + "Deployment {name}{} was updated automatically to {image}", + host_suffix(server_name, swarm_name) + ), + badge: None, + data, + } + } + AlertData::StackStateChange { + id, + name, + server_id: _, + server_name, + swarm_id: _, + swarm_name, + from, + to, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "server", server_name); + insert_opt(&mut data, "swarm", swarm_name); + insert(&mut data, "from", from.to_string()); + insert(&mut data, "to", to.to_string()); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Stack, id), + ); + Rendered { + title: Some(String::from("Stack state change")), + message: format!( + "Stack {name} is now {to}{}, previously {from}", + host_suffix(server_name, swarm_name) + ), + badge: Some(("State", to.to_string())), + data, + } + } + AlertData::StackImageUpdateAvailable { + id, + name, + server_id: _, + server_name, + swarm_id: _, + swarm_name, + service, + image, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "server", server_name); + insert_opt(&mut data, "swarm", swarm_name); + insert(&mut data, "service", service); + insert(&mut data, "image", image); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Stack, id), + ); + Rendered { + title: Some(String::from("Stack update available")), + message: format!( + "Stack {name} service {service}{} has an image update available: {image}", + host_suffix(server_name, swarm_name) + ), + badge: None, + data, + } + } + AlertData::StackAutoUpdated { + id, + name, + server_id: _, + server_name, + swarm_id: _, + swarm_name, + images, + } => { + insert(&mut data, "name", name); + insert_opt(&mut data, "server", server_name); + insert_opt(&mut data, "swarm", swarm_name); + insert(&mut data, "images", images.join(", ")); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Stack, id), + ); + let images_label = + if images.len() > 1 { "images" } else { "image" }; + Rendered { + title: Some(String::from("Stack auto-updated")), + message: format!( + "Stack {name}{} was updated automatically, {images_label}: {}", + host_suffix(server_name, swarm_name), + images.join(", ") + ), + badge: None, + data, + } + } + AlertData::AwsBuilderTerminationFailed { + instance_id, + message, + } => { + insert(&mut data, "instance_id", instance_id); + Rendered { + title: Some(String::from("AWS builder termination failed")), + message: format!( + "Failed to terminate AWS builder instance {instance_id}: {message}" + ), + badge: None, + data, + } + } + AlertData::ResourceSyncPendingUpdates { id, name } => { + insert(&mut data, "name", name); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::ResourceSync, id), + ); + Rendered { + title: Some(String::from("Pending resource sync updates")), + message: format!("Resource sync {name} has pending updates"), + badge: None, + data, + } + } + AlertData::BuildFailed { id, name, version } => { + insert(&mut data, "name", name); + insert(&mut data, "version", format!("v{version}")); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Build, id), + ); + Rendered { + title: Some(String::from("Build failed")), + message: format!("Build {name} failed at v{version}"), + badge: None, + data, + } + } + AlertData::RepoBuildFailed { id, name } => { + insert(&mut data, "name", name); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Repo, id), + ); + Rendered { + title: Some(String::from("Repo build failed")), + message: format!("Repo build for {name} failed"), + badge: None, + data, + } + } + AlertData::ProcedureFailed { id, name } => { + insert(&mut data, "name", name); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Procedure, id), + ); + Rendered { + title: Some(String::from("Procedure failed")), + message: format!("Procedure {name} failed"), + badge: None, + data, + } + } + AlertData::ActionFailed { id, name } => { + insert(&mut data, "name", name); + insert( + &mut data, + "link", + resource_link(ResourceTargetVariant::Action, id), + ); + Rendered { + title: Some(String::from("Action failed")), + message: format!("Action {name} failed"), + badge: None, + data, + } + } + AlertData::ScheduleRun { + resource_type, + id, + name, + } => { + insert(&mut data, "name", name); + insert(&mut data, "link", resource_link(*resource_type, id)); + Rendered { + title: Some(String::from("Scheduled run")), + message: format!( + "{name} ({resource_type}) scheduled run started" + ), + badge: None, + data, + } + } + // Custom alerts already carry a headline / body, so pass + // them through instead of a generic title. Pinglet rejects + // an empty message, so an all-empty custom alert falls back + // to a placeholder body. + AlertData::Custom { message, details } => { + let (title, message) = match ( + message.trim().is_empty(), + details.trim().is_empty(), + ) { + (false, false) => (Some(message.clone()), details.clone()), + (false, true) => (None, message.clone()), + (true, false) => (None, details.clone()), + (true, true) => (None, String::from("Custom alert")), + }; + Rendered { + title, + message, + badge: None, + data, + } + } + }; + Some(rendered) +} + +/// Assemble the full detail-sheet metadata: a `source` marker +/// and the alert target type, then the variant specific +/// entries collected in [`render`]. +fn data_bag( + alert: &Alert, + mut data: Map, +) -> Option { + data.insert(String::from("source"), json!("komodo")); + data.insert( + String::from("resource_type"), + json!(alert.target.extract_variant().to_string()), + ); + Some(Value::Object(data)) +} + +/// ` on server {server}` / ` on swarm {swarm}`, or empty when +/// the alert isn't attached to either — for inlining into a +/// sentence. +fn host_suffix( + server_name: &Option, + swarm_name: &Option, +) -> String { + if let Some(swarm) = swarm_name { + format!(" on swarm {swarm}") + } else if let Some(server) = server_name { + format!(" on server {server}") + } else { + String::new() + } +} + +fn insert( + data: &mut Map, + key: &str, + value: impl AsRef, +) { + data.insert( + key.to_string(), + json!(truncate(value.as_ref(), MAX_DATA_VALUE_LEN)), + ); +} + +fn insert_opt( + data: &mut Map, + key: &str, + value: &Option, +) { + if let Some(value) = value { + insert(data, key, value); + } +} + +/// Pinglet rejects (rather than truncates) over-length badge / +/// data values, so trim them before sending. +fn truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + s.chars().take(max_chars).collect() + } +} + +fn http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(reqwest::Client::new) +} diff --git a/client/core/rs/src/entities/alerter.rs b/client/core/rs/src/entities/alerter.rs index 7a4d9c2d6f..d6063d1db8 100644 --- a/client/core/rs/src/entities/alerter.rs +++ b/client/core/rs/src/entities/alerter.rs @@ -170,6 +170,9 @@ pub enum AlerterEndpoint { /// Send alert to Pushover Pushover(PushoverAlerterEndpoint), + + /// Send alert to Pinglet + Pinglet(PingletAlerterEndpoint), } impl Default for AlerterEndpoint { @@ -319,6 +322,39 @@ fn default_pushover_url() -> String { ) } +/// Configuration for a Pinglet alerter. +#[typeshare] +#[derive( + Debug, Clone, PartialEq, Serialize, Deserialize, Builder, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct PingletAlerterEndpoint { + /// The Pinglet topic publish URL, + /// ie `https://app.pinglet.co.uk//` + #[serde(default = "default_pinglet_url")] + #[builder(default = "default_pinglet_url()")] + pub url: String, + + /// The Pinglet API key, sent as a Bearer token. + /// Supports variable / secret interpolation, + /// eg `[[PINGLET_API_KEY]]`. + pub token: Option, +} + +impl Default for PingletAlerterEndpoint { + fn default() -> Self { + Self { + url: default_pinglet_url(), + token: None, + } + } +} + +fn default_pinglet_url() -> String { + String::from("https://app.pinglet.co.uk/namespace/komodo") +} + // QUERY #[typeshare] pub type AlerterQuery = ResourceQuery; diff --git a/client/core/ts/src/types.ts b/client/core/ts/src/types.ts index cb7f0c110f..be96fbbd08 100644 --- a/client/core/ts/src/types.ts +++ b/client/core/ts/src/types.ts @@ -227,7 +227,9 @@ export type AlerterEndpoint = /** Send alert to Ntfy */ | { type: "Ntfy", params: NtfyAlerterEndpoint } /** Send alert to Pushover */ - | { type: "Pushover", params: PushoverAlerterEndpoint }; + | { type: "Pushover", params: PushoverAlerterEndpoint } + /** Send alert to Pinglet */ + | { type: "Pinglet", params: PingletAlerterEndpoint }; /** Used to reference a specific resource across all resource types */ export type ResourceTarget = @@ -9304,6 +9306,21 @@ export interface PushoverAlerterEndpoint { url: string; } +/** Configuration for a Pinglet alerter. */ +export interface PingletAlerterEndpoint { + /** + * The Pinglet topic publish URL, + * ie `https://app.pinglet.co.uk//` + */ + url: string; + /** + * The Pinglet API key, sent as a Bearer token. + * Supports variable / secret interpolation, + * eg `[[PINGLET_API_KEY]]`. + */ + token?: string; +} + /** Trigger a refresh of the cached latest hash and message. */ export interface RefreshBuildCache { /** Id or name */ diff --git a/ui/public/client/types.d.ts b/ui/public/client/types.d.ts index 244a4c6fc4..9b1a42c6d2 100644 --- a/ui/public/client/types.d.ts +++ b/ui/public/client/types.d.ts @@ -218,6 +218,11 @@ export type AlerterEndpoint = | { type: "Pushover"; params: PushoverAlerterEndpoint; +} +/** Send alert to Pinglet */ + | { + type: "Pinglet"; + params: PingletAlerterEndpoint; }; /** Used to reference a specific resource across all resource types */ export type ResourceTarget = { @@ -8815,6 +8820,20 @@ export interface PushoverAlerterEndpoint { /** The pushover URL including application and user tokens in parameters. */ url: string; } +/** Configuration for a Pinglet alerter. */ +export interface PingletAlerterEndpoint { + /** + * The Pinglet topic publish URL, + * ie `https://app.pinglet.co.uk//` + */ + url: string; + /** + * The Pinglet API key, sent as a Bearer token. + * Supports variable / secret interpolation, + * eg `[[PINGLET_API_KEY]]`. + */ + token?: string; +} /** Trigger a refresh of the cached latest hash and message. */ export interface RefreshBuildCache { /** Id or name */ diff --git a/ui/src/resources/alerter/config/endpoint.tsx b/ui/src/resources/alerter/config/endpoint.tsx index 22f2d29b8c..15d018ec93 100644 --- a/ui/src/resources/alerter/config/endpoint.tsx +++ b/ui/src/resources/alerter/config/endpoint.tsx @@ -8,6 +8,7 @@ const ENDPOINT_TYPES: Types.AlerterEndpoint["type"][] = [ "Slack", "Ntfy", "Pushover", + "Pinglet", ] as const; export default function AlerterConfigEndpoint({ @@ -65,6 +66,21 @@ export default function AlerterConfigEndpoint({ email /> )} + {endpoint.type === "Pinglet" && ( + + set({ + ...endpoint, + params: { ...endpoint.params, token }, + }) + } + disabled={disabled} + /> + )} ); } @@ -80,5 +96,7 @@ function defaultUrl(type: Types.AlerterEndpoint["type"]) { ? "https://ntfy.sh/komodo" : type === "Pushover" ? "https://api.pushover.net/1/messages.json?token=XXXXXXXXXXXXX&user=XXXXXXXXXXXXX" - : ""; + : type === "Pinglet" + ? "https://app.pinglet.co.uk/namespace/komodo" + : ""; }