* feat: numa setup-phone — QR-based mobile DoT onboarding Adds a CLI subcommand that generates a one-time mobileconfig profile containing both the Numa local CA (as a com.apple.security.root payload) and the DoT DNS settings, then serves it via a temporary HTTP server and prints a scannable QR code in the terminal. Flow: 1. User runs `numa setup-phone` (no sudo needed) 2. Detects current LAN IP, reads CA from /usr/local/var/numa/ca.pem 3. Builds combined mobileconfig (CA trust + DoT) 4. Renders QR code with qrcode crate (Unicode block characters) 5. Serves the profile on port 8765, stays open until Ctrl+C 6. Counts successful downloads (multi-device households) Important caveat documented in instructions: even with the CA bundled in the profile, iOS still requires the user to manually enable trust in Settings → General → About → Certificate Trust Settings. Verified on a real iPhone. Stable PayloadIdentifiers/UUIDs ensure re-running replaces the existing profile on iOS rather than accumulating duplicates. - New module: src/setup_phone.rs (~270 lines) - New CLI subcommand: `numa setup-phone` - New dependency: qrcode = "0.14" (default-features = false) - tokio "signal" feature added for Ctrl+C handling - 3 unit tests: PEM stripping, mobileconfig generation, QR rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: mobile API, enriched /health, mobileconfig module Adds a persistent read-only HTTP listener (default port 8765, LAN-bound) serving a dedicated subset of Numa's API for iOS/Android companion apps and as a replacement for the one-shot server setup_phone used to spin up: GET /health — enriched JSON with version, hostname, LAN IP, SNI, DoT config, mobile API port, CA fingerprint, features (shared handler with the main API on port 5380) GET /ca.pem — public CA certificate (shared handler) GET /mobileconfig — full iOS profile (CA trust + DNS settings pinned to current LAN IP) GET /ca.mobileconfig — CA-only iOS profile (trust anchor without DNS override — for the iOS companion app's programmatic DNS flow via NEDNSSettingsManager) All routes are idempotent GETs. The mobile API never serves the state-mutating routes that live on the main API (overrides, blocking toggle, service CRUD, cache flush), so it is safe to expose on the LAN regardless of the main API's bind address. The CA private key is never served by any route. Opt-in via `[mobile] enabled = true`. Default is false so new installs do not silently expose a LAN listener after upgrading; our committed numa.toml template enables it explicitly for spike testing. New modules: - src/mobileconfig.rs — ProfileMode::{Full, CaOnly} enum with plist builder lifted from setup_phone.rs. Full and CaOnly share the CA payload UUID (same trust anchor) but have distinct top-level UUIDs so they coexist as separate installable profiles on iOS. - src/health.rs — HealthMeta cached metadata built once at startup from config + CA fingerprint (SHA-256 of the PEM via ring), and the HealthResponse JSON shape shared between the main and mobile APIs. - src/mobile_api.rs — axum Router for the persistent listener. Reuses api::health and api::serve_ca from the main API; owns the two mobileconfig handlers. Modified: - src/api.rs — health() returns the enriched HealthResponse, now pub. serve_ca is now pub so mobile_api can reuse it. - src/config.rs — MobileConfig section (enabled, port, bind_addr). - src/ctx.rs — health_meta: HealthMeta field on ServerCtx. - src/main.rs — builds HealthMeta at startup, spawns mobile API listener if enabled. - src/lan.rs — build_announcement takes &HealthMeta and writes enriched TXT records (version, api_port, proto, dot_port, ca_fp). SRV port now reports the mobile API port; peer discovery still reads TXT `services=` so this is backwards compatible. Always announces even when no .numa services are registered, so the iOS companion app can discover Numa via mDNS regardless of service state. - src/setup_phone.rs — reduced from 267 to 100 lines. The CLI is now a thin QR wrapper over the persistent /mobileconfig endpoint; the hand-rolled one-shot HTTP server (accept_loop, RUST_OK_HEADERS, RUST_NOT_FOUND, download counter) is gone. - src/dot.rs — test fixture updated with HealthMeta::test_fixture(). - numa.toml — commented [mobile] section, enabled = true for spike. Tests: 136 unit tests passing (5 new in mobileconfig, 3 new in health). cargo clippy clean. Integration sanity check: curl'd /health, /ca.pem, /mobileconfig, /ca.mobileconfig against a running numa — all return 200 with correct content types and valid response bodies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: setup-phone probe, unknown command error, query source in dashboard - setup-phone now probes the mobile API before printing the QR code and shows an actionable error if [mobile] is not enabled - Unknown CLI subcommands print an error instead of silently attempting to start a full server - Dashboard query log shows source IP under timestamp (localhost for loopback, full IP for LAN devices) with full addr on hover Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1405 lines
47 KiB
HTML
1405 lines
47 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.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); }
|
|
.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: 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"> </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"> </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"> </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"> </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>
|
|
|
|
<!-- 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
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 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>
|
|
`;
|
|
}
|
|
|
|
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 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 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">×</button>
|
|
</div>
|
|
<div class="override-target">${e.record_type} → ${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);
|
|
renderMemory(stats.memory, stats);
|
|
|
|
} 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 · ${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}')">×</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> ` +
|
|
`→ :${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;">×</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} → 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">×</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>
|