Update dashboard with probe status table.

Commit
c386a6b6d0aa2dbc14ef02c3e6aca437eb5db39c
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/dashboard/lib/dashboard.pm
index b6030444..6b80512b 100644..100644
@@ -36,21 +36,25 @@
36 36 key => 'ph',
37 37 label => 'pH',
38 38 unit => 'pH',
39 Added: node => 'Pi Zero 01',
39 40 },
40 41 {
41 42 key => 'do',
42 43 label => 'Dissolved Oxygen',
43 44 unit => 'mg/L',
45 Added: node => 'Pi Zero 03',
44 46 },
45 47 {
46 48 key => 'orp',
47 49 label => 'Redox / ORP',
48 50 unit => 'mV',
51 Added: node => 'Pi Zero 04',
49 52 },
50 53 {
51 54 key => 'ec',
52 55 label => 'Electrical Conductivity',
53 56 unit => 'µS/cm',
57 Added: node => 'Pi Zero 02',
54 58 },
55 59 ];
56 60 }
roles/dashboard/public/css/app.css
index 9c27aac7..0ef9f8a2 100644..100644
@@ -8,6 +8,12 @@
8 8 --border: #d8e2dc;
9 9 --accent: #276749;
10 10 --accent-soft: #e4f3ea;
11 Added: --danger: #b42318;
12 Added: --danger-soft: #fee4e2;
13 Added: --warn: #b54708;
14 Added: --warn-soft: #fff4d6;
15 Added: --unknown: #475467;
16 Added: --unknown-soft: #eef2f6;
11 17 }
12 18
13 19 * {
@@ -40,10 +46,104 @@
40 46 color: var(--muted);
41 47 }
42 48
49 Added: .status-band {
50 Added: margin-bottom: 1.25rem;
51 Added: }
52 Added:
53 Added: .section-heading {
54 Added: display: flex;
55 Added: justify-content: space-between;
56 Added: gap: 1rem;
57 Added: align-items: end;
58 Added: margin-bottom: 0.75rem;
59 Added: }
60 Added:
61 Added: .section-heading h2 {
62 Added: margin: 0;
63 Added: font-size: 1.15rem;
64 Added: }
65 Added:
66 Added: .section-heading p {
67 Added: margin: 0;
68 Added: color: var(--muted);
69 Added: font-size: 0.95rem;
70 Added: }
71 Added:
72 Added: .table-wrap {
73 Added: overflow-x: auto;
74 Added: border: 1px solid var(--border);
75 Added: border-radius: 0.5rem;
76 Added: background: var(--card);
77 Added: }
78 Added:
79 Added: .status-table {
80 Added: width: 100%;
81 Added: border-collapse: collapse;
82 Added: min-width: 760px;
83 Added: }
84 Added:
85 Added: .status-table th,
86 Added: .status-table td {
87 Added: padding: 0.8rem 0.9rem;
88 Added: border-bottom: 1px solid var(--border);
89 Added: text-align: left;
90 Added: vertical-align: middle;
91 Added: }
92 Added:
93 Added: .status-table thead th {
94 Added: background: #fafcfb;
95 Added: color: var(--muted);
96 Added: font-size: 0.82rem;
97 Added: font-weight: 700;
98 Added: text-transform: uppercase;
99 Added: }
100 Added:
101 Added: .status-table tbody tr:last-child th,
102 Added: .status-table tbody tr:last-child td {
103 Added: border-bottom: 0;
104 Added: }
105 Added:
106 Added: code {
107 Added: font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
108 Added: font-size: 0.9em;
109 Added: }
110 Added:
111 Added: .status-pill {
112 Added: display: inline-flex;
113 Added: align-items: center;
114 Added: justify-content: center;
115 Added: min-width: 5.75rem;
116 Added: border-radius: 999px;
117 Added: padding: 0.25rem 0.65rem;
118 Added: font-size: 0.82rem;
119 Added: font-weight: 700;
120 Added: white-space: nowrap;
121 Added: }
122 Added:
123 Added: .status-pill.is-online {
124 Added: background: var(--accent-soft);
125 Added: color: var(--accent);
126 Added: }
127 Added:
128 Added: .status-pill.is-stale {
129 Added: background: var(--warn-soft);
130 Added: color: var(--warn);
131 Added: }
132 Added:
133 Added: .status-pill.is-offline {
134 Added: background: var(--danger-soft);
135 Added: color: var(--danger);
136 Added: }
137 Added:
138 Added: .status-pill.is-unknown {
139 Added: background: var(--unknown-soft);
140 Added: color: var(--unknown);
141 Added: }
142 Added:
43 143 .dashboard-card {
44 144 background: var(--card);
45 145 border: 1px solid var(--border);
46 Removed: border-radius: 1rem;
146 Added: border-radius: 0.5rem;
47 147 box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
48 148 overflow: hidden;
49 149 }
@@ -125,6 +225,10 @@
125 225 }
126 226
127 227 .panel-heading {
228 Added: display: block;
229 Added: }
230 Added:
231 Added: .section-heading {
128 232 display: block;
129 233 }
130 234
roles/dashboard/public/js/dashboard.js
index 44e2f107..3be958d8 100644..100644
@@ -1,5 +1,11 @@
1 1 const charts = new Map();
2 Added: const ONLINE_AFTER_MS = 2 * 60 * 1000;
3 Added: const OFFLINE_AFTER_MS = 15 * 60 * 1000;
2 4
5 Added: function probeRows() {
6 Added: return Array.from(document.querySelectorAll("[data-latest-probe]"));
7 Added: }
8 Added:
3 9 function activeProbe() {
4 10 return document.querySelector(".tab-button.is-active").dataset.probe;
5 11 }
@@ -21,6 +27,129 @@
21 27 });
22 28 }
23 29
30 Added: function formatDateTime(timestamp) {
31 Added: return new Date(timestamp).toLocaleString([], {
32 Added: dateStyle: "short",
33 Added: timeStyle: "medium"
34 Added: });
35 Added: }
36 Added:
37 Added: function ageMs(timestamp) {
38 Added: const value = new Date(timestamp).getTime();
39 Added: return Number.isFinite(value) ? Date.now() - value : null;
40 Added: }
41 Added:
42 Added: function formatAge(ms) {
43 Added: if (ms === null || ms < 0) {
44 Added: return "Unknown";
45 Added: }
46 Added:
47 Added: const seconds = Math.floor(ms / 1000);
48 Added:
49 Added: if (seconds < 60) {
50 Added: return `${seconds}s ago`;
51 Added: }
52 Added:
53 Added: const minutes = Math.floor(seconds / 60);
54 Added:
55 Added: if (minutes < 60) {
56 Added: return `${minutes}m ago`;
57 Added: }
58 Added:
59 Added: const hours = Math.floor(minutes / 60);
60 Added:
61 Added: if (hours < 48) {
62 Added: return `${hours}h ago`;
63 Added: }
64 Added:
65 Added: const days = Math.floor(hours / 24);
66 Added: return `${days}d ago`;
67 Added: }
68 Added:
69 Added: function stateForAge(ms) {
70 Added: if (ms === null) {
71 Added: return "unknown";
72 Added: }
73 Added:
74 Added: if (ms <= ONLINE_AFTER_MS) {
75 Added: return "online";
76 Added: }
77 Added:
78 Added: if (ms <= OFFLINE_AFTER_MS) {
79 Added: return "stale";
80 Added: }
81 Added:
82 Added: return "offline";
83 Added: }
84 Added:
85 Added: function statusLabel(state) {
86 Added: return {
87 Added: online: "Online",
88 Added: stale: "Stale",
89 Added: offline: "Offline",
90 Added: unknown: "Unknown"
91 Added: }[state] || "Unknown";
92 Added: }
93 Added:
94 Added: function setStatusPill(element, state) {
95 Added: element.classList.remove("is-online", "is-stale", "is-offline", "is-unknown");
96 Added: element.classList.add(`is-${state}`);
97 Added: element.textContent = statusLabel(state);
98 Added: }
99 Added:
100 Added: async function fetchLatestReading(probe) {
101 Added: const response = await fetch(`/api/readings/${probe}?limit=1`);
102 Added:
103 Added: if (!response.ok) {
104 Added: throw new Error(`HTTP ${response.status}`);
105 Added: }
106 Added:
107 Added: const payload = await response.json();
108 Added: return (payload.readings || [])[0] || null;
109 Added: }
110 Added:
111 Added: function updateLatestRow(row, reading) {
112 Added: const stateElement = row.querySelector("[data-latest-state]");
113 Added:
114 Added: if (!reading) {
115 Added: row.querySelector("[data-latest-value]").textContent = "No reading";
116 Added: row.querySelector("[data-latest-time]").textContent = "Never";
117 Added: row.querySelector("[data-latest-age]").textContent = "Unknown";
118 Added: setStatusPill(stateElement, "unknown");
119 Added: return;
120 Added: }
121 Added:
122 Added: const age = ageMs(reading.timestamp);
123 Added: const state = stateForAge(age);
124 Added: const unit = reading.unit || "";
125 Added:
126 Added: row.querySelector("[data-latest-value]").textContent =
127 Added: `${reading.value} ${unit}`.trim();
128 Added: row.querySelector("[data-latest-time]").textContent =
129 Added: formatDateTime(reading.timestamp);
130 Added: row.querySelector("[data-latest-age]").textContent = formatAge(age);
131 Added: setStatusPill(stateElement, state);
132 Added: }
133 Added:
134 Added: async function loadOverview() {
135 Added: const rows = probeRows();
136 Added: const results = await Promise.allSettled(
137 Added: rows.map(row => fetchLatestReading(row.dataset.latestProbe))
138 Added: );
139 Added:
140 Added: rows.forEach((row, index) => {
141 Added: const result = results[index];
142 Added:
143 Added: if (result.status !== "fulfilled") {
144 Added: updateLatestRow(row, null);
145 Added: return;
146 Added: }
147 Added:
148 Added: const reading = result.value;
149 Added: updateLatestRow(row, reading);
150 Added: });
151 Added: }
152 Added:
24 153 async function loadProbe(probe) {
25 154 const status = statusElement(probe);
26 155
@@ -90,9 +219,10 @@
90 219
91 220 if (rows.length) {
92 221 const latest = rows[rows.length - 1];
222 Added: const age = ageMs(latest.timestamp);
93 223
94 224 status.textContent =
95 Removed: `Latest: ${latest.value} ${latest.unit || ""} from ${latest.node} at ${latest.timestamp}`;
225 Added: `Latest: ${latest.value} ${latest.unit || ""} from ${latest.node} at ${formatDateTime(latest.timestamp)} (${formatAge(age)})`;
96 226 } else {
97 227 status.textContent = "No readings found for this probe yet.";
98 228 }
@@ -123,7 +253,9 @@
123 253 });
124 254
125 255 loadProbe(activeProbe());
256 Added: loadOverview();
126 257
127 258 setInterval(() => {
128 259 loadProbe(activeProbe());
260 Added: loadOverview();
129 261 }, 5_000);
roles/dashboard/t/basic.t
index d17b3185..3a797902 100644..100644
@@ -37,7 +37,10 @@
37 37 $t->get_ok('/')
38 38 ->status_is(200)
39 39 ->content_like(qr/FAPG DAQ Dashboard/)
40 Removed: ->content_like(qr/Dissolved Oxygen/);
40 Added: ->content_like(qr/Dissolved Oxygen/)
41 Added: ->content_like(qr/Latest Probe Readings/)
42 Added: ->content_like(qr/Pi Zero 01/)
43 Added: ->content_unlike(qr/Connection Status/);
41 44
42 45 $t->get_ok('/api/readings/ph')
43 46 ->status_is(200)
roles/dashboard/templates/dashboard/index.html.ep
index db635b25..972f1877 100644..100644
@@ -8,6 +8,43 @@
8 8 <p>Live aquaponic sensor readings from the DAQ network.</p>
9 9 </section>
10 10
11 Added: <section class="status-band" aria-label="DAQ status overview">
12 Added: <div class="section-heading">
13 Added: <h2>Latest Probe Readings</h2>
14 Added: <p>Online: under 2 minutes. Stale: 2 to 15 minutes. Offline: over 15 minutes.</p>
15 Added: </div>
16 Added:
17 Added: <div class="table-wrap">
18 Added: <table class="status-table">
19 Added: <thead>
20 Added: <tr>
21 Added: <th scope="col">Probe</th>
22 Added: <th scope="col">Pi Zero node</th>
23 Added: <th scope="col">Latest value</th>
24 Added: <th scope="col">Last reading</th>
25 Added: <th scope="col">Age</th>
26 Added: <th scope="col">Status</th>
27 Added: </tr>
28 Added: </thead>
29 Added: <tbody>
30 Added: % for my $probe ($probes->@*) {
31 Added: <tr
32 Added: data-latest-probe="<%= $probe->{key} %>"
33 Added: data-probe-label="<%= $probe->{label} %>"
34 Added: data-probe-node="<%= $probe->{node} %>">
35 Added: <th scope="row"><%= $probe->{label} %></th>
36 Added: <td><code><%= $probe->{node} %></code></td>
37 Added: <td data-latest-value>Waiting</td>
38 Added: <td data-latest-time>Waiting for data</td>
39 Added: <td data-latest-age>Unknown</td>
40 Added: <td><span class="status-pill is-unknown" data-latest-state>Unknown</span></td>
41 Added: </tr>
42 Added: % }
43 Added: </tbody>
44 Added: </table>
45 Added: </div>
46 Added: </section>
47 Added:
11 48 <section class="dashboard-card">
12 49 <div class="tabs" role="tablist" aria-label="DAQ readings">
13 50 % for my $i (0 .. $probes->$#*) {