Skip to main content

studio_worker/ui/pages/
worker.rs

1//! Worker: the worker's state, registration, studio link, hardware, local
2//! API and version on one page (formerly the Status and About tabs).
3
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use eframe::egui::{self, Align, Layout, RichText};
9use parking_lot::Mutex;
10use tokio::runtime::Handle;
11
12use crate::auto_register::RegistrationState;
13use crate::config::Config;
14use crate::runtime::{self, GpuRuntimeStatus, HeartbeatOutcome, HeartbeatStatus, SessionState};
15use crate::{update, AGENT_VERSION, RELEASE_NAME};
16
17use super::super::format::{format_age, format_duration};
18use super::super::icons::{self, Icon};
19use super::super::pulse::{format_gb, Activity};
20use super::super::theme::{Palette, Tone};
21use super::super::widgets;
22
23/// Tracing target of the manual update check.  Stable so operators can
24/// filter with `RUST_LOG=studio_worker::ui::about=info`.
25const TRACE_TARGET: &str = "studio_worker::ui::about";
26
27// ---------------------------------------------------------------------------
28// View model
29// ---------------------------------------------------------------------------
30
31/// Where the worker is with the studio's approval.
32#[derive(Debug, Clone, PartialEq)]
33pub enum RegistrationView {
34    /// Asking for a registration slot; transient on first launch.
35    Initialising,
36    /// Waiting for the studio operator; the request id helps them find it.
37    Pending {
38        request_id: String,
39        since: DateTime<Utc>,
40    },
41    /// The operator said no; a reset asks again.
42    Rejected {
43        reason: String,
44    },
45    Registered {
46        worker_id: String,
47    },
48}
49
50impl RegistrationView {
51    /// Registered wins whatever the registration state says.
52    pub fn build(cfg: &Config, registered: bool, registration: &RegistrationState) -> Self {
53        if registered {
54            return Self::Registered {
55                worker_id: cfg.worker_id.clone().unwrap_or_default(),
56            };
57        }
58        match registration {
59            RegistrationState::Pending { request_id, since } => Self::Pending {
60                request_id: request_id.clone(),
61                since: *since,
62            },
63            RegistrationState::Rejected { reason } => Self::Rejected {
64                reason: reason.clone(),
65            },
66            RegistrationState::Pristine | RegistrationState::Approved => Self::Initialising,
67        }
68    }
69}
70
71/// The last heartbeat, as the page says it.
72#[derive(Debug, Clone, PartialEq)]
73pub struct HeartbeatSummary {
74    pub when: DateTime<Utc>,
75    pub ok: bool,
76    pub reason: Option<String>,
77}
78
79impl HeartbeatSummary {
80    pub fn from(status: &HeartbeatStatus) -> Self {
81        let (ok, reason) = match &status.outcome {
82            HeartbeatOutcome::Ok => (true, None),
83            HeartbeatOutcome::Err { reason } => (false, Some(reason.clone())),
84        };
85        Self {
86            when: status.last_attempt_at,
87            ok,
88            reason,
89        }
90    }
91
92    /// `ok · 5s ago` or `error · 5s ago · <reason>`.
93    pub fn line(&self, now: DateTime<Utc>) -> (String, Tone) {
94        let when = format_age(now, self.when);
95        if self.ok {
96            (format!("ok \u{00b7} {when}"), Tone::Good)
97        } else {
98            let reason = self.reason.as_deref().unwrap_or("unknown");
99            (
100                format!("error \u{00b7} {when} \u{00b7} {reason}"),
101                Tone::Bad,
102            )
103        }
104    }
105}
106
107/// The tone of the studio session's state.
108pub fn session_tone(session: &SessionState) -> Tone {
109    match session {
110        SessionState::Connected => Tone::Good,
111        SessionState::Connecting
112        | SessionState::Reconnecting { .. }
113        | SessionState::WaitingForApproval => Tone::Busy,
114        SessionState::AuthFailed { .. } | SessionState::Fatal { .. } => Tone::Bad,
115        SessionState::Stopped => Tone::Neutral,
116    }
117}
118
119/// The daemon's side of the page.
120#[derive(Debug, Clone, PartialEq)]
121pub struct WorkerFacts {
122    pub registration: RegistrationView,
123    pub api_base_url: String,
124    pub session: String,
125    pub session_tone: Tone,
126    pub heartbeat: Option<HeartbeatSummary>,
127    /// GPU runtime readiness: `None` until probed.
128    pub gpu: Option<(bool, String)>,
129    pub vram_total_gb: f32,
130    pub vram_threshold_gb: f32,
131    pub held_gb: f32,
132    pub local_api_url: Option<String>,
133}
134
135/// Everything [`WorkerFacts`] is built from.
136pub struct FactsInputs<'a> {
137    pub cfg: &'a Config,
138    pub registered: bool,
139    pub registration: &'a RegistrationState,
140    pub session: &'a SessionState,
141    pub heartbeat: Option<&'a HeartbeatStatus>,
142    pub gpu: Option<&'a GpuRuntimeStatus>,
143    pub vram_total_gb: f32,
144    pub held_gb: f32,
145    pub local_api_url: Option<String>,
146}
147
148impl WorkerFacts {
149    pub fn build(i: FactsInputs<'_>) -> Self {
150        Self {
151            registration: RegistrationView::build(i.cfg, i.registered, i.registration),
152            api_base_url: i.cfg.api_base_url.clone(),
153            session: i.session.summary(),
154            session_tone: session_tone(i.session),
155            heartbeat: i.heartbeat.map(HeartbeatSummary::from),
156            gpu: i.gpu.map(|g| (g.ok, g.detail.clone())),
157            vram_total_gb: i.vram_total_gb,
158            vram_threshold_gb: i.cfg.vram_threshold_gb,
159            held_gb: i.held_gb,
160            local_api_url: i.local_api_url,
161        }
162    }
163}
164
165/// The last manual update check.
166#[derive(Debug, Clone, Default)]
167pub struct AboutState {
168    pub last_check: Arc<Mutex<Option<CheckLine>>>,
169}
170
171#[derive(Debug, Clone, PartialEq)]
172pub enum CheckLine {
173    InFlight,
174    Result(String),
175}
176
177/// Versions and where things live; shown whether or not the daemon answers.
178#[derive(Debug, Clone, PartialEq)]
179pub struct AboutView {
180    pub version: &'static str,
181    /// The daemon's version, when it answers.  Differs from `version`
182    /// after the daemon updated itself and the tray UI has not restarted.
183    pub daemon_version: Option<String>,
184    pub release_name: &'static str,
185    pub config_path: PathBuf,
186    pub last_check: Option<CheckLine>,
187}
188
189impl AboutView {
190    pub fn build(state: &AboutState, config_path: &Path, daemon_version: Option<String>) -> Self {
191        Self {
192            version: AGENT_VERSION,
193            daemon_version,
194            release_name: RELEASE_NAME,
195            config_path: config_path.to_path_buf(),
196            last_check: state.last_check.lock().clone(),
197        }
198    }
199
200    /// The daemon's version line and its tone.
201    pub fn daemon_line(&self) -> (String, Tone) {
202        match &self.daemon_version {
203            Some(v) if v == self.version => (v.clone(), Tone::Neutral),
204            Some(v) => (format!("{v} (restart the tray UI to match)"), Tone::Busy),
205            None => ("not reachable".into(), Tone::Bad),
206        }
207    }
208}
209
210/// The release feed the daemon is configured with.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct UpdateFeed {
213    pub url: String,
214    pub prerelease: bool,
215}
216
217// ---------------------------------------------------------------------------
218// Update check
219// ---------------------------------------------------------------------------
220
221fn spawn_check(tokio: Handle, slot: Arc<Mutex<Option<CheckLine>>>, feed: UpdateFeed) {
222    *slot.lock() = Some(CheckLine::InFlight);
223    tokio.spawn(async move {
224        let outcome = run_check(feed).await;
225        let line = record_check_outcome(outcome);
226        *slot.lock() = Some(CheckLine::Result(line));
227    });
228}
229
230/// Log the outcome of a manual "Check for updates" and return the line to
231/// show; the same wording as `studio-worker check-update`.
232fn record_check_outcome(outcome: anyhow::Result<update::CheckOutcome>) -> String {
233    match outcome {
234        Ok(o) => {
235            match &o {
236                update::CheckOutcome::UpToDate { current } => tracing::info!(
237                    target: TRACE_TARGET,
238                    op = "manual_check",
239                    result = "up_to_date",
240                    current = %current,
241                    "manual update check completed"
242                ),
243                update::CheckOutcome::NewerAvailable { current, latest } => tracing::info!(
244                    target: TRACE_TARGET,
245                    op = "manual_check",
246                    result = "newer_available",
247                    current = %current,
248                    latest = %latest,
249                    "manual update check found a newer release"
250                ),
251            }
252            runtime::format_check_outcome(&o)
253        }
254        Err(e) => {
255            tracing::warn!(
256                target: TRACE_TARGET,
257                op = "manual_check",
258                error = %e,
259                "manual update check failed"
260            );
261            format!("check failed: {e}")
262        }
263    }
264}
265
266async fn run_check(feed: UpdateFeed) -> anyhow::Result<update::CheckOutcome> {
267    let current = semver::Version::parse(AGENT_VERSION)?;
268    let outcome =
269        tokio::task::spawn_blocking(move || update::check(&feed.url, &current, feed.prerelease))
270            .await??;
271    Ok(outcome)
272}
273
274// ---------------------------------------------------------------------------
275// Rendering
276// ---------------------------------------------------------------------------
277
278/// What the operator asked for.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum WorkerAction {
281    /// Pause (`true`) or resume claiming studio jobs.
282    SetPaused(bool),
283    /// Clear a rejected registration and ask the studio again.
284    ResetRegistration,
285}
286
287/// Why the daemon's side of the pages is missing.
288#[derive(Debug, Clone, PartialEq)]
289pub struct Offline {
290    /// What the UI is doing about it.
291    pub summary: String,
292    /// The last error, if any.
293    pub detail: String,
294    /// Where a daemon the UI starts writes its output.
295    pub daemon_log: PathBuf,
296}
297
298/// What the page needs besides its view.
299pub struct WorkerContext<'a> {
300    pub activity: &'a Activity,
301    pub paused: bool,
302    /// `None` while the daemon does not answer.
303    pub facts: Option<&'a WorkerFacts>,
304    /// Set while the daemon does not answer.
305    pub offline: Option<&'a Offline>,
306    pub about: &'a AboutView,
307    pub about_state: &'a AboutState,
308    pub tokio: &'a Handle,
309    pub feed: &'a UpdateFeed,
310    pub glow: f32,
311}
312
313/// Width below which the cards stack in one column, in points.
314const TWO_COLUMNS_FROM: f32 = 760.0;
315
316/// Draw the page (below the "daemon not reachable" card while the daemon
317/// does not answer); answers the operator's action.
318pub fn render(ui: &mut egui::Ui, cx: WorkerContext<'_>) -> Option<WorkerAction> {
319    widgets::page_title(
320        ui,
321        "Worker",
322        "This machine's worker: its state, its place in the studio, its hardware and version.",
323    );
324    let mut action = None;
325    if let Some(offline) = cx.offline {
326        unreachable_card(ui, offline);
327        ui.add_space(12.0);
328    }
329    if let Some(facts) = cx.facts {
330        action = hero(ui, cx.activity, cx.paused, cx.glow);
331        ui.add_space(12.0);
332        if let Some(a) = registration_card(ui, facts) {
333            action = Some(a);
334        }
335        ui.add_space(12.0);
336        two_columns(
337            ui,
338            |ui| studio_card(ui, facts),
339            |ui| hardware_card(ui, facts),
340        );
341        ui.add_space(12.0);
342    }
343    two_columns(
344        ui,
345        |ui| about_card(ui, cx.about, cx.about_state, cx.tokio, cx.feed),
346        |ui| local_api_card(ui, cx.facts.and_then(|f| f.local_api_url.as_deref())),
347    );
348    action
349}
350
351/// Two cards side by side, as tall as each other, or stacked on a narrow
352/// window.
353fn two_columns(
354    ui: &mut egui::Ui,
355    left: impl FnOnce(&mut egui::Ui),
356    right: impl FnOnce(&mut egui::Ui),
357) {
358    if ui.available_width() < TWO_COLUMNS_FROM {
359        widgets::card(ui, left);
360        ui.add_space(12.0);
361        widgets::card(ui, right);
362        return;
363    }
364    widgets::card_pair(ui, left, right);
365}
366
367fn hero(ui: &mut egui::Ui, activity: &Activity, paused: bool, glow: f32) -> Option<WorkerAction> {
368    let p = Palette::of_ui(ui);
369    let mut action = None;
370    widgets::card(ui, |ui| {
371        ui.horizontal(|ui| {
372            let halo = if activity.glows() { glow } else { 0.0 };
373            widgets::status_dot(ui, activity.tone(), halo);
374            ui.vertical(|ui| {
375                ui.label(
376                    RichText::new(activity.headline())
377                        .size(20.0)
378                        .strong()
379                        .color(p.text),
380                );
381                ui.label(widgets::muted(ui, activity.detail()));
382            });
383            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
384                let (label, hint) = if paused {
385                    ("Resume", "start accepting studio job offers again")
386                } else {
387                    (
388                        "Pause",
389                        "stop accepting studio job offers; a running job finishes",
390                    )
391                };
392                if widgets::primary_button(ui, label, true, 110.0)
393                    .on_hover_text(hint)
394                    .clicked()
395                {
396                    action = Some(WorkerAction::SetPaused(!paused));
397                }
398            });
399        });
400    });
401    action
402}
403
404fn registration_card(ui: &mut egui::Ui, facts: &WorkerFacts) -> Option<WorkerAction> {
405    let p = Palette::of_ui(ui);
406    let mut action = None;
407    widgets::card(ui, |ui| {
408        widgets::section_label(ui, "REGISTRATION");
409        match &facts.registration {
410            RegistrationView::Registered { worker_id } => {
411                ui.horizontal(|ui| {
412                    widgets::pill(ui, "Approved", Tone::Good);
413                    ui.label(widgets::muted(ui, "Worker id"));
414                    ui.label(RichText::new(worker_id).monospace().color(p.text));
415                    widgets::copy_button(ui, "worker-id", "Copy", worker_id);
416                });
417            }
418            RegistrationView::Initialising => {
419                ui.horizontal(|ui| {
420                    ui.spinner();
421                    ui.label(
422                        RichText::new(format!(
423                            "Asking {} for a registration slot\u{2026}",
424                            facts.api_base_url
425                        ))
426                        .color(p.text),
427                    );
428                });
429                ui.label(widgets::muted(
430                    ui,
431                    "No action needed: the worker keeps retrying until it gets through.",
432                ));
433            }
434            RegistrationView::Pending { request_id, since } => {
435                ui.horizontal(|ui| {
436                    widgets::pill(ui, "Waiting for approval", Tone::Busy);
437                    ui.label(widgets::muted(
438                        ui,
439                        format!("waiting {}", format_duration(Utc::now() - *since)),
440                    ));
441                });
442                ui.add_space(4.0);
443                ui.label(
444                    RichText::new(format!(
445                        "This worker asked {} to join and waits for the studio operator to \
446                         approve it.  It keeps polling in the background.",
447                        facts.api_base_url
448                    ))
449                    .color(p.text),
450                );
451                ui.horizontal(|ui| {
452                    ui.label(widgets::muted(ui, "Request id"));
453                    ui.label(RichText::new(request_id).monospace().color(p.text));
454                    widgets::copy_button(ui, "request-id", "Copy", request_id);
455                });
456                ui.label(widgets::muted(
457                    ui,
458                    "Share the request id with the studio operator so they find this worker quickly.",
459                ));
460            }
461            RegistrationView::Rejected { reason } => {
462                widgets::pill(ui, "Registration rejected", Tone::Bad);
463                ui.add_space(4.0);
464                let text = if reason.is_empty() {
465                    "The studio operator rejected this worker's registration.".to_string()
466                } else {
467                    format!("The studio operator rejected this worker's registration: {reason}")
468                };
469                widgets::problem_box(ui, &text);
470                ui.add_space(4.0);
471                ui.label(
472                    RichText::new(format!(
473                        "Local models and the local API keep working.  To ask {} again, check \
474                         with its operator why, then reset the registration.",
475                        facts.api_base_url
476                    ))
477                    .color(p.text),
478                );
479                ui.horizontal(|ui| {
480                    if widgets::primary_button(ui, "Reset registration", true, 160.0)
481                        .on_hover_text("clear the local registration state and ask again")
482                        .clicked()
483                    {
484                        action = Some(WorkerAction::ResetRegistration);
485                    }
486                    ui.label(widgets::muted(
487                        ui,
488                        "Same as `studio-worker register --reset`, without a restart.",
489                    ));
490                });
491            }
492        }
493    });
494    action
495}
496
497fn studio_card(ui: &mut egui::Ui, facts: &WorkerFacts) {
498    {
499        widgets::section_label(ui, "STUDIO");
500        widgets::facts(ui, "studio-facts", |rows| {
501            rows.toned("Connection", &facts.session, facts.session_tone);
502            match &facts.heartbeat {
503                None => rows.text("Last heartbeat", "never"),
504                Some(h) => {
505                    let (line, tone) = h.line(Utc::now());
506                    rows.toned("Last heartbeat", line, tone);
507                }
508            }
509            rows.mono("API base URL", &facts.api_base_url);
510        });
511    }
512}
513
514fn hardware_card(ui: &mut egui::Ui, facts: &WorkerFacts) {
515    {
516        widgets::section_label(ui, "HARDWARE");
517        widgets::facts(ui, "hardware-facts", |rows| {
518            match &facts.gpu {
519                Some((true, detail)) => rows.toned("GPU runtime", detail, Tone::Good),
520                Some((false, detail)) => rows.toned("GPU runtime", detail, Tone::Bad),
521                None => rows.text("GPU runtime", "not probed yet"),
522            }
523            rows.text(
524                "VRAM total",
525                format!("{} GB", format_gb(facts.vram_total_gb)),
526            );
527            rows.text(
528                "VRAM threshold",
529                format!("{} GB per claim", format_gb(facts.vram_threshold_gb)),
530            );
531            rows.text(
532                "Loaded models",
533                format!("\u{2248} {} GB held", format_gb(facts.held_gb)),
534            );
535        });
536    }
537}
538
539fn local_api_card(ui: &mut egui::Ui, url: Option<&str>) {
540    {
541        widgets::section_label(ui, "LOCAL API");
542        match url {
543            Some(url) => {
544                ui.horizontal_wrapped(|ui| {
545                    ui.label(RichText::new(url).monospace());
546                    widgets::copy_button(ui, "local-api", "Copy", url);
547                });
548                ui.label(widgets::muted(
549                    ui,
550                    "Generate on this machine without the studio; its jobs show under Local.",
551                ));
552            }
553            None => {
554                ui.label(widgets::muted(
555                    ui,
556                    "Not bound (the daemon does not answer yet).",
557                ));
558            }
559        }
560    }
561}
562
563fn about_card(
564    ui: &mut egui::Ui,
565    view: &AboutView,
566    state: &AboutState,
567    tokio: &Handle,
568    feed: &UpdateFeed,
569) {
570    let p = Palette::of_ui(ui);
571    {
572        widgets::section_label(ui, "ABOUT");
573        widgets::facts(ui, "about-facts", |rows| {
574            rows.mono("Tray UI", view.version);
575            let (line, tone) = view.daemon_line();
576            rows.mono_toned("Daemon", &line, tone);
577            rows.mono("Sentry release", view.release_name);
578            let path = view.config_path.to_string_lossy().to_string();
579            rows.row("Config file", |ui| {
580                ui.label(RichText::new(&path).monospace().color(p.text));
581                widgets::copy_button(ui, "config-path", "Copy", &path);
582            });
583        });
584        ui.add_space(8.0);
585        ui.horizontal(|ui| {
586            let busy = matches!(view.last_check, Some(CheckLine::InFlight));
587            if widgets::button(ui, "Check for updates", !busy, 150.0).clicked() {
588                spawn_check(tokio.clone(), state.last_check.clone(), feed.clone());
589            }
590            match &view.last_check {
591                None => {}
592                Some(CheckLine::InFlight) => {
593                    ui.spinner();
594                    ui.label(widgets::muted(ui, "Checking the release feed\u{2026}"));
595                }
596                Some(CheckLine::Result(line)) => {
597                    let tone = if line.starts_with("check failed") {
598                        Tone::Bad
599                    } else {
600                        Tone::Neutral
601                    };
602                    ui.label(RichText::new(line).color(p.tone(tone)));
603                }
604            }
605        });
606    }
607}
608
609/// The card that stands in for the daemon's side of the pages while it
610/// does not answer.
611pub fn unreachable_card(ui: &mut egui::Ui, offline: &Offline) {
612    let (summary, detail, daemon_log) = (&offline.summary, &offline.detail, &offline.daemon_log);
613    let p = Palette::of_ui(ui);
614    widgets::card(ui, |ui| {
615        ui.horizontal(|ui| {
616            icons::show(ui, Icon::Worker, 26.0, p.bad);
617            ui.vertical(|ui| {
618                ui.label(
619                    RichText::new("Worker daemon not reachable")
620                        .size(18.0)
621                        .strong()
622                        .color(p.text),
623                );
624                ui.horizontal(|ui| {
625                    ui.spinner();
626                    ui.label(RichText::new(summary.as_str()).color(p.muted));
627                });
628            });
629        });
630        if !detail.is_empty() {
631            ui.add_space(8.0);
632            widgets::tinted_box(ui, Tone::Neutral, detail.as_str());
633        }
634        ui.add_space(8.0);
635        ui.label(
636            RichText::new(
637                "The tray UI shows what the daemon (`studio-worker run`) does.  It starts one \
638                 when none is running and keeps retrying; nothing here is stale.",
639            )
640            .color(p.text),
641        );
642        let log = daemon_log.to_string_lossy().to_string();
643        ui.horizontal_wrapped(|ui| {
644            ui.label(widgets::muted(ui, "A daemon it starts writes to"));
645            ui.label(RichText::new(&log).monospace().color(p.text));
646            widgets::copy_button(ui, "daemon-log", "Copy", &log);
647        });
648    });
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use chrono::TimeZone;
655
656    fn registered_cfg() -> Config {
657        Config {
658            worker_id: Some("w-abc".into()),
659            auth_token: Some("tok-xyz".into()),
660            api_base_url: "https://studio.example".into(),
661            vram_threshold_gb: 12.0,
662            ..Config::default()
663        }
664    }
665
666    #[test]
667    fn registration_reads_initialising_pending_rejected_or_registered() {
668        let cfg = Config::default();
669        assert_eq!(
670            RegistrationView::build(&cfg, false, &RegistrationState::Pristine),
671            RegistrationView::Initialising
672        );
673        let since = Utc::now();
674        assert_eq!(
675            RegistrationView::build(
676                &cfg,
677                false,
678                &RegistrationState::Pending {
679                    request_id: "rr-42".into(),
680                    since
681                }
682            ),
683            RegistrationView::Pending {
684                request_id: "rr-42".into(),
685                since
686            }
687        );
688        assert_eq!(
689            RegistrationView::build(
690                &cfg,
691                false,
692                &RegistrationState::Rejected {
693                    reason: "unknown contributor".into()
694                }
695            ),
696            RegistrationView::Rejected {
697                reason: "unknown contributor".into()
698            }
699        );
700    }
701
702    #[test]
703    fn being_registered_wins_over_a_stale_registration_state() {
704        let view = RegistrationView::build(
705            &registered_cfg(),
706            true,
707            &RegistrationState::Pending {
708                request_id: "rr-stale".into(),
709                since: Utc::now(),
710            },
711        );
712        assert_eq!(
713            view,
714            RegistrationView::Registered {
715                worker_id: "w-abc".into()
716            }
717        );
718    }
719
720    #[test]
721    fn a_heartbeat_reads_ok_or_its_error() {
722        let now = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 30).unwrap();
723        let then = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 18).unwrap();
724        let ok = HeartbeatSummary::from(&HeartbeatStatus {
725            last_attempt_at: then,
726            outcome: HeartbeatOutcome::Ok,
727        });
728        assert_eq!(ok.line(now), ("ok \u{00b7} 12s ago".into(), Tone::Good));
729        let err = HeartbeatSummary::from(&HeartbeatStatus {
730            last_attempt_at: then,
731            outcome: HeartbeatOutcome::Err {
732                reason: "5xx".into(),
733            },
734        });
735        assert_eq!(
736            err.line(now),
737            ("error \u{00b7} 12s ago \u{00b7} 5xx".into(), Tone::Bad)
738        );
739    }
740
741    #[test]
742    fn session_states_have_tones() {
743        assert_eq!(session_tone(&SessionState::Connected), Tone::Good);
744        assert_eq!(
745            session_tone(&SessionState::Reconnecting { attempt: 2 }),
746            Tone::Busy
747        );
748        assert_eq!(
749            session_tone(&SessionState::AuthFailed { reason: "x".into() }),
750            Tone::Bad
751        );
752        assert_eq!(session_tone(&SessionState::Stopped), Tone::Neutral);
753    }
754
755    fn facts(registered: bool, registration: RegistrationState) -> WorkerFacts {
756        let hb = HeartbeatStatus {
757            last_attempt_at: Utc::now(),
758            outcome: HeartbeatOutcome::Ok,
759        };
760        let gpu = GpuRuntimeStatus {
761            ok: false,
762            detail: "install libvulkan1".into(),
763        };
764        WorkerFacts::build(FactsInputs {
765            cfg: &registered_cfg(),
766            registered,
767            registration: &registration,
768            session: &SessionState::Reconnecting { attempt: 4 },
769            heartbeat: Some(&hb),
770            gpu: Some(&gpu),
771            vram_total_gb: 24.0,
772            held_gb: 1.5,
773            local_api_url: Some("http://127.0.0.1:4787".into()),
774        })
775    }
776
777    #[test]
778    fn facts_carry_every_signal_the_page_shows() {
779        let f = facts(true, RegistrationState::Approved);
780        assert_eq!(f.api_base_url, "https://studio.example");
781        assert_eq!(f.session, "reconnecting (attempt 4)\u{2026}");
782        assert_eq!(f.session_tone, Tone::Busy);
783        assert!(f.heartbeat.as_ref().unwrap().ok);
784        assert_eq!(f.gpu, Some((false, "install libvulkan1".into())));
785        assert_eq!(f.vram_threshold_gb, 12.0);
786        assert_eq!(f.held_gb, 1.5);
787    }
788
789    #[test]
790    fn about_says_when_the_daemon_runs_another_version() {
791        let state = AboutState::default();
792        let same = AboutView::build(&state, Path::new("/tmp/c.toml"), Some(AGENT_VERSION.into()));
793        assert_eq!(same.daemon_line().1, Tone::Neutral);
794        let other = AboutView::build(&state, Path::new("/tmp/c.toml"), Some("0.0.1".into()));
795        assert!(other.daemon_line().0.contains("restart the tray UI"));
796        assert_eq!(other.daemon_line().1, Tone::Busy);
797        let none = AboutView::build(&state, Path::new("/tmp/c.toml"), None);
798        assert_eq!(none.daemon_line(), ("not reachable".into(), Tone::Bad));
799        assert_eq!(none.release_name, RELEASE_NAME);
800        assert_eq!(none.config_path, PathBuf::from("/tmp/c.toml"));
801        assert!(none.last_check.is_none());
802
803        *state.last_check.lock() = Some(CheckLine::Result("up to date".into()));
804        let view = AboutView::build(&state, Path::new("/tmp/c.toml"), None);
805        assert_eq!(
806            view.last_check,
807            Some(CheckLine::Result("up to date".into()))
808        );
809    }
810
811    use crate::test_support::capture;
812    use semver::Version;
813
814    #[test]
815    fn a_manual_check_logs_up_to_date_at_info() {
816        let logs = capture(|| {
817            let line = record_check_outcome(Ok(update::CheckOutcome::UpToDate {
818                current: Version::new(1, 2, 3),
819            }));
820            assert_eq!(line, "up to date: 1.2.3");
821        });
822        assert!(logs.contains("INFO"), "{logs}");
823        assert!(logs.contains("studio_worker::ui::about"), "{logs}");
824        assert!(logs.contains("op=\"manual_check\""), "{logs}");
825        assert!(logs.contains("result=\"up_to_date\""), "{logs}");
826    }
827
828    #[test]
829    fn a_manual_check_logs_a_newer_release() {
830        let logs = capture(|| {
831            let line = record_check_outcome(Ok(update::CheckOutcome::NewerAvailable {
832                current: Version::new(1, 0, 0),
833                latest: Version::new(2, 0, 0),
834            }));
835            assert_eq!(line, "update available: 1.0.0 -> 2.0.0");
836        });
837        assert!(logs.contains("result=\"newer_available\""), "{logs}");
838        assert!(logs.contains("2.0.0"), "{logs}");
839    }
840
841    #[test]
842    fn a_failed_manual_check_logs_at_warn() {
843        let logs = capture(|| {
844            let line = record_check_outcome(Err(anyhow::anyhow!("feed exploded")));
845            assert!(line.contains("check failed") && line.contains("feed exploded"));
846        });
847        assert!(logs.contains("WARN"), "{logs}");
848        assert!(logs.contains("op=\"manual_check\""), "{logs}");
849        assert!(logs.contains("feed exploded"), "{logs}");
850    }
851
852    fn tokio_handle() -> Handle {
853        static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
854        RT.get_or_init(|| {
855            tokio::runtime::Builder::new_current_thread()
856                .enable_all()
857                .build()
858                .expect("tokio runtime")
859        })
860        .handle()
861        .clone()
862    }
863
864    #[test]
865    fn every_registration_state_draws_connected_and_not() {
866        let state = AboutState::default();
867        let feed = UpdateFeed {
868            url: "http://127.0.0.1:9".into(),
869            prerelease: false,
870        };
871        let tokio = tokio_handle();
872        let all = [
873            facts(true, RegistrationState::Approved),
874            facts(false, RegistrationState::Pristine),
875            facts(
876                false,
877                RegistrationState::Pending {
878                    request_id: "rr".into(),
879                    since: Utc::now(),
880                },
881            ),
882            facts(
883                false,
884                RegistrationState::Rejected {
885                    reason: String::new(),
886                },
887            ),
888        ];
889        for check in [
890            None,
891            Some(CheckLine::InFlight),
892            Some(CheckLine::Result("check failed: x".into())),
893        ] {
894            *state.last_check.lock() = check;
895            let about = AboutView::build(&state, Path::new("/tmp/c.toml"), Some("0.0.1".into()));
896            let offline = Offline {
897                summary: "retrying".into(),
898                detail: "no discovery file".into(),
899                daemon_log: PathBuf::from("/tmp/daemon.log"),
900            };
901            for f in all.iter().map(Some).chain([None]) {
902                for (activity, paused) in [
903                    (Activity::Idle, false),
904                    (Activity::Paused, true),
905                    (Activity::Busy, false),
906                ] {
907                    egui::__run_test_ui(|ui| {
908                        let action = render(
909                            ui,
910                            WorkerContext {
911                                activity: &activity,
912                                paused,
913                                facts: f,
914                                offline: f.is_none().then_some(&offline),
915                                about: &about,
916                                about_state: &state,
917                                tokio: &tokio,
918                                feed: &feed,
919                                glow: 0.5,
920                            },
921                        );
922                        assert_eq!(action, None);
923                    });
924                }
925            }
926        }
927    }
928}