Add DAQ readings CSV downloads

Commit
47bb17bec6697e1e05872287194e82806cfb25b8
Author
Codex <codex@openai.com>
Author date
Committer
Codex <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
index 3cc36eb9..a109a7b7 100644..100644
@@ -74,6 +74,8 @@
74 74 $r->get('/')->to('dashboard#index');
75 75 $r->get('/status')->to('dashboard#status');
76 76 $r->get('/probes')->to('dashboard#probe_list');
77 Added: $r->get('/downloads')->to('dashboard#downloads');
78 Added: $r->get('/downloads/readings.csv')->to('Download#readings');
77 79 $r->get('/graphs/:probe')->to('dashboard#graph');
78 80
79 81 $r->get('/api/readings/:probe/series')->to('Reading#series');
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
index 1d16dc68..075c38c0 100644..100644
@@ -3,6 +3,8 @@
3 3 package FAPG::DAQ::Dashboard::Controller::Dashboard;
4 4 use Mojo::Base 'Mojolicious::Controller', -signatures;
5 5
6 Added: use POSIX qw(strftime);
7 Added:
6 8 sub index ($self) {
7 9 $self->render(
8 10 template => 'dashboard/index',
@@ -21,6 +23,32 @@
21 23 $self->render(
22 24 template => 'dashboard/probes',
23 25 probes => $self->probes,
26 Added: );
27 Added: }
28 Added:
29 Added: sub downloads ($self) {
30 Added: my $from = strftime( '%Y-%m-%d', gmtime( time - ( 7 * 24 * 60 * 60 ) ) );
31 Added: my $to = strftime( '%Y-%m-%d', gmtime(time) );
32 Added: my $database = $self->sqlite->db;
33 Added: my $table = $database->query(
34 Added: q{SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'readings'}
35 Added: )->hash;
36 Added: my $oldest;
37 Added:
38 Added: if ($table) {
39 Added: $oldest = $database->query(
40 Added: 'SELECT MIN(COALESCE(received_at, timestamp)) AS timestamp FROM readings'
41 Added: )->hash->{timestamp};
42 Added: }
43 Added:
44 Added: $oldest = $from if !defined $oldest;
45 Added: $oldest =~ s/T.*\z//;
46 Added:
47 Added: $self->render(
48 Added: template => 'dashboard/downloads',
49 Added: from => $from,
50 Added: to => $to,
51 Added: oldest => $oldest,
24 52 );
25 53 }
26 54
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Download.pm
index 00000000..81d73603 000000..100644
@@ -0,0 +1,84 @@
1 Added: # -*- mode: cperl; -*-
2 Added:
3 Added: package FAPG::DAQ::Dashboard::Controller::Download;
4 Added: use Mojo::Base 'Mojolicious::Controller', -signatures;
5 Added:
6 Added: use POSIX qw(strftime);
7 Added: use Time::Local qw(timegm);
8 Added:
9 Added: sub readings ($self) {
10 Added: my $from = $self->param('from') // '';
11 Added: my $to = $self->param('to') // '';
12 Added: my $from_epoch = date_epoch($from);
13 Added: my $to_epoch = date_epoch($to);
14 Added:
15 Added: return $self->render(
16 Added: status => 400,
17 Added: text => 'The from and to dates must be valid calendar dates.',
18 Added: ) unless defined $from_epoch && defined $to_epoch;
19 Added:
20 Added: return $self->render(
21 Added: status => 400,
22 Added: text => 'The from date must not be after the to date.',
23 Added: ) if $from_epoch > $to_epoch;
24 Added:
25 Added: my $database = $self->sqlite->db;
26 Added: my $table = $database->query(
27 Added: q{SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'readings'}
28 Added: )->hash;
29 Added: my $rows = [];
30 Added:
31 Added: if ($table) {
32 Added: $rows = $database->query(
33 Added: q{
34 Added: SELECT timestamp, received_at, node, probe, value, unit
35 Added: FROM readings
36 Added: WHERE COALESCE(received_at, timestamp) >= ?
37 Added: AND COALESCE(received_at, timestamp) < ?
38 Added: ORDER BY COALESCE(received_at, timestamp), id
39 Added: },
40 Added: utc_timestamp($from_epoch),
41 Added: utc_timestamp( $to_epoch + ( 24 * 60 * 60 ) ),
42 Added: )->hashes->to_array;
43 Added: }
44 Added:
45 Added: my @lines = ('timestamp,received_at,node,probe,value,unit');
46 Added:
47 Added: for my $row ( $rows->@* ) {
48 Added: push @lines, join ',', map { csv_value( $row->{$_} ) }
49 Added: qw(timestamp received_at node probe value unit);
50 Added: }
51 Added:
52 Added: my $filename = "fapg-daq-readings-$from-to-$to.csv";
53 Added:
54 Added: $self->res->headers->content_type('text/csv; charset=utf-8');
55 Added: $self->res->headers->content_disposition(
56 Added: qq{attachment; filename="$filename"} );
57 Added: $self->render( data => join( "\n", @lines ) . "\n" );
58 Added: }
59 Added:
60 Added: sub csv_value ($value) {
61 Added: $value //= '';
62 Added: $value =~ s/"/""/g;
63 Added:
64 Added: return qq{"$value"};
65 Added: }
66 Added:
67 Added: sub date_epoch ($date) {
68 Added: return undef unless $date =~ /\A(\d{4})-(\d{2})-(\d{2})\z/;
69 Added:
70 Added: my ( $year, $month, $day ) = ( $1, $2, $3 );
71 Added: my $epoch = eval { timegm( 0, 0, 0, $day, $month - 1, $year ) };
72 Added:
73 Added: return undef if !defined $epoch || $@;
74 Added:
75 Added: return undef unless strftime( '%Y-%m-%d', gmtime($epoch) ) eq $date;
76 Added:
77 Added: return $epoch;
78 Added: }
79 Added:
80 Added: sub utc_timestamp ($epoch) {
81 Added: return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
82 Added: }
83 Added:
84 Added: 1;
roles/dashboard/public/css/app.css
index bf0ebfcb..1f025783 100644..100644
@@ -98,6 +98,80 @@
98 98 color: var(--accent);
99 99 }
100 100
101 Added: .downloads-form {
102 Added: display: flex;
103 Added: flex: 1;
104 Added: flex-direction: column;
105 Added: gap: 1rem;
106 Added: }
107 Added:
108 Added: .downloads-card {
109 Added: display: flex;
110 Added: flex-direction: column;
111 Added: min-height: 20rem;
112 Added: }
113 Added:
114 Added: .downloads-card .card-body {
115 Added: display: flex;
116 Added: flex: 1;
117 Added: }
118 Added:
119 Added: .download-date-fields {
120 Added: display: flex;
121 Added: flex-wrap: wrap;
122 Added: gap: 1rem;
123 Added: }
124 Added:
125 Added: .download-date-field {
126 Added: display: grid;
127 Added: gap: 0.35rem;
128 Added: }
129 Added:
130 Added: .download-date-field label {
131 Added: font-weight: 700;
132 Added: }
133 Added:
134 Added: .date-input-control {
135 Added: display: flex;
136 Added: gap: 0.5rem;
137 Added: }
138 Added:
139 Added: .date-input-control input,
140 Added: .downloads-button,
141 Added: .secondary-button {
142 Added: min-height: 2.75rem;
143 Added: border: 1px solid var(--border);
144 Added: border-radius: 0.4rem;
145 Added: padding: 0.5rem 0.65rem;
146 Added: font: inherit;
147 Added: }
148 Added:
149 Added: .downloads-button {
150 Added: align-self: flex-end;
151 Added: margin-top: auto;
152 Added: border-color: var(--accent);
153 Added: background: var(--accent);
154 Added: color: white;
155 Added: font-weight: 700;
156 Added: cursor: pointer;
157 Added: }
158 Added:
159 Added: .downloads-button:hover {
160 Added: filter: brightness(0.9);
161 Added: }
162 Added:
163 Added: .secondary-button {
164 Added: background: white;
165 Added: color: var(--accent);
166 Added: font-weight: 700;
167 Added: cursor: pointer;
168 Added: }
169 Added:
170 Added: .secondary-button:hover {
171 Added: border-color: var(--accent);
172 Added: background: var(--accent-soft);
173 Added: }
174 Added:
101 175 .header-status-link {
102 176 position: relative;
103 177 display: inline-grid;
@@ -394,7 +468,7 @@
394 468 text-align: right;
395 469 }
396 470
397 Removed: .dashboard-card {
471 Added: .card {
398 472 background: var(--card);
399 473 border: 1px solid var(--border);
400 474 border-radius: 0.5rem;
@@ -402,6 +476,10 @@
402 476 overflow: hidden;
403 477 }
404 478
479 Added: .card + .card {
480 Added: margin-top: 1rem;
481 Added: }
482 Added:
405 483 .tabs {
406 484 display: flex;
407 485 flex-wrap: wrap;
@@ -432,35 +510,39 @@
432 510 color: white;
433 511 }
434 512
435 Removed: .tab-panel {
436 Removed: display: none;
437 Removed: padding: 1.25rem;
438 Removed: scroll-margin-top: 5.5rem;
439 Removed: }
440 Removed:
441 Removed: .tab-panel.is-active {
442 Removed: display: block;
443 Removed: }
444 Removed:
445 Removed: .panel-heading {
513 Added: .card-heading {
446 514 display: flex;
447 515 justify-content: space-between;
448 516 gap: 1rem;
449 517 align-items: flex-start;
450 Removed: margin-bottom: 1rem;
518 Added: padding: 1.25rem;
519 Added: border-bottom: 1px solid var(--border);
451 520 }
452 521
453 Removed: .panel-heading h1,
454 Removed: .panel-heading h2 {
522 Added: .card-heading h1,
523 Added: .card-heading h2 {
455 524 margin: 0;
456 525 font-size: 1.35rem;
457 526 }
458 527
459 Removed: .panel-heading p {
528 Added: .card-heading p {
460 529 margin: 0;
461 530 color: var(--muted);
462 531 }
463 532
533 Added: .card-body {
534 Added: padding: 1.25rem;
535 Added: }
536 Added:
537 Added: .tab-panel {
538 Added: display: none;
539 Added: scroll-margin-top: 5.5rem;
540 Added: }
541 Added:
542 Added: .tab-panel.is-active {
543 Added: display: block;
544 Added: }
545 Added:
464 546 .panel-tools {
465 547 display: flex;
466 548 flex-wrap: wrap;
@@ -574,14 +656,6 @@
574 656 border-bottom: 0;
575 657 }
576 658
577 Removed: .status-page-card {
578 Removed: padding: 1.25rem;
579 Removed: }
580 Removed:
581 Removed: .status-page-card .panel-heading {
582 Removed: margin-bottom: 1rem;
583 Removed: }
584 Removed:
585 659 .status-table {
586 660 min-width: 760px;
587 661 }
@@ -724,11 +798,12 @@
724 798 padding-inline: 0.65rem;
725 799 }
726 800
727 Removed: .panel-heading {
801 Added: .card-heading {
728 802 display: block;
729 803 }
730 804
731 Removed: .tab-panel {
805 Added: .card-heading,
806 Added: .card-body {
732 807 padding: 1rem;
733 808 }
734 809
roles/dashboard/public/js/downloads.js
index 00000000..a1e40619 000000..100644
@@ -0,0 +1,9 @@
1 Added: document.querySelectorAll('[data-date-shortcut]').forEach(button => {
2 Added: button.addEventListener('click', () => {
3 Added: const input = document.querySelector(button.dataset.dateShortcut);
4 Added:
5 Added: if (input) {
6 Added: input.value = button.dataset.dateValue;
7 Added: }
8 Added: });
9 Added: });
roles/dashboard/t/00-homepage.t
index 466b7ec3..f06122f1 100644..100644
@@ -31,7 +31,7 @@
31 31 'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
32 32 ->content_like(qr/Copyright &copy; 2026 Marius Peter/)
33 33 ->element_exists_not('section#probe-ph[data-panel="ph"]')
34 Removed: ->element_exists_not('section.dashboard-card')
34 Added: ->element_exists_not('div.card')
35 35 ->content_like(qr/Checking sensors/)
36 36 ->content_like(qr/data-overview-status/)
37 37 ->content_like(qr/data-sensor-status="ph"/)
roles/dashboard/t/01-graph-page.t
index 800c612e..41e35f37 100644..100644
@@ -25,6 +25,8 @@
25 25 ->element_exists_not('div.nav-links a[href^="/graphs/"]')
26 26 ->element_exists(
27 27 'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
28 Added: ->element_exists('div.card > div.card-heading')
29 Added: ->element_exists('div.card > div.card-body')
28 30 ->element_exists('section#probe-ph[data-panel="ph"]')
29 31 ->element_exists('canvas#chart-ph')
30 32 ->element_exists_not('section#probe-do[data-panel="do"]')
@@ -41,6 +43,8 @@
41 43 $t->get_ok('/probes')
42 44 ->status_is(200)
43 45 ->text_is('h1', 'pH')
46 Added: ->element_exists('div.card > div.card-heading')
47 Added: ->element_exists('div.card > div.card-body')
44 48 ->element_exists('section#probe-ph[data-panel="ph"]')
45 49 ->element_exists('section#probe-do[data-panel="do"]')
46 50 ->element_exists('section#probe-orp[data-panel="orp"]')
roles/dashboard/t/05-status-page.t
index 1ae7b71b..36f8f4ed 100644..100644
@@ -14,6 +14,8 @@
14 14 $t->get_ok('/status')
15 15 ->status_is(200)
16 16 ->text_is('h1', 'DAQ Status')
17 Added: ->element_exists('div.card.status-page-card > div.card-heading')
18 Added: ->element_exists('div.card.status-page-card > div.card-body')
17 19 ->element_exists('a.header-status-link[href="/status"][data-header-status]')
18 20 ->element_exists('table.status-table')
19 21 ->element_exists('tr[data-status-page-item="hub"]')
roles/dashboard/t/06-downloads.t
index 00000000..d9a768c4 000000..100644
@@ -0,0 +1,51 @@
1 Added: # -*- mode: cperl; -*-
2 Added:
3 Added: use Mojo::Base -strict;
4 Added:
5 Added: use Test2::V0;
6 Added:
7 Added: use FindBin;
8 Added: use lib "${FindBin::Bin}/../lib/";
9 Added: use lib "${FindBin::Bin}/lib/";
10 Added: use Dashboard::Test qw(test_app test_empty_app);
11 Added:
12 Added: my $t = test_app();
13 Added:
14 Added: $t->get_ok('/downloads')
15 Added: ->status_is(200)
16 Added: ->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
17 Added: ->text_is( 'div.nav-links a[href="/downloads"]', 'Downloads' )
18 Added: ->element_exists('a.header-status-link[href="/status"][data-header-status]')
19 Added: ->element_exists('a.header-status-link span.header-status-led')
20 Added: ->element_exists('div.card.downloads-card > div.card-heading')
21 Added: ->element_exists('div.card.downloads-card > div.card-body')
22 Added: ->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
23 Added: ->element_exists('input[name="from"][type="date"][required]')
24 Added: ->element_exists('input[name="to"][type="date"][required]')
25 Added: ->text_is('button.secondary-button[data-date-shortcut="#download-from"]', 'Oldest')
26 Added: ->text_is('button.secondary-button[data-date-shortcut="#download-to"]', 'Today')
27 Added: ->text_is('button.downloads-button[type="submit"]', 'Download CSV');
28 Added:
29 Added: $t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
30 Added: ->status_is(200)
31 Added: ->header_like( 'Content-Type' => qr{text/csv} )
32 Added: ->header_like( 'Content-Disposition' => qr{attachment} )
33 Added: ->content_like(qr/^timestamp,received_at,node,probe,value,unit/m)
34 Added: ->content_like(qr/2026-07-05T12:00:00Z/)
35 Added: ->content_like(qr/2026-07-05T12:00:15Z/);
36 Added:
37 Added: $t->get_ok('/downloads/readings.csv?from=2026-07-06&to=2026-07-05')
38 Added: ->status_is(400)
39 Added: ->content_like(qr/from date must not be after the to date/);
40 Added:
41 Added: my $empty_t = test_empty_app();
42 Added:
43 Added: $empty_t->get_ok('/downloads')
44 Added: ->status_is(200)
45 Added: ->element_exists('button.secondary-button[data-date-shortcut="#download-from"]');
46 Added:
47 Added: $empty_t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
48 Added: ->status_is(200)
49 Added: ->content_is("timestamp,received_at,node,probe,value,unit\n");
50 Added:
51 Added: done_testing;
roles/dashboard/t/lib/Dashboard/Test.pm
index 736f47c7..b44e036f 100644..100644
@@ -9,7 +9,7 @@
9 9
10 10 use FAPG::DAQ::Dashboard;
11 11
12 Removed: our @EXPORT_OK = qw(test_app);
12 Added: our @EXPORT_OK = qw(test_app test_empty_app);
13 13
14 14 sub test_app {
15 15 $ENV{FAPG_DAQ_DB} = ':memory:';
@@ -23,6 +23,12 @@
23 23 seed_statuses( $t, $fresh_status_at, $stale_status_at );
24 24
25 25 return $t;
26 Added: }
27 Added:
28 Added: sub test_empty_app {
29 Added: $ENV{FAPG_DAQ_DB} = ':memory:';
30 Added:
31 Added: return Test::Mojo->new('FAPG::DAQ::Dashboard');
26 32 }
27 33
28 34 sub create_schema {
roles/dashboard/templates/dashboard/downloads.html.ep
index 00000000..0c778a39 000000..100644
@@ -0,0 +1,38 @@
1 Added: <!-- -*- mode: web; -*- -->
2 Added:
3 Added: % layout 'default';
4 Added: % title 'FAPG DAQ Downloads';
5 Added:
6 Added: <div class="card downloads-card">
7 Added: <div class="card-heading">
8 Added: <div>
9 Added: <h1>Downloads</h1>
10 Added: <p>Download recorded DAQ readings as a CSV file.</p>
11 Added: </div>
12 Added: </div>
13 Added:
14 Added: <div class="card-body">
15 Added: <form class="downloads-form" action="/downloads/readings.csv" method="get">
16 Added: <div class="download-date-fields">
17 Added: <div class="download-date-field">
18 Added: <label for="download-from">From</label>
19 Added: <div class="date-input-control">
20 Added: <input id="download-from" name="from" type="date" value="<%= $from %>" required>
21 Added: <button class="secondary-button" type="button" data-date-shortcut="#download-from" data-date-value="<%= $oldest %>">Oldest</button>
22 Added: </div>
23 Added: </div>
24 Added: <div class="download-date-field">
25 Added: <label for="download-to">To</label>
26 Added: <div class="date-input-control">
27 Added: <input id="download-to" name="to" type="date" value="<%= $to %>" required>
28 Added: <button class="secondary-button" type="button" data-date-shortcut="#download-to" data-date-value="<%= $to %>">Today</button>
29 Added: </div>
30 Added: </div>
31 Added: </div>
32 Added: <button class="downloads-button" type="submit">Download CSV</button>
33 Added: </form>
34 Added: </div>
35 Added: </div>
36 Added:
37 Added: <script src="/js/dashboard.js"></script>
38 Added: <script src="/js/downloads.js"></script>
roles/dashboard/templates/dashboard/graph.html.ep
index 8906d0dd..2beb4a47 100644..100644
@@ -3,31 +3,28 @@
3 3 % layout 'default';
4 4 % title "FAPG DAQ $probe->{label}";
5 5
6 Removed: <section class="dashboard-card">
7 Removed: <section
8 Removed: id="probe-<%= $probe->{key} %>"
9 Removed: class="tab-panel is-active"
10 Removed: data-panel="<%= $probe->{key} %>">
11 Removed:
12 Removed: <div class="panel-heading">
13 Removed: <h1><%= $probe->{label} %></h1>
14 Removed: <div class="panel-tools">
15 Removed: <p>
16 Removed: Unit:
17 Removed: <strong><%= $probe->{unit} %></strong>
18 Removed: </p>
19 Removed: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
20 Removed: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
21 Removed: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
22 Removed: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
23 Removed: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
24 Removed: </div>
25 Removed: <label class="smoothing-control">
26 Removed: <input type="checkbox" data-smoothing-toggle checked>
27 Removed: <span>Smoothed</span>
28 Removed: </label>
6 Added: <div class="card">
7 Added: <div class="card-heading">
8 Added: <h1><%= $probe->{label} %></h1>
9 Added: <div class="panel-tools">
10 Added: <p>
11 Added: Unit:
12 Added: <strong><%= $probe->{unit} %></strong>
13 Added: </p>
14 Added: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
15 Added: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
16 Added: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
17 Added: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
18 Added: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
29 19 </div>
20 Added: <label class="smoothing-control">
21 Added: <input type="checkbox" data-smoothing-toggle checked>
22 Added: <span>Smoothed</span>
23 Added: </label>
30 24 </div>
25 Added: </div>
26 Added: <div class="card-body">
27 Added: <section id="probe-<%= $probe->{key} %>" class="tab-panel is-active" data-panel="<%= $probe->{key} %>">
31 28
32 29 <div class="chart-wrap">
33 30 <canvas id="chart-<%= $probe->{key} %>"></canvas>
@@ -58,8 +55,9 @@
58 55 </table>
59 56 </div>
60 57 </section>
61 Removed: </section>
62 Removed: </section>
58 Added: </section>
59 Added: </div>
60 Added: </div>
63 61
64 62 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
65 63 <script src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/probes.html.ep
index 424739fd..f9e3cf50 100644..100644
@@ -4,31 +4,28 @@
4 4 % title 'FAPG DAQ Probes';
5 5
6 6 % for my $probe ($probes->@*) {
7 Removed: <section class="dashboard-card">
8 Removed: <section
9 Removed: id="probe-<%= $probe->{key} %>"
10 Removed: class="tab-panel is-active"
11 Removed: data-panel="<%= $probe->{key} %>">
12 Removed:
13 Removed: <div class="panel-heading">
14 Removed: <h1><%= $probe->{label} %></h1>
15 Removed: <div class="panel-tools">
16 Removed: <p>
17 Removed: Unit:
18 Removed: <strong><%= $probe->{unit} %></strong>
19 Removed: </p>
20 Removed: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
21 Removed: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
22 Removed: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
23 Removed: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
24 Removed: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
25 Removed: </div>
26 Removed: <label class="smoothing-control">
27 Removed: <input type="checkbox" data-smoothing-toggle checked>
28 Removed: <span>Smoothed</span>
29 Removed: </label>
7 Added: <div class="card">
8 Added: <div class="card-heading">
9 Added: <h1><%= $probe->{label} %></h1>
10 Added: <div class="panel-tools">
11 Added: <p>
12 Added: Unit:
13 Added: <strong><%= $probe->{unit} %></strong>
14 Added: </p>
15 Added: <div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
16 Added: <button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
17 Added: <button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
18 Added: <button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
19 Added: <button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
30 20 </div>
21 Added: <label class="smoothing-control">
22 Added: <input type="checkbox" data-smoothing-toggle checked>
23 Added: <span>Smoothed</span>
24 Added: </label>
31 25 </div>
26 Added: </div>
27 Added: <div class="card-body">
28 Added: <section id="probe-<%= $probe->{key} %>" class="tab-panel is-active" data-panel="<%= $probe->{key} %>">
32 29
33 30 <div class="chart-wrap">
34 31 <canvas id="chart-<%= $probe->{key} %>"></canvas>
@@ -59,8 +56,9 @@
59 56 </table>
60 57 </div>
61 58 </section>
62 Removed: </section>
63 Removed: </section>
59 Added: </section>
60 Added: </div>
61 Added: </div>
64 62 % }
65 63
66 64 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
roles/dashboard/templates/dashboard/status.html.ep
index dea89278..a8edf2f8 100644..100644
@@ -3,16 +3,17 @@
3 3 % layout 'default';
4 4 % title 'FAPG DAQ Status';
5 5
6 Removed: <section class="dashboard-card status-page-card">
7 Removed: <div class="panel-heading">
6 Added: <div class="card status-page-card">
7 Added: <div class="card-heading">
8 8 <div>
9 9 <h1>DAQ Status</h1>
10 10 <p>Current connection status for the hub and sensor probes.</p>
11 11 </div>
12 12 </div>
13 13
14 Removed: <div class="message-table-wrap">
15 Removed: <table class="message-table status-table">
14 Added: <div class="card-body">
15 Added: <div class="message-table-wrap">
16 Added: <table class="message-table status-table">
16 17 <thead>
17 18 <tr>
18 19 <th scope="col">Probe</th>
@@ -33,8 +34,9 @@
33 34 </tr>
34 35 % }
35 36 </tbody>
36 Removed: </table>
37 Added: </table>
38 Added: </div>
37 39 </div>
38 Removed: </section>
40 Added: </div>
39 41
40 42 <script src="/js/dashboard.js"></script>
roles/dashboard/templates/layouts/default.html.ep
index 1dc86700..8f1fbe60 100644..100644
@@ -22,6 +22,7 @@
22 22 </label>
23 23 <div class="nav-links">
24 24 <a href="/probes">Probes</a>
25 Added: <a href="/downloads">Downloads</a>
25 26 </div>
26 27 <a
27 28 class="header-status-link is-unknown"