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