Modularize dashboard JavaScript

Commit
9da858382e57a9702b34d9d8e832805ee5dcde3e
Author
Codex <codex@openai.com>
Author date
Committer
Codex <codex@openai.com>
Committer date
Changed files
roles/dashboard/public/js/dashboard.js
index 9899c671..b1350044 100644..100644
@@ -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
index 00000000..c3629f0d 000000..100644
@@ -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
index 00000000..af43de40 000000..100644
@@ -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
index 00000000..06a22e6c 000000..100644
@@ -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
index 00000000..c141df6d 000000..100644
@@ -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
index 00000000..776075c9 000000..100644
@@ -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
index 00000000..a77a0052 000000..100644
@@ -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
index 00000000..0ae19c97 000000..100644
@@ -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
index 00000000..f9090373 000000..100644
@@ -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
index ba8ae5e5..70068bdd 100644..100644
@@ -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
index ffd78005..4abbe751 100644..100644
@@ -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
index ef9425ad..30e7f2fa 100644..100644
@@ -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
index fb7ec443..2c209b21 100644..100644
@@ -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
index a8edf2f8..1a9de88a 100644..100644
@@ -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>