Skip to main content

studio_worker/ui/tabs/
status.rs

1//! Status tab — surfaces who the worker is, who it's talking to, and
2//! how recently it last successfully heartbeat.  When the worker
3//! hasn't registered yet, this tab shows the in-window Register form
4//! (fork #2 of plans/native-ui.md, default A).
5
6use chrono::{DateTime, Utc};
7use eframe::egui;
8
9use crate::{
10    auto_register::RegistrationState,
11    config::Config,
12    runtime::{GpuRuntimeStatus, HeartbeatOutcome, HeartbeatStatus, SessionState},
13};
14
15/// Pure-data view of the Status tab.  Constructed each frame from
16/// the live shared state; no egui types in scope so it's
17/// unit-testable.  Maps onto `auto_register::RegistrationState` for
18/// the pre-registration phases.
19#[derive(Debug, Clone, PartialEq)]
20pub enum StatusView {
21    /// Worker is auto-registering but hasn't received a request_id
22    /// from the studio yet.  Transient — normally only seen for a
23    /// frame or two on first launch.
24    Initialising { api_base_url: String },
25    /// Studio has a Pending Workers row for this install; UI shows
26    /// the request id + a copy button so the operator can match it
27    /// in the dashboard.
28    Pending {
29        api_base_url: String,
30        request_id: String,
31        since: DateTime<Utc>,
32    },
33    /// Operator rejected the request.  Worker stops trying; the
34    /// hint instructs the user to run `studio-worker register --reset`
35    /// to clear state and try again.
36    Rejected {
37        api_base_url: String,
38        reason: String,
39    },
40    Registered {
41        worker_id: String,
42        api_base_url: String,
43        vram_total_gb: f32,
44        vram_threshold_gb: f32,
45        paused: bool,
46        busy: bool,
47        last_heartbeat: Option<HeartbeatSummary>,
48        /// One-line WS lifecycle summary (connected / reconnecting /
49        /// auth-failed + recovery action).
50        session: String,
51        /// GPU-runtime readiness: `None` = not probed, else (ok, detail).
52        gpu: Option<(bool, String)>,
53    },
54}
55
56#[derive(Debug, Clone, PartialEq)]
57pub struct HeartbeatSummary {
58    pub when: DateTime<Utc>,
59    pub ok: bool,
60    pub reason: Option<String>,
61}
62
63impl HeartbeatSummary {
64    pub fn from(status: &HeartbeatStatus) -> Self {
65        match &status.outcome {
66            HeartbeatOutcome::Ok => Self {
67                when: status.last_attempt_at,
68                ok: true,
69                reason: None,
70            },
71            HeartbeatOutcome::Err { reason } => Self {
72                when: status.last_attempt_at,
73                ok: false,
74                reason: Some(reason.clone()),
75            },
76        }
77    }
78}
79
80impl StatusView {
81    // A pure-data view assembled from the several independent runtime
82    // signals the Status tab shows; grouping them would only rename the
83    // list, not shorten it.
84    #[allow(clippy::too_many_arguments)]
85    pub fn build(
86        cfg: &Config,
87        registered: bool,
88        registration: &RegistrationState,
89        busy: bool,
90        paused: bool,
91        last_heartbeat: Option<&HeartbeatStatus>,
92        vram_total_gb: f32,
93        session_state: &SessionState,
94        gpu_runtime: Option<&GpuRuntimeStatus>,
95    ) -> Self {
96        if registered {
97            return Self::Registered {
98                worker_id: cfg.worker_id.clone().unwrap_or_default(),
99                api_base_url: cfg.api_base_url.clone(),
100                vram_total_gb,
101                vram_threshold_gb: cfg.vram_threshold_gb,
102                paused,
103                busy,
104                last_heartbeat: last_heartbeat.map(HeartbeatSummary::from),
105                session: session_state.summary(),
106                gpu: gpu_runtime.map(|g| (g.ok, g.detail.clone())),
107            };
108        }
109        match registration {
110            RegistrationState::Pending { request_id, since } => Self::Pending {
111                api_base_url: cfg.api_base_url.clone(),
112                request_id: request_id.clone(),
113                since: *since,
114            },
115            RegistrationState::Rejected { reason } => Self::Rejected {
116                api_base_url: cfg.api_base_url.clone(),
117                reason: reason.clone(),
118            },
119            // Pristine — cold start, between requests, or operator
120            // bootstrap path that hasn't completed yet.
121            RegistrationState::Pristine | RegistrationState::Approved => Self::Initialising {
122                api_base_url: cfg.api_base_url.clone(),
123            },
124        }
125    }
126}
127
128/// Human-friendly "5s ago" formatting for a heartbeat timestamp.
129pub fn format_age(now: DateTime<Utc>, when: DateTime<Utc>) -> String {
130    let delta = now.signed_duration_since(when);
131    let secs = delta.num_seconds();
132    if secs < 0 {
133        return "just now".into();
134    }
135    if secs < 60 {
136        return format!("{secs}s ago");
137    }
138    let mins = secs / 60;
139    if mins < 60 {
140        let rem = secs % 60;
141        return format!("{mins}m {rem:02}s ago");
142    }
143    let hours = mins / 60;
144    let rem_min = mins % 60;
145    format!("{hours}h {rem_min:02}m ago")
146}
147
148// ---------------------------------------------------------------------------
149// Rendering
150// ---------------------------------------------------------------------------
151
152/// What the operator asked for on the Status tab.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum StatusAction {
155    /// Pause (`true`) or resume claiming studio jobs.
156    SetPaused(bool),
157    /// Clear a rejected registration and ask the studio again.
158    ResetRegistration,
159}
160
161pub fn render(ui: &mut egui::Ui, view: &StatusView) -> Option<StatusAction> {
162    match view {
163        StatusView::Initialising { api_base_url } => render_initialising(ui, api_base_url),
164        StatusView::Pending {
165            api_base_url,
166            request_id,
167            since,
168        } => render_pending(ui, api_base_url, request_id, *since),
169        StatusView::Rejected {
170            api_base_url,
171            reason,
172        } => return render_rejected(ui, api_base_url, reason),
173        StatusView::Registered { .. } => return render_registered(ui, view),
174    }
175    None
176}
177
178fn render_initialising(ui: &mut egui::Ui, api_base_url: &str) {
179    ui.heading("Initialising");
180    ui.add_space(4.0);
181    ui.horizontal(|ui| {
182        ui.spinner();
183        ui.label(format!(
184            "Asking {api_base_url} for a registration slot\u{2026}"
185        ));
186    });
187    ui.add_space(8.0);
188    ui.label(
189        egui::RichText::new(
190            "No action needed.  The worker will keep retrying until it gets through.",
191        )
192        .italics()
193        .color(egui::Color32::from_gray(160)),
194    );
195}
196
197fn render_pending(ui: &mut egui::Ui, api_base_url: &str, request_id: &str, since: DateTime<Utc>) {
198    ui.heading("Waiting for approval");
199    ui.add_space(4.0);
200    ui.label(format!(
201        "This worker has registered with {api_base_url} and is waiting for the \
202         studio operator to approve it.  You can keep this window open or close \
203         it \u{2014} the worker keeps polling in the background."
204    ));
205    ui.add_space(12.0);
206    egui::Grid::new("pending_grid")
207        .num_columns(2)
208        .spacing([12.0, 6.0])
209        .show(ui, |ui| {
210            ui.label("Request ID");
211            ui.horizontal(|ui| {
212                ui.monospace(request_id);
213                if ui.button("Copy").clicked() {
214                    ui.ctx().copy_text(request_id.to_string());
215                }
216            });
217            ui.end_row();
218
219            ui.label("Waiting");
220            ui.label(format_age(Utc::now(), since));
221            ui.end_row();
222        });
223    ui.add_space(8.0);
224    ui.label(
225        egui::RichText::new(
226            "Share the Request ID with the studio operator if you want them to \
227             find your pending row quickly.",
228        )
229        .italics()
230        .color(egui::Color32::from_gray(160)),
231    );
232}
233
234fn render_rejected(ui: &mut egui::Ui, api_base_url: &str, reason: &str) -> Option<StatusAction> {
235    ui.heading("Registration rejected");
236    ui.add_space(4.0);
237    ui.colored_label(
238        egui::Color32::LIGHT_RED,
239        if reason.is_empty() {
240            "The studio operator rejected this worker's registration.".to_string()
241        } else {
242            format!("The studio operator rejected this worker's registration: {reason}")
243        },
244    );
245    ui.add_space(12.0);
246    ui.label(format!(
247        "Local models and the local API keep working.  To ask {api_base_url} again, \
248         check with its operator why, then reset the registration:"
249    ));
250    ui.add_space(4.0);
251    let clicked = ui
252        .button("Reset registration")
253        .on_hover_text("clear the local registration state and submit a fresh request")
254        .clicked();
255    ui.add_space(4.0);
256    ui.label(
257        egui::RichText::new("Same as `studio-worker register --reset`, without a restart.")
258            .italics()
259            .color(egui::Color32::from_gray(160)),
260    );
261    clicked.then_some(StatusAction::ResetRegistration)
262}
263
264fn render_registered(ui: &mut egui::Ui, view: &StatusView) -> Option<StatusAction> {
265    let StatusView::Registered {
266        worker_id,
267        api_base_url,
268        vram_total_gb,
269        vram_threshold_gb,
270        paused,
271        busy,
272        last_heartbeat,
273        session,
274        gpu,
275    } = view
276    else {
277        unreachable!();
278    };
279
280    ui.heading("Worker status");
281    ui.add_space(4.0);
282
283    let badge = if *busy {
284        ("BUSY", egui::Color32::from_rgb(232, 168, 56))
285    } else if *paused {
286        ("PAUSED", egui::Color32::LIGHT_GRAY)
287    } else {
288        ("IDLE", egui::Color32::LIGHT_GREEN)
289    };
290    ui.horizontal(|ui| {
291        ui.label(egui::RichText::new(badge.0).color(badge.1).strong());
292        ui.label("\u{2014}");
293        ui.label(if *busy {
294            "running a job"
295        } else if *paused {
296            "claiming paused by operator"
297        } else {
298            "waiting for work"
299        });
300    });
301    ui.add_space(8.0);
302
303    let mut action = None;
304    ui.horizontal(|ui| {
305        let (label, hint) = if *paused {
306            ("Resume", "start accepting new job offers again")
307        } else {
308            (
309                "Pause",
310                "stop accepting new job offers (in-flight job, if any, will finish)",
311            )
312        };
313        if ui.button(label).on_hover_text(hint).clicked() {
314            action = Some(StatusAction::SetPaused(!*paused));
315        }
316    });
317    ui.add_space(8.0);
318
319    egui::Grid::new("status_grid")
320        .num_columns(2)
321        .spacing([12.0, 6.0])
322        .show(ui, |ui| {
323            ui.label("Worker ID");
324            ui.monospace(worker_id);
325            ui.end_row();
326
327            ui.label("API base URL");
328            ui.monospace(api_base_url);
329            ui.end_row();
330
331            ui.label("VRAM total");
332            ui.label(format!("{vram_total_gb:.1} GB"));
333            ui.end_row();
334
335            ui.label("VRAM threshold");
336            ui.label(format!("{vram_threshold_gb:.1} GB per claim"));
337            ui.end_row();
338
339            ui.label("Connection");
340            // Auth-failed / fatal states carry a recovery action — draw
341            // them red so a stranded worker is impossible to miss.
342            if session.contains("failed") || session.contains("ended") {
343                ui.colored_label(egui::Color32::LIGHT_RED, session);
344            } else {
345                ui.label(session);
346            }
347            ui.end_row();
348
349            // GPU runtime: red when missing, with the exact remedy, so a
350            // box that can't run image jobs says why up front.
351            if let Some((ok, detail)) = gpu {
352                ui.label("GPU runtime");
353                if *ok {
354                    ui.colored_label(egui::Color32::LIGHT_GREEN, detail);
355                } else {
356                    ui.colored_label(egui::Color32::LIGHT_RED, detail);
357                }
358                ui.end_row();
359            }
360
361            ui.label("Last heartbeat");
362            match last_heartbeat {
363                None => ui.label("never"),
364                Some(h) => {
365                    let when = format_age(Utc::now(), h.when);
366                    if h.ok {
367                        ui.colored_label(egui::Color32::LIGHT_GREEN, format!("ok \u{00b7} {when}"))
368                    } else {
369                        let reason = h.reason.as_deref().unwrap_or("unknown");
370                        ui.colored_label(
371                            egui::Color32::LIGHT_RED,
372                            format!("error \u{00b7} {when} \u{00b7} {reason}"),
373                        )
374                    }
375                }
376            };
377            ui.end_row();
378        });
379    action
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::config::Config;
386    use crate::runtime::HeartbeatStatus;
387    use chrono::TimeZone;
388
389    fn registered_of(cfg: &Config) -> bool {
390        cfg.worker_id.is_some() && cfg.auth_token.is_some()
391    }
392
393    fn registered_cfg() -> Config {
394        Config {
395            worker_id: Some("w-abc".into()),
396            auth_token: Some("tok-xyz".into()),
397            api_base_url: "https://studio.example".into(),
398            vram_threshold_gb: 12.0,
399            ..Config::default()
400        }
401    }
402
403    #[test]
404    fn build_initialising_when_pristine_and_unregistered() {
405        let cfg = Config::default();
406        let view = StatusView::build(
407            &cfg,
408            registered_of(&cfg),
409            &RegistrationState::Pristine,
410            false,
411            false,
412            None,
413            0.0,
414            &SessionState::default(),
415            None,
416        );
417        match view {
418            StatusView::Initialising { api_base_url } => {
419                assert_eq!(api_base_url, cfg.api_base_url);
420            }
421            other => panic!("expected Initialising, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn build_pending_when_state_pending() {
427        let cfg = Config::default();
428        let since = Utc::now();
429        let view = StatusView::build(
430            &cfg,
431            registered_of(&cfg),
432            &RegistrationState::Pending {
433                request_id: "rr-42".into(),
434                since,
435            },
436            false,
437            false,
438            None,
439            0.0,
440            &SessionState::default(),
441            None,
442        );
443        match view {
444            StatusView::Pending {
445                request_id,
446                since: s,
447                ..
448            } => {
449                assert_eq!(request_id, "rr-42");
450                assert_eq!(s, since);
451            }
452            other => panic!("expected Pending, got {other:?}"),
453        }
454    }
455
456    #[test]
457    fn build_rejected_when_state_rejected() {
458        let cfg = Config::default();
459        let view = StatusView::build(
460            &cfg,
461            registered_of(&cfg),
462            &RegistrationState::Rejected {
463                reason: "unknown contributor".into(),
464            },
465            false,
466            false,
467            None,
468            0.0,
469            &SessionState::default(),
470            None,
471        );
472        match view {
473            StatusView::Rejected { reason, .. } => assert_eq!(reason, "unknown contributor"),
474            other => panic!("expected Rejected, got {other:?}"),
475        }
476    }
477
478    #[test]
479    fn build_registered_takes_precedence_over_registration_state() {
480        // If worker_id + auth_token are set, the registration state
481        // is irrelevant — we're operational.
482        let cfg = registered_cfg();
483        let view = StatusView::build(
484            &cfg,
485            registered_of(&cfg),
486            &RegistrationState::Pending {
487                request_id: "rr-stale".into(),
488                since: Utc::now(),
489            },
490            false,
491            false,
492            None,
493            24.0,
494            &SessionState::default(),
495            None,
496        );
497        assert!(matches!(view, StatusView::Registered { .. }));
498    }
499
500    #[test]
501    fn build_registered_when_worker_id_and_token_present() {
502        let cfg = registered_cfg();
503        let view = StatusView::build(
504            &cfg,
505            registered_of(&cfg),
506            &RegistrationState::Approved,
507            false,
508            false,
509            None,
510            24.0,
511            &SessionState::default(),
512            None,
513        );
514        match view {
515            StatusView::Registered {
516                worker_id,
517                api_base_url,
518                vram_total_gb,
519                vram_threshold_gb,
520                paused,
521                busy,
522                last_heartbeat,
523                session: _,
524                gpu: _,
525            } => {
526                assert_eq!(worker_id, "w-abc");
527                assert_eq!(api_base_url, "https://studio.example");
528                assert!((vram_total_gb - 24.0).abs() < f32::EPSILON);
529                assert!((vram_threshold_gb - 12.0).abs() < f32::EPSILON);
530                assert!(!paused);
531                assert!(!busy);
532                assert!(last_heartbeat.is_none());
533            }
534            _ => panic!("expected Registered"),
535        }
536    }
537
538    #[test]
539    fn build_registered_propagates_paused() {
540        let cfg = registered_cfg();
541        let view = StatusView::build(
542            &cfg,
543            registered_of(&cfg),
544            &RegistrationState::Approved,
545            false,
546            true,
547            None,
548            24.0,
549            &SessionState::default(),
550            None,
551        );
552        match view {
553            StatusView::Registered { paused, .. } => assert!(paused),
554            _ => panic!("expected Registered"),
555        }
556    }
557
558    #[test]
559    fn build_propagates_heartbeat_ok() {
560        let cfg = registered_cfg();
561        let hb = HeartbeatStatus {
562            last_attempt_at: Utc::now(),
563            outcome: HeartbeatOutcome::Ok,
564        };
565        let view = StatusView::build(
566            &cfg,
567            registered_of(&cfg),
568            &RegistrationState::Approved,
569            false,
570            false,
571            Some(&hb),
572            24.0,
573            &SessionState::default(),
574            None,
575        );
576        match view {
577            StatusView::Registered {
578                last_heartbeat: Some(s),
579                ..
580            } => {
581                assert!(s.ok);
582                assert!(s.reason.is_none());
583            }
584            _ => panic!("expected Registered with heartbeat"),
585        }
586    }
587
588    #[test]
589    fn build_propagates_heartbeat_err() {
590        let cfg = registered_cfg();
591        let hb = HeartbeatStatus {
592            last_attempt_at: Utc::now(),
593            outcome: HeartbeatOutcome::Err {
594                reason: "5xx".into(),
595            },
596        };
597        let view = StatusView::build(
598            &cfg,
599            registered_of(&cfg),
600            &RegistrationState::Approved,
601            true,
602            false,
603            Some(&hb),
604            24.0,
605            &SessionState::default(),
606            None,
607        );
608        match view {
609            StatusView::Registered {
610                busy,
611                last_heartbeat: Some(s),
612                ..
613            } => {
614                assert!(busy);
615                assert!(!s.ok);
616                assert_eq!(s.reason.as_deref(), Some("5xx"));
617            }
618            _ => panic!("expected Registered with err heartbeat"),
619        }
620    }
621
622    #[test]
623    fn format_age_sub_minute() {
624        let now = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 30).unwrap();
625        let then = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 18).unwrap();
626        assert_eq!(format_age(now, then), "12s ago");
627    }
628
629    #[test]
630    fn format_age_sub_hour() {
631        let now = Utc.with_ymd_and_hms(2026, 5, 25, 12, 5, 30).unwrap();
632        let then = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 18).unwrap();
633        assert_eq!(format_age(now, then), "5m 12s ago");
634    }
635
636    #[test]
637    fn format_age_multi_hour() {
638        let now = Utc.with_ymd_and_hms(2026, 5, 25, 14, 5, 0).unwrap();
639        let then = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
640        assert_eq!(format_age(now, then), "2h 05m ago");
641    }
642
643    #[test]
644    fn format_age_future_clamps_to_just_now() {
645        let now = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
646        let then = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 5).unwrap();
647        assert_eq!(format_age(now, then), "just now");
648    }
649}