[Perl] DAQ system for the FAPG.
Improve probe chart timeframe controls
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
@@ -7,107 +7,115 @@
7
7
use Time::Local qw(timegm);
8
8
9
9
my $STATUS_REACHABLE_SECONDS = 120;
10
Added:
my $MAX_SERIES_RANGE = 24;
10
11
my %SERIES_TIMEFRAMES = (
11
Removed:
hour => {
12
Removed:
bucket_count => 60,
13
Removed:
bucket_sql => '%Y-%m-%dT%H:%M:00Z',
14
Removed:
bucket_stride => 60,
15
Removed:
floor_to => 'minute',
16
Removed:
},
17
Removed:
day => {
18
Removed:
bucket_count => 24,
19
Removed:
bucket_sql => '%Y-%m-%dT%H:00:00Z',
20
Removed:
bucket_stride => 60 * 60,
21
Removed:
floor_to => 'hour',
22
Removed:
},
23
Removed:
week => {
24
Removed:
bucket_count => 7,
25
Removed:
bucket_sql => '%Y-%m-%dT00:00:00Z',
26
Removed:
bucket_stride => 24 * 60 * 60,
27
Removed:
floor_to => 'day',
28
Removed:
},
29
Removed:
year => {
30
Removed:
bucket_count => 12,
31
Removed:
bucket_sql => '%Y-%m-01T00:00:00Z',
32
Removed:
bucket_stride => undef,
33
Removed:
floor_to => 'month',
34
Removed:
},
12
Added:
hour => {
13
Added:
bucket_count => 60,
14
Added:
bucket_sql => '%Y-%m-%dT%H:%M:00Z',
15
Added:
bucket_stride => 60,
16
Added:
floor_to => 'minute',
17
Added:
},
18
Added:
day => {
19
Added:
bucket_count => 24,
20
Added:
bucket_sql => '%Y-%m-%dT%H:00:00Z',
21
Added:
bucket_stride => 60 * 60,
22
Added:
floor_to => 'hour',
23
Added:
},
24
Added:
week => {
25
Added:
bucket_count => 7,
26
Added:
bucket_sql => '%Y-%m-%dT00:00:00Z',
27
Added:
bucket_stride => 24 * 60 * 60,
28
Added:
floor_to => 'day',
29
Added:
},
30
Added:
year => {
31
Added:
bucket_count => 12,
32
Added:
bucket_sql => '%Y-%m-01T00:00:00Z',
33
Added:
bucket_stride => undef,
34
Added:
floor_to => 'month',
35
Added:
},
35
36
);
36
37
37
38
sub list ($self) {
38
Removed:
my $probe = $self->param('probe') // '';
39
Added:
my $probe = $self->param('probe') // '';
39
40
40
Removed:
my %known = map { $_->{key} => 1 } $self->probes->@*;
41
Added:
my %known = map { $_->{key} => 1 } $self->probes->@*;
41
42
42
Removed:
return $self->render(
43
Removed:
status => 404,
44
Removed:
json => { error => "Unknown probe type: $probe", },
45
Removed:
) unless $known{$probe};
43
Added:
return $self->render(
44
Added:
status => 404,
45
Added:
json => { error => "Unknown probe type: $probe", },
46
Added:
) unless $known{$probe};
46
47
47
Removed:
my $limit = $self->param('limit') // 300;
48
Added:
my $limit = $self->param('limit') // 300;
48
49
49
Removed:
$limit = 300 unless $limit =~ /^\d+$/;
50
Removed:
$limit = 2_000 if $limit > 2_000;
50
Added:
$limit = 300 unless $limit =~ /^\d+$/;
51
Added:
$limit = 2_000 if $limit > 2_000;
51
52
52
Removed:
my @where = ('probe = ?');
53
Removed:
my @bind = ($probe);
54
Removed:
my $since = $self->param('since');
53
Added:
my @where = ('probe = ?');
54
Added:
my @bind = ($probe);
55
Added:
my $since = $self->param('since');
55
56
56
Removed:
if ( defined $since
57
Removed:
&& $since =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ )
58
Removed:
{
59
Removed:
push @where, 'COALESCE(received_at, timestamp) >= ?';
60
Removed:
push @bind, $since;
61
Removed:
}
57
Added:
if ( defined $since
58
Added:
&& $since =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ )
59
Added:
{
60
Added:
push @where, 'COALESCE(received_at, timestamp) >= ?';
61
Added:
push @bind, $since;
62
Added:
}
62
63
63
Removed:
my $rows = $self->sqlite->db->query(
64
Removed:
q{
64
Added:
my $rows = $self->sqlite->db->query(
65
Added:
q{
65
66
SELECT timestamp, received_at, node, probe, value, unit
66
67
FROM readings
67
68
WHERE
68
69
}
69
Removed:
. join( "\n AND ", @where ) . q{
70
Added:
. join( "\n AND ", @where ) . q{
70
71
ORDER BY COALESCE(received_at, timestamp) DESC, id DESC
71
72
LIMIT ?
72
73
},
73
Removed:
@bind,
74
Removed:
$limit,
75
Removed:
)->hashes->to_array;
74
Added:
@bind,
75
Added:
$limit,
76
Added:
)->hashes->to_array;
76
77
77
Removed:
$rows = [ reverse $rows->@* ];
78
Added:
$rows = [ reverse $rows->@* ];
78
79
79
Removed:
$self->render(
80
Removed:
json => {
81
Removed:
probe => $probe,
82
Removed:
readings => $rows,
83
Removed:
},
84
Removed:
);
80
Added:
$self->render(
81
Added:
json => {
82
Added:
probe => $probe,
83
Added:
readings => $rows,
84
Added:
},
85
Added:
);
85
86
}
86
87
87
88
sub series ($self) {
88
Removed:
my $probe = $self->param('probe') // '';
89
Added:
my $probe = $self->param('probe') // '';
89
90
90
Removed:
my %known = map { $_->{key} => 1 } $self->probes->@*;
91
Added:
my %known = map { $_->{key} => 1 } $self->probes->@*;
91
92
92
Removed:
return $self->render(
93
Removed:
status => 404,
94
Removed:
json => { error => "Unknown probe type: $probe", },
95
Removed:
) unless $known{$probe};
93
Added:
return $self->render(
94
Added:
status => 404,
95
Added:
json => { error => "Unknown probe type: $probe", },
96
Added:
) unless $known{$probe};
96
97
97
Removed:
my $timeframe = $self->param('timeframe') // 'day';
98
Removed:
my $window = series_window($timeframe);
98
Added:
my $timeframe = $self->param('timeframe') // 'day';
99
Added:
my $range = $self->param('range') // 1;
99
100
100
Removed:
return $self->render(
101
Removed:
status => 400,
102
Removed:
json => { error => "Unknown timeframe: $timeframe", },
103
Removed:
) unless defined $window;
101
Added:
return $self->render(
102
Added:
status => 400,
103
Added:
json => { error => 'Range must be an integer from 1 to 24', },
104
Added:
) if $range !~ /\A\d+\z/ || $range < 1 || $range > $MAX_SERIES_RANGE;
104
105
105
Removed:
my $unit = ( grep { $_->{key} eq $probe } $self->probes->@* )[0]{unit};
106
Removed:
my $points = series_points($window);
107
Removed:
my %index = map { $points->[$_]{timestamp} => $_ } 0 .. $points->$#*;
106
Added:
my $window = series_window( $timeframe, $range );
108
107
109
Removed:
my $rows = $self->sqlite->db->query(
110
Removed:
q{
108
Added:
return $self->render(
109
Added:
status => 400,
110
Added:
json => { error => "Unknown timeframe: $timeframe", },
111
Added:
) unless defined $window;
112
Added:
113
Added:
my $unit = ( grep { $_->{key} eq $probe } $self->probes->@* )[0]{unit};
114
Added:
my $points = series_points($window);
115
Added:
my %index = map { $points->[$_]{timestamp} => $_ } 0 .. $points->$#*;
116
Added:
117
Added:
my $rows = $self->sqlite->db->query(
118
Added:
q{
111
119
SELECT
112
120
strftime(?, COALESCE(received_at, timestamp)) AS bucket,
113
121
AVG(value) AS value,
@@ -119,47 +127,48 @@
119
127
GROUP BY bucket
120
128
ORDER BY bucket
121
129
},
122
Removed:
$window->{bucket_sql},
123
Removed:
$probe,
124
Removed:
$window->{start_iso},
125
Removed:
$window->{end_iso},
126
Removed:
)->hashes->to_array;
130
Added:
$window->{bucket_sql},
131
Added:
$probe,
132
Added:
$window->{start_iso},
133
Added:
$window->{end_iso},
134
Added:
)->hashes->to_array;
127
135
128
Removed:
for my $row ( $rows->@* ) {
129
Removed:
my $point_index = $index{ $row->{bucket} };
130
Removed:
next if !defined $point_index;
136
Added:
for my $row ( $rows->@* ) {
137
Added:
my $point_index = $index{ $row->{bucket} };
138
Added:
next if !defined $point_index;
131
139
132
Removed:
$points->[$point_index] = {
133
Removed:
timestamp => $row->{bucket},
134
Removed:
value => 0 + $row->{value},
135
Removed:
unit => $unit,
136
Removed:
probe => $probe,
137
Removed:
count => 0 + $row->{count},
138
Removed:
};
139
Removed:
}
140
Added:
$points->[$point_index] = {
141
Added:
timestamp => $row->{bucket},
142
Added:
value => 0 + $row->{value},
143
Added:
unit => $unit,
144
Added:
probe => $probe,
145
Added:
count => 0 + $row->{count},
146
Added:
};
147
Added:
}
140
148
141
Removed:
$self->render(
142
Removed:
json => {
143
Removed:
probe => $probe,
144
Removed:
timeframe => $timeframe,
145
Removed:
readings => $points,
146
Removed:
},
147
Removed:
);
149
Added:
$self->render(
150
Added:
json => {
151
Added:
probe => $probe,
152
Added:
timeframe => $timeframe,
153
Added:
range => 0 + $range,
154
Added:
readings => $points,
155
Added:
},
156
Added:
);
148
157
}
149
158
150
159
sub status ($self) {
151
Removed:
my $probe = $self->param('probe') // '';
160
Added:
my $probe = $self->param('probe') // '';
152
161
153
Removed:
my %known = map { $_->{key} => 1 } $self->status_items->@*;
162
Added:
my %known = map { $_->{key} => 1 } $self->status_items->@*;
154
163
155
Removed:
return $self->render(
156
Removed:
status => 404,
157
Removed:
json => { error => "Unknown status item: $probe", },
158
Removed:
) unless $known{$probe};
164
Added:
return $self->render(
165
Added:
status => 404,
166
Added:
json => { error => "Unknown status item: $probe", },
167
Added:
) unless $known{$probe};
159
168
160
Removed:
my $row = eval {
161
Removed:
$self->sqlite->db->query(
162
Removed:
q{
169
Added:
my $row = eval {
170
Added:
$self->sqlite->db->query(
171
Added:
q{
163
172
SELECT timestamp, received_at, node, probe, status, message, error, device
164
173
FROM node_status
165
174
WHERE probe = ?
@@ -167,176 +176,158 @@
167
176
ORDER BY COALESCE(timestamp, received_at) DESC, id DESC
168
177
LIMIT 1
169
178
},
170
Removed:
$probe,
171
Removed:
)->hash;
172
Removed:
};
179
Added:
$probe,
180
Added:
)->hash;
181
Added:
};
173
182
174
Removed:
$row = undef if $@;
175
Removed:
mark_unreachable_if_stale($row) if $row;
183
Added:
$row = undef if $@;
184
Added:
mark_unreachable_if_stale($row) if $row;
176
185
177
Removed:
$self->render(
178
Removed:
json => {
179
Removed:
probe => $probe,
180
Removed:
status => $row,
181
Removed:
},
182
Removed:
);
186
Added:
$self->render(
187
Added:
json => {
188
Added:
probe => $probe,
189
Added:
status => $row,
190
Added:
},
191
Added:
);
183
192
}
184
193
185
194
sub mark_unreachable_if_stale ($row) {
186
Removed:
my $received_at = $row->{received_at} // $row->{timestamp};
187
Removed:
return if !defined $received_at || $received_at ge reachable_since();
195
Added:
my $received_at = $row->{received_at} // $row->{timestamp};
196
Added:
return if !defined $received_at || $received_at ge reachable_since();
188
197
189
Removed:
$row->{status} = 'unreachable';
190
Removed:
$row->{message}
191
Removed:
= "No status received in the last $STATUS_REACHABLE_SECONDS seconds";
198
Added:
$row->{status} = 'unreachable';
199
Added:
$row->{message} = "No status received in the last $STATUS_REACHABLE_SECONDS seconds";
192
200
}
193
201
194
202
sub reachable_since {
195
Removed:
return strftime( '%Y-%m-%dT%H:%M:%SZ',
196
Removed:
gmtime( time - $STATUS_REACHABLE_SECONDS ) );
203
Added:
return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime( time - $STATUS_REACHABLE_SECONDS ) );
197
204
}
198
205
199
Removed:
sub series_window ($timeframe) {
200
Removed:
my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
201
Removed:
my @now = gmtime;
202
Removed:
my ( $sec, $min, $hour, $mday, $mon, $year ) = @now[ 0 .. 5 ];
206
Added:
sub series_window ( $timeframe, $range ) {
207
Added:
my $config = $SERIES_TIMEFRAMES{$timeframe} or return undef;
208
Added:
my @now = gmtime;
209
Added:
my ( $sec, $min, $hour, $mday, $mon, $year ) = @now[ 0 .. 5 ];
203
210
204
Removed:
if ( $config->{floor_to} eq 'minute' ) {
205
Removed:
my $end_epoch = timegm( 0, $min, $hour, $mday, $mon, $year ) + 60;
211
Added:
if ( $config->{floor_to} eq 'minute' ) {
212
Added:
my $end_epoch = timegm( 0, $min, $hour, $mday, $mon, $year ) + 60;
206
213
207
Removed:
return {
208
Removed:
bucket_count => $config->{bucket_count},
209
Removed:
bucket_sql => $config->{bucket_sql},
210
Removed:
bucket_stride => $config->{bucket_stride},
211
Removed:
start_epoch => $end_epoch
212
Removed:
- ( $config->{bucket_count} * $config->{bucket_stride} ),
213
Removed:
end_epoch => $end_epoch,
214
Removed:
start_iso => utc_timestamp(
215
Removed:
$end_epoch
216
Removed:
- ( $config->{bucket_count} * $config->{bucket_stride} )
217
Removed:
),
218
Removed:
end_iso => utc_timestamp($end_epoch),
219
Removed:
};
220
Removed:
}
214
Added:
return {
215
Added:
bucket_count => $config->{bucket_count} * $range,
216
Added:
bucket_sql => $config->{bucket_sql},
217
Added:
bucket_stride => $config->{bucket_stride},
218
Added:
start_epoch => $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} ),
219
Added:
end_epoch => $end_epoch,
220
Added:
start_iso => utc_timestamp(
221
Added:
$end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} )
222
Added:
),
223
Added:
end_iso => utc_timestamp($end_epoch),
224
Added:
};
225
Added:
}
221
226
222
Removed:
if ( $config->{floor_to} eq 'hour' ) {
223
Removed:
my $end_epoch
224
Removed:
= timegm( 0, 0, $hour, $mday, $mon, $year ) + ( 60 * 60 );
227
Added:
if ( $config->{floor_to} eq 'hour' ) {
228
Added:
my $end_epoch = timegm( 0, 0, $hour, $mday, $mon, $year ) + ( 60 * 60 );
225
229
226
Removed:
return {
227
Removed:
bucket_count => $config->{bucket_count},
228
Removed:
bucket_sql => $config->{bucket_sql},
229
Removed:
bucket_stride => $config->{bucket_stride},
230
Removed:
start_epoch => $end_epoch
231
Removed:
- ( $config->{bucket_count} * $config->{bucket_stride} ),
232
Removed:
end_epoch => $end_epoch,
233
Removed:
start_iso => utc_timestamp(
234
Removed:
$end_epoch
235
Removed:
- ( $config->{bucket_count} * $config->{bucket_stride} )
236
Removed:
),
237
Removed:
end_iso => utc_timestamp($end_epoch),
238
Removed:
};
239
Removed:
}
230
Added:
return {
231
Added:
bucket_count => $config->{bucket_count} * $range,
232
Added:
bucket_sql => $config->{bucket_sql},
233
Added:
bucket_stride => $config->{bucket_stride},
234
Added:
start_epoch => $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} ),
235
Added:
end_epoch => $end_epoch,
236
Added:
start_iso => utc_timestamp(
237
Added:
$end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} )
238
Added:
),
239
Added:
end_iso => utc_timestamp($end_epoch),
240
Added:
};
241
Added:
}
240
242
241
Removed:
if ( $config->{floor_to} eq 'day' ) {
242
Removed:
my $end_epoch
243
Removed:
= timegm( 0, 0, 0, $mday, $mon, $year ) + ( 24 * 60 * 60 );
243
Added:
if ( $config->{floor_to} eq 'day' ) {
244
Added:
my $end_epoch = timegm( 0, 0, 0, $mday, $mon, $year ) + ( 24 * 60 * 60 );
244
245
245
Removed:
return {
246
Removed:
bucket_count => $config->{bucket_count},
247
Removed:
bucket_sql => $config->{bucket_sql},
248
Removed:
bucket_stride => $config->{bucket_stride},
249
Removed:
start_epoch => $end_epoch
250
Removed:
- ( $config->{bucket_count} * $config->{bucket_stride} ),
251
Removed:
end_epoch => $end_epoch,
252
Removed:
start_iso => utc_timestamp(
253
Removed:
$end_epoch
254
Removed:
- ( $config->{bucket_count} * $config->{bucket_stride} )
255
Removed:
),
256
Removed:
end_iso => utc_timestamp($end_epoch),
257
Removed:
};
258
Removed:
}
246
Added:
return {
247
Added:
bucket_count => $config->{bucket_count} * $range,
248
Added:
bucket_sql => $config->{bucket_sql},
249
Added:
bucket_stride => $config->{bucket_stride},
250
Added:
start_epoch => $end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} ),
251
Added:
end_epoch => $end_epoch,
252
Added:
start_iso => utc_timestamp(
253
Added:
$end_epoch - ( $config->{bucket_count} * $range * $config->{bucket_stride} )
254
Added:
),
255
Added:
end_iso => utc_timestamp($end_epoch),
256
Added:
};
257
Added:
}
259
258
260
Removed:
if ( $config->{floor_to} eq 'month' ) {
261
Removed:
my ( $start_year, $start_month )
262
Removed:
= normalize_month( $year, $mon - 11 );
263
Removed:
my ( $end_year, $end_month ) = normalize_month( $year, $mon + 1 );
259
Added:
if ( $config->{floor_to} eq 'month' ) {
260
Added:
my ( $start_year, $start_month ) =
261
Added:
normalize_month( $year, $mon - ( $config->{bucket_count} * $range - 1 ) );
262
Added:
my ( $end_year, $end_month ) = normalize_month( $year, $mon + 1 );
264
263
265
Removed:
return {
266
Removed:
bucket_count => $config->{bucket_count},
267
Removed:
bucket_sql => $config->{bucket_sql},
268
Removed:
bucket_stride => $config->{bucket_stride},
269
Removed:
start_epoch => timegm( 0, 0, 0, 1, $start_month, $start_year ),
270
Removed:
end_epoch => timegm( 0, 0, 0, 1, $end_month, $end_year ),
271
Removed:
start_iso => utc_timestamp(
272
Removed:
timegm( 0, 0, 0, 1, $start_month, $start_year )
273
Removed:
),
274
Removed:
end_iso =>
275
Removed:
utc_timestamp( timegm( 0, 0, 0, 1, $end_month, $end_year ) ),
276
Removed:
};
277
Removed:
}
264
Added:
return {
265
Added:
bucket_count => $config->{bucket_count} * $range,
266
Added:
bucket_sql => $config->{bucket_sql},
267
Added:
bucket_stride => $config->{bucket_stride},
268
Added:
start_epoch => timegm( 0, 0, 0, 1, $start_month, $start_year ),
269
Added:
end_epoch => timegm( 0, 0, 0, 1, $end_month, $end_year ),
270
Added:
start_iso => utc_timestamp( timegm( 0, 0, 0, 1, $start_month, $start_year ) ),
271
Added:
end_iso => utc_timestamp( timegm( 0, 0, 0, 1, $end_month, $end_year ) ),
272
Added:
};
273
Added:
}
278
274
279
Removed:
return undef;
275
Added:
return undef;
280
276
}
281
277
282
278
sub series_points ($window) {
283
Removed:
my @points;
279
Added:
my @points;
284
280
285
Removed:
if ( defined $window->{bucket_stride} ) {
286
Removed:
for my $i ( 0 .. $window->{bucket_count} - 1 ) {
287
Removed:
push @points,
288
Removed:
{
289
Removed:
timestamp => utc_timestamp(
290
Removed:
$window->{start_epoch} + ( $i * $window->{bucket_stride} )
291
Removed:
),
292
Removed:
value => undef,
293
Removed:
unit => undef,
294
Removed:
probe => undef,
295
Removed:
count => 0,
296
Removed:
};
297
Removed:
}
298
Removed:
299
Removed:
return \@points;
281
Added:
if ( defined $window->{bucket_stride} ) {
282
Added:
for my $i ( 0 .. $window->{bucket_count} - 1 ) {
283
Added:
push @points,
284
Added:
{
285
Added:
timestamp => utc_timestamp( $window->{start_epoch} + ( $i * $window->{bucket_stride} ) ),
286
Added:
value => undef,
287
Added:
unit => undef,
288
Added:
probe => undef,
289
Added:
count => 0,
290
Added:
};
300
291
}
301
292
302
Removed:
my @cursor = gmtime( $window->{start_epoch} );
303
Removed:
my ( $year, $month ) = ( $cursor[5], $cursor[4] );
293
Added:
return \@points;
294
Added:
}
304
295
305
Removed:
for my $i ( 0 .. $window->{bucket_count} - 1 ) {
306
Removed:
my ( $bucket_year, $bucket_month )
307
Removed:
= normalize_month( $year, $month + $i );
296
Added:
my @cursor = gmtime( $window->{start_epoch} );
297
Added:
my ( $year, $month ) = ( $cursor[5], $cursor[4] );
308
298
309
Removed:
push @points,
310
Removed:
{
311
Removed:
timestamp => utc_timestamp(
312
Removed:
timegm( 0, 0, 0, 1, $bucket_month, $bucket_year )
313
Removed:
),
314
Removed:
value => undef,
315
Removed:
unit => undef,
316
Removed:
probe => undef,
317
Removed:
count => 0,
318
Removed:
};
319
Removed:
}
299
Added:
for my $i ( 0 .. $window->{bucket_count} - 1 ) {
300
Added:
my ( $bucket_year, $bucket_month ) = normalize_month( $year, $month + $i );
320
301
321
Removed:
return \@points;
302
Added:
push @points,
303
Added:
{
304
Added:
timestamp => utc_timestamp( timegm( 0, 0, 0, 1, $bucket_month, $bucket_year ) ),
305
Added:
value => undef,
306
Added:
unit => undef,
307
Added:
probe => undef,
308
Added:
count => 0,
309
Added:
};
310
Added:
}
311
Added:
312
Added:
return \@points;
322
313
}
323
314
324
315
sub normalize_month ( $year, $month ) {
325
Removed:
while ( $month < 0 ) {
326
Removed:
$month += 12;
327
Removed:
$year--;
328
Removed:
}
316
Added:
while ( $month < 0 ) {
317
Added:
$month += 12;
318
Added:
$year--;
319
Added:
}
329
320
330
Removed:
while ( $month > 11 ) {
331
Removed:
$month -= 12;
332
Removed:
$year++;
333
Removed:
}
321
Added:
while ( $month > 11 ) {
322
Added:
$month -= 12;
323
Added:
$year++;
324
Added:
}
334
325
335
Removed:
return ( $year, $month );
326
Added:
return ( $year, $month );
336
327
}
337
328
338
329
sub utc_timestamp ($epoch) {
339
Removed:
return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
330
Added:
return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
340
331
}
341
332
342
333
1;
roles/dashboard/public/css/app.css
@@ -492,6 +492,51 @@
492
492
margin-bottom: 1rem;
493
493
}
494
494
495
Added:
.probes-timeframe-control .timeframe-option {
496
Added:
display: flex;
497
Added:
min-width: 4.75rem;
498
Added:
}
499
Added:
500
Added:
.probes-timeframe-control .timeframe-option + .timeframe-option {
501
Added:
border-left: 1px solid var(--border);
502
Added:
}
503
Added:
504
Added:
.probes-timeframe-control .timeframe-button {
505
Added:
width: 100%;
506
Added:
border-left: 0;
507
Added:
}
508
Added:
509
Added:
.probes-timeframe-control .timeframe-option.is-expanded {
510
Added:
min-width: 12rem;
511
Added:
}
512
Added:
513
Added:
.timeframe-adjustment {
514
Added:
display: none;
515
Added:
min-width: 2.75rem;
516
Added:
border: 0;
517
Added:
padding: 0.45rem 0.75rem;
518
Added:
background: var(--accent-soft);
519
Added:
color: var(--accent);
520
Added:
cursor: pointer;
521
Added:
font: inherit;
522
Added:
font-size: 1.15rem;
523
Added:
font-weight: 700;
524
Added:
}
525
Added:
526
Added:
.timeframe-option.is-expanded .timeframe-adjustment {
527
Added:
display: block;
528
Added:
}
529
Added:
530
Added:
.timeframe-adjustment:hover:not(:disabled) {
531
Added:
background: var(--accent);
532
Added:
color: white;
533
Added:
}
534
Added:
535
Added:
.timeframe-adjustment:disabled {
536
Added:
color: var(--muted);
537
Added:
cursor: not-allowed;
538
Added:
}
539
Added:
495
540
.probes-grid .card + .card {
496
541
margin-top: 0;
497
542
}
@@ -833,6 +878,19 @@
833
878
grid-template-columns: 1fr 1fr;
834
879
width: 100%;
835
880
border-radius: 0.5rem;
881
Added:
}
882
Added:
883
Added:
.probes-timeframe-control {
884
Added:
display: block;
885
Added:
}
886
Added:
887
Added:
.probes-timeframe-control .timeframe-option {
888
Added:
min-width: 0;
889
Added:
}
890
Added:
891
Added:
.probes-timeframe-control .timeframe-option.is-expanded {
892
Added:
grid-column: span 2;
893
Added:
min-width: 0;
836
894
}
837
895
838
896
.timeframe-button {
roles/dashboard/public/js/dashboard.js
@@ -56,8 +56,13 @@
56
56
}
57
57
};
58
58
const SMOOTHING_WINDOW = 7;
59
Added:
const MAX_TIMEFRAME_RANGE = 24;
59
60
let activeTimeframe = DEFAULT_TIMEFRAME;
61
Added:
let expandedTimeframe = null;
60
62
let smoothingEnabled = true;
63
Added:
const timeframeRanges = Object.fromEntries(
64
Added:
Object.keys(TIMEFRAMES).map(timeframe => [timeframe, 1])
65
Added:
);
61
66
62
67
function sensorStatusItems() {
63
68
return Array.from(document.querySelectorAll("[data-sensor-status]"));
@@ -167,6 +172,15 @@
167
172
return TIMEFRAMES[activeTimeframe] ? activeTimeframe : DEFAULT_TIMEFRAME;
168
173
}
169
174
175
Added:
function selectedTimeframeRange() {
176
Added:
return timeframeRanges[selectedTimeframe()] || 1;
177
Added:
}
178
Added:
179
Added:
function timeframeRangeLabel(timeframe, range) {
180
Added:
const unit = timeframe === "hour" ? "hour" : timeframe === "day" ? "day" : timeframe === "week" ? "week" : "year";
181
Added:
return `${range} ${unit}${range === 1 ? "" : "s"}`;
182
Added:
}
183
Added:
170
184
function formatChartTime(timestamp) {
171
185
const date = new Date(timestamp);
172
186
const timeframe = selectedTimeframe();
@@ -180,21 +194,18 @@
180
194
181
195
if (timeframe === "day") {
182
196
return date.toLocaleTimeString([], {
183
Removed:
hour: "2-digit",
184
Removed:
minute: "2-digit"
197
Added:
hour: "2-digit"
185
198
});
186
199
}
187
200
188
201
if (timeframe === "week") {
189
202
return date.toLocaleString([], {
190
Removed:
weekday: "short",
191
Removed:
hour: "2-digit"
203
Added:
weekday: "short"
192
204
});
193
205
}
194
206
195
207
return date.toLocaleDateString([], {
196
Removed:
month: "short",
197
Removed:
year: "numeric"
208
Added:
month: "short"
198
209
});
199
210
}
200
211
@@ -636,7 +647,8 @@
636
647
637
648
try {
638
649
const seriesParams = new URLSearchParams({
639
Removed:
timeframe: selectedTimeframe()
650
Added:
timeframe: selectedTimeframe(),
651
Added:
range: String(selectedTimeframeRange())
640
652
});
641
653
const readingsParams = new URLSearchParams({
642
654
limit: "10"
@@ -751,16 +763,57 @@
751
763
loadProbe(probe);
752
764
}
753
765
754
Removed:
function setTimeframe(timeframe) {
766
Added:
function updateTimeframeControls() {
767
Added:
document.querySelectorAll("[data-timeframe-option]").forEach(option => {
768
Added:
const timeframe = option.dataset.timeframeOption;
769
Added:
const range = timeframeRanges[timeframe] || 1;
770
Added:
const expanded = timeframe === expandedTimeframe;
771
Added:
const button = option.querySelector(".timeframe-button");
772
Added:
773
Added:
option.classList.toggle("is-active", timeframe === activeTimeframe);
774
Added:
option.classList.toggle("is-expanded", expanded);
775
Added:
button.classList.toggle("is-active", timeframe === activeTimeframe);
776
Added:
button.textContent = expanded
777
Added:
? timeframeRangeLabel(timeframe, range)
778
Added:
: button.dataset.timeframeLabel;
779
Added:
780
Added:
option.querySelector('[data-timeframe-adjustment="decrease"]').disabled = range <= 1;
781
Added:
option.querySelector('[data-timeframe-adjustment="increase"]').disabled = range >= MAX_TIMEFRAME_RANGE;
782
Added:
});
783
Added:
}
784
Added:
785
Added:
function setTimeframe(timeframe, expand = false) {
755
786
activeTimeframe = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
787
Added:
expandedTimeframe = expand ? activeTimeframe : null;
756
788
757
789
document.querySelectorAll(".timeframe-button").forEach(button => {
758
790
button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
759
791
});
760
792
793
Added:
updateTimeframeControls();
761
794
displayedProbes().forEach(loadProbe);
762
795
}
763
796
797
Added:
function adjustTimeframeRange(timeframe, adjustment) {
798
Added:
if (!TIMEFRAMES[timeframe]) {
799
Added:
return;
800
Added:
}
801
Added:
802
Added:
activeTimeframe = timeframe;
803
Added:
expandedTimeframe = timeframe;
804
Added:
timeframeRanges[timeframe] = Math.min(
805
Added:
MAX_TIMEFRAME_RANGE,
806
Added:
Math.max(1, (timeframeRanges[timeframe] || 1) + adjustment)
807
Added:
);
808
Added:
809
Added:
document.querySelectorAll(".timeframe-button").forEach(button => {
810
Added:
button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
811
Added:
});
812
Added:
813
Added:
updateTimeframeControls();
814
Added:
displayedProbes().forEach(loadProbe);
815
Added:
}
816
Added:
764
817
function setSmoothing(enabled) {
765
818
smoothingEnabled = enabled;
766
819
@@ -797,7 +850,14 @@
797
850
798
851
document.querySelectorAll(".timeframe-button").forEach(button => {
799
852
button.addEventListener("click", () => {
800
Removed:
setTimeframe(button.dataset.timeframe);
853
Added:
setTimeframe(button.dataset.timeframe, Boolean(button.closest("[data-timeframe-option]")));
854
Added:
});
855
Added:
});
856
Added:
857
Added:
document.querySelectorAll("[data-timeframe-adjustment]").forEach(button => {
858
Added:
button.addEventListener("click", () => {
859
Added:
const adjustment = button.dataset.timeframeAdjustment === "increase" ? 1 : -1;
860
Added:
adjustTimeframeRange(button.dataset.timeframe, adjustment);
801
861
});
802
862
});
803
863
roles/dashboard/t/01-graph-page.t
@@ -56,6 +56,9 @@
56
56
->element_exists('div.probes-grid')
57
57
->element_exists('div.probes-timeframe-control > div.timeframe-control')
58
58
->element_exists_not('div.card div.timeframe-control')
59
Added:
->element_exists('[data-timeframe-option="week"]')
60
Added:
->element_exists('[data-timeframe-adjustment="decrease"][data-timeframe="week"]')
61
Added:
->element_exists('[data-timeframe-adjustment="increase"][data-timeframe="week"]')
59
62
->element_exists_not('section.message-log')
60
63
->content_unlike(qr/Reading Messages/);
61
64
roles/dashboard/t/04-readings-series.t
@@ -33,7 +33,16 @@
33
33
->json_is( '/readings/11/count' => 2 )
34
34
->json_hasnt('/readings/12');
35
35
36
Added:
$t->get_ok('/api/readings/ec/series?timeframe=week&range=2')
37
Added:
->status_is(200)
38
Added:
->json_is( '/range' => 2 )
39
Added:
->json_is( '/readings/13/value' => 1050 )
40
Added:
->json_is( '/readings/13/count' => 2 )
41
Added:
->json_hasnt('/readings/14');
42
Added:
36
43
$t->get_ok('/api/readings/ec/series?timeframe=century')->status_is(400);
44
Added:
$t->get_ok('/api/readings/ec/series?timeframe=week&range=0')->status_is(400);
45
Added:
$t->get_ok('/api/readings/ec/series?timeframe=week&range=25')->status_is(400);
37
46
38
47
done_testing;
39
48
roles/dashboard/templates/dashboard/probes.html.ep
@@ -5,10 +5,27 @@
5
5
6
6
<div class="probes-timeframe-control">
7
7
<div class="timeframe-control" role="group" aria-label="All probe chart timeframes">
8
Removed:
<button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
9
Removed:
<button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
10
Removed:
<button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
11
Removed:
<button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
8
Added:
% for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ year => 'Yearly' ] ) {
9
Added:
<div class="timeframe-option" data-timeframe-option="<%= $timeframe->[0] %>">
10
Added:
<button
11
Added:
class="timeframe-adjustment"
12
Added:
type="button"
13
Added:
data-timeframe-adjustment="decrease"
14
Added:
data-timeframe="<%= $timeframe->[0] %>"
15
Added:
aria-label="Decrease <%= $timeframe->[0] %> range">−</button>
16
Added:
<button
17
Added:
class="timeframe-button<%= $timeframe->[0] eq 'day' ? ' is-active' : '' %>"
18
Added:
type="button"
19
Added:
data-timeframe="<%= $timeframe->[0] %>"
20
Added:
data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
21
Added:
<button
22
Added:
class="timeframe-adjustment"
23
Added:
type="button"
24
Added:
data-timeframe-adjustment="increase"
25
Added:
data-timeframe="<%= $timeframe->[0] %>"
26
Added:
aria-label="Increase <%= $timeframe->[0] %> range">+</button>
27
Added:
</div>
28
Added:
% }
12
29
</div>
13
30
</div>
14
31