Add dedicated DAQ status page.

Commit
430b67ed4438075f46340d95ed28a578562751fe
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
index 4ebf704d..9379a19d 100644..100644
@@ -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
index 70c9e5e1..b8fabe5f 100644..100644
@@ -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
index 93e84678..c9457777 100644..100644
@@ -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
index f8a77840..0dd6bb2c 100644..100644
@@ -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
index bfa9b02a..6de2d7a5 100644..100644
@@ -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
index 21a27a97..3000771c 100644..100644
@@ -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
index d6205ef9..742372e1 100644..100644
@@ -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
index 1b082ee8..d74e415b 100644..100644
@@ -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">