Use BarcodeDetector and ZXing for unlimited device ID scanning instead of the Scanbot trial SDK.
2937 lines
91 KiB
HTML
2937 lines
91 KiB
HTML
<!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 {
|
||
white-space:nowrap;
|
||
}
|
||
|
||
/* ===== 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"
|
||
style="padding:6px 10px;border-radius:10px;border:1px solid #ccc;cursor:pointer;">
|
||
📷 Scan ID
|
||
</button>
|
||
<button id="run-all-btn"
|
||
style="padding:6px 10px;border-radius:10px;border:1px solid #ccc;cursor:pointer;">
|
||
▶ Run All Checks
|
||
</button>
|
||
<button id="clear-all-btn"
|
||
style="padding:6px 10px;border-radius:10px;border:1px solid #ccc;cursor:pointer;">
|
||
✕ 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"
|
||
style="padding:6px 10px;border-radius:10px;border:1px solid #ccc;cursor:pointer;">
|
||
⇥ 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 >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}">×</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 didn’t 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 we’ll 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>
|