Files
numa/site/dashboard.html
Razvan Dimescu 15978a7859 fix(dashboard): pass missing args to encryptionPct in refresh()
Commit eb5ea3b generalised encryptionPct from (transport) to
(data, encryptedKeys, allKeys) and updated renderTransport and
renderUpstreamWire, but missed the call inside render() that computes
the inline `~N/s · M% enc` QPS tag. With undefined allKeys, the
first .reduce() threw TypeError and the render try/catch silently
downgraded the whole dashboard to "disconnected" — every panel left
empty even though /stats was returning real data.

Fix the call site to match the other two (inbound-wire keys) and have
the catch log to console so the next silent-failure regression shows
up in DevTools within seconds instead of a source dive.
2026-04-20 19:04:15 +03:00

1564 lines
55 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Numa — Dashboard</title>
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #f5f0e8;
--bg-surface: #ece5da;
--bg-elevated: #e3dbce;
--bg-card: #faf7f2;
--amber: #c0623a;
--amber-dim: #9e4e2d;
--teal: #6b7c4e;
--teal-dim: #566540;
--violet: #64748b;
--violet-dim: #4a5568;
--emerald: #527a52;
--rose: #b5443a;
--cyan: #4a7c8a;
--text-primary: #2c2418;
--text-secondary: #6b5e4f;
--text-dim: #a39888;
--border: rgba(0, 0, 0, 0.08);
--border-amber: rgba(192, 98, 58, 0.22);
--font-display: 'Instrument Serif', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
}
html { font-size: 15px; }
body {
font-family: var(--font-body);
background: var(--bg-deep);
color: var(--text-primary);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.2rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.header-left {
display: flex;
align-items: baseline;
gap: 1rem;
}
.logo {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--amber);
letter-spacing: 0.04em;
}
.tagline {
font-size: 0.85rem;
color: var(--text-dim);
font-style: italic;
font-family: var(--font-display);
}
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-dim);
font-family: var(--font-mono);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--emerald);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.error { background: var(--rose); animation: none; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Layout */
.dashboard {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
/* Stat cards row */
.stats-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 1rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.2rem 1.4rem;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.stat-card.queries::before { background: var(--amber); }
.stat-card.cache::before { background: var(--teal); }
.stat-card.blocked::before { background: var(--rose); }
.stat-card.overrides::before { background: var(--violet); }
.stat-card.uptime::before { background: var(--cyan); }
.stat-card.memory::before { background: var(--text-dim); }
.stat-card.memory .stat-value { color: var(--text-secondary); }
.stat-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.stat-value {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 500;
line-height: 1;
}
.stat-card.queries .stat-value { color: var(--amber); }
.stat-card.cache .stat-value { color: var(--teal); }
.stat-card.blocked .stat-value { color: var(--rose); }
.stat-card.overrides .stat-value { color: var(--violet); }
.stat-card.uptime .stat-value { color: var(--cyan); }
.stat-sub {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-dim);
margin-top: 0.3rem;
}
/* Two-column main area */
.main-grid {
display: grid;
grid-template-columns: 1fr 340px;
gap: 1.2rem;
}
/* Panels */
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.2rem;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.panel-title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
}
.panel-body {
padding: 1rem 1.2rem;
}
/* Resolution paths bar chart */
.path-bar-row {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 0.6rem;
}
.path-bar-row:last-child { margin-bottom: 0; }
.path-label {
font-family: var(--font-mono);
font-size: 0.75rem;
width: 70px;
text-align: right;
color: var(--text-secondary);
flex-shrink: 0;
}
.path-bar-track {
flex: 1;
height: 22px;
background: var(--bg-surface);
border-radius: 4px;
overflow: hidden;
}
.path-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.6s ease;
min-width: 2px;
}
.path-bar-fill.forward { background: var(--amber); }
.path-bar-fill.upstream { background: var(--amber-dim); }
.path-bar-fill.recursive { background: var(--cyan); }
.path-bar-fill.cached { background: var(--teal); }
.path-bar-fill.local { background: var(--violet); }
.path-bar-fill.override { background: var(--emerald); }
.path-bar-fill.error { background: var(--rose); }
.path-bar-fill.blocked { background: var(--text-dim); }
.path-bar-fill.udp { background: var(--text-dim); }
.path-bar-fill.tcp { background: var(--violet); }
.path-bar-fill.dot { background: var(--emerald); }
.path-bar-fill.doh { background: var(--teal); }
.path-bar-fill.odoh { background: var(--violet-dim); }
.path-pct {
font-family: var(--font-mono);
font-size: 0.75rem;
width: 42px;
color: var(--text-dim);
flex-shrink: 0;
}
/* Query log table */
.query-log {
max-height: 600px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--bg-elevated) transparent;
}
.query-log table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.query-log th {
text-align: left;
padding: 0.5rem 0.6rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
z-index: 1;
}
.query-log td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
color: var(--text-secondary);
}
.query-log tr:hover td {
background: var(--bg-surface);
}
.query-log .domain-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
.path-tag {
display: inline-block;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 500;
}
.path-tag.FORWARD { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); }
.path-tag.UPSTREAM { background: rgba(160, 120, 72, 0.12); color: var(--amber-dim); }
.path-tag.RECURSIVE { background: rgba(74, 124, 138, 0.12); color: var(--cyan); }
.path-tag.CACHED { background: rgba(107, 124, 78, 0.12); color: var(--teal-dim); }
.path-tag.LOCAL { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); }
.path-tag.OVERRIDE { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
.path-tag.UDP { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
.path-tag.TCP { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); }
.path-tag.DOT { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
.path-tag.DOH { background: rgba(107, 124, 78, 0.12); color: var(--teal); }
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
/* Sidebar panels */
.sidebar {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
/* Overrides list */
.override-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border);
}
.override-item:last-child { border-bottom: none; }
.override-domain {
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 500;
color: var(--emerald);
}
.override-target {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
.override-ttl {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--amber);
}
.empty-state {
font-size: 0.8rem;
color: var(--text-dim);
font-style: italic;
padding: 0.8rem 0;
}
/* Cache panel */
.cache-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.cache-item:last-child { border-bottom: none; }
.cache-domain {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.cache-ttl {
color: var(--text-dim);
flex-shrink: 0;
}
/* Service items */
.service-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
}
.service-item:last-child { border-bottom: none; }
.service-info { flex: 1; min-width: 0; }
.service-name {
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 500;
color: var(--cyan);
}
.service-name a {
color: inherit;
text-decoration: none;
}
.service-name a:hover { text-decoration: underline; }
.service-port {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-dim);
}
.health-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.health-dot.up { background: var(--emerald); }
.health-dot.down { background: var(--rose); }
.lan-badge {
font-family: var(--font-mono);
font-size: 0.58rem;
padding: 1px 5px;
border-radius: 3px;
margin-left: 0.3rem;
}
.lan-badge.shared { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
.lan-badge.local-only { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); }
/* Override form */
.override-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-bottom: 0.8rem;
margin-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
}
.override-form input {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-surface);
color: var(--text-primary);
outline: none;
transition: border-color 0.2s;
}
.override-form input:focus {
border-color: var(--amber);
}
.override-form input::placeholder {
color: var(--text-dim);
}
.override-form-row {
display: flex;
gap: 0.4rem;
}
.override-form-row input {
flex: 1;
min-width: 0;
}
.btn {
font-family: var(--font-body);
font-size: 0.72rem;
font-weight: 600;
padding: 0.4rem 0.8rem;
border: none;
border-radius: 5px;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.85; }
.btn:active { opacity: 0.7; }
.btn-add {
background: var(--emerald);
color: white;
}
.btn-delete {
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
font-size: 0.75rem;
padding: 0.15rem 0.3rem;
border-radius: 3px;
transition: color 0.2s, background 0.2s;
}
.btn-delete:hover {
color: var(--rose);
background: rgba(181, 68, 58, 0.08);
}
.override-item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.override-error {
font-size: 0.7rem;
color: var(--rose);
display: none;
}
/* Memory sidebar panel */
.memory-bar {
display: flex;
height: 18px;
border-radius: 4px;
overflow: hidden;
background: var(--bg-surface);
margin-bottom: 0.8rem;
}
.memory-bar-seg {
height: 100%;
min-width: 2px;
transition: width 0.6s ease;
}
.memory-bar-seg.cache { background: var(--teal); }
.memory-bar-seg.blocklist { background: var(--rose); }
.memory-bar-seg.querylog { background: var(--amber); }
.memory-bar-seg.srtt { background: var(--cyan); }
.memory-bar-seg.overrides { background: var(--violet); }
.memory-row {
display: flex;
align-items: center;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.memory-row:last-child { border-bottom: none; }
.memory-row-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
margin-right: 0.5rem;
}
.memory-row-label {
flex: 1;
color: var(--text-secondary);
}
.memory-row-size {
width: 65px;
text-align: right;
color: var(--text-primary);
font-weight: 500;
}
.memory-row-entries {
width: 90px;
text-align: right;
color: var(--text-dim);
}
.memory-rss {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
/* Responsive */
@media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.dashboard { padding: 1rem; }
.header { padding: 0.8rem 1rem; }
.logo { font-size: 1.4rem; }
.tagline { display: none; }
#headerVersion { display: none; }
#phoneSetup { display: none; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<div class="logo">Numa</div>
<span id="headerVersion" style="font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);"></span>
<div class="tagline">DNS that governs itself</div>
</div>
<div style="display:flex;align-items:center;gap:1.2rem;">
<div id="phoneSetup" style="position:relative;display:none;">
<button class="btn" onclick="togglePhoneSetup()" style="background:var(--bg-surface);color:var(--text-secondary);font-family:var(--font-mono);font-size:0.7rem;padding:0.35rem 0.6rem;border:1px solid var(--border);" title="Set up phone">Phone Setup</button>
<div id="phoneSetupPopover" style="display:none;position:absolute;top:calc(100% + 8px);right:0;z-index:100;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.2rem;width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.08);">
<div style="font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-secondary);margin-bottom:0.8rem;">Phone Setup</div>
<div id="qrContainer" style="display:flex;justify-content:center;margin-bottom:0.8rem;"></div>
<div id="phoneSetupLink" style="display:none;text-align:center;margin-bottom:0.8rem;"></div>
<div style="font-family:var(--font-mono);font-size:0.62rem;color:var(--text-dim);line-height:1.6;">
1. Scan QR &rarr; allow download<br>
2. Settings &rarr; Profile Downloaded &rarr; Install<br>
3. Settings &rarr; General &rarr; About &rarr;<br>
&nbsp;&nbsp;&nbsp;Certificate Trust Settings &rarr; toggle ON
</div>
</div>
</div>
<button class="btn" id="pauseBtn" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button>
<button class="btn" id="toggleBtn" onclick="toggleBlocking()" style="background:var(--rose);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;"></button>
<div class="status-badge">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">connecting...</span>
</div>
</div>
</div>
<div class="dashboard">
<!-- Stat cards -->
<div class="stats-row">
<div class="stat-card queries">
<div class="stat-label">Total Queries</div>
<div class="stat-value" id="totalQueries"></div>
<div class="stat-sub" id="qps"></div>
</div>
<div class="stat-card cache">
<div class="stat-label">Cache Hit Rate</div>
<div class="stat-value" id="cacheRate"></div>
<div class="stat-sub" id="cacheEntries"></div>
</div>
<div class="stat-card blocked">
<div class="stat-label">Blocked</div>
<div class="stat-value" id="blockedCount"></div>
<div class="stat-sub" id="blockedSub">&nbsp;</div>
</div>
<div class="stat-card overrides">
<div class="stat-label">Active Overrides</div>
<div class="stat-value" id="overrideCount"></div>
<div class="stat-sub">&nbsp;</div>
</div>
<div class="stat-card uptime">
<div class="stat-label">Uptime</div>
<div class="stat-value" id="uptime"></div>
<div class="stat-sub" id="uptimeSub">&nbsp;</div>
</div>
<div class="stat-card memory">
<div class="stat-label">Memory</div>
<div class="stat-value" id="memoryRss"></div>
<div class="stat-sub" id="memorySub">&nbsp;</div>
</div>
</div>
<!-- Resolution paths -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Resolution Paths</span>
</div>
<div class="panel-body" id="pathBars">
<!-- Populated by JS -->
</div>
</div>
<!-- Inbound wire (apps → numa) -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Inbound Wire <span style="color: var(--text-dim); font-weight: normal;">apps → numa</span></span>
<span class="panel-title" id="transportEncrypted" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="transportBars">
</div>
</div>
<!-- Outbound wire (numa → internet) -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Outbound Wire <span style="color: var(--text-dim); font-weight: normal;">numa → internet</span></span>
<span class="panel-title" id="upstreamWireEncrypted" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="upstreamWireBars">
</div>
</div>
<!-- Main grid: query log + sidebar -->
<div class="main-grid">
<!-- Query log -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Recent Queries</span>
<div style="display:flex;align-items:center;gap:0.5rem;">
<input type="text" id="logFilterDomain" placeholder="filter domain..." oninput="applyLogFilter()"
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.5rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-primary);outline:none;width:150px;">
<select id="logFilterPath" onchange="applyLogFilter()"
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
<option value="">all paths</option>
<option value="RECURSIVE">recursive</option>
<option value="COALESCED">coalesced</option>
<option value="FORWARD">forward</option>
<option value="UPSTREAM">upstream</option>
<option value="CACHED">cached</option>
<option value="BLOCKED">blocked</option>
<option value="OVERRIDE">override</option>
<option value="LOCAL">local</option>
<option value="SERVFAIL">error</option>
</select>
<select id="logFilterTransport" onchange="applyLogFilter()"
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
<option value="">all transports</option>
<option value="UDP">UDP</option>
<option value="TCP">TCP</option>
<option value="DOT">DoT</option>
<option value="DOH">DoH</option>
</select>
<span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span>
</div>
</div>
<div class="query-log" id="queryLog">
<table>
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Domain</th>
<th>Path</th>
<th>Transport</th>
<th>Result</th>
<th>Latency</th>
</tr>
</thead>
<tbody id="queryLogBody">
</tbody>
</table>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Local services -->
<div class="panel">
<div class="panel-header">
<div style="flex:1;">
<span class="panel-title">Local Services</span>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.15rem;">Give localhost apps clean .numa URLs. Persistent, with HTTP proxy.</div>
</div>
<span id="lanToggle" style="font-family:var(--font-mono);font-size:0.68rem;cursor:default;user-select:none;" title=""></span>
</div>
<div class="panel-body">
<form class="override-form" id="serviceForm" onsubmit="return addService(event)">
<div class="override-form-row">
<input type="text" id="svcName" placeholder="name (becomes name.numa)" required style="flex:2">
<input type="number" id="svcPort" placeholder="port (e.g. 3000)" required min="1" max="65535" style="flex:1">
</div>
<button type="submit" class="btn btn-add">Add Service</button>
<div class="override-error" id="serviceError"></div>
</form>
<div id="servicesList">
<div class="empty-state">No services configured</div>
</div>
</div>
</div>
<!-- Active overrides -->
<div class="panel">
<div class="panel-header">
<div>
<span class="panel-title">Active Overrides</span>
<div style="font-size:0.68rem;color:var(--text-dim);margin-top:0.15rem;">Redirect any domain to any IP. Temporary, DNS-only.</div>
</div>
</div>
<div class="panel-body">
<form class="override-form" id="overrideForm" onsubmit="return addOverride(event)">
<input type="text" id="ovDomain" placeholder="domain (e.g. api.stripe.com)" required>
<input type="text" id="ovTarget" placeholder="target (e.g. 10.0.0.5 or 127.0.0.1)" required>
<div class="override-form-row">
<input type="number" id="ovTTL" placeholder="DNS TTL" value="60" min="1" title="How long clients may cache this DNS response">
<input type="number" id="ovDuration" placeholder="Expires in (s)" value="300" min="1">
</div>
<button type="submit" class="btn btn-add">Add Override</button>
<div class="override-error" id="overrideError"></div>
</form>
<div id="overridesList">
<div class="empty-state">No active overrides</div>
</div>
</div>
</div>
<!-- Blocking -->
<div class="panel" id="blockingPanel">
<div class="panel-header">
<span class="panel-title">Blocking</span>
<span class="panel-title" id="blockingRefresh" style="color:var(--text-dim);font-weight:400;"></span>
</div>
<div class="panel-body">
<form class="override-form" onsubmit="return checkDomain(event)" style="margin-bottom:0;border-bottom:none;padding-bottom:0;">
<div class="override-form-row">
<input type="text" id="checkDomainInput" placeholder="Is this domain blocked?" required style="flex:3">
<button type="submit" class="btn" style="background:var(--violet);color:white;flex-shrink:0;">Check</button>
</div>
</form>
<div id="checkResult" style="display:none;margin-top:0.6rem;padding:0.5rem 0.6rem;border-radius:5px;font-family:var(--font-mono);font-size:0.72rem;"></div>
<div id="blockingSources" style="margin-top:0.8rem;padding-top:0.6rem;border-top:1px solid var(--border);"></div>
<div id="blockingAllowlist" style="margin-top:0.8rem;padding-top:0.6rem;border-top:1px solid var(--border);"></div>
</div>
</div>
<!-- Memory breakdown -->
<div class="panel" id="memoryPanel">
<div class="panel-header">
<span class="panel-title">Memory</span>
<span class="panel-title" id="memoryTotal" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="memoryBody">
<div class="empty-state">No memory data</div>
</div>
</div>
<!-- Cache entries -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Cached Domains</span>
<span class="panel-title" id="cacheCount" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="cacheList" style="max-height: 240px; overflow-y: auto; scrollbar-width: thin;">
<div class="empty-state">Cache empty</div>
</div>
</div>
</div>
</div>
</div>
<script>
const API = '';
const h = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let prevTotal = null;
let lastLogEntries = [];
let prevTime = null;
async function fetchJSON(path) {
const res = await fetch(API + path);
if (!res.ok) throw new Error(res.status);
return res.json();
}
function formatUptime(secs) {
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m`;
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return `${h}h ${m}m`;
}
function formatUptimeSub(secs) {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (d > 0) return `${d}d ${h}h ${m}m ${s}s`;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
}
function formatTime(epoch) {
const d = new Date(epoch * 1000);
return d.toLocaleTimeString([], { hour12: false });
}
let mobilePort = 8765;
function togglePhoneSetup() {
const pop = document.getElementById('phoneSetupPopover');
const isOpen = pop.style.display !== 'none';
pop.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
if (window.innerWidth <= 700) {
document.getElementById('qrContainer').style.display = 'none';
const linkEl = document.getElementById('phoneSetupLink');
const host = window.location.hostname;
linkEl.style.display = 'block';
linkEl.innerHTML = `<a href="http://${host}:${mobilePort}/mobileconfig" style="display:inline-block;padding:0.5rem 1rem;background:var(--amber);color:white;border-radius:6px;text-decoration:none;font-family:var(--font-mono);font-size:0.75rem;">Install Profile</a>`;
} else {
fetch(API + '/qr').then(r => r.text()).then(svg => {
document.getElementById('qrContainer').innerHTML = svg;
}).catch(() => {
document.getElementById('qrContainer').innerHTML = '<div class="empty-state">Could not load QR</div>';
});
}
}
}
document.addEventListener('click', (e) => {
const setup = document.getElementById('phoneSetup');
if (setup && !setup.contains(e.target)) {
document.getElementById('phoneSetupPopover').style.display = 'none';
}
});
function shortSrc(addr) {
if (!addr) return '';
const ip = addr.replace(/:\d+$/, '');
if (ip === '127.0.0.1' || ip === '::1') return 'localhost';
return ip;
}
function formatRemaining(secs) {
if (secs == null) return 'permanent';
if (secs < 60) return `${secs}s left`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s left`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`;
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
const MEMORY_COMPONENTS = [
{ key: 'cache', label: 'Cache', cls: 'cache', color: 'var(--teal)' },
{ key: 'blocklist', label: 'Blocklist', cls: 'blocklist', color: 'var(--rose)' },
{ key: 'query_log', label: 'Query Log', cls: 'querylog', color: 'var(--amber)' },
{ key: 'srtt', label: 'SRTT', cls: 'srtt', color: 'var(--cyan)' },
{ key: 'overrides', label: 'Overrides', cls: 'overrides', color: 'var(--violet)' },
];
function renderMemory(mem, stats) {
if (!mem) return;
// Stat card
document.getElementById('memoryRss').textContent = formatBytes(mem.process_memory_bytes);
document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes);
const entryCounts = {
cache: stats.cache.entries,
blocklist: stats.blocking.domains_loaded,
query_log: mem.query_log_entries,
srtt: mem.srtt_entries,
overrides: stats.overrides.active,
};
// Sidebar panel
const total = mem.total_estimated_bytes || 1;
document.getElementById('memoryTotal').textContent = formatBytes(total);
const barSegments = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const pct = ((bytes / total) * 100).toFixed(1);
return `<div class="memory-bar-seg ${c.cls}" style="width:${pct}%" title="${c.label}: ${formatBytes(bytes)} (${pct}%)"></div>`;
}).join('');
const rows = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const entries = entryCounts[c.key] || 0;
return `
<div class="memory-row">
<div class="memory-row-dot" style="background:${c.color}"></div>
<span class="memory-row-label">${c.label}</span>
<span class="memory-row-size">${formatBytes(bytes)}</span>
<span class="memory-row-entries">${formatNumber(entries)} entries</span>
</div>`;
}).join('');
document.getElementById('memoryBody').innerHTML = `
<div class="memory-bar">${barSegments}</div>
${rows}
<div class="memory-rss">
<span>Process Footprint</span>
<span>${formatBytes(mem.process_memory_bytes)}</span>
</div>
`;
}
function renderBarChart(containerId, defs, data, total) {
total = total || 1;
document.getElementById(containerId).innerHTML = defs
.filter(d => (data[d.key] || 0) > 0)
.map(d => {
const count = data[d.key] || 0;
const pct = ((count / total) * 100).toFixed(1);
return `
<div class="path-bar-row">
<span class="path-label">${d.label}</span>
<div class="path-bar-track">
<div class="path-bar-fill ${d.cls}" style="width: ${pct}%"></div>
</div>
<span class="path-pct">${pct}%</span>
</div>`;
}).join('');
}
function encryptionPct(data, encryptedKeys, allKeys) {
const total = allKeys.reduce((s, k) => s + (data[k] || 0), 0);
if (total === 0) return 0;
const encrypted = encryptedKeys.reduce((s, k) => s + (data[k] || 0), 0);
return Math.round((encrypted / total) * 100);
}
const PATH_DEFS = [
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
{ key: 'upstream', label: 'Upstream', cls: 'upstream' },
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
{ key: 'cached', label: 'Cached', cls: 'cached' },
{ key: 'local', label: 'Local', cls: 'local' },
{ key: 'overridden', label: 'Override', cls: 'override' },
{ key: 'blocked', label: 'Blocked', cls: 'blocked' },
{ key: 'errors', label: 'Errors', cls: 'error' },
];
function renderPaths(queries) {
renderBarChart('pathBars', PATH_DEFS, queries, queries.total);
}
const TRANSPORT_DEFS = [
{ key: 'udp', label: 'UDP', cls: 'udp' },
{ key: 'tcp', label: 'TCP', cls: 'tcp' },
{ key: 'dot', label: 'DoT', cls: 'dot' },
{ key: 'doh', label: 'DoH', cls: 'doh' },
];
function renderTransport(transport) {
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
renderBarChart('transportBars', TRANSPORT_DEFS, transport, total);
const encPct = encryptionPct(transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']);
const el = document.getElementById('transportEncrypted');
el.textContent = `${encPct}% encrypted inbound`;
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
}
const UPSTREAM_WIRE_DEFS = [
{ key: 'udp', label: 'UDP', cls: 'udp' },
{ key: 'doh', label: 'DoH', cls: 'doh' },
{ key: 'dot', label: 'DoT', cls: 'dot' },
{ key: 'odoh', label: 'ODoH', cls: 'odoh' },
];
function renderUpstreamWire(ut) {
const total = (ut.udp + ut.doh + ut.dot + ut.odoh) || 0;
renderBarChart('upstreamWireBars', UPSTREAM_WIRE_DEFS, ut, total || 1);
const encPct = encryptionPct(ut, ['doh', 'dot', 'odoh'], ['udp', 'doh', 'dot', 'odoh']);
const el = document.getElementById('upstreamWireEncrypted');
el.textContent = total > 0 ? `${encPct}% encrypted outbound` : '';
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
}
function renderQueryLog(entries) {
lastLogEntries = entries;
applyLogFilter();
}
function applyLogFilter() {
const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase();
const pathFilter = document.getElementById('logFilterPath').value;
const transportFilter = document.getElementById('logFilterTransport').value;
let filtered = lastLogEntries;
if (domainFilter) {
filtered = filtered.filter(e => e.domain.toLowerCase().includes(domainFilter));
}
if (pathFilter) {
filtered = filtered.filter(e => e.path === pathFilter);
}
if (transportFilter) {
filtered = filtered.filter(e => e.transport === transportFilter);
}
const tbody = document.getElementById('queryLogBody');
document.getElementById('queryCount').textContent =
filtered.length < lastLogEntries.length
? `${filtered.length} / ${lastLogEntries.length}`
: `last ${filtered.length}`;
tbody.innerHTML = filtered.map(e => {
const allowBtn = e.path === 'BLOCKED'
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
: '';
return `
<tr title="Source: ${e.src || 'unknown'}">
<td>${formatTime(e.timestamp_epoch)}<br><span class="src-tag">${shortSrc(e.src)}</span></td>
<td>${e.query_type}</td>
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
<td><span class="path-tag ${e.path}">${e.path}</span></td>
<td><span class="path-tag ${e.transport}">${e.transport}</span></td>
<td style="white-space:nowrap;"><span style="display:inline-block;width:15px;text-align:center;">${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}</span>${e.rescode}</td>
<td>${e.latency_ms.toFixed(1)}ms</td>
</tr>`;
}).join('');
}
function renderOverrides(entries) {
const el = document.getElementById('overridesList');
if (!entries.length) {
el.innerHTML = '<div class="empty-state">No active overrides</div>';
return;
}
el.innerHTML = entries.map(e => `
<div class="override-item">
<div class="override-item-header">
<span class="override-domain" style="cursor:pointer" onclick="editOverride('${e.domain}','${e.target}',${e.ttl || 60},${e.remaining_secs || 300})" title="Click to edit">${e.domain}</span>
<button class="btn-delete" onclick="deleteOverride('${e.domain}')" title="Remove override">&times;</button>
</div>
<div class="override-target">${e.record_type} &rarr; ${e.target}</div>
<div class="override-ttl">${e.remaining_secs != null ? formatRemaining(e.remaining_secs) : 'permanent'}</div>
</div>
`).join('');
}
async function addOverride(event) {
event.preventDefault();
const errEl = document.getElementById('overrideError');
errEl.style.display = 'none';
try {
const body = {
domain: document.getElementById('ovDomain').value.trim(),
target: document.getElementById('ovTarget').value.trim(),
ttl: parseInt(document.getElementById('ovTTL').value) || 60,
duration_secs: parseInt(document.getElementById('ovDuration').value) || 300,
};
const res = await fetch(API + '/overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text);
}
document.getElementById('ovDomain').value = '';
document.getElementById('ovTarget').value = '';
refresh();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
}
return false;
}
async function deleteOverride(domain) {
try {
await fetch(API + '/overrides/' + encodeURIComponent(domain), { method: 'DELETE' });
refresh();
} catch (err) { /* next refresh will update */ }
}
function editOverride(domain, target, ttl, duration) {
document.getElementById('ovDomain').value = domain;
document.getElementById('ovTarget').value = target;
document.getElementById('ovTTL').value = ttl;
document.getElementById('ovDuration').value = duration;
document.getElementById('ovDomain').focus();
}
function renderCache(entries) {
const el = document.getElementById('cacheList');
document.getElementById('cacheCount').textContent = entries.length ? `${entries.length} entries` : '';
if (!entries.length) {
el.innerHTML = '<div class="empty-state">Cache empty</div>';
return;
}
// Show first 50, sorted by TTL remaining desc
const sorted = entries.sort((a, b) => b.ttl_remaining - a.ttl_remaining).slice(0, 50);
el.innerHTML = sorted.map(e => `
<div class="cache-item">
<span class="cache-domain" title="${e.domain}">${e.domain}</span>
<span class="cache-ttl">${e.query_type} ${e.ttl_remaining}s</span>
</div>
`).join('');
}
async function refresh() {
try {
const [stats, logs, overrides, cache, services, blockingInfo, allowlist] = await Promise.all([
fetchJSON('/stats'),
fetchJSON('/query-log?limit=200'),
fetchJSON('/overrides'),
fetchJSON('/cache'),
fetchJSON('/services'),
fetchJSON('/blocking/stats'),
fetchJSON('/blocking/allowlist'),
]);
// Connection status
document.getElementById('statusDot').className = 'status-dot';
document.getElementById('statusText').textContent = 'connected';
// Stats cards
const q = stats.queries;
document.getElementById('totalQueries').textContent = formatNumber(q.total);
document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs);
document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs);
document.getElementById('headerVersion').textContent = stats.version ? 'v' + stats.version : '';
document.getElementById('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || '';
document.getElementById('footerData').textContent = stats.data_dir || '';
document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off';
document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
document.getElementById('footerSrtt').style.color = stats.srtt ? 'var(--emerald)' : 'var(--text-dim)';
if (!document.getElementById('footerLogs').textContent) {
const isWin = stats.data_dir && stats.data_dir.includes(':\\');
const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/');
const logsEl = document.getElementById('footerLogs');
logsEl.textContent = isWin
? stats.data_dir + '\\numa.log'
: isMac ? '/usr/local/var/log/numa.log'
: 'journalctl -u numa -f';
}
// LAN status indicator
const lanEl = document.getElementById('lanToggle');
if (stats.lan) {
if (!stats.lan.enabled) {
lanEl.style.color = 'var(--text-dim)';
lanEl.textContent = 'LAN off';
lanEl.title = 'Enable with: numa lan on';
} else {
const pc = stats.lan.peers || 0;
lanEl.style.color = pc > 0 ? 'var(--emerald)' : 'var(--teal)';
lanEl.textContent = `LAN on · ${pc} peer${pc !== 1 ? 's' : ''}`;
lanEl.title = 'mDNS discovery active (_numa._tcp.local)';
}
}
const phoneSetupEl = document.getElementById('phoneSetup');
if (stats.mobile && stats.mobile.enabled) {
phoneSetupEl.style.display = '';
mobilePort = stats.mobile.port;
} else {
phoneSetupEl.style.display = 'none';
}
document.getElementById('overrideCount').textContent = stats.overrides.active;
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
const bl = stats.blocking;
document.getElementById('blockedSub').textContent =
bl.paused ? 'paused' :
!bl.enabled ? 'disabled' :
bl.domains_loaded > 0 ? `${formatNumber(bl.domains_loaded)} in blocklist` : 'loading...';
// Blocking controls — single primary button + secondary toggle
const toggleBtn = document.getElementById('toggleBtn');
const pauseBtn = document.getElementById('pauseBtn');
toggleBtn.style.display = 'inline-block';
if (bl.paused) {
// Primary action: unpause. Hide toggle to prevent accidental disable.
pauseBtn.style.display = 'inline-block';
pauseBtn.textContent = 'Unpause';
pauseBtn.onclick = unpauseBlocking;
toggleBtn.textContent = 'Paused';
toggleBtn.style.background = 'var(--amber)';
toggleBtn.onclick = unpauseBlocking; // clicking "Paused" unpauses, not disables
} else if (bl.enabled) {
toggleBtn.textContent = 'Blocking On';
toggleBtn.style.background = 'var(--emerald)';
} else {
toggleBtn.textContent = 'Blocking Off';
toggleBtn.style.background = 'var(--rose)';
}
document.getElementById('cacheEntries').textContent =
`${stats.cache.entries} / ${formatNumber(stats.cache.max_entries)} entries`;
// QPS calculation
const now = Date.now();
const encPct = encryptionPct(stats.transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']);
if (prevTotal !== null && prevTime !== null) {
const dt = (now - prevTime) / 1000;
const dq = q.total - prevTotal;
const qps = dt > 0 ? (dq / dt).toFixed(1) : '0.0';
const encTag = q.total > 0 ? ` · ${encPct}% enc` : '';
document.getElementById('qps').textContent = `~${qps}/s${encTag}`;
}
prevTotal = q.total;
prevTime = now;
// Cache hit rate
const answered = q.cached + q.forwarded + q.upstream + q.recursive + q.coalesced + q.local + q.overridden;
const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0';
document.getElementById('cacheRate').textContent = hitRate + '%';
// Panels
renderPaths(q);
renderTransport(stats.transport);
renderUpstreamWire(stats.upstream_transport || { udp: 0, doh: 0, dot: 0, odoh: 0 });
renderQueryLog(logs);
renderOverrides(overrides);
renderCache(cache);
renderServices(services);
renderBlockingInfo(blockingInfo);
renderAllowlist(allowlist);
renderMemory(stats.memory, stats);
} catch (err) {
console.error('[numa dashboard] render failed:', err);
document.getElementById('statusDot').className = 'status-dot error';
document.getElementById('statusText').textContent = 'disconnected';
}
}
async function toggleBlocking() {
try {
const stats = await fetchJSON('/blocking/stats');
const newState = !stats.enabled;
await fetch(API + '/blocking/toggle', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newState }),
});
refresh();
} catch (err) {}
}
async function pauseBlocking() {
try {
await fetch(API + '/blocking/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minutes: 5 }),
});
refresh();
} catch (err) {}
}
async function unpauseBlocking() {
try {
await fetch(API + '/blocking/unpause', { method: 'POST' });
refresh();
} catch (err) {}
}
async function allowDomain(domain) {
try {
await fetch(API + '/blocking/allowlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain: domain }),
});
refresh();
} catch (err) {}
}
async function checkDomain(event) {
event.preventDefault();
const domain = document.getElementById('checkDomainInput').value.trim();
const el = document.getElementById('checkResult');
if (!domain) return false;
try {
const result = await fetchJSON('/blocking/check/' + encodeURIComponent(domain));
el.style.display = 'block';
if (result.blocked) {
el.style.background = 'rgba(181, 68, 58, 0.1)';
el.style.color = 'var(--rose)';
el.innerHTML = `<strong>Blocked</strong> — ${h(result.reason)}` +
(result.matched_rule ? `<br>Rule: <code>${h(result.matched_rule)}</code>` : '') +
` <button class="btn-delete" onclick="allowDomain('${h(domain)}')" style="color:var(--emerald);font-size:0.7rem;margin-left:0.4rem;">allow</button>`;
} else {
el.style.background = 'rgba(82, 122, 82, 0.1)';
el.style.color = 'var(--emerald)';
el.innerHTML = `<strong>Allowed</strong> — ${h(result.reason)}` +
(result.matched_rule ? `<br>Rule: <code>${h(result.matched_rule)}</code>` : '');
}
} catch (err) {
el.style.display = 'block';
el.style.background = 'rgba(181, 68, 58, 0.1)';
el.style.color = 'var(--rose)';
el.textContent = 'Error: ' + err.message;
}
return false;
}
function shortenUrl(url) {
try {
const u = new URL(url);
const parts = u.pathname.split('/').filter(Boolean);
// For GitHub CDN URLs, show "owner/repo/.../filename"
if (u.hostname.includes('jsdelivr') || u.hostname.includes('github')) {
const owner = parts[1] || '';
const file = parts[parts.length - 1] || '';
return owner ? `${owner} / ${file}` : file;
}
// For other URLs, show hostname + filename
const file = parts[parts.length - 1] || '';
return `${u.hostname} / ${file}`;
} catch { return url; }
}
function renderBlockingInfo(info) {
const el = document.getElementById('blockingSources');
const refreshEl = document.getElementById('blockingRefresh');
if (info.last_refresh_secs_ago != null) {
refreshEl.textContent = `refreshed ${formatUptime(info.last_refresh_secs_ago)} ago`;
}
const sources = info.list_sources || [];
if (!sources.length) {
el.innerHTML = '';
return;
}
el.innerHTML = `
<div style="font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:0.4rem;">Sources &middot; ${formatNumber(info.domains_loaded)} domains</div>
${sources.map(s => `
<div style="padding:0.3rem 0;font-family:var(--font-mono);font-size:0.72rem;">
<a href="${s}" target="_blank" rel="noopener" style="color:var(--text-secondary);text-decoration:none;" title="${s}">${shortenUrl(s)}</a>
</div>
`).join('')}
`;
}
function renderAllowlist(entries) {
if (document.activeElement && document.activeElement.id === 'allowDomainInput') return;
const el = document.getElementById('blockingAllowlist');
const count = entries.length;
el.innerHTML = `
<div style="font-size:0.65rem;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:0.4rem;">Allowlist${count ? ` (${count})` : ''}</div>
${count ? entries.map(d => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:0.25rem 0;border-bottom:1px solid var(--border);">
<span style="font-family:var(--font-mono);font-size:0.75rem;color:var(--emerald);">${d}</span>
<button class="btn-delete" onclick="removeAllowlistDomain('${d}')">&times;</button>
</div>
`).join('') : '<div class="empty-state">No exceptions</div>'}
<form onsubmit="return addAllowlistDomain(event)" style="display:flex;gap:0.4rem;margin-top:0.4rem;">
<input type="text" id="allowDomainInput" placeholder="domain to allow" required style="flex:1;font-family:var(--font-mono);font-size:0.75rem;padding:0.35rem 0.5rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-primary);outline:none;">
<button type="submit" class="btn" style="background:var(--emerald);color:white;flex-shrink:0;">Allow</button>
</form>
`;
}
async function addAllowlistDomain(event) {
event.preventDefault();
const input = document.getElementById('allowDomainInput');
const domain = input.value.trim();
if (!domain) return false;
try {
await fetch(API + '/blocking/allowlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain }),
});
input.value = '';
refresh();
} catch (err) {}
return false;
}
async function removeAllowlistDomain(domain) {
try {
await fetch(API + '/blocking/allowlist/' + encodeURIComponent(domain), { method: 'DELETE' });
refresh();
} catch (err) {}
}
let editingRoute = false;
function renderServices(entries) {
if (editingRoute) return;
const el = document.getElementById('servicesList');
if (!entries.length) {
el.innerHTML = '<div class="empty-state">No services configured</div>';
return;
}
el.innerHTML = entries.map(e => {
const lanBadge = e.healthy
? (e.lan_accessible
? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>'
: '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>')
: '';
const routeLines = (e.routes || []).map(r =>
`<div class="service-port" style="color:var(--text-dim);display:flex;align-items:center;gap:0.3rem;">` +
`<span style="display:inline-block;min-width:60px;">${h(r.path)}</span> ` +
`&rarr; :${parseInt(r.port)||0}` +
(r.strip ? ` <span style="opacity:0.6;">(strip)</span>` : '') +
(e.name === 'numa' ? '' : ` <button class="btn-delete" onclick="deleteRoute('${h(e.name)}','${h(r.path)}')" title="Remove route" style="font-size:0.65rem;padding:0 0.25rem;min-width:auto;opacity:0.5;">&times;</button>`) +
`</div>`
).join('');
const deletable = e.source !== 'config' && e.name !== 'numa';
const name = h(e.name);
return `
<div class="service-item">
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
<div class="service-info">
<div class="service-name"><a href="${h(e.url)}" target="_blank">${name}.numa</a>${lanBadge}</div>
<div class="service-port">localhost:${parseInt(e.target_port)||0} &rarr; proxied</div>
${routeLines}
${e.name === 'numa' ? '' : `<div style="margin-top:0.3rem;"><button onclick="toggleRouteForm('${name}')" style="font-size:0.7rem;padding:0.1rem 0.4rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">+ route</button><div id="routeForm-${name}" style="display:none;margin-top:0.3rem;"><div style="display:flex;gap:0.3rem;align-items:center;"><input type="text" id="routePath-${name}" placeholder="/path" style="flex:2;padding:0.25rem 0.4rem;font-size:0.75rem;"><input type="number" id="routePort-${name}" value="${parseInt(e.target_port)||0}" min="1" max="65535" style="flex:1;padding:0.25rem 0.4rem;font-size:0.75rem;"><label style="font-size:0.7rem;color:var(--text-dim);display:flex;align-items:center;gap:0.2rem;"><input type="checkbox" id="routeStrip-${name}">strip</label><button onclick="addRoute('${name}')" style="font-size:0.7rem;padding:0.2rem 0.5rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">add</button></div><div class="override-error" id="routeError-${name}" style="display:none;font-size:0.7rem;"></div></div></div>`}
</div>
${deletable ? `<button class="btn-delete" onclick="deleteService('${name}')" title="Remove service">&times;</button>` : ''}
</div>
`}).join('');
}
function toggleRouteForm(name) {
const el = document.getElementById('routeForm-' + name);
const opening = el.style.display === 'none';
el.style.display = opening ? 'block' : 'none';
editingRoute = opening;
}
async function addRoute(name) {
const errEl = document.getElementById('routeError-' + name);
errEl.style.display = 'none';
try {
const path = document.getElementById('routePath-' + name).value.trim();
const port = parseInt(document.getElementById('routePort-' + name).value) || 0;
const strip = document.getElementById('routeStrip-' + name).checked;
const res = await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, port, strip }),
});
if (!res.ok) throw new Error(await res.text());
editingRoute = false;
refresh();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
}
}
async function deleteRoute(name, path) {
try {
await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
refresh();
} catch (err) { /* next refresh will update */ }
}
async function addService(event) {
event.preventDefault();
const errEl = document.getElementById('serviceError');
errEl.style.display = 'none';
try {
const body = {
name: document.getElementById('svcName').value.trim(),
target_port: parseInt(document.getElementById('svcPort').value) || 0,
};
const res = await fetch(API + '/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text);
}
document.getElementById('svcName').value = '';
document.getElementById('svcPort').value = '';
refresh();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
}
return false;
}
async function deleteService(name) {
try {
await fetch(API + '/services/' + encodeURIComponent(name), { method: 'DELETE' });
refresh();
} catch (err) { /* next refresh will update */ }
}
// Initial load + polling
refresh();
setInterval(refresh, 2000);
</script>
<div style="text-align:center;padding:0.8rem 0.8rem 0.4rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);line-height:1.8;">
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
· Logs: <span id="footerLogs" style="user-select:all;color:var(--emerald);"></span>
<br>
Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span>
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span>
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
</div>
</body>
</html>