rrdnsd/
dash.rs

1// rrdnsd - Minimalistic HTML dashboard
2// Copyright 2024-2025 Federico Ceratto <federico@debian.org>
3// Released under AGPLv3
4
5use chrono::Utc;
6use itertools::Itertools;
7
8use crate::{Duration, Enst, FqdnToStatuses, Status, VERSION};
9
10#[derive(Debug)]
11struct DashServiceRow {
12    computed_status: String,
13    fqdn: String,
14    ipaddr: String,
15    node_statuses: Vec<(String, f32)>,
16}
17
18#[allow(clippy::too_many_lines)]
19pub fn render_html_dashboard(service_statuses: &FqdnToStatuses) -> String {
20    const JS: &str = r#"
21      async function fetchHtmlAsText(url) {
22        return await (await fetch(url)).text();
23      }
24      async function loadHome() {
25        setTimeout(loadHome, 1000);
26        const contentDiv = document.getElementById("content");
27        contentDiv.innerHTML = await fetchHtmlAsText("/dash");
28      }
29      setTimeout(loadHome, 1000);
30    "#;
31
32    const CSS: &str = r"
33      table {
34          margin: 2em;
35          padding: 1em;
36          font-family: sans-serif;
37          min-width: 400px;
38          box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
39          border-spacing: 10px;
40      }
41      p#footer {
42        padding-left: 5em;
43        color: #888;
44      }
45      td.dot {
46        text-shadow: 1px 1px 3px;
47      }
48    ";
49
50    fn circle(s: Status) -> String {
51        match s {
52            Status::Up => "🟢",
53            Status::Down => "🔴",
54            Status::Unknown => "â—¯",
55        }
56        .to_string()
57    }
58
59    fn opacity(ens: &Enst) -> f32 {
60        let delay_s = ens
61            .last_update_time
62            .elapsed()
63            .unwrap_or(Duration::new(0, 0))
64            .as_secs_f32();
65        if delay_s < 10.0 {
66            let d = 1.0 - delay_s / 15.0;
67            (d * 100.0).round() / 100.0
68        } else {
69            0.33
70        }
71    }
72
73    let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
74
75    // Builds 1 row for each service status + endpoint status pair
76    let service_tbl: Vec<DashServiceRow> = service_statuses
77        .iter()
78        .sorted_by_key(|item| item.0)
79        .flat_map(|(fqdn, srvs)| {
80            srvs.endpoint_statuses
81                .iter()
82                .sorted_by_key(|item| item.0)
83                .map(|(srv_ipaddr, ep)| {
84                    // Overall status icon on the left
85                    let computed_status = circle(ep.computed_status);
86                    let node_statuses = ep
87                        .node_to_ens
88                        .iter()
89                        .sorted_by_key(|item| item.0)
90                        .map(|(_, ens)| {
91                            // One status icon for each node, all on the same row
92                            (circle(ens.status), opacity(ens))
93                        })
94                        .collect::<Vec<_>>();
95
96                    DashServiceRow {
97                        computed_status,
98                        fqdn: fqdn.to_string(),
99                        ipaddr: srv_ipaddr.to_string(),
100                        node_statuses,
101                    }
102                })
103        })
104        .collect();
105
106    markup::new! {
107        html[lang="en", id="content"] {
108            head {
109                meta[charset="utf-8"];
110                title { "rrdnsd" }
111                script[type="text/javascript"] { @markup::raw(JS) }
112                style { @CSS }
113            }
114            body {
115                table {
116                    tr {
117                        th { "FQDN" }
118                        th { "IP address" }
119                        th { "Status" }
120                    }
121                    @for sv in &service_tbl {
122                        tr {
123                            td { @sv.fqdn }
124                            td { @sv.ipaddr }
125                            td[class="dot"] { @sv.computed_status }
126                            td {}
127                            @for (sym, opacity) in &sv.node_statuses {
128                                td[class="dot", style=format!("opacity: {opacity}" )] { @sym }
129                            }
130                        }
131                    }
132                }
133                p[id="footer"] {
134                    "rrdnsd v. " @VERSION " " @now
135                }
136            }
137        }
138    }
139    .to_string()
140}