<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Runbound — Management Console</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
.tab-active { border-bottom: 2px solid #22d3ee; color: #22d3ee; }
.fade { animation: fadeIn .15s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: #111; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
</style>
</head>
<body class="bg-gray-950 text-gray-200 min-h-screen">
<div id="setup-banner" class="bg-gray-900 border-b border-gray-800 px-6 py-3 flex flex-wrap items-center gap-3">
<span class="text-cyan-400 font-bold text-sm">RUNBOUND</span>
<input id="cfg-url" type="text" value="/api"
class="bg-gray-800 border border-gray-700 rounded px-3 py-1 text-sm w-52 focus:outline-none focus:border-cyan-500"
placeholder="http://localhost:8080" />
<input id="cfg-key" type="password"
class="bg-gray-800 border border-gray-700 rounded px-3 py-1 text-sm w-72 focus:outline-none focus:border-cyan-500"
value="99344aa13a58e77b77f13df9f4da1a12bacf3f83f625545cc08ccb116f2db452" placeholder="API key (Bearer token)" />
<button onclick="connect()" class="bg-cyan-600 hover:bg-cyan-500 text-white text-sm px-4 py-1 rounded transition">Connect</button>
<span id="conn-status" class="text-xs text-gray-500">— not connected</span>
<div class="ml-auto flex items-center gap-4 text-xs text-gray-500">
<span id="stat-qps">— qps</span>
<span id="stat-total">— queries</span>
<span id="stat-uptime">— uptime</span>
<button onclick="doReload()" title="Reload config (HUP)"
class="border border-gray-600 hover:border-cyan-500 hover:text-cyan-400 text-gray-400 text-xs px-3 py-1 rounded transition">
↺ Reload
</button>
</div>
</div>
<div class="border-b border-gray-800 flex gap-6 px-6 text-sm text-gray-500">
<button class="py-3 tab-active" onclick="showTab('overview',this)">Overview</button>
<button class="py-3 hover:text-gray-300" onclick="showTab('dns',this)">DNS Entries</button>
<button class="py-3 hover:text-gray-300" onclick="showTab('blacklist',this)">Blacklist</button>
<button class="py-3 hover:text-gray-300" onclick="showTab('feeds',this)">Feeds</button>
<button class="py-3 hover:text-gray-300" onclick="showTab('logs',this)">Logs</button>
<button class="py-3 hover:text-gray-300" onclick="showTab('system',this)">System</button>
<button class="py-3 hover:text-gray-300 ml-auto" onclick="showTab('about',this)">About</button>
</div>
<div class="p-6 max-w-6xl mx-auto">
<div id="tab-overview" class="fade">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">QPS <span class="text-gray-700">(1 min avg)</span></div>
<div id="ov-qps" class="text-2xl text-cyan-400">—</div>
</div>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Total queries</div>
<div id="ov-total" class="text-2xl text-white">—</div>
</div>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Cache hit rate</div>
<div id="ov-cache" class="text-2xl text-green-400">—</div>
</div>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Blocked</div>
<div id="ov-blocked" class="text-2xl text-red-400">—</div>
<div id="ov-blocked-pct" class="text-xs text-gray-600 mt-0.5">— of total</div>
</div>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Forwarded</div>
<div id="ov-forwarded" class="text-2xl text-white">—</div>
</div>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">SERVFAIL</div>
<div id="ov-servfail" class="text-2xl text-yellow-400">—</div>
</div>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Latency p50</div>
<div id="ov-latency" class="text-2xl text-white">—</div>
</div>
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
<div class="text-xs text-gray-500 mb-1">Uptime</div>
<div id="ov-uptime" class="text-2xl text-white">—</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Latency</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">p50</span><span id="ov-p50" class="text-green-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">p95</span><span id="ov-p95" class="text-yellow-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">p99</span><span id="ov-p99" class="text-red-400">—</span>
</div>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">QPS</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">1 min avg</span><span id="ov-qps1m" class="text-cyan-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">5 min avg</span><span id="ov-qps5m" class="text-cyan-300">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">peak</span><span id="ov-qpspeak" class="text-white">—</span>
</div>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Blocking</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">NXDOMAIN</span><span id="ov-nxdomain" class="text-red-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">Refused</span><span id="ov-refused" class="text-orange-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">Local hits</span><span id="ov-local" class="text-cyan-300">—</span>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Cache</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">Entries</span><span id="ov-cache-entries" class="text-green-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">Hit rate</span><span id="ov-cache-rate" class="text-green-300">—</span>
</div>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">
DNSSEC <span id="ov-dnssec-status" class="text-gray-600 normal-case font-normal ml-1"></span>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-500">Secure</span><span id="ov-dnssec-secure" class="text-green-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">Bogus</span><span id="ov-dnssec-bogus" class="text-red-400">—</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-500">Insecure</span><span id="ov-dnssec-insecure" class="text-gray-400">—</span>
</div>
</div>
</div>
</div>
<div class="text-right text-xs text-gray-700 mt-3" id="ov-refreshed">—</div>
</div>
<div id="tab-dns" class="hidden fade">
<div class="flex gap-3 mb-4 flex-wrap">
<input id="dns-name" type="text" placeholder="name (e.g. myserver.home.)" class="input w-64" />
<select id="dns-type" class="input w-24">
<option>A</option><option>AAAA</option><option>CNAME</option><option>MX</option><option>TXT</option>
</select>
<input id="dns-value" type="text" placeholder="value (e.g. 192.168.1.50)" class="input w-48" />
<input id="dns-ttl" type="number" placeholder="TTL" value="300" class="input w-24" />
<button onclick="dnsAdd()" class="btn-primary">+ Add</button>
<button onclick="loadDns()" class="btn-secondary">↻ Refresh</button>
</div>
<div id="dns-list" class="space-y-2"></div>
</div>
<div id="tab-blacklist" class="hidden fade">
<div class="flex gap-3 mb-4 flex-wrap">
<input id="bl-domain" type="text" placeholder="domain (e.g. ads.example.com)" class="input w-72" />
<select id="bl-action" class="input w-32">
<option value="nxdomain">nxdomain</option>
<option value="refuse">refuse</option>
</select>
<button onclick="blAdd()" class="btn-primary">+ Add</button>
<button onclick="loadBlacklist()" class="btn-secondary">↻ Refresh</button>
<span id="bl-count" class="text-xs text-gray-500 self-center"></span>
</div>
<div id="bl-list" class="space-y-1"></div>
</div>
<div id="tab-feeds" class="hidden fade">
<p class="text-xs text-gray-500 mb-3">Feeds are remote blocklists Runbound downloads and applies automatically every 24 h. Each domain in a feed is blocked with the chosen action.</p>
<div class="mb-5">
<div class="text-xs text-gray-500 mb-2 uppercase tracking-wide">Available presets</div>
<div id="feed-presets" class="space-y-1">
<span class="text-gray-600 italic text-xs">Loading…</span>
</div>
</div>
<div class="flex gap-3 mb-2 flex-wrap">
<input id="feed-name" type="text" placeholder="name" class="input w-36" />
<input id="feed-url" type="text" placeholder="URL (hosts or domains format)" class="input flex-1 min-w-64" />
<select id="feed-format" class="input w-28">
<option value="hosts">hosts</option>
<option value="adblock">adblock</option>
</select>
<select id="feed-action" class="input w-32">
<option value="nxdomain">nxdomain</option>
<option value="refuse">refuse</option>
</select>
<button onclick="feedAdd()" class="btn-primary">+ Add</button>
</div>
<div class="flex items-center justify-between mt-5 mb-2">
<span class="text-xs text-gray-500 uppercase tracking-wide">Active feeds</span>
<div class="flex gap-2">
<button onclick="feedUpdateAll()" class="btn-secondary text-xs">↻ Update all now</button>
<button onclick="loadFeeds()" class="btn-secondary text-xs">↻ Refresh list</button>
</div>
</div>
<div id="feed-list" class="space-y-2"></div>
</div>
<div id="tab-system" class="hidden fade">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl">
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Runtime</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Version</span><span id="sys-version" class="text-cyan-300">—</span></div>
<div class="flex justify-between"><span class="text-gray-500">Uptime</span><span id="sys-uptime" class="text-white">—</span></div>
<div class="flex justify-between"><span class="text-gray-500">Workers</span><span id="sys-workers" class="text-white">—</span></div>
</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">XDP</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Active</span><span id="sys-xdp-active" class="text-white">—</span></div>
<div class="flex justify-between"><span class="text-gray-500">Mode</span><span id="sys-xdp-mode" class="text-white">—</span></div>
</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">CPU</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Cores</span><span id="sys-cpu-cores" class="text-white">—</span></div>
<div class="flex justify-between"><span class="text-gray-500">Usage</span><span id="sys-cpu-pct" class="text-yellow-400">—</span></div>
</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Memory</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Total</span><span id="sys-mem-total" class="text-white">—</span></div>
<div class="flex justify-between"><span class="text-gray-500">Available</span><span id="sys-mem-avail" class="text-green-400">—</span></div>
</div>
</div>
</div>
<div class="text-right text-xs text-gray-700 mt-3 max-w-2xl" id="sys-refreshed">—</div>
</div>
<div id="tab-about" class="hidden fade">
<div class="max-w-2xl">
<div class="flex items-center gap-4 mb-6">
<span class="text-cyan-400 font-bold text-3xl tracking-tight">RUNBOUND</span>
<span id="about-version" class="bg-gray-800 border border-gray-700 text-cyan-300 text-xs px-2 py-0.5 rounded">v0.6.0</span>
<span class="bg-gray-800 border border-gray-700 text-gray-400 text-xs px-2 py-0.5 rounded">AGPL-3.0</span>
</div>
<p class="text-gray-400 text-sm mb-6">
High-performance DNS server written in Rust. Designed as a drop-in replacement for Unbound with a full REST API, built-in blocklist management, and real-time observability.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Runtime</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Uptime</span><span id="about-uptime" class="text-white">—</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Total queries</span><span id="about-total" class="text-white">—</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Cache hit rate</span><span id="about-cache" class="text-green-400">—</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">QPS (1m avg)</span><span id="about-qps" class="text-cyan-400">—</span>
</div>
</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Features</div>
<ul class="text-sm text-gray-400 space-y-1">
<li class="flex gap-2"><span class="text-cyan-500">›</span> XDP zero-copy receive path</li>
<li class="flex gap-2"><span class="text-cyan-500">›</span> REST API + Prometheus metrics</li>
<li class="flex gap-2"><span class="text-cyan-500">›</span> Blocklist feeds (hosts/adblock)</li>
<li class="flex gap-2"><span class="text-cyan-500">›</span> DNSSEC validation (optional)</li>
<li class="flex gap-2"><span class="text-cyan-500">›</span> Memory pressure management</li>
<li class="flex gap-2"><span class="text-cyan-500">›</span> HMAC-chained audit log</li>
</ul>
</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4 mb-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-3">Links</div>
<div class="flex flex-wrap gap-3 text-sm">
<a href="https://github.com/redlemonbe/Runbound" target="_blank"
class="text-cyan-400 hover:text-cyan-300 border border-gray-700 hover:border-cyan-700 rounded px-3 py-1 transition">
GitHub ↗
</a>
<a href="https://github.com/redlemonbe/Runbound/releases" target="_blank"
class="text-gray-400 hover:text-gray-200 border border-gray-700 hover:border-gray-500 rounded px-3 py-1 transition">
Releases ↗
</a>
<a href="https://github.com/redlemonbe/Runbound/issues" target="_blank"
class="text-gray-400 hover:text-gray-200 border border-gray-700 hover:border-gray-500 rounded px-3 py-1 transition">
Issues ↗
</a>
</div>
</div>
<p class="text-xs text-gray-600">redlemonbe · AGPL-3.0 · Built with Rust, Tokio, Hickory DNS</p>
</div>
</div>
<div id="tab-logs" class="hidden fade">
<div class="flex gap-3 mb-4">
<button onclick="loadLogs()" class="btn-secondary">↻ Refresh</button>
<label class="flex items-center gap-2 text-sm text-gray-400">
<input type="checkbox" id="logs-auto" onchange="toggleAutoLogs(this)" class="accent-cyan-500" />
Auto-refresh (3s)
</label>
<span class="text-xs text-gray-500 self-center">Last 100 queries — requires verbosity ≥ 1</span>
</div>
<div id="log-list" class="space-y-1 font-mono text-xs"></div>
</div>
</div>
<div id="toast" class="fixed bottom-6 right-6 hidden bg-gray-800 border border-gray-700 text-sm px-4 py-2 rounded shadow-lg"></div>
<style>
.input { background:#1f2937; border:1px solid #374151; border-radius:6px; padding:6px 12px; font-size:13px; outline:none; color:#e5e7eb; }
.input:focus { border-color:#22d3ee; }
.btn-primary { background:#0e7490; color:#fff; border-radius:6px; padding:6px 16px; font-size:13px; cursor:pointer; transition:background .15s; }
.btn-primary:hover { background:#0891b2; }
.btn-secondary { background:#1f2937; border:1px solid #374151; color:#d1d5db; border-radius:6px; padding:6px 14px; font-size:13px; cursor:pointer; transition:background .15s; }
.btn-secondary:hover { background:#374151; }
.row { display:flex; align-items:center; justify-content:space-between; background:#111827; border:1px solid #1f2937; border-radius:6px; padding:8px 14px; font-size:13px; }
.del { color:#ef4444; cursor:pointer; font-size:12px; padding:2px 8px; border:1px solid #374151; border-radius:4px; }
.del:hover { background:#7f1d1d22; }
</style>
<script>
let BASE = 'http://localhost:8080';
let KEY = '';
let statsTimer = null;
let logsTimer = null;
const h = (hasBody) => {
const hdrs = { 'Authorization': `Bearer ${KEY}` };
if (hasBody) hdrs['Content-Type'] = 'application/json';
return hdrs;
};
async function api(method, path, body) {
const r = await fetch(BASE + path, {
method, headers: h(!!body), body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json().catch(() => ({}));
}
async function connect() {
BASE = document.getElementById('cfg-url').value.replace(/\/$/, '');
KEY = document.getElementById('cfg-key').value.trim();
localStorage.setItem('rb-url', BASE);
localStorage.setItem('rb-key', KEY);
try {
const s = await api('GET', '/stats');
document.getElementById('conn-status').textContent = '● connected';
document.getElementById('conn-status').className = 'text-xs text-green-400';
updateStats(s);
clearInterval(statsTimer);
statsTimer = setInterval(refreshStats, 5000);
toast('Connected');
} catch(e) {
document.getElementById('conn-status').textContent = '● ' + e.message;
document.getElementById('conn-status').className = 'text-xs text-red-400';
toast('Connection failed: ' + e.message, true);
}
}
async function refreshStats() {
try { updateStats(await api('GET', '/stats')); } catch(_) {}
}
function updateStats(s) {
const fmt = n => n == null ? '—' : n.toLocaleString();
const fmtf = n => n == null ? '—' : Number(n).toFixed(1);
const ms = n => n == null ? '—' : n + ' ms';
const pct = n => n == null ? '—' : fmtf(n) + ' %';
const sec = n => {
if (n == null) return '—';
const d = Math.floor(n/86400), h = Math.floor((n%86400)/3600),
m = Math.floor((n%3600)/60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`;
};
set('ov-qps', fmtf(s.qps_1m));
set('ov-total', fmt(s.total));
set('ov-cache', pct(s.cache_hit_rate));
set('ov-blocked', fmt(s.blocked));
set('ov-blocked-pct', s.blocked_percent != null ? fmtf(s.blocked_percent) + '% of total' : '');
set('ov-forwarded', fmt(s.forwarded));
set('ov-servfail', fmt(s.servfail));
set('ov-latency', ms(s.latency_p50_ms));
set('ov-uptime', sec(s.uptime_secs));
set('ov-p50', ms(s.latency_p50_ms));
set('ov-p95', ms(s.latency_p95_ms));
set('ov-p99', ms(s.latency_p99_ms));
set('ov-qps1m', fmtf(s.qps_1m) + ' /s');
set('ov-qps5m', fmtf(s.qps_5m) + ' /s');
set('ov-qpspeak', fmt(s.qps_peak) + ' /s');
set('ov-nxdomain', fmt(s.nxdomain));
set('ov-refused', fmt(s.refused));
set('ov-local', fmt(s.local_hits));
set('ov-cache-entries', fmt(s.cache_entries));
set('ov-cache-rate', pct(s.cache_hit_rate));
const d = s.dnssec ?? {};
const dnssecTotal = (d.secure ?? 0) + (d.bogus ?? 0) + (d.insecure ?? 0);
const dnssecOff = dnssecTotal === 0 && (s.total ?? 0) > 0;
set('ov-dnssec-secure', dnssecOff ? '—' : fmt(d.secure));
set('ov-dnssec-bogus', dnssecOff ? '—' : fmt(d.bogus));
set('ov-dnssec-insecure', dnssecOff ? '—' : fmt(d.insecure));
set('ov-dnssec-status', dnssecOff ? '· validation off' : '');
set('stat-qps', fmtf(s.qps_1m) + ' qps');
set('stat-total', fmt(s.total) + ' queries');
set('stat-uptime', sec(s.uptime_secs));
set('ov-refreshed', 'updated ' + new Date().toLocaleTimeString());
set('about-uptime', sec(s.uptime_secs));
set('about-total', fmt(s.total));
set('about-cache', pct(s.cache_hit_rate));
set('about-qps', fmtf(s.qps_1m) + ' /s');
}
async function loadDns() {
try {
const d = await api('GET', '/dns');
const entries = d.entries ?? d.dns ?? [];
document.getElementById('dns-list').innerHTML = entries.length === 0
? '<p class="text-gray-600 text-sm">No entries.</p>'
: entries.map(e => `
<div class="row">
<span class="text-cyan-300 w-48 truncate">${esc(e.name)}</span>
<span class="text-gray-500 w-16">${esc(e.type ?? e.qtype)}</span>
<span class="text-gray-300 flex-1">${esc(e.value ?? e.rdata)}</span>
<span class="text-gray-600 w-16 text-right">${e.ttl}s</span>
<button class="del ml-4" onclick="dnsDelete('${e.id}')">✕</button>
</div>`).join('');
} catch(e) { toast('DNS load failed: '+e.message, true); }
}
async function dnsAdd() {
const name = document.getElementById('dns-name').value.trim();
const type = document.getElementById('dns-type').value;
const value = document.getElementById('dns-value').value.trim();
const ttl = parseInt(document.getElementById('dns-ttl').value) || 300;
if (!name || !value) return toast('Name and value are required', true);
try {
await api('POST', '/dns', { name, type, value, ttl });
toast('Entry added');
loadDns();
document.getElementById('dns-name').value = '';
document.getElementById('dns-value').value = '';
} catch(e) { toast('Add failed: '+e.message, true); }
}
async function dnsDelete(id) {
try { await api('DELETE', '/dns/'+id); toast('Entry deleted'); loadDns(); }
catch(e) { toast('Delete failed: '+e.message, true); }
}
async function loadBlacklist() {
try {
const d = await api('GET', '/blacklist');
const entries = d.blacklist ?? d.entries ?? [];
document.getElementById('bl-count').textContent = entries.length.toLocaleString() + ' entries';
document.getElementById('bl-list').innerHTML = entries.length === 0
? '<p class="text-gray-600 text-sm">No entries.</p>'
: entries.map(e => `
<div class="row py-1">
<span class="text-red-300 flex-1">${esc(e.domain)}</span>
<span class="text-gray-600 w-20 text-right text-xs">${e.action}</span>
<button class="del ml-4" onclick="blDelete('${e.id}')">✕</button>
</div>`).join('');
} catch(e) { toast('Blacklist load failed: '+e.message, true); }
}
async function blAdd() {
const domain = document.getElementById('bl-domain').value.trim();
const action = document.getElementById('bl-action').value;
if (!domain) return toast('Domain is required', true);
try {
await api('POST', '/blacklist', { domain, action });
toast('Domain blocked');
loadBlacklist();
document.getElementById('bl-domain').value = '';
} catch(e) { toast('Add failed: '+e.message, true); }
}
async function blDelete(id) {
try { await api('DELETE', '/blacklist/'+id); toast('Entry removed'); loadBlacklist(); }
catch(e) { toast('Delete failed: '+e.message, true); }
}
let presetsCache = [];
async function loadPresets() {
try {
const d = await api('GET', '/feeds/presets');
presetsCache = d.presets ?? [];
const activeFeedsResp = await api('GET', '/feeds');
const activeUrls = new Set((activeFeedsResp.feeds ?? []).map(f => f.url));
document.getElementById('feed-presets').innerHTML = presetsCache.length === 0
? '<span class="text-gray-600 italic text-xs">No presets available.</span>'
: presetsCache.map((p, i) => {
const already = activeUrls.has(p.url);
return `
<div class="row items-center gap-3 py-2 ${already ? 'opacity-40' : ''}">
<div class="flex-1 min-w-0">
<span class="text-gray-200 text-sm font-medium">${esc(p.name)}</span>
<span class="text-gray-500 text-xs ml-2">${esc(p.format ?? 'hosts')} · ${esc(p.action ?? 'nxdomain')}</span>
<div class="text-gray-500 text-xs mt-0.5">${esc(p.description ?? '')}</div>
</div>
<button onclick="feedAddPreset(${i})"
class="btn-primary text-xs px-3 py-1 shrink-0 ${already ? 'pointer-events-none opacity-50' : ''}"
${already ? 'disabled title="Already added"' : ''}>
${already ? '✓ Added' : '+ Add'}
</button>
</div>`;
}).join('');
} catch(e) {
document.getElementById('feed-presets').innerHTML = '<span class="text-gray-600 italic text-xs">Presets unavailable.</span>';
}
}
async function feedAddPreset(i) {
const p = presetsCache[i];
if (!p) return;
try {
const r = await api('POST', '/feeds', { name: p.name, url: p.url, format: p.format ?? 'hosts', action: p.action ?? 'nxdomain', enabled: true });
toast(`"${p.name}" added — fetching domains…`);
if (r.feed?.id) {
try { await api('POST', '/feeds/' + r.feed.id + '/update'); } catch(_) {}
}
setTimeout(() => { loadPresets(); loadFeeds(); }, 1500);
} catch(e) { toast('Add failed: '+e.message, true); }
}
async function loadFeeds() {
try {
const d = await api('GET', '/feeds');
const feeds = d.feeds ?? [];
document.getElementById('feed-list').innerHTML = feeds.length === 0
? '<p class="text-gray-600 text-sm">No feeds configured. Use a preset above or add a custom URL.</p>'
: feeds.map(f => {
const updated = f.last_updated
? new Date(f.last_updated).toLocaleString([], {dateStyle:'short', timeStyle:'short'})
: 'never';
return `
<div class="row gap-2">
<span class="w-3 h-3 rounded-full shrink-0 ${!f.enabled ? 'bg-gray-600' : (f.entry_count ?? 0) === 0 ? 'bg-red-500' : 'bg-green-500'}" title="${!f.enabled ? 'disabled' : (f.entry_count ?? 0) === 0 ? 'enabled — 0 entries (sync error?)' : 'enabled'}"></span>
<span class="text-cyan-300 w-32 truncate font-bold shrink-0">${esc(f.name)}</span>
<span class="text-gray-400 flex-1 truncate text-xs">${esc(f.url)}</span>
<span class="text-gray-500 w-20 text-right text-xs shrink-0">${(f.entry_count??0).toLocaleString()} entries</span>
<span class="text-gray-600 w-16 text-right text-xs shrink-0">${esc(f.action)}</span>
<span class="text-gray-700 w-28 text-right text-xs shrink-0" title="last updated">↻ ${updated}</span>
<button onclick="feedUpdate('${f.id}')" class="btn-secondary text-xs px-2 py-0.5 shrink-0" title="Force update now">↻</button>
<button class="del shrink-0" onclick="feedDelete('${f.id}')">✕</button>
</div>`;
}).join('');
} catch(e) { toast('Feeds load failed: '+e.message, true); }
}
async function feedAdd() {
const name = document.getElementById('feed-name').value.trim();
const url = document.getElementById('feed-url').value.trim();
const format = document.getElementById('feed-format').value;
const action = document.getElementById('feed-action').value;
if (!name || !url) return toast('Name and URL are required', true);
try {
await api('POST', '/feeds', { name, url, format, action, enabled: true });
toast('Feed added');
loadFeeds();
document.getElementById('feed-name').value = '';
document.getElementById('feed-url').value = '';
} catch(e) { toast('Add failed: '+e.message, true); }
}
async function feedUpdate(id) {
try { await api('POST', '/feeds/'+id+'/update'); toast('Feed update triggered'); setTimeout(loadFeeds, 1500); }
catch(e) { toast('Update failed: '+e.message, true); }
}
async function feedUpdateAll() {
try { await api('POST', '/feeds/update'); toast('All feeds update triggered'); setTimeout(loadFeeds, 1500); }
catch(e) { toast('Update failed: '+e.message, true); }
}
async function feedDelete(id) {
try { await api('DELETE', '/feeds/'+id); toast('Feed removed'); loadFeeds(); }
catch(e) { toast('Delete failed: '+e.message, true); }
}
async function loadLogs() {
try {
const d = await api('GET', '/logs');
const logs = d.logs ?? d.entries ?? [];
const rcodeColor = r => r === 'NOERROR' ? 'text-green-400' :
r === 'NXDOMAIN' ? 'text-yellow-400' : 'text-red-400';
document.getElementById('log-list').innerHTML = logs.length === 0
? '<p class="text-gray-600">No logs yet — requires verbosity: 1 or higher.</p>'
: [...logs].reverse().map(l => `
<div class="flex gap-3 items-center py-0.5 border-b border-gray-900">
<span class="text-gray-600 w-20 shrink-0">${(l.timestamp??'').slice(11,19)}</span>
<span class="text-gray-500 w-28 truncate shrink-0">${esc(l.client??'')}</span>
<span class="text-cyan-300 flex-1 truncate">${esc(l.name??'')}</span>
<span class="text-gray-500 w-12 shrink-0">${esc(l.qtype??'')}</span>
<span class="${rcodeColor(l.rcode)} w-20 shrink-0">${esc(l.rcode??'')}</span>
<span class="text-gray-600 w-16 text-right shrink-0">${l.ms != null ? l.ms+'ms' : ''}</span>
</div>`).join('');
} catch(e) { toast('Logs load failed: '+e.message, true); }
}
function toggleAutoLogs(el) {
clearInterval(logsTimer);
if (el.checked) { loadLogs(); logsTimer = setInterval(loadLogs, 3000); }
}
async function loadSystem() {
try {
const s = await api('GET', '/system');
const sec = n => {
if (n == null) return '—';
const d = Math.floor(n/86400), h = Math.floor((n%86400)/3600), m = Math.floor((n%3600)/60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`;
};
set('sys-version', s.version ?? '—');
set('sys-uptime', sec(s.uptime_secs));
set('sys-workers', s.workers ?? '—');
set('sys-xdp-active', s.xdp_active ? 'yes' : 'no');
set('sys-xdp-mode', s.xdp_mode ?? (s.xdp_active ? 'active' : 'disabled'));
set('sys-cpu-cores', s.cpu_cores ?? '—');
set('sys-cpu-pct', s.cpu_percent != null ? s.cpu_percent.toFixed(1) + ' %' : '—');
set('sys-mem-total', s.mem_total_mb != null ? s.mem_total_mb.toLocaleString() + ' MB' : '—');
set('sys-mem-avail', s.mem_avail_mb != null ? s.mem_avail_mb.toLocaleString() + ' MB' : '—');
set('about-version', s.version ?? '—');
const badge = document.getElementById('about-version');
if (badge) badge.textContent = s.version ? 'v' + s.version : 'v0.6.0';
set('sys-refreshed', 'updated ' + new Date().toLocaleTimeString());
} catch(e) { toast('System load failed: '+e.message, true); }
}
async function doReload() {
try {
await api('POST', '/reload');
toast('Config reloaded');
} catch(e) { toast('Reload failed: '+e.message, true); }
}
function showTab(name, btn) {
['overview','dns','blacklist','feeds','logs','system','about'].forEach(t => {
document.getElementById('tab-'+t).classList.add('hidden');
});
document.querySelectorAll('.tab-active').forEach(b => {
b.classList.remove('tab-active');
b.classList.add('text-gray-500');
});
const el = document.getElementById('tab-'+name);
el.classList.remove('hidden');
el.classList.add('fade');
btn.classList.add('tab-active');
btn.classList.remove('text-gray-500');
if (name === 'dns') loadDns();
if (name === 'blacklist') loadBlacklist();
if (name === 'feeds') { loadPresets(); loadFeeds(); }
if (name === 'logs') loadLogs();
if (name === 'system') loadSystem();
}
function set(id, v) { const el = document.getElementById(id); if (el) el.textContent = v; }
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
let toastTimer;
function toast(msg, err=false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `fixed bottom-6 right-6 text-sm px-4 py-2 rounded shadow-lg border
${err ? 'bg-red-950 border-red-800 text-red-300' : 'bg-gray-800 border-gray-700 text-gray-200'}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.className = el.className + ' hidden', 3000);
}
window.onload = () => {
const url = localStorage.getItem('rb-url');
const key = localStorage.getItem('rb-key');
if (url) document.getElementById('cfg-url').value = url;
if (key) document.getElementById('cfg-key').value = key;
if (url && key) connect();
else {
const u = document.getElementById("cfg-url").value;
const k = document.getElementById("cfg-key").value;
if (u && k) connect();
}
};
</script>
</body>
</html>