Redesign equipment status section.

Commit
c689605fbe581d01636776c6dee834e10765f7a8
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
roles/dashboard/lib/dashboard/Controller/Dashboard.pm
index eaa40969..89c0bd15 100644..100644
@@ -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
index 2f829d59..b4350940 100644..100644
@@ -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
index c79ef266..d2ef88a8 100644..100644
@@ -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
index 822cb6d4..4641ddbf 100644..100644
@@ -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
index c50499b8..7c24f273 100644..100644
@@ -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">