[Perl] DAQ system for the FAPG.
Add dedicated DAQ status page.
Changed files
- roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
- roles/dashboard/public/css/app.css
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/t/00-homepage.t
- roles/dashboard/t/01-graph-page.t
- roles/dashboard/templates/dashboard/index.html.ep
- roles/dashboard/templates/layouts/default.html.ep
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
@@ -72,6 +72,7 @@
72
72
my $r = $self->routes;
73
73
74
74
$r->get('/')->to('dashboard#index');
75
Added:
$r->get('/status')->to('dashboard#status');
75
76
$r->get('/graphs/:probe')->to('dashboard#graph');
76
77
77
78
$r->get('/api/readings/:probe/series')->to('Reading#series');
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
@@ -10,6 +10,13 @@
10
10
);
11
11
}
12
12
13
Added:
sub status ($self) {
14
Added:
$self->render(
15
Added:
template => 'dashboard/status',
16
Added:
items => $self->status_items,
17
Added:
);
18
Added:
}
19
Added:
13
20
sub graph ($self) {
14
21
my $probe_key = $self->stash('probe');
15
22
my ($probe) = grep { $_->{key} eq $probe_key } $self->probes->@*;
roles/dashboard/public/css/app.css
@@ -222,6 +222,15 @@
222
222
white-space: nowrap;
223
223
}
224
224
225
Added:
.status-pill[href] {
226
Added:
color: inherit;
227
Added:
text-decoration: none;
228
Added:
}
229
Added:
230
Added:
.status-pill[href]:hover {
231
Added:
filter: brightness(0.97);
232
Added:
}
233
Added:
225
234
.status-summary {
226
235
min-width: 12rem;
227
236
}
@@ -261,46 +270,6 @@
261
270
color: var(--unknown);
262
271
}
263
272
264
Removed:
.status-detail {
265
Removed:
width: 100%;
266
Removed:
max-width: 46rem;
267
Removed:
overflow-x: auto;
268
Removed:
-webkit-overflow-scrolling: touch;
269
Removed:
border: 1px solid var(--border);
270
Removed:
border-radius: 0.5rem;
271
Removed:
background: var(--card);
272
Removed:
}
273
Removed:
274
Removed:
.status-detail.is-hidden {
275
Removed:
display: none;
276
Removed:
}
277
Removed:
278
Removed:
.status-detail-table {
279
Removed:
width: 100%;
280
Removed:
min-width: 34rem;
281
Removed:
border-collapse: collapse;
282
Removed:
}
283
Removed:
284
Removed:
.status-detail-table th,
285
Removed:
.status-detail-table td {
286
Removed:
padding: 0.65rem 0.8rem;
287
Removed:
border-bottom: 1px solid var(--border);
288
Removed:
text-align: left;
289
Removed:
vertical-align: middle;
290
Removed:
}
291
Removed:
292
Removed:
.status-detail-table thead th {
293
Removed:
background: #fafcfb;
294
Removed:
color: var(--muted);
295
Removed:
font-size: 0.75rem;
296
Removed:
font-weight: 700;
297
Removed:
text-transform: uppercase;
298
Removed:
}
299
Removed:
300
Removed:
.status-detail-table tbody tr:last-child td {
301
Removed:
border-bottom: 0;
302
Removed:
}
303
Removed:
304
273
.reading-quick-status {
305
274
width: 100%;
306
275
margin-top: 0.35rem;
@@ -605,6 +574,23 @@
605
574
border-bottom: 0;
606
575
}
607
576
577
Added:
.status-page-card {
578
Added:
padding: 1.25rem;
579
Added:
}
580
Added:
581
Added:
.status-page-card .panel-heading {
582
Added:
margin-bottom: 1rem;
583
Added:
}
584
Added:
585
Added:
.status-table {
586
Added:
min-width: 760px;
587
Added:
}
588
Added:
589
Added:
.status-table .status-pill {
590
Added:
min-width: 7rem;
591
Added:
padding-block: 0.3rem;
592
Added:
}
593
Added:
608
594
@media (max-width: 900px) {
609
595
.reading-quick-grid {
610
596
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -715,10 +701,6 @@
715
701
padding-inline: 0.65rem;
716
702
text-align: center;
717
703
white-space: normal;
718
Removed:
}
719
Removed:
720
Removed:
.status-detail-table {
721
Removed:
min-width: 30rem;
722
704
}
723
705
724
706
.reading-quick-grid {
roles/dashboard/public/js/dashboard.js
@@ -102,14 +102,10 @@
102
102
return document.querySelector("[data-overview-status]");
103
103
}
104
104
105
Removed:
function statusDetailElement() {
106
Removed:
return document.querySelector("[data-status-detail]");
105
Added:
function statusPageItems() {
106
Added:
return Array.from(document.querySelectorAll("[data-status-page-item]"));
107
107
}
108
108
109
Removed:
function statusDetailRowsElement() {
110
Removed:
return document.querySelector("[data-status-detail-rows]");
111
Removed:
}
112
Removed:
113
109
function smoothingToggles() {
114
110
return Array.from(document.querySelectorAll("[data-smoothing-toggle]"));
115
111
}
@@ -360,18 +356,37 @@
360
356
return cell;
361
357
}
362
358
363
Removed:
function buildStatusDetailRow(item) {
364
Removed:
const row = document.createElement("tr");
365
Removed:
const timestamp = item.status?.received_at || item.status?.timestamp;
366
Removed:
const lastSeen = timestamp ? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})` : "Never";
359
Added:
function updateStatusPageRow(row, status) {
360
Added:
const state = stateForNodeStatus(status);
361
Added:
const timestamp = status?.received_at || status?.timestamp;
362
Added:
const stateElement = row.querySelector("[data-status-page-state]");
367
363
368
Removed:
[
369
Removed:
item.label,
370
Removed:
statusLabel(item.state),
371
Removed:
lastSeen
372
Removed:
].forEach(value => row.appendChild(buildMessageCell(value)));
364
Added:
stateElement.className = `status-pill is-${state}`;
365
Added:
stateElement.textContent = statusLabel(state);
366
Added:
row.querySelector("[data-status-page-node]").textContent = status?.node || "Unknown";
367
Added:
row.querySelector("[data-status-page-last-seen]").textContent = timestamp
368
Added:
? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})`
369
Added:
: "Never";
370
Added:
row.querySelector("[data-status-page-message]").textContent = status
371
Added:
? status.error || status.message || "No message"
372
Added:
: "No recent status received";
373
Added:
}
373
374
374
Removed:
return row;
375
Added:
async function loadStatusPage() {
376
Added:
const rows = statusPageItems();
377
Added:
378
Added:
if (!rows.length) {
379
Added:
return;
380
Added:
}
381
Added:
382
Added:
const results = await Promise.allSettled(
383
Added:
rows.map(row => fetchLatestStatus(row.dataset.statusPageItem))
384
Added:
);
385
Added:
386
Added:
rows.forEach((row, index) => {
387
Added:
const result = results[index];
388
Added:
updateStatusPageRow(row, result.status === "fulfilled" ? result.value : null);
389
Added:
});
375
390
}
376
391
377
392
function formatReadingValue(value, range) {
@@ -550,21 +565,14 @@
550
565
});
551
566
552
567
const unhealthyItems = items.filter(item => item.state !== "healthy");
553
Removed:
const detail = statusDetailElement();
554
Removed:
const detailRows = statusDetailRowsElement();
555
Removed:
556
568
if (!items.length) {
557
569
setStatusSummary("unknown", "No sensors configured");
558
Removed:
detail.classList.add("is-hidden");
559
Removed:
replaceChildren(detailRows, []);
560
570
return;
561
571
}
562
572
563
573
if (!unhealthyItems.length) {
564
574
setStatusSummary("healthy", "All sensors healthy");
565
575
overviewStatusElement().title = "Every sensor node has sent a recent healthy status.";
566
Removed:
detail.classList.add("is-hidden");
567
Removed:
replaceChildren(detailRows, []);
568
576
return;
569
577
}
570
578
@@ -575,8 +583,6 @@
575
583
576
584
setStatusSummary(summaryState, `${unhealthyItems.length} ${countLabel} attention`);
577
585
overviewStatusElement().title = unhealthyItems.map(item => statusTitle(item.status)).join(" ");
578
Removed:
detail.classList.remove("is-hidden");
579
Removed:
replaceChildren(detailRows, unhealthyItems.map(buildStatusDetailRow));
580
586
}
581
587
582
588
async function loadHeaderStatus() {
@@ -816,6 +822,7 @@
816
822
loadOverview();
817
823
loadQuickStatus();
818
824
loadHeaderStatus();
825
Added:
loadStatusPage();
819
826
820
827
setInterval(() => {
821
828
const probe = activeProbe();
@@ -827,4 +834,5 @@
827
834
loadOverview();
828
835
loadQuickStatus();
829
836
loadHeaderStatus();
837
Added:
loadStatusPage();
830
838
}, 5_000);
roles/dashboard/t/00-homepage.t
@@ -17,7 +17,8 @@
17
17
->element_exists_not('main.site-main > header.page-header')
18
18
->element_exists('input.nav-toggle[type="checkbox"]')
19
19
->text_is( 'a.page-brand[href="/"]', 'FAPG' )
20
Removed:
->element_exists('a.header-status-link[href="/"][data-header-status]')
20
Added:
->element_exists('a.header-status-link[href="/status"][data-header-status]')
21
Added:
->element_exists('a.status-summary[href="/status"][data-overview-status]')
21
22
->element_exists('a.header-status-link span.header-status-led')
22
23
->element_exists('[data-header-status-item="hub"]')
23
24
->element_exists('[data-header-status-item="ph"]')
@@ -40,7 +41,7 @@
40
41
->content_like(qr/Checking sensors/)
41
42
->content_like(qr/data-overview-status/)
42
43
->content_like(qr/data-sensor-status="ph"/)
43
Removed:
->content_like(qr/Last seen online/)
44
Added:
->content_unlike(qr/Last seen online/)
44
45
->content_like(qr/data-reading-quick-probe="ph"/)
45
46
->content_like(qr/data-reading-quick-probe="do"/)
46
47
->content_like(qr/data-reading-quick-probe="orp"/)
roles/dashboard/t/01-graph-page.t
@@ -14,7 +14,7 @@
14
14
$t->get_ok('/graphs/ph')
15
15
->status_is(200)
16
16
->text_is( 'a.page-brand[href="/"]', 'FAPG' )
17
Removed:
->element_exists('a.header-status-link[href="/"][data-header-status]')
17
Added:
->element_exists('a.header-status-link[href="/status"][data-header-status]')
18
18
->element_exists('a.header-status-link span.header-status-led')
19
19
->element_exists('[data-header-status-item="hub"]')
20
20
->element_exists('[data-header-status-item="ph"]')
roles/dashboard/templates/dashboard/index.html.ep
@@ -5,9 +5,9 @@
5
5
6
6
<section class="status-band" aria-label="DAQ status overview">
7
7
<div class="status-overview">
8
Removed:
<span class="status-pill status-summary is-unknown" data-overview-status>
8
Added:
<a class="status-pill status-summary is-unknown" href="/status" data-overview-status>
9
9
<span data-status-text>Checking sensors</span>
10
Removed:
</span>
10
Added:
</a>
11
11
12
12
% for my $probe ($probes->@*) {
13
13
<span
@@ -15,20 +15,6 @@
15
15
data-sensor-status="<%= $probe->{key} %>"
16
16
data-sensor-label="<%= $probe->{label} %>"></span>
17
17
% }
18
Removed:
19
Removed:
<div class="status-detail is-hidden" data-status-detail>
20
Removed:
<table class="status-detail-table">
21
Removed:
<thead>
22
Removed:
<tr>
23
Removed:
<th scope="col">Probe</th>
24
Removed:
<th scope="col">Status</th>
25
Removed:
<th scope="col">Last seen online</th>
26
Removed:
</tr>
27
Removed:
</thead>
28
Removed:
<tbody data-status-detail-rows>
29
Removed:
</tbody>
30
Removed:
</table>
31
Removed:
</div>
32
18
33
19
<section class="reading-quick-status" aria-label="Sensor reading quick status">
34
20
<div class="reading-quick-grid" data-reading-quick-status>
roles/dashboard/templates/layouts/default.html.ep
@@ -27,7 +27,7 @@
27
27
</nav>
28
28
<a
29
29
class="header-status-link is-unknown"
30
Removed:
href="/"
30
Added:
href="/status"
31
31
data-header-status
32
32
aria-label="DAQ status unknown"
33
33
title="Checking DAQ status">