[Perl] DAQ system for the FAPG.
Modularize dashboard JavaScript
Changed files
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/public/js/dashboard/api.js
- roles/dashboard/public/js/dashboard/charts.js
- roles/dashboard/public/js/dashboard/constants.js
- roles/dashboard/public/js/dashboard/format.js
- roles/dashboard/public/js/dashboard/graph-controls.js
- roles/dashboard/public/js/dashboard/quick-readings.js
- roles/dashboard/public/js/dashboard/status.js
- roles/dashboard/public/js/dashboard/timeframe.js
- roles/dashboard/templates/dashboard/downloads.html.ep
- roles/dashboard/templates/dashboard/graph.html.ep
- roles/dashboard/templates/dashboard/index.html.ep
- roles/dashboard/templates/dashboard/probes.html.ep
- roles/dashboard/templates/dashboard/status.html.ep
roles/dashboard/public/js/dashboard.js
@@ -1,973 +1,42 @@
1
Removed:
const charts = new Map();
2
Removed:
const OFFLINE_AFTER_MS = 2 * 60 * 1000;
3
Removed:
const DEFAULT_TIMEFRAME = "day";
4
Removed:
const TIMEFRAMES = {
5
Removed:
hour: {
6
Removed:
ms: 60 * 60 * 1000,
7
Removed:
ticks: 6
8
Removed:
},
9
Removed:
day: {
10
Removed:
ms: 24 * 60 * 60 * 1000,
11
Removed:
ticks: 8
12
Removed:
},
13
Removed:
week: {
14
Removed:
ms: 7 * 24 * 60 * 60 * 1000,
15
Removed:
ticks: 7
16
Removed:
},
17
Removed:
month: {
18
Removed:
ms: 30 * 24 * 60 * 60 * 1000,
19
Removed:
ticks: 10
20
Removed:
},
21
Removed:
year: {
22
Removed:
ms: 365 * 24 * 60 * 60 * 1000,
23
Removed:
ticks: 12
24
Removed:
}
25
Removed:
};
26
Removed:
const MIN_Y_SPAN = {
27
Removed:
ph: 2,
28
Removed:
do: 5,
29
Removed:
orp: 200,
30
Removed:
ec: 500
31
Removed:
};
32
Removed:
const READING_RANGES = {
33
Removed:
ph: {
34
Removed:
min: 5.5,
35
Removed:
goodMin: 6.4,
36
Removed:
goodMax: 7.2,
37
Removed:
max: 8.5,
38
Removed:
precision: 2
39
Removed:
},
40
Removed:
do: {
41
Removed:
min: 0,
42
Removed:
goodMin: 5.5,
43
Removed:
goodMax: 10,
44
Removed:
max: 14,
45
Removed:
precision: 2
46
Removed:
},
47
Removed:
orp: {
48
Removed:
min: 100,
49
Removed:
goodMin: 250,
50
Removed:
goodMax: 400,
51
Removed:
max: 500,
52
Removed:
precision: 0
53
Removed:
},
54
Removed:
ec: {
55
Removed:
min: 0,
56
Removed:
goodMin: 300,
57
Removed:
goodMax: 1500,
58
Removed:
max: 2500,
59
Removed:
precision: 0
60
Removed:
}
61
Removed:
};
62
Removed:
const MAX_TIMEFRAME_RANGE = 24;
63
Removed:
const PRIMARY_CHART_COLOR = "#276749";
64
Removed:
const RAW_BAND_COLOR = "rgba(39, 103, 73, 0.18)";
65
Removed:
const TRANSPARENT_CHART_COLOR = "rgba(39, 103, 73, 0)";
66
Removed:
let activeTimeframe = DEFAULT_TIMEFRAME;
67
Removed:
let expandedTimeframe = null;
68
Removed:
const timeframeRanges = Object.fromEntries(
69
Removed:
Object.keys(TIMEFRAMES).map(timeframe => [timeframe, 1])
70
Removed:
);
1
Added:
import { initialiseGraphControls } from "./dashboard/graph-controls.js";
2
Added:
import { createProbeCharts } from "./dashboard/charts.js";
3
Added:
import { loadQuickReadings } from "./dashboard/quick-readings.js";
4
Added:
import { createStatusLoader } from "./dashboard/status.js";
5
Added:
import { createTimeframeState } from "./dashboard/timeframe.js";
71
6
72
Removed:
function sensorStatusItems() {
73
Removed:
return Array.from(document.querySelectorAll("[data-sensor-status]"));
74
Removed:
}
7
Added:
const timeframe = createTimeframeState();
8
Added:
const probes = createProbeCharts(timeframe);
9
Added:
const loadStatuses = createStatusLoader();
75
10
76
Removed:
function headerStatusItems() {
77
Removed:
return Array.from(document.querySelectorAll("[data-header-status-item]"));
78
Removed:
}
79
Removed:
80
Removed:
function headerStatusElement() {
81
Removed:
return document.querySelector("[data-header-status]");
82
Removed:
}
83
Removed:
84
Removed:
function readingQuickItems() {
85
Removed:
return Array.from(document.querySelectorAll("[data-reading-quick-probe]"));
86
Removed:
}
87
Removed:
88
Removed:
function activeProbe() {
89
Removed:
const activeButton = document.querySelector(".tab-button.is-active");
90
Removed:
const activePanel = document.querySelector(".tab-panel.is-active") || document.querySelector(".tab-panel");
91
Removed:
92
Removed:
if (activeButton) {
93
Removed:
return activeButton.dataset.probe;
94
Removed:
}
95
Removed:
96
Removed:
return activePanel ? activePanel.dataset.panel : null;
97
Removed:
}
98
Removed:
99
Removed:
function displayedProbes() {
100
Removed:
return Array.from(document.querySelectorAll(".tab-panel[data-panel]"))
101
Removed:
.map(panel => panel.dataset.panel);
102
Removed:
}
103
Removed:
104
Removed:
function statusElement(probe) {
105
Removed:
return document.querySelector(`[data-status="${probe}"]`);
106
Removed:
}
107
Removed:
108
Removed:
function chartElement(probe) {
109
Removed:
return document.getElementById(`chart-${probe}`);
110
Removed:
}
111
Removed:
112
Removed:
function messageTableBody(probe) {
113
Removed:
return document.querySelector(`[data-reading-messages="${probe}"]`);
114
Removed:
}
115
Removed:
116
Removed:
function overviewStatusElement() {
117
Removed:
return document.querySelector("[data-overview-status]");
118
Removed:
}
119
Removed:
120
Removed:
function statusPageItems() {
121
Removed:
return Array.from(document.querySelectorAll("[data-status-page-item]"));
122
Removed:
}
123
Removed:
124
Removed:
function rawDownloadLinks() {
125
Removed:
return Array.from(document.querySelectorAll("[data-raw-download]"));
126
Removed:
}
127
Removed:
128
Removed:
function graphControlsToggles() {
129
Removed:
return Array.from(document.querySelectorAll("[data-graph-controls-toggle]"));
130
Removed:
}
131
Removed:
132
Removed:
function labelForRow(row) {
133
Removed:
return formatChartTime(row.timestamp);
134
Removed:
}
135
Removed:
136
Removed:
function formatDateTime(timestamp) {
137
Removed:
return new Date(timestamp).toLocaleString([], {
138
Removed:
dateStyle: "short",
139
Removed:
timeStyle: "medium"
140
Removed:
});
141
Removed:
}
142
Removed:
143
Removed:
function ageMs(timestamp) {
144
Removed:
const value = new Date(timestamp).getTime();
145
Removed:
return Number.isFinite(value) ? Date.now() - value : null;
146
Removed:
}
147
Removed:
148
Removed:
function formatAge(ms) {
149
Removed:
if (ms === null || ms < 0) {
150
Removed:
return "Unknown";
151
Removed:
}
152
Removed:
153
Removed:
const seconds = Math.floor(ms / 1000);
154
Removed:
155
Removed:
if (seconds < 60) {
156
Removed:
return `${seconds}s ago`;
157
Removed:
}
158
Removed:
159
Removed:
const minutes = Math.floor(seconds / 60);
160
Removed:
161
Removed:
if (minutes < 60) {
162
Removed:
return `${minutes}m ago`;
163
Removed:
}
164
Removed:
165
Removed:
const hours = Math.floor(minutes / 60);
166
Removed:
167
Removed:
if (hours < 48) {
168
Removed:
return `${hours}h ago`;
169
Removed:
}
170
Removed:
171
Removed:
const days = Math.floor(hours / 24);
172
Removed:
return `${days}d ago`;
173
Removed:
}
174
Removed:
175
Removed:
function replaceChildren(parent, children) {
176
Removed:
parent.textContent = "";
177
Removed:
children.forEach(child => parent.appendChild(child));
178
Removed:
}
179
Removed:
180
Removed:
function selectedTimeframe() {
181
Removed:
return TIMEFRAMES[activeTimeframe] ? activeTimeframe : DEFAULT_TIMEFRAME;
182
Removed:
}
183
Removed:
184
Removed:
function selectedTimeframeRange() {
185
Removed:
return timeframeRanges[selectedTimeframe()] || 1;
186
Removed:
}
187
Removed:
188
Removed:
function timeframeRangeLabel(timeframe, range) {
189
Removed:
const units = {
190
Removed:
hour: "hour",
191
Removed:
day: "day",
192
Removed:
week: "week",
193
Removed:
month: "month",
194
Removed:
year: "year"
195
Removed:
};
196
Removed:
const unit = units[timeframe] || "year";
197
Removed:
return `${range} ${unit}${range === 1 ? "" : "s"}`;
198
Removed:
}
199
Removed:
200
Removed:
function formatChartTime(timestamp) {
201
Removed:
const date = new Date(timestamp);
202
Removed:
const timeframe = selectedTimeframe();
203
Removed:
204
Removed:
if (timeframe === "hour") {
205
Removed:
return date.toLocaleTimeString([], {
206
Removed:
hour: "2-digit",
207
Removed:
minute: "2-digit"
208
Removed:
});
209
Removed:
}
210
Removed:
211
Removed:
if (timeframe === "day") {
212
Removed:
return date.toLocaleTimeString([], {
213
Removed:
hour: "2-digit"
214
Removed:
});
215
Removed:
}
216
Removed:
217
Removed:
if (timeframe === "week") {
218
Removed:
return date.toLocaleString([], {
219
Removed:
weekday: "short"
220
Removed:
});
221
Removed:
}
222
Removed:
223
Removed:
if (timeframe === "month") {
224
Removed:
return date.toLocaleDateString([], {
225
Removed:
month: "short",
226
Removed:
day: "numeric"
227
Removed:
});
228
Removed:
}
229
Removed:
230
Removed:
return date.toLocaleDateString([], {
231
Removed:
month: "short"
232
Removed:
});
233
Removed:
}
234
Removed:
235
Removed:
function yAxisBounds(probe, values) {
236
Removed:
if (probe === "ph") {
237
Removed:
return { min: 5, max: 9 };
238
Removed:
}
239
Removed:
240
Removed:
const numbers = values.filter(Number.isFinite);
241
Removed:
242
Removed:
if (!numbers.length) {
243
Removed:
return {};
244
Removed:
}
245
Removed:
246
Removed:
const min = Math.min(...numbers);
247
Removed:
const max = Math.max(...numbers);
248
Removed:
const center = (min + max) / 2;
249
Removed:
const observedSpan = max - min;
250
Removed:
const configuredSpan = MIN_Y_SPAN[probe] || 1;
251
Removed:
const relativeSpan = Math.abs(center) * 0.2;
252
Removed:
const span = Math.max(observedSpan * 2.5, configuredSpan, relativeSpan);
253
Removed:
const halfSpan = span / 2;
254
Removed:
255
Removed:
return {
256
Removed:
min: center - halfSpan,
257
Removed:
max: center + halfSpan
258
Removed:
};
259
Removed:
}
260
Removed:
261
Removed:
function chartSeries(rows) {
262
Removed:
const series = {
263
Removed:
labels: [],
264
Removed:
lower: [],
265
Removed:
upper: [],
266
Removed:
trend: []
267
Removed:
};
268
Removed:
269
Removed:
rows.forEach(row => {
270
Removed:
if (row.gap && series.labels.length) {
271
Removed:
series.labels.push("");
272
Removed:
series.lower.push(null);
273
Removed:
series.upper.push(null);
274
Removed:
series.trend.push(null);
275
Removed:
}
276
Removed:
277
Removed:
series.labels.push(labelForRow(row));
278
Removed:
series.lower.push(row.lower === null ? null : Number(row.lower));
279
Removed:
series.upper.push(row.upper === null ? null : Number(row.upper));
280
Removed:
series.trend.push(row.value === null ? null : Number(row.value));
281
Removed:
});
282
Removed:
283
Removed:
return series;
284
Removed:
}
285
Removed:
286
Removed:
function statusLabel(state) {
287
Removed:
return {
288
Removed:
online: "Online",
289
Removed:
healthy: "Healthy",
290
Removed:
stale: "Stale",
291
Removed:
"probe-error": "Probe unavailable",
292
Removed:
offline: "Offline",
293
Removed:
unreachable: "Unreachable",
294
Removed:
unknown: "Unknown"
295
Removed:
}[state] || "Unknown";
296
Removed:
}
297
Removed:
298
Removed:
function setStatusPill(element, state) {
299
Removed:
element.classList.remove(
300
Removed:
"is-online",
301
Removed:
"is-healthy",
302
Removed:
"is-stale",
303
Removed:
"is-probe-error",
304
Removed:
"is-offline",
305
Removed:
"is-unreachable",
306
Removed:
"is-unknown"
307
Removed:
);
308
Removed:
element.classList.add(`is-${state}`);
309
Removed:
element.querySelector("[data-status-text]").textContent = statusLabel(state);
310
Removed:
}
311
Removed:
312
Removed:
function setStatusSummary(state, text) {
313
Removed:
const element = overviewStatusElement();
314
Removed:
315
Removed:
setStatusPill(element, state);
316
Removed:
element.querySelector("[data-status-text]").textContent = text;
317
Removed:
}
318
Removed:
319
Removed:
function setHeaderStatus(state, text) {
320
Removed:
const element = headerStatusElement();
321
Removed:
322
Removed:
if (!element) {
323
Removed:
return;
324
Removed:
}
325
Removed:
326
Removed:
element.classList.remove("is-healthy", "is-warning", "is-unknown");
327
Removed:
element.classList.add(`is-${state}`);
328
Removed:
element.setAttribute("aria-label", text);
329
Removed:
element.title = text;
330
Removed:
}
331
Removed:
332
Removed:
async function fetchLatestStatus(probe) {
333
Removed:
const response = await fetch(`/api/status/${probe}`);
334
Removed:
335
Removed:
if (!response.ok) {
336
Removed:
throw new Error(`HTTP ${response.status}`);
337
Removed:
}
338
Removed:
339
Removed:
const payload = await response.json();
340
Removed:
return payload.status || null;
341
Removed:
}
342
Removed:
343
Removed:
async function fetchLatestReading(probe) {
344
Removed:
const params = new URLSearchParams({
345
Removed:
limit: "1"
346
Removed:
});
347
Removed:
const response = await fetch(`/api/readings/${probe}?${params}`);
348
Removed:
349
Removed:
if (!response.ok) {
350
Removed:
throw new Error(`HTTP ${response.status}`);
351
Removed:
}
352
Removed:
353
Removed:
const payload = await response.json();
354
Removed:
const readings = payload.readings || [];
355
Removed:
return readings.length ? readings[readings.length - 1] : null;
356
Removed:
}
357
Removed:
358
Removed:
function stateForNodeStatus(nodeStatus) {
359
Removed:
if (!nodeStatus) {
360
Removed:
return "unreachable";
361
Removed:
}
362
Removed:
363
Removed:
const timestamp = nodeStatus.received_at || nodeStatus.timestamp;
364
Removed:
const age = ageMs(timestamp);
365
Removed:
366
Removed:
if (age === null || age > OFFLINE_AFTER_MS) {
367
Removed:
return "unreachable";
368
Removed:
}
369
Removed:
370
Removed:
if (nodeStatus.status === "ok") {
371
Removed:
return "healthy";
372
Removed:
}
373
Removed:
374
Removed:
if (nodeStatus.status === "probe_error") {
375
Removed:
return "probe-error";
376
Removed:
}
377
Removed:
378
Removed:
return "unknown";
379
Removed:
}
380
Removed:
381
Removed:
function statusTitle(nodeStatus) {
382
Removed:
if (!nodeStatus) {
383
Removed:
return "No recent node status message has been stored.";
384
Removed:
}
385
Removed:
386
Removed:
const detail = nodeStatus.error || nodeStatus.message || "";
387
Removed:
const timestamp = nodeStatus.received_at || nodeStatus.timestamp;
388
Removed:
const when = timestamp ? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})` : "unknown time";
389
Removed:
390
Removed:
return [detail, `Status received at ${when}`].filter(Boolean).join(" ");
391
Removed:
}
392
Removed:
393
Removed:
function buildMessageCell(text) {
394
Removed:
const cell = document.createElement("td");
395
Removed:
cell.textContent = text;
396
Removed:
return cell;
397
Removed:
}
398
Removed:
399
Removed:
function updateStatusPageRow(row, status) {
400
Removed:
const state = stateForNodeStatus(status);
401
Removed:
const timestamp = status?.received_at || status?.timestamp;
402
Removed:
const stateElement = row.querySelector("[data-status-page-state]");
403
Removed:
404
Removed:
stateElement.className = `status-pill is-${state}`;
405
Removed:
stateElement.textContent = statusLabel(state);
406
Removed:
row.querySelector("[data-status-page-node]").textContent = status?.node || "Unknown";
407
Removed:
row.querySelector("[data-status-page-last-seen]").textContent = timestamp
408
Removed:
? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})`
409
Removed:
: "Never";
410
Removed:
row.querySelector("[data-status-page-message]").textContent = status
411
Removed:
? status.error || status.message || "No message"
412
Removed:
: "No recent status received";
413
Removed:
}
414
Removed:
415
Removed:
async function loadStatusPage() {
416
Removed:
const rows = statusPageItems();
417
Removed:
418
Removed:
if (!rows.length) {
419
Removed:
return;
420
Removed:
}
421
Removed:
422
Removed:
const results = await Promise.allSettled(
423
Removed:
rows.map(row => fetchLatestStatus(row.dataset.statusPageItem))
424
Removed:
);
425
Removed:
426
Removed:
rows.forEach((row, index) => {
427
Removed:
const result = results[index];
428
Removed:
updateStatusPageRow(row, result.status === "fulfilled" ? result.value : null);
429
Removed:
});
430
Removed:
}
431
Removed:
432
Removed:
function formatReadingValue(value, range) {
433
Removed:
if (!Number.isFinite(value)) {
434
Removed:
return "No reading";
435
Removed:
}
436
Removed:
437
Removed:
return value.toFixed(range.precision);
438
Removed:
}
439
Removed:
440
Removed:
function rangePercent(value, range) {
441
Removed:
const percent = ((value - range.min) / (range.max - range.min)) * 100;
442
Removed:
return Math.max(0, Math.min(100, percent));
443
Removed:
}
444
Removed:
445
Removed:
function readingState(value, range) {
446
Removed:
if (!Number.isFinite(value)) {
447
Removed:
return "unknown";
448
Removed:
}
449
Removed:
450
Removed:
if (value < range.goodMin) {
451
Removed:
return "low";
452
Removed:
}
453
Removed:
454
Removed:
if (value > range.goodMax) {
455
Removed:
return "high";
456
Removed:
}
457
Removed:
458
Removed:
return "good";
459
Removed:
}
460
Removed:
461
Removed:
function readingStateLabel(state) {
462
Removed:
return {
463
Removed:
good: "Good",
464
Removed:
low: "Too low",
465
Removed:
high: "Too high",
466
Removed:
unknown: "No data"
467
Removed:
}[state] || "No data";
468
Removed:
}
469
Removed:
470
Removed:
function setQuickItemState(item, state) {
471
Removed:
item.classList.remove("is-good", "is-low", "is-high", "is-unknown");
472
Removed:
item.classList.add(`is-${state}`);
473
Removed:
}
474
Removed:
475
Removed:
function configureQuickItemRange(item, range, unit) {
476
Removed:
const goodStart = rangePercent(range.goodMin, range);
477
Removed:
const goodEnd = rangePercent(range.goodMax, range);
478
Removed:
const warnLow = Math.max(0, goodStart - 8);
479
Removed:
const warnHigh = Math.min(100, goodEnd + 8);
480
Removed:
481
Removed:
item.style.setProperty("--warn-low", `${warnLow}%`);
482
Removed:
item.style.setProperty("--good-start", `${goodStart}%`);
483
Removed:
item.style.setProperty("--good-end", `${goodEnd}%`);
484
Removed:
item.style.setProperty("--warn-high", `${warnHigh}%`);
485
Removed:
item.querySelector("[data-reading-quick-min]").textContent =
486
Removed:
`${formatReadingValue(range.min, range)} ${unit}`;
487
Removed:
item.querySelector("[data-reading-quick-target]").textContent =
488
Removed:
`${formatReadingValue(range.goodMin, range)}-${formatReadingValue(range.goodMax, range)} ${unit}`;
489
Removed:
item.querySelector("[data-reading-quick-max]").textContent =
490
Removed:
`${formatReadingValue(range.max, range)} ${unit}`;
491
Removed:
}
492
Removed:
493
Removed:
function updateQuickItem(item, reading) {
494
Removed:
const probe = item.dataset.readingQuickProbe;
495
Removed:
const range = READING_RANGES[probe];
496
Removed:
const unit = item.dataset.readingQuickUnit || reading?.unit || "";
497
Removed:
const label = item.dataset.readingQuickLabel || probe;
498
Removed:
const value = Number(reading?.value);
499
Removed:
const state = readingState(value, range);
500
Removed:
const marker = item.querySelector("[data-reading-quick-marker]");
501
Removed:
const meter = item.querySelector(".reading-range");
502
Removed:
const valueElement = item.querySelector("[data-reading-quick-value]");
503
Removed:
const percent = Number.isFinite(value) ? rangePercent(value, range) : 50;
504
Removed:
const timestamp = reading?.received_at || reading?.timestamp;
505
Removed:
const age = timestamp ? formatAge(ageMs(timestamp)) : "unknown age";
506
Removed:
507
Removed:
configureQuickItemRange(item, range, unit);
508
Removed:
setQuickItemState(item, state);
509
Removed:
marker.style.left = `${percent}%`;
510
Removed:
meter.setAttribute("aria-valuenow", Math.round(percent).toString());
511
Removed:
meter.setAttribute(
512
Removed:
"aria-label",
513
Removed:
`${label} ${readingStateLabel(state)} at ${formatReadingValue(value, range)} ${unit}`
514
Removed:
);
515
Removed:
valueElement.textContent = Number.isFinite(value)
516
Removed:
? `${formatReadingValue(value, range)} ${unit} - ${readingStateLabel(state)} - ${age}`
517
Removed:
: "No reading yet";
518
Removed:
}
519
Removed:
520
Removed:
async function loadQuickStatus() {
521
Removed:
const items = readingQuickItems();
522
Removed:
523
Removed:
if (!items.length) {
524
Removed:
return;
525
Removed:
}
526
Removed:
527
Removed:
const results = await Promise.allSettled(
528
Removed:
items.map(item => fetchLatestReading(item.dataset.readingQuickProbe))
529
Removed:
);
530
Removed:
531
Removed:
items.forEach((item, index) => {
532
Removed:
const range = READING_RANGES[item.dataset.readingQuickProbe];
533
Removed:
534
Removed:
if (!range) {
535
Removed:
return;
536
Removed:
}
537
Removed:
538
Removed:
const result = results[index];
539
Removed:
updateQuickItem(item, result.status === "fulfilled" ? result.value : null);
540
Removed:
});
541
Removed:
}
542
Removed:
543
Removed:
function renderMessageTable(probe, rows) {
544
Removed:
const body = messageTableBody(probe);
545
Removed:
546
Removed:
if (!body) {
547
Removed:
return;
548
Removed:
}
549
Removed:
550
Removed:
const latestRows = rows
551
Removed:
.filter(row => row.probe === probe)
552
Removed:
.slice(-10)
553
Removed:
.reverse();
554
Removed:
555
Removed:
if (!latestRows.length) {
556
Removed:
const emptyRow = document.createElement("tr");
557
Removed:
const emptyCell = buildMessageCell("No reading messages received yet");
558
Removed:
559
Removed:
emptyCell.colSpan = 5;
560
Removed:
emptyRow.appendChild(emptyCell);
561
Removed:
replaceChildren(body, [emptyRow]);
562
Removed:
return;
563
Removed:
}
564
Removed:
565
Removed:
const tableRows = latestRows.map(row => {
566
Removed:
const receivedAt = row.received_at || row.timestamp;
567
Removed:
const tableRow = document.createElement("tr");
568
Removed:
569
Removed:
[
570
Removed:
formatDateTime(receivedAt),
571
Removed:
formatAge(ageMs(receivedAt)),
572
Removed:
row.node || "Unknown",
573
Removed:
row.value ?? "",
574
Removed:
row.unit || ""
575
Removed:
].forEach(value => tableRow.appendChild(buildMessageCell(value)));
576
Removed:
577
Removed:
return tableRow;
578
Removed:
});
579
Removed:
580
Removed:
replaceChildren(body, tableRows);
581
Removed:
}
582
Removed:
583
Removed:
async function loadOverview() {
584
Removed:
const rows = sensorStatusItems();
585
Removed:
const overview = overviewStatusElement();
586
Removed:
587
Removed:
if (!overview) {
588
Removed:
return;
589
Removed:
}
590
Removed:
591
Removed:
const results = await Promise.allSettled(
592
Removed:
rows.map(row => fetchLatestStatus(row.dataset.sensorStatus))
593
Removed:
);
594
Removed:
595
Removed:
const items = rows.map((row, index) => {
596
Removed:
const result = results[index];
597
Removed:
const status = result.status === "fulfilled" ? result.value : null;
598
Removed:
599
Removed:
return {
600
Removed:
key: row.dataset.sensorStatus,
601
Removed:
label: row.dataset.sensorLabel,
602
Removed:
status,
603
Removed:
state: stateForNodeStatus(status)
604
Removed:
};
605
Removed:
});
606
Removed:
607
Removed:
const unhealthyItems = items.filter(item => item.state !== "healthy");
608
Removed:
if (!items.length) {
609
Removed:
setStatusSummary("unknown", "No sensors configured");
610
Removed:
return;
611
Removed:
}
612
Removed:
613
Removed:
if (!unhealthyItems.length) {
614
Removed:
setStatusSummary("healthy", "All sensors healthy");
615
Removed:
overviewStatusElement().title = "Every sensor node has sent a recent healthy status.";
616
Removed:
return;
617
Removed:
}
618
Removed:
619
Removed:
const summaryState = unhealthyItems.some(item => item.state === "unreachable")
620
Removed:
? "unreachable"
621
Removed:
: "probe-error";
622
Removed:
const countLabel = unhealthyItems.length === 1 ? "sensor needs" : "sensors need";
623
Removed:
624
Removed:
setStatusSummary(summaryState, `${unhealthyItems.length} ${countLabel} attention`);
625
Removed:
overviewStatusElement().title = unhealthyItems.map(item => statusTitle(item.status)).join(" ");
626
Removed:
}
627
Removed:
628
Removed:
async function loadHeaderStatus() {
629
Removed:
const rows = headerStatusItems();
630
Removed:
631
Removed:
if (!headerStatusElement()) {
632
Removed:
return;
633
Removed:
}
634
Removed:
635
Removed:
if (!rows.length) {
636
Removed:
setHeaderStatus("unknown", "No DAQ status items configured");
637
Removed:
return;
638
Removed:
}
639
Removed:
640
Removed:
const results = await Promise.allSettled(
641
Removed:
rows.map(row => fetchLatestStatus(row.dataset.headerStatusItem))
642
Removed:
);
643
Removed:
644
Removed:
const warningItems = rows
645
Removed:
.map((row, index) => {
646
Removed:
const result = results[index];
647
Removed:
const status = result.status === "fulfilled" ? result.value : null;
648
Removed:
649
Removed:
return {
650
Removed:
label: row.dataset.headerStatusLabel,
651
Removed:
state: stateForNodeStatus(status)
652
Removed:
};
653
Removed:
})
654
Removed:
.filter(item => item.state !== "healthy");
655
Removed:
656
Removed:
if (!warningItems.length) {
657
Removed:
setHeaderStatus("healthy", "All DAQ status checks are healthy");
658
Removed:
return;
659
Removed:
}
660
Removed:
661
Removed:
const countLabel = warningItems.length === 1 ? "status check needs" : "status checks need";
662
Removed:
setHeaderStatus("warning", `${warningItems.length} ${countLabel} attention`);
663
Removed:
}
664
Removed:
665
Removed:
async function loadProbe(probe) {
666
Removed:
if (!probe || !chartElement(probe)) {
667
Removed:
return;
668
Removed:
}
669
Removed:
670
Removed:
const status = statusElement(probe);
671
Removed:
672
Removed:
try {
673
Removed:
const seriesParams = new URLSearchParams({
674
Removed:
timeframe: selectedTimeframe(),
675
Removed:
range: String(selectedTimeframeRange()),
676
Removed:
smooth: "1"
677
Removed:
});
678
Removed:
const readingsParams = new URLSearchParams({
679
Removed:
limit: "10"
680
Removed:
});
681
Removed:
const [seriesResponse, readingsResponse] = await Promise.all([
682
Removed:
fetch(`/api/readings/${probe}/series?${seriesParams}`),
683
Removed:
fetch(`/api/readings/${probe}?${readingsParams}`)
684
Removed:
]);
685
Removed:
686
Removed:
if (!seriesResponse.ok) {
687
Removed:
throw new Error(`HTTP ${seriesResponse.status}`);
688
Removed:
}
689
Removed:
690
Removed:
if (!readingsResponse.ok) {
691
Removed:
throw new Error(`HTTP ${readingsResponse.status}`);
692
Removed:
}
693
Removed:
694
Removed:
const seriesPayload = await seriesResponse.json();
695
Removed:
const readingsPayload = await readingsResponse.json();
696
Removed:
const rows = seriesPayload.readings || [];
697
Removed:
const readings = readingsPayload.readings || [];
698
Removed:
699
Removed:
const series = chartSeries(rows);
700
Removed:
const latest = [...readings]
701
Removed:
.reverse()
702
Removed:
.find(row => row.value !== null && Number.isFinite(Number(row.value)));
703
Removed:
const unit = [...rows].reverse().find(row => row.unit)?.unit || "";
704
Removed:
const yBounds = yAxisBounds(
705
Removed:
probe,
706
Removed:
[...series.lower, ...series.upper, ...series.trend]
707
Removed:
);
708
Removed:
const trendLabel = `${probe} ${unit ? `(${unit})` : ""} trend`;
709
Removed:
710
Removed:
if (!charts.has(probe)) {
711
Removed:
const ctx = chartElement(probe);
712
Removed:
713
Removed:
const chart = new Chart(ctx, {
714
Removed:
type: "line",
715
Removed:
data: {
716
Removed:
labels: series.labels,
717
Removed:
datasets: [
718
Removed:
{
719
Removed:
label: "Raw lower percentile",
720
Removed:
data: series.lower,
721
Removed:
borderColor: TRANSPARENT_CHART_COLOR,
722
Removed:
backgroundColor: TRANSPARENT_CHART_COLOR,
723
Removed:
pointRadius: 0,
724
Removed:
pointHoverRadius: 0
725
Removed:
},
726
Removed:
{
727
Removed:
label: "Raw variability (10th–90th percentile)",
728
Removed:
data: series.upper,
729
Removed:
borderColor: TRANSPARENT_CHART_COLOR,
730
Removed:
backgroundColor: RAW_BAND_COLOR,
731
Removed:
pointRadius: 0,
732
Removed:
pointHoverRadius: 0,
733
Removed:
fill: "-1"
734
Removed:
},
735
Removed:
{
736
Removed:
label: trendLabel,
737
Removed:
data: series.trend,
738
Removed:
tension: 0.2,
739
Removed:
borderColor: PRIMARY_CHART_COLOR,
740
Removed:
backgroundColor: PRIMARY_CHART_COLOR,
741
Removed:
pointBackgroundColor: PRIMARY_CHART_COLOR,
742
Removed:
pointRadius: 0,
743
Removed:
pointHoverRadius: 0
744
Removed:
}
745
Removed:
]
746
Removed:
},
747
Removed:
options: {
748
Removed:
responsive: true,
749
Removed:
maintainAspectRatio: false,
750
Removed:
animation: false,
751
Removed:
scales: {
752
Removed:
x: {
753
Removed:
ticks: {
754
Removed:
maxTicksLimit: TIMEFRAMES[selectedTimeframe()].ticks
755
Removed:
}
756
Removed:
},
757
Removed:
y: {
758
Removed:
beginAtZero: false,
759
Removed:
...yBounds
760
Removed:
}
761
Removed:
},
762
Removed:
plugins: {
763
Removed:
legend: {
764
Removed:
display: true,
765
Removed:
labels: {
766
Removed:
filter: item => item.datasetIndex !== 0
767
Removed:
}
768
Removed:
},
769
Removed:
tooltip: {
770
Removed:
mode: "index",
771
Removed:
intersect: false
772
Removed:
}
773
Removed:
}
774
Removed:
}
775
Removed:
});
776
Removed:
777
Removed:
charts.set(probe, chart);
778
Removed:
} else {
779
Removed:
const chart = charts.get(probe);
780
Removed:
781
Removed:
chart.data.labels = series.labels;
782
Removed:
chart.data.datasets[0].data = series.lower;
783
Removed:
chart.data.datasets[1].data = series.upper;
784
Removed:
chart.data.datasets[2].data = series.trend;
785
Removed:
chart.data.datasets[2].label = trendLabel;
786
Removed:
chart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[selectedTimeframe()].ticks;
787
Removed:
chart.options.scales.y.min = yBounds.min;
788
Removed:
chart.options.scales.y.max = yBounds.max;
789
Removed:
chart.update();
790
Removed:
}
791
Removed:
792
Removed:
if (latest) {
793
Removed:
const age = ageMs(latest.timestamp);
794
Removed:
795
Removed:
status.textContent =
796
Removed:
`Latest: ${latest.value} ${latest.unit || ""} at ${formatDateTime(latest.timestamp)} (${formatAge(age)})`;
797
Removed:
} else {
798
Removed:
status.textContent = "No readings found for this probe yet.";
799
Removed:
}
800
Removed:
801
Removed:
renderMessageTable(probe, readings);
802
Removed:
} catch (error) {
803
Removed:
status.textContent = `Could not load ${probe} readings: ${error.message}`;
804
Removed:
renderMessageTable(probe, []);
805
Removed:
}
806
Removed:
}
807
Removed:
808
Removed:
function activateTab(probe) {
809
Removed:
document.querySelectorAll(".tab-button").forEach(button => {
810
Removed:
const active = button.dataset.probe === probe;
811
Removed:
812
Removed:
button.classList.toggle("is-active", active);
813
Removed:
button.setAttribute("aria-selected", active ? "true" : "false");
814
Removed:
});
815
Removed:
816
Removed:
document.querySelectorAll(".tab-panel").forEach(panel => {
817
Removed:
panel.classList.toggle("is-active", panel.dataset.panel === probe);
818
Removed:
});
819
Removed:
820
Removed:
loadProbe(probe);
821
Removed:
}
822
Removed:
823
Removed:
function updateTimeframeControls() {
824
Removed:
document.querySelectorAll("[data-timeframe-option]").forEach(option => {
825
Removed:
const timeframe = option.dataset.timeframeOption;
826
Removed:
const range = timeframeRanges[timeframe] || 1;
827
Removed:
const expanded = timeframe === expandedTimeframe;
828
Removed:
const button = option.querySelector(".timeframe-button");
829
Removed:
830
Removed:
option.classList.toggle("is-active", timeframe === activeTimeframe);
831
Removed:
option.classList.toggle("is-expanded", expanded);
832
Removed:
button.classList.toggle("is-active", timeframe === activeTimeframe);
833
Removed:
button.textContent = expanded
834
Removed:
? timeframeRangeLabel(timeframe, range)
835
Removed:
: button.dataset.timeframeLabel;
836
Removed:
837
Removed:
option.querySelector('[data-timeframe-adjustment="decrease"]').disabled = range <= 1;
838
Removed:
option.querySelector('[data-timeframe-adjustment="increase"]').disabled = range >= MAX_TIMEFRAME_RANGE;
839
Removed:
});
840
Removed:
841
Removed:
updateRawDownloadLinks();
842
Removed:
}
843
Removed:
844
Removed:
function updateRawDownloadLinks() {
845
Removed:
rawDownloadLinks().forEach(link => {
846
Removed:
const params = new URLSearchParams({
847
Removed:
probe: link.dataset.probe,
848
Removed:
timeframe: selectedTimeframe(),
849
Removed:
range: String(selectedTimeframeRange())
850
Removed:
});
851
Removed:
852
Removed:
link.href = `/downloads/readings.csv?${params}`;
853
Removed:
});
854
Removed:
}
855
Removed:
856
Removed:
function setTimeframe(timeframe, expand = false) {
857
Removed:
const selected = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
858
Removed:
859
Removed:
if (selected === activeTimeframe) {
860
Removed:
timeframeRanges[selected] = 1;
861
Removed:
}
862
Removed:
863
Removed:
activeTimeframe = selected;
864
Removed:
expandedTimeframe = expand ? activeTimeframe : null;
865
Removed:
866
Removed:
document.querySelectorAll(".timeframe-button").forEach(button => {
867
Removed:
button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
868
Removed:
});
869
Removed:
870
Removed:
updateTimeframeControls();
871
Removed:
displayedProbes().forEach(loadProbe);
872
Removed:
}
873
Removed:
874
Removed:
function adjustTimeframeRange(timeframe, adjustment) {
875
Removed:
if (!TIMEFRAMES[timeframe]) {
876
Removed:
return;
877
Removed:
}
878
Removed:
879
Removed:
activeTimeframe = timeframe;
880
Removed:
expandedTimeframe = timeframe;
881
Removed:
timeframeRanges[timeframe] = Math.min(
882
Removed:
MAX_TIMEFRAME_RANGE,
883
Removed:
Math.max(1, (timeframeRanges[timeframe] || 1) + adjustment)
884
Removed:
);
885
Removed:
886
Removed:
document.querySelectorAll(".timeframe-button").forEach(button => {
887
Removed:
button.classList.toggle("is-active", button.dataset.timeframe === activeTimeframe);
888
Removed:
});
889
Removed:
890
Removed:
updateTimeframeControls();
891
Removed:
displayedProbes().forEach(loadProbe);
892
Removed:
}
893
Removed:
894
Removed:
function probeFromHash() {
895
Removed:
const match = window.location.hash.match(/^#probe-([a-z]+)$/);
896
Removed:
const probe = match ? match[1] : null;
897
Removed:
898
Removed:
return chartElement(probe) ? probe : null;
899
Removed:
}
900
Removed:
901
Removed:
function probeFromPath() {
902
Removed:
const match = window.location.pathname.match(/^\/probes\/([a-z]+)$/);
903
Removed:
const probe = match ? match[1] : null;
904
Removed:
905
Removed:
return chartElement(probe) ? probe : null;
906
Removed:
}
907
Removed:
908
Removed:
function probeFromLocation() {
909
Removed:
return probeFromPath() || probeFromHash();
910
Removed:
}
911
Removed:
912
11
document.querySelectorAll(".tab-button").forEach(button => {
913
Removed:
button.addEventListener("click", () => {
914
Removed:
activateTab(button.dataset.probe);
915
Removed:
});
12
Added:
button.addEventListener("click", () => probes.activateTab(button.dataset.probe));
916
13
});
917
14
918
Removed:
document.querySelectorAll(".timeframe-button").forEach(button => {
919
Removed:
button.addEventListener("click", () => {
920
Removed:
setTimeframe(button.dataset.timeframe, Boolean(button.closest("[data-timeframe-option]")));
921
Removed:
});
922
Removed:
});
15
Added:
initialiseGraphControls(timeframe, probes.loadDisplayed);
923
16
924
Removed:
document.querySelectorAll("[data-timeframe-adjustment]").forEach(button => {
925
Removed:
button.addEventListener("click", () => {
926
Removed:
const adjustment = button.dataset.timeframeAdjustment === "increase" ? 1 : -1;
927
Removed:
adjustTimeframeRange(button.dataset.timeframe, adjustment);
928
Removed:
});
929
Removed:
});
930
Removed:
931
Removed:
graphControlsToggles().forEach(toggle => {
932
Removed:
toggle.addEventListener("click", () => {
933
Removed:
const expanded = toggle.getAttribute("aria-expanded") === "true";
934
Removed:
toggle.setAttribute("aria-expanded", expanded ? "false" : "true");
935
Removed:
});
936
Removed:
});
937
Removed:
938
17
window.addEventListener("hashchange", () => {
939
Removed:
const probe = probeFromLocation();
940
Removed:
941
Removed:
if (probe) {
942
Removed:
activateTab(probe);
943
Removed:
}
18
Added:
const probe = probes.probeFromLocation();
19
Added:
if (probe) probes.activateTab(probe);
944
20
});
945
21
946
Removed:
const initialHashProbe = probeFromHash();
947
Removed:
const initialProbe = probeFromPath() || initialHashProbe;
22
Added:
const initialHashProbe = probes.probeFromHash();
23
Added:
const initialProbe = probes.probeFromLocation();
948
24
949
Removed:
updateRawDownloadLinks();
950
Removed:
951
25
if (initialProbe) {
952
Removed:
activateTab(initialProbe);
26
Added:
probes.activateTab(initialProbe);
953
27
954
28
if (initialHashProbe) {
955
Removed:
document.getElementById(`probe-${initialHashProbe}`).scrollIntoView();
29
Added:
document.getElementById(`probe-${initialHashProbe}`)?.scrollIntoView();
956
30
}
957
Removed:
} else if (activeProbe()) {
958
Removed:
displayedProbes().forEach(loadProbe);
31
Added:
} else if (probes.activeProbe()) {
32
Added:
probes.loadDisplayed();
959
33
}
960
34
961
Removed:
loadOverview();
962
Removed:
loadQuickStatus();
963
Removed:
loadHeaderStatus();
964
Removed:
loadStatusPage();
35
Added:
function refreshDashboard() {
36
Added:
probes.loadDisplayed();
37
Added:
loadQuickReadings();
38
Added:
loadStatuses();
39
Added:
}
965
40
966
Removed:
setInterval(() => {
967
Removed:
displayedProbes().forEach(loadProbe);
968
Removed:
969
Removed:
loadOverview();
970
Removed:
loadQuickStatus();
971
Removed:
loadHeaderStatus();
972
Removed:
loadStatusPage();
973
Removed:
}, 5_000);
41
Added:
refreshDashboard();
42
Added:
setInterval(refreshDashboard, 5_000);
roles/dashboard/public/js/dashboard/api.js
@@ -0,0 +1,40 @@
1
Added:
async function fetchJson(url) {
2
Added:
const response = await fetch(url);
3
Added:
4
Added:
if (!response.ok) {
5
Added:
throw new Error(`HTTP ${response.status}`);
6
Added:
}
7
Added:
8
Added:
return response.json();
9
Added:
}
10
Added:
11
Added:
export async function fetchLatestStatus(probe) {
12
Added:
const payload = await fetchJson(`/api/status/${probe}`);
13
Added:
return payload.status || null;
14
Added:
}
15
Added:
16
Added:
export async function fetchLatestReading(probe) {
17
Added:
const params = new URLSearchParams({ limit: "1" });
18
Added:
const payload = await fetchJson(`/api/readings/${probe}?${params}`);
19
Added:
const readings = payload.readings || [];
20
Added:
21
Added:
return readings.length ? readings[readings.length - 1] : null;
22
Added:
}
23
Added:
24
Added:
export async function fetchProbeData(probe, timeframe, range) {
25
Added:
const seriesParams = new URLSearchParams({
26
Added:
timeframe,
27
Added:
range: String(range),
28
Added:
smooth: "1"
29
Added:
});
30
Added:
const readingsParams = new URLSearchParams({ limit: "10" });
31
Added:
const [series, readings] = await Promise.all([
32
Added:
fetchJson(`/api/readings/${probe}/series?${seriesParams}`),
33
Added:
fetchJson(`/api/readings/${probe}?${readingsParams}`)
34
Added:
]);
35
Added:
36
Added:
return {
37
Added:
series: series.readings || [],
38
Added:
readings: readings.readings || []
39
Added:
};
40
Added:
}
roles/dashboard/public/js/dashboard/charts.js
@@ -0,0 +1,222 @@
1
Added:
import { fetchProbeData } from "./api.js";
2
Added:
import {
3
Added:
MIN_Y_SPAN,
4
Added:
PRIMARY_CHART_COLOR,
5
Added:
RAW_BAND_COLOR,
6
Added:
TIMEFRAMES,
7
Added:
TRANSPARENT_CHART_COLOR
8
Added:
} from "./constants.js";
9
Added:
import { ageMs, formatAge, formatChartTime, formatDateTime, replaceChildren } from "./format.js";
10
Added:
11
Added:
function chartElement(probe) {
12
Added:
return document.getElementById(`chart-${probe}`);
13
Added:
}
14
Added:
15
Added:
function displayedProbes() {
16
Added:
return Array.from(document.querySelectorAll(".tab-panel[data-panel]")).map(panel => panel.dataset.panel);
17
Added:
}
18
Added:
19
Added:
function yAxisBounds(probe, values) {
20
Added:
if (probe === "ph") return { min: 5, max: 9 };
21
Added:
22
Added:
const numbers = values.filter(Number.isFinite);
23
Added:
if (!numbers.length) return {};
24
Added:
25
Added:
const min = Math.min(...numbers);
26
Added:
const max = Math.max(...numbers);
27
Added:
const center = (min + max) / 2;
28
Added:
const span = Math.max(
29
Added:
(max - min) * 2.5,
30
Added:
MIN_Y_SPAN[probe] || 1,
31
Added:
Math.abs(center) * 0.2
32
Added:
);
33
Added:
34
Added:
return { min: center - span / 2, max: center + span / 2 };
35
Added:
}
36
Added:
37
Added:
function chartSeries(rows, timeframe) {
38
Added:
const series = { labels: [], lower: [], upper: [], trend: [] };
39
Added:
40
Added:
rows.forEach(row => {
41
Added:
if (row.gap && series.labels.length) {
42
Added:
series.labels.push("");
43
Added:
series.lower.push(null);
44
Added:
series.upper.push(null);
45
Added:
series.trend.push(null);
46
Added:
}
47
Added:
48
Added:
series.labels.push(formatChartTime(row.timestamp, timeframe));
49
Added:
series.lower.push(row.lower === null ? null : Number(row.lower));
50
Added:
series.upper.push(row.upper === null ? null : Number(row.upper));
51
Added:
series.trend.push(row.value === null ? null : Number(row.value));
52
Added:
});
53
Added:
54
Added:
return series;
55
Added:
}
56
Added:
57
Added:
function buildMessageCell(text) {
58
Added:
const cell = document.createElement("td");
59
Added:
cell.textContent = text;
60
Added:
return cell;
61
Added:
}
62
Added:
63
Added:
function renderMessageTable(probe, rows) {
64
Added:
const body = document.querySelector(`[data-reading-messages="${probe}"]`);
65
Added:
if (!body) return;
66
Added:
67
Added:
const latestRows = rows.filter(row => row.probe === probe).slice(-10).reverse();
68
Added:
if (!latestRows.length) {
69
Added:
const emptyRow = document.createElement("tr");
70
Added:
const emptyCell = buildMessageCell("No reading messages received yet");
71
Added:
emptyCell.colSpan = 5;
72
Added:
emptyRow.appendChild(emptyCell);
73
Added:
replaceChildren(body, [emptyRow]);
74
Added:
return;
75
Added:
}
76
Added:
77
Added:
replaceChildren(body, latestRows.map(row => {
78
Added:
const receivedAt = row.received_at || row.timestamp;
79
Added:
const tableRow = document.createElement("tr");
80
Added:
[
81
Added:
formatDateTime(receivedAt),
82
Added:
formatAge(ageMs(receivedAt)),
83
Added:
row.node || "Unknown",
84
Added:
row.value ?? "",
85
Added:
row.unit || ""
86
Added:
].forEach(value => tableRow.appendChild(buildMessageCell(value)));
87
Added:
return tableRow;
88
Added:
}));
89
Added:
}
90
Added:
91
Added:
function createChart(canvas, series, trendLabel, yBounds, timeframe) {
92
Added:
return new Chart(canvas, {
93
Added:
type: "line",
94
Added:
data: {
95
Added:
labels: series.labels,
96
Added:
datasets: [
97
Added:
{
98
Added:
label: "Raw lower percentile",
99
Added:
data: series.lower,
100
Added:
borderColor: TRANSPARENT_CHART_COLOR,
101
Added:
backgroundColor: TRANSPARENT_CHART_COLOR,
102
Added:
pointRadius: 0,
103
Added:
pointHoverRadius: 0
104
Added:
},
105
Added:
{
106
Added:
label: "Raw variability (10th–90th percentile)",
107
Added:
data: series.upper,
108
Added:
borderColor: TRANSPARENT_CHART_COLOR,
109
Added:
backgroundColor: RAW_BAND_COLOR,
110
Added:
pointRadius: 0,
111
Added:
pointHoverRadius: 0,
112
Added:
fill: "-1"
113
Added:
},
114
Added:
{
115
Added:
label: trendLabel,
116
Added:
data: series.trend,
117
Added:
tension: 0.2,
118
Added:
borderColor: PRIMARY_CHART_COLOR,
119
Added:
backgroundColor: PRIMARY_CHART_COLOR,
120
Added:
pointBackgroundColor: PRIMARY_CHART_COLOR,
121
Added:
pointRadius: 0,
122
Added:
pointHoverRadius: 0
123
Added:
}
124
Added:
]
125
Added:
},
126
Added:
options: {
127
Added:
responsive: true,
128
Added:
maintainAspectRatio: false,
129
Added:
animation: false,
130
Added:
scales: {
131
Added:
x: { ticks: { maxTicksLimit: TIMEFRAMES[timeframe].ticks } },
132
Added:
y: { beginAtZero: false, ...yBounds }
133
Added:
},
134
Added:
plugins: {
135
Added:
legend: { display: true, labels: { filter: item => item.datasetIndex !== 0 } },
136
Added:
tooltip: { mode: "index", intersect: false }
137
Added:
}
138
Added:
}
139
Added:
});
140
Added:
}
141
Added:
142
Added:
export function createProbeCharts(timeframe) {
143
Added:
const charts = new Map();
144
Added:
145
Added:
async function loadProbe(probe) {
146
Added:
const canvas = chartElement(probe);
147
Added:
if (!probe || !canvas) return;
148
Added:
149
Added:
const status = document.querySelector(`[data-status="${probe}"]`);
150
Added:
151
Added:
try {
152
Added:
const data = await fetchProbeData(probe, timeframe.selected(), timeframe.range());
153
Added:
const series = chartSeries(data.series, timeframe.selected());
154
Added:
const latest = [...data.readings]
155
Added:
.reverse()
156
Added:
.find(row => row.value !== null && Number.isFinite(Number(row.value)));
157
Added:
const unit = [...data.series].reverse().find(row => row.unit)?.unit || "";
158
Added:
const yBounds = yAxisBounds(probe, [...series.lower, ...series.upper, ...series.trend]);
159
Added:
const trendLabel = `${probe} ${unit ? `(${unit})` : ""} trend`;
160
Added:
161
Added:
if (!charts.has(probe)) {
162
Added:
charts.set(probe, createChart(canvas, series, trendLabel, yBounds, timeframe.selected()));
163
Added:
} else {
164
Added:
const chart = charts.get(probe);
165
Added:
chart.data.labels = series.labels;
166
Added:
chart.data.datasets[0].data = series.lower;
167
Added:
chart.data.datasets[1].data = series.upper;
168
Added:
chart.data.datasets[2].data = series.trend;
169
Added:
chart.data.datasets[2].label = trendLabel;
170
Added:
chart.options.scales.x.ticks.maxTicksLimit = TIMEFRAMES[timeframe.selected()].ticks;
171
Added:
chart.options.scales.y.min = yBounds.min;
172
Added:
chart.options.scales.y.max = yBounds.max;
173
Added:
chart.update();
174
Added:
}
175
Added:
176
Added:
if (status) {
177
Added:
status.textContent = latest
178
Added:
? `Latest: ${latest.value} ${latest.unit || ""} at ${formatDateTime(latest.timestamp)} (${formatAge(ageMs(latest.timestamp))})`
179
Added:
: "No readings found for this probe yet.";
180
Added:
}
181
Added:
renderMessageTable(probe, data.readings);
182
Added:
} catch (error) {
183
Added:
if (status) status.textContent = `Could not load ${probe} readings: ${error.message}`;
184
Added:
renderMessageTable(probe, []);
185
Added:
}
186
Added:
}
187
Added:
188
Added:
function activateTab(probe) {
189
Added:
document.querySelectorAll(".tab-button").forEach(button => {
190
Added:
const active = button.dataset.probe === probe;
191
Added:
button.classList.toggle("is-active", active);
192
Added:
button.setAttribute("aria-selected", active ? "true" : "false");
193
Added:
});
194
Added:
document.querySelectorAll(".tab-panel").forEach(panel => {
195
Added:
panel.classList.toggle("is-active", panel.dataset.panel === probe);
196
Added:
});
197
Added:
loadProbe(probe);
198
Added:
}
199
Added:
200
Added:
function activeProbe() {
201
Added:
const activeButton = document.querySelector(".tab-button.is-active");
202
Added:
const activePanel = document.querySelector(".tab-panel.is-active") || document.querySelector(".tab-panel");
203
Added:
return activeButton ? activeButton.dataset.probe : activePanel?.dataset.panel || null;
204
Added:
}
205
Added:
206
Added:
function probeFromLocation() {
207
Added:
const pathMatch = window.location.pathname.match(/^\/probes\/([a-z]+)$/);
208
Added:
const hashMatch = window.location.hash.match(/^#probe-([a-z]+)$/);
209
Added:
const probe = pathMatch?.[1] || hashMatch?.[1] || null;
210
Added:
return chartElement(probe) ? probe : null;
211
Added:
}
212
Added:
213
Added:
return {
214
Added:
activeProbe,
215
Added:
activateTab,
216
Added:
displayedProbes,
217
Added:
loadProbe,
218
Added:
loadDisplayed: () => displayedProbes().forEach(loadProbe),
219
Added:
probeFromLocation,
220
Added:
probeFromHash: () => window.location.hash.match(/^#probe-([a-z]+)$/)?.[1] || null
221
Added:
};
222
Added:
}
roles/dashboard/public/js/dashboard/constants.js
@@ -0,0 +1,20 @@
1
Added:
export const OFFLINE_AFTER_MS = 2 * 60 * 1000;
2
Added:
export const DEFAULT_TIMEFRAME = "day";
3
Added:
export const TIMEFRAMES = {
4
Added:
hour: { ms: 60 * 60 * 1000, ticks: 6 },
5
Added:
day: { ms: 24 * 60 * 60 * 1000, ticks: 8 },
6
Added:
week: { ms: 7 * 24 * 60 * 60 * 1000, ticks: 7 },
7
Added:
month: { ms: 30 * 24 * 60 * 60 * 1000, ticks: 10 },
8
Added:
year: { ms: 365 * 24 * 60 * 60 * 1000, ticks: 12 }
9
Added:
};
10
Added:
export const MIN_Y_SPAN = { ph: 2, do: 5, orp: 200, ec: 500 };
11
Added:
export const READING_RANGES = {
12
Added:
ph: { min: 5.5, goodMin: 6.4, goodMax: 7.2, max: 8.5, precision: 2 },
13
Added:
do: { min: 0, goodMin: 5.5, goodMax: 10, max: 14, precision: 2 },
14
Added:
orp: { min: 100, goodMin: 250, goodMax: 400, max: 500, precision: 0 },
15
Added:
ec: { min: 0, goodMin: 300, goodMax: 1500, max: 2500, precision: 0 }
16
Added:
};
17
Added:
export const MAX_TIMEFRAME_RANGE = 24;
18
Added:
export const PRIMARY_CHART_COLOR = "#276749";
19
Added:
export const RAW_BAND_COLOR = "rgba(39, 103, 73, 0.18)";
20
Added:
export const TRANSPARENT_CHART_COLOR = "rgba(39, 103, 73, 0)";
roles/dashboard/public/js/dashboard/format.js
@@ -0,0 +1,55 @@
1
Added:
export function formatDateTime(timestamp) {
2
Added:
return new Date(timestamp).toLocaleString([], {
3
Added:
dateStyle: "short",
4
Added:
timeStyle: "medium"
5
Added:
});
6
Added:
}
7
Added:
8
Added:
export function ageMs(timestamp) {
9
Added:
const value = new Date(timestamp).getTime();
10
Added:
return Number.isFinite(value) ? Date.now() - value : null;
11
Added:
}
12
Added:
13
Added:
export function formatAge(ms) {
14
Added:
if (ms === null || ms < 0) {
15
Added:
return "Unknown";
16
Added:
}
17
Added:
18
Added:
const seconds = Math.floor(ms / 1000);
19
Added:
if (seconds < 60) return `${seconds}s ago`;
20
Added:
21
Added:
const minutes = Math.floor(seconds / 60);
22
Added:
if (minutes < 60) return `${minutes}m ago`;
23
Added:
24
Added:
const hours = Math.floor(minutes / 60);
25
Added:
if (hours < 48) return `${hours}h ago`;
26
Added:
27
Added:
return `${Math.floor(hours / 24)}d ago`;
28
Added:
}
29
Added:
30
Added:
export function replaceChildren(parent, children) {
31
Added:
parent.textContent = "";
32
Added:
children.forEach(child => parent.appendChild(child));
33
Added:
}
34
Added:
35
Added:
export function formatChartTime(timestamp, timeframe) {
36
Added:
const date = new Date(timestamp);
37
Added:
38
Added:
if (timeframe === "hour") {
39
Added:
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
40
Added:
}
41
Added:
42
Added:
if (timeframe === "day") {
43
Added:
return date.toLocaleTimeString([], { hour: "2-digit" });
44
Added:
}
45
Added:
46
Added:
if (timeframe === "week") {
47
Added:
return date.toLocaleString([], { weekday: "short" });
48
Added:
}
49
Added:
50
Added:
if (timeframe === "month") {
51
Added:
return date.toLocaleDateString([], { month: "short", day: "numeric" });
52
Added:
}
53
Added:
54
Added:
return date.toLocaleDateString([], { month: "short" });
55
Added:
}
roles/dashboard/public/js/dashboard/graph-controls.js
@@ -0,0 +1,58 @@
1
Added:
import { MAX_TIMEFRAME_RANGE, TIMEFRAMES } from "./constants.js";
2
Added:
import { timeframeRangeLabel } from "./timeframe.js";
3
Added:
4
Added:
function updateRawDownloadLinks(timeframe) {
5
Added:
document.querySelectorAll("[data-raw-download]").forEach(link => {
6
Added:
const params = new URLSearchParams({
7
Added:
probe: link.dataset.probe,
8
Added:
timeframe: timeframe.selected(),
9
Added:
range: String(timeframe.range())
10
Added:
});
11
Added:
link.href = `/downloads/readings.csv?${params}`;
12
Added:
});
13
Added:
}
14
Added:
15
Added:
function updateControls(timeframe) {
16
Added:
document.querySelectorAll("[data-timeframe-option]").forEach(option => {
17
Added:
const value = option.dataset.timeframeOption;
18
Added:
const range = timeframe.ranges[value] || 1;
19
Added:
const expanded = value === timeframe.expanded();
20
Added:
const button = option.querySelector(".timeframe-button");
21
Added:
22
Added:
option.classList.toggle("is-active", value === timeframe.selected());
23
Added:
option.classList.toggle("is-expanded", expanded);
24
Added:
button.classList.toggle("is-active", value === timeframe.selected());
25
Added:
button.textContent = expanded ? timeframeRangeLabel(value, range) : button.dataset.timeframeLabel;
26
Added:
option.querySelector('[data-timeframe-adjustment="decrease"]').disabled = range <= 1;
27
Added:
option.querySelector('[data-timeframe-adjustment="increase"]').disabled = range >= MAX_TIMEFRAME_RANGE;
28
Added:
});
29
Added:
30
Added:
document.querySelectorAll(".timeframe-button").forEach(button => {
31
Added:
button.classList.toggle("is-active", button.dataset.timeframe === timeframe.selected());
32
Added:
});
33
Added:
updateRawDownloadLinks(timeframe);
34
Added:
}
35
Added:
36
Added:
export function initialiseGraphControls(timeframe, refreshCharts) {
37
Added:
document.querySelectorAll(".timeframe-button").forEach(button => {
38
Added:
button.addEventListener("click", () => {
39
Added:
timeframe.select(button.dataset.timeframe, Boolean(button.closest("[data-timeframe-option]")));
40
Added:
updateControls(timeframe);
41
Added:
refreshCharts();
42
Added:
});
43
Added:
});
44
Added:
document.querySelectorAll("[data-timeframe-adjustment]").forEach(button => {
45
Added:
button.addEventListener("click", () => {
46
Added:
timeframe.adjust(button.dataset.timeframe, button.dataset.timeframeAdjustment === "increase" ? 1 : -1);
47
Added:
updateControls(timeframe);
48
Added:
refreshCharts();
49
Added:
});
50
Added:
});
51
Added:
document.querySelectorAll("[data-graph-controls-toggle]").forEach(toggle => {
52
Added:
toggle.addEventListener("click", () => {
53
Added:
toggle.setAttribute("aria-expanded", toggle.getAttribute("aria-expanded") === "true" ? "false" : "true");
54
Added:
});
55
Added:
});
56
Added:
57
Added:
updateControls(timeframe);
58
Added:
}
roles/dashboard/public/js/dashboard/quick-readings.js
@@ -0,0 +1,73 @@
1
Added:
import { fetchLatestReading } from "./api.js";
2
Added:
import { READING_RANGES } from "./constants.js";
3
Added:
import { ageMs, formatAge } from "./format.js";
4
Added:
5
Added:
function formatValue(value, range) {
6
Added:
return Number.isFinite(value) ? value.toFixed(range.precision) : "No reading";
7
Added:
}
8
Added:
9
Added:
function rangePercent(value, range) {
10
Added:
return Math.max(0, Math.min(100, ((value - range.min) / (range.max - range.min)) * 100));
11
Added:
}
12
Added:
13
Added:
function readingState(value, range) {
14
Added:
if (!Number.isFinite(value)) return "unknown";
15
Added:
if (value < range.goodMin) return "low";
16
Added:
if (value > range.goodMax) return "high";
17
Added:
return "good";
18
Added:
}
19
Added:
20
Added:
function readingStateLabel(state) {
21
Added:
return { good: "Good", low: "Too low", high: "Too high", unknown: "No data" }[state] || "No data";
22
Added:
}
23
Added:
24
Added:
function configureRange(item, range, unit) {
25
Added:
const goodStart = rangePercent(range.goodMin, range);
26
Added:
const goodEnd = rangePercent(range.goodMax, range);
27
Added:
28
Added:
item.style.setProperty("--warn-low", `${Math.max(0, goodStart - 8)}%`);
29
Added:
item.style.setProperty("--good-start", `${goodStart}%`);
30
Added:
item.style.setProperty("--good-end", `${goodEnd}%`);
31
Added:
item.style.setProperty("--warn-high", `${Math.min(100, goodEnd + 8)}%`);
32
Added:
item.querySelector("[data-reading-quick-min]").textContent = `${formatValue(range.min, range)} ${unit}`;
33
Added:
item.querySelector("[data-reading-quick-target]").textContent =
34
Added:
`${formatValue(range.goodMin, range)}-${formatValue(range.goodMax, range)} ${unit}`;
35
Added:
item.querySelector("[data-reading-quick-max]").textContent = `${formatValue(range.max, range)} ${unit}`;
36
Added:
}
37
Added:
38
Added:
function updateItem(item, reading) {
39
Added:
const probe = item.dataset.readingQuickProbe;
40
Added:
const range = READING_RANGES[probe];
41
Added:
const unit = item.dataset.readingQuickUnit || reading?.unit || "";
42
Added:
const label = item.dataset.readingQuickLabel || probe;
43
Added:
const value = Number(reading?.value);
44
Added:
const state = readingState(value, range);
45
Added:
const percent = Number.isFinite(value) ? rangePercent(value, range) : 50;
46
Added:
const timestamp = reading?.received_at || reading?.timestamp;
47
Added:
const age = timestamp ? formatAge(ageMs(timestamp)) : "unknown age";
48
Added:
49
Added:
configureRange(item, range, unit);
50
Added:
item.classList.remove("is-good", "is-low", "is-high", "is-unknown");
51
Added:
item.classList.add(`is-${state}`);
52
Added:
item.querySelector("[data-reading-quick-marker]").style.left = `${percent}%`;
53
Added:
const meter = item.querySelector(".reading-range");
54
Added:
meter.setAttribute("aria-valuenow", Math.round(percent).toString());
55
Added:
meter.setAttribute("aria-label", `${label} ${readingStateLabel(state)} at ${formatValue(value, range)} ${unit}`);
56
Added:
item.querySelector("[data-reading-quick-value]").textContent = Number.isFinite(value)
57
Added:
? `${formatValue(value, range)} ${unit} - ${readingStateLabel(state)} - ${age}`
58
Added:
: "No reading yet";
59
Added:
}
60
Added:
61
Added:
export async function loadQuickReadings() {
62
Added:
const items = Array.from(document.querySelectorAll("[data-reading-quick-probe]"));
63
Added:
if (!items.length) return;
64
Added:
65
Added:
const results = await Promise.allSettled(
66
Added:
items.map(item => fetchLatestReading(item.dataset.readingQuickProbe))
67
Added:
);
68
Added:
items.forEach((item, index) => {
69
Added:
if (READING_RANGES[item.dataset.readingQuickProbe]) {
70
Added:
updateItem(item, results[index].status === "fulfilled" ? results[index].value : null);
71
Added:
}
72
Added:
});
73
Added:
}
roles/dashboard/public/js/dashboard/status.js
@@ -0,0 +1,149 @@
1
Added:
import { fetchLatestStatus } from "./api.js";
2
Added:
import { OFFLINE_AFTER_MS } from "./constants.js";
3
Added:
import { ageMs, formatAge, formatDateTime } from "./format.js";
4
Added:
5
Added:
function statusLabel(state) {
6
Added:
return {
7
Added:
online: "Online",
8
Added:
healthy: "Healthy",
9
Added:
stale: "Stale",
10
Added:
"probe-error": "Probe unavailable",
11
Added:
offline: "Offline",
12
Added:
unreachable: "Unreachable",
13
Added:
unknown: "Unknown"
14
Added:
}[state] || "Unknown";
15
Added:
}
16
Added:
17
Added:
function stateForNodeStatus(nodeStatus) {
18
Added:
if (!nodeStatus) return "unreachable";
19
Added:
20
Added:
const timestamp = nodeStatus.received_at || nodeStatus.timestamp;
21
Added:
const age = ageMs(timestamp);
22
Added:
if (age === null || age > OFFLINE_AFTER_MS) return "unreachable";
23
Added:
if (nodeStatus.status === "ok") return "healthy";
24
Added:
if (nodeStatus.status === "probe_error") return "probe-error";
25
Added:
26
Added:
return "unknown";
27
Added:
}
28
Added:
29
Added:
function setStatusPill(element, state, text = statusLabel(state)) {
30
Added:
element.classList.remove(
31
Added:
"is-online", "is-healthy", "is-stale", "is-probe-error",
32
Added:
"is-offline", "is-unreachable", "is-unknown"
33
Added:
);
34
Added:
element.classList.add(`is-${state}`);
35
Added:
element.querySelector("[data-status-text]").textContent = text;
36
Added:
}
37
Added:
38
Added:
function statusTitle(nodeStatus) {
39
Added:
if (!nodeStatus) return "No recent node status message has been stored.";
40
Added:
41
Added:
const detail = nodeStatus.error || nodeStatus.message || "";
42
Added:
const timestamp = nodeStatus.received_at || nodeStatus.timestamp;
43
Added:
const when = timestamp
44
Added:
? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})`
45
Added:
: "unknown time";
46
Added:
47
Added:
return [detail, `Status received at ${when}`].filter(Boolean).join(" ");
48
Added:
}
49
Added:
50
Added:
async function loadOverview() {
51
Added:
const overview = document.querySelector("[data-overview-status]");
52
Added:
if (!overview) return;
53
Added:
54
Added:
const rows = Array.from(document.querySelectorAll("[data-sensor-status]"));
55
Added:
const results = await Promise.allSettled(
56
Added:
rows.map(row => fetchLatestStatus(row.dataset.sensorStatus))
57
Added:
);
58
Added:
const items = rows.map((row, index) => {
59
Added:
const result = results[index];
60
Added:
const status = result.status === "fulfilled" ? result.value : null;
61
Added:
62
Added:
return { status, state: stateForNodeStatus(status) };
63
Added:
});
64
Added:
const unhealthy = items.filter(item => item.state !== "healthy");
65
Added:
66
Added:
if (!items.length) {
67
Added:
setStatusPill(overview, "unknown", "No sensors configured");
68
Added:
} else if (!unhealthy.length) {
69
Added:
setStatusPill(overview, "healthy", "All sensors healthy");
70
Added:
overview.title = "Every sensor node has sent a recent healthy status.";
71
Added:
} else {
72
Added:
const state = unhealthy.some(item => item.state === "unreachable")
73
Added:
? "unreachable"
74
Added:
: "probe-error";
75
Added:
const label = unhealthy.length === 1 ? "sensor needs" : "sensors need";
76
Added:
77
Added:
setStatusPill(overview, state, `${unhealthy.length} ${label} attention`);
78
Added:
overview.title = unhealthy.map(item => statusTitle(item.status)).join(" ");
79
Added:
}
80
Added:
}
81
Added:
82
Added:
function setHeaderStatus(state, text) {
83
Added:
const element = document.querySelector("[data-header-status]");
84
Added:
if (!element) return;
85
Added:
86
Added:
element.classList.remove("is-healthy", "is-warning", "is-unknown");
87
Added:
element.classList.add(`is-${state}`);
88
Added:
element.setAttribute("aria-label", text);
89
Added:
element.title = text;
90
Added:
}
91
Added:
92
Added:
async function loadHeaderStatus() {
93
Added:
if (!document.querySelector("[data-header-status]")) return;
94
Added:
95
Added:
const rows = Array.from(document.querySelectorAll("[data-header-status-item]"));
96
Added:
if (!rows.length) {
97
Added:
setHeaderStatus("unknown", "No DAQ status items configured");
98
Added:
return;
99
Added:
}
100
Added:
101
Added:
const results = await Promise.allSettled(
102
Added:
rows.map(row => fetchLatestStatus(row.dataset.headerStatusItem))
103
Added:
);
104
Added:
const warnings = rows.filter((row, index) =>
105
Added:
stateForNodeStatus(results[index].status === "fulfilled" ? results[index].value : null) !== "healthy"
106
Added:
);
107
Added:
108
Added:
if (!warnings.length) {
109
Added:
setHeaderStatus("healthy", "All DAQ status checks are healthy");
110
Added:
return;
111
Added:
}
112
Added:
113
Added:
const label = warnings.length === 1 ? "status check needs" : "status checks need";
114
Added:
setHeaderStatus("warning", `${warnings.length} ${label} attention`);
115
Added:
}
116
Added:
117
Added:
function updateStatusPageRow(row, status) {
118
Added:
const state = stateForNodeStatus(status);
119
Added:
const timestamp = status?.received_at || status?.timestamp;
120
Added:
const stateElement = row.querySelector("[data-status-page-state]");
121
Added:
122
Added:
stateElement.className = `status-pill is-${state}`;
123
Added:
stateElement.textContent = statusLabel(state);
124
Added:
row.querySelector("[data-status-page-node]").textContent = status?.node || "Unknown";
125
Added:
row.querySelector("[data-status-page-last-seen]").textContent = timestamp
126
Added:
? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})`
127
Added:
: "Never";
128
Added:
row.querySelector("[data-status-page-message]").textContent = status
129
Added:
? status.error || status.message || "No message"
130
Added:
: "No recent status received";
131
Added:
}
132
Added:
133
Added:
async function loadStatusPage() {
134
Added:
const rows = Array.from(document.querySelectorAll("[data-status-page-item]"));
135
Added:
if (!rows.length) return;
136
Added:
137
Added:
const results = await Promise.allSettled(
138
Added:
rows.map(row => fetchLatestStatus(row.dataset.statusPageItem))
139
Added:
);
140
Added:
rows.forEach((row, index) => {
141
Added:
updateStatusPageRow(row, results[index].status === "fulfilled" ? results[index].value : null);
142
Added:
});
143
Added:
}
144
Added:
145
Added:
export function createStatusLoader() {
146
Added:
return async function loadStatuses() {
147
Added:
await Promise.all([loadOverview(), loadHeaderStatus(), loadStatusPage()]);
148
Added:
};
149
Added:
}
roles/dashboard/public/js/dashboard/timeframe.js
@@ -0,0 +1,43 @@
1
Added:
import { DEFAULT_TIMEFRAME, MAX_TIMEFRAME_RANGE, TIMEFRAMES } from "./constants.js";
2
Added:
3
Added:
export function createTimeframeState() {
4
Added:
let active = DEFAULT_TIMEFRAME;
5
Added:
let expanded = null;
6
Added:
const ranges = Object.fromEntries(Object.keys(TIMEFRAMES).map(timeframe => [timeframe, 1]));
7
Added:
8
Added:
function selected() {
9
Added:
return TIMEFRAMES[active] ? active : DEFAULT_TIMEFRAME;
10
Added:
}
11
Added:
12
Added:
return {
13
Added:
selected,
14
Added:
range: () => ranges[selected()] || 1,
15
Added:
expanded: () => expanded,
16
Added:
ranges,
17
Added:
select(timeframe, expand = false) {
18
Added:
const next = TIMEFRAMES[timeframe] ? timeframe : DEFAULT_TIMEFRAME;
19
Added:
20
Added:
if (next === active) {
21
Added:
ranges[next] = 1;
22
Added:
}
23
Added:
24
Added:
active = next;
25
Added:
expanded = expand ? active : null;
26
Added:
},
27
Added:
adjust(timeframe, adjustment) {
28
Added:
if (!TIMEFRAMES[timeframe]) return;
29
Added:
30
Added:
active = timeframe;
31
Added:
expanded = timeframe;
32
Added:
ranges[timeframe] = Math.min(
33
Added:
MAX_TIMEFRAME_RANGE,
34
Added:
Math.max(1, (ranges[timeframe] || 1) + adjustment)
35
Added:
);
36
Added:
}
37
Added:
};
38
Added:
}
39
Added:
40
Added:
export function timeframeRangeLabel(timeframe, range) {
41
Added:
const unit = { hour: "hour", day: "day", week: "week", month: "month", year: "year" }[timeframe] || "year";
42
Added:
return `${range} ${unit}${range === 1 ? "" : "s"}`;
43
Added:
}
roles/dashboard/templates/dashboard/downloads.html.ep
@@ -47,5 +47,5 @@
47
47
</div>
48
48
</div>
49
49
50
Removed:
<script src="/js/dashboard.js"></script>
50
Added:
<script type="module" src="/js/dashboard.js"></script>
51
51
<script src="/js/downloads.js"></script>
roles/dashboard/templates/dashboard/graph.html.ep
@@ -32,4 +32,4 @@
32
32
</section>
33
33
34
34
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
35
Removed:
<script src="/js/dashboard.js"></script>
35
Added:
<script type="module" src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/index.html.ep
@@ -42,4 +42,4 @@
42
42
</section>
43
43
44
44
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
45
Removed:
<script src="/js/dashboard.js"></script>
45
Added:
<script type="module" src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/probes.html.ep
@@ -10,4 +10,4 @@
10
10
</div>
11
11
12
12
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
13
Removed:
<script src="/js/dashboard.js"></script>
13
Added:
<script type="module" src="/js/dashboard.js"></script>
roles/dashboard/templates/dashboard/status.html.ep
@@ -39,4 +39,4 @@
39
39
</div>
40
40
</div>
41
41
42
Removed:
<script src="/js/dashboard.js"></script>
42
Added:
<script type="module" src="/js/dashboard.js"></script>