[Perl] DAQ system for the FAPG.
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.
Changed files
- roles/dashboard/lib/dashboard.pm
- roles/dashboard/lib/dashboard/Controller/Dashboard.pm
- roles/dashboard/public/css/app.css
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/t/basic.t
- roles/dashboard/templates/dashboard/graph.html.ep
- roles/dashboard/templates/dashboard/index.html.ep
- roles/dashboard/templates/layouts/default.html.ep
roles/dashboard/lib/dashboard.pm
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 © 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
@@ -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
@@ -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
@@ -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 © 2026 Marius Peter</p>
51
Added:
</footer>
18
52
</body>
19
53
</html>