This repository has been archived on 2026-06-29. You can view files and clone it, but cannot push or open issues or pull requests.
Files
resensys-site-status/templates/index.html
lakshay 950d09814e Align Scan ID button styling with other checks toolbar buttons.
Remove inline styles from Scan ID and use the shared check-btn class so it matches Run All, Clear All, and the rest of the row.
2026-06-29 14:26:25 -04:00

2928 lines
90 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Resensys Site Status</title>
<link rel="icon" href="/static/logo.png" type="image/png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css" />
<script src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js"></script>
<style>
:root{color-scheme: light dark;}
body {
font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 16px;
}
.toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #ccc;
background: #f8f8f8;
cursor: pointer;
transition: background 0.2s ease;
}
.toggle-btn:hover {
background: #e9e9e9;
}
/* Optional dark mode */
@media (prefers-color-scheme: dark) {
.toggle-btn {
background: #1f1f1f;
border-color: #444;
color: #e5e7eb;
}
.toggle-btn:hover {
background: #2c2c2c;
}
}
/* Hidden state */
.stats.hidden {
display: none;
}
.check-details {
border: 1px dashed rgba(255,255,255,.15);
border-radius: 10px;
padding: 10px 12px;
margin: 8px 16px 16px 16px;
background: rgba(0,0,0,.08);
}
@media (prefers-color-scheme: light) {
.check-details {
background: #f9fafb;
border-color: #e5e7eb;
}
}
.check-row.expanded {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.detail-row + .detail-row { margin-top: 10px; }
.detail-head { display:flex; align-items:center; }
.detail-body { margin-left: 26px; margin-top: 6px; }
.detail-explain { font-size: 13px; }
.detail-metrics .kv { display:grid; grid-template-columns: 160px 1fr; gap:4px 8px; margin-top:6px; }
.toolbar {
display:flex;
gap:8px;
align-items:center;
margin-bottom:12px;
flex-wrap:wrap;
background: rgba(0,0,0,0); /* themed override below */
}
input, select, button {
padding:6px 10px;
border-radius:10px;
border:1px solid #ccc;
}
button { cursor:pointer; }
.grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap:12px;
}
.card {
border-radius:16px;
padding:12px;
box-shadow: 0 2px 12px rgba(0,0,0,.08);
background:#fff;
border:1px solid #eee;
transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0,0,0,.12);
border-color:#ddd;
}
.site {
font-weight:700;
font-size:16px;
margin-bottom:6px;
display:flex;
align-items:center;
gap:8px;
}
.meta { font-size:12px; color:#979797; }
.pill {
display:inline-block;
padding:2px 8px;
border-radius:999px;
color:#fff;
font-size:12px;
text-transform:capitalize;
}
.online { background:#00a86b; }
.recent { background:#e4a11b; }
.offline { background:#dc3545; }
.portnull { background:#797979; }
.port5555 { background:#15add3; }
.port5580 { background:#9d15d3; }
.pill-tag {
background:#6b7280;
color:#fff;
padding:2px 6px;
border-radius:999px;
font-size:11px;
}
.pill-sm{
display:inline-block;
padding:2px 6px;
border-radius:999px;
color:#fff;
font-size:11px;
}
.ok{background:#00a86b}
.bad{background:#dc3545}
.linklike {
background:none;
border:none;
padding:0;
margin:0;
color:inherit;
text-decoration:underline;
cursor:pointer;
font-weight:700
}
.linklike:focus {
outline: 2px solid #7aa7ff;
outline-offset: 2px;
border-radius:6px;
}
/* drawer panel */
#drawer {
position:fixed;
top:0;
right:-1020px;
width:1020px;
max-width:92vw;
height:100vh;
background:#232836;
border-left:1px solid #313131;
box-shadow:-8px 0 24px rgba(0,0,0,.1);
transition:right .25s ease;
z-index:1000;
display:flex;
flex-direction:column;
}
#drawer .hdr {
position:sticky;
top:0;
background:#1a1e29;
z-index:1;
}
/* generalized row grid (used in drawer, cache, and new checks tab) */
.row {
display:grid;
grid-template-columns: minmax(16ch, 1fr) 10ch 10ch 10ch 10ch 10ch 10ch;
gap:12px;
align-items:center;
padding:8px 0;
border-bottom:1px solid #f0f0f0;
}
.row.hdr {
font-weight:700;
border-bottom:2px solid #ddd;
padding-top:10px;
padding-bottom:10px;
}
.cell--mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.cell--right { text-align:right; }
.row.data:hover {
background: rgba(0,0,0,.035);
border-radius:8px;
}
/* stats cards in toolbar */
.stats {
display:flex;
gap:8px;
margin-left:auto;
flex-wrap:wrap;
}
.stat-card {
min-width: 120px;
padding:8px 10px;
border:1px solid #eee;
border-radius:12px;
background:#fff;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
}
.stat-title { font-size:11px; opacity:.7; margin-bottom:2px; }
.stat-value { font-size:18px; font-weight:700; }
.stat-online .stat-value { color:#00a86b; }
.stat-recent .stat-value { color:#e4a11b; }
.stat-offline .stat-value { color:#dc3545; }
/* site meta editor */
.meta-form {
padding:10px 14px;
border-bottom:1px solid #eee;
display:flex;
flex-direction:column;
gap:8px;
}
.meta-row {
display:flex;
gap:8px;
align-items:center;
flex-wrap:wrap;
}
.meta-row label {
font-size:12px;
opacity:.7;
min-width:56px;
}
.meta-row input[type="text"] {
flex:1;
padding:6px 10px;
border-radius:10px;
border:1px solid #ccc;
}
.chip {
display:inline-flex;
align-items:center;
gap:6px;
padding:2px 8px;
border-radius:999px;
background:#eef2ff;
border:1px solid #dbe2ff;
font-size:12px;
}
.chip button {
background:none;
border:none;
cursor:pointer;
font-size:12px;
opacity:.7;
}
.chip button:hover { opacity:1; }
.tag-entry { min-width:160px; }
.save-hint { font-size:12px; opacity:.6; }
.kv {
display:grid;
grid-template-columns: 120px 1fr;
gap:6px 12px;
}
.k { opacity:.7; font-size:12px; align-self:center; }
.v { font-size:14px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* ===== NEW: Device Checks Tab ===== */
/* layout */
#page-checks .card-header-row {
display:flex;
flex-wrap:wrap;
align-items:flex-start;
gap:12px;
margin-bottom:12px;
}
#checks-input {
min-width:200px;
flex:1;
}
#run-all-btn,
#page-checks .card-header-row .check-btn {
white-space: nowrap;
padding: 6px 10px;
border-radius: 10px;
}
/* ===== Desktop / default layout ===== */
/* Header + data rows share the same grid template so columns line up */
.check-row-hdr,
.check-row {
display: grid;
grid-template-columns:
minmax(14ch, 1fr) /* Device ID */
minmax(10ch, 10ch) /* Type */
minmax(8ch, 8ch) /* Battery */
minmax(8ch, 8ch) /* Signal */
minmax(9ch, 9ch) /* Classify */
minmax(24ch, 1fr) /* Actions (Run / Remove) */
;
gap: 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.check-row-hdr {
font-weight: 700;
padding-top: 10px;
padding-bottom: 10px;
border-bottom: 2px solid #ddd;
}
.check-row:hover {
background: rgba(0,0,0,.035);
border-radius: 8px;
}
/* When all tests have passed for a row */
.check-row.all-ok {
border: 2px solid #00ff99;
box-shadow: 0 0 12px rgba(0, 255, 153, 0.7);
border-radius: 10px;
transition: box-shadow 0.4s ease, border-color 0.4s ease;
}
@media (prefers-color-scheme: dark) {
.check-row.all-ok {
box-shadow: 0 0 16px rgba(0, 255, 153, 0.45);
}
}
/* Actions cell (buttons on the right) */
.check-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
/* Individual action buttons */
.check-btn {
font-size: 12px;
line-height: 1.2;
border-radius: 8px;
border: 1px solid #ccc;
background: #fff;
padding: 6px 8px;
cursor: pointer;
transition: box-shadow .12s ease,
border-color .12s ease,
background .12s ease;
}
.check-btn:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(122,167,255,.35);
border-color: rgba(122,167,255,.55);
}
.check-btn.running {
opacity: .6;
cursor: wait;
}
.check-btn.disable {
opacity: .9;
color:#616161
}
/* Per-test status badge cells (Battery / Signal / Classify) */
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2ch;
font-size: 16px;
font-weight: 700;
line-height: 1;
border-radius: 8px;
padding: 4px 6px;
background: #aaa;
color: #fff;
opacity: .8;
transition: background .2s ease,
box-shadow .2s ease,
transform .2s ease;
}
/* PASS / FAIL / WAIT visual states */
.status-pass {
background:#00a86b;
color:#0f0f0f;
box-shadow:0 8px 24px rgba(0,168,107,.4);
animation: popfade 0.5s ease;
}
.status-fail {
background:#dc3545;
color:#fff;
box-shadow:0 8px 24px rgba(220,53,69,.4);
animation: popfade 0.5s ease;
}
.status-wait {
background:#6b7280;
color:#fff;
box-shadow:0 4px 14px rgba(0,0,0,.4);
}
/* little pop animation when badge changes */
@keyframes popfade {
0% { transform: scale(.8); opacity: .4; }
60% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1.0); opacity: 1; }
}
/* Dark mode tweaks */
@media (prefers-color-scheme: dark) {
.check-row-hdr {
border-bottom-color:#29374f;
background:#0f1522;
}
.check-row {
border-bottom-color:#1b2638;
}
.check-row:hover {
background: rgba(255,255,255,.06);
}
.check-btn {
background:#1b1b1b;
border-color:#3a3a3a;
color:#e5e7eb;
}
}
/* ===== Mobile layout =====
For screens narrower than 768px:
- Hide the table-style header.
- Each .check-row becomes a "card".
- We stack DID, Type, Statuses, and Actions vertically with clear labels.
*/
@media (max-width: 768px) {
/* Hide the header row entirely on mobile */
.check-row-hdr {
display: none;
}
/* Each device row becomes a card block */
.check-row {
display: block;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
background: rgba(0,0,0,0.15);
}
/* light mode fallback for card look */
@media (prefers-color-scheme: light) {
.check-row {
background: #fff;
border: 1px solid #e5e7eb;
}
}
/* We'll reflow inner cells manually:
- The markup you're already rendering is:
<div class="cell--mono">DID</div>
<div class="cell--right">TYPE</div>
<div class="cell--right">[battery badge]</div>
<div class="cell--right">[signal badge]</div>
<div class="cell--right">[classify badge]</div>
<div class="check-actions">[...]</div>
We'll just stack them vertically and label as needed.
*/
.check-row > div {
margin-bottom: 8px;
}
/* Device ID row: make it bold, on its own line */
.check-row > div:nth-child(1) {
font-weight: 600;
font-size: 15px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
/* Type row: "Type: STRED" */
.check-row > div:nth-child(2) {
font-size: 13px;
opacity: .8;
}
.check-row > div:nth-child(2)::before {
content: "Type: ";
font-weight: 500;
opacity: .9;
margin-right: 4px;
}
/* Status badges section (Battery / Signal / Classify):
We'll group these three rows visually into one flex row.
Easiest way: convert items 3-5 into inline chips with labels.
*/
.check-row > div:nth-child(3),
.check-row > div:nth-child(4),
.check-row > div:nth-child(5) {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
margin-right: 12px;
}
.check-row > div:nth-child(3)::before {
content: "Battery";
font-weight:500;
font-size:12px;
color:#9ca3af;
}
.check-row > div:nth-child(4)::before {
content: "Signal";
font-weight:500;
font-size:12px;
color:#9ca3af;
}
.check-row > div:nth-child(5)::before {
content: "Classify";
font-weight:500;
font-size:12px;
color:#9ca3af;
}
/* Put the three status groups (3-5) on one line that wraps if needed */
.check-row > div:nth-child(3),
.check-row > div:nth-child(4),
.check-row > div:nth-child(5) {
margin-bottom: 8px;
}
.check-row > div:nth-child(3) {
display:inline-flex;
flex-wrap:nowrap;
}
.check-row > div:nth-child(4) {
display:inline-flex;
flex-wrap:nowrap;
}
.check-row > div:nth-child(5) {
display:inline-flex;
flex-wrap:nowrap;
}
/* Actions block last, full width */
.check-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 8px;
margin-top: 4px;
}
.check-btn {
font-size: 13px;
line-height: 1.3;
padding: 8px 10px;
border-radius: 10px;
}
}
/* ===== THEMES (your existing themes preserved) ===== */
/* GLASS THEME */
body.theme-glass {
--accent: #5b8cff;
--bg-grad-a: #f7f9ff;
--bg-grad-b: #eef2ff;
background: linear-gradient(160deg, var(--bg-grad-a), var(--bg-grad-b));
}
@media (prefers-color-scheme: dark) {
body.theme-glass {
--accent: #7aa7ff;
--bg-grad-a: #0f1220;
--bg-grad-b: #14182a;
background: radial-gradient(1200px 600px at 20% 0%, #1a1f36 0%, #0f1220 55%);
}
}
body.theme-glass .toolbar,
body.theme-glass .stat-card,
body.theme-glass .card {
background: rgba(255,255,255,.55);
border: 1px solid rgba(255,255,255,.35);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0,0,0,.08);
}
@media (prefers-color-scheme: dark) {
body.theme-glass .toolbar,
body.theme-glass .stat-card,
body.theme-glass .card {
background: rgba(22,26,44,.55);
border: 1px solid rgba(122,167,255,.15);
box-shadow: 0 12px 28px rgba(0,0,0,.35);
}
}
body.theme-glass input,
body.theme-glass select,
body.theme-glass button {
border-radius: 12px;
border: 1px solid rgba(0,0,0,.1);
background: rgba(255,255,255,.7);
}
@media (prefers-color-scheme: dark) {
body.theme-glass input,
body.theme-glass select,
body.theme-glass button {
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
color: #e5e7eb;
}
}
body.theme-glass .pill,
body.theme-glass .pill-sm {
box-shadow: inset 0 0 0 1px rgba(255,255,255,.5);
}
body.theme-glass .online { background: #22c55e; }
body.theme-glass .recent { background: #f59e0b; }
body.theme-glass .offline{ background: #ef4444; }
body.theme-glass .linklike:focus { outline-color: var(--accent); }
body.theme-glass .row.data:hover { background: rgba(255,255,255,.35); }
@media (prefers-color-scheme: dark) {
body.theme-glass .row.data:hover { background: rgba(255,255,255,.08); }
}
/* NEON THEME (current default) */
body.theme-neon {
background: #0b0f16;
color: #e5e7eb;
--neon: #4fd1c5;
--neon2: #a78bfa;
}
body.theme-neon .toolbar {
background: #0f1522;
border: 1px solid #1f2a3a;
border-radius: 14px;
box-shadow: inset 0 0 0 1px rgba(79,209,197,.08),
0 8px 24px rgba(0,0,0,.4);
padding: 10px;
}
body.theme-neon .card {
background: #101725;
border: 1px solid #1f2a3a;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
}
body.theme-neon .card:hover {
transform: translateY(-2px);
border-color: rgba(79,209,197,.35);
box-shadow: 0 14px 36px rgba(0,0,0,.65),
0 0 0 1px rgba(167,139,250,.15) inset;
}
body.theme-neon .site { letter-spacing:.2px; }
body.theme-neon .meta { color:#94a3b8; }
body.theme-neon input,
body.theme-neon select,
body.theme-neon button {
background:#0f1522;
border:1px solid #243044;
color:#e5e7eb;
box-shadow:0 0 0 2px transparent inset;
}
body.theme-neon input:focus,
body.theme-neon select:focus,
body.theme-neon button:focus {
outline:none;
box-shadow:0 0 0 2px rgba(79,209,197,.35) inset;
border-color:rgba(79,209,197,.55);
}
body.theme-neon .linklike {
text-decoration: none;
border-bottom:1px dashed rgba(167,139,250,.6);
}
body.theme-neon .linklike:hover {
color: var(--neon2);
border-bottom-color: var(--neon2);
}
body.theme-neon .pill,
.theme-neon .pill-sm {
background: linear-gradient(90deg, var(--neon), var(--neon));
color:#0b0f16;
font-weight:700;
}
body.theme-neon .online { background:#06d6a0; color:#0b0f16; }
body.theme-neon .recent { background:#ffd166; color:#0b0f16; }
body.theme-neon .offline { background:#ef476f; color:#0b0f16; }
body.theme-neon .portnull { background:#797979; color:#0b0f16;}
body.theme-neon .port5555 { background:#15add3; color:#0b0f16;}
body.theme-neon .port5580 { background:#a878ba; color:#0b0f16;}
body.theme-neon .row { border-bottom-color:#1b2638; }
body.theme-neon .row.hdr {
border-bottom-color:#29374f;
background:#0f1522;
}
body.theme-neon .row.data:hover { background: rgba(79,209,197,.06); }
body.theme-neon .stat-card {
background:#0f1522;
border:1px solid #1f2a3a;
box-shadow:0 6px 18px rgba(0,0,0,.5);
}
body.theme-neon .stat-title { color:#9fb3c8; }
body.theme-neon .stat-online .stat-value { color:#06d6a0; }
body.theme-neon .stat-recent .stat-value { color:#ffd166; }
body.theme-neon .stat-offline .stat-value { color:#ef476f; }
/* MINIMAL THEME */
body.theme-minimal {
--ink: #1f2937;
--subink: #6b7280;
--bg: #fafafa;
--line: #eaeaea;
background: var(--bg);
color: var(--ink);
}
@media (prefers-color-scheme: dark) {
body.theme-minimal {
--ink: #e5e7eb;
--subink:#9ca3af;
--bg:#0f1115;
--line:#22242a;
background: var(--bg);
}
}
body.theme-minimal .toolbar {
background: transparent;
border: none;
padding: 0;
gap: 10px;
}
body.theme-minimal input,
body.theme-minimal select,
body.theme-minimal button {
border-radius: 12px;
background: #fff;
border: 1px solid var(--line);
}
@media (prefers-color-scheme: dark) {
body.theme-minimal input,
body.theme-minimal select,
body.theme-minimal button {
background:#14161b;
border-color:#22242a;
color:#e5e7eb;
}
}
body.theme-minimal .card {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
}
body.theme-minimal .card + .card { margin-top: 8px; }
body.theme-minimal .site {
font-weight: 700;
font-size: 18px;
margin-bottom: 4px;
}
body.theme-minimal .meta { color: var(--subink); }
body.theme-minimal .row {
border-bottom: 1px solid var(--line);
padding: 10px 0;
}
body.theme-minimal .row.hdr {
border-bottom: 2px solid var(--ink);
background: transparent;
}
body.theme-minimal .row.data:hover { background: transparent; }
body.theme-minimal .pill,
body.theme-minimal .pill-sm {
background: #111827;
color:#fff;
border-radius: 999px;
}
@media (prefers-color-scheme: dark) {
body.theme-minimal .pill,
body.theme-minimal .pill-sm { background:#374151; }
}
body.theme-minimal .online { background:#059669; }
body.theme-minimal .recent { background:#d97706; }
body.theme-minimal .offline { background:#b91c1c; }
body.theme-minimal .stat-card {
background: transparent;
border: 1px dashed var(--line);
box-shadow: none;
}
body.theme-minimal .stat-title {
color: var(--subink);
text-transform: uppercase;
letter-spacing: .04em;
}
/* AURORA THEME */
body.theme-aurora {
--bg: #f9fafb;
--card: #ffffff;
--ink: #0f172a;
--subink: #64748b;
--line: #e5e7eb;
--accent: #2563eb;
--accent-2: #22c55e;
--shadow: 0 8px 24px rgba(15, 23, 42, .06);
background: linear-gradient(180deg, #ffffff 0%, var(--bg) 100%);
color: var(--ink);
}
body.theme-aurora .toolbar {
background: #ffffff;
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px;
box-shadow: var(--shadow);
}
body.theme-aurora input,
body.theme-aurora select,
body.theme-aurora button {
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 12px;
}
body.theme-aurora input:focus,
body.theme-aurora select:focus,
body.theme-aurora button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(37,99,235,.18);
border-color: rgba(37,99,235,.5);
}
body.theme-aurora .card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease;
}
body.theme-aurora .card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 36px rgba(15,23,42,.10);
border-color: #d1d5db;
}
body.theme-aurora .site { font-size: 17px; font-weight: 750; }
body.theme-aurora .meta { color: var(--subink); }
body.theme-aurora .row { border-bottom: 1px solid var(--line); }
body.theme-aurora .row.hdr {
border-bottom: 2px solid #cbd5e1;
background: #fff;
}
body.theme-aurora .row.data:hover { background: #f3f4f6; }
body.theme-aurora .pill,
.theme-aurora .pill-sm {
background: linear-gradient(180deg, #ffffff, #eef2ff);
border: 1px solid #dbeafe;
color: #1e293b;
}
body.theme-aurora .online { background: #16a34a; }
body.theme-aurora .recent { background: #d97706; }
body.theme-aurora .offline { background: #d83030; }
body.theme-aurora .stat-card {
background: #fff;
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
}
body.theme-aurora .stat-title { color: var(--subink); }
body.theme-aurora .stat-online .stat-value { color: #16a34a; }
body.theme-aurora .stat-recent .stat-value { color: #d97706; }
body.theme-aurora .stat-offline .stat-value { color: #d83030; }
body.theme-aurora .linklike {
text-decoration: none;
color: var(--accent);
font-weight: 700;
}
body.theme-aurora .linklike:hover { text-decoration: underline; }
.local-address-grid{
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 6px;
padding: 8px 0;
}
.addr-cell{
color: #000;
border-radius: 8px;
padding: 8px 6px;
border: 1px solid rgba(0,0,0,0.12);
font: 600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
text-align: center;
user-select: none;
cursor: default;
}
.addr-cell:hover{
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
}
.addr-free { background: #26f063; } /* green-ish */
.addr-taken{ background: #e33737; } /* red-ish */
.addr-dup { background: #2a77f2; } /* blue-ish */
/* small legend chips */
.local-address-legend{
display:flex;
gap:14px;
align-items:center;
font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
opacity: 0.85;
}
.chip{
display:inline-block;
width:12px; height:12px;
border-radius:3px;
border:1px solid rgba(0,0,0,0.15);
margin-right:6px;
}
.chip-green{ background:#26f063; }
.chip-red { background:#e33737; }
.chip-blue { background:#2a77f2; }
.addr-tooltip{
position: fixed;
z-index: 9999;
max-width: 260px;
padding: 6px 8px;
background: rgba(0,0,0,0.9);
color: #fff;
font: 12px/1.3 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
border-radius: 6px;
white-space: pre-wrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.local-address-grid,
.addr-cell{
pointer-events: auto;
}
.page h2 { margin: 12px 0; }
.controls{
display:flex;
flex-direction:column;
gap:8px;
max-width: 720px;
margin: 8px 0 12px 0;
}
.controls label{
font-size:12px;
opacity:.8;
}
.controls textarea{
width:100%;
min-height: 64px;
resize: vertical;
}
.status{
font-size:12px;
opacity:.8;
}
.btn{
width: fit-content;
}
#confirm-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
z-index: 9999;
align-items: center;
justify-content: center;
padding: 18px;
}
.modal-card {
width: min(520px, 96vw);
border-radius: 18px;
background: rgba(14, 22, 40, 0.95);
border: 1px solid rgba(255,255,255,0.14);
box-shadow: 0 20px 60px rgba(0,0,0,0.45);
padding: 16px;
backdrop-filter: blur(10px);
}
.modal-title {
font-size: 16px;
font-weight: 800;
margin: 0 0 6px 0;
}
.modal-text {
margin: 0 0 14px 0;
color: var(--muted);
font-size: 14px;
line-height: 1.35;
}
.modal-id {
margin: 10px 0 14px;
display: inline-flex;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
</style>
</head>
<body class="theme-neon">
<span>
<img src="/static/logo.png" alt="App Icon" width="32" height="32" style="vertical-align: middle;">
<h1 style="display: inline-block; margin: 10px; vertical-align: middle;">Resensys Site Status</h1>
</span>
<!-- GLOBAL TOOLBAR -->
<div class="toolbar">
<input id="q" style="margin:5px;" placeholder='Filter by site (e.g., "48-")' />
<select id="state">
<option value="">All states</option>
<option value="online">Online</option>
<option value="recent">Recently offline</option>
<option value="offline">Offline</option>
</select>
<select id="port">
<option value="">All ports</option>
<option value="5555">5555</option>
<option value="5580">5580</option>
</select>
<button id="refresh">Refresh</button>
<button id="tab-sites">Sites</button>
<button id="tab-cache">Device Cache</button>
<button id="tab-checks">Device Checks</button>
<button class="nav-btn" data-page="page-local-matrix">Local Address Matrix</button>
<button id="toggle-stats" class="toggle-btn">
<i class="fas fa-chart-bar"></i> Toggle Stats
</button>
<div id="stats" class="stats"></div>
<span id="count" style="margin-left:auto;opacity:.7;margin:5px;"></span>
</div>
<!-- PAGE: SITES -->
<div id="page-sites">
<div id="grid" class="grid"></div>
<!-- Drawer for per-site devices -->
<div id="drawer">
<div style="display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid #eee;" class="hdr">
<strong id="drawer-title" style="font-size:16px;">Site</strong>
<span id="drawer-count" style="opacity:.7"></span>
<button id="drawer-close" style="margin-left:auto;padding:6px 10px;border-radius:10px;border:1px solid #ccc;cursor:pointer;">Close</button>
</div>
<div style="padding:10px;border-bottom:1px solid #eee;display:flex;gap:8px;">
<input id="dev-filter" placeholder="Filter device ID, local address, or device type…" style="flex:1;padding:6px 10px;border-radius:10px;border:1px solid #ccc;" />
</div>
<div id="bundle-card" style="padding:12px 14px; border-bottom:1px solid #eee;">
<div style="font-weight:700; margin-bottom:6px; color:rgb(200, 200, 200)">Gateway / Modem Info</div>
<div class="kv">
<div class="k" style="color:rgb(200,200,200)">Firmware</div><div class="v" id="bfw" style="color:rgb(200,200,200)"></div>
<div class="k" style="color:rgb(200,200,200)">Error code</div><div class="v" id="berr" style="color:rgb(200,200,200)"></div>
<div class="k" style="color:rgb(200,200,200)">ICCID</div><div class="v mono" id="biccid" style="color:rgb(200,200,200)"></div>
<div class="k" style="color:rgb(200,200,200)">IMEI</div><div class="v mono" id="bimei" style="color:rgb(200,200,200)"></div>
</div>
</div>
<!-- Editable site meta -->
<!-- <div id="meta-form" class="meta-form">
<div class="meta-row">
<label for="site-name" style="color:#e5e7eb">Name</label>
<input id="site-name" type="text" placeholder='e.g. "Br. 101"' />
</div>
<div class="meta-row">
<label style="color:#e5e7eb">Tags</label>
<div id="tags-wrap" style="display:flex; gap:6px; flex-wrap:wrap;"></div>
<input id="tag-input" class="tag-entry" type="text" placeholder="Add tag… (enter)" />
</div>
<div class="meta-row" style="justify-content:flex-end">
<span id="save-status" class="save-hint" style="color:#e5e7eb"></span>
<button id="save-meta" style="padding:6px 10px;border-radius:10px;border:1px solid #ccc;cursor:pointer;">Save</button>
</div>
</div> -->
<div style="overflow:auto;">
<section class="drawer-section" style="padding:8px 12px;">
<div class="drawer-section-title">Local Address Map</div>
<div id="local-address-grid" class="local-address-grid" aria-label="Local address occupancy grid"></div>
<div class="local-address-legend">
<span class="chip chip-green"></span> free
<span class="chip chip-red"></span> taken (1 device)
<span class="chip chip-blue"></span> conflict (2+ devices)
</div>
</section>
<!-- Drawer table -->
<div id="drawer-body" style="padding:8px 12px;"></div>
</div>
</div>
</div>
<!-- PAGE: DEVICE CACHE -->
<div id="page-cache" style="display:none;">
<div class="card" style="padding:12px;">
<div class="site">Device Cache Lookup</div>
<div class="meta">Enter one or more Device IDs (AA-BB-CC-DD), separated by commas or whitespace, then press Enter.</div>
<input id="cache-input" placeholder="e.g. 12-34-56-78, 9A-BC-DE-F0" style="width:90%"/>
</div>
<div class="toolbar" id="cache-toolbar" style="margin-top:8px;">
<button id="export-cache-csv" disabled>Export CSV</button>
<span class="meta" id="cache-export-hint" style="opacity:.7;">Exports the table below.</span>
</div>
<div id="cache-results" class="card" style="margin-top:10px; padding:12px;">
<div class="row hdr">
<div>Device ID</div>
<div class="cell--right">Type</div>
<div class="cell--right">Site</div>
<div class="cell--right">Local addr</div>
<div class="cell--right">Latest ts</div>
<div class="cell--right">RSSI</div>
<div class="cell--right">Voltage</div>
</div>
<div id="cache-rows"></div>
</div>
<div id="cache-df-wrap" class="card" style="margin-top:10px; padding:12px; display:none;">
<div class="site">Type-specific Cached Values</div>
<div class="meta">Shows per-device cached DataFormats determined by device type (from Redis).</div>
<div id="cache-df-tables"></div>
</div>
</div>
<!-- PAGE: DEVICE CHECKS (NEW) -->
<div id="page-checks" style="display:none;">
<div class="card" style="padding:12px;">
<div class="site">Device Health / Classification</div>
<div class="meta">
Type a device ID and hit Enter. Add multiple devices. Then run tests
per device or run all.
</div>
<div class="card-header-row" style="display:flex;flex-wrap:wrap;align-items:flex-start;gap:12px;margin-bottom:12px;">
<input id="checks-input"
style="flex:1;min-width:200px;"
placeholder="e.g. 15-03-58-80"
title="Type Device ID and press Enter to add"/>
<button id="btn-scan" class="check-btn">&#128247; Scan ID</button>
<button id="run-all-btn" class="check-btn">▶ Run All Checks</button>
<button id="clear-all-btn" class="check-btn">✕ Clear All</button>
<input id="site-id-input"
style="min-width:140px"
placeholder="Site ID (e.g. 48-07)"
title="Enter a Site ID to load all devices into checks"/>
<button id="load-site-btn" class="check-btn">⇥ Load from Site</button>
<button id="exportChecksCsvBtn" class="check-btn">Export CSV</button>
</div>
<!-- hidden overlay for scanner -->
<div id="scanner-overlay"
style="position:fixed;inset:0;background:rgba(0,0,0,.8);color:#fff;
display:none;flex-direction:column;align-items:center;justify-content:center;z-index:2000;">
<div style="position:relative;width:90%;max-width:400px;">
<video id="scanner-video" autoplay playsinline
style="width:100%;border:2px solid #00ff99;border-radius:12px;
box-shadow:0 0 20px rgba(0,255,153,.5);"></video>
<div style="position:absolute;top:8px;right:8px;">
<button id="scanner-cancel"
style="background:#000;border:1px solid #fff;color:#fff;
border-radius:8px;padding:4px 8px;font-size:13px;cursor:pointer;">
</button>
</div>
</div>
<div style="margin-top:16px;font-size:14px;opacity:.8;text-align:center;">
Align barcode in frame…
</div>
</div>
<div id="checks-table" style="margin-top:8px;">
<div class="check-row-hdr">
<div>Device ID</div>
<div class="cell--right">Type</div>
<div class="cell--right">Battery</div>
<div class="cell--right">Signal</div>
<div class="cell--right">Classify</div>
<div class="cell--right">
Actions
<span style="font-size:11px;opacity:.6;display:block;line-height:1.2;">
(Run / Remove)
</span>
</div>
</div>
<div id="checks-rows"></div>
</div>
</div>
</div>
<section id="page-local-matrix" class="page" style="display:none;">
<h2>Local Address Matrix</h2>
<div class="controls">
<label for="matrix-sids">Site IDs (comma / space / newline separated)</label>
<textarea id="matrix-sids" rows="3" placeholder="50-50, 50-51, 3D-34"></textarea>
<button id="matrix-run" class="btn">Build Matrix</button>
<span id="matrix-status" class="status"></span>
</div>
<div class="drawer-section">
<div class="drawer-section-title">Local Address Map (combined)</div>
<div id="matrix-local-address-grid" class="local-address-grid"></div>
<div class="local-address-legend">
<span class="chip chip-green"></span> free
<span class="chip chip-red"></span> taken (1 device)
<span class="chip chip-blue"></span> conflict (2+ devices)
</div>
</div>
</section>
<div id="confirm-modal">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="modal-title" id="confirm-title">Confirm Device ID</div>
<p class="modal-text" id="confirm-text">Add this ID to the table?</p>
<div class="modal-id"><span class="pill" id="confirm-id"></span></div>
<div class="modal-actions">
<button class="btn" id="btn-deny">Deny</button>
<button class="btn primary" id="btn-accept">Accept</button>
</div>
</div>
</div>
<div id="addr-tooltip" class="addr-tooltip" hidden></div>
<!-- ===================== JS ===================== -->
<script>
const cr123 = ["STRED","STRAIN","4CHSTR","DISP","MRT","2DHRT","HUT","HPA","DISP_ED","WETNESS"]
const recharge = ["WLS_S","WLS_A","GEO","WEATHER","SNMX"]
/* -----------------
Shared helpers
------------------*/
function safeText(x) {
if (x === null || x === undefined) return '';
return String(x);
}
/* -----------------
Sites tab logic
------------------*/
async function load() {
const q = document.getElementById('q').value.trim();
const state = document.getElementById('state').value;
const port = document.getElementById('port').value;
const params = new URLSearchParams();
if (q) params.set('q', q);
if (state) params.set('state', state);
if (port) params.set('port', port);
console.log('/api/site-grid?' + params.toString());
const res = await fetch('/api/site-grid?' + params.toString());
const data = await res.json();
// sort naturally by site_id
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
data.sort((a, b) => collator.compare(a.site_id, b.site_id));
renderSites(data);
}
function renderSites(items) {
const grid = document.getElementById('grid');
grid.innerHTML = '';
// High-level stats
const totalSites = items.length;
const totalDevices = items.reduce((acc, it) => acc + (it.num_devices || 0), 0);
const online = items.filter(it => it.state === 'online').length;
const recent = items.filter(it => it.state === 'recent').length;
const offline = items.filter(it => it.state === 'offline').length;
const countEl = document.getElementById('count');
if (countEl)
countEl.textContent = `${totalSites} site(s) • ${totalDevices} device(s)`;
const stats = document.getElementById('stats');
stats.innerHTML = `
<div class="stat-card">
<div class="stat-title">Total sites</div>
<div class="stat-value">${totalSites}</div>
</div>
<div class="stat-card stat-online">
<div class="stat-title">Online</div>
<div class="stat-value">${online}</div>
</div>
<div class="stat-card stat-recent">
<div class="stat-title">Recently offline</div>
<div class="stat-value">${recent}</div>
</div>
<div class="stat-card stat-offline">
<div class="stat-title">Offline</div>
<div class="stat-value">${offline}</div>
</div>
`;
// Site cards
for (const it of items) {
const div = document.createElement('div');
div.className = 'card';
div.innerHTML = `
<div class="site">
<button class="linklike js-open-devices" data-sid="${it.site_id}">${it.site_id}</button>
${it.site_name ? `<span style="opacity:.75">${it.site_name}</span>` : ''}
<span class="pill ${it.state}">${it.state}</span>
<span class="pill port${it.port}">${it.port}</span>
</div>
${Array.isArray(it.site_tags) && it.site_tags.length ? `
<div class="meta">
${it.site_tags.map(t => `<span class="pill-tag">${t}</span>`).join(' ')}
</div>` : ''}
<div class="meta">Last contact: ${it.last_contact_age_min ?? ''} min ago</div>
<div class="meta">Gateways: ${it.num_gateways} • Devices: ${it.num_devices}</div>
<div class="meta">Inactive &gt;24h: ${it.num_inactive_24h}</div>
`;
grid.appendChild(div);
}
}
document.getElementById('refresh').onclick = load;
document.getElementById('q').oninput = load;
document.getElementById('state').onchange = load;
document.getElementById('port').onchange = load;
load();
setInterval(load, 60000);
document.addEventListener('click', (e)=>{
const btn = e.target.closest('.js-open-devices');
if (!btn) return;
const sid = btn.getAttribute('data-sid');
openDrawerForSite(sid);
});
function openDrawer() {
document.getElementById('drawer').style.right = '0';
}
function closeDrawer() {
document.getElementById('drawer').style.right = '-1020px';
}
document.getElementById('drawer-close').onclick = closeDrawer;
function parseSiteIds(raw) {
const parts = (raw || "")
.split(/[\s,]+/g)
.map(s => s.trim())
.filter(Boolean);
// dedupe while preserving order
const seen = new Set();
const out = [];
for (const s of parts) {
if (seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
async function fetchDevicesForSites(siteIds) {
const requests = siteIds.map(async (sid) => {
const res = await fetch('/api/devices?site_id=' + encodeURIComponent(sid));
if (!res.ok) throw new Error(`Failed /api/devices for site ${sid}`);
const devs = await res.json();
// Tag each device with its site_id (useful for tooltip context)
return (devs || []).map(d => ({
...d,
_matrix_site_id: sid
}));
});
const results = await Promise.all(requests);
return results.flat();
}
function renderLocalAddressGridInto(gridElId, devs) {
const gridEl = document.getElementById(gridElId);
const tooltip = document.getElementById('addr-tooltip');
if (!gridEl || !tooltip) return;
// addr -> [{device_id, site_id}, ...]
const addrMap = new Map();
for (const d of devs || []) {
const addr = parseInt(d.local_address, 10);
if (!Number.isFinite(addr) || addr < 1 || addr > 120) continue;
if (!addrMap.has(addr)) addrMap.set(addr, []);
addrMap.get(addr).push({
device_id: d.device_id,
site_id: d.site_id || d._matrix_site_id || ""
});
}
gridEl.innerHTML = '';
for (let addr = 1; addr <= 120; addr++) {
const entries = addrMap.get(addr) || [];
const count = entries.length;
const cell = document.createElement('div');
cell.className =
'addr-cell ' +
(count === 0 ? 'addr-free' : count === 1 ? 'addr-taken' : 'addr-dup');
cell.textContent = addr;
// Tooltip text
let tipText;
if (count === 0) {
tipText = `Local address ${addr}\n(no devices assigned)`;
} else if (count === 1) {
const e = entries[0];
tipText = `Local address ${addr}\nDevice:\n${e.device_id}${e.site_id ? ` (site ${e.site_id})` : ""}`;
} else {
tipText =
`Local address ${addr}\n${count} devices:\n` +
entries.map(e => `${e.device_id}${e.site_id ? ` (site ${e.site_id})` : ""}`).join('\n');
}
cell.addEventListener('mouseenter', () => {
tooltip.textContent = tipText;
tooltip.hidden = false;
});
cell.addEventListener('mousemove', (e) => {
tooltip.style.left = (e.clientX + 12) + 'px';
tooltip.style.top = (e.clientY + 12) + 'px';
});
cell.addEventListener('mouseleave', () => {
tooltip.hidden = true;
});
gridEl.appendChild(cell);
}
}
async function runLocalMatrix() {
const textarea = document.getElementById('matrix-sids');
const statusEl = document.getElementById('matrix-status');
const siteIds = parseSiteIds(textarea.value);
if (siteIds.length === 0) {
statusEl.textContent = "Enter at least one site_id.";
renderLocalAddressGridInto('matrix-local-address-grid', []);
return;
}
statusEl.textContent = `Loading ${siteIds.length} site(s)…`;
try {
const devs = await fetchDevicesForSites(siteIds);
statusEl.textContent = `Loaded ${devs.length} device(s) across ${siteIds.length} site(s).`;
renderLocalAddressGridInto('matrix-local-address-grid', devs);
} catch (err) {
console.error(err);
statusEl.textContent = `Error: ${err.message || err}`;
renderLocalAddressGridInto('matrix-local-address-grid', []);
}
}
document.getElementById('matrix-run')?.addEventListener('click', runLocalMatrix);
function buildLocalAddressCounts(devs) {
const counts = new Map(); // addr -> count
for (const d of devs || []) {
// local_address might come back as string from Redis; normalize to int
const addr = parseInt(d.local_address, 10);
if (!Number.isFinite(addr)) continue;
if (addr < 1 || addr > 120) continue;
counts.set(addr, (counts.get(addr) || 0) + 1);
}
return counts;
}
function renderLocalAddressGrid(devs) {
const gridEl = document.getElementById('local-address-grid');
const tooltip = document.getElementById('addr-tooltip');
if (!gridEl || !tooltip) return;
// addr -> [device_id, ...]
const addrMap = new Map();
for (const d of devs || []) {
const addr = parseInt(d.local_address, 10);
if (!Number.isFinite(addr) || addr < 1 || addr > 120) continue;
if (!addrMap.has(addr)) addrMap.set(addr, []);
addrMap.get(addr).push(d.device_id);
}
gridEl.innerHTML = '';
for (let addr = 1; addr <= 120; addr++) {
const devices = addrMap.get(addr) || [];
const count = devices.length;
const cell = document.createElement('div');
cell.className =
'addr-cell ' +
(count === 0 ? 'addr-free' : count === 1 ? 'addr-taken' : 'addr-dup');
cell.textContent = addr;
// Tooltip text
let tipText;
if (count === 0) {
tipText = `Local address ${addr}\n(no devices assigned)`;
} else if (count === 1) {
tipText = `Local address ${addr}\nDevice:\n${devices[0]}`;
} else {
tipText =
`Local address ${addr}\n${count} devices:\n` +
devices.join('\n');
}
// Hover handlers
cell.addEventListener('mouseenter', (e) => {
tooltip.textContent = tipText;
tooltip.hidden = false;
});
cell.addEventListener('mousemove', (e) => {
tooltip.style.left = e.clientX + 12 + 'px';
tooltip.style.top = e.clientY + 12 + 'px';
});
cell.addEventListener('mouseleave', () => {
tooltip.hidden = true;
});
gridEl.appendChild(cell);
}
}
async function openDrawerForSite(siteId) {
// fetch in parallel
const [resDevs, resMeta, resBundle] = await Promise.all([
fetch('/api/devices?site_id=' + encodeURIComponent(siteId)),
fetch('/api/site-meta?site_id=' + encodeURIComponent(siteId)).catch(()=>({ok:false,json:async()=>({})})),
fetch('/api/site-bundle?site_id=' + encodeURIComponent(siteId)).catch(()=>({ok:false,json:async()=>({})}))
]);
const devs = await resDevs.json();
const meta = resMeta && resMeta.ok ? await resMeta.json() : {};
const bundle = resBundle && resBundle.ok ? await resBundle.json() : {};
// title + count
const titleEl = document.getElementById('drawer-title');
const countEl = document.getElementById('drawer-count');
titleEl.textContent = `Site ${siteId}`;
countEl.textContent = `${devs.length} device(s)`;
// store + render
window._drawerDevices = devs;
window._siteMeta = {
site_id: siteId,
name: meta.name || '',
tags: Array.isArray(meta.tags) ? meta.tags : []
};
renderBundle(bundle);
//renderSiteMeta(window._siteMeta);
renderDrawer(devs);
// render local-address occupancy grid
renderLocalAddressGrid(devs);
openDrawer();
}
function renderBundle(bundle) {
const fw = (bundle && bundle.firmware_version) ? String(bundle.firmware_version) : "—";
const err = (bundle && bundle.error_code) ? String(bundle.error_code) : "—";
const icc = (bundle && bundle.iccid) ? String(bundle.iccid) : "—";
const ime = (bundle && bundle.imei) ? String(bundle.imei) : "—";
document.getElementById('bfw').textContent = fw;
document.getElementById('berr').textContent = err;
document.getElementById('biccid').textContent = icc;
document.getElementById('bimei').textContent = ime;
}
function renderSiteMeta(meta) {
// name
const nameEl = document.getElementById('site-name');
nameEl.value = meta.name || '';
// tags
const wrap = document.getElementById('tags-wrap');
wrap.innerHTML = '';
(meta.tags || []).forEach((tag) => {
const chip = document.createElement('span');
chip.className = 'chip';
chip.innerHTML = `${tag} <button aria-label="Remove tag" data-tag="${tag}">&times;</button>`;
wrap.appendChild(chip);
});
// single-use listener per render
wrap.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-tag]');
if (!btn) return;
const t = btn.getAttribute('data-tag');
window._siteMeta.tags = (window._siteMeta.tags || []).filter(x => x !== t);
renderSiteMeta(window._siteMeta);
dirtyMeta();
}, { once: true });
}
document.getElementById('toggle-stats').addEventListener('click', () => {
const stats = document.getElementById('stats');
stats.classList.toggle('hidden');
const icon = document.querySelector('#toggle-stats i');
if (stats.classList.contains('hidden')) {
icon.classList.replace('fa-chart-bar', 'fa-eye');
} else {
icon.classList.replace('fa-eye', 'fa-chart-bar');
}
});
// Tag add on Enter
// document.getElementById('tag-input').addEventListener('keydown', (e) => {
// if (e.key !== 'Enter') return;
// const v = e.currentTarget.value.trim();
// if (!v) return;
// if (!window._siteMeta) window._siteMeta = {};
// if (!Array.isArray(window._siteMeta.tags)) window._siteMeta.tags = [];
// if (!window._siteMeta.tags.includes(v)) window._siteMeta.tags.push(v);
// e.currentTarget.value = '';
// renderSiteMeta(window._siteMeta);
// dirtyMeta();
// });
// Track name edits
// document.getElementById('site-name').addEventListener('input', (e) => {
// if (!window._siteMeta) window._siteMeta = {};
// window._siteMeta.name = e.currentTarget.value;
// dirtyMeta();
// });
// Save site meta button
// document.getElementById('save-meta').addEventListener('click', async () => {
// const meta = window._siteMeta || {};
// setSaveStatus('Saving…');
// const res = await fetch('/api/site-meta', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// site_id: meta.site_id,
// name: (meta.name || '').trim(),
// tags: Array.isArray(meta.tags) ? meta.tags : []
// })
// });
// if (res.ok) {
// setSaveStatus('Saved ✓');
// load(); // refresh cards to show updated name/tags
// } else {
// const txt = await res.text();
// setSaveStatus('Error: ' + txt);
// }
// });
// function setSaveStatus(msg) {
// document.getElementById('save-status').textContent = msg || '';
// }
// let _metaDirtyTimer = null;
// function dirtyMeta() {
// setSaveStatus('Unsaved changes');
// clearTimeout(_metaDirtyTimer);
// _metaDirtyTimer = setTimeout(() => {
// document.getElementById('save-meta').click();
// }, 1500);
// }
function renderDrawer(devs) {
const body = document.getElementById('drawer-body');
const now = Math.floor(Date.now()/1000);
const rows = [];
rows.push(`
<div class="row hdr">
<div>Device ID</div>
<div class="cell--right">Type</div>
<div class="cell--right">Local</div>
<div class="cell--right">RSSI</div>
<div class="cell--right">Volt</div>
<div class="cell--right">Last</div>
<div class="cell--right">Status</div>
</div>
`);
for (const d of devs) {
const ts = Number(d.last_voltage_ts || 0);
const ageMin = ts ? Math.floor((now - ts)/60) : null;
const inactive = ts ? (now - ts) > (24*3600) : true;
// RSSI checks
rssi_color = "#eb4034" // red
if (d.rssi >= -70){
rssi_color = "#34eb74" // green
} else if (d.rssi >= -80){
rssi_color = "#ebd234" // yellow
} else {
rssi_color = "#eb4034" // red
}
// Voltage checks
volt_color = "#eb4034" // red
if (cr123.includes(d.device_type_code)){
if (d.voltage >= 2.9){
volt_color = "#34eb74" // green
} else if (d.voltage >= 2.8) {
volt_color = "#ebd234" // yellow
} else {
volt_color = "#eb4034" // red
}
} else if (recharge.includes(d.device_type_code)){
if (d.voltage >= 3.6){
volt_color = "#34eb74" // green
} else if (d.voltage >= 3.2) {
volt_color = "#ebd234" // yellow
} else {
volt_color = "#eb4034" // red
}
} else {
volt_color = "#eb4034" // red
}
rows.push(`
<div class="row data">
<div class="cell--mono">${safeText(d.device_id)}</div>
<div class="cell--right">${safeText(d.device_type_code || '-')}</div>
<div class="cell--right">${safeText(d.local_address ?? '-')}</div>
<div class="cell--right" style="background-color:${rssi_color};color:black"><strong>${safeText(d.rssi ?? '-')}</strong></div>
<div class="cell--right" style="background-color:${volt_color};color:black"><strong>${safeText(d.voltage ?? '-')}</strong></div>
<div class="cell--right">${ageMin != null ? ageMin + ' min' : ''}</div>
<div class="cell--right">
<span class="pill-sm ${inactive ? 'bad' : 'ok'}">${inactive ? 'inactive' : 'active'}</span>
</div>
</div>
`);
}
body.innerHTML = rows.join('');
}
// drawer filter
document.getElementById('dev-filter').addEventListener('input', (e)=>{
const q = e.target.value.trim().toLowerCase();
const all = window._drawerDevices || [];
if (!q) return renderDrawer(all);
const filtered = all.filter(d =>
String(d.device_id).toLowerCase().includes(q) ||
String(d.local_address ?? '').toLowerCase().includes(q) ||
String(d.device_type_code ?? '').toLowerCase().includes(q)
);
renderDrawer(filtered);
});
/* -----------------
Cache tab logic
------------------*/
window._cacheDevices = [];
const exportBtn = document.getElementById('export-cache-csv');
if (exportBtn) {
exportBtn.addEventListener('click', exportCacheCsv);
}
document.getElementById('cache-input').addEventListener('keydown', async (e) => {
if (e.key !== 'Enter') return;
const raw = e.currentTarget.value.trim();
if (!raw) return;
const dids = raw.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
if (!dids.length) return;
const res = await fetch('/api/device-cache', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ device_ids: dids })
});
const data = await res.json();
renderCacheSummary(data);
renderCacheDFTables(data);
});
function renderCacheSummary(data) {
const rowsEl = document.getElementById('cache-rows');
const now = Math.floor(Date.now()/1000);
const out = [];
for (const d of (data.devices || [])) {
const ts = Number(d.latest_ts || 0);
out.push(`
<div class="row data">
<div class="cell--mono">${safeText(d.device_id)}</div>
<div class="cell--right">${safeText(d.device_type_code || d.device_type || '-')}</div>
<div class="cell--right">${safeText(d.site_id || '-')}</div>
<div class="cell--right">${safeText(d.local_address ?? '-')}</div>
<div class="cell--right">${ts ? Math.floor((now-ts)/60) + ' min' : ''}</div>
<div class="cell--right">${safeText(d.rssi ?? '-')}</div>
<div class="cell--right">${safeText(d.voltage ?? '-')}</div>
</div>
`);
}
rowsEl.innerHTML = out.join('');
// store for CSV export
window._cacheDevices = Array.isArray(data?.devices) ? data.devices : [];
if (exportBtn) exportBtn.disabled = window._cacheDevices.length === 0;
}
function renderCacheDFTables(data) {
// keep hidden by default unless you want it visible
const wrapCard = document.getElementById('cache-df-wrap');
const wrap = document.getElementById('cache-df-tables');
wrap.innerHTML = '';
const df_map = data.df_map || {};
const dids = Object.keys(df_map);
if (!dids.length) {
wrapCard.style.display = 'none';
return;
}
wrapCard.style.display = '';
for (const [did, perDf] of Object.entries(df_map)) {
const cont = document.createElement('div');
cont.className = 'card';
cont.style.marginTop = '10px';
cont.style.padding = '12px';
cont.innerHTML = `
<div class="site">Device ${safeText(did)}</div>
<div class="row hdr">
<div>DataFormat</div>
<div class="cell--right">Value</div>
<div class="cell--right">Optional</div>
<div class="cell--right">Timestamp</div>
<div class="cell--right">Type Code</div>
</div>
<div id="rows-${did}"></div>
`;
wrap.appendChild(cont);
const body = cont.querySelector(`#rows-${did}`);
const r = [];
for (const [df, obj] of Object.entries(perDf)) {
r.push(`
<div class="row data">
<div class="cell--mono">${safeText(df)}</div>
<div class="cell--right">${safeText(obj.value ?? '')}</div>
<div class="cell--right">${safeText(obj.optional ?? '')}</div>
<div class="cell--right">${safeText(obj.ts ?? '')}</div>
<div class="cell--right">${safeText(obj.type_code ?? '')}</div>
</div>
`);
}
body.innerHTML = r.join('') || `<div class="meta">No cached DF values.</div>`;
}
}
function exportCacheCsv() {
const rows = window._cacheDevices || [];
if (!rows.length) return;
const nowSec = Math.floor(Date.now()/1000);
const header = [
'Device ID',
'Type',
'Site',
'Local addr',
'Latest ts (min ago)',
'Latest ts (ISO)',
'Latest ts (epoch)'
];
const dataRows = rows.map(d => {
const ts = Number(d.latest_ts || 0);
const ageMin = ts ? Math.floor((nowSec - ts)/60) : '';
const iso = ts ? new Date(ts*1000).toISOString() : '';
const type = d.device_type_code || d.device_type || '';
return [
csvEscape(d.device_id),
csvEscape(type),
csvEscape(d.site_id || ''),
csvEscape(d.local_address ?? ''),
String(ageMin),
csvEscape(iso),
ts ? String(ts) : ''
];
});
const csv = [header, ...dataRows].map(r => r.join(',')).join('\r\n');
const blob = new Blob(["\uFEFF" + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const stamp = new Date().toISOString().replace(/[:T]/g,'-').split('.')[0];
const a = document.createElement('a');
a.href = url;
a.download = `device_cache_${stamp}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function csvEscape(val) {
const s = (val === null || val === undefined) ? '' : String(val);
if (/[",\n\r]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
/* -----------------
Device Checks tab logic (NEW)
------------------*/
// We'll maintain an array of {did, type, status} objects
window._checksList = [];
const checksInput = document.getElementById('checks-input');
const checksRowsEl = document.getElementById('checks-rows');
const runAllBtn = document.getElementById('run-all-btn');
const clearAllBtn = document.getElementById('clear-all-btn');
// Return the list of tests this device supports, based on its code_name
function getTestsForCodeName(codeName) {
// normalize for safety
const c = (codeName || '').toUpperCase();
// STRAIN-like devices (STRAIN / STRED share behavior here)
if (c === 'STRAIN' || c === 'STRED') {
return [
{ kind: 'battery', label: 'Battery' },
{ kind: 'signal', label: 'Signal' },
{ kind: 'strain_classify', label: 'Measurements' }
];
}
// Water level sensor (WLS example)
if (c === 'WLS_S') {
return [
{ kind: 'battery', label: 'Battery' },
{ kind: 'signal', label: 'Signal' },
{ kind: 'wls_classify', label: 'Measurements' }
];
}
if (c === 'HPA') {
return [
{ kind: 'battery', label: 'Battery' },
{ kind: 'signal', label: 'Signal' },
{ kind: 'hpa_classify', label: 'Measurements' }
];
}
if (c === 'SNMX') {
return [
{ kind: 'battery', label: 'Battery' },
{ kind: 'charge_classify', label: 'Charging' },
];
}
// Default / fallback
// If you really do have a generic "classify", include it here:
return [
{ kind: 'battery', label: 'Battery' },
{ kind: 'signal', label: 'Signal' },
{ kind: 'classify', label: 'Classify' }
];
}
function updateRowHighlight(did) {
const row = document.querySelector(`.check-row[data-did="${CSS.escape(did)}"]`);
if (!row) return;
const device = window._checksList.find(d => d.did === did);
if (!device) return;
const statuses = Object.values(device.statuses);
const allPass = statuses.length && statuses.every(v => v === 'pass');
const anyFail = statuses.some(v => v === 'fail');
row.classList.toggle('all-ok', allPass);
if (anyFail) {
const panel = document.querySelector(`.check-details[data-did="${CSS.escape(did)}"]`);
panel?.removeAttribute('hidden'); // auto-open on failure (optional)
row.classList.add('expanded');
}
}
async function addDeviceToChecks(did) {
const clean = (did || '').trim();
if (!clean) return;
// De-duplicate
if (window._checksList.find(d => d.did === clean)) return;
// Fetch device meta (for code_name) — falls back gracefully
let devInfo = { device_id: clean, code_name: '—' };
try {
const res = await fetch('/api/device-info?did=' + encodeURIComponent(clean));
if (res.ok) devInfo = await res.json();
} catch (_) {}
const codeName = devInfo.code_name || '—';
const tests = getTestsForCodeName(codeName);
const statuses = {};
for (const t of tests) statuses[t.kind] = 'wait';
window._checksList.push({
did: clean,
codeName,
tests,
statuses, // e.g., { battery: 'wait', signal: 'wait', ... }
details: {} // e.g., { battery: { ts, explain, metrics, thresholds } }
});
renderChecksTable();
}
// Add device on Enter
checksInput.addEventListener('keydown', async (e) => {
if (e.key !== 'Enter') return;
const did = e.currentTarget.value.trim();
if (!did) return;
e.currentTarget.value = '';
await addDeviceToChecks(did);
});
document.getElementById('load-site-btn')?.addEventListener('click', async () => {
const siteInput = document.getElementById('site-id-input');
const btn = document.getElementById('load-site-btn');
const sid = (siteInput?.value || '').trim();
if (!sid) {
siteInput?.focus();
siteInput?.setAttribute('placeholder', 'Please enter a Site ID');
return;
}
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Loading…';
try {
// Reuse the same API the drawer uses
const res = await fetch('/api/devices?site_id=' + encodeURIComponent(sid));
if (!res.ok) throw new Error('Failed to fetch devices');
const devs = await res.json();
// Optional: sort by device_id for stable UX
devs.sort((a, b) => String(a.device_id).localeCompare(String(b.device_id)));
// Add each device to the checks list (dedupe handled inside helper)
for (const d of devs) {
await addDeviceToChecks(d.device_id);
// (Optional) small micro-yield for smoother rendering:
// await new Promise(r => setTimeout(r, 0));
}
// Visual confirmation
btn.textContent = `Loaded ${devs.length} device(s)`;
} catch (err) {
console.error(err);
btn.textContent = 'Error loading';
} finally {
setTimeout(() => {
btn.textContent = original;
btn.disabled = false;
}, 1200);
}
});
// Clear all rows
clearAllBtn.addEventListener('click', () => {
window._checksList = [];
renderChecksTable();
});
// Run all checks for all devices
// runAllBtn.addEventListener('click', async () => {
// for (const row of window._checksList) {
// await runSingleTest(row.did, 'battery');
// await runSingleTest(row.did, 'signal');
// await runSingleTest(row.did, 'classify');
// }
// });
runAllBtn.addEventListener('click', async () => {
for (const row of window._checksList) {
for (const t of row.tests) {
await runSingleTest(row.did, t.kind);
}
}
});
// Main executor for a single test on a single device.
async function runSingleTest(did, kind) {
markTestRunning(did, kind, true);
try {
let ok = false, explain = '', metrics = null, thresholds = null;
const post = async (url) => {
const resp = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ did })});
if (!resp.ok) return { ok:false };
const data = await resp.json();
return {
ok: !!data.ok,
explain: data.explain || '',
metrics: data.metrics || null,
thresholds: data.thresholds || null
};
};
let res = { ok:false };
if (kind === 'signal') res = await post('/internal/check-signal');
else if (kind === 'battery') res = await post('/internal/check-battery');
else if (kind === 'strain_classify') res = await post('/internal/check-strain');
else if (kind === 'hpa_classify') res = await post('/internal/check-hpa');
else if (kind === 'charge_classify') res = await post('/internal/check-charge');
else if (kind === 'wls_classify') res = await post('/internal/check-wls');
else if (kind === 'classify') res = { ok: Math.random() > 0.25, explain: 'Generic classifier used.' };
console.log("INTERNAL RES: ",res);
ok = res.ok; explain = res.explain; metrics = res.metrics; thresholds = res.thresholds;
// Fallback: if API didnt send an explanation, create a simple one
if (!explain) {
explain = ok ? 'All checks within expected ranges.' : 'One or more values out of range.';
}
// Save details on the device model
const rowObj = window._checksList.find(x => x.did === did);
if (rowObj) {
rowObj.details[kind] = {
ts: new Date().toISOString(),
ok,
explain,
metrics,
thresholds
};
}
setTestStatus(did, kind, ok ? 'pass' : 'fail');
} catch {
setTestStatus(did, kind, 'fail');
} finally {
markTestRunning(did, kind, false);
renderChecksTable();
}
}
// Update internal model + DOM badge for one test
function setTestStatus(did, kind, status) {
// update in-memory model
const rowObj = window._checksList.find(x => x.did === did);
if (!rowObj) return;
if (!rowObj.statuses.hasOwnProperty(kind)) {
// this device doesn't track this test kind
return;
}
rowObj.statuses[kind] = status;
// update DOM badge if present
const badge = document.querySelector(
`.check-row[data-did="${CSS.escape(did)}"] .status-badge[data-kind="${kind}"]`
);
if (!badge) return;
badge.classList.remove('status-pass','status-fail','status-wait');
if (status === 'pass') {
badge.textContent = '✓';
badge.classList.add('status-pass');
} else if (status === 'fail') {
badge.textContent = '✕';
badge.classList.add('status-fail');
} else {
badge.textContent = '…';
badge.classList.add('status-wait');
}
updateRowHighlight(did);
}
function markTestRunning(did, kind, isRunning) {
const btn = document.querySelector(
`.check-row[data-did="${CSS.escape(did)}"] .check-btn[data-kind="${kind}"]`
);
if (!btn) return;
if (isRunning) {
btn.classList.add('running');
btn.disabled = true;
} else {
btn.classList.remove('running');
btn.disabled = false;
}
}
// Remove a row by DID
function removeDeviceRow(did) {
window._checksList = window._checksList.filter(d => d.did !== did);
renderChecksTable();
}
function iconHTMLForDevice(code) {
const c = (code || '').toUpperCase();
if (['STRAIN', 'STRED'].includes(c))
return '<i class="fas fa-wave-square" title="Strain gauge"></i>';
if (c.startsWith('WLS'))
return '<i class="fas fa-water" title="Water level sensor"></i>';
if (c === 'HPA')
return '<i class="fas fa-bolt" title="Accelerometer"></i>';
return '<i class="fas fa-microchip" title="Generic sensor"></i>';
}
function getOpenPanels() {
const open = {};
document.querySelectorAll('.check-details').forEach(p => {
const did = p.getAttribute('data-did');
open[did] = !p.hasAttribute('hidden');
});
return open;
}
function restoreOpenPanels(openMap) {
Object.entries(openMap || {}).forEach(([did, isOpen]) => {
const panel = document.querySelector(`.check-details[data-did="${CSS.escape(did)}"]`);
const row = document.querySelector(`.check-row[data-did="${CSS.escape(did)}"]`);
if (!panel || !row) return;
if (isOpen) {
panel.removeAttribute('hidden');
row.classList.add('expanded');
} else {
panel.setAttribute('hidden','');
row.classList.remove('expanded');
}
});
}
function renderChecksTable() {
// Remember which panels are open (merge any persisted state too)
const openFromDOM = {};
document.querySelectorAll('.check-details').forEach(p => {
const did = p.getAttribute('data-did');
openFromDOM[did] = !p.hasAttribute('hidden');
});
window._checksOpen = window._checksOpen || {};
const openMap = { ...window._checksOpen, ...openFromDOM };
const rows = [];
for (const d of window._checksList) {
// helper: get current status badge HTML for a given test kind
function badgeHtml(kind, labelIfMissing = '') {
// if this device doesn't even have that test, show '' so table cell is blank
if (!d.statuses.hasOwnProperty(kind)) {
return labelIfMissing;
}
const st = d.statuses[kind];
const statusClass =
st === 'pass' ? 'status-pass' :
st === 'fail' ? 'status-fail' :
'status-wait';
const statusChar =
st === 'pass' ? '✓' :
st === 'fail' ? '✕' :
'…';
return `<span class="status-badge ${statusClass}" data-kind="${kind}">${statusChar}</span>`;
}
// build action buttons (Battery / Signal / Measurements / etc.)
const actionBtnsHtml = d.tests.map(t => {
return `<button class="check-btn js-check"
data-kind="${t.kind}"
data-did="${d.did}">${t.label}</button>`;
}).join(' ')
+ `<button class="check-btn js-details"
data-did="${d.did}">Details</button>`
+ `<button class="check-btn js-install disable"
data-did="${d.did}" disabled>Mark Installed</button>`
+ `<button class="check-btn js-remove"
data-did="${d.did}"
title="Remove this device">✕</button>`;
// For column 5 we try to choose the "primary" classify-ish test
// In priority order: strain_classify, wls_classify, hpa_classify, classify
const primaryClassifyKind =
d.statuses['strain_classify'] ? 'strain_classify' :
d.statuses['wls_classify'] ? 'wls_classify' :
d.statuses['hpa_classify'] ? 'hpa_classify' :
d.statuses['classify'] ? 'classify' :
null;
rows.push(`
<div class="check-row data" data-did="${d.did}" style="padding: 8px 16px;">
<div class="cell--mono">${d.did}</div>
<div class="cell--right">${iconHTMLForDevice(d.codeName)} ${safeText(d.codeName)}</div>
<div class="cell--right">${badgeHtml('battery')}</div>
<div class="cell--right">${badgeHtml('signal')}</div>
<div class="cell--right">${primaryClassifyKind ? badgeHtml(primaryClassifyKind) : ''}</div>
<div class="check-actions">${actionBtnsHtml}</div>
</div>
<div class="check-details" data-did="${d.did}" hidden>
${d.tests.map(t => {
const det = (d.details && d.details[t.kind]) || null;
const icon = det?.ok ? '✓' : (det === null ? '…' : '✕');
const state = det?.ok ? 'status-pass' : (det === null ? 'status-wait' : 'status-fail');
const when = det?.ts ? new Date(det.ts).toLocaleString() : '';
const explain = det?.explain ? safeText(det.explain) : 'No results yet — run this test.';
const metrics = det?.metrics ? Object.entries(det.metrics).map(([k,v]) => `<div class="kv"><div class="k">${safeText(k)}</div><div class="v mono">${safeText(v)}</div></div>`).join('') : '';
const thresholds = det?.thresholds ? Object.entries(det.thresholds).map(([k,v]) => `<span class="pill-tag">${safeText(k)}: ${safeText(v)}</span>`).join(' ') : '';
return `
<div class="detail-row">
<div class="detail-head">
<span class="status-badge ${state}" aria-hidden="true">${icon}</span>
<strong style="margin-left:8px">${safeText(t.label)}</strong>
${when ? `<span class="meta" style="margin-left:8px">(${when})</span>` : ''}
</div>
<div class="detail-body">
<div class="detail-explain">${explain}</div>
${metrics ? `<div class="detail-metrics">${metrics}</div>` : ''}
${thresholds ? `<div class="detail-thresholds" style="margin-top:6px">${thresholds}</div>` : ''}
</div>
</div>
`;
}).join('')}
</div>
`);
}
// Swap DOM
const openBeforeSwap = openMap; // snapshot
checksRowsEl.innerHTML = rows.join('');
// Re-bind listeners
checksRowsEl.querySelectorAll('.js-check').forEach(btn => {
btn.addEventListener('click', async (ev) => {
const did = ev.currentTarget.getAttribute('data-did');
const kind = ev.currentTarget.getAttribute('data-kind');
await runSingleTest(did, kind);
// Re-render; open panels will be preserved below
renderChecksTable();
});
});
checksRowsEl.querySelectorAll('.js-remove').forEach(btn => {
btn.addEventListener('click', (ev) => {
const did = ev.currentTarget.getAttribute('data-did');
removeDeviceRow(did);
});
});
checksRowsEl.querySelectorAll('.js-details').forEach(btn => {
btn.addEventListener('click', (ev) => {
const did = ev.currentTarget.getAttribute('data-did');
const panel = checksRowsEl.querySelector(`.check-details[data-did="${CSS.escape(did)}"]`);
const row = checksRowsEl.querySelector(`.check-row[data-did="${CSS.escape(did)}"]`);
if (!panel) return;
const willOpen = panel.hasAttribute('hidden');
if (willOpen) {
panel.removeAttribute('hidden');
row?.classList.add('expanded');
window._checksOpen[did] = true;
} else {
panel.setAttribute('hidden','');
row?.classList.remove('expanded');
window._checksOpen[did] = false;
}
});
});
function exportChecksCSV() {
const rows = [];
const allMetricKeys = new Set();
const allThreshKeys = new Set();
// Build an intermediate array of per-test results well flatten to CSV
const results = [];
for (const d of (window._checksList || [])) {
// Only include tests that have details (i.e., were run at least once)
for (const t of (d.tests || [])) {
const det = d.details?.[t.kind];
if (!det) continue;
// Track column keys we might need to add
if (det.metrics && typeof det.metrics === 'object') {
Object.keys(det.metrics).forEach(k => allMetricKeys.add(k));
}
if (det.thresholds && typeof det.thresholds === 'object') {
Object.keys(det.thresholds).forEach(k => allThreshKeys.add(k));
}
results.push({
did: d.did,
codeName: d.codeName || '',
testKind: t.kind,
testLabel: t.label || t.kind,
status: det.ok === true ? 'pass' : det.ok === false ? 'fail' : 'wait',
ts: det.ts || '',
explain: det.explain || '',
metrics: det.metrics || {},
thresholds: det.thresholds || {}
});
}
}
if (results.length === 0) {
alert('No test results to export yet.');
return;
}
// Build header
const metricCols = Array.from(allMetricKeys).map(k => `metric_${k}`);
const threshCols = Array.from(allThreshKeys).map(k => `threshold_${k}`);
const header = [
'did','device_type','test_kind','test_label','status','timestamp','explain',
...metricCols,
...threshCols
];
// CSV escape helper
const esc = (v) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
// Header row
rows.push(header.map(esc).join(','));
// Data rows
for (const r of results) {
const metricVals = metricCols.map(col => {
const key = col.replace(/^metric_/, '');
return esc(r.metrics[key]);
});
const threshVals = threshCols.map(col => {
const key = col.replace(/^threshold_/, '');
return esc(r.thresholds[key]);
});
rows.push([
esc(r.did),
esc(r.codeName),
esc(r.testKind),
esc(r.testLabel),
esc(r.status),
esc(r.ts),
esc(r.explain),
...metricVals,
...threshVals
].join(','));
}
const csv = rows.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const now = new Date().toISOString().replace(/[:.]/g,'-');
a.download = `device_checks_${now}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
document.getElementById('exportChecksCsvBtn')?.addEventListener('mouseup', (ev) => {
ev.stopPropagation();
exportChecksCSV();
});
// Restore open/closed state after re-render
function restoreOpenPanels(map) {
Object.entries(map || {}).forEach(([did, isOpen]) => {
const panel = checksRowsEl.querySelector(`.check-details[data-did="${CSS.escape(did)}"]`);
const row = checksRowsEl.querySelector(`.check-row[data-did="${CSS.escape(did)}"]`);
if (!panel || !row) return;
if (isOpen) {
panel.removeAttribute('hidden');
row.classList.add('expanded');
} else {
panel.setAttribute('hidden','');
row.classList.remove('expanded');
}
});
}
restoreOpenPanels(openBeforeSwap);
// Persist current state for future renders
window._checksOpen = { ...openBeforeSwap };
}
/* -----------------
Tabs switching
------------------*/
const pageSites = document.getElementById('page-sites');
const pageCache = document.getElementById('page-cache');
const pageChecks = document.getElementById('page-checks');
const pageMatrix = document.getElementById('page-local-matrix');
function showPage(which) {
pageSites.style.display = (which === 'sites') ? '' : 'none';
pageCache.style.display = (which === 'cache') ? '' : 'none';
pageChecks.style.display = (which === 'checks') ? '' : 'none';
pageMatrix.style.display = (which === 'matrix') ? '' : 'none';
}
document.getElementById('tab-sites').onclick = () => showPage('sites');
document.getElementById('tab-cache').onclick = () => showPage('cache');
document.getElementById('tab-checks').onclick = () => showPage('checks');
document.querySelector('.nav-btn[data-page="page-local-matrix"]')?.addEventListener('click', () => {
showPage('matrix');
// Optional: auto-run if textarea has values
const v = document.getElementById('matrix-sids')?.value?.trim();
if (v) runLocalMatrix();
});
</script>
<script>
// -------------------------------------------------------------------------
// Toast helpers (Toastify is global from the non-module script tag above)
// -------------------------------------------------------------------------
function toastOk(msg) {
Toastify({
text: msg,
duration: 2600,
gravity: "bottom",
position: "center",
style: { background: "rgba(34,197,94,0.95)" }
}).showToast();
}
function toastErr(msg) {
Toastify({
text: msg,
duration: 3200,
gravity: "bottom",
position: "center",
style: { background: "rgba(239,68,68,0.95)" }
}).showToast();
}
function toastInfo(msg) {
Toastify({
text: msg,
duration: 2600,
gravity: "bottom",
position: "center",
style: { background: "rgba(56,189,248,0.95)" }
}).showToast();
}
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
const accepted = new Map(); // id -> { id, deviceType }
const pendingQueue = [];
let confirming = false;
// -------------------------------------------------------------------------
// Barcode scanner (BarcodeDetector + ZXing)
// -------------------------------------------------------------------------
const scannerOverlay = document.getElementById("scanner-overlay");
const scannerVideo = document.getElementById("scanner-video");
const scannerCancel = document.getElementById("scanner-cancel");
let scanStream = null;
let scanTrack = null;
let scanning = false;
let scanRafId = null;
let lastDecodeTime = 0;
const DECODE_INTERVAL = 120;
const scanCanvas = document.createElement("canvas");
const scanCtx = scanCanvas.getContext("2d", { willReadFrequently: true });
let scanEngine = null;
let nativeDetector = null;
let zxingReader = null;
let scanEngineReady = false;
function normalizeBarcode(raw) {
if (!raw) return null;
const digits = String(raw).replace(/\D/g, "");
if (digits.length !== 8) return null;
return digits.replace(/(\d\d)(\d\d)(\d\d)(\d\d)/, "$1-$2-$3-$4");
}
async function initScanEngine() {
if (scanEngineReady) return scanEngine !== null;
scanEngineReady = true;
if ("BarcodeDetector" in window) {
try {
const formats = await window.BarcodeDetector.getSupportedFormats();
if (formats.includes("code_128")) {
nativeDetector = new window.BarcodeDetector({ formats: ["code_128"] });
scanEngine = "native";
return true;
}
} catch (e) { /* fall through */ }
}
if (window.ZXing) {
const hints = new Map();
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, [ZXing.BarcodeFormat.CODE_128]);
hints.set(ZXing.DecodeHintType.TRY_HARDER, true);
zxingReader = new ZXing.MultiFormatReader();
zxingReader.setHints(hints);
scanEngine = "zxing";
return true;
}
scanEngine = null;
return false;
}
async function startScanCamera() {
try {
scanStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
focusMode: "continuous"
},
audio: false
});
} catch (err) {
handleScanCameraError(err);
return false;
}
scannerVideo.srcObject = scanStream;
await scannerVideo.play().catch(() => {});
scanTrack = scanStream.getVideoTracks()[0];
return true;
}
function stopScanCamera() {
if (scanRafId) cancelAnimationFrame(scanRafId);
scanRafId = null;
scanning = false;
if (scanStream) scanStream.getTracks().forEach(t => t.stop());
scanStream = null;
scanTrack = null;
scannerVideo.srcObject = null;
}
function handleScanCameraError(err) {
let msg;
if (err && (err.name === "NotAllowedError" || err.name === "SecurityError")) {
msg = "Camera access was blocked. Allow camera permission for this page, then try again. The page must be served over HTTPS.";
} else if (err && err.name === "NotFoundError") {
msg = "No camera found on this device.";
} else {
msg = "Could not start the camera. Serve this page over HTTPS or localhost and try again.";
}
toastErr(msg);
closeScannerOverlay();
}
function drawCropToCanvas() {
const vw = scannerVideo.videoWidth;
const vh = scannerVideo.videoHeight;
if (!vw || !vh) return false;
const cropW = Math.round(vw * 0.82);
const cropH = Math.round(cropW / 2.6);
const sx = Math.round((vw - cropW) / 2);
const sy = Math.round((vh - cropH) / 2);
if (scanCanvas.width !== cropW || scanCanvas.height !== cropH) {
scanCanvas.width = cropW;
scanCanvas.height = cropH;
}
scanCtx.drawImage(scannerVideo, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
return true;
}
function handleDecodedBarcode(text) {
const id = normalizeBarcode(text);
if (!id) return;
if (accepted.has(id) || pendingQueue.includes(id)) return;
enqueueForConfirm(id);
toastOk(`Queued ${id}`);
if (navigator.vibrate) navigator.vibrate(40);
}
async function scanTick(now) {
if (!scanning) return;
scanRafId = requestAnimationFrame(scanTick);
if (now - lastDecodeTime < DECODE_INTERVAL) return;
lastDecodeTime = now;
if (!drawCropToCanvas()) return;
let text = null;
try {
if (scanEngine === "native") {
const codes = await nativeDetector.detect(scanCanvas);
if (codes && codes.length) text = codes[0].rawValue;
} else if (scanEngine === "zxing") {
const lum = new ZXing.HTMLCanvasElementLuminanceSource(scanCanvas);
const bitmap = new ZXing.BinaryBitmap(new ZXing.HybridBinarizer(lum));
try {
const res = zxingReader.decode(bitmap);
text = res ? res.getText() : null;
} catch (e) { /* no barcode this frame */ }
finally { zxingReader.reset(); }
}
} catch (e) { /* keep scanning */ }
if (text) handleDecodedBarcode(text);
}
function closeScannerOverlay() {
stopScanCamera();
scannerOverlay.style.display = "none";
}
async function openScannerOverlay() {
const ok = await initScanEngine();
if (!ok) {
toastErr("Barcode scanner unavailable in this browser.");
return;
}
scannerOverlay.style.display = "flex";
const camOk = await startScanCamera();
if (!camOk) return;
scanning = true;
lastDecodeTime = 0;
scanRafId = requestAnimationFrame(scanTick);
}
scannerCancel.addEventListener("click", () => {
closeScannerOverlay();
if (pendingQueue.length) processQueue();
});
window.addEventListener("pagehide", stopScanCamera);
// -------------------------------------------------------------------------
// Confirm modal (queue-based)
// -------------------------------------------------------------------------
const modal = document.getElementById("confirm-modal");
const confirmTextEl = document.getElementById("confirm-text");
const confirmIdEl = document.getElementById("confirm-id");
const btnAccept = document.getElementById("btn-accept");
const btnDeny = document.getElementById("btn-deny");
function enqueueForConfirm(id) {
if (accepted.has(id)) return;
if (pendingQueue.includes(id)) return;
pendingQueue.push(id);
}
function processQueue() {
if (confirming) return;
const next = pendingQueue.shift();
if (!next) return;
showConfirm(next);
}
function showConfirm(id) {
confirming = true;
confirmTextEl.textContent = `Add this ID to the table?`;
confirmIdEl.textContent = id;
modal.style.display = "flex";
const cleanup = () => {
modal.style.display = "none";
confirming = false;
};
btnAccept.onclick = async () => {
cleanup();
console.log(`We got id ${id}.`);
if (!accepted.has(id)) {
accepted.set(id, { id, deviceType: "" });
// renderTable();
toastOk(`Added ${id}`);
await addDeviceToChecks(id);
} else {
toastErr(`Already added: ${id}`);
}
processQueue();
};
btnDeny.onclick = () => {
cleanup();
toastErr(`Denied ${id}`);
processQueue();
};
}
// Keyboard shortcuts for modal
window.addEventListener("keydown", (e) => {
if (modal.style.display !== "flex") return;
if (e.key === "Escape") btnDeny.click();
if (e.key === "Enter") btnAccept.click();
});
// Clicking outside the modal card denies
modal.addEventListener("click", (e) => {
if (e.target === modal) btnDeny.click();
});
// -------------------------------------------------------------------------
// Buttons
// -------------------------------------------------------------------------
document.getElementById("btn-scan").onclick = () => {
openScannerOverlay();
};
</script>
</body>
</html>