Followers in the inflight coalescing path now log as COALESCED instead of RECURSIVE, making it visible in the dashboard when queries were deduplicated vs independently resolved. Adds 10 tests covering InflightGuard cleanup, broadcast mechanics, and concurrent handle_query coalescing through a mock TCP DNS server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1247 lines
42 KiB
HTML
1247 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"> </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>
|
|
|
|
<!-- 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,'&').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 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">×</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 || '';
|
|
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.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 · ${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>
|
|
· 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>
|