This repository has been archived on 2026-06-29. You can view files and clone it, but cannot push or open issues or pull requests.
Files
resensys-site-status/templates/index copy.html
2026-06-29 13:32:41 -04:00

1067 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>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 &gt;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 &gt;24h: ${it.num_inactive_24h}</div>
`;
grid.appendChild(div);
}
}
document.getElementById('refresh').onclick = load;
document.getElementById('q').oninput = () => load();
document.getElementById('state').onchange = () => load();
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}">&times;</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>