1use 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
23const TRACE_TARGET: &str = "studio_worker::ui::about";
26
27#[derive(Debug, Clone, PartialEq)]
33pub enum RegistrationView {
34 Initialising,
36 Pending {
38 request_id: String,
39 since: DateTime<Utc>,
40 },
41 Rejected {
43 reason: String,
44 },
45 Registered {
46 worker_id: String,
47 },
48}
49
50impl RegistrationView {
51 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#[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 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
107pub 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#[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 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
135pub 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#[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#[derive(Debug, Clone, PartialEq)]
179pub struct AboutView {
180 pub version: &'static str,
181 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 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#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct UpdateFeed {
213 pub url: String,
214 pub prerelease: bool,
215}
216
217fn 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
230fn 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, ¤t, feed.prerelease))
270 .await??;
271 Ok(outcome)
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum WorkerAction {
281 SetPaused(bool),
283 ResetRegistration,
285}
286
287#[derive(Debug, Clone, PartialEq)]
289pub struct Offline {
290 pub summary: String,
292 pub detail: String,
294 pub daemon_log: PathBuf,
296}
297
298pub struct WorkerContext<'a> {
300 pub activity: &'a Activity,
301 pub paused: bool,
302 pub facts: Option<&'a WorkerFacts>,
304 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
313const TWO_COLUMNS_FROM: f32 = 760.0;
315
316pub 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
351fn 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
609pub 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 ®istered_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: ®istered_cfg(),
766 registered,
767 registration: ®istration,
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}