Add combined probes dashboard page

Commit
9ca8fffeaebb27bec510a18a461087e3d41cca0f
Author
Codex <codex@openai.com>
Author date
Committer
Codex <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
index 9379a19d..3cc36eb9 100644..100644
@@ -73,6 +73,7 @@
73 73
74 74 $r->get('/')->to('dashboard#index');
75 75 $r->get('/status')->to('dashboard#status');
76 Added: $r->get('/probes')->to('dashboard#probe_list');
76 77 $r->get('/graphs/:probe')->to('dashboard#graph');
77 78
78 79 $r->get('/api/readings/:probe/series')->to('Reading#series');
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
index b8fabe5f..1d16dc68 100644..100644
@@ -17,6 +17,13 @@
17 17 );
18 18 }
19 19
20 Added: sub probe_list ($self) {
21 Added: $self->render(
22 Added: template => 'dashboard/probes',
23 Added: probes => $self->probes,
24 Added: );
25 Added: }
26 Added:
20 27 sub graph ($self) {
21 28 my $probe_key = $self->stash('probe');
22 29 my ($probe) = grep { $_->{key} eq $probe_key } $self->probes->@*;
roles/dashboard/public/css/app.css
index c9457777..bf0ebfcb 100644..100644
@@ -76,7 +76,7 @@
76 76 text-decoration: underline;
77 77 }
78 78
79 Removed: .probe-nav {
79 Added: .nav-links {
80 80 display: flex;
81 81 flex-wrap: wrap;
82 82 justify-content: flex-end;
@@ -84,7 +84,7 @@
84 84 margin-left: auto;
85 85 }
86 86
87 Removed: .probe-nav a {
87 Added: .nav-links a {
88 88 border-radius: 999px;
89 89 padding: 0.5rem 0.75rem;
90 90 color: var(--text);
@@ -93,7 +93,7 @@
93 93 text-decoration: none;
94 94 }
95 95
96 Removed: .probe-nav a:hover {
96 Added: .nav-links a:hover {
97 97 background: var(--accent-soft);
98 98 color: var(--accent);
99 99 }
@@ -661,7 +661,7 @@
661 661 background: var(--text);
662 662 }
663 663
664 Removed: .probe-nav {
664 Added: .nav-links {
665 665 position: absolute;
666 666 top: calc(100% + 0.35rem);
667 667 right: 0;
@@ -674,14 +674,14 @@
674 674 box-shadow: 0 10px 30px rgba(23, 33, 27, 0.12);
675 675 }
676 676
677 Removed: .probe-nav a {
677 Added: .nav-links a {
678 678 display: block;
679 679 width: 100%;
680 680 border-radius: 0.4rem;
681 681 padding: 0.75rem;
682 682 }
683 683
684 Removed: .nav-toggle:checked ~ .probe-nav {
684 Added: .nav-toggle:checked ~ .nav-links {
685 685 display: block;
686 686 }
687 687
roles/dashboard/public/js/dashboard.js
index 0dd6bb2c..2679edf6 100644..100644
@@ -86,6 +86,11 @@
86 86 return activePanel ? activePanel.dataset.panel : null;
87 87 }
88 88
89 Added: function displayedProbes() {
90 Added: return Array.from(document.querySelectorAll(".tab-panel[data-panel]"))
91 Added: .map(panel => panel.dataset.panel);
92 Added: }
93 Added:
89 94 function statusElement(probe) {
90 95 return document.querySelector(`[data-status="${probe}"]`);
91 96 }
@@ -753,7 +758,7 @@
753 758 button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
754 759 });
755 760
756 Removed: loadProbe(activeProbe());
761 Added: displayedProbes().forEach(loadProbe);
757 762 }
758 763
759 764 function setSmoothing(enabled) {
@@ -763,7 +768,7 @@
763 768 toggle.checked = smoothingEnabled;
764 769 });
765 770
766 Removed: loadProbe(activeProbe());
771 Added: displayedProbes().forEach(loadProbe);
767 772 }
768 773
769 774 function probeFromHash() {
@@ -816,7 +821,7 @@
816 821 activateTab(initialProbe);
817 822 document.getElementById(`probe-${initialProbe}`).scrollIntoView();
818 823 } else if (activeProbe()) {
819 Removed: loadProbe(activeProbe());
824 Added: displayedProbes().forEach(loadProbe);
820 825 }
821 826
822 827 loadOverview();
@@ -825,11 +830,7 @@
825 830 loadStatusPage();
826 831
827 832 setInterval(() => {
828 Removed: const probe = activeProbe();
829 Removed:
830 Removed: if (probe) {
831 Removed: loadProbe(probe);
832 Removed: }
833 Added: displayedProbes().forEach(loadProbe);
833 834
834 835 loadOverview();
835 836 loadQuickStatus();
roles/dashboard/t/00-homepage.t
index 6de2d7a5..466b7ec3 100644..100644
@@ -25,14 +25,8 @@
25 25 ->element_exists('[data-header-status-item="do"]')
26 26 ->element_exists('[data-header-status-item="orp"]')
27 27 ->element_exists('[data-header-status-item="ec"]')
28 Removed: ->element_exists(
29 Removed: 'nav.probe-nav a[href="/graphs/ph"][data-nav-probe="ph"]')
30 Removed: ->element_exists(
31 Removed: 'nav.probe-nav a[href="/graphs/do"][data-nav-probe="do"]')
32 Removed: ->element_exists(
33 Removed: 'nav.probe-nav a[href="/graphs/orp"][data-nav-probe="orp"]')
34 Removed: ->element_exists(
35 Removed: 'nav.probe-nav a[href="/graphs/ec"][data-nav-probe="ec"]')
28 Added: ->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
29 Added: ->element_exists_not('div.nav-links a[href^="/graphs/"]')
36 30 ->element_exists(
37 31 'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
38 32 ->content_like(qr/Copyright &copy; 2026 Marius Peter/)
roles/dashboard/t/01-graph-page.t
index 3000771c..800c612e 100644..100644
@@ -21,15 +21,9 @@
21 21 ->element_exists('[data-header-status-item="do"]')
22 22 ->element_exists('[data-header-status-item="orp"]')
23 23 ->element_exists('[data-header-status-item="ec"]')
24 Added: ->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
25 Added: ->element_exists_not('div.nav-links a[href^="/graphs/"]')
24 26 ->element_exists(
25 Removed: 'nav.probe-nav a[href="/graphs/ph"][data-nav-probe="ph"]')
26 Removed: ->element_exists(
27 Removed: 'nav.probe-nav a[href="/graphs/do"][data-nav-probe="do"]')
28 Removed: ->element_exists(
29 Removed: 'nav.probe-nav a[href="/graphs/orp"][data-nav-probe="orp"]')
30 Removed: ->element_exists(
31 Removed: 'nav.probe-nav a[href="/graphs/ec"][data-nav-probe="ec"]')
32 Removed: ->element_exists(
33 27 'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
34 28 ->element_exists('section#probe-ph[data-panel="ph"]')
35 29 ->element_exists('canvas#chart-ph')
@@ -43,5 +37,17 @@
43 37 ->content_like(qr/Latest pH Reading Messages/);
44 38
45 39 $t->get_ok('/graphs/unknown')->status_is(404);
40 Added:
41 Added: $t->get_ok('/probes')
42 Added: ->status_is(200)
43 Added: ->text_is('h1', 'pH')
44 Added: ->element_exists('section#probe-ph[data-panel="ph"]')
45 Added: ->element_exists('section#probe-do[data-panel="do"]')
46 Added: ->element_exists('section#probe-orp[data-panel="orp"]')
47 Added: ->element_exists('section#probe-ec[data-panel="ec"]')
48 Added: ->element_exists('canvas#chart-ph')
49 Added: ->element_exists('canvas#chart-do')
50 Added: ->element_exists('canvas#chart-orp')
51 Added: ->element_exists('canvas#chart-ec');
46 52
47 53 done_testing;
roles/dashboard/templates/dashboard/probes.html.ep
index 00000000..424739fd 000000..100644
@@ -0,0 +1,67 @@
1 Added: <!-- -*- mode: web; -*- -->
2 Added:
3 Added: % layout 'default';
4 Added: % title 'FAPG DAQ Probes';
5 Added:
6 Added: % for my $probe ($probes->@*) {
7 Added: <section class="dashboard-card">
8 Added: <section
9 Added: id="probe-<%= $probe->{key} %>"
10 Added: class="tab-panel is-active"
11 Added: data-panel="<%= $probe->{key} %>">
12 Added:
13 Added: <div class="panel-heading">
14 Added: <h1><%= $probe->{label} %></h1>
15 Added: <div class="panel-tools">
16 Added: <p>
17 Added: Unit:
18 Added: <strong><%= $probe->{unit} %></strong>
19 Added: </p>
20 Added: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
21 Added: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
22 Added: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
23 Added: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
24 Added: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
25 Added: </div>
26 Added: <label class="smoothing-control">
27 Added: <input type="checkbox" data-smoothing-toggle checked>
28 Added: <span>Smoothed</span>
29 Added: </label>
30 Added: </div>
31 Added: </div>
32 Added:
33 Added: <div class="chart-wrap">
34 Added: <canvas id="chart-<%= $probe->{key} %>"></canvas>
35 Added: </div>
36 Added:
37 Added: <p class="reading-status" data-status="<%= $probe->{key} %>">
38 Added: Waiting for data…
39 Added: </p>
40 Added:
41 Added: <section class="message-log" aria-label="Last ten reading messages for <%= $probe->{label} %>">
42 Added: <h2>Latest <%= $probe->{label} %> Reading Messages</h2>
43 Added: <div class="message-table-wrap">
44 Added: <table class="message-table">
45 Added: <thead>
46 Added: <tr>
47 Added: <th scope="col">Received</th>
48 Added: <th scope="col">Age</th>
49 Added: <th scope="col">Node</th>
50 Added: <th scope="col">Value</th>
51 Added: <th scope="col">Unit</th>
52 Added: </tr>
53 Added: </thead>
54 Added: <tbody data-reading-messages="<%= $probe->{key} %>">
55 Added: <tr>
56 Added: <td colspan="5">Waiting for messages</td>
57 Added: </tr>
58 Added: </tbody>
59 Added: </table>
60 Added: </div>
61 Added: </section>
62 Added: </section>
63 Added: </section>
64 Added: % }
65 Added:
66 Added: <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
67 Added: <script src="/js/dashboard.js"></script>
roles/dashboard/templates/layouts/default.html.ep
index d74e415b..1dc86700 100644..100644
@@ -14,17 +14,15 @@
14 14 <body>
15 15 <header class="page-header" aria-label="Dashboard navigation">
16 16 <a class="page-brand" href="/">FAPG</a>
17 Removed: <input class="nav-toggle" id="probe-nav-toggle" type="checkbox" aria-label="Toggle graph navigation">
18 Removed: <label class="nav-toggle-button" for="probe-nav-toggle" aria-hidden="true">
17 Added: <input class="nav-toggle" id="nav-links-toggle" type="checkbox" aria-label="Toggle navigation">
18 Added: <label class="nav-toggle-button" for="nav-links-toggle" aria-hidden="true">
19 19 <span></span>
20 20 <span></span>
21 21 <span></span>
22 22 </label>
23 Removed: <nav class="probe-nav" aria-label="Probe graphs">
24 Removed: % for my $probe (probes()->@*) {
25 Removed: <a href="/graphs/<%= $probe->{key} %>" data-nav-probe="<%= $probe->{key} %>"><%= $probe->{label} %></a>
26 Removed: % }
27 Removed: </nav>
23 Added: <div class="nav-links">
24 Added: <a href="/probes">Probes</a>
25 Added: </div>
28 26 <a
29 27 class="header-status-link is-unknown"
30 28 href="/status"