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