[Perl] DAQ system for the FAPG.
Improve readings chart.
Add chart x-axis timeframe selection interface, set minimum y-axis span.
Changed files
roles/dashboard/lib/dashboard/Controller/Reading.pm
@@ -24,15 +24,26 @@
24
24
$limit = 300 unless $limit =~ /^\d+$/;
25
25
$limit = 2_000 if $limit > 2_000;
26
26
27
Added:
my @where = ('probe = ?');
28
Added:
my @bind = ($probe);
29
Added:
my $since = $self->param('since');
30
Added:
31
Added:
if ( defined $since && $since =~ /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ ) {
32
Added:
push @where, 'COALESCE(received_at, timestamp) >= ?';
33
Added:
push @bind, $since;
34
Added:
}
35
Added:
27
36
my $rows = $self->sqlite->db->query(
28
37
q{
29
38
SELECT timestamp, received_at, node, probe, value, unit
30
39
FROM readings
31
Removed:
WHERE probe = ?
40
Added:
WHERE
41
Added:
}
42
Added:
. join( "\n AND ", @where ) . q{
32
43
ORDER BY COALESCE(received_at, timestamp) DESC, id DESC
33
44
LIMIT ?
34
45
},
35
Removed:
$probe,
46
Added:
@bind,
36
47
$limit,
37
48
)->hashes->to_array;
38
49
roles/dashboard/public/css/app.css
@@ -186,7 +186,7 @@
186
186
display: flex;
187
187
justify-content: space-between;
188
188
gap: 1rem;
189
Removed:
align-items: baseline;
189
Added:
align-items: flex-start;
190
190
margin-bottom: 1rem;
191
191
}
192
192
@@ -199,6 +199,48 @@
199
199
color: var(--muted);
200
200
}
201
201
202
Added:
.panel-tools {
203
Added:
display: flex;
204
Added:
flex-wrap: wrap;
205
Added:
justify-content: flex-end;
206
Added:
gap: 0.75rem;
207
Added:
align-items: center;
208
Added:
}
209
Added:
210
Added:
.timeframe-control {
211
Added:
display: inline-flex;
212
Added:
overflow: hidden;
213
Added:
border: 1px solid var(--border);
214
Added:
border-radius: 999px;
215
Added:
background: white;
216
Added:
}
217
Added:
218
Added:
.timeframe-button {
219
Added:
min-width: 4.75rem;
220
Added:
border: 0;
221
Added:
border-left: 1px solid var(--border);
222
Added:
padding: 0.45rem 0.75rem;
223
Added:
background: transparent;
224
Added:
color: var(--text);
225
Added:
cursor: pointer;
226
Added:
font: inherit;
227
Added:
font-size: 0.88rem;
228
Added:
font-weight: 700;
229
Added:
}
230
Added:
231
Added:
.timeframe-button:first-child {
232
Added:
border-left: 0;
233
Added:
}
234
Added:
235
Added:
.timeframe-button:hover {
236
Added:
background: var(--accent-soft);
237
Added:
}
238
Added:
239
Added:
.timeframe-button.is-active {
240
Added:
background: var(--accent);
241
Added:
color: white;
242
Added:
}
243
Added:
202
244
.chart-wrap {
203
245
min-height: 420px;
204
246
}
@@ -263,6 +305,31 @@
263
305
264
306
.panel-heading {
265
307
display: block;
308
Added:
}
309
Added:
310
Added:
.panel-tools {
311
Added:
justify-content: flex-start;
312
Added:
margin-top: 0.75rem;
313
Added:
}
314
Added:
315
Added:
.timeframe-control {
316
Added:
display: grid;
317
Added:
grid-template-columns: 1fr 1fr;
318
Added:
width: 100%;
319
Added:
border-radius: 0.5rem;
320
Added:
}
321
Added:
322
Added:
.timeframe-button {
323
Added:
border-top: 1px solid var(--border);
324
Added:
}
325
Added:
326
Added:
.timeframe-button:nth-child(1),
327
Added:
.timeframe-button:nth-child(2) {
328
Added:
border-top: 0;
329
Added:
}
330
Added:
331
Added:
.timeframe-button:nth-child(odd) {
332
Added:
border-left: 0;
266
333
}
267
334
268
335
.section-heading {
roles/dashboard/public/js/dashboard.js
@@ -1,5 +1,31 @@
1
1
const charts = new Map();
2
2
const OFFLINE_AFTER_MS = 60 * 1000;
3
Added:
const DEFAULT_TIMEFRAME = "day";
4
Added:
const TIMEFRAMES = {
5
Added:
hour: {
6
Added:
ms: 60 * 60 * 1000,
7
Added:
ticks: 6
8
Added:
},
9
Added:
day: {
10
Added:
ms: 24 * 60 * 60 * 1000,
11
Added:
ticks: 8
12
Added:
},
13
Added:
week: {
14
Added:
ms: 7 * 24 * 60 * 60 * 1000,
15
Added:
ticks: 7
16
Added:
},
17
Added:
year: {
18
Added:
ms: 365 * 24 * 60 * 60 * 1000,
19
Added:
ticks: 12
20
Added:
}
21
Added:
};
22
Added:
const MIN_Y_SPAN = {
23
Added:
ph: 2,
24
Added:
do: 5,
25
Added:
orp: 200,
26
Added:
ec: 500
27
Added:
};
28
Added:
let activeTimeframe = DEFAULT_TIMEFRAME;
3
29
4
30
function probeRows() {
5
31
return Array.from(document.querySelectorAll("[data-latest-probe]"));
@@ -22,12 +48,7 @@
22
48
}
23
49
24
50
function labelForRow(row) {
25
Removed:
const date = new Date(row.timestamp);
26
Removed:
return date.toLocaleTimeString([], {
27
Removed:
hour: "2-digit",
28
Removed:
minute: "2-digit",
29
Removed:
second: "2-digit"
30
Removed:
});
51
Added:
return formatChartTime(row.timestamp);
31
52
}
32
53
33
54
function formatDateTime(timestamp) {
@@ -74,6 +95,68 @@
74
95
children.forEach(child => parent.appendChild(child));
75
96
}
76
97
98
Added:
function selectedTimeframe() {
99
Added:
return TIMEFRAMES[activeTimeframe] ? activeTimeframe : DEFAULT_TIMEFRAME;
100
Added:
}
101
Added:
102
Added:
function timeframeSince() {
103
Added:
const timeframe = TIMEFRAMES[selectedTimeframe()];
104
Added:
return new Date(Date.now() - timeframe.ms).toISOString().replace(/\.\d{3}Z$/, "Z");
105
Added:
}
106
Added:
107
Added:
function formatChartTime(timestamp) {
108
Added:
const date = new Date(timestamp);
109
Added:
const timeframe = selectedTimeframe();
110
Added:
111
Added:
if (timeframe === "hour") {
112
Added:
return date.toLocaleTimeString([], {
113
Added:
hour: "2-digit",
114
Added:
minute: "2-digit"
115
Added:
});
116
Added:
}
117
Added:
118
Added:
if (timeframe === "day") {
119
Added:
return date.toLocaleTimeString([], {
120
Added:
hour: "2-digit",
121
Added:
minute: "2-digit"
122
Added:
});
123
Added:
}
124
Added:
125
Added:
if (timeframe === "week") {
126
Added:
return date.toLocaleString([], {
127
Added:
weekday: "short",
128
Added:
hour: "2-digit"
129
Added:
});
130
Added:
}
131
Added:
132
Added:
return date.toLocaleDateString([], {
133
Added:
month: "short",
134
Added:
day: "numeric"
135
Added:
});
136
Added:
}
137
Added:
138
Added:
function yAxisBounds(probe, values) {
139
Added:
const numbers = values.filter(Number.isFinite);
140
Added:
141
Added:
if (!numbers.length) {
142
Added:
return {};
143
Added:
}
144
Added:
145
Added:
const min = Math.min(...numbers);
146
Added:
const max = Math.max(...numbers);
147
Added:
const center = (min + max) / 2;
148
Added:
const observedSpan = max - min;
149
Added:
const configuredSpan = MIN_Y_SPAN[probe] || 1;
150
Added:
const relativeSpan = Math.abs(center) * 0.2;
151
Added:
const span = Math.max(observedSpan * 2.5, configuredSpan, relativeSpan);
152
Added:
const halfSpan = span / 2;
153
Added:
154
Added:
return {
155
Added:
min: center - halfSpan,
156
Added:
max: center + halfSpan
157
Added:
};
158
Added:
}
159
Added:
77
160
function statusLabel(state) {
78
161
return {
79
162
online: "Online",
@@ -216,7 +299,11 @@
216
299
const status = statusElement(probe);
217
300
218
301
try {
219
Removed:
const response = await fetch(`/api/readings/${probe}?limit=300`);
302
Added:
const params = new URLSearchParams({
303
Added:
limit: "2000",
304
Added:
since: timeframeSince()
305
Added:
});
306
Added:
const response = await fetch(`/api/readings/${probe}?${params}`);
220
307
221
308
if (!response.ok) {
222
309
throw new Error(`HTTP ${response.status}`);
@@ -228,6 +315,7 @@
228
315
const labels = rows.map(labelForRow);
229
316
const values = rows.map(row => Number(row.value));
230
317
const unit = rows.length ? rows[rows.length - 1].unit : "";
318
Added:
const yBounds = yAxisBounds(probe, values);
231
319
232
320
if (!charts.has(probe)) {
233
321
const ctx = chartElement(probe);
@@ -250,11 +338,12 @@
250
338
scales: {
251
339
x: {
252
340
ticks: {
253
Removed:
maxTicksLimit: 8
341
Added:
maxTicksLimit: TIMEFRAMES[selectedTimeframe()].ticks
254
342
}
255
343
},
256
344
y: {
257
Removed:
beginAtZero: false
345
Added:
beginAtZero: false,
346
Added:
...yBounds
258
347
}
259
348
},
260
349
plugins: {
@@ -276,6 +365,9 @@
276
365
chart.data.labels = labels;
277
366
chart.data.datasets[0].data = values;
278
367
chart.data.datasets[0].label = `${probe} ${unit ? `(${unit})` : ""}`;
368
Added:
chart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[selectedTimeframe()].ticks;
369
Added:
chart.options.scales.y.min = yBounds.min;
370
Added:
chart.options.scales.y.max = yBounds.max;
279
371
chart.update();
280
372
}
281
373
@@ -311,9 +403,25 @@
311
403
loadProbe(probe);
312
404
}
313
405
406
Added:
function setTimeframe(timeframe) {
407
Added:
activeTimeframe = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
408
Added:
409
Added:
document.querySelectorAll(".timeframe-button").forEach(button => {
410
Added:
button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
411
Added:
});
412
Added:
413
Added:
loadProbe(activeProbe());
414
Added:
}
415
Added:
314
416
document.querySelectorAll(".tab-button").forEach(button => {
315
417
button.addEventListener("click", () => {
316
418
activateTab(button.dataset.probe);
419
Added:
});
420
Added:
});
421
Added:
422
Added:
document.querySelectorAll(".timeframe-button").forEach(button => {
423
Added:
button.addEventListener("click", () => {
424
Added:
setTimeframe(button.dataset.timeframe);
317
425
});
318
426
});
319
427
roles/dashboard/t/basic.t
@@ -116,6 +116,10 @@
116
116
->status_is(200)
117
117
->content_like(qr/FAPG DAQ Dashboard/)
118
118
->content_like(qr/Dissolved Oxygen/)
119
Added:
->content_like(qr/Hourly/)
120
Added:
->content_like(qr/Daily/)
121
Added:
->content_like(qr/Weekly/)
122
Added:
->content_like(qr/Yearly/)
119
123
->content_like(qr/Last 10 pH Reading Messages/)
120
124
->content_like(qr/Last 10 Dissolved Oxygen Reading Messages/)
121
125
->content_like(qr/status-pill-list/)
@@ -128,6 +132,15 @@
128
132
->json_is( '/readings/0/received_at' => '2026-07-05T12:00:10Z' )
129
133
->json_is( '/readings/0/value' => 7.12 )
130
134
->json_hasnt('/readings/1');
135
Added:
136
Added:
$t->get_ok('/api/readings/ph?since=2026-07-05T12:00:05Z')
137
Added:
->status_is(200)
138
Added:
->json_is( '/readings/0/value' => 7.12 )
139
Added:
->json_hasnt('/readings/1');
140
Added:
141
Added:
$t->get_ok('/api/readings/ph?since=2026-07-05T12:00:15Z')
142
Added:
->status_is(200)
143
Added:
->json_hasnt('/readings/0');
131
144
132
145
$t->get_ok('/api/readings/do')
133
146
->status_is(200)
roles/dashboard/templates/dashboard/index.html.ep
@@ -48,10 +48,18 @@
48
48
49
49
<div class="panel-heading">
50
50
<h2><%= $probe->{label} %></h2>
51
Removed:
<p>
52
Removed:
Unit:
53
Removed:
<strong><%= $probe->{unit} %></strong>
54
Removed:
</p>
51
Added:
<div class="panel-tools">
52
Added:
<p>
53
Added:
Unit:
54
Added:
<strong><%= $probe->{unit} %></strong>
55
Added:
</p>
56
Added:
<div class="timeframe-control" role="group" aria-label="<%= $probe->{label} %> chart timeframe">
57
Added:
<button class="timeframe-button" type="button" data-timeframe="hour">Hourly</button>
58
Added:
<button class="timeframe-button is-active" type="button" data-timeframe="day">Daily</button>
59
Added:
<button class="timeframe-button" type="button" data-timeframe="week">Weekly</button>
60
Added:
<button class="timeframe-button" type="button" data-timeframe="year">Yearly</button>
61
Added:
</div>
62
Added:
</div>
55
63
</div>
56
64
57
65
<div class="chart-wrap">