Skip to main content

uptrakit_wire/
admin_events.rs

1//! SSE event types for real-time admin event streaming.
2//!
3//! [`AdminEvent`] is the server-side enum pushed over `GET /api/v1/events/stream`.
4//! Each variant maps to an SSE `event:` name (via [`AdminEvent::event_name`]) with
5//! the variant's inner fields serialised as the `data:` payload.
6
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// A real-time event pushed to admin SSE subscribers.
11///
12/// Each variant represents a state change that the frontend can use to
13/// invalidate and refresh the relevant data. Events are lightweight
14/// invalidation signals — they carry only enough context (entity IDs,
15/// status strings) for the subscriber to decide whether to refetch.
16///
17/// # Wire format
18///
19/// Sent as SSE with `event:` set to [`event_name()`](Self::event_name) and
20/// `data:` set to the JSON-serialised inner fields of the variant.
21///
22/// # Wire forward-compatibility
23///
24/// `Other(String)` is a catch-all for event type strings received from a newer
25/// server that this client does not yet recognise. Serde deserialization is
26/// infallible: an unknown variant becomes `Other(variant_name)` rather than a
27/// parse error, allowing older consumers to survive rolling upgrades without
28/// failing.
29#[derive(Clone, Debug, Serialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum AdminEvent {
33    /// A host's metadata was updated.
34    HostUpdated { id: Uuid },
35    /// A new host was created (e.g. reported by an agent).
36    HostCreated { id: Uuid },
37    /// A host was deactivated / deleted.
38    HostDeleted { id: Uuid },
39    /// A service's status changed (approved, rejected, deactivated).
40    ServiceStatusChanged { id: Uuid, status: String },
41    /// A software item was updated.
42    SoftwareItemUpdated { id: Uuid },
43    /// A new software item was created.
44    SoftwareItemCreated { id: Uuid },
45    /// A version check completed for a host + software item pair.
46    VersionCheckCompleted {
47        host_id: Uuid,
48        software_item_id: Uuid,
49    },
50    /// A software update was created and dispatched to the agent.
51    ///
52    /// Emitted immediately after `trigger_update_for_host` succeeds, before
53    /// the agent confirms start. Allows the History page to show the new
54    /// pending/queued entry in real-time without polling.
55    UpdateTriggered {
56        update_history_id: Uuid,
57        host_id: Uuid,
58        software_item_id: Uuid,
59        /// Trigger status: "pending" (agent connected) or "queued" (agent offline).
60        status: String,
61    },
62    /// Controller pre-update protection started for a software update.
63    ///
64    /// Emitted by the orchestrator when protection (snapshot/backup) begins.
65    /// The frontend transitions the update record to In Progress state on receipt.
66    UpdateProtectionStarted {
67        update_history_id: Uuid,
68        host_id: Uuid,
69        software_item_id: Uuid,
70    },
71    /// A software update started executing.
72    UpdateStarted {
73        update_history_id: Uuid,
74        host_id: Uuid,
75        software_item_id: Uuid,
76        /// Whether the update was dispatched in interactive mode (PTY allocated).
77        ///
78        /// Allows the history list to show an "Input Required" badge in
79        /// real-time without reloading, as soon as the update transitions to
80        /// `in_progress`.
81        interactive: bool,
82    },
83    /// A software update completed (successfully or with failure).
84    UpdateCompleted {
85        update_history_id: Uuid,
86        host_id: Uuid,
87        software_item_id: Uuid,
88        status: String,
89    },
90    /// Autodiscovery completed for a host.
91    DiscoveryCompleted { host_id: Uuid },
92    /// A system service's status changed (approved, rejected, deactivated).
93    SystemServiceStatusChanged { id: Uuid, status: String },
94    /// A scheduled task completed execution.
95    SchedulerTaskCompleted { task_id: Uuid },
96    /// A host tag was created.
97    HostTagCreated { id: Uuid },
98    /// A host tag was updated.
99    HostTagUpdated { id: Uuid },
100    /// A host tag was deleted.
101    HostTagDeleted { id: Uuid },
102    /// Tag assignments changed on a host.
103    HostTagsChanged { host_id: Uuid },
104    /// The global GitHub provider settings are stored in an invalid state.
105    GlobalGitHubProviderMisconfigured { problem: String },
106    /// All tenant data was reset (hosts, software items, etc. deleted).
107    DataReset,
108    /// The surface provider registry changed (provider joined or left).
109    ///
110    /// Carries no payload — coarse invalidation signal. The frontend re-fetches
111    /// `GET /api/v1/surfaces` and provider availability on receipt.
112    SurfacesChanged,
113    /// An unknown event variant received from a newer peer.
114    ///
115    /// The inner string is the raw variant name as it appeared on the wire.
116    /// Deserialization is infallible — unknown variants are captured here rather
117    /// than causing a parse error, so older consumers survive rolling upgrades.
118    Other(String),
119}
120
121impl AdminEvent {
122    /// Returns the SSE `event:` field name for this variant.
123    ///
124    /// The name is the snake_case version of the variant name, matching the
125    /// serde `rename_all = "snake_case"` serialisation. For `Other`, returns
126    /// the raw variant string as received on the wire.
127    pub fn event_name(&self) -> &str {
128        match self {
129            Self::HostUpdated { .. } => "host_updated",
130            Self::HostCreated { .. } => "host_created",
131            Self::HostDeleted { .. } => "host_deleted",
132            Self::ServiceStatusChanged { .. } => "service_status_changed",
133            Self::SoftwareItemUpdated { .. } => "software_item_updated",
134            Self::SoftwareItemCreated { .. } => "software_item_created",
135            Self::VersionCheckCompleted { .. } => "version_check_completed",
136            Self::UpdateTriggered { .. } => "update_triggered",
137            Self::UpdateProtectionStarted { .. } => "update_protection_started",
138            Self::UpdateStarted { .. } => "update_started",
139            Self::UpdateCompleted { .. } => "update_completed",
140            Self::DiscoveryCompleted { .. } => "discovery_completed",
141            Self::SystemServiceStatusChanged { .. } => "system_service_status_changed",
142            Self::SchedulerTaskCompleted { .. } => "scheduler_task_completed",
143            Self::HostTagCreated { .. } => "host_tag_created",
144            Self::HostTagUpdated { .. } => "host_tag_updated",
145            Self::HostTagDeleted { .. } => "host_tag_deleted",
146            Self::HostTagsChanged { .. } => "host_tags_changed",
147            Self::GlobalGitHubProviderMisconfigured { .. } => {
148                "global_github_provider_misconfigured"
149            }
150            Self::DataReset => "data_reset",
151            Self::SurfacesChanged => "surfaces_changed",
152            Self::Other(v) => v.as_str(),
153        }
154    }
155}
156
157// ── Custom Deserialize for wire forward-compatibility ─────────────────────────
158//
159// `AdminEvent` uses serde's externally-tagged format (default for enums):
160//   - struct variants:  `{"host_updated": {"id": "..."}}`
161//   - unit variants:    `"data_reset"` (bare string)
162//
163// We cannot use `#[derive(Deserialize)]` directly because serde's derived impl
164// would return an error for unknown variant keys. Instead we deserialize into a
165// `serde_json::Value` first, extract the variant key, and if unknown return
166// `Other(key)` rather than failing. This preserves rolling-upgrade safety.
167impl<'de> Deserialize<'de> for AdminEvent {
168    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
169        let value = serde_json::Value::deserialize(deserializer)?;
170
171        // Unit variants serialize as bare strings.
172        if let serde_json::Value::String(ref s) = value {
173            return match s.as_str() {
174                "data_reset" => Ok(Self::DataReset),
175                "surfaces_changed" => Ok(Self::SurfacesChanged),
176                other => {
177                    tracing::debug!(variant = other, "received unknown AdminEvent variant");
178                    Ok(Self::Other(other.to_string()))
179                }
180            };
181        }
182
183        // Struct variants serialize as `{"variant_name": {...fields...}}`.
184        let obj = match value {
185            serde_json::Value::Object(map) => map,
186            _ => {
187                return Err(serde::de::Error::custom(
188                    "expected string or object for AdminEvent",
189                ));
190            }
191        };
192
193        let (key, inner) = match obj.into_iter().next() {
194            Some(pair) => pair,
195            None => {
196                return Err(serde::de::Error::custom(
197                    "expected non-empty object for AdminEvent",
198                ));
199            }
200        };
201
202        match key.as_str() {
203            "host_updated" => {
204                #[derive(Deserialize)]
205                struct Inner {
206                    id: Uuid,
207                }
208                let Inner { id } =
209                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
210                Ok(Self::HostUpdated { id })
211            }
212            "host_created" => {
213                #[derive(Deserialize)]
214                struct Inner {
215                    id: Uuid,
216                }
217                let Inner { id } =
218                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
219                Ok(Self::HostCreated { id })
220            }
221            "host_deleted" => {
222                #[derive(Deserialize)]
223                struct Inner {
224                    id: Uuid,
225                }
226                let Inner { id } =
227                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
228                Ok(Self::HostDeleted { id })
229            }
230            "service_status_changed" => {
231                #[derive(Deserialize)]
232                struct Inner {
233                    id: Uuid,
234                    status: String,
235                }
236                let Inner { id, status } =
237                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
238                Ok(Self::ServiceStatusChanged { id, status })
239            }
240            "software_item_updated" => {
241                #[derive(Deserialize)]
242                struct Inner {
243                    id: Uuid,
244                }
245                let Inner { id } =
246                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
247                Ok(Self::SoftwareItemUpdated { id })
248            }
249            "software_item_created" => {
250                #[derive(Deserialize)]
251                struct Inner {
252                    id: Uuid,
253                }
254                let Inner { id } =
255                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
256                Ok(Self::SoftwareItemCreated { id })
257            }
258            "version_check_completed" => {
259                #[derive(Deserialize)]
260                struct Inner {
261                    host_id: Uuid,
262                    software_item_id: Uuid,
263                }
264                let Inner {
265                    host_id,
266                    software_item_id,
267                } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
268                Ok(Self::VersionCheckCompleted {
269                    host_id,
270                    software_item_id,
271                })
272            }
273            "update_triggered" => {
274                fn default_pending_status() -> String {
275                    "pending".into()
276                }
277                #[derive(Deserialize)]
278                struct Inner {
279                    update_history_id: Uuid,
280                    host_id: Uuid,
281                    software_item_id: Uuid,
282                    #[serde(default = "default_pending_status")]
283                    status: String,
284                }
285                let Inner {
286                    update_history_id,
287                    host_id,
288                    software_item_id,
289                    status,
290                } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
291                Ok(Self::UpdateTriggered {
292                    update_history_id,
293                    host_id,
294                    software_item_id,
295                    status,
296                })
297            }
298            "update_protection_started" => {
299                #[derive(Deserialize)]
300                struct Inner {
301                    update_history_id: Uuid,
302                    host_id: Uuid,
303                    software_item_id: Uuid,
304                }
305                let Inner {
306                    update_history_id,
307                    host_id,
308                    software_item_id,
309                } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
310                Ok(Self::UpdateProtectionStarted {
311                    update_history_id,
312                    host_id,
313                    software_item_id,
314                })
315            }
316            "update_started" => {
317                #[derive(Deserialize)]
318                struct Inner {
319                    update_history_id: Uuid,
320                    host_id: Uuid,
321                    software_item_id: Uuid,
322                    interactive: bool,
323                }
324                let Inner {
325                    update_history_id,
326                    host_id,
327                    software_item_id,
328                    interactive,
329                } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
330                Ok(Self::UpdateStarted {
331                    update_history_id,
332                    host_id,
333                    software_item_id,
334                    interactive,
335                })
336            }
337            "update_completed" => {
338                #[derive(Deserialize)]
339                struct Inner {
340                    update_history_id: Uuid,
341                    host_id: Uuid,
342                    software_item_id: Uuid,
343                    status: String,
344                }
345                let Inner {
346                    update_history_id,
347                    host_id,
348                    software_item_id,
349                    status,
350                } = serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
351                Ok(Self::UpdateCompleted {
352                    update_history_id,
353                    host_id,
354                    software_item_id,
355                    status,
356                })
357            }
358            "discovery_completed" => {
359                #[derive(Deserialize)]
360                struct Inner {
361                    host_id: Uuid,
362                }
363                let Inner { host_id } =
364                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
365                Ok(Self::DiscoveryCompleted { host_id })
366            }
367            "system_service_status_changed" => {
368                #[derive(Deserialize)]
369                struct Inner {
370                    id: Uuid,
371                    status: String,
372                }
373                let Inner { id, status } =
374                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
375                Ok(Self::SystemServiceStatusChanged { id, status })
376            }
377            "scheduler_task_completed" => {
378                #[derive(Deserialize)]
379                struct Inner {
380                    task_id: Uuid,
381                }
382                let Inner { task_id } =
383                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
384                Ok(Self::SchedulerTaskCompleted { task_id })
385            }
386            "host_tag_created" => {
387                #[derive(Deserialize)]
388                struct Inner {
389                    id: Uuid,
390                }
391                let Inner { id } =
392                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
393                Ok(Self::HostTagCreated { id })
394            }
395            "host_tag_updated" => {
396                #[derive(Deserialize)]
397                struct Inner {
398                    id: Uuid,
399                }
400                let Inner { id } =
401                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
402                Ok(Self::HostTagUpdated { id })
403            }
404            "host_tag_deleted" => {
405                #[derive(Deserialize)]
406                struct Inner {
407                    id: Uuid,
408                }
409                let Inner { id } =
410                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
411                Ok(Self::HostTagDeleted { id })
412            }
413            "host_tags_changed" => {
414                #[derive(Deserialize)]
415                struct Inner {
416                    host_id: Uuid,
417                }
418                let Inner { host_id } =
419                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
420                Ok(Self::HostTagsChanged { host_id })
421            }
422            // Note: serde's snake_case renaming converts "GitHub" → "git_hub",
423            // so the wire key is "global_git_hub_provider_misconfigured".
424            "global_git_hub_provider_misconfigured" => {
425                #[derive(Deserialize)]
426                struct Inner {
427                    problem: String,
428                }
429                let Inner { problem } =
430                    serde_json::from_value(inner).map_err(serde::de::Error::custom)?;
431                Ok(Self::GlobalGitHubProviderMisconfigured { problem })
432            }
433            other => {
434                tracing::debug!(variant = other, "received unknown AdminEvent variant");
435                Ok(Self::Other(other.to_string()))
436            }
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    /// All known (non-Other) variants for exhaustive testing.
446    const KNOWN_VARIANTS: &[&str] = &[
447        "host_updated",
448        "host_created",
449        "host_deleted",
450        "service_status_changed",
451        "software_item_updated",
452        "software_item_created",
453        "version_check_completed",
454        "update_triggered",
455        "update_protection_started",
456        "update_started",
457        "update_completed",
458        "discovery_completed",
459        "system_service_status_changed",
460        "scheduler_task_completed",
461        "host_tag_created",
462        "host_tag_updated",
463        "host_tag_deleted",
464        "host_tags_changed",
465        "global_git_hub_provider_misconfigured",
466        "data_reset",
467        "surfaces_changed",
468    ];
469
470    /// All known variants as `AdminEvent` instances for exhaustive testing.
471    fn all_variants() -> Vec<AdminEvent> {
472        let id = Uuid::nil();
473        vec![
474            AdminEvent::HostUpdated { id },
475            AdminEvent::HostCreated { id },
476            AdminEvent::HostDeleted { id },
477            AdminEvent::ServiceStatusChanged {
478                id,
479                status: "approved".to_string(),
480            },
481            AdminEvent::SoftwareItemUpdated { id },
482            AdminEvent::SoftwareItemCreated { id },
483            AdminEvent::VersionCheckCompleted {
484                host_id: id,
485                software_item_id: id,
486            },
487            AdminEvent::UpdateTriggered {
488                update_history_id: id,
489                host_id: id,
490                software_item_id: id,
491                status: "pending".to_string(),
492            },
493            AdminEvent::UpdateProtectionStarted {
494                update_history_id: id,
495                host_id: id,
496                software_item_id: id,
497            },
498            AdminEvent::UpdateStarted {
499                update_history_id: id,
500                host_id: id,
501                software_item_id: id,
502                interactive: false,
503            },
504            AdminEvent::UpdateCompleted {
505                update_history_id: id,
506                host_id: id,
507                software_item_id: id,
508                status: "completed".to_string(),
509            },
510            AdminEvent::DiscoveryCompleted { host_id: id },
511            AdminEvent::SystemServiceStatusChanged {
512                id,
513                status: "approved".to_string(),
514            },
515            AdminEvent::SchedulerTaskCompleted { task_id: id },
516            AdminEvent::HostTagCreated { id },
517            AdminEvent::HostTagUpdated { id },
518            AdminEvent::HostTagDeleted { id },
519            AdminEvent::HostTagsChanged { host_id: id },
520            AdminEvent::GlobalGitHubProviderMisconfigured {
521                problem: "api_base_url requires auth_token".to_string(),
522            },
523            AdminEvent::DataReset,
524            AdminEvent::SurfacesChanged,
525        ]
526    }
527
528    #[test]
529    fn event_name_returns_correct_strings() {
530        let id = Uuid::nil();
531        assert_eq!(AdminEvent::HostUpdated { id }.event_name(), "host_updated");
532        assert_eq!(AdminEvent::HostCreated { id }.event_name(), "host_created");
533        assert_eq!(AdminEvent::HostDeleted { id }.event_name(), "host_deleted");
534        assert_eq!(
535            AdminEvent::ServiceStatusChanged {
536                id,
537                status: String::new()
538            }
539            .event_name(),
540            "service_status_changed"
541        );
542        assert_eq!(
543            AdminEvent::SoftwareItemUpdated { id }.event_name(),
544            "software_item_updated"
545        );
546        assert_eq!(
547            AdminEvent::SoftwareItemCreated { id }.event_name(),
548            "software_item_created"
549        );
550        assert_eq!(
551            AdminEvent::VersionCheckCompleted {
552                host_id: id,
553                software_item_id: id,
554            }
555            .event_name(),
556            "version_check_completed"
557        );
558        assert_eq!(
559            AdminEvent::UpdateTriggered {
560                update_history_id: id,
561                host_id: id,
562                software_item_id: id,
563                status: "pending".to_string(),
564            }
565            .event_name(),
566            "update_triggered"
567        );
568        assert_eq!(
569            AdminEvent::UpdateStarted {
570                update_history_id: id,
571                host_id: id,
572                software_item_id: id,
573                interactive: false,
574            }
575            .event_name(),
576            "update_started"
577        );
578        assert_eq!(
579            AdminEvent::UpdateCompleted {
580                update_history_id: id,
581                host_id: id,
582                software_item_id: id,
583                status: String::new(),
584            }
585            .event_name(),
586            "update_completed"
587        );
588        assert_eq!(
589            AdminEvent::DiscoveryCompleted { host_id: id }.event_name(),
590            "discovery_completed"
591        );
592        assert_eq!(
593            AdminEvent::SystemServiceStatusChanged {
594                id,
595                status: String::new()
596            }
597            .event_name(),
598            "system_service_status_changed"
599        );
600        assert_eq!(
601            AdminEvent::SchedulerTaskCompleted { task_id: id }.event_name(),
602            "scheduler_task_completed"
603        );
604        assert_eq!(
605            AdminEvent::GlobalGitHubProviderMisconfigured {
606                problem: String::new(),
607            }
608            .event_name(),
609            "global_github_provider_misconfigured"
610        );
611    }
612
613    #[test]
614    fn event_name_count_matches_variant_count() {
615        // Variant guard: if a new variant is added without updating KNOWN_VARIANTS
616        // and all_variants(), this test will fail.
617        assert_eq!(all_variants().len(), KNOWN_VARIANTS.len());
618    }
619
620    #[test]
621    fn update_protection_started_event_name() {
622        let id = Uuid::nil();
623        let event = AdminEvent::UpdateProtectionStarted {
624            update_history_id: id,
625            host_id: id,
626            software_item_id: id,
627        };
628        assert_eq!(event.event_name(), "update_protection_started");
629    }
630
631    /// Verify OUR custom `Deserialize` impl: unknown variants deserialize to
632    /// `Other(String)` rather than returning an error, enabling rolling upgrades.
633    #[test]
634    fn unknown_variant_deserializes_to_other() {
635        // Struct-style unknown variant (object form)
636        let json = r#"{"future_variant":{"host_id":"00000000-0000-0000-0000-000000000000"}}"#;
637        let event: AdminEvent = serde_json::from_str(json).expect("should accept unknown variant");
638        assert!(
639            matches!(event, AdminEvent::Other(ref v) if v == "future_variant"),
640            "expected Other(\"future_variant\"), got: {event:?}"
641        );
642    }
643
644    /// Verify unit-style unknown variants also deserialize to `Other(String)`.
645    #[test]
646    fn unknown_unit_variant_deserializes_to_other() {
647        let json = r#""brand_new_unit_event""#;
648        let event: AdminEvent = serde_json::from_str(json).expect("should accept unknown variant");
649        assert!(
650            matches!(event, AdminEvent::Other(ref v) if v == "brand_new_unit_event"),
651            "expected Other(\"brand_new_unit_event\"), got: {event:?}"
652        );
653    }
654
655    /// Verify that all known variants round-trip through serialize → deserialize
656    /// and that event_name() is preserved. This tests OUR custom Deserialize impl,
657    /// not serde's generic derive behavior.
658    #[test]
659    fn known_variants_round_trip_through_custom_deserialize() {
660        for event in all_variants() {
661            let json = serde_json::to_string(&event).expect("serialization should succeed");
662            let deserialized: AdminEvent =
663                serde_json::from_str(&json).expect("deserialization should succeed");
664            assert_eq!(
665                event.event_name(),
666                deserialized.event_name(),
667                "event_name mismatch after round-trip for: {json}"
668            );
669            // Deserialized known variants must NOT produce Other(_).
670            assert!(
671                !matches!(deserialized, AdminEvent::Other(_)),
672                "known variant round-tripped to Other: {json}"
673            );
674        }
675    }
676
677    #[test]
678    fn update_triggered_missing_status_defaults_to_pending() {
679        let json = r#"{"update_triggered":{"update_history_id":"00000000-0000-0000-0000-000000000000","host_id":"00000000-0000-0000-0000-000000000000","software_item_id":"00000000-0000-0000-0000-000000000000"}}"#;
680        let event: AdminEvent =
681            serde_json::from_str(json).expect("backward-compat deserialization");
682        assert!(
683            matches!(event, AdminEvent::UpdateTriggered { status: ref s, .. } if s == "pending"),
684            "expected UpdateTriggered with pending status, got: {event:?}"
685        );
686    }
687
688    /// Verify that `Other(String)` event_name() returns the raw variant string.
689    #[test]
690    fn other_event_name_returns_raw_string() {
691        let event = AdminEvent::Other("some_future_event".to_string());
692        assert_eq!(event.event_name(), "some_future_event");
693    }
694}