1use 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#[derive(Debug, Clone, PartialEq)]
20pub enum StatusView {
21 Initialising { api_base_url: String },
25 Pending {
29 api_base_url: String,
30 request_id: String,
31 since: DateTime<Utc>,
32 },
33 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 session: String,
51 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 #[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 RegistrationState::Pristine | RegistrationState::Approved => Self::Initialising {
122 api_base_url: cfg.api_base_url.clone(),
123 },
124 }
125 }
126}
127
128pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum StatusAction {
155 SetPaused(bool),
157 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 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 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 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}