Skip to main content

studio_worker/ui/tabs/
config.rs

1//! Config tab — the operator-editable subset of [`Config`] as widgets.
2//! The daemon owns the config: Save sends the edit to it
3//! (`PUT /daemon/config`), which validates, saves and applies it; the
4//! answer comes back through [`ConfigDraft::saved`] or
5//! [`ConfigDraft::save_failed`].
6//!
7//! Internal state (`worker_id`, `auth_token`, `install_id`,
8//! `registration_*`) is deliberately not surfaced here.  The
9//! auto-register flow owns it end-to-end.
10
11use std::path::{Path, PathBuf};
12
13use eframe::egui;
14
15use crate::config::{self, default_models_root, Config};
16
17use super::super::notifier::NotificationPrefs;
18
19/// Buffer the user is editing.  `dirty` is true when any field
20/// differs from `original`; Save / Reset clear it.
21#[derive(Debug, Clone)]
22pub struct ConfigDraft {
23    pub current: Config,
24    pub original: Config,
25    pub last_save_error: Option<String>,
26    /// A save is on its way to the daemon.
27    pub pending: bool,
28}
29
30impl ConfigDraft {
31    pub fn from(cfg: &Config) -> Self {
32        Self {
33            current: cfg.clone(),
34            original: cfg.clone(),
35            last_save_error: None,
36            pending: false,
37        }
38    }
39
40    pub fn dirty(&self) -> bool {
41        !configs_equal(&self.current, &self.original)
42    }
43
44    /// Follow the daemon's config while the operator is not editing, so
45    /// the tab never shows values the daemon no longer has.
46    pub fn follow(&mut self, live: &Config) {
47        if !self.dirty() && !self.pending && !configs_equal(&self.original, live) {
48            *self = Self::from(live);
49        }
50    }
51
52    /// The daemon saved `saved`: it is the new baseline.
53    pub fn saved(&mut self, saved: &Config) {
54        let changed = config::changed_fields(&self.original, saved).join(",");
55        tracing::info!(
56            target: "studio_worker::ui::config",
57            changed = ?changed,
58            "operator applied config changes via UI"
59        );
60        self.original = saved.clone();
61        self.current = saved.clone();
62        self.last_save_error = None;
63        self.pending = false;
64    }
65
66    /// The daemon refused the edit or could not be reached; keep it.
67    pub fn save_failed(&mut self, error: String) {
68        tracing::warn!(
69            target: "studio_worker::ui::config",
70            error = %error,
71            "config changes not applied"
72        );
73        self.last_save_error = Some(error);
74        self.pending = false;
75    }
76
77    pub fn reset(&mut self) {
78        self.current = self.original.clone();
79        self.last_save_error = None;
80    }
81}
82
83/// Equality over the operator-editable fields (see
84/// [`config::changed_fields`]).
85fn configs_equal(a: &Config, b: &Config) -> bool {
86    config::changed_fields(a, b).is_empty()
87}
88
89/// Draw the tab; answers the config to send to the daemon when the
90/// operator pressed Save.
91pub fn render(
92    ui: &mut egui::Ui,
93    draft: &mut ConfigDraft,
94    config_path: &Path,
95    notification_prefs: &mut NotificationPrefs,
96) -> Option<Config> {
97    let mut save_requested = None;
98    ui.heading("Configuration");
99    ui.label(
100        egui::RichText::new(format!("{}", config_path.display()))
101            .color(egui::Color32::from_gray(150))
102            .small(),
103    );
104    ui.add_space(8.0);
105
106    section(ui, "Connection", |ui| {
107        labeled_text(ui, "API base URL", &mut draft.current.api_base_url);
108    });
109
110    section(ui, "Worker", |ui| {
111        labeled_slider(
112            ui,
113            "VRAM threshold (GB)",
114            &mut draft.current.vram_threshold_gb,
115            0.0,
116            96.0,
117        );
118    });
119
120    section(ui, "Auto-update", |ui| {
121        labeled_bool(
122            ui,
123            "Auto-update enabled",
124            &mut draft.current.auto_update_enabled,
125        );
126        labeled_u64(
127            ui,
128            "Interval (seconds)",
129            &mut draft.current.auto_update_interval_secs,
130        );
131        labeled_text(ui, "Release feed URL", &mut draft.current.auto_update_feed);
132        labeled_bool(
133            ui,
134            "Track pre-releases",
135            &mut draft.current.auto_update_prerelease,
136        );
137    });
138
139    section(ui, "Models", |ui| {
140        labeled_folder(ui, "Models root", &mut draft.current.models_root);
141        ui.label("");
142        ui.label(
143            egui::RichText::new(
144                "This is where the models will be stored.  You might need a fair bit \
145                 of disk space to be able to satisfy different types of jobs.",
146            )
147            .italics()
148            .color(egui::Color32::from_gray(160)),
149        );
150        ui.end_row();
151    });
152
153    section(ui, "Notifications", |ui| {
154        ui.label("On job completion");
155        ui.checkbox(&mut notification_prefs.on_completion, "");
156        ui.end_row();
157        ui.label("On job failure");
158        ui.checkbox(&mut notification_prefs.on_failure, "");
159        ui.end_row();
160    });
161
162    section(ui, "Window", |ui| {
163        ui.label("Start minimised");
164        ui.checkbox(&mut draft.current.start_minimised, "");
165        ui.end_row();
166    });
167
168    ui.add_space(12.0);
169    ui.horizontal(|ui| {
170        let dirty = draft.dirty();
171        let save = ui.add_enabled(dirty && !draft.pending, egui::Button::new("Save"));
172        if save.clicked() {
173            draft.pending = true;
174            save_requested = Some(draft.current.clone());
175        }
176        if ui.add_enabled(dirty, egui::Button::new("Reset")).clicked() {
177            draft.reset();
178        }
179        if draft.pending {
180            ui.spinner();
181            ui.label("saving\u{2026}");
182        } else if let Some(err) = &draft.last_save_error {
183            ui.colored_label(egui::Color32::LIGHT_RED, format!("save failed: {err}"));
184        } else if !dirty && draft.last_save_error.is_none() {
185            ui.label(
186                egui::RichText::new("up to date")
187                    .italics()
188                    .color(egui::Color32::from_gray(150)),
189            );
190        }
191    });
192    save_requested
193}
194
195// ---------------------------------------------------------------------------
196// Widget helpers
197// ---------------------------------------------------------------------------
198
199fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
200    egui::CollapsingHeader::new(title)
201        .default_open(true)
202        .show(ui, |ui| {
203            egui::Grid::new(title)
204                .num_columns(2)
205                .spacing([12.0, 6.0])
206                .show(ui, |ui| {
207                    add(ui);
208                });
209        });
210    ui.add_space(4.0);
211}
212
213fn labeled_text(ui: &mut egui::Ui, label: &str, value: &mut String) {
214    ui.label(label);
215    ui.add(egui::TextEdit::singleline(value).desired_width(360.0));
216    ui.end_row();
217}
218
219fn labeled_bool(ui: &mut egui::Ui, label: &str, value: &mut bool) {
220    ui.label(label);
221    ui.checkbox(value, "");
222    ui.end_row();
223}
224
225fn labeled_slider(ui: &mut egui::Ui, label: &str, value: &mut f32, min: f32, max: f32) {
226    ui.label(label);
227    ui.add(egui::Slider::new(value, min..=max).fixed_decimals(1));
228    ui.end_row();
229}
230
231fn labeled_u64(ui: &mut egui::Ui, label: &str, value: &mut u64) {
232    ui.label(label);
233    let mut buf = value.to_string();
234    if ui
235        .add(egui::TextEdit::singleline(&mut buf).desired_width(120.0))
236        .changed()
237    {
238        if let Ok(n) = buf.parse::<u64>() {
239            *value = n;
240        }
241    }
242    ui.end_row();
243}
244
245/// Path-with-folder-picker widget.  The text edit reflects the
246/// current value at all times; the "Browse…" button opens the
247/// native picker (rfd) and overwrites it on confirm.
248fn labeled_folder(ui: &mut egui::Ui, label: &str, value: &mut PathBuf) {
249    ui.label(label);
250    ui.horizontal(|ui| {
251        let mut buf = value.to_string_lossy().to_string();
252        let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(280.0));
253        if r.changed() {
254            *value = PathBuf::from(buf);
255        }
256        if ui.button("Browse…").clicked() {
257            let starting = if value.is_absolute() {
258                value.clone()
259            } else {
260                default_models_root()
261            };
262            if let Some(picked) = rfd::FileDialog::new()
263                .set_directory(starting.parent().unwrap_or(&starting))
264                .pick_folder()
265            {
266                *value = picked;
267            }
268        }
269    });
270    ui.end_row();
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn draft_starts_clean() {
279        let cfg = Config::default();
280        let draft = ConfigDraft::from(&cfg);
281        assert!(!draft.dirty());
282    }
283
284    #[test]
285    fn draft_marks_dirty_after_edit() {
286        let cfg = Config::default();
287        let mut draft = ConfigDraft::from(&cfg);
288        draft.current.vram_threshold_gb = 24.0;
289        assert!(draft.dirty());
290    }
291
292    #[test]
293    fn draft_marks_dirty_when_models_root_changes() {
294        let cfg = Config::default();
295        let mut draft = ConfigDraft::from(&cfg);
296        draft.current.models_root = PathBuf::from("/tmp/other-models");
297        assert!(draft.dirty());
298    }
299
300    #[test]
301    fn saved_makes_the_answer_the_new_baseline() {
302        let mut draft = ConfigDraft::from(&Config::default());
303        draft.current.vram_threshold_gb = 24.0;
304        draft.pending = true;
305        let mut answer = draft.current.clone();
306        answer.vram_threshold_gb = 23.5;
307        draft.saved(&answer);
308        assert!(!draft.dirty() && !draft.pending);
309        assert_eq!(draft.current.vram_threshold_gb, 23.5);
310    }
311
312    #[test]
313    fn a_clean_draft_follows_the_daemon_but_an_edit_is_kept() {
314        let mut live = Config::default();
315        let mut draft = ConfigDraft::from(&live);
316        live.vram_threshold_gb = 5.0;
317        draft.follow(&live);
318        assert_eq!(draft.current.vram_threshold_gb, 5.0);
319
320        draft.current.vram_threshold_gb = 7.0;
321        live.vram_threshold_gb = 6.0;
322        draft.follow(&live);
323        assert_eq!(draft.current.vram_threshold_gb, 7.0, "edits survive a poll");
324    }
325
326    #[test]
327    fn reset_reverts_unsaved_edits() {
328        let cfg = Config::default();
329        let mut draft = ConfigDraft::from(&cfg);
330        draft.current.vram_threshold_gb = 33.0;
331        draft.reset();
332        assert!((draft.current.vram_threshold_gb - cfg.vram_threshold_gb).abs() < f32::EPSILON);
333        assert!(!draft.dirty());
334    }
335
336    #[test]
337    fn saved_emits_operator_apply_breadcrumb() {
338        use crate::test_support::capture;
339        let logs = capture(move || {
340            let mut draft = ConfigDraft::from(&Config::default());
341            draft.current.vram_threshold_gb = 24.0;
342            let answer = draft.current.clone();
343            draft.saved(&answer);
344        });
345        assert!(logs.contains("studio_worker::ui::config"), "{logs}");
346        assert!(logs.contains("changed=\"vram_threshold_gb\""), "{logs}");
347        assert!(
348            logs.contains("operator applied config changes via UI"),
349            "{logs}"
350        );
351    }
352
353    #[test]
354    fn save_failed_keeps_the_edit_and_the_error() {
355        let mut draft = ConfigDraft::from(&Config::default());
356        draft.current.vram_threshold_gb = 3.0;
357        draft.pending = true;
358        draft.save_failed("invalid config".into());
359        assert!(draft.dirty() && !draft.pending);
360        assert_eq!(draft.last_save_error.as_deref(), Some("invalid config"));
361    }
362}