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