1067 lines
40 KiB
HTML
1067 lines
40 KiB
HTML
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Gateway Status Grid</title>
|
||
<link rel="icon" href="/static/logo.png" type="image/png">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
:root{color-scheme: light dark;}
|
||
body { font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 16px; }
|
||
.toolbar { display:flex; gap:8px; align-items:center; margin-bottom:12px; flex-wrap:wrap; }
|
||
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; } */
|
||
.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; }
|
||
|
||
.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; }
|
||
.pill-tag { background:#6b7280; color:#fff; padding:2px 6px; border-radius:999px; font-size:11px; }
|
||
@media (prefers-color-scheme: dark) {
|
||
.meta-form { border-bottom-color:#2a2a2a; }
|
||
.meta-row input[type="text"] { border-color:#161616; background:#f6f6f6; color:#222222; }
|
||
.chip { background:#2a2f45; border-color:#3a415c; color:#e5e7eb; }
|
||
}
|
||
|
||
.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 { 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 { position:fixed; top:0; right:-1020px; width:1020px; max-width:92vw; height:100vh;
|
||
background:#fff; border-left:1px solid #ddd; 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:#fff; z-index:1; }
|
||
|
||
.row {
|
||
display:grid;
|
||
grid-template-columns: minmax(16ch, 1fr) 10ch 10ch 10ch 10ch 10ch 10ch; /* + 'Type' column (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;
|
||
}
|
||
|
||
/* Cells */
|
||
.cell--mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
.cell--right { text-align:right; }
|
||
.pill-sm { display:inline-block; padding:2px 8px; border-radius:999px; color:#fff; font-size:11px }
|
||
.ok { background:#00a86b } .bad { background:#dc3545 }
|
||
|
||
/* Hover rows */
|
||
.row.data:hover {
|
||
background: rgba(0,0,0,.035);
|
||
border-radius:8px;
|
||
}
|
||
|
||
/* Dark mode contrast */
|
||
@media (prefers-color-scheme: dark) {
|
||
.card { background:#1b1b1b; border-color:#2a2a2a; }
|
||
.card:hover { border-color:#3a3a3a; }
|
||
#drawer { background:#1b1b1b; border-left-color:#2a2a2a; }
|
||
.row { border-bottom-color:#2a2a2a; }
|
||
.row.hdr { border-bottom-color:#3a3a3a; background:#1b1b1b; }
|
||
.row.data:hover { background:rgba(255,255,255,.06); }
|
||
}
|
||
|
||
.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; }
|
||
|
||
/* color cues */
|
||
.stat-online .stat-value { color:#00a86b; }
|
||
.stat-recent .stat-value { color:#e4a11b; }
|
||
.stat-offline .stat-value { color:#dc3545; }
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
.stat-card { background:#1b1b1b; border-color:#2a2a2a; }
|
||
}
|
||
|
||
.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; }
|
||
@media (prefers-color-scheme: dark) {
|
||
#bundle-card { border-bottom-color:#2a2a2a; }
|
||
}
|
||
|
||
|
||
|
||
/* ===== THEME: GLASS ===== */
|
||
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); }
|
||
}
|
||
|
||
|
||
/* ===== THEME: NEON ===== */
|
||
body.theme-neon {
|
||
background: #0b0f16;
|
||
color: #e5e7eb;
|
||
--neon: #4fd1c5; /* teal */
|
||
--neon2: #a78bfa; /* violet */
|
||
}
|
||
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 .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; }
|
||
|
||
|
||
/* ===== THEME: MINIMAL ===== */
|
||
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; }
|
||
|
||
|
||
/* ===== THEME: AURORA (LIGHT-FIRST) ===== */
|
||
body.theme-aurora {
|
||
--bg: #f9fafb;
|
||
--card: #ffffff;
|
||
--ink: #0f172a; /* slate-900 */
|
||
--subink: #64748b; /* slate-500 */
|
||
--line: #e5e7eb; /* gray-200 */
|
||
--accent: #2563eb; /* blue-600 */
|
||
--accent-2: #22c55e; /* green-500 */
|
||
--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; /* gray-300 */
|
||
}
|
||
|
||
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; /* blue-100 */
|
||
color: #1e293b;
|
||
}
|
||
|
||
body.theme-aurora .online { background: #16a34a; } /* green-600 */
|
||
body.theme-aurora .recent { background: #d97706; } /* amber-600 */
|
||
body.theme-aurora .offline { background: #d83030; } /* red-700 */
|
||
|
||
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; }
|
||
|
||
|
||
|
||
</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>
|
||
<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>
|
||
<button id="refresh">Refresh</button>
|
||
<button id="tab-sites">Sites</button>
|
||
<button id="tab-cache">Device Cache</button>
|
||
<div id="stats" class="stats"></div>
|
||
<span id="count" style="margin-left:auto;opacity:.7;margin:5px;"></span>
|
||
<!-- <button id="themeToggle" style="
|
||
padding:6px 10px; border-radius:10px; border:1px solid #ccc; cursor:pointer;">
|
||
Theme
|
||
</button> -->
|
||
</div>
|
||
|
||
<div id="page-sites">
|
||
<div id="grid" class="grid"></div>
|
||
|
||
<div id="drawer" style="position:fixed;top:0;right:-1020px;width:1020px;max-width:92vw;height:100vh;background:#fff;border-left:1px solid #ddd;box-shadow: -8px 0 24px rgba(0,0,0,.1);transition:right .25s ease;z-index:1000;display:flex;flex-direction:column;">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid #eee;">
|
||
<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 or local address…" 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(55, 55, 55)">Gateway / Modem Info</div>
|
||
<div class="kv">
|
||
<div class="k" style="color:black">Firmware</div><div class="v" id="bfw" style="color:black">—</div>
|
||
<div class="k" style="color:black">Error code</div><div class="v" id="berr" style="color:black">—</div>
|
||
<div class="k" style="color:black">ICCID</div><div class="v mono" id="biccid" style="color:black">—</div>
|
||
<div class="k" style="color:black">IMEI</div><div class="v mono" id="bimei" style="color:black">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Site meta editor -->
|
||
<div id="meta-form" class="meta-form">
|
||
<div class="meta-row">
|
||
<label for="site-name" style="color:black">Name</label>
|
||
<input id="site-name" type="text" placeholder='e.g. "Br. 101"' />
|
||
</div>
|
||
<div class="meta-row">
|
||
<label style="color:black">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"></span>
|
||
<button id="save-meta" style="padding:6px 10px;border-radius:10px;border:1px solid #ccc;cursor:pointer;">Save</button>
|
||
</div>
|
||
</div>
|
||
<div id="drawer-body" style="overflow:auto;padding:8px 12px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<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" />
|
||
</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;">
|
||
<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>
|
||
|
||
|
||
<script>
|
||
async function load() {
|
||
const q = document.getElementById('q').value.trim();
|
||
const state = document.getElementById('state').value;
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('q', q);
|
||
if (state) params.set('state', state);
|
||
const res = await fetch('/api/site-grid?' + params.toString());
|
||
const data = await res.json();
|
||
|
||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||
data.sort((a, b) => collator.compare(a.site_id, b.site_id));
|
||
|
||
render(data);
|
||
}
|
||
// function render(items) {
|
||
// const grid = document.getElementById('grid');
|
||
// grid.innerHTML = '';
|
||
// document.getElementById('count').textContent = `${items.length} site(s)`;
|
||
// 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>
|
||
// <span class="pill ${it.state}">${it.state}</span>
|
||
// </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);
|
||
// }
|
||
// }
|
||
|
||
function render(items) {
|
||
const grid = document.getElementById('grid');
|
||
grid.innerHTML = '';
|
||
|
||
// Totals
|
||
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;
|
||
|
||
// Optional: If you still have a #count span somewhere
|
||
const countEl = document.getElementById('count');
|
||
if (countEl) countEl.textContent = `${totalSites} site(s) • ${totalDevices} device(s)`;
|
||
|
||
// Render stat cards
|
||
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>
|
||
</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();
|
||
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;
|
||
|
||
//async function openDrawerForSite(siteId) {
|
||
// // fetch devices
|
||
// const res = await fetch('/api/devices?site_id=' + encodeURIComponent(siteId));
|
||
// const devs = await res.json();
|
||
//
|
||
// // title + count
|
||
// document.getElementById('drawer-title').textContent = `Site ${siteId}`;
|
||
// document.getElementById('drawer-title').style="color:black";
|
||
// document.getElementById('drawer-count').textContent = `• ${devs.length} device(s)`;
|
||
// document.getElementById('drawer-count').style="color:black";
|
||
//
|
||
// // stash to window for filtering
|
||
// window._drawerDevices = devs;
|
||
// renderDrawer(devs);
|
||
//
|
||
// // show drawer
|
||
// openDrawer();
|
||
//}
|
||
|
||
function makeCopyable(el, value) {
|
||
el.style.cursor = 'pointer';
|
||
el.title = 'Click to copy';
|
||
el.onclick = async () => {
|
||
try { await navigator.clipboard.writeText(value); el.textContent = value + ' ✓'; setTimeout(()=>el.textContent=value, 800); }
|
||
catch {}
|
||
};
|
||
}
|
||
|
||
function renderBundle(bundle) {
|
||
const icc = (bundle && bundle.iccid) ? String(bundle.iccid) : "—";
|
||
const ime = (bundle && bundle.imei) ? String(bundle.imei) : "—";
|
||
|
||
document.getElementById('bfw').textContent = (bundle?.firmware_version ?? "—");
|
||
document.getElementById('berr').textContent = (bundle?.error_code ?? "—");
|
||
|
||
const iccEl = document.getElementById('biccid');
|
||
const imeEl = document.getElementById('bimei');
|
||
iccEl.textContent = icc; imeEl.textContent = ime;
|
||
if (icc !== "—") makeCopyable(iccEl, icc);
|
||
if (ime !== "—") makeCopyable(imeEl, ime);
|
||
}
|
||
|
||
|
||
function renderBundle(bundle) {
|
||
const fw = (bundle && bundle.firmware_version != null && bundle.firmware_version !== "") ? String(bundle.firmware_version) : "—";
|
||
const err = (bundle && bundle.error_code != null && 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;
|
||
}
|
||
|
||
|
||
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
|
||
document.getElementById('drawer-title').textContent = `Site ${siteId}`;
|
||
document.getElementById('drawer-title').style="color:black";
|
||
document.getElementById('drawer-count').textContent = `• ${devs.length} device(s)`;
|
||
document.getElementById('drawer-count').style="color:black";
|
||
|
||
// store and 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);
|
||
|
||
openDrawer();
|
||
}
|
||
|
||
|
||
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);
|
||
});
|
||
|
||
// wire remove
|
||
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 }); // re-wires each render
|
||
}
|
||
|
||
// Add tag 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 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 ✓');
|
||
// Optionally refresh grid so name/tags appear on card if your API returns them
|
||
load();
|
||
} 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');
|
||
// Optional: auto-save after idle
|
||
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 = [];
|
||
|
||
// header
|
||
//rows.push(`
|
||
// <div class="row hdr">
|
||
// <div style="color:black;">Device ID</div>
|
||
// <div class="cell--right" style="color:black;">Local address</div>
|
||
// <div class="cell--right" style="color:black;">Last connect</div>
|
||
// <div class="cell--right" style="color:black;">Status</div>
|
||
// </div>
|
||
//`);
|
||
|
||
rows.push(`
|
||
<div class="row hdr">
|
||
<div style="color:black;">Device ID</div>
|
||
<div class="cell--right" style="color:black;">Type</div>
|
||
<div class="cell--right" style="color:black;">Local address</div>
|
||
|
||
<div class="cell--right" style="color:black;">RSSI</div>
|
||
<div class="cell--right" style="color:black;">Voltage</div>
|
||
|
||
<div class="cell--right" style="color:black;">Last connect</div>
|
||
<div class="cell--right" style="color:black;">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;
|
||
|
||
// rows.push(`
|
||
// <div class="row data">
|
||
// <div class="cell--mono" style="color:black;">${d.device_id}</div>
|
||
// <div class="cell--right" style="color:black;">${d.local_address ?? '-'}</div>
|
||
// <div class="cell--right" style="color:black;">${ageMin != null ? ageMin + ' min' : '–'}</div>
|
||
// <div class="cell--right" style="color:black;">
|
||
// <span class="pill-sm ${inactive ? 'bad' : 'ok'}">${inactive ? 'inactive' : 'active'}</span>
|
||
// </div>
|
||
// </div>
|
||
// `);
|
||
|
||
rows.push(`
|
||
<div class="row data">
|
||
<div class="cell--mono" style="color:black;">${d.device_id}</div>
|
||
<div class="cell--right" style="color:black;">${d.device_type_code || d.device_type || '-'}</div>
|
||
<div class="cell--right" style="color:black;">${d.local_address ?? '-'}</div>
|
||
|
||
<div class="cell--right"
|
||
style="color: ${d.device_class === 'non_recharge'
|
||
? 'red'
|
||
: d.device_class === 'recharge'
|
||
? 'blue'
|
||
: 'black'};">
|
||
${d.rssi ?? '-'}
|
||
</div>
|
||
|
||
<div class="cell--right"
|
||
style="color: ${d.device_class === 'non_recharge'
|
||
? 'red'
|
||
: d.device_class === 'recharge'
|
||
? 'blue'
|
||
: 'black'};">
|
||
${d.voltage ?? '-'}
|
||
</div>
|
||
|
||
<div class="cell--right" style="color:black;">${ageMin != null ? ageMin + ' min' : '–'}</div>
|
||
<div class="cell--right" style="color:black;">
|
||
<span class="pill-sm ${inactive ? 'bad' : 'ok'}">${inactive ? 'inactive' : 'active'}</span>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
body.innerHTML = rows.join('');
|
||
}
|
||
|
||
|
||
// client-side filter in the drawer
|
||
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)
|
||
);
|
||
renderDrawer(filtered);
|
||
});
|
||
|
||
|
||
// Tabs
|
||
const pageSites = document.getElementById('page-sites');
|
||
const pageCache = document.getElementById('page-cache');
|
||
document.getElementById('tab-sites').onclick = () => { pageSites.style.display=''; pageCache.style.display='none'; };
|
||
document.getElementById('tab-cache').onclick = () => { pageSites.style.display='none'; pageCache.style.display=''; };
|
||
|
||
// Cache input: Enter to fetch
|
||
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(); // { devices:[{...}], df_map: {did:{ df: {value, ts, ...}} } }
|
||
console.log("data: ",data);
|
||
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">${d.device_id}</div>
|
||
<div class="cell--right">${d.device_type_code || d.device_type || '-'}</div>
|
||
<div class="cell--right">${d.site_id || '-'}</div>
|
||
<div class="cell--right">${d.local_address ?? '-'}</div>
|
||
<div class="cell--right">${ts ? Math.floor((now-ts)/60) + ' min' : '–'}</div>
|
||
<div class="cell--right">${d.rssi ?? '-'}</div>
|
||
<div class="cell--right">${d.voltage ?? '-'}</div>
|
||
</div>
|
||
`);
|
||
}
|
||
rowsEl.innerHTML = out.join('');
|
||
}
|
||
|
||
function renderCacheDFTables(data) {
|
||
const wrap = document.getElementById('cache-df-tables');
|
||
wrap.innerHTML = '';
|
||
const df_map = data.df_map || {};
|
||
|
||
for (const [did, perDf] of Object.entries(df_map)) {
|
||
const rows = [];
|
||
rows.push(`
|
||
<div class="card" style="margin-top:10px; padding:12px;">
|
||
<div class="site">Device ${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>
|
||
</div>
|
||
`);
|
||
const cont = document.createElement('div');
|
||
cont.innerHTML = rows.join('');
|
||
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" style="color:black;">${df}</div>
|
||
<div class="cell--right" style="color:black;">${obj.value ?? ''}</div>
|
||
<div class="cell--right" style="color:black;">${obj.optional ?? ''}</div>
|
||
<div class="cell--right" style="color:black;">${obj.ts ?? ''}</div>
|
||
<div class="cell--right" style="color:black;">${obj.type_code ?? ''}</div>
|
||
</div>
|
||
`);
|
||
}
|
||
body.innerHTML = r.join('') || `<div class="meta">No cached DF values.</div>`;
|
||
}
|
||
}
|
||
|
||
|
||
</script>
|
||
|
||
<script>
|
||
// Hold most recent cache lookup results for export
|
||
window._cacheDevices = [];
|
||
|
||
// Enable the button + click handler
|
||
(function initCacheExport() {
|
||
const btn = document.getElementById('export-cache-csv');
|
||
if (!btn) return;
|
||
btn.addEventListener('click', exportCacheCsv);
|
||
})();
|
||
|
||
function exportCacheCsv() {
|
||
const rows = window._cacheDevices || [];
|
||
if (!rows.length) return;
|
||
|
||
// CSV header (matches visible columns + a couple of helpful extras)
|
||
const header = [
|
||
'Device ID',
|
||
'Type',
|
||
'Site',
|
||
'Local addr',
|
||
'Latest ts (min ago)',
|
||
'Latest ts (ISO)',
|
||
'Latest ts (epoch)'
|
||
];
|
||
|
||
const nowSec = Math.floor(Date.now()/1000);
|
||
|
||
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 [
|
||
safeCsv(d.device_id),
|
||
safeCsv(type),
|
||
safeCsv(d.site_id || ''),
|
||
safeCsv(d.local_address ?? ''),
|
||
String(ageMin),
|
||
safeCsv(iso),
|
||
ts ? String(ts) : ''
|
||
];
|
||
});
|
||
|
||
const csv = [header, ...dataRows].map(r => r.join(',')).join('\r\n');
|
||
|
||
// Prepend BOM so Excel opens UTF-8 cleanly
|
||
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 safeCsv(val) {
|
||
// Ensure strings & escape quotes/newlines/commas
|
||
const s = (val === null || val === undefined) ? '' : String(val);
|
||
if (/[",\n\r]/.test(s)) {
|
||
return `"${s.replace(/"/g, '""')}"`;
|
||
}
|
||
return s;
|
||
}
|
||
|
||
// Hook into your existing render to store results & toggle the button
|
||
const _origRenderCacheSummary = renderCacheSummary;
|
||
renderCacheSummary = function(data) {
|
||
// Keep the original behavior
|
||
_origRenderCacheSummary.call(this, data);
|
||
|
||
// Stash for export and toggle button state
|
||
window._cacheDevices = Array.isArray(data?.devices) ? data.devices : [];
|
||
const btn = document.getElementById('export-cache-csv');
|
||
if (btn) btn.disabled = window._cacheDevices.length === 0;
|
||
};
|
||
</script>
|
||
|
||
</body>
|
||
</html> |