Skip to main content

pamoja_dashboard/
catalog.rs

1//! The presentation catalog a gateway serves so the dashboard can show custom elements.
2//!
3//! The page ships a built-in set of sensor types it knows how to draw and offer. A
4//! deployment usually measures something beyond that set, and a [`Profile`] declares
5//! those extras in its [`Presentation`](pamoja_profile::Presentation). This turns those
6//! declarations into the small JSON catalog served at `GET /catalog`: the page appends
7//! the custom presets to its own and applies the theme, so a new sensor type needs no
8//! page change.
9//!
10//! The catalog is presentation only - which graphic, which band, which label, and which
11//! groups an element is offered on. Live values still travel in the [`State`](crate::State)
12//! snapshot.
13
14use std::collections::BTreeMap;
15
16use serde::Serialize;
17
18use pamoja_profile::{LocalizedText, Profile, Scope, Theme};
19
20/// One custom sensor or stat the page should add to its built-in catalog.
21///
22/// Serialized to the same shape the page's catalog uses, so a served preset merges in
23/// by `id` next to the defaults.
24#[derive(Clone, Debug, Serialize)]
25#[serde(rename_all = "camelCase")]
26struct Preset {
27    id: String,
28    key: String,
29    unit: String,
30    viz: String,
31    label: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    labels: Option<BTreeMap<String, String>>,
34    scope: Scope,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    band: Option<[f32; 2]>,
37    #[serde(skip_serializing_if = "is_false")]
38    stat: bool,
39    #[serde(skip_serializing_if = "is_false")]
40    span: bool,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    value: Option<f32>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    state: Option<String>,
45}
46
47/// The presentation catalog served at `GET /catalog`.
48///
49/// Build one from the profiles a deployment runs with [`from_profiles`](Catalog::from_profiles).
50/// The page fetches it on boot, appends its custom presets to the built-in ones, and
51/// applies the theme. A gateway with no custom elements need not serve a catalog at all;
52/// the page then keeps its defaults.
53#[derive(Clone, Debug, Serialize)]
54#[serde(rename_all = "camelCase")]
55pub struct Catalog {
56    sensor_presets: Vec<Preset>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    theme: Option<Theme>,
59    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
60    messages: BTreeMap<String, LocalizedText>,
61}
62
63impl Catalog {
64    /// Builds a catalog from the presentation of each profile a deployment runs.
65    ///
66    /// Every [`ElementSpec`](pamoja_profile::ElementSpec) across the profiles becomes one
67    /// preset, keyed and de-duplicated by its element key (the first wins). The theme is
68    /// the first one a profile declares.
69    ///
70    /// # Arguments
71    ///
72    /// * `profiles` - the profiles whose presentation to gather.
73    ///
74    /// # Returns
75    ///
76    /// A catalog carrying the custom presets and theme.
77    pub fn from_profiles(profiles: &[&Profile]) -> Self {
78        let mut sensor_presets: Vec<Preset> = Vec::new();
79        let mut theme: Option<Theme> = None;
80        let mut messages: BTreeMap<String, LocalizedText> = BTreeMap::new();
81        for profile in profiles {
82            let Some(presentation) = &profile.presentation else {
83                continue;
84            };
85            if theme.is_none() {
86                theme = presentation.theme.clone();
87            }
88            for (key, text) in &presentation.messages {
89                messages.entry(key.clone()).or_insert_with(|| text.clone());
90            }
91            for element in &presentation.elements {
92                if sensor_presets.iter().any(|p| p.key == element.key) {
93                    continue;
94                }
95                sensor_presets.push(Preset {
96                    id: element.key.clone(),
97                    key: element.key.clone(),
98                    unit: element.unit.clone(),
99                    viz: element.viz.kind().to_owned(),
100                    label: element.label.clone(),
101                    labels: element.labels.clone(),
102                    scope: element.scope.clone(),
103                    band: element.band,
104                    stat: element.stat,
105                    span: element.span,
106                    value: element.value,
107                    state: element.state.clone(),
108                });
109            }
110        }
111        Self {
112            sensor_presets,
113            theme,
114            messages,
115        }
116    }
117
118    /// Whether the catalog carries nothing the page does not already have.
119    ///
120    /// # Returns
121    ///
122    /// `true` when there are no custom presets, theme, or messages, so a gateway can skip
123    /// serving it.
124    pub fn is_empty(&self) -> bool {
125        self.sensor_presets.is_empty() && self.theme.is_none() && self.messages.is_empty()
126    }
127
128    /// Serializes the catalog to the JSON served at `GET /catalog`.
129    ///
130    /// # Returns
131    ///
132    /// The JSON text of the catalog.
133    ///
134    /// # Errors
135    ///
136    /// Returns a [`serde_json::Error`] if the catalog cannot be serialized, which in
137    /// practice only happens on a non-finite band value.
138    pub fn to_json(&self) -> Result<String, serde_json::Error> {
139        serde_json::to_string(self)
140    }
141}
142
143fn is_false(value: &bool) -> bool {
144    !*value
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use pamoja_profile::{ElementSpec, Presentation, Viz};
151
152    fn water_profile() -> Profile {
153        Profile::well_level().with_presentation(
154            Presentation::new()
155                .with_element(
156                    ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
157                        .with_band(0.0, 5.0)
158                        .on(Scope::Links(vec!["mesh".into()])),
159                )
160                .with_element(
161                    ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
162                        .as_stat(),
163                )
164                .with_message("event.filter_clog", "Filter clogged"),
165        )
166    }
167
168    #[test]
169    fn from_profiles_flattens_every_element_to_a_preset() {
170        let profile = water_profile();
171        let catalog = Catalog::from_profiles(&[&profile]);
172        assert_eq!(catalog.sensor_presets.len(), 2);
173        let turbidity = &catalog.sensor_presets[0];
174        assert_eq!(turbidity.viz, "radial");
175        assert_eq!(turbidity.band, Some([0.0, 5.0]));
176        assert!(!turbidity.stat);
177    }
178
179    #[test]
180    fn duplicate_keys_across_profiles_are_kept_once() {
181        let profile = water_profile();
182        let catalog = Catalog::from_profiles(&[&profile, &profile]);
183        assert_eq!(
184            catalog.sensor_presets.len(),
185            2,
186            "the second copy is skipped"
187        );
188    }
189
190    #[test]
191    fn a_profile_without_presentation_yields_an_empty_catalog() {
192        let plain = Profile::well_level();
193        assert!(Catalog::from_profiles(&[&plain]).is_empty());
194    }
195
196    #[test]
197    fn the_json_uses_the_page_catalog_shape() {
198        let profile = water_profile();
199        let json = Catalog::from_profiles(&[&profile])
200            .to_json()
201            .expect("serialize");
202        assert!(json.contains("\"sensorPresets\""));
203        assert!(json.contains("\"viz\":\"count\""));
204        // A scoped element carries its links; an always element omits the form entirely.
205        assert!(json.contains("\"scope\":{\"links\":[\"mesh\"]}"));
206        assert!(json.contains("\"scope\":\"always\""));
207        // A profile-supplied message for a custom code rides along for the page to localize.
208        assert!(json.contains("\"messages\":{\"event.filter_clog\":\"Filter clogged\"}"));
209    }
210}