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