[Perl] DAQ system for the FAPG.
Update dashboard with probe status table.
Changed files
roles/dashboard/lib/dashboard.pm
@@ -36,21 +36,25 @@
36
36
key => 'ph',
37
37
label => 'pH',
38
38
unit => 'pH',
39
Added:
node => 'Pi Zero 01',
39
40
},
40
41
{
41
42
key => 'do',
42
43
label => 'Dissolved Oxygen',
43
44
unit => 'mg/L',
45
Added:
node => 'Pi Zero 03',
44
46
},
45
47
{
46
48
key => 'orp',
47
49
label => 'Redox / ORP',
48
50
unit => 'mV',
51
Added:
node => 'Pi Zero 04',
49
52
},
50
53
{
51
54
key => 'ec',
52
55
label => 'Electrical Conductivity',
53
56
unit => 'µS/cm',
57
Added:
node => 'Pi Zero 02',
54
58
},
55
59
];
56
60
}
roles/dashboard/public/css/app.css
@@ -8,6 +8,12 @@
8
8
--border: #d8e2dc;
9
9
--accent: #276749;
10
10
--accent-soft: #e4f3ea;
11
Added:
--danger: #b42318;
12
Added:
--danger-soft: #fee4e2;
13
Added:
--warn: #b54708;
14
Added:
--warn-soft: #fff4d6;
15
Added:
--unknown: #475467;
16
Added:
--unknown-soft: #eef2f6;
11
17
}
12
18
13
19
* {
@@ -40,10 +46,104 @@
40
46
color: var(--muted);
41
47
}
42
48
49
Added:
.status-band {
50
Added:
margin-bottom: 1.25rem;
51
Added:
}
52
Added:
53
Added:
.section-heading {
54
Added:
display: flex;
55
Added:
justify-content: space-between;
56
Added:
gap: 1rem;
57
Added:
align-items: end;
58
Added:
margin-bottom: 0.75rem;
59
Added:
}
60
Added:
61
Added:
.section-heading h2 {
62
Added:
margin: 0;
63
Added:
font-size: 1.15rem;
64
Added:
}
65
Added:
66
Added:
.section-heading p {
67
Added:
margin: 0;
68
Added:
color: var(--muted);
69
Added:
font-size: 0.95rem;
70
Added:
}
71
Added:
72
Added:
.table-wrap {
73
Added:
overflow-x: auto;
74
Added:
border: 1px solid var(--border);
75
Added:
border-radius: 0.5rem;
76
Added:
background: var(--card);
77
Added:
}
78
Added:
79
Added:
.status-table {
80
Added:
width: 100%;
81
Added:
border-collapse: collapse;
82
Added:
min-width: 760px;
83
Added:
}
84
Added:
85
Added:
.status-table th,
86
Added:
.status-table td {
87
Added:
padding: 0.8rem 0.9rem;
88
Added:
border-bottom: 1px solid var(--border);
89
Added:
text-align: left;
90
Added:
vertical-align: middle;
91
Added:
}
92
Added:
93
Added:
.status-table thead th {
94
Added:
background: #fafcfb;
95
Added:
color: var(--muted);
96
Added:
font-size: 0.82rem;
97
Added:
font-weight: 700;
98
Added:
text-transform: uppercase;
99
Added:
}
100
Added:
101
Added:
.status-table tbody tr:last-child th,
102
Added:
.status-table tbody tr:last-child td {
103
Added:
border-bottom: 0;
104
Added:
}
105
Added:
106
Added:
code {
107
Added:
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
108
Added:
font-size: 0.9em;
109
Added:
}
110
Added:
111
Added:
.status-pill {
112
Added:
display: inline-flex;
113
Added:
align-items: center;
114
Added:
justify-content: center;
115
Added:
min-width: 5.75rem;
116
Added:
border-radius: 999px;
117
Added:
padding: 0.25rem 0.65rem;
118
Added:
font-size: 0.82rem;
119
Added:
font-weight: 700;
120
Added:
white-space: nowrap;
121
Added:
}
122
Added:
123
Added:
.status-pill.is-online {
124
Added:
background: var(--accent-soft);
125
Added:
color: var(--accent);
126
Added:
}
127
Added:
128
Added:
.status-pill.is-stale {
129
Added:
background: var(--warn-soft);
130
Added:
color: var(--warn);
131
Added:
}
132
Added:
133
Added:
.status-pill.is-offline {
134
Added:
background: var(--danger-soft);
135
Added:
color: var(--danger);
136
Added:
}
137
Added:
138
Added:
.status-pill.is-unknown {
139
Added:
background: var(--unknown-soft);
140
Added:
color: var(--unknown);
141
Added:
}
142
Added:
43
143
.dashboard-card {
44
144
background: var(--card);
45
145
border: 1px solid var(--border);
46
Removed:
border-radius: 1rem;
146
Added:
border-radius: 0.5rem;
47
147
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
48
148
overflow: hidden;
49
149
}
@@ -125,6 +225,10 @@
125
225
}
126
226
127
227
.panel-heading {
228
Added:
display: block;
229
Added:
}
230
Added:
231
Added:
.section-heading {
128
232
display: block;
129
233
}
130
234
roles/dashboard/public/js/dashboard.js
@@ -1,5 +1,11 @@
1
1
const charts = new Map();
2
Added:
const ONLINE_AFTER_MS = 2 * 60 * 1000;
3
Added:
const OFFLINE_AFTER_MS = 15 * 60 * 1000;
2
4
5
Added:
function probeRows() {
6
Added:
return Array.from(document.querySelectorAll("[data-latest-probe]"));
7
Added:
}
8
Added:
3
9
function activeProbe() {
4
10
return document.querySelector(".tab-button.is-active").dataset.probe;
5
11
}
@@ -21,6 +27,129 @@
21
27
});
22
28
}
23
29
30
Added:
function formatDateTime(timestamp) {
31
Added:
return new Date(timestamp).toLocaleString([], {
32
Added:
dateStyle: "short",
33
Added:
timeStyle: "medium"
34
Added:
});
35
Added:
}
36
Added:
37
Added:
function ageMs(timestamp) {
38
Added:
const value = new Date(timestamp).getTime();
39
Added:
return Number.isFinite(value) ? Date.now() - value : null;
40
Added:
}
41
Added:
42
Added:
function formatAge(ms) {
43
Added:
if (ms === null || ms < 0) {
44
Added:
return "Unknown";
45
Added:
}
46
Added:
47
Added:
const seconds = Math.floor(ms / 1000);
48
Added:
49
Added:
if (seconds < 60) {
50
Added:
return `${seconds}s ago`;
51
Added:
}
52
Added:
53
Added:
const minutes = Math.floor(seconds / 60);
54
Added:
55
Added:
if (minutes < 60) {
56
Added:
return `${minutes}m ago`;
57
Added:
}
58
Added:
59
Added:
const hours = Math.floor(minutes / 60);
60
Added:
61
Added:
if (hours < 48) {
62
Added:
return `${hours}h ago`;
63
Added:
}
64
Added:
65
Added:
const days = Math.floor(hours / 24);
66
Added:
return `${days}d ago`;
67
Added:
}
68
Added:
69
Added:
function stateForAge(ms) {
70
Added:
if (ms === null) {
71
Added:
return "unknown";
72
Added:
}
73
Added:
74
Added:
if (ms <= ONLINE_AFTER_MS) {
75
Added:
return "online";
76
Added:
}
77
Added:
78
Added:
if (ms <= OFFLINE_AFTER_MS) {
79
Added:
return "stale";
80
Added:
}
81
Added:
82
Added:
return "offline";
83
Added:
}
84
Added:
85
Added:
function statusLabel(state) {
86
Added:
return {
87
Added:
online: "Online",
88
Added:
stale: "Stale",
89
Added:
offline: "Offline",
90
Added:
unknown: "Unknown"
91
Added:
}[state] || "Unknown";
92
Added:
}
93
Added:
94
Added:
function setStatusPill(element, state) {
95
Added:
element.classList.remove("is-online", "is-stale", "is-offline", "is-unknown");
96
Added:
element.classList.add(`is-${state}`);
97
Added:
element.textContent = statusLabel(state);
98
Added:
}
99
Added:
100
Added:
async function fetchLatestReading(probe) {
101
Added:
const response = await fetch(`/api/readings/${probe}?limit=1`);
102
Added:
103
Added:
if (!response.ok) {
104
Added:
throw new Error(`HTTP ${response.status}`);
105
Added:
}
106
Added:
107
Added:
const payload = await response.json();
108
Added:
return (payload.readings || [])[0] || null;
109
Added:
}
110
Added:
111
Added:
function updateLatestRow(row, reading) {
112
Added:
const stateElement = row.querySelector("[data-latest-state]");
113
Added:
114
Added:
if (!reading) {
115
Added:
row.querySelector("[data-latest-value]").textContent = "No reading";
116
Added:
row.querySelector("[data-latest-time]").textContent = "Never";
117
Added:
row.querySelector("[data-latest-age]").textContent = "Unknown";
118
Added:
setStatusPill(stateElement, "unknown");
119
Added:
return;
120
Added:
}
121
Added:
122
Added:
const age = ageMs(reading.timestamp);
123
Added:
const state = stateForAge(age);
124
Added:
const unit = reading.unit || "";
125
Added:
126
Added:
row.querySelector("[data-latest-value]").textContent =
127
Added:
`${reading.value} ${unit}`.trim();
128
Added:
row.querySelector("[data-latest-time]").textContent =
129
Added:
formatDateTime(reading.timestamp);
130
Added:
row.querySelector("[data-latest-age]").textContent = formatAge(age);
131
Added:
setStatusPill(stateElement, state);
132
Added:
}
133
Added:
134
Added:
async function loadOverview() {
135
Added:
const rows = probeRows();
136
Added:
const results = await Promise.allSettled(
137
Added:
rows.map(row => fetchLatestReading(row.dataset.latestProbe))
138
Added:
);
139
Added:
140
Added:
rows.forEach((row, index) => {
141
Added:
const result = results[index];
142
Added:
143
Added:
if (result.status !== "fulfilled") {
144
Added:
updateLatestRow(row, null);
145
Added:
return;
146
Added:
}
147
Added:
148
Added:
const reading = result.value;
149
Added:
updateLatestRow(row, reading);
150
Added:
});
151
Added:
}
152
Added:
24
153
async function loadProbe(probe) {
25
154
const status = statusElement(probe);
26
155
@@ -90,9 +219,10 @@
90
219
91
220
if (rows.length) {
92
221
const latest = rows[rows.length - 1];
222
Added:
const age = ageMs(latest.timestamp);
93
223
94
224
status.textContent =
95
Removed:
`Latest: ${latest.value} ${latest.unit || ""} from ${latest.node} at ${latest.timestamp}`;
225
Added:
`Latest: ${latest.value} ${latest.unit || ""} from ${latest.node} at ${formatDateTime(latest.timestamp)} (${formatAge(age)})`;
96
226
} else {
97
227
status.textContent = "No readings found for this probe yet.";
98
228
}
@@ -123,7 +253,9 @@
123
253
});
124
254
125
255
loadProbe(activeProbe());
256
Added:
loadOverview();
126
257
127
258
setInterval(() => {
128
259
loadProbe(activeProbe());
260
Added:
loadOverview();
129
261
}, 5_000);
roles/dashboard/t/basic.t
@@ -37,7 +37,10 @@
37
37
$t->get_ok('/')
38
38
->status_is(200)
39
39
->content_like(qr/FAPG DAQ Dashboard/)
40
Removed:
->content_like(qr/Dissolved Oxygen/);
40
Added:
->content_like(qr/Dissolved Oxygen/)
41
Added:
->content_like(qr/Latest Probe Readings/)
42
Added:
->content_like(qr/Pi Zero 01/)
43
Added:
->content_unlike(qr/Connection Status/);
41
44
42
45
$t->get_ok('/api/readings/ph')
43
46
->status_is(200)
roles/dashboard/templates/dashboard/index.html.ep
@@ -8,6 +8,43 @@
8
8
<p>Live aquaponic sensor readings from the DAQ network.</p>
9
9
</section>
10
10
11
Added:
<section class="status-band" aria-label="DAQ status overview">
12
Added:
<div class="section-heading">
13
Added:
<h2>Latest Probe Readings</h2>
14
Added:
<p>Online: under 2 minutes. Stale: 2 to 15 minutes. Offline: over 15 minutes.</p>
15
Added:
</div>
16
Added:
17
Added:
<div class="table-wrap">
18
Added:
<table class="status-table">
19
Added:
<thead>
20
Added:
<tr>
21
Added:
<th scope="col">Probe</th>
22
Added:
<th scope="col">Pi Zero node</th>
23
Added:
<th scope="col">Latest value</th>
24
Added:
<th scope="col">Last reading</th>
25
Added:
<th scope="col">Age</th>
26
Added:
<th scope="col">Status</th>
27
Added:
</tr>
28
Added:
</thead>
29
Added:
<tbody>
30
Added:
% for my $probe ($probes->@*) {
31
Added:
<tr
32
Added:
data-latest-probe="<%= $probe->{key} %>"
33
Added:
data-probe-label="<%= $probe->{label} %>"
34
Added:
data-probe-node="<%= $probe->{node} %>">
35
Added:
<th scope="row"><%= $probe->{label} %></th>
36
Added:
<td><code><%= $probe->{node} %></code></td>
37
Added:
<td data-latest-value>Waiting</td>
38
Added:
<td data-latest-time>Waiting for data</td>
39
Added:
<td data-latest-age>Unknown</td>
40
Added:
<td><span class="status-pill is-unknown" data-latest-state>Unknown</span></td>
41
Added:
</tr>
42
Added:
% }
43
Added:
</tbody>
44
Added:
</table>
45
Added:
</div>
46
Added:
</section>
47
Added:
11
48
<section class="dashboard-card">
12
49
<div class="tabs" role="tablist" aria-label="DAQ readings">
13
50
% for my $i (0 .. $probes->$#*) {