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>
487 lines
18 KiB
HTML
487 lines
18 KiB
HTML
<!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">📡</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…">
|
||
<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">‹ Prev</button>
|
||
<span id="pageInfo"></span>
|
||
<button id="nextPage">Next ›</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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|