Skip to main content

studio_worker/ui/
pulse.rs

1//! The worker's pulse: what the header always says.  Pure data built from
2//! the replica, so what the header says is tested without egui.
3
4use chrono::{DateTime, Utc};
5
6use crate::auto_register::RegistrationState;
7use crate::daemon_api::ModelEntry;
8use crate::daemon_link::LinkState;
9use crate::runtime::{CurrentJob, SessionState};
10
11use super::format::format_duration;
12use super::theme::Tone;
13
14/// What the header shows.
15#[derive(Debug, Clone, PartialEq)]
16pub struct Pulse {
17    pub activity: Activity,
18    pub daemon: Signal,
19    pub studio: Signal,
20    /// `None` while the daemon does not answer: nothing is known.
21    pub gpu: Option<GpuMemory>,
22    /// The daemon answers, so Pause / Resume can reach it.
23    pub can_pause: bool,
24    pub paused: bool,
25}
26
27/// What the worker is doing.
28#[derive(Debug, Clone, PartialEq)]
29pub enum Activity {
30    /// The daemon does not answer.
31    Offline,
32    Idle,
33    /// Not claiming studio jobs, nothing running.
34    Paused,
35    /// The one-job gate is taken but no job is listed yet.
36    Busy,
37    Running {
38        kind: String,
39        model: String,
40        elapsed: String,
41        /// Further jobs running besides the one shown.
42        more: usize,
43    },
44}
45
46impl Activity {
47    /// The headline, e.g. `Running image · sdxl · 12s`.
48    pub fn headline(&self) -> String {
49        match self {
50            Activity::Offline => "Not connected".into(),
51            Activity::Idle => "Idle".into(),
52            Activity::Paused => "Paused".into(),
53            Activity::Busy => "Busy".into(),
54            Activity::Running {
55                kind,
56                model,
57                elapsed,
58                ..
59            } => format!("Running {kind} \u{00b7} {model} \u{00b7} {elapsed}"),
60        }
61    }
62
63    /// The line under the headline.
64    pub fn detail(&self) -> String {
65        match self {
66            Activity::Offline => "waiting for the worker daemon".into(),
67            Activity::Idle => "waiting for work".into(),
68            Activity::Paused => "not claiming studio jobs".into(),
69            Activity::Busy => "a job is starting".into(),
70            Activity::Running { more: 0, .. } => "one job running".into(),
71            Activity::Running { more, .. } => format!("+{more} more running"),
72        }
73    }
74
75    pub fn tone(&self) -> Tone {
76        match self {
77            Activity::Offline => Tone::Bad,
78            Activity::Idle => Tone::Good,
79            Activity::Paused => Tone::Neutral,
80            Activity::Busy | Activity::Running { .. } => Tone::Busy,
81        }
82    }
83
84    /// Running work glows.
85    pub fn glows(&self) -> bool {
86        matches!(self, Activity::Busy | Activity::Running { .. })
87    }
88}
89
90/// A short labelled state with its tone and a longer hover text.
91#[derive(Debug, Clone, PartialEq)]
92pub struct Signal {
93    pub label: String,
94    pub tone: Tone,
95    pub detail: String,
96}
97
98impl Signal {
99    fn new(label: &str, tone: Tone, detail: impl Into<String>) -> Self {
100        Self {
101            label: label.to_string(),
102            tone,
103            detail: detail.into(),
104        }
105    }
106}
107
108/// Device memory held by the models the daemon keeps loaded.
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct GpuMemory {
111    /// Sum of the catalogue estimates of loaded (or loading) models, GB.
112    pub held_gb: f32,
113    /// Device total, GB; 0 when unknown.
114    pub total_gb: f32,
115}
116
117/// Held memory at or above this share of the device reads as a warning.
118pub const GPU_TIGHT_FRACTION: f32 = 0.9;
119
120impl GpuMemory {
121    /// Memory the loaded models hold, from their catalogue estimates.
122    pub fn from_models(models: &[ModelEntry], total_gb: f32) -> Self {
123        let held_gb = models
124            .iter()
125            .filter(|m| holds_memory(&m.state))
126            .map(|m| m.vram_gb_estimate.max(0.0))
127            // A plain `sum` of nothing is -0.0, which prints as "-0".
128            .fold(0.0, |a, b| a + b);
129        Self { held_gb, total_gb }
130    }
131
132    /// Share of the device held, 0..=1; 0 when the total is unknown.
133    pub fn fraction(&self) -> f32 {
134        if self.total_gb > 0.0 {
135            (self.held_gb / self.total_gb).clamp(0.0, 1.0)
136        } else {
137            0.0
138        }
139    }
140
141    /// `≈ 6.5 / 24 GB`, or `≈ 6.5 GB` when the total is unknown.
142    pub fn label(&self) -> String {
143        if self.total_gb > 0.0 {
144            format!(
145                "\u{2248} {} / {} GB",
146                format_gb(self.held_gb),
147                format_gb(self.total_gb)
148            )
149        } else {
150            format!("\u{2248} {} GB", format_gb(self.held_gb))
151        }
152    }
153
154    pub fn tone(&self) -> Tone {
155        if self.fraction() >= GPU_TIGHT_FRACTION {
156            Tone::Busy
157        } else {
158            Tone::Neutral
159        }
160    }
161}
162
163/// Whether a model in `state` holds device memory.
164pub fn holds_memory(state: &str) -> bool {
165    matches!(state, "loaded" | "loading" | "unloading")
166}
167
168/// `6.5`, `24`, `0` — one decimal, none when whole.
169pub fn format_gb(gb: f32) -> String {
170    // Adding 0.0 turns a negative zero into a plain one.
171    let rounded = (gb * 10.0).round() / 10.0 + 0.0;
172    if rounded.fract() == 0.0 {
173        format!("{rounded:.0}")
174    } else {
175        format!("{rounded:.1}")
176    }
177}
178
179/// Everything the pulse is built from.
180pub struct PulseInputs<'a> {
181    pub link: &'a LinkState,
182    pub registered: bool,
183    pub registration: &'a RegistrationState,
184    pub session: &'a SessionState,
185    pub busy: bool,
186    pub paused: bool,
187    pub active: &'a [CurrentJob],
188    pub models: &'a [ModelEntry],
189    pub vram_total_gb: f32,
190    pub now: DateTime<Utc>,
191}
192
193impl Pulse {
194    pub fn build(i: PulseInputs<'_>) -> Self {
195        let connected = i.link.is_connected();
196        Self {
197            activity: activity(&i, connected),
198            daemon: daemon_signal(i.link),
199            studio: studio_signal(connected, i.registered, i.registration, i.session),
200            gpu: connected.then(|| GpuMemory::from_models(i.models, i.vram_total_gb)),
201            can_pause: connected,
202            paused: connected && i.paused,
203        }
204    }
205}
206
207fn activity(i: &PulseInputs<'_>, connected: bool) -> Activity {
208    if !connected {
209        return Activity::Offline;
210    }
211    // The oldest running job leads; the rest are counted.
212    if let Some(first) = i.active.iter().min_by_key(|j| j.started_at) {
213        return Activity::Running {
214            kind: first.kind.as_str().to_string(),
215            model: first.model.clone(),
216            elapsed: format_duration(i.now.signed_duration_since(first.started_at)),
217            more: i.active.len() - 1,
218        };
219    }
220    if i.busy {
221        Activity::Busy
222    } else if i.paused {
223        Activity::Paused
224    } else {
225        Activity::Idle
226    }
227}
228
229fn daemon_signal(link: &LinkState) -> Signal {
230    let (label, tone) = match link {
231        LinkState::Connected { .. } => ("Daemon connected", Tone::Good),
232        LinkState::Connecting => ("Connecting", Tone::Neutral),
233        LinkState::Starting { .. } => ("Daemon starting", Tone::Busy),
234        LinkState::Unreachable { .. } => ("Daemon unreachable", Tone::Bad),
235    };
236    Signal::new(label, tone, link.summary())
237}
238
239fn studio_signal(
240    connected: bool,
241    registered: bool,
242    registration: &RegistrationState,
243    session: &SessionState,
244) -> Signal {
245    if !connected {
246        return Signal::new(
247            "Studio unknown",
248            Tone::Neutral,
249            "the daemon holds the studio session; it does not answer",
250        );
251    }
252    if !registered {
253        return match registration {
254            RegistrationState::Pending { .. } => Signal::new(
255                "Awaiting approval",
256                Tone::Busy,
257                "the studio operator has not approved this worker yet",
258            ),
259            RegistrationState::Rejected { reason } => {
260                Signal::new("Registration rejected", Tone::Bad, reason.clone())
261            }
262            RegistrationState::Pristine | RegistrationState::Approved => Signal::new(
263                "Registering",
264                Tone::Neutral,
265                "asking the studio for a registration slot",
266            ),
267        };
268    }
269    let detail = session.summary();
270    match session {
271        SessionState::Connected => Signal::new("Studio connected", Tone::Good, detail),
272        SessionState::Connecting => Signal::new("Connecting to studio", Tone::Busy, detail),
273        SessionState::Reconnecting { attempt } => Signal {
274            label: format!("Reconnecting ({attempt})"),
275            tone: Tone::Busy,
276            detail,
277        },
278        SessionState::WaitingForApproval => Signal::new("Awaiting approval", Tone::Busy, detail),
279        SessionState::AuthFailed { .. } => Signal::new("Studio auth failed", Tone::Bad, detail),
280        SessionState::Fatal { .. } => Signal::new("Studio session ended", Tone::Bad, detail),
281        SessionState::Stopped => Signal::new("Studio stopped", Tone::Neutral, detail),
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::daemon_api::ModelSourceBrief;
289    use crate::runtime::JobSource;
290    use crate::types::{ModelEngine, TaskKind};
291
292    fn connected() -> LinkState {
293        LinkState::Connected {
294            url: "http://127.0.0.1:4787".into(),
295            version: "0.4.9".into(),
296        }
297    }
298
299    fn job(id: &str, model: &str, secs_ago: i64, now: DateTime<Utc>) -> CurrentJob {
300        CurrentJob {
301            job_id: id.into(),
302            kind: TaskKind::Image,
303            model: model.into(),
304            prompt: String::new(),
305            started_at: now - chrono::Duration::seconds(secs_ago),
306            source: JobSource::Local,
307        }
308    }
309
310    fn model(state: &str, gb: f32) -> ModelEntry {
311        ModelEntry {
312            id: format!("m-{state}"),
313            display_name: "M".into(),
314            kind: TaskKind::Llm,
315            vram_gb_estimate: gb,
316            source: ModelSourceBrief {
317                engine: ModelEngine::LlamaCpp,
318            },
319            enabled: true,
320            exclusive_group: None,
321            state: state.into(),
322            resident: false,
323            since: None,
324            error: None,
325            loadable: true,
326        }
327    }
328
329    struct Given {
330        link: LinkState,
331        registered: bool,
332        registration: RegistrationState,
333        session: SessionState,
334        busy: bool,
335        paused: bool,
336        active: Vec<CurrentJob>,
337        models: Vec<ModelEntry>,
338        now: DateTime<Utc>,
339    }
340
341    impl Default for Given {
342        fn default() -> Self {
343            Self {
344                link: connected(),
345                registered: true,
346                registration: RegistrationState::Approved,
347                session: SessionState::Connected,
348                busy: false,
349                paused: false,
350                active: Vec::new(),
351                models: Vec::new(),
352                now: Utc::now(),
353            }
354        }
355    }
356
357    fn pulse(g: &Given) -> Pulse {
358        Pulse::build(PulseInputs {
359            link: &g.link,
360            registered: g.registered,
361            registration: &g.registration,
362            session: &g.session,
363            busy: g.busy,
364            paused: g.paused,
365            active: &g.active,
366            models: &g.models,
367            vram_total_gb: 24.0,
368            now: g.now,
369        })
370    }
371
372    #[test]
373    fn an_idle_connected_worker_reads_calm() {
374        let p = pulse(&Given::default());
375        assert_eq!(p.activity, Activity::Idle);
376        assert_eq!(p.activity.headline(), "Idle");
377        assert_eq!(p.activity.tone(), Tone::Good);
378        assert!(!p.activity.glows());
379        assert_eq!(p.daemon.label, "Daemon connected");
380        assert_eq!(p.studio.label, "Studio connected");
381        assert!(p.can_pause && !p.paused);
382    }
383
384    #[test]
385    fn the_oldest_running_job_leads_and_the_rest_are_counted() {
386        let now = Utc::now();
387        let g = Given {
388            active: vec![job("b", "young", 3, now), job("a", "sdxl", 72, now)],
389            now,
390            ..Given::default()
391        };
392        let p = pulse(&g);
393        assert_eq!(
394            p.activity.headline(),
395            "Running image \u{00b7} sdxl \u{00b7} 1m 12s"
396        );
397        assert_eq!(p.activity.detail(), "+1 more running");
398        assert!(p.activity.glows());
399
400        let g = Given {
401            active: vec![job("a", "sdxl", 5, now)],
402            now,
403            ..Given::default()
404        };
405        assert_eq!(pulse(&g).activity.detail(), "one job running");
406    }
407
408    #[test]
409    fn paused_busy_and_offline_each_say_so() {
410        let p = pulse(&Given {
411            paused: true,
412            ..Given::default()
413        });
414        assert_eq!(p.activity, Activity::Paused);
415        assert!(p.paused);
416        assert_eq!(p.activity.detail(), "not claiming studio jobs");
417
418        let p = pulse(&Given {
419            busy: true,
420            ..Given::default()
421        });
422        assert_eq!(p.activity, Activity::Busy);
423        assert!(p.activity.glows());
424
425        let p = pulse(&Given {
426            link: LinkState::Unreachable {
427                error: "e".into(),
428                started_daemon: true,
429            },
430            paused: true,
431            ..Given::default()
432        });
433        assert_eq!(p.activity, Activity::Offline);
434        assert_eq!(p.activity.tone(), Tone::Bad);
435        assert_eq!(p.daemon.label, "Daemon unreachable");
436        assert_eq!(p.studio.label, "Studio unknown");
437        assert_eq!(p.gpu, None, "no stale memory figure");
438        assert!(!p.can_pause && !p.paused, "no stale pause state");
439    }
440
441    #[test]
442    fn every_link_state_has_its_signal() {
443        let cases = [
444            (LinkState::Connecting, "Connecting", Tone::Neutral),
445            (
446                LinkState::Starting { error: "e".into() },
447                "Daemon starting",
448                Tone::Busy,
449            ),
450        ];
451        for (link, label, tone) in cases {
452            let s = pulse(&Given {
453                link,
454                ..Given::default()
455            })
456            .daemon;
457            assert_eq!((s.label.as_str(), s.tone), (label, tone));
458        }
459    }
460
461    #[test]
462    fn the_studio_signal_follows_registration_then_the_session() {
463        let unregistered = |registration| Given {
464            registered: false,
465            registration,
466            ..Given::default()
467        };
468        let label = |g: &Given| pulse(g).studio.label;
469        assert_eq!(
470            label(&unregistered(RegistrationState::Pending {
471                request_id: "r".into(),
472                since: Utc::now()
473            })),
474            "Awaiting approval"
475        );
476        let rejected = pulse(&unregistered(RegistrationState::Rejected {
477            reason: "unknown contributor".into(),
478        }))
479        .studio;
480        assert_eq!(rejected.label, "Registration rejected");
481        assert_eq!(rejected.tone, Tone::Bad);
482        assert_eq!(rejected.detail, "unknown contributor");
483        assert_eq!(
484            label(&unregistered(RegistrationState::Pristine)),
485            "Registering"
486        );
487
488        let with_session = |session| Given {
489            session,
490            ..Given::default()
491        };
492        for (session, expected, tone) in [
493            (SessionState::Connecting, "Connecting to studio", Tone::Busy),
494            (
495                SessionState::Reconnecting { attempt: 4 },
496                "Reconnecting (4)",
497                Tone::Busy,
498            ),
499            (
500                SessionState::WaitingForApproval,
501                "Awaiting approval",
502                Tone::Busy,
503            ),
504            (
505                SessionState::AuthFailed { reason: "x".into() },
506                "Studio auth failed",
507                Tone::Bad,
508            ),
509            (
510                SessionState::Fatal { reason: "x".into() },
511                "Studio session ended",
512                Tone::Bad,
513            ),
514            (SessionState::Stopped, "Studio stopped", Tone::Neutral),
515        ] {
516            let s = pulse(&with_session(session)).studio;
517            assert_eq!((s.label.as_str(), s.tone), (expected, tone));
518        }
519    }
520
521    #[test]
522    fn gpu_memory_counts_the_models_that_hold_it() {
523        let g = Given {
524            models: vec![
525                model("loaded", 6.0),
526                model("loading", 0.5),
527                model("unloading", 1.0),
528                model("unloaded", 12.0),
529                model("failed", 3.0),
530            ],
531            ..Given::default()
532        };
533        let gpu = pulse(&g).gpu.expect("known while connected");
534        assert_eq!(gpu.held_gb, 7.5);
535        assert_eq!(gpu.label(), "\u{2248} 7.5 / 24 GB");
536        assert!((gpu.fraction() - 7.5 / 24.0).abs() < 1e-6);
537        assert_eq!(gpu.tone(), Tone::Neutral);
538    }
539
540    #[test]
541    fn gpu_memory_warns_when_tight_and_copes_without_a_total() {
542        let tight = GpuMemory {
543            held_gb: 22.0,
544            total_gb: 24.0,
545        };
546        assert_eq!(tight.tone(), Tone::Busy);
547        let unknown = GpuMemory {
548            held_gb: 2.0,
549            total_gb: 0.0,
550        };
551        assert_eq!(unknown.fraction(), 0.0);
552        assert_eq!(unknown.label(), "\u{2248} 2 GB");
553        let over = GpuMemory {
554            held_gb: 30.0,
555            total_gb: 24.0,
556        };
557        assert_eq!(over.fraction(), 1.0);
558    }
559
560    #[test]
561    fn no_loaded_models_hold_plain_zero() {
562        let gpu = GpuMemory::from_models(&[], 24.0);
563        assert_eq!(gpu.label(), "\u{2248} 0 / 24 GB");
564    }
565
566    #[test]
567    fn gigabytes_read_short() {
568        assert_eq!(format_gb(24.0), "24");
569        assert_eq!(format_gb(6.54), "6.5");
570        assert_eq!(format_gb(0.0), "0");
571        assert_eq!(format_gb(0.04), "0");
572        assert_eq!(format_gb(-0.0), "0");
573        assert_eq!(format_gb(-0.04), "0");
574    }
575}