Change status table to status list.

Commit
3bb36e4b663cb0e4ce4beda1c43f19346b496692
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/dashboard/lib/dashboard/Controller/Reading.pm
index 4aed0566..6caa1437 100644..100644
@@ -26,10 +26,10 @@
26 26
27 27 my $rows = $self->sqlite->db->query(
28 28 q{
29 Removed: SELECT timestamp, node, probe, value, unit
29 Added: SELECT timestamp, received_at, node, probe, value, unit
30 30 FROM readings
31 31 WHERE probe = ?
32 Removed: ORDER BY timestamp DESC
32 Added: ORDER BY COALESCE(received_at, timestamp) DESC, id DESC
33 33 LIMIT ?
34 34 },
35 35 $probe,
roles/dashboard/public/css/app.css
index 3ab3156d..8146c74e 100644..100644
@@ -69,57 +69,38 @@
69 69 font-size: 0.95rem;
70 70 }
71 71
72 Removed: .table-wrap {
73 Removed: overflow-x: auto;
74 Removed: border: 1px solid var(--border);
75 Removed: border-radius: 0.5rem;
76 Removed: background: var(--card);
72 Added: .status-pill-list {
73 Added: display: flex;
74 Added: flex-wrap: wrap;
75 Added: gap: 0.65rem;
76 Added: padding: 0;
77 Added: margin: 0;
78 Added: list-style: none;
77 79 }
78 80
79 Removed: .status-table {
80 Removed: width: 100%;
81 Removed: border-collapse: collapse;
82 Removed: min-width: 760px;
83 Removed: }
84 Removed:
85 Removed: .status-table th,
86 Removed: .status-table td {
87 Removed: padding: 0.8rem 0.9rem;
88 Removed: border-bottom: 1px solid var(--border);
89 Removed: text-align: left;
90 Removed: vertical-align: middle;
91 Removed: }
92 Removed:
93 Removed: .status-table thead th {
94 Removed: background: #fafcfb;
95 Removed: color: var(--muted);
96 Removed: font-size: 0.82rem;
97 Removed: font-weight: 700;
98 Removed: text-transform: uppercase;
99 Removed: }
100 Removed:
101 Removed: .status-table tbody tr:last-child th,
102 Removed: .status-table tbody tr:last-child td {
103 Removed: border-bottom: 0;
104 Removed: }
105 Removed:
106 Removed: code {
107 Removed: font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
108 Removed: font-size: 0.9em;
109 Removed: }
110 Removed:
111 81 .status-pill {
112 82 display: inline-flex;
113 83 align-items: center;
114 84 justify-content: center;
115 Removed: min-width: 5.75rem;
85 Added: gap: 0.5rem;
86 Added: min-width: 8.5rem;
116 87 border-radius: 999px;
117 Removed: padding: 0.25rem 0.65rem;
88 Added: padding: 0.45rem 0.8rem;
118 89 font-size: 0.82rem;
119 90 font-weight: 700;
120 91 white-space: nowrap;
121 92 }
122 93
94 Added: .status-pill-probe {
95 Added: color: currentColor;
96 Added: }
97 Added:
98 Added: .status-pill-probe::after {
99 Added: content: "/";
100 Added: margin-left: 0.5rem;
101 Added: opacity: 0.45;
102 Added: }
103 Added:
123 104 .status-pill.is-online {
124 105 background: var(--accent-soft);
125 106 color: var(--accent);
@@ -226,6 +207,47 @@
226 207 margin: 1rem 0 0;
227 208 color: var(--muted);
228 209 font-size: 0.95rem;
210 Added: }
211 Added:
212 Added: .message-log {
213 Added: margin-top: 1.25rem;
214 Added: }
215 Added:
216 Added: .message-log h3 {
217 Added: margin: 0 0 0.75rem;
218 Added: font-size: 1rem;
219 Added: }
220 Added:
221 Added: .message-table-wrap {
222 Added: overflow-x: auto;
223 Added: border: 1px solid var(--border);
224 Added: border-radius: 0.5rem;
225 Added: }
226 Added:
227 Added: .message-table {
228 Added: width: 100%;
229 Added: min-width: 640px;
230 Added: border-collapse: collapse;
231 Added: }
232 Added:
233 Added: .message-table th,
234 Added: .message-table td {
235 Added: padding: 0.7rem 0.85rem;
236 Added: border-bottom: 1px solid var(--border);
237 Added: text-align: left;
238 Added: vertical-align: middle;
239 Added: }
240 Added:
241 Added: .message-table thead th {
242 Added: background: #fafcfb;
243 Added: color: var(--muted);
244 Added: font-size: 0.78rem;
245 Added: font-weight: 700;
246 Added: text-transform: uppercase;
247 Added: }
248 Added:
249 Added: .message-table tbody tr:last-child td {
250 Added: border-bottom: 0;
229 251 }
230 252
231 253 @media (max-width: 700px) {
roles/dashboard/public/js/dashboard.js
index 68395c44..fb82bb20 100644..100644
@@ -17,6 +17,10 @@
17 17 return document.getElementById(`chart-${probe}`);
18 18 }
19 19
20 Added: function messageTableBody(probe) {
21 Added: return document.querySelector(`[data-reading-messages="${probe}"]`);
22 Added: }
23 Added:
20 24 function labelForRow(row) {
21 25 const date = new Date(row.timestamp);
22 26 return date.toLocaleTimeString([], {
@@ -65,6 +69,11 @@
65 69 return `${days}d ago`;
66 70 }
67 71
72 Added: function replaceChildren(parent, children) {
73 Added: parent.textContent = "";
74 Added: children.forEach(child => parent.appendChild(child));
75 Added: }
76 Added:
68 77 function statusLabel(state) {
69 78 return {
70 79 online: "Online",
@@ -88,20 +97,9 @@
88 97 "is-unknown"
89 98 );
90 99 element.classList.add(`is-${state}`);
91 Removed: element.textContent = statusLabel(state);
100 Added: element.querySelector("[data-status-text]").textContent = statusLabel(state);
92 101 }
93 102
94 Removed: async function fetchLatestReading(probe) {
95 Removed: const response = await fetch(`/api/readings/${probe}?limit=1`);
96 Removed:
97 Removed: if (!response.ok) {
98 Removed: throw new Error(`HTTP ${response.status}`);
99 Removed: }
100 Removed:
101 Removed: const payload = await response.json();
102 Removed: return (payload.readings || [])[0] || null;
103 Removed: }
104 Removed:
105 103 async function fetchLatestStatus(probe) {
106 104 const response = await fetch(`/api/status/${probe}`);
107 105
@@ -148,49 +146,66 @@
148 146 return [detail, `Status received at ${when}`].filter(Boolean).join(" ");
149 147 }
150 148
151 Removed: function updateLatestRow(row, reading, nodeStatus) {
149 Added: function updateStatusPill(row, nodeStatus) {
152 150 const stateElement = row.querySelector("[data-latest-state]");
153 151
154 Removed: if (!reading) {
155 Removed: row.querySelector("[data-latest-value]").textContent = "No reading";
156 Removed: row.querySelector("[data-latest-time]").textContent = "Never";
157 Removed: row.querySelector("[data-latest-age]").textContent = "Unknown";
158 Removed: setStatusPill(stateElement, stateForNodeStatus(nodeStatus));
159 Removed: stateElement.title = statusTitle(nodeStatus);
152 Added: setStatusPill(stateElement, stateForNodeStatus(nodeStatus));
153 Added: stateElement.title = statusTitle(nodeStatus);
154 Added: }
155 Added:
156 Added: function buildMessageCell(text) {
157 Added: const cell = document.createElement("td");
158 Added: cell.textContent = text;
159 Added: return cell;
160 Added: }
161 Added:
162 Added: function renderMessageTable(probe, rows) {
163 Added: const body = messageTableBody(probe);
164 Added: const latestRows = rows.slice(-10).reverse();
165 Added:
166 Added: if (!latestRows.length) {
167 Added: const emptyRow = document.createElement("tr");
168 Added: const emptyCell = buildMessageCell("No reading messages received yet");
169 Added:
170 Added: emptyCell.colSpan = 5;
171 Added: emptyRow.appendChild(emptyCell);
172 Added: replaceChildren(body, [emptyRow]);
160 173 return;
161 174 }
162 175
163 Removed: const age = ageMs(reading.timestamp);
164 Removed: const unit = reading.unit || "";
176 Added: const tableRows = latestRows.map(row => {
177 Added: const receivedAt = row.received_at || row.timestamp;
178 Added: const tableRow = document.createElement("tr");
165 179
166 Removed: row.querySelector("[data-latest-value]").textContent =
167 Removed: `${reading.value} ${unit}`.trim();
168 Removed: row.querySelector("[data-latest-time]").textContent =
169 Removed: formatDateTime(reading.timestamp);
170 Removed: row.querySelector("[data-latest-age]").textContent = formatAge(age);
171 Removed: setStatusPill(stateElement, stateForNodeStatus(nodeStatus));
172 Removed: stateElement.title = statusTitle(nodeStatus);
180 Added: [
181 Added: formatDateTime(receivedAt),
182 Added: formatAge(ageMs(receivedAt)),
183 Added: row.node || "Unknown",
184 Added: row.value ?? "",
185 Added: row.unit || ""
186 Added: ].forEach(value => tableRow.appendChild(buildMessageCell(value)));
187 Added:
188 Added: return tableRow;
189 Added: });
190 Added:
191 Added: replaceChildren(body, tableRows);
173 192 }
174 193
175 194 async function loadOverview() {
176 195 const rows = probeRows();
177 196 const results = await Promise.allSettled(
178 Removed: rows.map(row => Promise.all([
179 Removed: fetchLatestReading(row.dataset.latestProbe),
180 Removed: fetchLatestStatus(row.dataset.latestProbe)
181 Removed: ]))
197 Added: rows.map(row => fetchLatestStatus(row.dataset.latestProbe))
182 198 );
183 199
184 200 rows.forEach((row, index) => {
185 201 const result = results[index];
186 202
187 203 if (result.status !== "fulfilled") {
188 Removed: updateLatestRow(row, null, null);
204 Added: updateStatusPill(row, null);
189 205 return;
190 206 }
191 207
192 Removed: const [reading, nodeStatus] = result.value;
193 Removed: updateLatestRow(row, reading, nodeStatus);
208 Added: updateStatusPill(row, result.value);
194 209 });
195 210 }
196 211
@@ -270,8 +285,11 @@
270 285 } else {
271 286 status.textContent = "No readings found for this probe yet.";
272 287 }
288 Added:
289 Added: renderMessageTable(probe, rows);
273 290 } catch (error) {
274 291 status.textContent = `Could not load ${probe} readings: ${error.message}`;
292 Added: renderMessageTable(probe, []);
275 293 }
276 294 }
277 295
roles/dashboard/t/basic.t
index 76e35182..8727dc91 100644..100644
@@ -19,13 +19,14 @@
19 19 $t->app->sqlite->db->query(
20 20 q{
21 21 CREATE TABLE readings (
22 Removed: id INTEGER PRIMARY KEY AUTOINCREMENT,
23 Removed: timestamp TEXT NOT NULL,
24 Removed: node TEXT NOT NULL,
25 Removed: probe TEXT NOT NULL,
26 Removed: value REAL NOT NULL,
27 Removed: unit TEXT,
28 Removed: raw_json TEXT
22 Added: id INTEGER PRIMARY KEY AUTOINCREMENT,
23 Added: received_at TEXT NOT NULL,
24 Added: timestamp TEXT NOT NULL,
25 Added: node TEXT NOT NULL,
26 Added: probe TEXT NOT NULL,
27 Added: value REAL NOT NULL,
28 Added: unit TEXT,
29 Added: raw_json TEXT
29 30 )
30 31 }
31 32 );
@@ -53,9 +54,15 @@
53 54
54 55 $t->app->sqlite->db->query(
55 56 q{
56 Removed: INSERT INTO readings (timestamp, node, probe, value, unit)
57 Removed: VALUES (?, ?, ?, ?, ?)
58 Removed: }, '2026-07-05T12:00:00Z', 'fapg-daq-zero-ph-01', 'ph', 7.12, 'pH'
57 Added: INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
58 Added: VALUES (?, ?, ?, ?, ?, ?)
59 Added: },
60 Added: '2026-07-05T12:00:10Z',
61 Added: '2026-07-05T12:00:00Z',
62 Added: 'fapg-daq-zero-ph-01',
63 Added: 'ph',
64 Added: 7.12,
65 Added: 'pH'
59 66 );
60 67
61 68 $t->app->sqlite->db->query(
@@ -96,14 +103,16 @@
96 103 ->status_is(200)
97 104 ->content_like(qr/FAPG DAQ Dashboard/)
98 105 ->content_like(qr/Dissolved Oxygen/)
99 Removed: ->content_like(qr/Latest Probe Readings/)
100 Removed: ->content_like(qr/Pi Zero 01/)
106 Added: ->content_like(qr/Last 10 Reading Messages/)
107 Added: ->content_like(qr/status-pill-list/)
108 Added: ->content_unlike(qr/Pi Zero 01/)
101 109 ->content_unlike(qr/Connection Status/);
102 110
103 111 $t->get_ok('/api/readings/ph')
104 112 ->status_is(200)
105 Removed: ->json_is( '/probe' => 'ph' )
106 Removed: ->json_is( '/readings/0/value' => 7.12 );
113 Added: ->json_is( '/probe' => 'ph' )
114 Added: ->json_is( '/readings/0/received_at' => '2026-07-05T12:00:10Z' )
115 Added: ->json_is( '/readings/0/value' => 7.12 );
107 116
108 117 $t->get_ok('/api/status/ph')
109 118 ->status_is(200)
roles/dashboard/templates/dashboard/index.html.ep
index c71ee5e6..4596017c 100644..100644
@@ -9,40 +9,18 @@
9 9 </section>
10 10
11 11 <section class="status-band" aria-label="DAQ status overview">
12 Removed: <div class="section-heading">
13 Removed: <h2>Latest Probe Readings</h2>
14 Removed: <p>Healthy nodes publish valid readings. Probe unavailable means the node is reachable but cannot read its probe. Unreachable means no recent status.</p>
15 Removed: </div>
16 Removed:
17 Removed: <div class="table-wrap">
18 Removed: <table class="status-table">
19 Removed: <thead>
20 Removed: <tr>
21 Removed: <th scope="col">Probe</th>
22 Removed: <th scope="col">Pi Zero node</th>
23 Removed: <th scope="col">Latest value</th>
24 Removed: <th scope="col">Last reading</th>
25 Removed: <th scope="col">Age</th>
26 Removed: <th scope="col">Status</th>
27 Removed: </tr>
28 Removed: </thead>
29 Removed: <tbody>
30 Removed: % for my $probe ($probes->@*) {
31 Removed: <tr
32 Removed: data-latest-probe="<%= $probe->{key} %>"
33 Removed: data-probe-label="<%= $probe->{label} %>"
34 Removed: data-probe-node="<%= $probe->{node} %>">
35 Removed: <th scope="row"><%= $probe->{label} %></th>
36 Removed: <td><code><%= $probe->{node} %></code></td>
37 Removed: <td data-latest-value>Waiting</td>
38 Removed: <td data-latest-time>Waiting for data</td>
39 Removed: <td data-latest-age>Unknown</td>
40 Removed: <td><span class="status-pill is-unknown" data-latest-state>Unknown</span></td>
41 Removed: </tr>
42 Removed: % }
43 Removed: </tbody>
44 Removed: </table>
45 Removed: </div>
12 Added: <ul class="status-pill-list">
13 Added: % for my $probe ($probes->@*) {
14 Added: <li
15 Added: data-latest-probe="<%= $probe->{key} %>"
16 Added: data-probe-label="<%= $probe->{label} %>">
17 Added: <span class="status-pill is-unknown" data-latest-state>
18 Added: <span class="status-pill-probe"><%= $probe->{label} %></span>
19 Added: <span data-status-text>Unknown</span>
20 Added: </span>
21 Added: </li>
22 Added: % }
23 Added: </ul>
46 24 </section>
47 25
48 26 <section class="dashboard-card">
@@ -83,6 +61,28 @@
83 61 <p class="reading-status" data-status="<%= $probe->{key} %>">
84 62 Waiting for data…
85 63 </p>
64 Added:
65 Added: <section class="message-log" aria-label="Last ten reading messages for <%= $probe->{label} %>">
66 Added: <h3>Last 10 Reading Messages</h3>
67 Added: <div class="message-table-wrap">
68 Added: <table class="message-table">
69 Added: <thead>
70 Added: <tr>
71 Added: <th scope="col">Received</th>
72 Added: <th scope="col">Age</th>
73 Added: <th scope="col">Node</th>
74 Added: <th scope="col">Value</th>
75 Added: <th scope="col">Unit</th>
76 Added: </tr>
77 Added: </thead>
78 Added: <tbody data-reading-messages="<%= $probe->{key} %>">
79 Added: <tr>
80 Added: <td colspan="5">Waiting for messages</td>
81 Added: </tr>
82 Added: </tbody>
83 Added: </table>
84 Added: </div>
85 Added: </section>
86 86 </section>
87 87 % }
88 88 </section>