From 01d37d1e53c1b47aaf5319cd2421dbbb253916ed Mon Sep 17 00:00:00 2001 From: Jairo Llopis Date: Wed, 19 Aug 2026 09:47:13 +0100 Subject: [PATCH] feat: enable declarative status_page entities with stable monitor references AutoKuma already parsed and created/deleted status_page entities, but two gaps made them impractical to use from Kubernetes: - Updates were silently ignored because update_entity only handled Monitor, DockerHost, Notification, and Tag. - public_group_list monitors had to be referenced by numeric Uptime Kuma id, which users cannot know in advance. Resolve both by: - Adding an edit_status_page branch to sync.rs so status page changes are reconciled. - Introducing an internal entity_id field on PublicGroupMonitor that AutoKuma resolves to the stored Uptime Kuma monitor id before saving. - Accepting snake_case aliases for publicGroupList/monitorList so Kubernetes CRs feel natural. Add a Kubernetes example and document the new status_page type and entity_id field in ENTITY_TYPES.md. Assisted-by: OpenCode + Kimi k2.7-code --- ENTITY_TYPES.md | 28 ++++++++++- autokuma/kubernetes/status-page-example.yml | 16 ++++++ autokuma/src/entity.rs | 37 +++++++++++++- autokuma/src/sync.rs | 3 ++ kuma-client/src/models/status_page.rs | 54 ++++++++++++++++++++- 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 autokuma/kubernetes/status-page-example.yml diff --git a/ENTITY_TYPES.md b/ENTITY_TYPES.md index 7d3eb6a..efc2bd4 100644 --- a/ENTITY_TYPES.md +++ b/ENTITY_TYPES.md @@ -8,6 +8,7 @@ AutoKuma adds a few special properties which are handled internally and aren't s | `tag_names` | `[{"name": "mytag", "value": "A value" }]` | List of structs containing the id and optionally a values for labels, | | `docker_host_name` | `local_socket` | The autokuma id of the docker socket for a docker monitor | | `create_paused` | false | If true new monitors will be added in paused state, does not effect existing monitors | +| `entity_id` | `my-app-kuma` | Inside a `status_page` `public_group_list` monitor entry, the autokuma id of the monitor to include. Resolved to the Uptime Kuma monitor id before saving. | # `docker_host` | Property | Example Value | @@ -22,10 +23,11 @@ AutoKuma adds a few special properties which are handled internally and aren't s | `is_default` | `true` (Note: this is only used by the WebUI, AutoKuma does not respect this setting for technical reasons) | | `config` | nested provider specific settings. Too many to list here. I suggest creating a notification with your provider in the WebUI and then using the `kuma` CLI to inspect the options | -# Monitor Types +# Entity Types - [AutoKuma specific properties:](#autokuma-specific-properties) - [`docker_host`](#docker_host) - [`notification`](#notification) +- [`status_page`](#status_page) - [Monitor Types](#monitor-types) - [`dns`](#dns) - [`docker`](#docker) @@ -55,6 +57,30 @@ AutoKuma adds a few special properties which are handled internally and aren't s - [`rabbitmq`](#rabbitmq) +## `status_page` +| Property | Example Value | +|-------------------|------------------------------------------------------------| +| `slug` | `production` | +| `title` | `Production Status` | +| `description` | `Status page for the production environment` | +| `published` | `true` | +| `icon` | `/icon.svg` | +| `theme` | `auto` | +| `showTags` | `false` | +| `domainNameList` | `["status.example.com"]` | +| `customCSS` | `body {\n \n}\n` | +| `footerText` | `Managed by AutoKuma` | +| `showPoweredBy` | `true` | +| `analyticsType` | `google` | +| `analyticsId` | `G-XXXXXXXXXX` | +| `analyticsScriptUrl` | `https://analytics.example.com/script.js` | +| `showCertificateExpiry` | `true` | +| `publicGroupList` | `[{"name": "Services", "monitorList": [{"entity_id": "my-app-kuma"}, {"entity_id": "my-db-kuma"}]}]` | + +Inside `publicGroupList` each monitor entry supports either: +- `id`: the numeric Uptime Kuma monitor id. +- `entity_id`: the autokuma id of the monitor. AutoKuma resolves this to the numeric `id` using the internal mapping before saving the status page. The referenced monitor must already be known to AutoKuma (created in a previous sync or an earlier resource in the same source); otherwise the status page will be skipped until the monitor exists. + ## `dns` | Property | Example Value | |------------------------|---------------| diff --git a/autokuma/kubernetes/status-page-example.yml b/autokuma/kubernetes/status-page-example.yml new file mode 100644 index 0000000..eda1cab --- /dev/null +++ b/autokuma/kubernetes/status-page-example.yml @@ -0,0 +1,16 @@ +apiVersion: autokuma.bigboot.dev/v1 +kind: KumaEntity +metadata: + name: production-status + namespace: default +spec: + config: + type: status_page + slug: production + title: Production Status + published: true + public_group_list: + - name: Services + monitorList: + - entity_id: my-app-kuma + - entity_id: my-db-kuma diff --git a/autokuma/src/entity.rs b/autokuma/src/entity.rs index c0e1ecb..9f38c55 100644 --- a/autokuma/src/entity.rs +++ b/autokuma/src/entity.rs @@ -378,6 +378,37 @@ pub fn get_entities_from_labels( .collect() } +fn resolve_status_page_names(state: Arc, status_page: &mut StatusPage) -> Result<()> { + if let Some(groups) = status_page.public_group_list.as_mut() { + for group in groups.iter_mut() { + for monitor in group.monitor_list.iter_mut() { + if monitor.id.is_some() { + continue; + } + + let entity_id = monitor + .entity_id + .clone() + .ok_or_else(|| Error::DeserializeError( + "Status page monitor entry must have either `id` or `entity_id`".to_owned(), + ))?; + + let name = Name::Monitor(entity_id); + let id = state + .db + .get_id::(name.clone()) + .ok() + .flatten() + .ok_or_else(|| Error::NameNotFound(name))?; + + monitor.id = Some(id); + } + } + } + + Ok(()) +} + fn resolve_names(state: Arc, monitor: &mut Monitor) -> Result<()> { if let Some(group_name) = monitor.common().parent_name().clone() { let name = Name::Monitor(group_name.clone()); @@ -516,7 +547,11 @@ pub fn get_entity_from_settings( if let Entity::Monitor(monitor) = &mut entity { monitor.validate(id)?; - resolve_names(state, monitor)?; + resolve_names(state.clone(), monitor)?; + } + + if let Entity::StatusPage(status_page) = &mut entity { + resolve_status_page_names(state, status_page)?; } Ok(entity) diff --git a/autokuma/src/sync.rs b/autokuma/src/sync.rs index 89740a6..734e60e 100644 --- a/autokuma/src/sync.rs +++ b/autokuma/src/sync.rs @@ -178,6 +178,9 @@ impl Sync { (Entity::Tag(merge), Entity::Tag(_)) => { kuma.edit_tag(merge).await?; } + (Entity::StatusPage(merge), Entity::StatusPage(_)) => { + kuma.edit_status_page(merge).await?; + } _ => {} } } diff --git a/kuma-client/src/models/status_page.rs b/kuma-client/src/models/status_page.rs index 26a5dc3..4f6f838 100644 --- a/kuma-client/src/models/status_page.rs +++ b/kuma-client/src/models/status_page.rs @@ -25,6 +25,11 @@ pub struct PublicGroupMonitor { #[derivative(Hash = "ignore")] pub name: Option, + #[serde(skip_serializing)] + #[derivative(PartialEq = "ignore")] + #[derivative(Hash = "ignore")] + pub entity_id: Option, + #[serde(rename = "weight")] #[serde_as(as = "Option")] pub weight: Option, @@ -55,7 +60,7 @@ pub struct PublicGroup { #[serde_as(as = "Option")] pub weight: Option, - #[serde(rename = "monitorList", default)] + #[serde(rename = "monitorList", alias = "monitor_list", default)] pub monitor_list: PublicGroupMonitorList, } crate::default_from_serde!(PublicGroup); @@ -145,7 +150,7 @@ pub struct StatusPage { #[serde_as(as = "Option")] pub show_certificate_expiry: Option, - #[serde(rename = "publicGroupList")] + #[serde(rename = "publicGroupList", alias = "public_group_list")] #[serde_as(as = "Option>")] pub public_group_list: Option, } @@ -154,3 +159,48 @@ crate::default_from_serde!(StatusPage); pub type StatusPageList = HashMap; pub type PublicGroupList = Vec; pub type PublicGroupMonitorList = Vec; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_page_deserializes_snake_case_aliases_and_entity_id() { + let json = serde_json::json!({ + "type": "status_page", + "slug": "production", + "title": "Production Status", + "public_group_list": [ + { + "name": "Services", + "monitor_list": [ + { "entity_id": "my-app-kuma" }, + { "entity_id": "my-db-kuma" }, + ] + } + ] + }); + + let page: StatusPage = serde_json::from_value(json).expect("should parse status page"); + let group = page.public_group_list.expect("should have groups").into_iter().next().expect("should have one group"); + assert_eq!(group.name.as_deref(), Some("Services")); + assert_eq!(group.monitor_list.len(), 2); + assert_eq!(group.monitor_list[0].entity_id.as_deref(), Some("my-app-kuma")); + assert!(group.monitor_list[0].id.is_none()); + } + + #[test] + fn public_group_monitor_skips_entity_id_on_serialization() { + let monitor = PublicGroupMonitor { + id: Some(42), + name: None, + entity_id: Some("my-app-kuma".to_owned()), + weight: None, + monitor_type: None, + }; + + let json = serde_json::to_value(&monitor).expect("should serialize"); + assert!(json.get("entity_id").is_none()); + assert_eq!(json.get("id").and_then(|v| v.as_i64()), Some(42)); + } +}