Files
ExploreDNS/internal/receiver/server/static/admin.html
T
Gary HansenandClaude Fable 5 beb595442f feat(receiver): ingest server and admin dashboard
POST /webhook (bearer-token auth, strict validation, 1MB cap) and
/healthz stay open; everything under /admin requires basic auth
(RECEIVER_ADMIN_USER/PASSWORD, refuses to start without a password,
constant-time compares). Admin JSON APIs for the traversal log
(filters, pagination) and stats, plus an embedded dashboard: stat
cards, four Chart.js charts (lazy CDN load with graceful degradation),
filterable log with expandable summaries, auto-refresh. Fetches
resolve against location.origin so credentialed bookmark URLs work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:43:51 +10:00

487 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ExploreDNS Receiver — Admin</title>
<style>
/* ── Design tokens (mirrors web/api/static/index.html) ─────────── */
:root {
--bg: #0f1117;
--bg-surface: #1a1d27;
--bg-card: #21242f;
--bg-input: #2a2d3a;
--border: #353847;
--border-focus: #4a7cf6;
--text: #e8eaf0;
--text-muted: #8b8fa8;
--text-dim: #5c6070;
--primary: #4a7cf6;
--primary-hover:#3a6ce6;
--success: #3ecf8e;
--warning: #f5a623;
--danger: #f04438;
--info: #38bdf8;
--radius: 8px;
--radius-sm: 5px;
--shadow: 0 4px 20px rgba(0,0,0,0.4);
--font-mono: 'JetBrains Mono', 'Fira Mono', 'Cascadia Code', 'Consolas', monospace;
}
/* ── Reset ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
a { color: var(--primary); text-decoration: none; }
button { cursor: pointer; font: inherit; }
/* ── Layout ─────────────────────────────────────────────────────── */
.app { display: flex; flex-direction: column; min-height: 100vh; }
header {
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
.logo-icon {
width: 28px; height: 28px;
background: var(--primary);
border-radius: 7px;
display: flex; align-items: center; justify-content: center;
font-size: 14px;
}
.logo-sub { color: var(--text-muted); font-weight: 500; font-size: 0.85rem; }
.header-controls { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; color: var(--text-muted); }
.header-controls label { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; }
main { flex: 1; padding: 2rem 1.5rem; max-width: 1100px; margin: 0 auto; width: 100%; }
footer {
text-align: center;
padding: 1rem;
color: var(--text-dim);
font-size: 0.8rem;
border-top: 1px solid var(--border);
}
/* ── Cards ─────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
box-shadow: var(--shadow);
}
.card-title {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 1rem;
}
/* ── Stat cards ────────────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.stat-card { padding: 0.9rem 1.1rem; }
.stat-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.stat-value { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
/* ── Charts ────────────────────────────────────────────────────── */
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.chart-card.wide { grid-column: 1 / -1; }
.chart-card canvas { max-height: 260px; }
/* ── Filters ───────────────────────────────────────────────────── */
.filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
.filters input, .filters select, .header-controls select {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
padding: 6px 10px;
font: inherit;
font-size: 0.85rem;
}
.filters input:focus, .filters select:focus { outline: none; border-color: var(--border-focus); }
.filters button, .pager button {
background: var(--primary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
padding: 6px 14px;
font-size: 0.85rem;
}
.filters button:hover, .pager button:hover:not(:disabled) { background: var(--primary-hover); }
.pager button:disabled { background: var(--bg-input); color: var(--text-dim); cursor: default; }
/* ── Log table ─────────────────────────────────────────────────── */
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
tr.log-row { cursor: pointer; }
tr.log-row:hover td { background: var(--bg-input); }
td.mono { font-family: var(--font-mono); font-size: 0.8rem; }
.chip {
display: inline-block;
padding: 1px 9px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chip-complete { background: rgba(62,207,142,0.15); color: var(--success); }
.chip-error { background: rgba(240,68,56,0.15); color: var(--danger); }
.chip-running { background: rgba(56,189,248,0.15); color: var(--info); }
tr.detail-row td { background: var(--bg-surface); padding: 0.75rem 1rem; }
tr.detail-row pre {
font-family: var(--font-mono);
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow: auto;
color: var(--text-muted);
}
.pager {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.empty { color: var(--text-dim); text-align: center; padding: 1.5rem 0; }
#errorBar {
display: none;
background: rgba(240,68,56,0.12);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius-sm);
padding: 0.5rem 1rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
</style>
</head>
<body>
<div class="app">
<header>
<div class="logo"><div class="logo-icon">&#128225;</div>ExploreDNS <span class="logo-sub">receiver admin</span></div>
<div class="header-controls">
<label><input type="checkbox" id="autoRefresh"> auto-refresh 30s</label>
<select id="daysSelect" title="chart window">
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
</select>
</div>
</header>
<main>
<div id="errorBar"></div>
<section class="stat-grid" id="statCards">
<div class="card stat-card"><div class="stat-label">All time</div><div class="stat-value" id="statAllTime">–</div></div>
<div class="card stat-card"><div class="stat-label">Last 24 h</div><div class="stat-value" id="statLast24h">–</div></div>
<div class="card stat-card"><div class="stat-label">Last 7 d</div><div class="stat-value" id="statLast7d">–</div></div>
<div class="card stat-card"><div class="stat-label">Domains</div><div class="stat-value" id="statDomains">–</div></div>
<div class="card stat-card"><div class="stat-label">Clients</div><div class="stat-value" id="statClients">–</div></div>
<div class="card stat-card"><div class="stat-label">Error rate</div><div class="stat-value" id="statErrorRate">–</div></div>
</section>
<section class="chart-grid">
<div class="card chart-card wide"><div class="card-title">Traversals per day</div><canvas id="perDayChart"></canvas></div>
<div class="card chart-card"><div class="card-title">Status</div><canvas id="statusChart"></canvas></div>
<div class="card chart-card"><div class="card-title">Top domains</div><canvas id="domainsChart"></canvas></div>
<div class="card chart-card"><div class="card-title">Query types</div><canvas id="typesChart"></canvas></div>
</section>
<section class="card log-card">
<div class="card-title">Log</div>
<div class="filters">
<input id="filterDomain" placeholder="domain contains&hellip;">
<select id="filterStatus">
<option value="">all statuses</option>
<option value="running">running</option>
<option value="complete">complete</option>
<option value="error">error</option>
</select>
<input type="datetime-local" id="filterFrom" title="from">
<input type="datetime-local" id="filterTo" title="to">
<button id="applyFilters">Apply</button>
</div>
<table id="logTable">
<thead>
<tr><th>Received</th><th>Domain</th><th>Type</th><th>Status</th><th>Duration</th><th>Client IP</th><th>Results</th></tr>
</thead>
<tbody id="logBody"></tbody>
</table>
<div class="pager">
<button id="prevPage">&lsaquo; Prev</button>
<span id="pageInfo"></span>
<button id="nextPage">Next &rsaquo;</button>
</div>
</section>
</main>
<footer>ExploreDNS webhook receiver</footer>
</div>
<script>
(() => {
'use strict';
const CHART_SRC = 'https://cdn.jsdelivr.net/npm/chart.js@4';
const PAGE_SIZE = 50;
const $ = (id) => document.getElementById(id);
let page = 0;
let total = 0;
const charts = {};
let chartLoad = null;
let refreshTimer = null;
const esc = (s) => String(s).replace(/[&<>"']/g,
(c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// Chart.js is lazy-loaded on first use so the page renders without the CDN.
function loadChartJS() {
if (window.Chart) return Promise.resolve();
if (!chartLoad) {
chartLoad = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = CHART_SRC;
s.onload = resolve;
s.onerror = () => { chartLoad = null; reject(new Error('failed to load Chart.js')); };
document.head.appendChild(s);
});
}
return chartLoad;
}
async function fetchJSON(url) {
// Resolve against origin (never contains userinfo) so the dashboard
// still works when opened via a http://user:pass@host bookmark —
// fetch() rejects relative URLs on pages with credentialed base URLs.
const abs = new URL(url, window.location.origin).toString();
const resp = await fetch(abs, { headers: { 'Accept': 'application/json' } });
if (!resp.ok) throw new Error(url + ': HTTP ' + resp.status);
return resp.json();
}
function showError(err) {
const bar = $('errorBar');
bar.textContent = String(err);
bar.style.display = 'block';
}
function clearError() { $('errorBar').style.display = 'none'; }
const fmtDuration = (ms) => ms == null ? '—'
: (ms >= 1000 ? (ms / 1000).toFixed(2) + ' s' : ms + ' ms');
const fmtLocal = (iso) => iso ? new Date(iso).toLocaleString() : '—';
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function upsertChart(id, cfg) {
if (charts[id]) {
charts[id].data = cfg.data;
charts[id].update();
} else {
charts[id] = new Chart($(id), cfg);
}
}
function drawCharts(s) {
Chart.defaults.color = cssVar('--text-muted');
Chart.defaults.borderColor = cssVar('--border');
const statusColor = { complete: cssVar('--success'), error: cssVar('--danger'), running: cssVar('--info') };
upsertChart('perDayChart', {
type: 'line',
data: {
labels: s.per_day.map((d) => d.day),
datasets: [
{ label: 'traversals', data: s.per_day.map((d) => d.total),
borderColor: cssVar('--primary'), backgroundColor: 'rgba(74,124,246,0.15)', fill: true, tension: 0.25 },
{ label: 'errors', data: s.per_day.map((d) => d.errors),
borderColor: cssVar('--danger'), backgroundColor: 'transparent', tension: 0.25 },
],
},
options: { scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
});
upsertChart('statusChart', {
type: 'doughnut',
data: {
labels: s.statuses.map((x) => x.name),
datasets: [{
data: s.statuses.map((x) => x.count),
backgroundColor: s.statuses.map((x) => statusColor[x.name] || cssVar('--warning')),
borderWidth: 0,
}],
},
});
upsertChart('domainsChart', {
type: 'bar',
data: {
labels: s.top_domains.map((x) => x.name),
datasets: [{ label: 'traversals', data: s.top_domains.map((x) => x.count), backgroundColor: cssVar('--primary') }],
},
options: { indexAxis: 'y', plugins: { legend: { display: false } },
scales: { x: { beginAtZero: true, ticks: { precision: 0 } } } },
});
upsertChart('typesChart', {
type: 'bar',
data: {
labels: s.query_types.map((x) => x.name),
datasets: [{ label: 'traversals', data: s.query_types.map((x) => x.count), backgroundColor: cssVar('--info') }],
},
options: { plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } },
});
}
async function refreshStats() {
const s = await fetchJSON('/admin/api/stats?days=' + encodeURIComponent($('daysSelect').value));
$('statAllTime').textContent = s.totals.all_time;
$('statLast24h').textContent = s.totals.last_24h;
$('statLast7d').textContent = s.totals.last_7d;
$('statDomains').textContent = s.totals.distinct_domains;
$('statClients').textContent = s.totals.distinct_clients;
$('statErrorRate').textContent = (s.totals.error_rate * 100).toFixed(1) + '%';
await loadChartJS();
drawCharts(s);
}
function filterQuery() {
const p = new URLSearchParams();
p.set('limit', PAGE_SIZE);
p.set('offset', page * PAGE_SIZE);
const domain = $('filterDomain').value.trim();
if (domain) p.set('domain', domain);
const status = $('filterStatus').value;
if (status) p.set('status', status);
const from = $('filterFrom').value;
if (from) p.set('from', new Date(from).toISOString());
const to = $('filterTo').value;
if (to) p.set('to', new Date(to).toISOString());
return p;
}
function detailJSON(item) {
if (item.summary != null) return JSON.stringify(item.summary, null, 2);
return JSON.stringify(item, null, 2);
}
async function refreshLog() {
const data = await fetchJSON('/admin/api/traversals?' + filterQuery());
total = data.total;
const body = $('logBody');
body.textContent = '';
if (!data.items.length) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="7" class="empty">no traversals</td>';
body.appendChild(tr);
}
for (const item of data.items) {
const row = document.createElement('tr');
row.className = 'log-row';
row.innerHTML =
'<td>' + esc(fmtLocal(item.received_at)) + '</td>' +
'<td class="mono">' + esc(item.domain) + '</td>' +
'<td class="mono">' + esc(item.query_type) + '</td>' +
'<td><span class="chip chip-' + esc(item.status) + '">' + esc(item.status) + '</span></td>' +
'<td>' + esc(fmtDuration(item.duration_ms)) + '</td>' +
'<td class="mono">' + esc(item.client_ip) + '</td>' +
'<td>' + esc(item.result_count == null ? '—' : item.result_count) + '</td>';
const detail = document.createElement('tr');
detail.className = 'detail-row';
detail.style.display = 'none';
const cell = document.createElement('td');
cell.colSpan = 7;
const pre = document.createElement('pre');
pre.textContent = detailJSON(item);
cell.appendChild(pre);
detail.appendChild(cell);
row.addEventListener('click', () => {
detail.style.display = detail.style.display === 'none' ? '' : 'none';
});
body.appendChild(row);
body.appendChild(detail);
}
const first = total ? page * PAGE_SIZE + 1 : 0;
const last = Math.min((page + 1) * PAGE_SIZE, total);
$('pageInfo').textContent = first + '–' + last + ' of ' + total;
$('prevPage').disabled = page === 0;
$('nextPage').disabled = last >= total;
}
function refreshAll() {
clearError();
refreshStats().catch(showError);
refreshLog().catch(showError);
}
$('daysSelect').addEventListener('change', () => { refreshStats().catch(showError); });
$('applyFilters').addEventListener('click', () => { page = 0; refreshLog().catch(showError); });
$('filterDomain').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { page = 0; refreshLog().catch(showError); }
});
$('prevPage').addEventListener('click', () => {
if (page > 0) { page--; refreshLog().catch(showError); }
});
$('nextPage').addEventListener('click', () => {
if ((page + 1) * PAGE_SIZE < total) { page++; refreshLog().catch(showError); }
});
$('autoRefresh').addEventListener('change', (e) => {
if (e.target.checked) {
refreshTimer = setInterval(refreshAll, 30000);
} else {
clearInterval(refreshTimer);
refreshTimer = null;
}
});
refreshAll();
})();
</script>
</body>
</html>