[Perl] DAQ system for the FAPG.
Redesign equipment status section.
Changed files
roles/dashboard/lib/dashboard/Controller/Dashboard.pm
@@ -5,9 +5,8 @@
5
5
6
6
sub index ($self) {
7
7
$self->render(
8
Removed:
template => 'dashboard/index',
9
Removed:
probes => $self->probes,
10
Removed:
status_items => $self->status_items,
8
Added:
template => 'dashboard/index',
9
Added:
probes => $self->probes,
11
10
);
12
11
}
13
12
roles/dashboard/public/css/app.css
@@ -69,13 +69,10 @@
69
69
font-size: 0.95rem;
70
70
}
71
71
72
Removed:
.status-pill-list {
73
Removed:
display: flex;
74
Removed:
flex-wrap: wrap;
75
Removed:
gap: 0.65rem;
76
Removed:
padding: 0;
77
Removed:
margin: 0;
78
Removed:
list-style: none;
72
Added:
.status-overview {
73
Added:
display: grid;
74
Added:
gap: 0.75rem;
75
Added:
justify-items: start;
79
76
}
80
77
81
78
.status-pill {
@@ -91,16 +88,10 @@
91
88
white-space: nowrap;
92
89
}
93
90
94
Removed:
.status-pill-probe {
95
Removed:
color: currentColor;
91
Added:
.status-summary {
92
Added:
min-width: 12rem;
96
93
}
97
94
98
Removed:
.status-pill-probe::after {
99
Removed:
content: "/";
100
Removed:
margin-left: 0.5rem;
101
Removed:
opacity: 0.45;
102
Removed:
}
103
Removed:
104
95
.status-pill.is-online {
105
96
background: var(--accent-soft);
106
97
color: var(--accent);
@@ -136,6 +127,152 @@
136
127
color: var(--unknown);
137
128
}
138
129
130
Added:
.status-detail {
131
Added:
width: 100%;
132
Added:
max-width: 46rem;
133
Added:
overflow-x: auto;
134
Added:
-webkit-overflow-scrolling: touch;
135
Added:
border: 1px solid var(--border);
136
Added:
border-radius: 0.5rem;
137
Added:
background: var(--card);
138
Added:
}
139
Added:
140
Added:
.status-detail.is-hidden {
141
Added:
display: none;
142
Added:
}
143
Added:
144
Added:
.status-detail-table {
145
Added:
width: 100%;
146
Added:
min-width: 34rem;
147
Added:
border-collapse: collapse;
148
Added:
}
149
Added:
150
Added:
.status-detail-table th,
151
Added:
.status-detail-table td {
152
Added:
padding: 0.65rem 0.8rem;
153
Added:
border-bottom: 1px solid var(--border);
154
Added:
text-align: left;
155
Added:
vertical-align: middle;
156
Added:
}
157
Added:
158
Added:
.status-detail-table thead th {
159
Added:
background: #fafcfb;
160
Added:
color: var(--muted);
161
Added:
font-size: 0.75rem;
162
Added:
font-weight: 700;
163
Added:
text-transform: uppercase;
164
Added:
}
165
Added:
166
Added:
.status-detail-table tbody tr:last-child td {
167
Added:
border-bottom: 0;
168
Added:
}
169
Added:
170
Added:
.reading-quick-status {
171
Added:
width: 100%;
172
Added:
margin-top: 0.35rem;
173
Added:
}
174
Added:
175
Added:
.reading-quick-status h2 {
176
Added:
margin: 0 0 0.7rem;
177
Added:
font-size: 1rem;
178
Added:
}
179
Added:
180
Added:
.reading-quick-grid {
181
Added:
display: grid;
182
Added:
grid-template-columns: repeat(4, minmax(0, 1fr));
183
Added:
gap: 0.75rem;
184
Added:
}
185
Added:
186
Added:
.reading-quick-item {
187
Added:
--warn-low: 27%;
188
Added:
--good-start: 35%;
189
Added:
--good-end: 65%;
190
Added:
--warn-high: 73%;
191
Added:
display: grid;
192
Added:
gap: 0.7rem;
193
Added:
min-width: 0;
194
Added:
border: 1px solid var(--border);
195
Added:
border-radius: 0.5rem;
196
Added:
padding: 0.85rem;
197
Added:
background: var(--card);
198
Added:
}
199
Added:
200
Added:
.reading-quick-header {
201
Added:
display: grid;
202
Added:
gap: 0.2rem;
203
Added:
min-height: 3.15rem;
204
Added:
}
205
Added:
206
Added:
.reading-quick-header h3 {
207
Added:
margin: 0;
208
Added:
font-size: 0.9rem;
209
Added:
}
210
Added:
211
Added:
.reading-quick-header p {
212
Added:
margin: 0;
213
Added:
color: var(--muted);
214
Added:
font-size: 0.82rem;
215
Added:
line-height: 1.3;
216
Added:
overflow-wrap: anywhere;
217
Added:
}
218
Added:
219
Added:
.reading-range {
220
Added:
position: relative;
221
Added:
height: 0.85rem;
222
Added:
border-radius: 999px;
223
Added:
background:
224
Added:
linear-gradient(
225
Added:
90deg,
226
Added:
var(--danger) 0%,
227
Added:
var(--warn) var(--warn-low),
228
Added:
var(--accent) var(--good-start),
229
Added:
var(--accent) var(--good-end),
230
Added:
var(--warn) var(--warn-high),
231
Added:
var(--danger) 100%
232
Added:
);
233
Added:
}
234
Added:
235
Added:
.reading-range-marker {
236
Added:
position: absolute;
237
Added:
top: 50%;
238
Added:
left: 50%;
239
Added:
width: 1rem;
240
Added:
height: 1rem;
241
Added:
border: 2px solid white;
242
Added:
border-radius: 50%;
243
Added:
background: var(--unknown);
244
Added:
box-shadow: 0 1px 4px rgba(23, 33, 27, 0.35);
245
Added:
transform: translate(-50%, -50%);
246
Added:
}
247
Added:
248
Added:
.reading-quick-item.is-good .reading-range-marker {
249
Added:
background: var(--accent);
250
Added:
}
251
Added:
252
Added:
.reading-quick-item.is-low .reading-range-marker,
253
Added:
.reading-quick-item.is-high .reading-range-marker {
254
Added:
background: var(--danger);
255
Added:
}
256
Added:
257
Added:
.reading-range-labels {
258
Added:
display: grid;
259
Added:
grid-template-columns: 1fr auto 1fr;
260
Added:
gap: 0.35rem;
261
Added:
color: var(--muted);
262
Added:
font-size: 0.72rem;
263
Added:
line-height: 1.25;
264
Added:
}
265
Added:
266
Added:
.reading-range-labels span:nth-child(2) {
267
Added:
color: var(--accent);
268
Added:
font-weight: 700;
269
Added:
text-align: center;
270
Added:
}
271
Added:
272
Added:
.reading-range-labels span:last-child {
273
Added:
text-align: right;
274
Added:
}
275
Added:
139
276
.dashboard-card {
140
277
background: var(--card);
141
278
border: 1px solid var(--border);
@@ -312,6 +449,12 @@
312
449
border-bottom: 0;
313
450
}
314
451
452
Added:
@media (max-width: 900px) {
453
Added:
.reading-quick-grid {
454
Added:
grid-template-columns: repeat(2, minmax(0, 1fr));
455
Added:
}
456
Added:
}
457
Added:
315
458
@media (max-width: 700px) {
316
459
.site-main {
317
460
width: min(100% - 1rem, 1100px);
@@ -326,12 +469,11 @@
326
469
font-size: 1.75rem;
327
470
}
328
471
329
Removed:
.status-pill-list {
330
Removed:
display: grid;
331
Removed:
grid-template-columns: 1fr 1fr;
472
Added:
.status-overview {
473
Added:
justify-items: stretch;
332
474
}
333
475
334
Removed:
.status-pill-list li {
476
Added:
.status-summary {
335
477
min-width: 0;
336
478
}
337
479
@@ -345,6 +487,18 @@
345
487
white-space: normal;
346
488
}
347
489
490
Added:
.status-detail-table {
491
Added:
min-width: 30rem;
492
Added:
}
493
Added:
494
Added:
.reading-quick-grid {
495
Added:
grid-template-columns: 1fr;
496
Added:
}
497
Added:
498
Added:
.reading-quick-header {
499
Added:
min-height: 0;
500
Added:
}
501
Added:
348
502
.tabs {
349
503
display: grid;
350
504
grid-template-columns: 1fr 1fr;
@@ -423,10 +577,6 @@
423
577
}
424
578
425
579
@media (max-width: 380px) {
426
Removed:
.status-pill-list {
427
Removed:
grid-template-columns: 1fr;
428
Removed:
}
429
Removed:
430
580
.timeframe-button {
431
581
min-width: 0;
432
582
padding-inline: 0.5rem;
roles/dashboard/public/js/dashboard.js
@@ -25,14 +25,48 @@
25
25
orp: 200,
26
26
ec: 500
27
27
};
28
Added:
const READING_RANGES = {
29
Added:
ph: {
30
Added:
min: 5.5,
31
Added:
goodMin: 6.4,
32
Added:
goodMax: 7.2,
33
Added:
max: 8.5,
34
Added:
precision: 2
35
Added:
},
36
Added:
do: {
37
Added:
min: 0,
38
Added:
goodMin: 5.5,
39
Added:
goodMax: 10,
40
Added:
max: 14,
41
Added:
precision: 2
42
Added:
},
43
Added:
orp: {
44
Added:
min: 100,
45
Added:
goodMin: 250,
46
Added:
goodMax: 400,
47
Added:
max: 500,
48
Added:
precision: 0
49
Added:
},
50
Added:
ec: {
51
Added:
min: 0,
52
Added:
goodMin: 300,
53
Added:
goodMax: 1500,
54
Added:
max: 2500,
55
Added:
precision: 0
56
Added:
}
57
Added:
};
28
58
const SMOOTHING_WINDOW = 7;
29
59
let activeTimeframe = DEFAULT_TIMEFRAME;
30
60
let smoothingEnabled = true;
31
61
32
Removed:
function probeRows() {
33
Removed:
return Array.from(document.querySelectorAll("[data-latest-status]"));
62
Added:
function sensorStatusItems() {
63
Added:
return Array.from(document.querySelectorAll("[data-sensor-status]"));
34
64
}
35
65
66
Added:
function readingQuickItems() {
67
Added:
return Array.from(document.querySelectorAll("[data-reading-quick-probe]"));
68
Added:
}
69
Added:
36
70
function activeProbe() {
37
71
return document.querySelector(".tab-button.is-active").dataset.probe;
38
72
}
@@ -49,6 +83,18 @@
49
83
return document.querySelector(`[data-reading-messages="${probe}"]`);
50
84
}
51
85
86
Added:
function overviewStatusElement() {
87
Added:
return document.querySelector("[data-overview-status]");
88
Added:
}
89
Added:
90
Added:
function statusDetailElement() {
91
Added:
return document.querySelector("[data-status-detail]");
92
Added:
}
93
Added:
94
Added:
function statusDetailRowsElement() {
95
Added:
return document.querySelector("[data-status-detail-rows]");
96
Added:
}
97
Added:
52
98
function smoothingToggles() {
53
99
return Array.from(document.querySelectorAll("[data-smoothing-toggle]"));
54
100
}
@@ -217,6 +263,13 @@
217
263
element.querySelector("[data-status-text]").textContent = statusLabel(state);
218
264
}
219
265
266
Added:
function setStatusSummary(state, text) {
267
Added:
const element = overviewStatusElement();
268
Added:
269
Added:
setStatusPill(element, state);
270
Added:
element.querySelector("[data-status-text]").textContent = text;
271
Added:
}
272
Added:
220
273
async function fetchLatestStatus(probe) {
221
274
const response = await fetch(`/api/status/${probe}`);
222
275
@@ -228,6 +281,21 @@
228
281
return payload.status || null;
229
282
}
230
283
284
Added:
async function fetchLatestReading(probe) {
285
Added:
const params = new URLSearchParams({
286
Added:
limit: "1"
287
Added:
});
288
Added:
const response = await fetch(`/api/readings/${probe}?${params}`);
289
Added:
290
Added:
if (!response.ok) {
291
Added:
throw new Error(`HTTP ${response.status}`);
292
Added:
}
293
Added:
294
Added:
const payload = await response.json();
295
Added:
const readings = payload.readings || [];
296
Added:
return readings.length ? readings[readings.length - 1] : null;
297
Added:
}
298
Added:
231
299
function stateForNodeStatus(nodeStatus) {
232
300
if (!nodeStatus) {
233
301
return "unreachable";
@@ -263,19 +331,132 @@
263
331
return [detail, `Status received at ${when}`].filter(Boolean).join(" ");
264
332
}
265
333
266
Removed:
function updateStatusPill(row, nodeStatus) {
267
Removed:
const stateElement = row.querySelector("[data-latest-state]");
268
Removed:
269
Removed:
setStatusPill(stateElement, stateForNodeStatus(nodeStatus));
270
Removed:
stateElement.title = statusTitle(nodeStatus);
271
Removed:
}
272
Removed:
273
334
function buildMessageCell(text) {
274
335
const cell = document.createElement("td");
275
336
cell.textContent = text;
276
337
return cell;
277
338
}
278
339
340
Added:
function buildStatusDetailRow(item) {
341
Added:
const row = document.createElement("tr");
342
Added:
const timestamp = item.status?.received_at || item.status?.timestamp;
343
Added:
const lastSeen = timestamp ? `${formatDateTime(timestamp)} (${formatAge(ageMs(timestamp))})` : "Never";
344
Added:
345
Added:
[
346
Added:
item.label,
347
Added:
statusLabel(item.state),
348
Added:
lastSeen
349
Added:
].forEach(value => row.appendChild(buildMessageCell(value)));
350
Added:
351
Added:
return row;
352
Added:
}
353
Added:
354
Added:
function formatReadingValue(value, range) {
355
Added:
if (!Number.isFinite(value)) {
356
Added:
return "No reading";
357
Added:
}
358
Added:
359
Added:
return value.toFixed(range.precision);
360
Added:
}
361
Added:
362
Added:
function rangePercent(value, range) {
363
Added:
const percent = ((value - range.min) / (range.max - range.min)) * 100;
364
Added:
return Math.max(0, Math.min(100, percent));
365
Added:
}
366
Added:
367
Added:
function readingState(value, range) {
368
Added:
if (!Number.isFinite(value)) {
369
Added:
return "unknown";
370
Added:
}
371
Added:
372
Added:
if (value < range.goodMin) {
373
Added:
return "low";
374
Added:
}
375
Added:
376
Added:
if (value > range.goodMax) {
377
Added:
return "high";
378
Added:
}
379
Added:
380
Added:
return "good";
381
Added:
}
382
Added:
383
Added:
function readingStateLabel(state) {
384
Added:
return {
385
Added:
good: "Good",
386
Added:
low: "Too low",
387
Added:
high: "Too high",
388
Added:
unknown: "No data"
389
Added:
}[state] || "No data";
390
Added:
}
391
Added:
392
Added:
function setQuickItemState(item, state) {
393
Added:
item.classList.remove("is-good", "is-low", "is-high", "is-unknown");
394
Added:
item.classList.add(`is-${state}`);
395
Added:
}
396
Added:
397
Added:
function configureQuickItemRange(item, range, unit) {
398
Added:
const goodStart = rangePercent(range.goodMin, range);
399
Added:
const goodEnd = rangePercent(range.goodMax, range);
400
Added:
const warnLow = Math.max(0, goodStart - 8);
401
Added:
const warnHigh = Math.min(100, goodEnd + 8);
402
Added:
403
Added:
item.style.setProperty("--warn-low", `${warnLow}%`);
404
Added:
item.style.setProperty("--good-start", `${goodStart}%`);
405
Added:
item.style.setProperty("--good-end", `${goodEnd}%`);
406
Added:
item.style.setProperty("--warn-high", `${warnHigh}%`);
407
Added:
item.querySelector("[data-reading-quick-min]").textContent =
408
Added:
`${formatReadingValue(range.min, range)} ${unit}`;
409
Added:
item.querySelector("[data-reading-quick-target]").textContent =
410
Added:
`${formatReadingValue(range.goodMin, range)}-${formatReadingValue(range.goodMax, range)} ${unit}`;
411
Added:
item.querySelector("[data-reading-quick-max]").textContent =
412
Added:
`${formatReadingValue(range.max, range)} ${unit}`;
413
Added:
}
414
Added:
415
Added:
function updateQuickItem(item, reading) {
416
Added:
const probe = item.dataset.readingQuickProbe;
417
Added:
const range = READING_RANGES[probe];
418
Added:
const unit = item.dataset.readingQuickUnit || reading?.unit || "";
419
Added:
const label = item.dataset.readingQuickLabel || probe;
420
Added:
const value = Number(reading?.value);
421
Added:
const state = readingState(value, range);
422
Added:
const marker = item.querySelector("[data-reading-quick-marker]");
423
Added:
const meter = item.querySelector(".reading-range");
424
Added:
const valueElement = item.querySelector("[data-reading-quick-value]");
425
Added:
const percent = Number.isFinite(value) ? rangePercent(value, range) : 50;
426
Added:
const timestamp = reading?.received_at || reading?.timestamp;
427
Added:
const age = timestamp ? formatAge(ageMs(timestamp)) : "unknown age";
428
Added:
429
Added:
configureQuickItemRange(item, range, unit);
430
Added:
setQuickItemState(item, state);
431
Added:
marker.style.left = `${percent}%`;
432
Added:
meter.setAttribute("aria-valuenow", Math.round(percent).toString());
433
Added:
meter.setAttribute(
434
Added:
"aria-label",
435
Added:
`${label} ${readingStateLabel(state)} at ${formatReadingValue(value, range)} ${unit}`
436
Added:
);
437
Added:
valueElement.textContent = Number.isFinite(value)
438
Added:
? `${formatReadingValue(value, range)} ${unit} - ${readingStateLabel(state)} - ${age}`
439
Added:
: "No reading yet";
440
Added:
}
441
Added:
442
Added:
async function loadQuickStatus() {
443
Added:
const items = readingQuickItems();
444
Added:
const results = await Promise.allSettled(
445
Added:
items.map(item => fetchLatestReading(item.dataset.readingQuickProbe))
446
Added:
);
447
Added:
448
Added:
items.forEach((item, index) => {
449
Added:
const range = READING_RANGES[item.dataset.readingQuickProbe];
450
Added:
451
Added:
if (!range) {
452
Added:
return;
453
Added:
}
454
Added:
455
Added:
const result = results[index];
456
Added:
updateQuickItem(item, result.status === "fulfilled" ? result.value : null);
457
Added:
});
458
Added:
}
459
Added:
279
460
function renderMessageTable(probe, rows) {
280
461
const body = messageTableBody(probe);
281
462
const latestRows = rows
@@ -312,21 +493,51 @@
312
493
}
313
494
314
495
async function loadOverview() {
315
Removed:
const rows = probeRows();
496
Added:
const rows = sensorStatusItems();
316
497
const results = await Promise.allSettled(
317
Removed:
rows.map(row => fetchLatestStatus(row.dataset.latestStatus))
498
Added:
rows.map(row => fetchLatestStatus(row.dataset.sensorStatus))
318
499
);
319
500
320
Removed:
rows.forEach((row, index) => {
501
Added:
const items = rows.map((row, index) => {
321
502
const result = results[index];
503
Added:
const status = result.status === "fulfilled" ? result.value : null;
322
504
323
Removed:
if (result.status !== "fulfilled") {
324
Removed:
updateStatusPill(row, null);
325
Removed:
return;
326
Removed:
}
327
Removed:
328
Removed:
updateStatusPill(row, result.value);
505
Added:
return {
506
Added:
key: row.dataset.sensorStatus,
507
Added:
label: row.dataset.sensorLabel,
508
Added:
status,
509
Added:
state: stateForNodeStatus(status)
510
Added:
};
329
511
});
512
Added:
513
Added:
const unhealthyItems = items.filter(item => item.state !== "healthy");
514
Added:
const detail = statusDetailElement();
515
Added:
const detailRows = statusDetailRowsElement();
516
Added:
517
Added:
if (!items.length) {
518
Added:
setStatusSummary("unknown", "No sensors configured");
519
Added:
detail.classList.add("is-hidden");
520
Added:
replaceChildren(detailRows, []);
521
Added:
return;
522
Added:
}
523
Added:
524
Added:
if (!unhealthyItems.length) {
525
Added:
setStatusSummary("healthy", "All sensors healthy");
526
Added:
overviewStatusElement().title = "Every sensor node has sent a recent healthy status.";
527
Added:
detail.classList.add("is-hidden");
528
Added:
replaceChildren(detailRows, []);
529
Added:
return;
530
Added:
}
531
Added:
532
Added:
const summaryState = unhealthyItems.some(item => item.state === "unreachable")
533
Added:
? "unreachable"
534
Added:
: "probe-error";
535
Added:
const countLabel = unhealthyItems.length === 1 ? "sensor needs" : "sensors need";
536
Added:
537
Added:
setStatusSummary(summaryState, `${unhealthyItems.length} ${countLabel} attention`);
538
Added:
overviewStatusElement().title = unhealthyItems.map(item => statusTitle(item.status)).join(" ");
539
Added:
detail.classList.remove("is-hidden");
540
Added:
replaceChildren(detailRows, unhealthyItems.map(buildStatusDetailRow));
330
541
}
331
542
332
543
async function loadProbe(probe) {
@@ -478,8 +689,10 @@
478
689
479
690
loadProbe(activeProbe());
480
691
loadOverview();
692
Added:
loadQuickStatus();
481
693
482
694
setInterval(() => {
483
695
loadProbe(activeProbe());
484
696
loadOverview();
697
Added:
loadQuickStatus();
485
698
}, 5_000);
roles/dashboard/t/basic.t
@@ -132,7 +132,15 @@
132
132
$t->get_ok('/')
133
133
->status_is(200)
134
134
->content_like(qr/FAPG DAQ Dashboard/)
135
Removed:
->content_like(qr/DAQ Hub/)
135
Added:
->content_like(qr/Checking sensors/)
136
Added:
->content_like(qr/data-overview-status/)
137
Added:
->content_like(qr/data-sensor-status="ph"/)
138
Added:
->content_like(qr/Last seen online/)
139
Added:
->content_like(qr/Sensor Reading Quick Status/)
140
Added:
->content_like(qr/data-reading-quick-probe="ph"/)
141
Added:
->content_like(qr/data-reading-quick-probe="do"/)
142
Added:
->content_like(qr/data-reading-quick-probe="orp"/)
143
Added:
->content_like(qr/data-reading-quick-probe="ec"/)
136
144
->content_like(qr/Dissolved Oxygen/)
137
145
->content_like(qr/Hourly/)
138
146
->content_like(qr/Daily/)
@@ -142,7 +150,7 @@
142
150
->content_like(qr/smoothing/)
143
151
->content_like(qr/Last 10 pH Reading Messages/)
144
152
->content_like(qr/Last 10 Dissolved Oxygen Reading Messages/)
145
Removed:
->content_like(qr/status-pill-list/)
153
Added:
->content_unlike(qr/status-pill-list/)
146
154
->content_unlike(qr/Pi Zero 01/)
147
155
->content_unlike(qr/Connection Status/);
148
156
roles/dashboard/templates/dashboard/index.html.ep
@@ -5,22 +5,61 @@
5
5
6
6
<section class="page-header">
7
7
<h1>FAPG DAQ Dashboard</h1>
8
Removed:
<p>Live aquaponic sensor readings from the DAQ network.</p>
9
8
</section>
10
9
11
10
<section class="status-band" aria-label="DAQ status overview">
12
Removed:
<ul class="status-pill-list">
13
Removed:
% for my $item ($status_items->@*) {
14
Removed:
<li
15
Removed:
data-latest-status="<%= $item->{key} %>"
16
Removed:
data-status-label="<%= $item->{label} %>">
17
Removed:
<span class="status-pill is-unknown" data-latest-state>
18
Removed:
<span class="status-pill-probe"><%= $item->{label} %></span>
19
Removed:
<span data-status-text>Unknown</span>
20
Removed:
</span>
21
Removed:
</li>
11
Added:
<div class="status-overview">
12
Added:
<span class="status-pill status-summary is-unknown" data-overview-status>
13
Added:
<span data-status-text>Checking sensors</span>
14
Added:
</span>
15
Added:
16
Added:
% for my $probe ($probes->@*) {
17
Added:
<span
18
Added:
hidden
19
Added:
data-sensor-status="<%= $probe->{key} %>"
20
Added:
data-sensor-label="<%= $probe->{label} %>"></span>
22
21
% }
23
Removed:
</ul>
22
Added:
23
Added:
<div class="status-detail is-hidden" data-status-detail>
24
Added:
<table class="status-detail-table">
25
Added:
<thead>
26
Added:
<tr>
27
Added:
<th scope="col">Probe</th>
28
Added:
<th scope="col">Status</th>
29
Added:
<th scope="col">Last seen online</th>
30
Added:
</tr>
31
Added:
</thead>
32
Added:
<tbody data-status-detail-rows>
33
Added:
</tbody>
34
Added:
</table>
35
Added:
</div>
36
Added:
37
Added:
<section class="reading-quick-status" aria-label="Sensor reading quick status">
38
Added:
<h2>Sensor Reading Quick Status</h2>
39
Added:
<div class="reading-quick-grid" data-reading-quick-status>
40
Added:
% for my $probe ($probes->@*) {
41
Added:
<article
42
Added:
class="reading-quick-item is-unknown"
43
Added:
data-reading-quick-probe="<%= $probe->{key} %>"
44
Added:
data-reading-quick-label="<%= $probe->{label} %>"
45
Added:
data-reading-quick-unit="<%= $probe->{unit} %>">
46
Added:
<div class="reading-quick-header">
47
Added:
<h3><%= $probe->{label} %></h3>
48
Added:
<p data-reading-quick-value>Waiting for data</p>
49
Added:
</div>
50
Added:
<div class="reading-range" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50">
51
Added:
<span class="reading-range-marker" data-reading-quick-marker></span>
52
Added:
</div>
53
Added:
<div class="reading-range-labels">
54
Added:
<span data-reading-quick-min></span>
55
Added:
<span data-reading-quick-target></span>
56
Added:
<span data-reading-quick-max></span>
57
Added:
</div>
58
Added:
</article>
59
Added:
% }
60
Added:
</div>
61
Added:
</section>
62
Added:
</div>
24
63
</section>
25
64
26
65
<section class="dashboard-card">