feat merge dashboard enhancements

Squash the dashboard-enhancements branch into master. - Centralize Bootstrap and Chart.js assets in the shared layout, add SRI and crossorigin metadata, and restore Bootstrap-powered graph action dropdowns. - Replace custom responsive controls with Bootstrap components, correct timeframe active-state styling, and keep raw-data links synchronized with the selected range. - Improve page hierarchy with active navigation, headings, breadcrumbs, overview status placement, and explicit wide-layout rendering for probe collections. - Add client-side download error feedback while preserving CSV endpoint behavior, and simplify dashboard markup and CSS. - Expand page regression coverage for navigation, layouts, graph downloads, control states, asset integrity, and download errors. Tests: prove -lr t (248 tests)

Commit
4aab04b141c387f41f56e2f6d007bebf865d2967
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
index 4c88e057..b7b4acf7 100644..100644
@@ -7,28 +7,31 @@
7 7
8 8 sub index ($self) {
9 9 $self->render(
10 Removed: template => 'dashboard/index',
11 Removed: probes => $self->probes,
12 Removed: breadcrumbs => [ { label => 'Home' } ],
13 Removed: show_overview_status => 1,
10 Added: template => 'dashboard/index',
11 Added: probes => $self->probes,
12 Added: breadcrumbs => [ { label => 'Home' } ],
14 13 );
15 14 }
16 15
17 16 sub status ($self) {
18 17 $self->render(
19 Removed: template => 'dashboard/status',
20 Removed: items => $self->status_items,
21 Removed: breadcrumbs =>
18 Added: template => 'dashboard/status',
19 Added: items => $self->status_items,
20 Added: nav_page => 'status',
21 Added: show_overview_status => 1,
22 Added: breadcrumbs =>
22 23 [ { label => 'Home', href => '/' }, { label => 'Status' }, ],
23 24 );
24 25 }
25 26
26 27 sub probe_list ($self) {
27 28 $self->render(
28 Removed: template => 'dashboard/probes',
29 Removed: probes => $self->probes,
30 Removed: container_class => 'container-wide',
31 Removed: breadcrumbs =>
29 Added: template => 'dashboard/probes',
30 Added: probes => $self->probes,
31 Added: wide_layout => 1,
32 Added: nav_page => 'probes',
33 Added: show_overview_status => 1,
34 Added: breadcrumbs =>
32 35 [ { label => 'Home', href => '/' }, { label => 'Probes' }, ],
33 36 graph_control_aria_label => 'All probe chart timeframes',
34 37 );
@@ -63,6 +66,8 @@
63 66 to => $to,
64 67 oldest => $oldest,
65 68 probes => $self->probes,
69 Added: nav_page => 'downloads',
70 Added: show_overview_status => 1,
66 71 breadcrumbs =>
67 72 [ { label => 'Home', href => '/' }, { label => 'Downloads' }, ],
68 73 );
@@ -75,9 +80,12 @@
75 80 return $self->reply->not_found unless defined $probe;
76 81
77 82 $self->render(
78 Removed: template => 'dashboard/graph',
79 Removed: probe => $probe,
80 Removed: breadcrumbs => [
83 Added: template => 'dashboard/graph',
84 Added: probe => $probe,
85 Added: nav_page => 'probes',
86 Added: show_overview_status => 1,
87 Added: single_probe => 1,
88 Added: breadcrumbs => [
81 89 { label => 'Home', href => '/' },
82 90 { label => 'Probes', href => '/probes' },
83 91 { label => $probe->{label} },
roles/dashboard/public/css/app.css
index 2a57e366..38ced7e4 100644..100644
@@ -181,6 +181,11 @@
181 181 --warn-high: 73%;
182 182 }
183 183
184 Added: /* Three-column label row under the range bar */
185 Added: .reading-range-labels {
186 Added: grid-template-columns: 1fr auto 1fr;
187 Added: }
188 Added:
184 189 .reading-range {
185 190 position: relative;
186 191 height: 0.85rem;
@@ -214,6 +219,11 @@
214 219 .reading-quick-item.is-low .reading-range-marker,
215 220 .reading-quick-item.is-high .reading-range-marker { background: var(--fapg-danger); }
216 221
222 Added: /* Status node detail-list term labels */
223 Added: .status-node-dt {
224 Added: font-size: 0.7rem;
225 Added: }
226 Added:
217 227 /* ----------------------------------------------------------------
218 228 Chart canvas sizing
219 229 ---------------------------------------------------------------- */
@@ -280,18 +290,4 @@
280 290 background-color: var(--fapg-accent) !important;
281 291 border-color: var(--fapg-accent) !important;
282 292 color: #fff !important;
283 Removed: }
284 Removed:
285 Removed: /* ----------------------------------------------------------------
286 Removed: Graph controls — mobile collapse
287 Removed: ---------------------------------------------------------------- */
288 Removed: @media (max-width: 767px) {
289 Removed: .graph-controls-panel {
290 Removed: display: none;
291 Removed: }
292 Removed:
293 Removed: .graph-controls-toggle[aria-expanded="true"] + .graph-controls-panel {
294 Removed: display: block;
295 Removed: margin-top: 0.5rem;
296 Removed: }
297 293 }
roles/dashboard/public/js/dashboard/graph-controls.js
index 776075c9..ec5b8b85 100644..100644
@@ -48,11 +48,5 @@
48 48 refreshCharts();
49 49 });
50 50 });
51 Removed: document.querySelectorAll("[data-graph-controls-toggle]").forEach(toggle => {
52 Removed: toggle.addEventListener("click", () => {
53 Removed: toggle.setAttribute("aria-expanded", toggle.getAttribute("aria-expanded") === "true" ? "false" : "true");
54 Removed: });
55 Removed: });
56 Removed:
57 51 updateControls(timeframe);
58 52 }
roles/dashboard/public/js/downloads.js
index dfca783f..15f7fce3 100644..100644
@@ -2,6 +2,8 @@
2 2 const customDateFields = document.querySelector("[data-custom-date-fields]");
3 3 const downloadFrom = document.querySelector("#download-from");
4 4 const downloadTo = document.querySelector("#download-to");
5 Added: const errorBanner = document.querySelector("[data-downloads-error]");
6 Added: const form = document.querySelector("form.downloads-form");
5 7
6 8 downloadRangeButtons.forEach(option => {
7 9 option.addEventListener("change", () => {
@@ -25,3 +27,57 @@
25 27 }
26 28 });
27 29 });
30 Added:
31 Added: function showError(message) {
32 Added: if (!errorBanner) return;
33 Added: errorBanner.textContent = message;
34 Added: errorBanner.classList.remove("d-none");
35 Added: }
36 Added:
37 Added: function clearError() {
38 Added: if (!errorBanner) return;
39 Added: errorBanner.textContent = "";
40 Added: errorBanner.classList.add("d-none");
41 Added: }
42 Added:
43 Added: function triggerDownload(blob, filename) {
44 Added: const url = URL.createObjectURL(blob);
45 Added: const link = document.createElement("a");
46 Added: link.href = url;
47 Added: link.download = filename;
48 Added: document.body.appendChild(link);
49 Added: link.click();
50 Added: link.remove();
51 Added: URL.revokeObjectURL(url);
52 Added: }
53 Added:
54 Added: function filenameFromResponse(response) {
55 Added: const header = response.headers.get("Content-Disposition") || "";
56 Added: const match = header.match(/filename="([^"]+)"/);
57 Added: return match ? match[1] : "readings.csv";
58 Added: }
59 Added:
60 Added: if (form) {
61 Added: form.addEventListener("submit", async event => {
62 Added: event.preventDefault();
63 Added: clearError();
64 Added:
65 Added: const params = new URLSearchParams(new FormData(form));
66 Added: const url = `${form.action}?${params}`;
67 Added:
68 Added: try {
69 Added: const response = await fetch(url);
70 Added:
71 Added: if (!response.ok) {
72 Added: const text = await response.text();
73 Added: showError(text || `Download failed (HTTP ${response.status}).`);
74 Added: return;
75 Added: }
76 Added:
77 Added: const blob = await response.blob();
78 Added: triggerDownload(blob, filenameFromResponse(response));
79 Added: } catch (err) {
80 Added: showError("Could not reach the server. Check your connection and try again.");
81 Added: }
82 Added: });
83 Added: }
roles/dashboard/t/00-homepage.t
index fdef4808..3adae562 100644..100644
@@ -17,17 +17,19 @@
17 17 ->text_is( 'a.navbar-brand[href="/"]', 'FAPG' )
18 18 ->text_is( '.navbar-nav a.nav-link[href="/probes"]', 'Probes' )
19 19 ->text_is( '.navbar-nav a.nav-link[href="/downloads"]', 'Downloads' )
20 Added: ->element_exists_not('.navbar-nav a.nav-link.active')
20 21 ->element_exists(
21 22 'a.header-status-link[href="/status"][data-header-status]')
22 23 ->element_exists(
23 24 'nav[aria-label="Breadcrumb"] [aria-current="page"]')
24 25 ->text_is(
25 26 'nav[aria-label="Breadcrumb"] [aria-current="page"]', 'Home' )
26 Removed: ->element_exists(
27 Added: ->element_exists_not(
27 28 'div.toolbar-items a.status-summary[href="/status"][data-overview-status]'
28 29 )
29 30 ->element_exists(
30 31 'section.reading-quick-status [data-reading-quick-status]')
32 Added: ->element_exists('section.reading-quick-status h2')
31 33 ->element_exists(
32 34 'footer a[href="https://www.lafermeaquaponique.com/"]');
33 35
roles/dashboard/t/01-graph-page.t
index 1d2b3fe7..0bbba616 100644..100644
@@ -20,8 +20,9 @@
20 20 ->text_is(
21 21 'nav[aria-label="Breadcrumb"] [aria-current="page"]', 'pH' )
22 22 ->text_is( '.card-header h1 a[href="/probes/ph"]', 'pH' )
23 Added: ->element_exists('.navbar-nav a.nav-link.active[href="/probes"][aria-current="page"]')
23 24 ->element_exists('section#probe-ph[data-panel="ph"] canvas#chart-ph')
24 Removed: ->element_exists('[data-status="ph"]')
25 Added: ->element_exists_not('[data-status="ph"]')
25 26 ->element_exists(
26 27 'div.toolbar-items div.graph-control > div.timeframe-control[role="group"]'
27 28 )
@@ -30,6 +31,14 @@
30 31 'Graph controls'
31 32 )
32 33 ->text_is( 'a[data-raw-download][data-probe="ph"]', 'Download raw data' )
34 Added: ->element_exists(
35 Added: 'button[data-timeframe="day"].timeframe-button.btn-outline-secondary'
36 Added: )
37 Added: ->element_exists_not('button[data-timeframe="day"].timeframe-button.btn-success')
38 Added: ->element_exists(
39 Added: 'script[src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"]'
40 Added: . '[integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"]'
41 Added: )
33 42 ->element_exists('tbody[data-reading-messages="ph"]')
34 43 ->element_exists_not('section#probe-do[data-panel="do"]');
35 44
@@ -50,13 +59,17 @@
50 59 'nav[aria-label="Breadcrumb"] [aria-current="page"]', 'Probes' )
51 60 ->element_exists('main.container-wide')
52 61 ->element_exists('div.probes-grid')
53 Removed: ->element_exists('div.toolbar-items div.graph-control');
62 Added: ->element_exists('div.toolbar-items div.graph-control')
63 Added: ->element_exists('.navbar-nav a.nav-link.active[href="/probes"][aria-current="page"]')
64 Added: ->element_exists(
65 Added: 'div.toolbar-items a.status-summary[href="/status"][data-overview-status]');
54 66
55 67 for my $probe (qw(ph do orp ec)) {
56 68 $t->element_exists(qq{div.probes-grid .card-header h1 a[href="/probes/$probe"]});
57 69 $t->element_exists(
58 70 qq{section#probe-${probe}[data-panel="$probe"] canvas#chart-$probe});
59 71 $t->element_exists(qq{a[data-raw-download][data-probe="$probe"]});
72 Added: $t->element_exists(qq{[data-status="$probe"]});
60 73 }
61 74
62 75 done_testing;
roles/dashboard/t/06-downloads.t
index 0c00aecb..12e83ad8 100644..100644
@@ -18,7 +18,9 @@
18 18 ->text_is(
19 19 'nav[aria-label="Breadcrumb"] [aria-current="page"]', 'Downloads' )
20 20 ->text_is( 'h1', 'Downloads' )
21 Added: ->element_exists('.navbar-nav a.nav-link.active[href="/downloads"][aria-current="page"]')
21 22 ->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
23 Added: ->element_exists('[data-downloads-error]')
22 24 ->text_is(
23 25 'select#download-probe[name="probe"] option[value=""][selected]',
24 26 'All probes' )
roles/dashboard/templates/dashboard/downloads.html.ep
index 8c905602..7d79c180 100644..100644
@@ -10,6 +10,7 @@
10 10 </div>
11 11
12 12 <div class="card-body">
13 Added: <div class="alert alert-danger d-none" role="alert" id="downloads-error" data-downloads-error></div>
13 14 <form action="/downloads/readings.csv" method="get" class="downloads-form d-flex flex-column gap-3">
14 15
15 16 <div>
@@ -61,5 +62,4 @@
61 62 </div>
62 63 </div>
63 64
64 Removed: <script type="module" src="/js/dashboard.js"></script>
65 65 <script src="/js/downloads.js"></script>
roles/dashboard/templates/dashboard/graph-control.html.ep
index e9fec41c..8bef30bf 100644..100644
@@ -4,10 +4,12 @@
4 4 <button
5 5 class="btn btn-outline-secondary d-md-none w-100 mb-2 graph-controls-toggle"
6 6 type="button"
7 Added: data-bs-toggle="collapse"
8 Added: data-bs-target="#graph-controls-panel"
7 9 aria-expanded="false"
8 10 aria-controls="graph-controls-panel"
9 11 data-graph-controls-toggle>Graph controls</button>
10 Removed: <div class="graph-controls-panel" id="graph-controls-panel">
12 Added: <div class="collapse d-md-block graph-controls-panel" id="graph-controls-panel">
11 13 <div class="graph-control">
12 14 <div class="btn-group timeframe-control" role="group" aria-label="<%= $aria_label %>">
13 15 % for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ month => 'Monthly' ], [ year => 'Yearly' ] ) {
@@ -19,7 +21,7 @@
19 21 data-timeframe="<%= $timeframe->[0] %>"
20 22 aria-label="Decrease <%= $timeframe->[0] %> range">−</button>
21 23 <button
22 Removed: class="btn btn-sm<%= $timeframe->[0] eq 'day' ? ' btn-success' : ' btn-outline-secondary' %> timeframe-button"
24 Added: class="btn btn-sm btn-outline-secondary timeframe-button"
23 25 type="button"
24 26 data-timeframe="<%= $timeframe->[0] %>"
25 27 data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
roles/dashboard/templates/dashboard/graph.html.ep
index 8e977629..d8abb149 100644..100644
@@ -2,6 +2,7 @@
2 2
3 3 % layout 'default';
4 4 % title "FAPG DAQ $probe->{label}";
5 Added: % stash use_charts => 1;
5 6
6 7 %= include 'dashboard/probe-card', probe => $probe
7 8
@@ -30,6 +31,3 @@
30 31 </div>
31 32 </div>
32 33 </div>
33 Removed:
34 Removed: <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
35 Removed: <script type="module" src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/index.html.ep
index abb9da19..a3634887 100644..100644
@@ -2,6 +2,7 @@
2 2
3 3 % layout 'default';
4 4 % title 'FAPG DAQ Dashboard';
5 Added: % stash use_charts => 1;
5 6
6 7 <section class="mb-4" aria-label="DAQ status overview">
7 8 % for my $probe ($probes->@*) {
@@ -11,7 +12,13 @@
11 12 data-sensor-label="<%= $probe->{label} %>"></span>
12 13 % }
13 14
15 Added: <div class="mb-3">
16 Added: <h1 class="h4 fw-bold mb-1">FAPG DAQ Dashboard</h1>
17 Added: <p class="text-muted mb-0">Live aquaponic sensor readings</p>
18 Added: </div>
19 Added:
14 20 <section class="reading-quick-status" aria-label="Sensor reading quick status">
21 Added: <h2 class="h6 fw-semibold text-uppercase text-muted mb-3">Live readings</h2>
15 22 <div class="row g-3" data-reading-quick-status>
16 23 % for my $probe ($probes->@*) {
17 24 <div class="col-12 col-sm-6 col-xl-3">
@@ -29,7 +36,7 @@
29 36 <div class="reading-range" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
30 37 <span class="reading-range-marker" data-reading-quick-marker></span>
31 38 </div>
32 Removed: <div class="reading-range-labels d-grid gap-1 small text-muted" style="grid-template-columns: 1fr auto 1fr">
39 Added: <div class="reading-range-labels d-grid gap-1 small text-muted">
33 40 <span data-reading-quick-min></span>
34 41 <span class="text-success fw-bold text-center" data-reading-quick-target></span>
35 42 <span class="text-end" data-reading-quick-max></span>
@@ -42,6 +49,3 @@
42 49 </div>
43 50 </section>
44 51 </section>
45 Removed:
46 Removed: <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
47 Removed: <script type="module" src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/probe-card.html.ep
index 0699ec6c..781ca458 100644..100644
@@ -17,7 +17,7 @@
17 17 data-bs-toggle="dropdown"
18 18 aria-expanded="false"
19 19 aria-label="Actions for <%= $probe->{label} %>">
20 Removed: <span class="probe-card-menu-icon" aria-hidden="true"></span>
20 Added: &#8942;
21 21 </button>
22 22 <ul class="dropdown-menu dropdown-menu-end">
23 23 <li>
@@ -35,9 +35,11 @@
35 35 <div class="chart-wrap">
36 36 <canvas id="chart-<%= $probe->{key} %>"></canvas>
37 37 </div>
38 Added: % unless (stash('single_probe')) {
38 39 <p class="text-muted small mt-2 mb-0 reading-status" data-status="<%= $probe->{key} %>">
39 40 Waiting for data…
40 41 </p>
42 Added: % }
41 43 </section>
42 44 </div>
43 45 </div>
roles/dashboard/templates/dashboard/probes.html.ep
index 7a1894a2..059f04c7 100644..100644
@@ -2,6 +2,7 @@
2 2
3 3 % layout 'default';
4 4 % title 'FAPG DAQ Probes';
5 Added: % stash use_charts => 1;
5 6
6 7 <div class="row g-3 probes-grid">
7 8 % for my $probe ($probes->@*) {
@@ -10,6 +11,3 @@
10 11 </div>
11 12 % }
12 13 </div>
13 Removed:
14 Removed: <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
15 Removed: <script type="module" src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/status.html.ep
index f9d9f376..f0178b81 100644..100644
@@ -32,11 +32,11 @@
32 32 </div>
33 33 <dl class="row row-cols-1 row-cols-sm-2 g-2 mb-0 small">
34 34 <div class="col">
35 Removed: <dt class="text-muted text-uppercase fw-semibold" style="font-size:.7rem">Last status</dt>
35 Added: <dt class="text-muted text-uppercase fw-semibold status-node-dt">Last status</dt>
36 36 <dd class="mb-0" data-status-page-last-seen>Checking…</dd>
37 37 </div>
38 38 <div class="col">
39 Removed: <dt class="text-muted text-uppercase fw-semibold" style="font-size:.7rem">Message</dt>
39 Added: <dt class="text-muted text-uppercase fw-semibold status-node-dt">Message</dt>
40 40 <dd class="mb-0" data-status-page-message>Checking status…</dd>
41 41 </div>
42 42 </dl>
@@ -48,5 +48,3 @@
48 48 </div>
49 49 </div>
50 50 </div>
51 Removed:
52 Removed: <script type="module" src="/js/dashboard.js"></script>
roles/dashboard/templates/layouts/default.html.ep
index d8c6e97d..bd7ed4e9 100644..100644
@@ -51,10 +51,18 @@
51 51 <div class="collapse navbar-collapse" id="navbarLinks">
52 52 <ul class="navbar-nav ms-auto mb-2 mb-md-0">
53 53 <li class="nav-item">
54 Added: % if ((stash('nav_page') // '') eq 'probes') {
55 Added: <a class="nav-link fw-semibold active" href="/probes" aria-current="page">Probes</a>
56 Added: % } else {
54 57 <a class="nav-link fw-semibold" href="/probes">Probes</a>
58 Added: % }
55 59 </li>
56 60 <li class="nav-item">
61 Added: % if ((stash('nav_page') // '') eq 'downloads') {
62 Added: <a class="nav-link fw-semibold active" href="/downloads" aria-current="page">Downloads</a>
63 Added: % } else {
57 64 <a class="nav-link fw-semibold" href="/downloads">Downloads</a>
65 Added: % }
58 66 </li>
59 67 </ul>
60 68 </div>
@@ -62,7 +70,7 @@
62 70 </div>
63 71 </nav>
64 72
65 Removed: <main class="<%= stash('container_class') // 'container' %>">
73 Added: <main class="<%= stash('wide_layout') ? 'container-wide' : 'container' %>">
66 74 %= include 'dashboard/toolbar', breadcrumbs => (stash('breadcrumbs') // []), graph_control_aria_label => stash('graph_control_aria_label'), show_overview_status => stash('show_overview_status')
67 75 <%= content %>
68 76 </main>
@@ -76,7 +84,14 @@
76 84
77 85 <script
78 86 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
79 Removed: integrity="sha384-YvpcrYf0tY3lHB60NNkmXc4s9bIOgUxi8T/jzmDPtGBsYjRfXpjIIl0gEiDIVrqf"
87 Added: integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
80 88 crossorigin="anonymous"></script>
89 Added: % if (stash('use_charts')) {
90 Added: <script
91 Added: src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"
92 Added: integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ"
93 Added: crossorigin="anonymous"></script>
94 Added: % }
95 Added: <script type="module" src="/js/dashboard.js"></script>
81 96 </body>
82 97 </html>