[Perl] DAQ system for the FAPG.
feat add raw reading downloads
- add probe-card downloads synchronized with the selected graph timeframe - add probe filtering and semantic preset or custom date ranges to the downloads page - share timeframe window calculations and cover download behavior with dashboard tests
Changed files
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Download.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Timeframe.pm
- roles/dashboard/public/css/app.css
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/public/js/downloads.js
- roles/dashboard/t/01-graph-page.t
- roles/dashboard/t/04-readings-series.t
- roles/dashboard/t/06-downloads.t
- roles/dashboard/templates/dashboard/downloads.html.ep
- roles/dashboard/templates/dashboard/probe-card.html.ep
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Dashboard.pm
@@ -27,28 +27,34 @@
27
27
}
28
28
29
29
sub downloads ($self) {
30
Removed:
my $from = strftime( '%Y-%m-%d', gmtime( time - ( 7 * 24 * 60 * 60 ) ) );
31
Removed:
my $to = strftime( '%Y-%m-%d', gmtime(time) );
30
Added:
my $from = strftime( '%Y-%m-%d', gmtime( time - ( 6 * 24 * 60 * 60 ) ) );
31
Added:
my $month_from
32
Added:
= strftime( '%Y-%m-%d', gmtime( time - ( 29 * 24 * 60 * 60 ) ) );
33
Added:
my $to = strftime( '%Y-%m-%d', gmtime(time) );
32
34
my $database = $self->sqlite->db;
33
Removed:
my $table = $database->query(
35
Added:
my $table
36
Added:
= $database->query(
34
37
q{SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'readings'}
35
Removed:
)->hash;
38
Added:
)->hash;
36
39
my $oldest;
37
40
38
41
if ($table) {
39
Removed:
$oldest = $database->query(
42
Added:
$oldest
43
Added:
= $database->query(
40
44
'SELECT MIN(COALESCE(received_at, timestamp)) AS timestamp FROM readings'
41
Removed:
)->hash->{timestamp};
45
Added:
)->hash->{timestamp};
42
46
}
43
47
44
48
$oldest = $from if !defined $oldest;
45
49
$oldest =~ s/T.*\z//;
46
50
47
51
$self->render(
48
Removed:
template => 'dashboard/downloads',
49
Removed:
from => $from,
50
Removed:
to => $to,
51
Removed:
oldest => $oldest,
52
Added:
template => 'dashboard/downloads',
53
Added:
from => $from,
54
Added:
month_from => $month_from,
55
Added:
to => $to,
56
Added:
oldest => $oldest,
57
Added:
probes => $self->probes,
52
58
);
53
59
}
54
60
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Download.pm
@@ -3,14 +3,19 @@
3
3
package FAPG::DAQ::Dashboard::Controller::Download;
4
4
use Mojo::Base 'Mojolicious::Controller', -signatures;
5
5
6
Removed:
use POSIX qw(strftime);
7
Removed:
use Time::Local qw(timegm);
6
Added:
use FAPG::DAQ::Dashboard::Timeframe qw(max_series_range series_window);
7
Added:
use POSIX qw(strftime);
8
Added:
use Time::Local qw(timegm);
8
9
9
10
sub readings ($self) {
10
Removed:
my $from = $self->param('from') // '';
11
Removed:
my $to = $self->param('to') // '';
11
Added:
return timeframe_readings($self) if defined $self->param('timeframe');
12
Added:
13
Added:
my $from = $self->param('from') // '';
14
Added:
my $to = $self->param('to') // '';
15
Added:
my $probe = $self->param('probe') // '';
12
16
my $from_epoch = date_epoch($from);
13
17
my $to_epoch = date_epoch($to);
18
Added:
my %known = map { $_->{key} => 1 } $self->probes->@*;
14
19
15
20
return $self->render(
16
21
status => 400,
@@ -22,38 +27,103 @@
22
27
text => 'The from date must not be after the to date.',
23
28
) if $from_epoch > $to_epoch;
24
29
30
Added:
return $self->render(
31
Added:
status => 400,
32
Added:
text => 'The probe must be blank or a known probe type.',
33
Added:
) if $probe ne '' && !$known{$probe};
34
Added:
35
Added:
my $probe_filter = $probe eq '' ? '' : ' AND probe = ?';
36
Added:
my @probe_bind = $probe eq '' ? () : ($probe);
37
Added:
my $rows = raw_readings(
38
Added:
$self,
39
Added:
'COALESCE(received_at, timestamp) >= ? '
40
Added:
. 'AND COALESCE(received_at, timestamp) < ?'
41
Added:
. $probe_filter,
42
Added:
'COALESCE(received_at, timestamp)',
43
Added:
utc_timestamp($from_epoch),
44
Added:
utc_timestamp( $to_epoch + ( 24 * 60 * 60 ) ),
45
Added:
@probe_bind,
46
Added:
);
47
Added:
my $filename
48
Added:
= $probe eq ''
49
Added:
? "fapg-daq-readings-$from-to-$to.csv"
50
Added:
: "fapg-daq-readings-$probe-$from-to-$to.csv";
51
Added:
52
Added:
render_csv( $self, $rows, $filename );
53
Added:
}
54
Added:
55
Added:
sub timeframe_readings ($self) {
56
Added:
my $probe = $self->param('probe') // '';
57
Added:
my $timeframe = $self->param('timeframe') // '';
58
Added:
my $range = $self->param('range') // 1;
59
Added:
my %known = map { $_->{key} => 1 } $self->probes->@*;
60
Added:
61
Added:
return $self->render(
62
Added:
status => 400,
63
Added:
text => 'A known probe is required for timeframe downloads.',
64
Added:
) unless $known{$probe};
65
Added:
66
Added:
return $self->render(
67
Added:
status => 400,
68
Added:
text => 'Range must be an integer from 1 to ' . max_series_range(),
69
Added:
)
70
Added:
if $range !~ /\A\d+\z/
71
Added:
|| $range < 1
72
Added:
|| $range > max_series_range();
73
Added:
74
Added:
my $window = series_window( $timeframe, $range );
75
Added:
76
Added:
return $self->render(
77
Added:
status => 400,
78
Added:
text => "Unknown timeframe: $timeframe",
79
Added:
) unless defined $window;
80
Added:
81
Added:
my $rows = raw_readings(
82
Added:
$self,
83
Added:
'probe = ? AND COALESCE(timestamp, received_at) >= ? '
84
Added:
. 'AND COALESCE(timestamp, received_at) < ?',
85
Added:
'COALESCE(timestamp, received_at)',
86
Added:
$probe,
87
Added:
$window->{start_iso},
88
Added:
$window->{end_iso},
89
Added:
);
90
Added:
my $filename = "fapg-daq-$probe-$timeframe-$range.csv";
91
Added:
92
Added:
render_csv( $self, $rows, $filename );
93
Added:
}
94
Added:
95
Added:
sub raw_readings ( $self, $where, $order_by, @bind ) {
25
96
my $database = $self->sqlite->db;
26
Removed:
my $table = $database->query(
97
Added:
my $table
98
Added:
= $database->query(
27
99
q{SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'readings'}
28
Removed:
)->hash;
29
Removed:
my $rows = [];
100
Added:
)->hash;
30
101
31
Removed:
if ($table) {
32
Removed:
$rows = $database->query(
33
Removed:
q{
102
Added:
return [] unless $table;
103
Added:
104
Added:
return $database->query(
105
Added:
qq{
34
106
SELECT timestamp, received_at, node, probe, value, unit
35
107
FROM readings
36
Removed:
WHERE COALESCE(received_at, timestamp) >= ?
37
Removed:
AND COALESCE(received_at, timestamp) < ?
38
Removed:
ORDER BY COALESCE(received_at, timestamp), id
108
Added:
WHERE $where
109
Added:
ORDER BY $order_by, id
39
110
},
40
Removed:
utc_timestamp($from_epoch),
41
Removed:
utc_timestamp( $to_epoch + ( 24 * 60 * 60 ) ),
42
Removed:
)->hashes->to_array;
43
Removed:
}
111
Added:
@bind,
112
Added:
)->hashes->to_array;
113
Added:
}
44
114
115
Added:
sub render_csv ( $self, $rows, $filename ) {
45
116
my @lines = ('timestamp,received_at,node,probe,value,unit');
46
117
47
118
for my $row ( $rows->@* ) {
48
Removed:
push @lines, join ',', map { csv_value( $row->{$_} ) }
119
Added:
push @lines, join ',',
120
Added:
map { csv_value( $row->{$_} ) }
49
121
qw(timestamp received_at node probe value unit);
50
122
}
51
123
52
Removed:
my $filename = "fapg-daq-readings-$from-to-$to.csv";
53
Removed:
54
124
$self->res->headers->content_type('text/csv; charset=utf-8');
55
125
$self->res->headers->content_disposition(
56
Removed:
qq{attachment; filename="$filename"} );
126
Added:
qq{attachment; filename="$filename"});
57
127
$self->render( data => join( "\n", @lines ) . "\n" );
58
128
}
59
129
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
@@ -3,34 +3,13 @@
3
3
package FAPG::DAQ::Dashboard::Controller::Reading;
4
4
use Mojo::Base 'Mojolicious::Controller', -signatures;
5
5
6
Removed:
use POSIX qw(strftime);
6
Added:
use FAPG::DAQ::Dashboard::Timeframe qw(max_series_range series_window);
7
Added:
use POSIX qw(strftime);
7
8
8
9
my $STATUS_REACHABLE_SECONDS = 120;
9
Removed:
my $MAX_SERIES_RANGE = 24;
10
Added:
my $MAX_SERIES_RANGE = max_series_range();
10
11
my $SERIES_GAP_SECONDS = 30;
11
12
my $MINUTE_SECONDS = 60;
12
Removed:
my %SERIES_TIMEFRAMES = (
13
Removed:
hour => {
14
Removed:
span_seconds => 60 * 60,
15
Removed:
bucket_stride => 60,
16
Removed:
},
17
Removed:
day => {
18
Removed:
span_seconds => 24 * 60 * 60,
19
Removed:
bucket_stride => 15 * 60,
20
Removed:
},
21
Removed:
week => {
22
Removed:
span_seconds => 7 * 24 * 60 * 60,
23
Removed:
bucket_stride => 60 * 60,
24
Removed:
},
25
Removed:
month => {
26
Removed:
span_seconds => 30 * 24 * 60 * 60,
27
Removed:
bucket_stride => 6 * 60 * 60,
28
Removed:
},
29
Removed:
year => {
30
Removed:
span_seconds => 365 * 24 * 60 * 60,
31
Removed:
bucket_stride => 24 * 60 * 60,
32
Removed:
},
33
Removed:
);
34
13
35
14
sub list ($self) {
36
15
my $probe = $self->param('probe') // '';
@@ -266,24 +245,6 @@
266
245
sub reachable_since {
267
246
return strftime( '%Y-%m-%dT%H:%M:%SZ',
268
247
gmtime( time - $STATUS_REACHABLE_SECONDS ) );
269
Removed:
}
270
Removed:
271
Removed:
sub series_window ( $timeframe, $range ) {
272
Removed:
my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
273
Removed:
my $bucket_stride = $config->{bucket_stride} * $range;
274
Removed:
my $span_seconds = $config->{span_seconds} * $range;
275
Removed:
my $end_epoch
276
Removed:
= int( time / $bucket_stride ) * $bucket_stride + $bucket_stride;
277
Removed:
my $start_epoch = $end_epoch - $span_seconds;
278
Removed:
279
Removed:
return {
280
Removed:
bucket_count => int( $span_seconds / $bucket_stride ),
281
Removed:
bucket_stride => $bucket_stride,
282
Removed:
start_epoch => $start_epoch,
283
Removed:
end_epoch => $end_epoch,
284
Removed:
start_iso => utc_timestamp($start_epoch),
285
Removed:
end_iso => utc_timestamp($end_epoch),
286
Removed:
};
287
248
}
288
249
289
250
sub series_points ($window) {
roles/dashboard/lib/FAPG/DAQ/Dashboard/Timeframe.pm
@@ -0,0 +1,63 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
package FAPG::DAQ::Dashboard::Timeframe;
4
Added:
use Mojo::Base -strict;
5
Added:
6
Added:
use Exporter qw(import);
7
Added:
use POSIX qw(strftime);
8
Added:
9
Added:
our @EXPORT_OK = qw(max_series_range series_window);
10
Added:
11
Added:
my $MAX_SERIES_RANGE = 24;
12
Added:
my %SERIES_TIMEFRAMES = (
13
Added:
hour => {
14
Added:
span_seconds => 60 * 60,
15
Added:
bucket_stride => 60,
16
Added:
},
17
Added:
day => {
18
Added:
span_seconds => 24 * 60 * 60,
19
Added:
bucket_stride => 15 * 60,
20
Added:
},
21
Added:
week => {
22
Added:
span_seconds => 7 * 24 * 60 * 60,
23
Added:
bucket_stride => 60 * 60,
24
Added:
},
25
Added:
month => {
26
Added:
span_seconds => 30 * 24 * 60 * 60,
27
Added:
bucket_stride => 6 * 60 * 60,
28
Added:
},
29
Added:
year => {
30
Added:
span_seconds => 365 * 24 * 60 * 60,
31
Added:
bucket_stride => 24 * 60 * 60,
32
Added:
},
33
Added:
);
34
Added:
35
Added:
sub max_series_range {
36
Added:
return $MAX_SERIES_RANGE;
37
Added:
}
38
Added:
39
Added:
sub series_window {
40
Added:
my ( $timeframe, $range ) = @_;
41
Added:
my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
42
Added:
my $bucket_stride = $config->{bucket_stride} * $range;
43
Added:
my $span_seconds = $config->{span_seconds} * $range;
44
Added:
my $end_epoch
45
Added:
= int( time / $bucket_stride ) * $bucket_stride + $bucket_stride;
46
Added:
my $start_epoch = $end_epoch - $span_seconds;
47
Added:
48
Added:
return {
49
Added:
bucket_count => int( $span_seconds / $bucket_stride ),
50
Added:
bucket_stride => $bucket_stride,
51
Added:
start_epoch => $start_epoch,
52
Added:
end_epoch => $end_epoch,
53
Added:
start_iso => utc_timestamp($start_epoch),
54
Added:
end_iso => utc_timestamp($end_epoch),
55
Added:
};
56
Added:
}
57
Added:
58
Added:
sub utc_timestamp {
59
Added:
my ($epoch) = @_;
60
Added:
return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
61
Added:
}
62
Added:
63
Added:
1;
roles/dashboard/public/css/app.css
@@ -122,21 +122,100 @@
122
122
gap: 1rem;
123
123
}
124
124
125
Added:
.download-date-fields[hidden] {
126
Added:
display: none;
127
Added:
}
128
Added:
125
129
.download-date-field {
126
130
display: grid;
127
131
gap: 0.35rem;
128
132
}
129
133
130
Removed:
.download-date-field label {
134
Added:
.download-range-fieldset {
135
Added:
display: grid;
136
Added:
gap: 0.35rem;
137
Added:
margin: 0;
138
Added:
border: 0;
139
Added:
padding: 0;
140
Added:
}
141
Added:
142
Added:
.download-range-fieldset legend {
143
Added:
margin-bottom: 0.35rem;
144
Added:
padding: 0;
131
145
font-weight: 700;
132
146
}
133
147
134
Removed:
.date-input-control {
135
Removed:
display: flex;
136
Removed:
gap: 0.5rem;
148
Added:
.download-range-control {
149
Added:
display: inline-flex;
150
Added:
align-self: flex-start;
151
Added:
overflow: hidden;
152
Added:
border: 1px solid var(--border);
153
Added:
border-radius: 0.4rem;
154
Added:
background: white;
137
155
}
138
156
139
Removed:
.date-input-control input,
157
Added:
.download-range-control label {
158
Added:
position: relative;
159
Added:
margin: 0;
160
Added:
cursor: pointer;
161
Added:
}
162
Added:
163
Added:
.download-range-control input {
164
Added:
position: absolute;
165
Added:
width: 1px;
166
Added:
height: 1px;
167
Added:
opacity: 0;
168
Added:
}
169
Added:
170
Added:
.download-range-control span {
171
Added:
display: inline-flex;
172
Added:
align-items: center;
173
Added:
justify-content: center;
174
Added:
min-height: 2.75rem;
175
Added:
border: 0;
176
Added:
border-left: 1px solid var(--border);
177
Added:
padding: 0.5rem 0.75rem;
178
Added:
background: white;
179
Added:
color: var(--text);
180
Added:
cursor: pointer;
181
Added:
font: inherit;
182
Added:
font-weight: 700;
183
Added:
}
184
Added:
185
Added:
.download-range-control label:first-child span {
186
Added:
border-left: 0;
187
Added:
}
188
Added:
189
Added:
.download-range-control label:hover span {
190
Added:
background: var(--accent-soft);
191
Added:
}
192
Added:
193
Added:
.download-range-control input:checked + span {
194
Added:
background: var(--accent);
195
Added:
color: white;
196
Added:
}
197
Added:
198
Added:
.download-range-control input:focus-visible + span {
199
Added:
position: relative;
200
Added:
z-index: 1;
201
Added:
outline: 2px solid var(--accent);
202
Added:
outline-offset: -2px;
203
Added:
}
204
Added:
205
Added:
206
Added:
.download-probe-field {
207
Added:
display: grid;
208
Added:
gap: 0.35rem;
209
Added:
align-self: flex-start;
210
Added:
}
211
Added:
212
Added:
.download-date-field label,
213
Added:
.download-probe-field label {
214
Added:
font-weight: 700;
215
Added:
}
216
Added:
217
Added:
.download-date-field input,
218
Added:
.download-probe-field select,
140
219
.downloads-button,
141
220
.secondary-button {
142
221
min-height: 2.75rem;
@@ -146,6 +225,12 @@
146
225
font: inherit;
147
226
}
148
227
228
Added:
.download-probe-field select {
229
Added:
min-width: 14rem;
230
Added:
background: white;
231
Added:
color: var(--text);
232
Added:
}
233
Added:
149
234
.downloads-button {
150
235
align-self: flex-end;
151
236
margin-top: auto;
@@ -161,10 +246,14 @@
161
246
}
162
247
163
248
.secondary-button {
249
Added:
display: inline-flex;
250
Added:
align-items: center;
251
Added:
justify-content: center;
164
252
background: white;
165
253
color: var(--accent);
166
254
font-weight: 700;
167
255
cursor: pointer;
256
Added:
text-decoration: none;
168
257
}
169
258
170
259
.secondary-button:hover {
@@ -687,6 +776,12 @@
687
776
font-size: 0.95rem;
688
777
}
689
778
779
Added:
.probe-card-actions {
780
Added:
display: flex;
781
Added:
justify-content: flex-end;
782
Added:
margin-top: 1rem;
783
Added:
}
784
Added:
690
785
.message-log {
691
786
margin-top: 1.25rem;
692
787
}
@@ -849,6 +944,30 @@
849
944
padding-inline: 0.65rem;
850
945
text-align: center;
851
946
white-space: normal;
947
Added:
}
948
Added:
949
Added:
.download-range-control {
950
Added:
display: grid;
951
Added:
grid-template-columns: repeat(2, minmax(0, 1fr));
952
Added:
width: 100%;
953
Added:
}
954
Added:
955
Added:
.download-range-control span {
956
Added:
width: 100%;
957
Added:
border-top: 1px solid var(--border);
958
Added:
}
959
Added:
960
Added:
.download-range-control label:first-child span,
961
Added:
.download-range-control label:nth-child(2) span {
962
Added:
border-top: 0;
963
Added:
}
964
Added:
965
Added:
.download-range-control label:nth-child(odd) span {
966
Added:
border-left: 0;
967
Added:
}
968
Added:
969
Added:
.download-range-control label:last-child {
970
Added:
grid-column: span 2;
852
971
}
853
972
854
973
.reading-quick-grid {
roles/dashboard/public/js/dashboard.js
@@ -126,6 +126,10 @@
126
126
return Array.from(document.querySelectorAll("[data-smoothing-toggle]"));
127
127
}
128
128
129
Added:
function rawDownloadLinks() {
130
Added:
return Array.from(document.querySelectorAll("[data-raw-download]"));
131
Added:
}
132
Added:
129
133
function labelForRow(row) {
130
134
return formatChartTime(row.timestamp);
131
135
}
@@ -830,8 +834,22 @@
830
834
option.querySelector('[data-timeframe-adjustment="decrease"]').disabled = range <= 1;
831
835
option.querySelector('[data-timeframe-adjustment="increase"]').disabled = range >= MAX_TIMEFRAME_RANGE;
832
836
});
837
Added:
838
Added:
updateRawDownloadLinks();
833
839
}
834
840
841
Added:
function updateRawDownloadLinks() {
842
Added:
rawDownloadLinks().forEach(link => {
843
Added:
const params = new URLSearchParams({
844
Added:
probe: link.dataset.probe,
845
Added:
timeframe: selectedTimeframe(),
846
Added:
range: String(selectedTimeframeRange())
847
Added:
});
848
Added:
849
Added:
link.href = `/downloads/readings.csv?${params}`;
850
Added:
});
851
Added:
}
852
Added:
835
853
function setTimeframe(timeframe, expand = false) {
836
854
const selected = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
837
855
@@ -932,6 +950,8 @@
932
950
});
933
951
934
952
const initialProbe = probeFromLocation();
953
Added:
954
Added:
updateRawDownloadLinks();
935
955
936
956
if (initialProbe) {
937
957
activateTab(initialProbe);
roles/dashboard/public/js/downloads.js
@@ -1,9 +1,27 @@
1
Removed:
document.querySelectorAll('[data-date-shortcut]').forEach(button => {
2
Removed:
button.addEventListener('click', () => {
3
Removed:
const input = document.querySelector(button.dataset.dateShortcut);
1
Added:
const downloadRangeButtons = Array.from(document.querySelectorAll("[data-download-range]"));
2
Added:
const customDateFields = document.querySelector("[data-custom-date-fields]");
3
Added:
const downloadFrom = document.querySelector("#download-from");
4
Added:
const downloadTo = document.querySelector("#download-to");
4
5
5
Removed:
if (input) {
6
Removed:
input.value = button.dataset.dateValue;
6
Added:
downloadRangeButtons.forEach(option => {
7
Added:
option.addEventListener("change", () => {
8
Added:
const custom = option.value === "custom";
9
Added:
10
Added:
if (customDateFields) {
11
Added:
customDateFields.hidden = !custom;
12
Added:
}
13
Added:
14
Added:
if (custom) {
15
Added:
downloadFrom?.focus();
16
Added:
return;
17
Added:
}
18
Added:
19
Added:
if (downloadFrom) {
20
Added:
downloadFrom.value = option.dataset.from;
21
Added:
}
22
Added:
23
Added:
if (downloadTo) {
24
Added:
downloadTo.value = option.dataset.to;
7
25
}
8
26
});
9
27
});
roles/dashboard/t/01-graph-page.t
@@ -14,7 +14,8 @@
14
14
$t->get_ok('/probes/ph')
15
15
->status_is(200)
16
16
->text_is( 'a.page-brand[href="/"]', 'FAPG' )
17
Removed:
->element_exists('a.header-status-link[href="/status"][data-header-status]')
17
Added:
->element_exists(
18
Added:
'a.header-status-link[href="/status"][data-header-status]')
18
19
->element_exists('a.header-status-link span.header-status-led')
19
20
->element_exists('[data-header-status-item="hub"]')
20
21
->element_exists('[data-header-status-item="ph"]')
@@ -27,9 +28,14 @@
27
28
'footer.site-footer a[href="https://www.lafermeaquaponique.com/"]')
28
29
->element_exists('div.card > div.card-heading')
29
30
->element_exists('div.card > div.card-body')
30
Removed:
->text_is('div.card h1 a[href="/probes/ph"]', 'pH')
31
Added:
->text_is( 'div.card h1 a[href="/probes/ph"]', 'pH' )
31
32
->element_exists('section#probe-ph[data-panel="ph"]')
32
33
->element_exists('canvas#chart-ph')
34
Added:
->text_is( 'a.secondary-button[data-raw-download][data-probe="ph"]',
35
Added:
'Download raw data' )
36
Added:
->element_exists(
37
Added:
'a[data-raw-download][href="/downloads/readings.csv?probe=ph&timeframe=day&range=1"]'
38
Added:
)
33
39
->element_exists_not('section#probe-do[data-panel="do"]')
34
40
->content_unlike(qr/Sensor Reading Quick Status/)
35
41
->content_like(qr/Hourly/)
@@ -40,8 +46,10 @@
40
46
->element_exists('div.graph-control > div.timeframe-control')
41
47
->element_exists('[data-timeframe-option="week"]')
42
48
->element_exists('[data-timeframe-option="month"]')
43
Removed:
->element_exists('[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
44
Removed:
->element_exists('[data-timeframe-adjustment="increase"][data-timeframe="week"]')
49
Added:
->element_exists(
50
Added:
'[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
51
Added:
->element_exists(
52
Added:
'[data-timeframe-adjustment="increase"][data-timeframe="week"]')
45
53
->content_like(qr/data-smoothing-toggle checked/)
46
54
->content_like(qr/Latest Readings/)
47
55
->element_exists('section.card.message-log > div.card-heading')
@@ -55,10 +63,10 @@
55
63
->status_is(200)
56
64
->element_exists('div.card > div.card-heading')
57
65
->element_exists('div.card > div.card-body')
58
Removed:
->text_is('h1 a[href="/probes/ph"]', 'pH')
59
Removed:
->text_is('h1 a[href="/probes/do"]', 'Dissolved Oxygen')
60
Removed:
->text_is('h1 a[href="/probes/orp"]', 'ORP')
61
Removed:
->text_is('h1 a[href="/probes/ec"]', 'Electrical Conductivity')
66
Added:
->text_is( 'h1 a[href="/probes/ph"]', 'pH' )
67
Added:
->text_is( 'h1 a[href="/probes/do"]', 'Dissolved Oxygen' )
68
Added:
->text_is( 'h1 a[href="/probes/orp"]', 'ORP' )
69
Added:
->text_is( 'h1 a[href="/probes/ec"]', 'Electrical Conductivity' )
62
70
->element_exists('section#probe-ph[data-panel="ph"]')
63
71
->element_exists('section#probe-do[data-panel="do"]')
64
72
->element_exists('section#probe-orp[data-panel="orp"]')
@@ -67,12 +75,18 @@
67
75
->element_exists('canvas#chart-do')
68
76
->element_exists('canvas#chart-orp')
69
77
->element_exists('canvas#chart-ec')
78
Added:
->element_exists('a[data-raw-download][data-probe="ph"]')
79
Added:
->element_exists('a[data-raw-download][data-probe="do"]')
80
Added:
->element_exists('a[data-raw-download][data-probe="orp"]')
81
Added:
->element_exists('a[data-raw-download][data-probe="ec"]')
70
82
->element_exists('div.probes-grid')
71
83
->element_exists('div.graph-control > div.timeframe-control')
72
84
->element_exists_not('div.card div.timeframe-control')
73
85
->element_exists('[data-timeframe-option="week"]')
74
Removed:
->element_exists('[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
75
Removed:
->element_exists('[data-timeframe-adjustment="increase"][data-timeframe="week"]')
86
Added:
->element_exists(
87
Added:
'[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
88
Added:
->element_exists(
89
Added:
'[data-timeframe-adjustment="increase"][data-timeframe="week"]')
76
90
->element_exists_not('section.message-log')
77
91
->content_unlike(qr/Reading Messages/);
78
92
roles/dashboard/t/04-readings-series.t
@@ -19,40 +19,33 @@
19
19
->status_is(200)
20
20
->json_is( '/probe' => 'ec' )
21
21
->json_is( '/timeframe' => 'hour' )
22
Removed:
->json_is( '/readings/58/value' => 1050 )
23
Removed:
->json_is( '/readings/58/lower' => 1000 )
24
Removed:
->json_is( '/readings/58/upper' => 1100 )
25
Removed:
->json_is( '/readings/58/count' => 2 )
26
22
->json_is( '/band/lower_percentile' => 10 )
27
23
->json_is( '/band/upper_percentile' => 90 )
28
24
->json_is( '/gap_seconds' => 30 )
29
25
->json_hasnt('/readings/60');
26
Added:
assert_series_reading( $t, 1050, 1000, 1100, 2 );
30
27
31
28
$t->get_ok('/api/readings/ec/series?timeframe=day')
32
29
->status_is(200)
33
Removed:
->json_is( '/readings/95/value' => 1050 )
34
Removed:
->json_is( '/readings/95/count' => 2 )
35
30
->json_hasnt('/readings/96');
31
Added:
assert_series_reading( $t, 1050, 1000, 1100, 2 );
36
32
37
33
$t->get_ok('/api/readings/ec/series?timeframe=month')
38
34
->status_is(200)
39
Removed:
->json_is( '/timeframe' => 'month' )
40
Removed:
->json_is( '/readings/119/value' => 1050 )
41
Removed:
->json_is( '/readings/119/count' => 2 )
35
Added:
->json_is( '/timeframe' => 'month' )
42
36
->json_hasnt('/readings/120');
37
Added:
assert_series_reading( $t, 1050, 1000, 1100, 2 );
43
38
44
39
$t->get_ok('/api/readings/ec/series?timeframe=year')
45
40
->status_is(200)
46
Removed:
->json_is( '/readings/364/value' => 1050 )
47
Removed:
->json_is( '/readings/364/count' => 2 )
48
41
->json_hasnt('/readings/365');
42
Added:
assert_series_reading( $t, 1050, 1000, 1100, 2 );
49
43
50
44
$t->get_ok('/api/readings/ec/series?timeframe=week&range=2')
51
45
->status_is(200)
52
Removed:
->json_is( '/range' => 2 )
53
Removed:
->json_is( '/readings/167/value' => 1050 )
54
Removed:
->json_is( '/readings/167/count' => 2 )
46
Added:
->json_is( '/range' => 2 )
55
47
->json_hasnt('/readings/168');
48
Added:
assert_series_reading( $t, 1050, 1000, 1100, 2 );
56
49
57
50
$t->get_ok('/api/readings/orp/series?timeframe=hour&smooth=0')
58
51
->status_is(200)
@@ -84,9 +77,26 @@
84
77
85
78
done_testing;
86
79
80
Added:
sub assert_series_reading {
81
Added:
my ( $test, $value, $lower, $upper, $count ) = @_;
82
Added:
my @populated
83
Added:
= grep { defined $_->{value} } $test->tx->res->json->{readings}->@*;
84
Added:
85
Added:
is( scalar @populated, 1, 'one series bucket is populated' );
86
Added:
is( $populated[0]{value}, $value, 'series bucket has expected value' );
87
Added:
is( $populated[0]{lower},
88
Added:
$lower, 'series bucket has expected lower band' );
89
Added:
is( $populated[0]{upper},
90
Added:
$upper, 'series bucket has expected upper band' );
91
Added:
is( $populated[0]{count},
92
Added:
$count, 'series bucket has expected sample count' );
93
Added:
94
Added:
return;
95
Added:
}
96
Added:
87
97
sub seed_series_readings {
88
98
my ($t) = @_;
89
Removed:
my $bucket = minute_floor(time);
99
Added:
my $sample = time - 5;
90
100
my $db = $t->app->sqlite->db;
91
101
92
102
$db->query(
@@ -94,8 +104,8 @@
94
104
INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
95
105
VALUES (?, ?, ?, ?, ?, ?)
96
106
},
97
Removed:
utc_timestamp( $bucket - 50 ),
98
Removed:
utc_timestamp( $bucket - 50 ),
107
Added:
utc_timestamp( $sample - 1 ),
108
Added:
utc_timestamp( $sample - 1 ),
99
109
'fapg-daq-zero-ec-01',
100
110
'ec',
101
111
1000,
@@ -107,8 +117,8 @@
107
117
INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
108
118
VALUES (?, ?, ?, ?, ?, ?)
109
119
},
110
Removed:
utc_timestamp( $bucket - 40 ),
111
Removed:
utc_timestamp( $bucket - 40 ),
120
Added:
utc_timestamp($sample),
121
Added:
utc_timestamp($sample),
112
122
'fapg-daq-zero-ec-01',
113
123
'ec',
114
124
1100,
roles/dashboard/t/06-downloads.t
@@ -10,30 +10,79 @@
10
10
use Dashboard::Test qw(test_app test_empty_app);
11
11
12
12
my $t = test_app();
13
Added:
seed_recent_readings($t);
13
14
14
15
$t->get_ok('/downloads')
15
16
->status_is(200)
16
Removed:
->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
17
Added:
->text_is( 'div.nav-links a[href="/probes"]', 'Probes' )
17
18
->text_is( 'div.nav-links a[href="/downloads"]', 'Downloads' )
18
Removed:
->element_exists('a.header-status-link[href="/status"][data-header-status]')
19
Added:
->element_exists(
20
Added:
'a.header-status-link[href="/status"][data-header-status]')
19
21
->element_exists('a.header-status-link span.header-status-led')
20
22
->element_exists('div.card.downloads-card > div.card-heading')
21
23
->element_exists('div.card.downloads-card > div.card-body')
22
24
->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
25
Added:
->text_is(
26
Added:
'select#download-probe[name="probe"] option[value=""][selected]',
27
Added:
'All probes' )
28
Added:
->text_is( 'select#download-probe option[value="ph"]', 'pH' )
29
Added:
->text_is( 'select#download-probe option[value="do"]',
30
Added:
'Dissolved Oxygen' )
31
Added:
->text_is( 'select#download-probe option[value="orp"]', 'ORP' )
32
Added:
->text_is( 'select#download-probe option[value="ec"]',
33
Added:
'Electrical Conductivity' )
34
Added:
->element_exists('fieldset.download-range-fieldset')
35
Added:
->text_is( 'div.download-range-control label:nth-child(1) span', 'Today' )
36
Added:
->element_exists(
37
Added:
'input[type="radio"][name="date_range"][value="today"][data-download-range]'
38
Added:
)
39
Added:
->text_is( 'div.download-range-control label:nth-child(2) span',
40
Added:
'Past week' )
41
Added:
->element_exists(
42
Added:
'input[type="radio"][name="date_range"][value="week"][data-download-range][checked]'
43
Added:
)
44
Added:
->text_is( 'div.download-range-control label:nth-child(3) span',
45
Added:
'Past month' )
46
Added:
->text_is( 'div.download-range-control label:nth-child(4) span',
47
Added:
'All time' )
48
Added:
->text_is( 'div.download-range-control label:nth-child(5) span',
49
Added:
'Custom' )
50
Added:
->element_exists(
51
Added:
'input[type="radio"][name="date_range"][value="custom"][data-download-range]'
52
Added:
)
53
Added:
->element_exists(
54
Added:
'div.download-date-fields[data-custom-date-fields][hidden]')
23
55
->element_exists('input[name="from"][type="date"][required]')
24
56
->element_exists('input[name="to"][type="date"][required]')
25
Removed:
->text_is('button.secondary-button[data-date-shortcut="#download-from"]', 'Oldest')
26
Removed:
->text_is('button.secondary-button[data-date-shortcut="#download-to"]', 'Today')
27
Removed:
->text_is('button.downloads-button[type="submit"]', 'Download CSV');
57
Added:
->element_exists_not('button[data-date-shortcut]')
58
Added:
->text_is( 'button.downloads-button[type="submit"]', 'Download CSV' );
28
59
29
60
$t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
30
61
->status_is(200)
31
Removed:
->header_like( 'Content-Type' => qr{text/csv} )
62
Added:
->header_like( 'Content-Type' => qr{text/csv} )
32
63
->header_like( 'Content-Disposition' => qr{attachment} )
33
64
->content_like(qr/^timestamp,received_at,node,probe,value,unit/m)
34
65
->content_like(qr/2026-07-05T12:00:00Z/)
35
66
->content_like(qr/2026-07-05T12:00:15Z/);
36
67
68
Added:
$t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05&probe=ph')
69
Added:
->status_is(200)
70
Added:
->content_like(qr/2026-07-05T12:00:00Z/)
71
Added:
->content_unlike(qr/2026-07-05T12:00:15Z/);
72
Added:
73
Added:
$t->get_ok('/downloads/readings.csv?probe=ph&timeframe=hour&range=1')
74
Added:
->status_is(200)
75
Added:
->header_like( 'Content-Disposition' => qr/fapg-daq-ph-hour-1\.csv/ )
76
Added:
->content_like(qr/,"ph",/)
77
Added:
->content_unlike(qr/,"do",/);
78
Added:
79
Added:
$t->get_ok('/downloads/readings.csv?probe=unknown&timeframe=day&range=1')
80
Added:
->status_is(400);
81
Added:
82
Added:
$t->get_ok(
83
Added:
'/downloads/readings.csv?probe=unknown&from=2026-07-05&to=2026-07-05')
84
Added:
->status_is(400);
85
Added:
37
86
$t->get_ok('/downloads/readings.csv?from=2026-07-06&to=2026-07-05')
38
87
->status_is(400)
39
88
->content_like(qr/from date must not be after the to date/);
@@ -42,10 +91,41 @@
42
91
43
92
$empty_t->get_ok('/downloads')
44
93
->status_is(200)
45
Removed:
->element_exists('button.secondary-button[data-date-shortcut="#download-from"]');
94
Added:
->element_exists('select#download-probe option[value=""][selected]')
95
Added:
->element_exists('input[data-download-range][value="custom"]');
46
96
47
97
$empty_t->get_ok('/downloads/readings.csv?from=2026-07-05&to=2026-07-05')
48
98
->status_is(200)
49
99
->content_is("timestamp,received_at,node,probe,value,unit\n");
50
100
51
101
done_testing;
102
Added:
103
Added:
sub seed_recent_readings {
104
Added:
my ($test) = @_;
105
Added:
my $timestamp = utc_timestamp( time - 60 );
106
Added:
107
Added:
for my $reading ( [ 'ph', 7.15, 'pH' ], [ 'do', 8.20, 'mg/L' ], ) {
108
Added:
my ( $probe, $value, $unit ) = $reading->@*;
109
Added:
110
Added:
$test->app->sqlite->db->query(
111
Added:
q{
112
Added:
INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
113
Added:
VALUES (?, ?, ?, ?, ?, ?)
114
Added:
},
115
Added:
$timestamp,
116
Added:
$timestamp,
117
Added:
"fapg-daq-zero-$probe-01",
118
Added:
$probe,
119
Added:
$value,
120
Added:
$unit,
121
Added:
);
122
Added:
}
123
Added:
124
Added:
return;
125
Added:
}
126
Added:
127
Added:
sub utc_timestamp {
128
Added:
my ($epoch) = @_;
129
Added:
require POSIX;
130
Added:
return POSIX::strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
131
Added:
}
roles/dashboard/templates/dashboard/downloads.html.ep
@@ -13,20 +13,33 @@
13
13
14
14
<div class="card-body">
15
15
<form class="downloads-form" action="/downloads/readings.csv" method="get">
16
Removed:
<div class="download-date-fields">
16
Added:
<div class="download-probe-field">
17
Added:
<label for="download-probe">Probe</label>
18
Added:
<select id="download-probe" name="probe">
19
Added:
<option value="" selected>All probes</option>
20
Added:
% for my $probe ($probes->@*) {
21
Added:
<option value="<%= $probe->{key} %>"><%= $probe->{label} %></option>
22
Added:
% }
23
Added:
</select>
24
Added:
</div>
25
Added:
<fieldset class="download-range-fieldset">
26
Added:
<legend>Date range</legend>
27
Added:
<div class="download-range-control">
28
Added:
<label><input type="radio" name="date_range" value="today" data-download-range data-from="<%= $to %>" data-to="<%= $to %>"><span>Today</span></label>
29
Added:
<label><input type="radio" name="date_range" value="week" data-download-range data-from="<%= $from %>" data-to="<%= $to %>" checked><span>Past week</span></label>
30
Added:
<label><input type="radio" name="date_range" value="month" data-download-range data-from="<%= $month_from %>" data-to="<%= $to %>"><span>Past month</span></label>
31
Added:
<label><input type="radio" name="date_range" value="all" data-download-range data-from="<%= $oldest %>" data-to="<%= $to %>"><span>All time</span></label>
32
Added:
<label><input type="radio" name="date_range" value="custom" data-download-range><span>Custom</span></label>
33
Added:
</div>
34
Added:
</fieldset>
35
Added:
<div class="download-date-fields" data-custom-date-fields hidden>
17
36
<div class="download-date-field">
18
37
<label for="download-from">From</label>
19
Removed:
<div class="date-input-control">
20
Removed:
<input id="download-from" name="from" type="date" value="<%= $from %>" required>
21
Removed:
<button class="secondary-button" type="button" data-date-shortcut="#download-from" data-date-value="<%= $oldest %>">Oldest</button>
22
Removed:
</div>
38
Added:
<input id="download-from" name="from" type="date" value="<%= $from %>" required>
23
39
</div>
24
40
<div class="download-date-field">
25
41
<label for="download-to">To</label>
26
Removed:
<div class="date-input-control">
27
Removed:
<input id="download-to" name="to" type="date" value="<%= $to %>" required>
28
Removed:
<button class="secondary-button" type="button" data-date-shortcut="#download-to" data-date-value="<%= $to %>">Today</button>
29
Removed:
</div>
42
Added:
<input id="download-to" name="to" type="date" value="<%= $to %>" required>
30
43
</div>
31
44
</div>
32
45
<button class="downloads-button" type="submit">Download CSV</button>
roles/dashboard/templates/dashboard/probe-card.html.ep
@@ -20,5 +20,13 @@
20
20
Waiting for data…
21
21
</p>
22
22
</section>
23
Added:
24
Added:
<div class="probe-card-actions">
25
Added:
<a
26
Added:
class="secondary-button graph-download-button"
27
Added:
href="/downloads/readings.csv?probe=<%= $probe->{key} %>&timeframe=day&range=1"
28
Added:
data-raw-download
29
Added:
data-probe="<%= $probe->{key} %>">Download raw data</a>
30
Added:
</div>
23
31
</div>
24
32
</div>