1use 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#[derive(Debug, Clone, PartialEq)]
16pub struct Pulse {
17 pub activity: Activity,
18 pub daemon: Signal,
19 pub studio: Signal,
20 pub gpu: Option<GpuMemory>,
22 pub can_pause: bool,
24 pub paused: bool,
25}
26
27#[derive(Debug, Clone, PartialEq)]
29pub enum Activity {
30 Offline,
32 Idle,
33 Paused,
35 Busy,
37 Running {
38 kind: String,
39 model: String,
40 elapsed: String,
41 more: usize,
43 },
44}
45
46impl Activity {
47 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 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 pub fn glows(&self) -> bool {
86 matches!(self, Activity::Busy | Activity::Running { .. })
87 }
88}
89
90#[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#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct GpuMemory {
111 pub held_gb: f32,
113 pub total_gb: f32,
115}
116
117pub const GPU_TIGHT_FRACTION: f32 = 0.9;
119
120impl GpuMemory {
121 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 .fold(0.0, |a, b| a + b);
129 Self { held_gb, total_gb }
130 }
131
132 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 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
163pub fn holds_memory(state: &str) -> bool {
165 matches!(state, "loaded" | "loading" | "unloading")
166}
167
168pub fn format_gb(gb: f32) -> String {
170 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
179pub 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 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}