Files
numa/site/dashboard.html
Razvan Dimescu 8791198d10 feat: forward-by-default, auto recursive mode, Linux install fixes (#27)
* feat: auto recursive mode, fix Linux install

Auto mode (new default): probes a root server on startup; uses
recursive resolution if outbound DNS works, falls back to Quad9 DoH
if blocked. Dashboard shows mode indicator (green/yellow).

Linux install fixes:
- Add DNSStubListener=no to resolved drop-in (frees port 53)
- Configure DNS before starting service (correct ordering)
- Skip 127.0.0.53 in upstream detection
- `numa install` now does everything (service + DNS + CA)
- `numa uninstall` mirrors install (stop service + restore DNS)
- Extract is_loopback_or_stub() for consistent filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: enable DNSSEC validation by default

With recursive as the default mode, DNSSEC validation completes the
trustless resolution chain. Strict mode remains off by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: forward search domains to VPC resolver on Linux

Parse search/domain lines from resolv.conf and create conditional
forwarding rules to the original nameserver or AWS VPC resolver
(169.254.169.253). Fixes internal hostname resolution on cloud VMs
where recursive mode can't resolve private DNS zones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: single-pass resolv.conf parsing, eliminate redundancies

Parse resolv.conf once for both upstream and search domains instead
of 2-3 reads. Extract CLOUD_VPC_RESOLVER constant. Use &'static str
for mode in StatsResponse. Remove dead read_upstream_from_file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: macOS install health check, harden recursive probe

Verify numa is listening (API port) before redirecting system DNS on
macOS — if the service fails to start (e.g. port 53 in use), unload
the service and abort instead of breaking DNS. Probe up to 3 root
hints before declaring recursive mode unavailable. Validate IPs from
resolvectl to avoid IPv6 fragment extraction. Extract DEFAULT_API_PORT
constant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: widen make_rule cfg gate to include Linux

make_rule was gated to macOS-only but discover_linux() calls it for
search domain forwarding rules. CI failed on Linux with E0425.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: forward mode as default, recursive opt-in

Forward mode (transparent proxy to system DNS) is now the default.
Recursive and auto modes are explicit opt-in via config. This avoids
bypassing corporate DNS policies, captive portals, VPC private zones,
and parental controls on first install.

- Move #[default] from Auto to Forward on UpstreamMode
- DNSSEC defaults to off (no-op in forward mode)
- 3-way match in main: Forward/Recursive/Auto with clean separation
- Post-install message suggests mode = "recursive" for sovereignty
- Update README, site, and launch drafts messaging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:49:16 +03:00

1251 lines
42 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(5, 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-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.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-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.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); }
/* 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;
}
/* Responsive */
@media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.dashboard { padding: 1rem; }
.header { padding: 1rem; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<div class="logo">Numa</div>
<div class="tagline">DNS that governs itself</div>
</div>
<div style="display:flex;align-items:center;gap:1.2rem;">
<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>
<!-- 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>
<!-- 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="CACHED">cached</option>
<option value="BLOCKED">blocked</option>
<option value="OVERRIDE">override</option>
<option value="LOCAL">local</option>
<option value="SERVFAIL">error</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>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>
<!-- 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 });
}
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`;
}
const PATH_DEFS = [
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
{ 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) {
const total = queries.total || 1;
const container = document.getElementById('pathBars');
container.innerHTML = PATH_DEFS.map(p => {
const count = queries[p.key] || 0;
const pct = ((count / total) * 100).toFixed(1);
return `
<div class="path-bar-row">
<span class="path-label">${p.label}</span>
<div class="path-bar-track">
<div class="path-bar-fill ${p.cls}" style="width: ${pct}%"></div>
</div>
<span class="path-pct">${pct}%</span>
</div>`;
}).join('');
}
function renderQueryLog(entries) {
lastLogEntries = entries;
applyLogFilter();
}
function applyLogFilter() {
const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase();
const pathFilter = document.getElementById('logFilterPath').value;
let filtered = lastLogEntries;
if (domainFilter) {
filtered = filtered.filter(e => e.domain.toLowerCase().includes(domainFilter));
}
if (pathFilter) {
filtered = filtered.filter(e => e.path === pathFilter);
}
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>
<td>${formatTime(e.timestamp_epoch)}</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 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('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || '';
document.getElementById('footerData').textContent = stats.data_dir || '';
const modeEl = document.getElementById('footerMode');
modeEl.textContent = stats.mode || '—';
modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)';
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)';
// 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)';
}
}
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();
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';
document.getElementById('qps').textContent = `~${qps}/s`;
}
prevTotal = q.total;
prevTime = now;
// Cache hit rate
const answered = q.cached + q.forwarded + 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);
renderQueryLog(logs);
renderOverrides(overrides);
renderCache(cache);
renderServices(services);
renderBlockingInfo(blockingInfo);
renderAllowlist(allowlist);
} catch (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) {
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;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);">
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
· Mode: <span id="footerMode" style="color:var(--text-dim);"></span>
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span>
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span>
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
</div>
</body>
</html>