Refactor dashboard navigation and graph pages

Move global dashboard navigation and footer into the shared layout. Split probe graph pages into a dedicated template and route, leaving the homepage focused on quick sensor status cards. Add responsive navigation, clickable quick-status cards, and a header DAQ status LED. Update dashboard JavaScript to handle homepage and graph-page rendering independently, and expand dashboard tests for the new route and navigation structure.

Commit
5a29bf4a321667163c5a4153e115e9821418a153
Author
OpenAI Codex <codex@openai.com>
Author date
Committer
OpenAI Codex <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/dashboard.pm
index aeb8c7fb..6f433366 100644..100644
@@ -75,6 +75,7 @@
75 75 my $r = $self->routes;
76 76
77 77 $r->get('/')->to('dashboard#index');
78 Added: $r->get('/graphs/:probe')->to('dashboard#graph');
78 79
79 80 $r->get('/api/readings/:probe')->to('Reading#list');
80 81 $r->get('/api/status/:probe')->to('Reading#status');
roles/dashboard/lib/dashboard/Controller/Dashboard.pm
index 89c0bd15..2203f350 100644..100644
@@ -10,4 +10,16 @@
10 10 );
11 11 }
12 12
13 Added: sub graph ($self) {
14 Added: my $probe_key = $self->stash('probe');
15 Added: my ($probe) = grep { $_->{key} eq $probe_key } $self->probes->@*;
16 Added:
17 Added: return $self->reply->not_found unless defined $probe;
18 Added:
19 Added: $self->render(
20 Added: template => 'dashboard/graph',
21 Added: probe => $probe,
22 Added: );
23 Added: }
24 Added:
13 25 1;
roles/dashboard/public/css/app.css
index b4350940..93e84678 100644..100644
@@ -29,23 +29,157 @@
29 29
30 30 .site-main {
31 31 width: min(1100px, calc(100% - 2rem));
32 Removed: margin: 2rem auto;
32 Added: margin: 0 auto 2rem;
33 33 }
34 34
35 Added: .site-footer {
36 Added: width: min(1100px, calc(100% - 2rem));
37 Added: margin: 0 auto 2rem;
38 Added: color: var(--muted);
39 Added: font-size: 0.88rem;
40 Added: text-align: center;
41 Added: }
42 Added:
43 Added: .site-footer p {
44 Added: margin: 0.2rem 0;
45 Added: }
46 Added:
47 Added: .site-footer a {
48 Added: color: inherit;
49 Added: }
50 Added:
35 51 .page-header {
36 Removed: margin-bottom: 1.5rem;
52 Added: position: sticky;
53 Added: top: 0;
54 Added: z-index: 20;
55 Added: display: flex;
56 Added: align-items: center;
57 Added: justify-content: flex-start;
58 Added: gap: 1rem;
59 Added: width: 100%;
60 Added: margin: 0 0 1.5rem;
61 Added: border: 1px solid var(--border);
62 Added: border-radius: 0 0 0.5rem 0.5rem;
63 Added: padding: 0.7rem 0.85rem;
64 Added: background: var(--card);
65 Added: box-shadow: 0 8px 24px rgba(23, 33, 27, 0.08);
37 66 }
38 67
39 Removed: .page-header h1 {
40 Removed: margin: 0 0 0.4rem;
41 Removed: font-size: clamp(1.8rem, 3vw, 2.6rem);
68 Added: .page-brand {
69 Added: color: var(--accent);
70 Added: font-size: 1.15rem;
71 Added: font-weight: 700;
72 Added: text-decoration: none;
42 73 }
43 74
44 Removed: .page-header p {
45 Removed: margin: 0;
46 Removed: color: var(--muted);
75 Added: .page-brand:hover {
76 Added: text-decoration: underline;
47 77 }
48 78
79 Added: .probe-nav {
80 Added: display: flex;
81 Added: flex-wrap: wrap;
82 Added: justify-content: flex-end;
83 Added: gap: 0.4rem;
84 Added: margin-left: auto;
85 Added: }
86 Added:
87 Added: .probe-nav a {
88 Added: border-radius: 999px;
89 Added: padding: 0.5rem 0.75rem;
90 Added: color: var(--text);
91 Added: font-size: 0.9rem;
92 Added: font-weight: 700;
93 Added: text-decoration: none;
94 Added: }
95 Added:
96 Added: .probe-nav a:hover {
97 Added: background: var(--accent-soft);
98 Added: color: var(--accent);
99 Added: }
100 Added:
101 Added: .header-status-link {
102 Added: position: relative;
103 Added: display: inline-grid;
104 Added: flex: 0 0 auto;
105 Added: place-items: center;
106 Added: width: 1.35rem;
107 Added: height: 1.35rem;
108 Added: border-radius: 50%;
109 Added: color: inherit;
110 Added: text-decoration: none;
111 Added: }
112 Added:
113 Added: .header-status-link:focus-visible {
114 Added: outline: 2px solid var(--accent);
115 Added: outline-offset: 0.25rem;
116 Added: }
117 Added:
118 Added: .header-status-led {
119 Added: width: 0.85rem;
120 Added: height: 0.85rem;
121 Added: border: 1px solid rgba(23, 33, 27, 0.22);
122 Added: border-radius: 50%;
123 Added: background: radial-gradient(circle at 32% 28%, #ffffff 0 8%, #98a2b3 18%, #475467 70%);
124 Added: box-shadow:
125 Added: inset 0 -1px 2px rgba(23, 33, 27, 0.45),
126 Added: 0 0 0.35rem rgba(71, 84, 103, 0.45);
127 Added: }
128 Added:
129 Added: .header-status-link.is-healthy .header-status-led {
130 Added: background: radial-gradient(circle at 32% 28%, #ffffff 0 8%, #86efac 18%, #16a34a 68%, #14532d 100%);
131 Added: animation: led-green 1.35s ease-in-out infinite;
132 Added: }
133 Added:
134 Added: .header-status-link.is-warning .header-status-led {
135 Added: background: radial-gradient(circle at 32% 28%, #ffffff 0 8%, #fca5a5 18%, #dc2626 68%, #7f1d1d 100%);
136 Added: animation: led-red 1s ease-in-out infinite;
137 Added: }
138 Added:
139 Added: @keyframes led-green {
140 Added: 0%,
141 Added: 100% {
142 Added: box-shadow:
143 Added: inset 0 -1px 2px rgba(23, 33, 27, 0.45),
144 Added: 0 0 0.3rem rgba(22, 163, 74, 0.45);
145 Added: opacity: 0.78;
146 Added: }
147 Added:
148 Added: 50% {
149 Added: box-shadow:
150 Added: inset 0 -1px 2px rgba(23, 33, 27, 0.45),
151 Added: 0 0 0.85rem rgba(22, 163, 74, 0.95),
152 Added: 0 0 1.25rem rgba(134, 239, 172, 0.45);
153 Added: opacity: 1;
154 Added: }
155 Added: }
156 Added:
157 Added: @keyframes led-red {
158 Added: 0%,
159 Added: 100% {
160 Added: box-shadow:
161 Added: inset 0 -1px 2px rgba(23, 33, 27, 0.45),
162 Added: 0 0 0.35rem rgba(220, 38, 38, 0.5);
163 Added: opacity: 0.75;
164 Added: }
165 Added:
166 Added: 50% {
167 Added: box-shadow:
168 Added: inset 0 -1px 2px rgba(23, 33, 27, 0.45),
169 Added: 0 0 0.9rem rgba(220, 38, 38, 1),
170 Added: 0 0 1.35rem rgba(252, 165, 165, 0.5);
171 Added: opacity: 1;
172 Added: }
173 Added: }
174 Added:
175 Added: .nav-toggle {
176 Added: display: none;
177 Added: }
178 Added:
179 Added: .nav-toggle-button {
180 Added: display: none;
181 Added: }
182 Added:
49 183 .status-band {
50 184 margin-bottom: 1.25rem;
51 185 }
@@ -183,6 +317,23 @@
183 317 gap: 0.75rem;
184 318 }
185 319
320 Added: .reading-quick-link {
321 Added: min-width: 0;
322 Added: color: inherit;
323 Added: text-decoration: none;
324 Added: }
325 Added:
326 Added: .reading-quick-link:hover .reading-quick-item,
327 Added: .reading-quick-link:focus-visible .reading-quick-item {
328 Added: border-color: var(--accent);
329 Added: }
330 Added:
331 Added: .reading-quick-link:focus-visible {
332 Added: border-radius: 0.5rem;
333 Added: outline: 2px solid var(--accent);
334 Added: outline-offset: 2px;
335 Added: }
336 Added:
186 337 .reading-quick-item {
187 338 --warn-low: 27%;
188 339 --good-start: 35%;
@@ -191,6 +342,7 @@
191 342 display: grid;
192 343 gap: 0.7rem;
193 344 min-width: 0;
345 Added: height: 100%;
194 346 border: 1px solid var(--border);
195 347 border-radius: 0.5rem;
196 348 padding: 0.85rem;
@@ -314,6 +466,7 @@
314 466 .tab-panel {
315 467 display: none;
316 468 padding: 1.25rem;
469 Added: scroll-margin-top: 5.5rem;
317 470 }
318 471
319 472 .tab-panel.is-active {
@@ -328,8 +481,10 @@
328 481 margin-bottom: 1rem;
329 482 }
330 483
484 Added: .panel-heading h1,
331 485 .panel-heading h2 {
332 486 margin: 0;
487 Added: font-size: 1.35rem;
333 488 }
334 489
335 490 .panel-heading p {
@@ -411,6 +566,7 @@
411 566 margin-top: 1.25rem;
412 567 }
413 568
569 Added: .message-log h2,
414 570 .message-log h3 {
415 571 margin: 0 0 0.75rem;
416 572 font-size: 1rem;
@@ -458,15 +614,89 @@
458 614 @media (max-width: 700px) {
459 615 .site-main {
460 616 width: min(100% - 1rem, 1100px);
461 Removed: margin: 1rem auto;
617 Added: margin: 0 auto 1rem;
462 618 }
463 619
464 Removed: .page-header {
620 Added: .site-footer {
621 Added: width: min(100% - 1rem, 1100px);
465 622 margin-bottom: 1rem;
466 623 }
467 624
468 Removed: .page-header h1 {
469 Removed: font-size: 1.75rem;
625 Added: .page-header {
626 Added: align-items: center;
627 Added: width: 100%;
628 Added: margin: 0 0 1rem;
629 Added: border-right: 0;
630 Added: border-left: 0;
631 Added: border-radius: 0 0 0.5rem 0.5rem;
632 Added: }
633 Added:
634 Added: .header-status-link {
635 Added: order: 3;
636 Added: margin-left: 0;
637 Added: }
638 Added:
639 Added: .nav-toggle-button {
640 Added: order: 2;
641 Added: display: inline-grid;
642 Added: place-content: center;
643 Added: gap: 0.25rem;
644 Added: width: 2.75rem;
645 Added: height: 2.75rem;
646 Added: margin-left: auto;
647 Added: border: 1px solid var(--border);
648 Added: border-radius: 0.5rem;
649 Added: background: white;
650 Added: cursor: pointer;
651 Added: }
652 Added:
653 Added: .nav-toggle {
654 Added: position: absolute;
655 Added: display: block;
656 Added: top: 0.7rem;
657 Added: right: 3.95rem;
658 Added: width: 2.75rem;
659 Added: height: 2.75rem;
660 Added: opacity: 0;
661 Added: pointer-events: auto;
662 Added: cursor: pointer;
663 Added: }
664 Added:
665 Added: .nav-toggle:focus-visible + .nav-toggle-button {
666 Added: outline: 2px solid var(--accent);
667 Added: outline-offset: 2px;
668 Added: }
669 Added:
670 Added: .nav-toggle-button span {
671 Added: display: block;
672 Added: width: 1.15rem;
673 Added: height: 0.12rem;
674 Added: border-radius: 999px;
675 Added: background: var(--text);
676 Added: }
677 Added:
678 Added: .probe-nav {
679 Added: position: absolute;
680 Added: top: calc(100% + 0.35rem);
681 Added: right: 0;
682 Added: left: 0;
683 Added: display: none;
684 Added: border: 1px solid var(--border);
685 Added: border-radius: 0.5rem;
686 Added: padding: 0.5rem;
687 Added: background: white;
688 Added: box-shadow: 0 10px 30px rgba(23, 33, 27, 0.12);
689 Added: }
690 Added:
691 Added: .probe-nav a {
692 Added: display: block;
693 Added: width: 100%;
694 Added: border-radius: 0.4rem;
695 Added: padding: 0.75rem;
696 Added: }
697 Added:
698 Added: .nav-toggle:checked ~ .probe-nav {
699 Added: display: block;
470 700 }
471 701
472 702 .status-overview {
roles/dashboard/public/js/dashboard.js
index 2b7a6a14..fe82fc14 100644..100644
@@ -63,12 +63,27 @@
63 63 return Array.from(document.querySelectorAll("[data-sensor-status]"));
64 64 }
65 65
66 Added: function headerStatusItems() {
67 Added: return Array.from(document.querySelectorAll("[data-header-status-item]"));
68 Added: }
69 Added:
70 Added: function headerStatusElement() {
71 Added: return document.querySelector("[data-header-status]");
72 Added: }
73 Added:
66 74 function readingQuickItems() {
67 75 return Array.from(document.querySelectorAll("[data-reading-quick-probe]"));
68 76 }
69 77
70 78 function activeProbe() {
71 Removed: return document.querySelector(".tab-button.is-active").dataset.probe;
79 Added: const activeButton = document.querySelector(".tab-button.is-active");
80 Added: const activePanel = document.querySelector(".tab-panel.is-active") || document.querySelector(".tab-panel");
81 Added:
82 Added: if (activeButton) {
83 Added: return activeButton.dataset.probe;
84 Added: }
85 Added:
86 Added: return activePanel ? activePanel.dataset.panel : null;
72 87 }
73 88
74 89 function statusElement(probe) {
@@ -270,6 +285,19 @@
270 285 element.querySelector("[data-status-text]").textContent = text;
271 286 }
272 287
288 Added: function setHeaderStatus(state, text) {
289 Added: const element = headerStatusElement();
290 Added:
291 Added: if (!element) {
292 Added: return;
293 Added: }
294 Added:
295 Added: element.classList.remove("is-healthy", "is-warning", "is-unknown");
296 Added: element.classList.add(`is-${state}`);
297 Added: element.setAttribute("aria-label", text);
298 Added: element.title = text;
299 Added: }
300 Added:
273 301 async function fetchLatestStatus(probe) {
274 302 const response = await fetch(`/api/status/${probe}`);
275 303
@@ -441,6 +469,11 @@
441 469
442 470 async function loadQuickStatus() {
443 471 const items = readingQuickItems();
472 Added:
473 Added: if (!items.length) {
474 Added: return;
475 Added: }
476 Added:
444 477 const results = await Promise.allSettled(
445 478 items.map(item => fetchLatestReading(item.dataset.readingQuickProbe))
446 479 );
@@ -459,6 +492,11 @@
459 492
460 493 function renderMessageTable(probe, rows) {
461 494 const body = messageTableBody(probe);
495 Added:
496 Added: if (!body) {
497 Added: return;
498 Added: }
499 Added:
462 500 const latestRows = rows
463 501 .filter(row => row.probe === probe)
464 502 .slice(-10)
@@ -494,6 +532,12 @@
494 532
495 533 async function loadOverview() {
496 534 const rows = sensorStatusItems();
535 Added: const overview = overviewStatusElement();
536 Added:
537 Added: if (!overview) {
538 Added: return;
539 Added: }
540 Added:
497 541 const results = await Promise.allSettled(
498 542 rows.map(row => fetchLatestStatus(row.dataset.sensorStatus))
499 543 );
@@ -540,7 +584,48 @@
540 584 replaceChildren(detailRows, unhealthyItems.map(buildStatusDetailRow));
541 585 }
542 586
587 Added: async function loadHeaderStatus() {
588 Added: const rows = headerStatusItems();
589 Added:
590 Added: if (!headerStatusElement()) {
591 Added: return;
592 Added: }
593 Added:
594 Added: if (!rows.length) {
595 Added: setHeaderStatus("unknown", "No DAQ status items configured");
596 Added: return;
597 Added: }
598 Added:
599 Added: const results = await Promise.allSettled(
600 Added: rows.map(row => fetchLatestStatus(row.dataset.headerStatusItem))
601 Added: );
602 Added:
603 Added: const warningItems = rows
604 Added: .map((row, index) => {
605 Added: const result = results[index];
606 Added: const status = result.status === "fulfilled" ? result.value : null;
607 Added:
608 Added: return {
609 Added: label: row.dataset.headerStatusLabel,
610 Added: state: stateForNodeStatus(status)
611 Added: };
612 Added: })
613 Added: .filter(item => item.state !== "healthy");
614 Added:
615 Added: if (!warningItems.length) {
616 Added: setHeaderStatus("healthy", "All DAQ status checks are healthy");
617 Added: return;
618 Added: }
619 Added:
620 Added: const countLabel = warningItems.length === 1 ? "status check needs" : "status checks need";
621 Added: setHeaderStatus("warning", `${warningItems.length} ${countLabel} attention`);
622 Added: }
623 Added:
543 624 async function loadProbe(probe) {
625 Added: if (!probe || !chartElement(probe)) {
626 Added: return;
627 Added: }
628 Added:
544 629 const status = statusElement(probe);
545 630
546 631 try {
@@ -669,6 +754,24 @@
669 754 loadProbe(activeProbe());
670 755 }
671 756
757 Added: function probeFromHash() {
758 Added: const match = window.location.hash.match(/^#probe-([a-z]+)$/);
759 Added: const probe = match ? match[1] : null;
760 Added:
761 Added: return chartElement(probe) ? probe : null;
762 Added: }
763 Added:
764 Added: function probeFromPath() {
765 Added: const match = window.location.pathname.match(/^\/graphs\/([a-z]+)$/);
766 Added: const probe = match ? match[1] : null;
767 Added:
768 Added: return chartElement(probe) ? probe : null;
769 Added: }
770 Added:
771 Added: function probeFromLocation() {
772 Added: return probeFromPath() || probeFromHash();
773 Added: }
774 Added:
672 775 document.querySelectorAll(".tab-button").forEach(button => {
673 776 button.addEventListener("click", () => {
674 777 activateTab(button.dataset.probe);
@@ -687,12 +790,35 @@
687 790 });
688 791 });
689 792
690 Removed: loadProbe(activeProbe());
793 Added: window.addEventListener("hashchange", () => {
794 Added: const probe = probeFromLocation();
795 Added:
796 Added: if (probe) {
797 Added: activateTab(probe);
798 Added: }
799 Added: });
800 Added:
801 Added: const initialProbe = probeFromLocation();
802 Added:
803 Added: if (initialProbe) {
804 Added: activateTab(initialProbe);
805 Added: document.getElementById(`probe-${initialProbe}`).scrollIntoView();
806 Added: } else if (activeProbe()) {
807 Added: loadProbe(activeProbe());
808 Added: }
809 Added:
691 810 loadOverview();
692 811 loadQuickStatus();
812 Added: loadHeaderStatus();
693 813
694 814 setInterval(() => {
695 Removed: loadProbe(activeProbe());
815 Added: const probe = activeProbe();
816 Added:
817 Added: if (probe) {
818 Added: loadProbe(probe);
819 Added: }
820 Added:
696 821 loadOverview();
697 822 loadQuickStatus();
823 Added: loadHeaderStatus();
698 824 }, 5_000);
roles/dashboard/t/basic.t
index e1444f1b..99531d34 100644..100644
@@ -131,7 +131,25 @@
131 131
132 132 $t->get_ok('/')
133 133 ->status_is(200)
134 Removed: ->content_like(qr/FAPG DAQ Dashboard/)
134 Added: ->element_exists('body > header.page-header')
135 Added: ->element_exists_not('main.site-main > header.page-header')
136 Added: ->element_exists('input.nav-toggle[type="checkbox"]')
137 Added: ->text_is('a.page-brand[href="/"]', 'FAPG')
138 Added: ->element_exists('a.header-status-link[href="/"][data-header-status]')
139 Added: ->element_exists('a.header-status-link span.header-status-led')
140 Added: ->element_exists('[data-header-status-item="hub"]')
141 Added: ->element_exists('[data-header-status-item="ph"]')
142 Added: ->element_exists('[data-header-status-item="do"]')
143 Added: ->element_exists('[data-header-status-item="orp"]')
144 Added: ->element_exists('[data-header-status-item="ec"]')
145 Added: ->element_exists('nav.probe-nav a[href="/graphs/ph"][data-nav-probe="ph"]')
146 Added: ->element_exists('nav.probe-nav a[href="/graphs/do"][data-nav-probe="do"]')
147 Added: ->element_exists('nav.probe-nav a[href="/graphs/orp"][data-nav-probe="orp"]')
148 Added: ->element_exists('nav.probe-nav a[href="/graphs/ec"][data-nav-probe="ec"]')
149 Added: ->element_exists('footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
150 Added: ->content_like(qr/Copyright &copy; 2026 Marius Peter/)
151 Added: ->element_exists_not('section#probe-ph[data-panel="ph"]')
152 Added: ->element_exists_not('section.dashboard-card')
135 153 ->content_like(qr/Checking sensors/)
136 154 ->content_like(qr/data-overview-status/)
137 155 ->content_like(qr/data-sensor-status="ph"/)
@@ -141,18 +159,51 @@
141 159 ->content_like(qr/data-reading-quick-probe="do"/)
142 160 ->content_like(qr/data-reading-quick-probe="orp"/)
143 161 ->content_like(qr/data-reading-quick-probe="ec"/)
162 Added: ->element_exists('a.reading-quick-link[href="/graphs/ph"] article[data-reading-quick-probe="ph"]')
163 Added: ->element_exists('a.reading-quick-link[href="/graphs/do"] article[data-reading-quick-probe="do"]')
164 Added: ->element_exists('a.reading-quick-link[href="/graphs/orp"] article[data-reading-quick-probe="orp"]')
165 Added: ->element_exists('a.reading-quick-link[href="/graphs/ec"] article[data-reading-quick-probe="ec"]')
166 Added: ->element_exists_not('article[data-reading-quick-probe="ph"] h3 a')
144 167 ->content_like(qr/Dissolved Oxygen/)
168 Added: ->content_unlike(qr/Hourly/)
169 Added: ->content_unlike(qr/Daily/)
170 Added: ->content_unlike(qr/Weekly/)
171 Added: ->content_unlike(qr/Yearly/)
172 Added: ->content_unlike(qr/data-smoothing-toggle checked/)
173 Added: ->content_unlike(qr/smoothing/)
174 Added: ->content_unlike(qr/Last 10 pH Reading Messages/)
175 Added: ->content_unlike(qr/Last 10 Dissolved Oxygen Reading Messages/)
176 Added: ->content_unlike(qr/status-pill-list/)
177 Added: ->content_unlike(qr/Pi Zero 01/)
178 Added: ->content_unlike(qr/Connection Status/);
179 Added:
180 Added: $t->get_ok('/graphs/ph')
181 Added: ->status_is(200)
182 Added: ->text_is('a.page-brand[href="/"]', 'FAPG')
183 Added: ->element_exists('a.header-status-link[href="/"][data-header-status]')
184 Added: ->element_exists('a.header-status-link span.header-status-led')
185 Added: ->element_exists('[data-header-status-item="hub"]')
186 Added: ->element_exists('[data-header-status-item="ph"]')
187 Added: ->element_exists('[data-header-status-item="do"]')
188 Added: ->element_exists('[data-header-status-item="orp"]')
189 Added: ->element_exists('[data-header-status-item="ec"]')
190 Added: ->element_exists('nav.probe-nav a[href="/graphs/ph"][data-nav-probe="ph"]')
191 Added: ->element_exists('nav.probe-nav a[href="/graphs/do"][data-nav-probe="do"]')
192 Added: ->element_exists('nav.probe-nav a[href="/graphs/orp"][data-nav-probe="orp"]')
193 Added: ->element_exists('nav.probe-nav a[href="/graphs/ec"][data-nav-probe="ec"]')
194 Added: ->element_exists('footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
195 Added: ->element_exists('section#probe-ph[data-panel="ph"]')
196 Added: ->element_exists('canvas#chart-ph')
197 Added: ->element_exists_not('section#probe-do[data-panel="do"]')
198 Added: ->content_unlike(qr/Sensor Reading Quick Status/)
145 199 ->content_like(qr/Hourly/)
146 200 ->content_like(qr/Daily/)
147 201 ->content_like(qr/Weekly/)
148 202 ->content_like(qr/Yearly/)
149 203 ->content_like(qr/data-smoothing-toggle checked/)
150 Removed: ->content_like(qr/smoothing/)
151 Removed: ->content_like(qr/Last 10 pH Reading Messages/)
152 Removed: ->content_like(qr/Last 10 Dissolved Oxygen Reading Messages/)
153 Removed: ->content_unlike(qr/status-pill-list/)
154 Removed: ->content_unlike(qr/Pi Zero 01/)
155 Removed: ->content_unlike(qr/Connection Status/);
204 Added: ->content_like(qr/Latest pH Reading Messages/);
205 Added:
206 Added: $t->get_ok('/graphs/unknown')->status_is(404);
156 207
157 208 $t->get_ok('/api/readings/ph')
158 209 ->status_is(200)
roles/dashboard/templates/dashboard/graph.html.ep
index 00000000..8906d0dd 000000..100644
@@ -0,0 +1,65 @@
1 Added: <!-- -*- mode: web; -*- -->
2 Added:
3 Added: % layout 'default';
4 Added: % title "FAPG DAQ $probe->{label}";
5 Added:
6 Added: <section class="dashboard-card">
7 Added: <section
8 Added: id="probe-<%= $probe->{key} %>"
9 Added: class="tab-panel is-active"
10 Added: data-panel="<%= $probe->{key} %>">
11 Added:
12 Added: <div class="panel-heading">
13 Added: <h1><%= $probe->{label} %></h1>
14 Added: <div class="panel-tools">
15 Added: <p>
16 Added: Unit:
17 Added: <strong><%= $probe->{unit} %></strong>
18 Added: </p>
19 Added: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
20 Added: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
21 Added: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
22 Added: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
23 Added: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
24 Added: </div>
25 Added: <label class="smoothing-control">
26 Added: <input type="checkbox" data-smoothing-toggle checked>
27 Added: <span>Smoothed</span>
28 Added: </label>
29 Added: </div>
30 Added: </div>
31 Added:
32 Added: <div class="chart-wrap">
33 Added: <canvas id="chart-<%= $probe->{key} %>"></canvas>
34 Added: </div>
35 Added:
36 Added: <p class="reading-status" data-status="<%= $probe->{key} %>">
37 Added: Waiting for data…
38 Added: </p>
39 Added:
40 Added: <section class="message-log" aria-label="Last ten reading messages for <%= $probe->{label} %>">
41 Added: <h2>Latest <%= $probe->{label} %> Reading Messages</h2>
42 Added: <div class="message-table-wrap">
43 Added: <table class="message-table">
44 Added: <thead>
45 Added: <tr>
46 Added: <th scope="col">Received</th>
47 Added: <th scope="col">Age</th>
48 Added: <th scope="col">Node</th>
49 Added: <th scope="col">Value</th>
50 Added: <th scope="col">Unit</th>
51 Added: </tr>
52 Added: </thead>
53 Added: <tbody data-reading-messages="<%= $probe->{key} %>">
54 Added: <tr>
55 Added: <td colspan="5">Waiting for messages</td>
56 Added: </tr>
57 Added: </tbody>
58 Added: </table>
59 Added: </div>
60 Added: </section>
61 Added: </section>
62 Added: </section>
63 Added:
64 Added: <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
65 Added: <script src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/index.html.ep
index 7c24f273..d6205ef9 100644..100644
@@ -3,10 +3,6 @@
3 3 % layout 'default';
4 4 % title 'FAPG DAQ Dashboard';
5 5
6 Removed: <section class="page-header">
7 Removed: <h1>FAPG DAQ Dashboard</h1>
8 Removed: </section>
9 Removed:
10 6 <section class="status-band" aria-label="DAQ status overview">
11 7 <div class="status-overview">
12 8 <span class="status-pill status-summary is-unknown" data-overview-status>
@@ -35,107 +31,32 @@
35 31 </div>
36 32
37 33 <section class="reading-quick-status" aria-label="Sensor reading quick status">
38 Removed: <h2>Sensor Reading Quick Status</h2>
39 34 <div class="reading-quick-grid" data-reading-quick-status>
40 35 % for my $probe ($probes->@*) {
41 Removed: <article
42 Removed: class="reading-quick-item is-unknown"
43 Removed: data-reading-quick-probe="<%= $probe->{key} %>"
44 Removed: data-reading-quick-label="<%= $probe->{label} %>"
45 Removed: data-reading-quick-unit="<%= $probe->{unit} %>">
46 Removed: <div class="reading-quick-header">
47 Removed: <h3><%= $probe->{label} %></h3>
48 Removed: <p data-reading-quick-value>Waiting for data</p>
49 Removed: </div>
50 Removed: <div class="reading-range" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
51 Removed: <span class="reading-range-marker" data-reading-quick-marker></span>
52 Removed: </div>
53 Removed: <div class="reading-range-labels">
54 Removed: <span data-reading-quick-min></span>
55 Removed: <span data-reading-quick-target></span>
56 Removed: <span data-reading-quick-max></span>
57 Removed: </div>
58 Removed: </article>
36 Added: <a class="reading-quick-link" href="/graphs/<%= $probe->{key} %>">
37 Added: <article
38 Added: class="reading-quick-item is-unknown"
39 Added: data-reading-quick-probe="<%= $probe->{key} %>"
40 Added: data-reading-quick-label="<%= $probe->{label} %>"
41 Added: data-reading-quick-unit="<%= $probe->{unit} %>">
42 Added: <div class="reading-quick-header">
43 Added: <h3><%= $probe->{label} %></h3>
44 Added: <p data-reading-quick-value>Waiting for data</p>
45 Added: </div>
46 Added: <div class="reading-range" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
47 Added: <span class="reading-range-marker" data-reading-quick-marker></span>
48 Added: </div>
49 Added: <div class="reading-range-labels">
50 Added: <span data-reading-quick-min></span>
51 Added: <span data-reading-quick-target></span>
52 Added: <span data-reading-quick-max></span>
53 Added: </div>
54 Added: </article>
55 Added: </a>
59 56 % }
60 57 </div>
61 58 </section>
62 59 </div>
63 Removed: </section>
64 Removed:
65 Removed: <section class="dashboard-card">
66 Removed: <div class="tabs" role="tablist" aria-label="DAQ readings">
67 Removed: % for my $i (0 .. $probes->$#*) {
68 Removed: % my $probe = $probes->[$i];
69 Removed: <button
70 Removed: class="tab-button <%= $i == 0 ? 'is-active' : '' %>"
71 Removed: type="button"
72 Removed: role="tab"
73 Removed: data-probe="<%= $probe->{key} %>"
74 Removed: aria-selected="<%= $i == 0 ? 'true' : 'false' %>">
75 Removed: <%= $probe->{label} %>
76 Removed: </button>
77 Removed: % }
78 Removed: </div>
79 Removed:
80 Removed: % for my $i (0 .. $probes->$#*) {
81 Removed: % my $probe = $probes->[$i];
82 Removed:
83 Removed: <section
84 Removed: class="tab-panel <%= $i == 0 ? 'is-active' : '' %>"
85 Removed: data-panel="<%= $probe->{key} %>"
86 Removed: role="tabpanel">
87 Removed:
88 Removed: <div class="panel-heading">
89 Removed: <h2><%= $probe->{label} %></h2>
90 Removed: <div class="panel-tools">
91 Removed: <p>
92 Removed: Unit:
93 Removed: <strong><%= $probe->{unit} %></strong>
94 Removed: </p>
95 Removed: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
96 Removed: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
97 Removed: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
98 Removed: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
99 Removed: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
100 Removed: </div>
101 Removed: <label class="smoothing-control">
102 Removed: <input type="checkbox" data-smoothing-toggle checked>
103 Removed: <span>Smoothed</span>
104 Removed: </label>
105 Removed: </div>
106 Removed: </div>
107 Removed:
108 Removed: <div class="chart-wrap">
109 Removed: <canvas id="chart-<%= $probe->{key} %>"></canvas>
110 Removed: </div>
111 Removed:
112 Removed: <p class="reading-status" data-status="<%= $probe->{key} %>">
113 Removed: Waiting for data…
114 Removed: </p>
115 Removed:
116 Removed: <section class="message-log" aria-label="Last ten reading messages for <%= $probe->{label} %>">
117 Removed: <h3>Last 10 <%= $probe->{label} %> Reading Messages</h3>
118 Removed: <div class="message-table-wrap">
119 Removed: <table class="message-table">
120 Removed: <thead>
121 Removed: <tr>
122 Removed: <th scope="col">Received</th>
123 Removed: <th scope="col">Age</th>
124 Removed: <th scope="col">Node</th>
125 Removed: <th scope="col">Value</th>
126 Removed: <th scope="col">Unit</th>
127 Removed: </tr>
128 Removed: </thead>
129 Removed: <tbody data-reading-messages="<%= $probe->{key} %>">
130 Removed: <tr>
131 Removed: <td colspan="5">Waiting for messages</td>
132 Removed: </tr>
133 Removed: </tbody>
134 Removed: </table>
135 Removed: </div>
136 Removed: </section>
137 Removed: </section>
138 Removed: % }
139 60 </section>
140 61
141 62 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
roles/dashboard/templates/layouts/default.html.ep
index b3fccd2b..1b082ee8 100644..100644
@@ -12,8 +12,42 @@
12 12 <link rel="stylesheet" href="/css/app.css">
13 13 </head>
14 14 <body>
15 Added: <header class="page-header" aria-label="Dashboard navigation">
16 Added: <a class="page-brand" href="/">FAPG</a>
17 Added: <input class="nav-toggle" id="probe-nav-toggle" type="checkbox" aria-label="Toggle graph navigation">
18 Added: <label class="nav-toggle-button" for="probe-nav-toggle" aria-hidden="true">
19 Added: <span></span>
20 Added: <span></span>
21 Added: <span></span>
22 Added: </label>
23 Added: <nav class="probe-nav" aria-label="Probe graphs">
24 Added: % for my $probe (probes()->@*) {
25 Added: <a href="/graphs/<%= $probe->{key} %>" data-nav-probe="<%= $probe->{key} %>"><%= $probe->{label} %></a>
26 Added: % }
27 Added: </nav>
28 Added: <a
29 Added: class="header-status-link is-unknown"
30 Added: href="/"
31 Added: data-header-status
32 Added: aria-label="DAQ status unknown"
33 Added: title="Checking DAQ status">
34 Added: <span class="header-status-led"></span>
35 Added: </a>
36 Added: % for my $item (status_items()->@*) {
37 Added: <span
38 Added: hidden
39 Added: data-header-status-item="<%= $item->{key} %>"
40 Added: data-header-status-label="<%= $item->{label} %>"></span>
41 Added: % }
42 Added: </header>
15 43 <main class="site-main">
16 44 <%= content %>
17 45 </main>
46 Added: <footer class="site-footer">
47 Added: <p>
48 Added: <a href="https://www.lafermeaquaponique.com/">lafermeaquaponique.com</a>
49 Added: </p>
50 Added: <p>Copyright &copy; 2026 Marius Peter</p>
51 Added: </footer>
18 52 </body>
19 53 </html>