Skip to main content

uptrakit_web_api_types/
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#[derive(Clone, Debug, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum AdminEvent {
25    /// A host's metadata was updated.
26    HostUpdated { id: Uuid },
27    /// A new host was created (e.g. reported by an agent).
28    HostCreated { id: Uuid },
29    /// A host was deactivated / deleted.
30    HostDeleted { id: Uuid },
31    /// A service's status changed (approved, rejected, deactivated).
32    ServiceStatusChanged { id: Uuid, status: String },
33    /// A software item was updated.
34    SoftwareItemUpdated { id: Uuid },
35    /// A new software item was created.
36    SoftwareItemCreated { id: Uuid },
37    /// A version check completed for a host + software item pair.
38    VersionCheckCompleted {
39        host_id: Uuid,
40        software_item_id: Uuid,
41    },
42    /// A software update was created and dispatched to the agent.
43    ///
44    /// Emitted immediately after `trigger_update_for_host` succeeds, before
45    /// the agent confirms start. Allows the History page to show the new
46    /// pending/queued entry in real-time without polling.
47    UpdateTriggered {
48        update_history_id: Uuid,
49        host_id: Uuid,
50        software_item_id: Uuid,
51    },
52    /// Controller pre-update protection started for a software update.
53    ///
54    /// Emitted by the orchestrator when protection (snapshot/backup) begins.
55    /// The frontend transitions the update record to In Progress state on receipt.
56    UpdateProtectionStarted {
57        update_history_id: Uuid,
58        host_id: Uuid,
59        software_item_id: Uuid,
60    },
61    /// A software update started executing.
62    UpdateStarted {
63        update_history_id: Uuid,
64        host_id: Uuid,
65        software_item_id: Uuid,
66        /// Whether the update was dispatched in interactive mode (PTY allocated).
67        ///
68        /// Allows the history list to show an "Input Required" badge in
69        /// real-time without reloading, as soon as the update transitions to
70        /// `in_progress`.
71        interactive: bool,
72    },
73    /// A software update completed (successfully or with failure).
74    UpdateCompleted {
75        update_history_id: Uuid,
76        host_id: Uuid,
77        software_item_id: Uuid,
78        status: String,
79    },
80    /// Autodiscovery completed for a host.
81    DiscoveryCompleted { host_id: Uuid },
82    /// A system service's status changed (approved, rejected, deactivated).
83    SystemServiceStatusChanged { id: Uuid, status: String },
84    /// A scheduled task completed execution.
85    SchedulerTaskCompleted { task_id: Uuid },
86    /// A host tag was created.
87    HostTagCreated { id: Uuid },
88    /// A host tag was updated.
89    HostTagUpdated { id: Uuid },
90    /// A host tag was deleted.
91    HostTagDeleted { id: Uuid },
92    /// Tag assignments changed on a host.
93    HostTagsChanged { host_id: Uuid },
94    /// The global GitHub provider settings are stored in an invalid state.
95    GlobalGitHubProviderMisconfigured { problem: String },
96    /// All tenant data was reset (hosts, software items, etc. deleted).
97    DataReset,
98}
99
100impl AdminEvent {
101    /// Returns the SSE `event:` field name for this variant.
102    ///
103    /// The name is the snake_case version of the variant name, matching the
104    /// serde `rename_all = "snake_case"` serialisation.
105    pub fn event_name(&self) -> &'static str {
106        match self {
107            Self::HostUpdated { .. } => "host_updated",
108            Self::HostCreated { .. } => "host_created",
109            Self::HostDeleted { .. } => "host_deleted",
110            Self::ServiceStatusChanged { .. } => "service_status_changed",
111            Self::SoftwareItemUpdated { .. } => "software_item_updated",
112            Self::SoftwareItemCreated { .. } => "software_item_created",
113            Self::VersionCheckCompleted { .. } => "version_check_completed",
114            Self::UpdateTriggered { .. } => "update_triggered",
115            Self::UpdateProtectionStarted { .. } => "update_protection_started",
116            Self::UpdateStarted { .. } => "update_started",
117            Self::UpdateCompleted { .. } => "update_completed",
118            Self::DiscoveryCompleted { .. } => "discovery_completed",
119            Self::SystemServiceStatusChanged { .. } => "system_service_status_changed",
120            Self::SchedulerTaskCompleted { .. } => "scheduler_task_completed",
121            Self::HostTagCreated { .. } => "host_tag_created",
122            Self::HostTagUpdated { .. } => "host_tag_updated",
123            Self::HostTagDeleted { .. } => "host_tag_deleted",
124            Self::HostTagsChanged { .. } => "host_tags_changed",
125            Self::GlobalGitHubProviderMisconfigured { .. } => {
126                "global_github_provider_misconfigured"
127            }
128            Self::DataReset => "data_reset",
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// All known variants for exhaustive testing.
138    fn all_variants() -> Vec<AdminEvent> {
139        let id = Uuid::nil();
140        vec![
141            AdminEvent::HostUpdated { id },
142            AdminEvent::HostCreated { id },
143            AdminEvent::HostDeleted { id },
144            AdminEvent::ServiceStatusChanged {
145                id,
146                status: "approved".to_string(),
147            },
148            AdminEvent::SoftwareItemUpdated { id },
149            AdminEvent::SoftwareItemCreated { id },
150            AdminEvent::VersionCheckCompleted {
151                host_id: id,
152                software_item_id: id,
153            },
154            AdminEvent::UpdateTriggered {
155                update_history_id: id,
156                host_id: id,
157                software_item_id: id,
158            },
159            AdminEvent::UpdateProtectionStarted {
160                update_history_id: id,
161                host_id: id,
162                software_item_id: id,
163            },
164            AdminEvent::UpdateStarted {
165                update_history_id: id,
166                host_id: id,
167                software_item_id: id,
168                interactive: false,
169            },
170            AdminEvent::UpdateCompleted {
171                update_history_id: id,
172                host_id: id,
173                software_item_id: id,
174                status: "completed".to_string(),
175            },
176            AdminEvent::DiscoveryCompleted { host_id: id },
177            AdminEvent::SystemServiceStatusChanged {
178                id,
179                status: "approved".to_string(),
180            },
181            AdminEvent::SchedulerTaskCompleted { task_id: id },
182            AdminEvent::HostTagCreated { id },
183            AdminEvent::HostTagUpdated { id },
184            AdminEvent::HostTagDeleted { id },
185            AdminEvent::HostTagsChanged { host_id: id },
186            AdminEvent::GlobalGitHubProviderMisconfigured {
187                problem: "api_base_url requires auth_token".to_string(),
188            },
189            AdminEvent::DataReset,
190        ]
191    }
192
193    #[test]
194    fn serde_round_trip_all_variants() {
195        for event in all_variants() {
196            let json = serde_json::to_string(&event).unwrap();
197            let deserialized: AdminEvent = serde_json::from_str(&json).unwrap();
198            // Verify the event_name matches after round-trip
199            assert_eq!(event.event_name(), deserialized.event_name());
200        }
201    }
202
203    #[test]
204    fn event_name_returns_correct_strings() {
205        let id = Uuid::nil();
206        assert_eq!(AdminEvent::HostUpdated { id }.event_name(), "host_updated");
207        assert_eq!(AdminEvent::HostCreated { id }.event_name(), "host_created");
208        assert_eq!(AdminEvent::HostDeleted { id }.event_name(), "host_deleted");
209        assert_eq!(
210            AdminEvent::ServiceStatusChanged {
211                id,
212                status: String::new()
213            }
214            .event_name(),
215            "service_status_changed"
216        );
217        assert_eq!(
218            AdminEvent::SoftwareItemUpdated { id }.event_name(),
219            "software_item_updated"
220        );
221        assert_eq!(
222            AdminEvent::SoftwareItemCreated { id }.event_name(),
223            "software_item_created"
224        );
225        assert_eq!(
226            AdminEvent::VersionCheckCompleted {
227                host_id: id,
228                software_item_id: id,
229            }
230            .event_name(),
231            "version_check_completed"
232        );
233        assert_eq!(
234            AdminEvent::UpdateTriggered {
235                update_history_id: id,
236                host_id: id,
237                software_item_id: id,
238            }
239            .event_name(),
240            "update_triggered"
241        );
242        assert_eq!(
243            AdminEvent::UpdateStarted {
244                update_history_id: id,
245                host_id: id,
246                software_item_id: id,
247                interactive: false,
248            }
249            .event_name(),
250            "update_started"
251        );
252        assert_eq!(
253            AdminEvent::UpdateCompleted {
254                update_history_id: id,
255                host_id: id,
256                software_item_id: id,
257                status: String::new(),
258            }
259            .event_name(),
260            "update_completed"
261        );
262        assert_eq!(
263            AdminEvent::DiscoveryCompleted { host_id: id }.event_name(),
264            "discovery_completed"
265        );
266        assert_eq!(
267            AdminEvent::SystemServiceStatusChanged {
268                id,
269                status: String::new()
270            }
271            .event_name(),
272            "system_service_status_changed"
273        );
274        assert_eq!(
275            AdminEvent::SchedulerTaskCompleted { task_id: id }.event_name(),
276            "scheduler_task_completed"
277        );
278        assert_eq!(
279            AdminEvent::GlobalGitHubProviderMisconfigured {
280                problem: String::new(),
281            }
282            .event_name(),
283            "global_github_provider_misconfigured"
284        );
285    }
286
287    #[test]
288    fn event_name_count_matches_variant_count() {
289        // If a new variant is added without updating event_name(), this
290        // test will fail because all_variants() won't include it.
291        assert_eq!(all_variants().len(), 20);
292    }
293
294    #[test]
295    fn serde_uses_snake_case_tag() {
296        let event = AdminEvent::HostUpdated { id: Uuid::nil() };
297        let json = serde_json::to_string(&event).unwrap();
298        // The tagged enum serialises with a type discriminator
299        assert!(json.contains("host_updated"), "json was: {json}");
300    }
301
302    #[test]
303    fn update_protection_started_event_name() {
304        let id = Uuid::nil();
305        let event = AdminEvent::UpdateProtectionStarted {
306            update_history_id: id,
307            host_id: id,
308            software_item_id: id,
309        };
310        assert_eq!(event.event_name(), "update_protection_started");
311    }
312}