[Perl] DAQ system for the FAPG.
Change status table to status list.
Changed files
roles/dashboard/lib/dashboard/Controller/Reading.pm
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>