Skip to main content

rigtop_core/
config.rs

1//! User configuration (`config.toml`).
2//!
3//! The file lives at `<config dir>/rigtop/config.toml`
4//! (`~/.config/rigtop/config.toml` on Linux, `%APPDATA%\rigtop\config.toml`
5//! on Windows). A missing file means "all defaults".
6//!
7//! ```toml
8//! [sensors]
9//! # Names for sensor channels, keyed by the stable channel key shown in the UI.
10//! labels = { "nct6686:nct6683.2592/fan1" = "CPU fan", "nct6686:nct6683.2592/temp1" = "VRM" }
11//! # Channels to hide (e.g. unconnected thermistors reading -40 °C).
12//! hidden = ["nct6686:nct6683.2592/temp4"]
13//!
14//! [smart]
15//! # Directory with smartctl JSON files written by the root timer.
16//! dir = "/var/lib/rigtop/smart"
17//!
18//! [btrfs]
19//! # Directory with btrfs scrub status files copied by the root timer.
20//! scrub_dir = "/var/lib/rigtop/btrfs"
21//!
22//! [notifications]
23//! # rigtop-gui: desktop notifications for new alerts ("critical", "warning" or "off").
24//! level = "critical"
25//!
26//! [lhm]
27//! # Windows: LibreHardwareMonitor's web server ("" to disable).
28//! url = "http://127.0.0.1:8085/data.json"
29//!
30//! [overview]
31//! # Sections of each host card, most important first.
32//! sections = ["cpu", "gpu", "memory", "processes", "network", "storage"]
33//! include_local = true
34//! processes = 10
35//!
36//! [[hosts]]
37//! name = "zefat"
38//!
39//! [[hosts]]
40//! name = "nas"
41//! ssh = "admin@nas.local"
42//! command = "/usr/local/bin/rigtop --agent"
43//! sections = ["storage", "cpu", "network"]
44//!
45//! [[widgets]]
46//! name = "Clock"
47//! builtin = "clock"
48//!
49//! [[widgets]]
50//! name = "Rates"
51//! command = "~/bin/rates.sh"   # prints text or JSON rows, see `widget`
52//! interval_secs = 60
53//!
54//! ```
55//!
56//! Labels are applied by front-ends rather than by data sources, so that a
57//! client monitoring remote hosts uses its own local configuration.
58
59use std::collections::BTreeMap;
60use std::fs;
61use std::io;
62use std::path::{Path, PathBuf};
63
64use serde::{Deserialize, Serialize};
65
66use crate::snapshot::{Fan, Temperature};
67
68/// Default location of the SMART result files.
69pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
70/// Default location of the copied btrfs scrub status files.
71pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
72/// Default address of LibreHardwareMonitor's web server (Windows sensors).
73pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
74
75/// Errors while loading or saving the configuration.
76#[derive(Debug, thiserror::Error)]
77pub enum ConfigError {
78    /// The file could not be read or written.
79    #[error("cannot access {path}: {source}")]
80    Io {
81        /// File that failed.
82        path: PathBuf,
83        /// Underlying error.
84        source: io::Error,
85    },
86    /// The file is not valid TOML or has unexpected fields.
87    #[error("invalid config {path}: {source}")]
88    Parse {
89        /// File that failed.
90        path: PathBuf,
91        /// Underlying error.
92        source: toml::de::Error,
93    },
94    /// The configuration could not be serialized.
95    #[error("cannot serialize config: {0}")]
96    Serialize(#[from] toml::ser::Error),
97    /// The file is not valid TOML, so it cannot be edited in place.
98    #[error("cannot edit {path}: {source}")]
99    Edit {
100        /// File that failed.
101        path: PathBuf,
102        /// Underlying error.
103        source: toml_edit::TomlError,
104    },
105}
106
107/// Top-level configuration.
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct Config {
111    /// Sensor naming and visibility.
112    pub sensors: SensorConfig,
113    /// SMART result files.
114    pub smart: SmartConfig,
115    /// btrfs scrub status files.
116    pub btrfs: BtrfsConfig,
117    /// LibreHardwareMonitor's web server, the source of temperatures, fans
118    /// and CPU power on Windows.
119    pub lhm: LhmConfig,
120    /// The multi-host overview.
121    pub overview: OverviewConfig,
122    /// Desktop notifications for new alerts (GUI).
123    pub notifications: NotificationsConfig,
124    /// Remote hosts monitored over SSH (`[[hosts]]` tables).
125    pub hosts: Vec<HostConfig>,
126    /// Extra panels beside the monitor (`[[widgets]]` tables).
127    pub widgets: Vec<WidgetConfig>,
128}
129
130/// Widgets drawn by rigtop itself.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum BuiltinWidget {
134    /// The date and a large clock.
135    Clock,
136}
137
138/// One widget: a built-in one, or a command whose output is shown (see
139/// [`crate::widget`] for the output format).
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(default, deny_unknown_fields)]
142pub struct WidgetConfig {
143    /// Title of the widget.
144    pub name: String,
145    /// Shell command printing the content.
146    pub command: Option<String>,
147    /// Or a built-in widget.
148    pub builtin: Option<BuiltinWidget>,
149    /// Seconds between runs of the command.
150    pub interval_secs: u64,
151    /// Seconds after which a run is killed.
152    pub timeout_secs: u64,
153}
154
155impl Default for WidgetConfig {
156    fn default() -> Self {
157        Self {
158            name: String::new(),
159            command: None,
160            builtin: None,
161            interval_secs: 5,
162            timeout_secs: 10,
163        }
164    }
165}
166
167/// A block of information on a host card in the overview.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
169#[serde(rename_all = "lowercase")]
170pub enum OverviewSection {
171    Cpu,
172    Gpu,
173    Memory,
174    Processes,
175    Network,
176    Storage,
177}
178
179impl OverviewSection {
180    /// All sections in the default priority order.
181    pub const ALL: [OverviewSection; 6] = [
182        OverviewSection::Cpu,
183        OverviewSection::Gpu,
184        OverviewSection::Memory,
185        OverviewSection::Processes,
186        OverviewSection::Network,
187        OverviewSection::Storage,
188    ];
189
190    /// Name shown in menus.
191    pub fn label(self) -> &'static str {
192        match self {
193            OverviewSection::Cpu => "CPU",
194            OverviewSection::Gpu => "GPU",
195            OverviewSection::Memory => "Memory",
196            OverviewSection::Processes => "Processes",
197            OverviewSection::Network => "Network",
198            OverviewSection::Storage => "Storage",
199        }
200    }
201}
202
203/// Settings of the multi-host overview.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(default, deny_unknown_fields)]
206pub struct OverviewConfig {
207    /// Sections shown on each host card, most important first. Sections are
208    /// dropped from the end when a card is too small; leaving one out of
209    /// the list hides it.
210    pub sections: Vec<OverviewSection>,
211    /// Whether this machine also gets a card (it always has its own tab).
212    pub include_local: bool,
213    /// Processes listed per card. Hosts not shown in detail only send this
214    /// many, to keep SSH traffic small.
215    pub processes: usize,
216}
217
218impl Default for OverviewConfig {
219    fn default() -> Self {
220        Self {
221            sections: OverviewSection::ALL.to_vec(),
222            include_local: true,
223            processes: 10,
224        }
225    }
226}
227
228/// A remote host to monitor.
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct HostConfig {
232    /// Name shown in tabs and on the overview card.
233    pub name: String,
234    /// SSH destination (a `~/.ssh/config` host, `user@host`, ...);
235    /// defaults to `name`.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub ssh: Option<String>,
238    /// Command that starts the agent; defaults to `rigtop --agent`.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub command: Option<String>,
241    /// Overview sections for this host, overriding `[overview] sections`
242    /// (e.g. storage first for a file server).
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub sections: Option<Vec<OverviewSection>>,
245}
246
247impl HostConfig {
248    /// A host given only by its SSH destination (e.g. on the command line).
249    pub fn from_destination(destination: &str) -> Self {
250        Self {
251            name: destination.to_owned(),
252            ssh: None,
253            command: None,
254            sections: None,
255        }
256    }
257
258    /// The SSH destination.
259    pub fn destination(&self) -> &str {
260        self.ssh.as_deref().unwrap_or(&self.name)
261    }
262}
263
264/// Sensor naming and visibility.
265#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
266#[serde(default, deny_unknown_fields)]
267pub struct SensorConfig {
268    /// User-defined names keyed by channel key (`"<chip key>/fan1"`, `"<chip key>/temp3"`).
269    /// A `BTreeMap` keeps the saved file in a stable order.
270    pub labels: BTreeMap<String, String>,
271    /// Channel keys to hide.
272    pub hidden: Vec<String>,
273}
274
275/// Where to find SMART results.
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277#[serde(default, deny_unknown_fields)]
278pub struct SmartConfig {
279    /// Directory containing one smartctl JSON file per device.
280    pub dir: PathBuf,
281}
282
283impl Default for SmartConfig {
284    fn default() -> Self {
285        Self {
286            dir: PathBuf::from(DEFAULT_SMART_DIR),
287        }
288    }
289}
290
291/// Which new alerts the GUI announces with a desktop notification.
292#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "lowercase")]
294pub enum NotifyLevel {
295    /// No notifications.
296    Off,
297    /// Warnings and critical alerts.
298    Warning,
299    /// Critical alerts only (data at risk).
300    #[default]
301    Critical,
302}
303
304/// Desktop notification settings.
305#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
306#[serde(default, deny_unknown_fields)]
307pub struct NotificationsConfig {
308    pub level: NotifyLevel,
309}
310
311/// Where to read Windows sensors from.
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313#[serde(default, deny_unknown_fields)]
314pub struct LhmConfig {
315    /// `data.json` of LibreHardwareMonitor's "Remote Web Server" (plain
316    /// HTTP without authentication); empty to disable.
317    pub url: String,
318}
319
320impl Default for LhmConfig {
321    fn default() -> Self {
322        Self {
323            url: DEFAULT_LHM_URL.to_owned(),
324        }
325    }
326}
327
328/// Where to find btrfs scrub results.
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330#[serde(default, deny_unknown_fields)]
331pub struct BtrfsConfig {
332    /// Directory with world-readable copies of `scrub.status.<fsid>`.
333    pub scrub_dir: PathBuf,
334}
335
336impl Default for BtrfsConfig {
337    fn default() -> Self {
338        Self {
339            scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
340        }
341    }
342}
343
344impl Config {
345    /// Default path of the configuration file, if the platform has a config directory.
346    pub fn default_path() -> Option<PathBuf> {
347        dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
348    }
349
350    /// Loads the configuration from `path`. A missing file yields the defaults.
351    pub fn load(path: &Path) -> Result<Self, ConfigError> {
352        let text = match fs::read_to_string(path) {
353            Ok(text) => text,
354            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
355            Err(source) => {
356                return Err(ConfigError::Io {
357                    path: path.to_owned(),
358                    source,
359                });
360            }
361        };
362        toml::from_str(&text).map_err(|source| ConfigError::Parse {
363            path: path.to_owned(),
364            source,
365        })
366    }
367
368    /// Loads from [`Config::default_path`], or returns the defaults when the
369    /// platform has no config directory.
370    pub fn load_default() -> Result<Self, ConfigError> {
371        match Self::default_path() {
372            Some(path) => Self::load(&path),
373            None => Ok(Self::default()),
374        }
375    }
376
377    /// Writes the configuration to `path`, creating parent directories.
378    ///
379    /// The file is written to a temporary sibling and renamed into place, so
380    /// a crash never leaves a truncated config behind.
381    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
382        let text = toml::to_string_pretty(self)?;
383        let io_err = |source| ConfigError::Io {
384            path: path.to_owned(),
385            source,
386        };
387        if let Some(parent) = path.parent() {
388            fs::create_dir_all(parent).map_err(io_err)?;
389        }
390        let tmp = path.with_extension("toml.tmp");
391        fs::write(&tmp, text).map_err(io_err)?;
392        fs::rename(&tmp, path).map_err(io_err)
393    }
394
395    /// Replaces the `[[hosts]]` of the configuration file at `path` with
396    /// `hosts`, keeping everything else in the file (other settings,
397    /// comments, formatting) untouched. Creates the file if needed.
398    ///
399    /// Used by the GUI's host editor. The edited file is checked to load as
400    /// a valid configuration before it replaces the old one.
401    pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
402        let io_err = |source| ConfigError::Io {
403            path: path.to_owned(),
404            source,
405        };
406        let text = match fs::read_to_string(path) {
407            Ok(text) => text,
408            Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
409            Err(err) => return Err(io_err(err)),
410        };
411        let mut document: toml_edit::DocumentMut =
412            text.parse().map_err(|source| ConfigError::Edit {
413                path: path.to_owned(),
414                source,
415            })?;
416        if hosts.is_empty() {
417            document.remove("hosts");
418        } else {
419            let mut tables = toml_edit::ArrayOfTables::new();
420            for host in hosts {
421                let mut table = toml_edit::Table::new();
422                table["name"] = toml_edit::value(host.name.as_str());
423                if let Some(ssh) = &host.ssh {
424                    table["ssh"] = toml_edit::value(ssh.as_str());
425                }
426                if let Some(command) = &host.command {
427                    table["command"] = toml_edit::value(command.as_str());
428                }
429                if let Some(sections) = &host.sections {
430                    let mut array = toml_edit::Array::new();
431                    for section in sections {
432                        // Serialized names match the serde representation.
433                        let name = toml::Value::try_from(section)?;
434                        if let Some(name) = name.as_str() {
435                            array.push(name);
436                        }
437                    }
438                    table["sections"] = toml_edit::value(array);
439                }
440                tables.push(table);
441            }
442            document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
443        }
444        let edited = document.to_string();
445        // Never write a file that rigtop itself could not load afterwards.
446        toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
447            path: path.to_owned(),
448            source,
449        })?;
450        if let Some(parent) = path.parent() {
451            fs::create_dir_all(parent).map_err(io_err)?;
452        }
453        let tmp = path.with_extension("toml.tmp");
454        fs::write(&tmp, edited).map_err(io_err)?;
455        fs::rename(&tmp, path).map_err(io_err)
456    }
457
458    /// Display name of a fan: the user-defined label, or the driver/generic label.
459    pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
460        self.label_for(&fan.key).unwrap_or(&fan.label)
461    }
462
463    /// Display name of a temperature channel.
464    pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
465        self.label_for(&temperature.key)
466            .unwrap_or(&temperature.label)
467    }
468
469    /// User-defined label for a channel key, if any.
470    pub fn label_for(&self, key: &str) -> Option<&str> {
471        self.sensors.labels.get(key).map(String::as_str)
472    }
473
474    /// Sets (or, with an empty name, removes) the label of a channel.
475    pub fn set_label(&mut self, key: &str, name: &str) {
476        let name = name.trim();
477        if name.is_empty() {
478            self.sensors.labels.remove(key);
479        } else {
480            self.sensors.labels.insert(key.to_owned(), name.to_owned());
481        }
482    }
483
484    /// Whether the user chose to hide a channel.
485    pub fn is_hidden(&self, key: &str) -> bool {
486        self.sensors.hidden.iter().any(|k| k == key)
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn missing_file_gives_defaults() {
496        let tmp = tempfile::tempdir().unwrap();
497        let config = Config::load(&tmp.path().join("none.toml")).unwrap();
498        assert_eq!(config, Config::default());
499        assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
500    }
501
502    #[test]
503    fn parses_labels_hidden_and_smart_dir() {
504        let tmp = tempfile::tempdir().unwrap();
505        let path = tmp.path().join("config.toml");
506        fs::write(
507            &path,
508            r#"
509[sensors]
510labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
511hidden = ["nct6686:nct6683.2592/temp4"]
512
513[smart]
514dir = "/tmp/smart"
515
516[lhm]
517url = ""
518
519[notifications]
520level = "warning"
521"#,
522        )
523        .unwrap();
524        let config = Config::load(&path).unwrap();
525        let fan = Fan {
526            key: "nct6686:nct6683.2592/fan1".to_owned(),
527            label: "fan1".to_owned(),
528            rpm: 900,
529        };
530        let other = Fan {
531            key: "nct6686:nct6683.2592/fan2".to_owned(),
532            label: "fan2".to_owned(),
533            rpm: 0,
534        };
535        assert_eq!(config.fan_label(&fan), "CPU fan");
536        // Unnamed channels fall back to the generic label.
537        assert_eq!(config.fan_label(&other), "fan2");
538        assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
539        assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
540        assert_eq!(config.lhm.url, "");
541        assert_eq!(config.notifications.level, NotifyLevel::Warning);
542        assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
543        assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
544    }
545
546    #[test]
547    fn parses_hosts_and_overview() {
548        let config: Config = toml::from_str(
549            r#"
550[overview]
551sections = ["storage", "cpu"]
552include_local = false
553
554[[hosts]]
555name = "zefat"
556
557[[hosts]]
558name = "nas"
559ssh = "admin@nas.local"
560sections = ["storage"]
561
562[[widgets]]
563name = "Clock"
564builtin = "clock"
565
566[[widgets]]
567name = "Rates"
568command = "rates.sh"
569interval_secs = 60
570"#,
571        )
572        .unwrap();
573        assert_eq!(
574            config.overview.sections,
575            vec![OverviewSection::Storage, OverviewSection::Cpu]
576        );
577        assert!(!config.overview.include_local);
578        assert_eq!(config.overview.processes, 10, "default kept");
579        assert_eq!(config.hosts.len(), 2);
580        assert_eq!(config.hosts[0].destination(), "zefat");
581        assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
582        assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
583        assert_eq!(config.widgets[1].interval_secs, 60);
584        assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
585        assert_eq!(config.hosts[1].destination(), "admin@nas.local");
586        assert_eq!(
587            config.hosts[1].sections,
588            Some(vec![OverviewSection::Storage])
589        );
590        // Defaults: every section in priority order, local included.
591        let defaults = Config::default();
592        assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
593        assert!(defaults.overview.include_local);
594    }
595
596    #[test]
597    fn save_hosts_replaces_only_the_hosts() {
598        let tmp = tempfile::tempdir().unwrap();
599        let path = tmp.path().join("config.toml");
600        fs::write(
601            &path,
602            "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
603        )
604        .unwrap();
605        let hosts = vec![
606            HostConfig::from_destination("zefat"),
607            HostConfig {
608                name: "nas".to_owned(),
609                ssh: Some("admin@nas.local".to_owned()),
610                command: Some("/opt/rigtop --agent".to_owned()),
611                sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
612            },
613        ];
614        Config::save_hosts(&path, &hosts).unwrap();
615
616        let text = fs::read_to_string(&path).unwrap();
617        assert!(
618            text.contains("# my settings") && text.contains("# keep me"),
619            "{text}"
620        );
621        assert!(!text.contains("\"old\""), "{text}");
622        let config = Config::load(&path).unwrap();
623        assert_eq!(config.hosts, hosts);
624        assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
625
626        // Removing every host drops the [[hosts]] tables.
627        Config::save_hosts(&path, &[]).unwrap();
628        assert!(Config::load(&path).unwrap().hosts.is_empty());
629        assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
630    }
631
632    #[test]
633    fn save_hosts_creates_a_missing_file() {
634        let tmp = tempfile::tempdir().unwrap();
635        let path = tmp.path().join("new/rigtop/config.toml");
636        Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
637        assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
638    }
639
640    #[test]
641    fn save_hosts_refuses_to_touch_a_broken_file() {
642        let tmp = tempfile::tempdir().unwrap();
643        let path = tmp.path().join("config.toml");
644        fs::write(&path, "[sensors\n").unwrap();
645        let err = Config::save_hosts(&path, &[]).unwrap_err();
646        assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
647        assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
648    }
649
650    #[test]
651    fn unknown_fields_are_rejected() {
652        let tmp = tempfile::tempdir().unwrap();
653        let path = tmp.path().join("config.toml");
654        // A typo such as "label" instead of "labels" must not be silently ignored.
655        fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
656        assert!(matches!(
657            Config::load(&path),
658            Err(ConfigError::Parse { .. })
659        ));
660    }
661
662    #[test]
663    fn save_and_reload_round_trip() {
664        let tmp = tempfile::tempdir().unwrap();
665        let path = tmp.path().join("nested/rigtop/config.toml");
666        let mut config = Config::default();
667        config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
668        config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
669        config.set_label("nct6686:nct6683.2592/fan1", "");
670        config.save(&path).unwrap();
671
672        let back = Config::load(&path).unwrap();
673        assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
674        // An empty name removes the label.
675        assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
676        assert!(!path.with_extension("toml.tmp").exists());
677    }
678}