Initial commit from Martins Github

This commit is contained in:
Jake Kasper
2026-04-23 13:50:31 -05:00
parent 488a0d01ef
commit 3228db3097
30 changed files with 4377 additions and 1 deletions

693
app/ui/overview.html Normal file
View File

@@ -0,0 +1,693 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>P5G Marvis Insights</title>
<style>
:root {
--bg: #0f1117;
--surface: #161b27;
--card: #1e2535;
--border: #2a3148;
--text: #e2e8f0;
--muted: #7a8499;
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.15);
--blue: #3b82f6;
--blue-dim: rgba(59,130,246,0.15);
--green: #10b981;
--green-dim: rgba(16,185,129,0.12);
--yellow: #f59e0b;
--yellow-dim: rgba(245,158,11,0.12);
--red: #ef4444;
--red-dim: rgba(239,68,68,0.12);
--orange: #f97316;
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px; height: 100%;
display: flex; flex-direction: column; overflow: hidden;
}
/* ── Header ────────────────────────────────────────────────────── */
header {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 0 20px; height: 52px;
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
}
.logo {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
background: linear-gradient(135deg, var(--purple), var(--blue));
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
header h1 span { color: var(--muted); font-weight: 400; }
.pill {
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
background: var(--blue-dim); color: var(--blue); border: 1px solid var(--blue);
letter-spacing: 0.04em;
}
.hdr-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.dot { animation: pulse 2.5s infinite; }
.dot.err { background: var(--red); animation: none; }
.stat-badge {
font-size: 11px; color: var(--muted);
background: var(--card); border: 1px solid var(--border);
padding: 3px 10px; border-radius: 20px;
}
.stat-badge b { color: var(--text); }
/* ── Toolbar ───────────────────────────────────────────────────── */
.toolbar {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 10px 20px; display: flex; align-items: center; gap: 10px; flex-shrink: 0;
}
.search-wrap { position: relative; flex: 1; max-width: 300px; }
.search-wrap svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px; color: var(--muted); pointer-events: none;
}
.search {
width: 100%; background: var(--card); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); padding: 7px 12px 7px 32px;
font-size: 13px; font-family: var(--font); outline: none;
transition: border-color .15s;
}
.search:focus { border-color: var(--blue); }
.search::placeholder { color: var(--muted); }
.filter-group { display: flex; gap: 6px; }
.filter-btn {
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
color: var(--muted); padding: 6px 12px; font-size: 12px; cursor: pointer;
transition: all .15s; font-family: var(--font); white-space: nowrap;
}
.filter-btn:hover, .filter-btn.active { border-color: var(--blue); color: var(--text); background: var(--blue-dim); }
.filter-btn.active { font-weight: 600; }
.tb-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.refresh-btn {
background: none; border: 1px solid var(--border); color: var(--muted);
cursor: pointer; font-size: 14px; padding: 5px 10px; border-radius: 6px;
transition: all .15s; font-family: var(--font);
}
.refresh-btn:hover { color: var(--text); border-color: var(--blue); }
/* ── Table ─────────────────────────────────────────────────────── */
.table-wrap {
flex: 1; overflow-y: auto; padding: 16px 20px;
}
.table-wrap::-webkit-scrollbar { width: 5px; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
table {
width: 100%; border-collapse: collapse;
font-size: 13px;
}
thead th {
background: var(--surface); color: var(--muted);
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; padding: 10px 14px; text-align: left;
border-bottom: 1px solid var(--border); white-space: nowrap;
position: sticky; top: 0; z-index: 10;
cursor: pointer; user-select: none;
}
thead th:hover { color: var(--text); }
thead th .sort-icon { margin-left: 4px; opacity: .4; }
thead th.sorted .sort-icon { opacity: 1; color: var(--blue); }
tbody tr {
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background .12s;
}
tbody tr:hover { background: rgba(59,130,246,0.06); }
tbody tr:last-child { border-bottom: none; }
td { padding: 11px 14px; vertical-align: middle; }
/* Status badge */
.status {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 600; padding: 3px 9px;
border-radius: 20px; text-transform: uppercase; letter-spacing: .04em;
}
.status.connected { background: var(--green-dim); color: var(--green); }
.status.idle { background: var(--yellow-dim); color: var(--yellow); }
.status.limited { background: var(--red-dim); color: var(--red); }
/* Signal bars */
.signal { display: flex; align-items: flex-end; gap: 2px; height: 16px; }
.signal-bar {
width: 4px; border-radius: 1px;
background: var(--border);
}
.signal-bar.on { background: var(--green); }
.signal.medium .signal-bar.on { background: var(--yellow); }
.signal.low .signal-bar.on { background: var(--red); }
.signal-bar:nth-child(1) { height: 4px; }
.signal-bar:nth-child(2) { height: 7px; }
.signal-bar:nth-child(3) { height: 11px; }
.signal-bar:nth-child(4) { height: 16px; }
/* Traffic mini-bar */
.traffic { display: flex; flex-direction: column; gap: 3px; min-width: 80px; }
.traffic-row { display: flex; align-items: center; gap: 5px; font-size: 11px; }
.traffic-label { color: var(--muted); width: 16px; }
.traffic-val { color: var(--text); min-width: 52px; }
.traffic-bar-wrap { flex: 1; height: 3px; background: var(--border); border-radius: 2px; }
.traffic-bar { height: 100%; border-radius: 2px; background: var(--blue); }
.traffic-bar.up { background: var(--green); }
/* Device cell */
.dev { display: flex; align-items: center; gap: 10px; }
.dev-icon {
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
background: var(--card); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center; font-size: 15px;
}
.dev-name { font-weight: 600; font-size: 13px; }
.dev-imsi { font-size: 11px; color: var(--muted); margin-top: 1px; }
/* IMEI/IP mono */
.mono { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 12px; }
/* gNB cell */
.gnb { display: flex; flex-direction: column; gap: 2px; }
.gnb-name { font-size: 12px; font-weight: 600; color: var(--blue); }
.gnb-cell { font-size: 11px; color: var(--muted); }
/* Last seen */
.last-seen { font-size: 12px; color: var(--muted); white-space: nowrap; }
.last-seen.recent { color: var(--green); }
/* Empty state */
.empty { text-align: center; padding: 48px; color: var(--muted); }
.empty-icon { font-size: 36px; margin-bottom: 12px; }
/* ── Modal overlay ─────────────────────────────────────────────── */
.overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.65); backdrop-filter: blur(4px);
z-index: 100; align-items: center; justify-content: center;
padding: 20px;
}
.overlay.open { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; width: 100%; max-width: 680px;
max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 60px rgba(0,0,0,.5);
animation: slideUp .2s ease;
}
@keyframes slideUp { from { transform: translateY(18px); opacity:0; } to { transform:none; opacity:1; } }
.modal::-webkit-scrollbar { width: 5px; }
.modal::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.modal-hdr {
padding: 18px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
}
.modal-hdr .dev-icon { width: 40px; height: 40px; font-size: 20px; }
.modal-hdr-titles { flex: 1; }
.modal-hdr-titles h2 { font-size: 16px; font-weight: 700; }
.modal-hdr-titles p { font-size: 12px; color: var(--muted); margin-top: 2px; }
.modal-close {
background: none; border: none; color: var(--muted); font-size: 20px;
cursor: pointer; padding: 4px 8px; line-height: 1; border-radius: 6px;
transition: all .15s;
}
.modal-close:hover { color: var(--text); background: var(--card); }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 18px; }
.m-section-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .1em; color: var(--muted); margin-bottom: 10px;
}
.m-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
}
.m-grid.cols3 { grid-template-columns: repeat(3, 1fr); }
.m-kv {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 12px;
}
.m-kv-key { font-size: 10px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
.m-kv-val { font-size: 13px; font-weight: 600; margin-top: 3px; }
.m-kv-val.mono { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 12px; }
.m-kv-val.green { color: var(--green); }
.m-kv-val.yellow { color: var(--yellow); }
.m-kv-val.red { color: var(--red); }
.m-kv-val.blue { color: var(--blue); }
/* Sparkline / traffic chart */
.chart-wrap {
background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 14px;
}
.chart-title { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
.chart-title .chart-legend { display: flex; gap: 12px; }
.chart-legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
.legend-dot.dl { background: var(--blue); }
.legend-dot.ul { background: var(--green); }
canvas#trafficChart { width: 100% !important; height: 80px; }
/* Modal action buttons */
.m-actions { display: flex; gap: 8px; padding-top: 4px; }
.m-btn {
flex: 1; padding: 9px 16px; border-radius: 8px; font-size: 13px;
font-weight: 600; cursor: pointer; border: 1px solid var(--border);
font-family: var(--font); transition: all .15s;
}
.m-btn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
.m-btn.primary:hover { opacity: .85; }
.m-btn.danger { background: var(--red-dim); border-color: var(--red); color: var(--red); }
.m-btn.danger:hover { background: var(--red); color: #fff; }
.m-btn.secondary { background: var(--card); color: var(--muted); }
.m-btn.secondary:hover { border-color: var(--blue); color: var(--text); }
</style>
</head>
<body>
<header>
<div class="logo"></div>
<h1>P5G Marvis <span>/ Connected Clients</span></h1>
<div class="pill">INSIGHTS</div>
<div class="hdr-right">
<div class="stat-badge">Connected: <b id="cntConnected"></b></div>
<div class="stat-badge">Idle: <b id="cntIdle"></b></div>
<div class="stat-badge">Issues: <b id="cntIssues"></b></div>
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Loading…</span></div>
</div>
</header>
<div class="toolbar">
<div class="search-wrap">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="8.5" cy="8.5" r="5.5"/><path d="M15 15l-3-3"/>
</svg>
<input class="search" id="searchInp" placeholder="Search by device, IMSI, IP, gNB…" oninput="filterTable()">
</div>
<div class="filter-group">
<button class="filter-btn active" id="f-all" onclick="setFilter('all')">All</button>
<button class="filter-btn" id="f-connected" onclick="setFilter('connected')">Connected</button>
<button class="filter-btn" id="f-idle" onclick="setFilter('idle')">Idle</button>
<button class="filter-btn" id="f-limited" onclick="setFilter('limited')">Issues</button>
</div>
<div class="tb-right">
<button class="refresh-btn" onclick="renderTable()">↻ Refresh</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th onclick="sortBy('name')">Device <span class="sort-icon"></span></th>
<th onclick="sortBy('ip')">Assigned IP <span class="sort-icon"></span></th>
<th onclick="sortBy('gnb')">Connected Radio <span class="sort-icon"></span></th>
<th>Signal</th>
<th>Traffic (DL/UL)</th>
<th onclick="sortBy('lastSeen')">Last Activity <span class="sort-icon"></span></th>
<th>APN / DNN</th>
<th onclick="sortBy('status')">Status <span class="sort-icon"></span></th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<div class="empty" id="emptyState" style="display:none">
<div class="empty-icon">📡</div>
<div>No clients match your filter</div>
</div>
</div>
<!-- Modal -->
<div class="overlay" id="overlay" onclick="closeModal(event)">
<div class="modal" id="modal">
<div class="modal-hdr">
<div class="dev-icon" id="m-icon"></div>
<div class="modal-hdr-titles">
<h2 id="m-title"></h2>
<p id="m-subtitle"></p>
</div>
<div id="m-status-badge"></div>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-body">
<!-- UE Identity -->
<div>
<div class="m-section-title">UE Identity</div>
<div class="m-grid" id="m-identity"></div>
</div>
<!-- Session -->
<div>
<div class="m-section-title">PDU Session</div>
<div class="m-grid" id="m-session"></div>
</div>
<!-- Radio -->
<div>
<div class="m-section-title">Radio Link</div>
<div class="m-grid cols3" id="m-radio"></div>
</div>
<!-- Traffic chart -->
<div class="chart-wrap">
<div class="chart-title">
Throughput (last 60 s)
<div class="chart-legend">
<div class="chart-legend-item"><div class="legend-dot dl"></div>Downlink</div>
<div class="chart-legend-item"><div class="legend-dot ul"></div>Uplink</div>
</div>
</div>
<canvas id="trafficChart"></canvas>
</div>
<!-- Actions -->
<div class="m-actions">
<button class="m-btn secondary" onclick="closeModal()">Close</button>
<button class="m-btn primary">📋 View Session Log</button>
<button class="m-btn danger" id="m-disconnect-btn">⊘ Force Disconnect</button>
</div>
</div>
</div>
</div>
<script>
// ─── Mock data ───────────────────────────────────────────────────────────────
const CLIENTS = [
{
id: 1, icon: '📱', name: 'UE-Alpha-001', imsi: '315010000000001', imei: '353456789012301', iccid: '8901315010000000018',
ip: '10.45.0.11', gnb: 'gNB-North-01', cellId: 'Cell-NR-0101', rsrp: -72, rsrq: -9, sinr: 18,
dl: 48.2, ul: 12.4, dlMax: 80, ulMax: 30, apn: 'internet', pduType: 'IPv4',
sessionId: 'PDU-0x4A3F', upf: 'UPF-01', lastSeen: 4, status: 'connected',
connectedSince: '06:42:11', sliceId: 'SST:1 SD:000001'
},
{
id: 2, icon: '💻', name: 'UE-Beta-007', imsi: '315010000000007', imei: '353456789012302', iccid: '8901315010000000074',
ip: '10.45.0.22', gnb: 'gNB-North-01', cellId: 'Cell-NR-0102', rsrp: -85, rsrq: -12, sinr: 11,
dl: 9.1, ul: 2.8, dlMax: 80, ulMax: 30, apn: 'ims', pduType: 'IPv4v6',
sessionId: 'PDU-0x4A40', upf: 'UPF-01', lastSeen: 12, status: 'connected',
connectedSince: '05:15:33', sliceId: 'SST:1 SD:000001'
},
{
id: 3, icon: '🖥️', name: 'UE-Camera-03', imsi: '315010000000003', imei: '353456789012303', iccid: '8901315010000000031',
ip: '10.45.0.55', gnb: 'gNB-East-02', cellId: 'Cell-NR-0201', rsrp: -68, rsrq: -8, sinr: 22,
dl: 124.7, ul: 88.3, dlMax: 150, ulMax: 100, apn: 'surveillance', pduType: 'IPv4',
sessionId: 'PDU-0x4A41', upf: 'UPF-02', lastSeen: 2, status: 'connected',
connectedSince: '07:00:01', sliceId: 'SST:2 SD:000010'
},
{
id: 4, icon: '🤖', name: 'UE-Robot-A12', imsi: '315010000000012', imei: '353456789012304', iccid: '8901315010000000128',
ip: '10.45.0.78', gnb: 'gNB-East-02', cellId: 'Cell-NR-0202', rsrp: -91, rsrq: -14, sinr: 7,
dl: 1.2, ul: 0.4, dlMax: 80, ulMax: 30, apn: 'robotics', pduType: 'IPv4',
sessionId: 'PDU-0x4A42', upf: 'UPF-02', lastSeen: 180, status: 'idle',
connectedSince: '04:28:55', sliceId: 'SST:3 SD:000020'
},
{
id: 5, icon: '📡', name: 'UE-Sensor-19', imsi: '315010000000019', imei: '353456789012305', iccid: '8901315010000000197',
ip: '10.45.0.99', gnb: 'gNB-South-03', cellId: 'Cell-NR-0301', rsrp: -74, rsrq: -10, sinr: 15,
dl: 0.3, ul: 0.8, dlMax: 80, ulMax: 30, apn: 'sensors.iot', pduType: 'IPv4',
sessionId: 'PDU-0x4A43', upf: 'UPF-03', lastSeen: 25, status: 'connected',
connectedSince: '06:55:44', sliceId: 'SST:3 SD:000020'
},
{
id: 6, icon: '🔧', name: 'UE-Maint-05', imsi: '315010000000005', imei: '353456789012306', iccid: '8901315010000000055',
ip: '10.45.0.130', gnb: 'gNB-South-03', cellId: 'Cell-NR-0302', rsrp: -103, rsrq: -17, sinr: 2,
dl: 0.1, ul: 0.0, dlMax: 80, ulMax: 30, apn: 'internet', pduType: 'IPv4',
sessionId: 'PDU-0x4A44', upf: 'UPF-03', lastSeen: 620, status: 'limited',
connectedSince: '03:11:22', sliceId: 'SST:1 SD:000001'
},
{
id: 7, icon: '📲', name: 'UE-Admin-02', imsi: '315010000000002', imei: '353456789012307', iccid: '8901315010000000025',
ip: '10.45.0.14', gnb: 'gNB-North-01', cellId: 'Cell-NR-0101', rsrp: -65, rsrq: -7, sinr: 25,
dl: 22.6, ul: 5.1, dlMax: 80, ulMax: 30, apn: 'admin', pduType: 'IPv4',
sessionId: 'PDU-0x4A45', upf: 'UPF-01', lastSeen: 8, status: 'connected',
connectedSince: '06:50:00', sliceId: 'SST:1 SD:000001'
},
{
id: 8, icon: '🏭', name: 'UE-PLC-08', imsi: '315010000000008', imei: '353456789012308', iccid: '8901315010000000082',
ip: '10.45.0.200', gnb: 'gNB-West-04', cellId: 'Cell-NR-0401', rsrp: -78, rsrq: -11, sinr: 14,
dl: 6.8, ul: 3.3, dlMax: 80, ulMax: 30, apn: 'industrial', pduType: 'IPv4',
sessionId: 'PDU-0x4A46', upf: 'UPF-04', lastSeen: 60, status: 'idle',
connectedSince: '05:40:17', sliceId: 'SST:2 SD:000010'
},
{
id: 9, icon: '🚁', name: 'UE-Drone-D4', imsi: '315010000000024', imei: '353456789012309', iccid: '8901315010000000243',
ip: '10.45.0.244', gnb: 'gNB-West-04', cellId: 'Cell-NR-0402', rsrp: -70, rsrq: -9, sinr: 19,
dl: 31.4, ul: 18.7, dlMax: 80, ulMax: 30, apn: 'uav.ctrl', pduType: 'IPv4',
sessionId: 'PDU-0x4A47', upf: 'UPF-04', lastSeen: 3, status: 'connected',
connectedSince: '07:01:55', sliceId: 'SST:2 SD:000010'
},
{
id: 10, icon: '📊', name: 'UE-Monitor-11', imsi: '315010000000011', imei: '353456789012310', iccid: '8901315010000000116',
ip: '10.45.0.181', gnb: 'gNB-East-02', cellId: 'Cell-NR-0203', rsrp: -88, rsrq: -13, sinr: 9,
dl: 4.2, ul: 1.1, dlMax: 80, ulMax: 30, apn: 'monitoring', pduType: 'IPv4v6',
sessionId: 'PDU-0x4A48', upf: 'UPF-02', lastSeen: 45, status: 'idle',
connectedSince: '04:55:30', sliceId: 'SST:1 SD:000001'
}
];
// ─── State ────────────────────────────────────────────────────────────────
let activeFilter = 'all';
let sortKey = 'status';
let sortAsc = true;
// ─── Helpers ─────────────────────────────────────────────────────────────
function fmtLastSeen(secs) {
if (secs < 30) return 'Just now';
if (secs < 120) return `${secs}s ago`;
if (secs < 3600) return `${Math.round(secs/60)}m ago`;
return `${Math.round(secs/3600)}h ago`;
}
function signalStrength(rsrp) {
if (rsrp >= -80) return 'high';
if (rsrp >= -95) return 'medium';
return 'low';
}
function signalBars(rsrp) {
const s = signalStrength(rsrp);
const bars = rsrp >= -80 ? 4 : rsrp >= -90 ? 3 : rsrp >= -100 ? 2 : 1;
return `<div class="signal ${s}">${[1,2,3,4].map(i=>`<div class="signal-bar${i<=bars?' on':''}"></div>`).join('')}</div>`;
}
function fmtMbps(v) {
if (v >= 1000) return (v/1000).toFixed(1) + ' Gbps';
return v.toFixed(1) + ' Mbps';
}
function maskedImsi(imsi) { return imsi.slice(0,5) + '·····' + imsi.slice(-4); }
// ─── Table ────────────────────────────────────────────────────────────────
function getVisible() {
const q = document.getElementById('searchInp').value.toLowerCase();
return CLIENTS
.filter(c => activeFilter === 'all' || c.status === activeFilter)
.filter(c => !q || [c.name,c.imsi,c.ip,c.gnb,c.apn].some(v=>v.toLowerCase().includes(q)))
.sort((a,b) => {
const va = a[sortKey], vb = b[sortKey];
return sortAsc ? (va > vb ? 1 : -1) : (va < vb ? 1 : -1);
});
}
function renderTable() {
const rows = getVisible();
const tbody = document.getElementById('tableBody');
document.getElementById('emptyState').style.display = rows.length ? 'none' : 'block';
// update header badges
document.getElementById('cntConnected').textContent = CLIENTS.filter(c=>c.status==='connected').length;
document.getElementById('cntIdle').textContent = CLIENTS.filter(c=>c.status==='idle').length;
document.getElementById('cntIssues').textContent = CLIENTS.filter(c=>c.status==='limited').length;
document.getElementById('dot').className = 'dot';
document.getElementById('connLabel').textContent = `${CLIENTS.length} UEs tracked`;
tbody.innerHTML = rows.map(c => {
const dlPct = Math.min(100, (c.dl/c.dlMax)*100);
const ulPct = Math.min(100, (c.ul/c.ulMax)*100);
const lsClass = c.lastSeen < 30 ? 'recent' : '';
return `<tr onclick="openModal(${c.id})">
<td>
<div class="dev">
<div class="dev-icon">${c.icon}</div>
<div>
<div class="dev-name">${c.name}</div>
<div class="dev-imsi">${maskedImsi(c.imsi)}</div>
</div>
</div>
</td>
<td class="mono">${c.ip}</td>
<td>
<div class="gnb">
<div class="gnb-name">${c.gnb}</div>
<div class="gnb-cell">${c.cellId}</div>
</div>
</td>
<td title="RSRP ${c.rsrp} dBm SINR ${c.sinr} dB">${signalBars(c.rsrp)}</td>
<td>
<div class="traffic">
<div class="traffic-row">
<span class="traffic-label">↓</span>
<span class="traffic-val">${fmtMbps(c.dl)}</span>
<div class="traffic-bar-wrap"><div class="traffic-bar" style="width:${dlPct}%"></div></div>
</div>
<div class="traffic-row">
<span class="traffic-label">↑</span>
<span class="traffic-val">${fmtMbps(c.ul)}</span>
<div class="traffic-bar-wrap"><div class="traffic-bar up" style="width:${ulPct}%"></div></div>
</div>
</div>
</td>
<td><span class="last-seen ${lsClass}">${fmtLastSeen(c.lastSeen)}</span></td>
<td style="font-size:12px">${c.apn}<br><span style="color:var(--muted);font-size:10px">${c.pduType}</span></td>
<td><span class="status ${c.status}">${c.status}</span></td>
</tr>`;
}).join('');
}
function setFilter(f) {
activeFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
document.getElementById('f-'+f).classList.add('active');
renderTable();
}
function filterTable() { renderTable(); }
function sortBy(key) {
if (sortKey === key) sortAsc = !sortAsc;
else { sortKey = key; sortAsc = true; }
document.querySelectorAll('thead th').forEach(th => th.classList.remove('sorted'));
renderTable();
}
// ─── Modal ────────────────────────────────────────────────────────────────
let chartCtx = null;
function kv(key, val, cls='') {
return `<div class="m-kv"><div class="m-kv-key">${key}</div><div class="m-kv-val ${cls}">${val}</div></div>`;
}
function openModal(id) {
const c = CLIENTS.find(x => x.id === id);
if (!c) return;
document.getElementById('m-icon').textContent = c.icon;
document.getElementById('m-title').textContent = c.name;
document.getElementById('m-subtitle').textContent = `IMSI ${c.imsi} · ${c.ip}`;
document.getElementById('m-status-badge').innerHTML = `<span class="status ${c.status}">${c.status}</span>`;
// UE Identity
document.getElementById('m-identity').innerHTML = [
kv('IMSI', c.imsi, 'mono'),
kv('IMEI', c.imei, 'mono'),
kv('ICCID', c.iccid, 'mono'),
kv('Connected Since', c.connectedSince),
].join('');
// PDU Session
const sessColor = c.status === 'limited' ? 'red' : c.status === 'idle' ? 'yellow' : 'green';
document.getElementById('m-session').innerHTML = [
kv('Assigned IP', c.ip, 'mono blue'),
kv('PDU Type', c.pduType),
kv('APN / DNN', c.apn),
kv('Session ID', c.sessionId, 'mono'),
kv('UPF Node', c.upf, 'blue'),
kv('Network Slice', c.sliceId),
kv('Downlink', fmtMbps(c.dl), 'blue'),
kv('Uplink', fmtMbps(c.ul), 'green'),
].join('');
// Radio link
const rsrpColor = c.rsrp >= -80 ? 'green' : c.rsrp >= -95 ? 'yellow' : 'red';
document.getElementById('m-radio').innerHTML = [
kv('gNB', c.gnb, 'blue'),
kv('Cell ID', c.cellId, 'mono'),
kv('RSRP', `${c.rsrp} dBm`, rsrpColor),
kv('RSRQ', `${c.rsrq} dB`, rsrpColor),
kv('SINR', `${c.sinr} dB`, c.sinr >= 15 ? 'green' : c.sinr >= 8 ? 'yellow' : 'red'),
kv('Signal', signalStrength(c.rsrp).toUpperCase(), rsrpColor),
].join('');
// Disconnect button state
document.getElementById('m-disconnect-btn').disabled = c.status === 'limited';
// Traffic sparkline
drawChart(c);
document.getElementById('overlay').classList.add('open');
}
function closeModal(e) {
if (e && e.target !== document.getElementById('overlay')) return;
document.getElementById('overlay').classList.remove('open');
}
function drawChart(c) {
const canvas = document.getElementById('trafficChart');
const ctx = canvas.getContext('2d');
// Generate fake smoothed time-series (20 points)
const points = 20;
function fakeSeries(base, noise) {
let v = base;
return Array.from({length: points}, () => {
v += (Math.random() - 0.5) * noise;
v = Math.max(0, v);
return v;
});
}
const dl = fakeSeries(c.dl, c.dl * 0.35);
const ul = fakeSeries(c.ul, c.ul * 0.4);
const W = canvas.offsetWidth || 600;
const H = 80;
canvas.width = W;
canvas.height = H;
const maxVal = Math.max(...dl, ...ul, 1);
const step = W / (points - 1);
function toX(i) { return i * step; }
function toY(v) { return H - 8 - (v / maxVal) * (H - 16); }
function drawLine(series, color) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
series.forEach((v, i) => {
if (i === 0) ctx.moveTo(toX(i), toY(v));
else ctx.lineTo(toX(i), toY(v));
});
ctx.stroke();
// fill under
ctx.lineTo(toX(points-1), H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fillStyle = color.replace(')', ',0.08)').replace('rgb', 'rgba');
ctx.fill();
}
ctx.clearRect(0, 0, W, H);
// Grid lines
ctx.strokeStyle = 'rgba(42,49,72,0.8)';
ctx.lineWidth = 1;
[0.25, 0.5, 0.75, 1].forEach(r => {
const y = H - 8 - r * (H - 16);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
});
drawLine(dl, 'rgb(59,130,246)');
drawLine(ul, 'rgb(16,185,129)');
}
// ─── Init ─────────────────────────────────────────────────────────────────
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.getElementById('overlay').classList.remove('open'); });
renderTable();
</script>
</body>
</html>