feat/release-telemetry-webui #28

Merged
gary merged 4 commits from feat/release-telemetry-webui into main 2026-07-07 15:11:19 +00:00
3 changed files with 440 additions and 6 deletions
Showing only changes of commit 8593e0f322 - Show all commits
Binary file not shown.

After

Width:  |  Height:  |  Size: 499 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- ExploreDNS: delegation tree — root fanning to leaves, green = answered path -->
<rect width="32" height="32" rx="5.8" fill="#2563eb"/>
<g stroke="#ffffff" stroke-width="1.8" stroke-linecap="round">
<line x1="16" y1="7" x2="7" y2="24"/>
<line x1="16" y1="7" x2="16" y2="25"/>
<line x1="16" y1="7" x2="25" y2="24"/>
</g>
<circle cx="16" cy="7" r="3.5" fill="#ffffff"/>
<circle cx="7" cy="24" r="2.9" fill="#ffffff"/>
<circle cx="16" cy="25" r="2.9" fill="#4ade80"/>
<circle cx="25" cy="24" r="2.9" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 616 B

+427 -6
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ExploreDNS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="16x16 32x32">
<style>
/* ── Design tokens ─────────────────────────────────────────────── */
:root {
@@ -308,6 +310,92 @@
spacing intact. */
.pe-text { color: var(--text); white-space: pre; }
/* ── Detail tree ────────────────────────────────────────────────── */
/* Live tree mirroring the dns.squish.net detail page: one node per
refid, indented per depth, resolve subtrees collapsed by default. */
.detail-tree {
font-family: var(--font-mono);
font-size: 0.78rem;
line-height: 1.7;
max-height: 340px;
overflow: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.6rem 0.75rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.detail-tree:empty::before {
content: 'Awaiting traversal…';
color: var(--text-dim);
}
.detail-tree::-webkit-scrollbar { width: 5px; height: 5px; }
.detail-tree::-webkit-scrollbar-track { background: transparent; }
.detail-tree::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.tnode-line { display: flex; gap: 0.5rem; align-items: baseline; white-space: nowrap; }
.tnode-children, .tnode-resolves {
margin-left: 0.9rem;
border-left: 1px dotted var(--border);
padding-left: 0.6rem;
}
.tnode-refid { color: var(--text-dim); }
.tnode-text { color: var(--text); white-space: pre; }
.tnode-early { color: var(--text-dim); font-style: italic; }
.resolve-toggle {
background: none;
border: 1px dashed var(--border);
color: var(--info);
border-radius: 3px;
font-size: 0.65rem;
padding: 0 5px;
font-family: inherit;
line-height: 1.4;
}
.resolve-toggle:hover { border-color: var(--info); }
.raw-toggle {
margin-left: auto;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: var(--radius-sm);
font-size: 0.7rem;
padding: 2px 8px;
text-transform: none;
letter-spacing: normal;
}
.raw-toggle:hover { color: var(--text); }
.raw-toggle.active { color: var(--primary); border-color: var(--primary); }
/* ── Servers map + table ────────────────────────────────────────── */
.server-map {
height: 320px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 1rem;
background: var(--bg);
z-index: 0; /* keep Leaflet panes under the sticky header */
}
.servers-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.servers-table th {
text-align: left;
color: var(--text-muted);
text-transform: uppercase;
font-size: 0.68rem;
letter-spacing: 0.05em;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
}
.servers-table td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text);
}
.servers-table td.loc { font-family: inherit; color: var(--text-muted); }
/* ── Results tree ───────────────────────────────────────────────── */
.results-section h2 {
font-size: 0.9rem;
@@ -515,13 +603,15 @@
<!-- Left: progress + results -->
<div>
<!-- Progress feed -->
<!-- Detail tree (raw event log kept behind the toggle for debugging) -->
<div class="card" id="progressCard" style="margin-bottom:1.25rem;">
<div class="card-title">
⚡ Live Progress
🌳 Traversal Detail
<span class="badge-count" id="progressCount">0</span>
<button class="raw-toggle" id="rawToggle" title="Toggle the raw event log">raw log</button>
</div>
<div class="progress-feed" id="progressFeed"></div>
<div class="detail-tree" id="detailTree"></div>
<div class="progress-feed hidden" id="progressFeed"></div>
</div>
<!-- Results -->
@@ -596,6 +686,23 @@
</div><!-- /two-col -->
<!-- Servers map + table (appears once post-traversal fingerprinting is
done; geolocation runs client-side via get.geojs.io, Leaflet loads
lazily from unpkg.com/leaflet@1.9 the first time it is needed) -->
<div class="card hidden" id="serversCard" style="margin-top:1.25rem;">
<div class="card-title">
🗺️ Servers
<span class="badge-count" id="serversCount">0</span>
</div>
<div id="serverMap" class="server-map hidden"></div>
<table class="servers-table">
<thead>
<tr><th>Country</th><th>City</th><th>Servers</th><th>Software guess</th></tr>
</thead>
<tbody id="serversTableBody"></tbody>
</table>
</div>
</main>
<footer>
@@ -621,6 +728,12 @@
const errorBox = document.getElementById('errorBox');
const progressFeed = document.getElementById('progressFeed');
const progressCount = document.getElementById('progressCount');
const detailTree = document.getElementById('detailTree');
const rawToggle = document.getElementById('rawToggle');
const serversCard = document.getElementById('serversCard');
const serversCount = document.getElementById('serversCount');
const serverMap = document.getElementById('serverMap');
const serversTableBody = document.getElementById('serversTableBody');
const resultsContainer = document.getElementById('resultsContainer');
const resultsEmpty = document.getElementById('resultsEmpty');
const resultsCount = document.getElementById('resultsCount');
@@ -643,8 +756,17 @@
let startTime = null;
let timerInterval = null;
let progressItems = [];
let progressRows = new Map(); // refid → row elements, updated in place
let progressRows = new Map(); // refid → raw-log row elements, updated in place
let resultItems = [];
let treeNodes = new Map(); // refid → detail-tree node, updated in place
let resultsFetched = false;
let serversFetched = false;
let serversRetryTimer = null;
let serversRetriesLeft = 0;
const geoCache = new Map(); // ip → geo object or null; kept across runs
let leafletPromise = null;
let mapInstance = null;
let mapMarkers = null;
// ── Theme toggle ──────────────────────────────────────────────────
const html = document.documentElement;
@@ -710,22 +832,35 @@
try {
const ev = JSON.parse(e.data);
appendProgress(ev);
updateTree(ev);
// The traversal reaches its terminal state before fingerprinting:
// fetch the results immediately, then the servers once ready.
if (ev.stage === 'complete') maybeFetchResults();
if (ev.stage === 'servers') fetchServers();
} catch (_) {}
});
eventSource.addEventListener('done', () => {
eventSource.close();
eventSource = null;
fetchResults(id);
maybeFetchResults();
fetchServers();
});
eventSource.onerror = () => {
if (eventSource) { eventSource.close(); eventSource = null; }
// Fall back to polling
fetchResults(id);
maybeFetchResults();
fetchServers();
};
}
function maybeFetchResults() {
if (resultsFetched || !currentJobId) return;
resultsFetched = true;
fetchResults(currentJobId);
}
// ── Fetch final results ───────────────────────────────────────────
async function fetchResults(id) {
try {
@@ -793,6 +928,105 @@
}
}
// ── Detail tree ───────────────────────────────────────────────────
// One node per refid, indented per depth like the dns.squish.net detail
// page. Nodes appear on their first event and update in place; resolve
// subtrees (refids entering a ".0." component) are collapsed by default
// behind a per-node show/hide toggle. The raw event feed stays available
// behind the "raw log" toggle for debugging.
rawToggle.addEventListener('click', () => {
const showRaw = progressFeed.classList.contains('hidden');
progressFeed.classList.toggle('hidden', !showRaw);
detailTree.classList.toggle('hidden', showRaw);
rawToggle.classList.toggle('active', showRaw);
});
function updateTree(ev) {
if (!ev.refid) return; // synthetic stages (complete/servers) have no node
let node = treeNodes.get(ev.refid);
if (!node) node = createTreeNode(ev.refid);
const status = ev.status || ev.stage;
node.badge.textContent = ev.is_resolve ? 'glue ' + status : status;
node.badge.className = 'pe-badge ' + progressBadgeCls(ev);
let text = ev.server || ev.name || '';
text += ' (' + (ev.ips || '') + ')';
text += ' <' + (ev.bailiwick || '') + '>';
node.text.textContent = text;
node.text.title = ev.refid + ' ' + (ev.name || '') + '/' + (ev.qtype || '');
node.early.textContent = ev.completed_earlier
? ' -- completed earlier (' + ev.completed_earlier + ')' : '';
}
// createTreeNode builds the DOM for refid and attaches it under the node
// with the longest existing refid prefix — never assume one dotted
// component per level, since multi-childset refids insert an extra digit
// (e.g. 1.1.2.1 hanging off 1.1) and resolve refids insert ".0.".
function createTreeNode(refid) {
const el = document.createElement('div');
el.className = 'tnode';
const line = document.createElement('div');
line.className = 'tnode-line';
const badge = document.createElement('span');
badge.className = 'pe-badge';
const rid = document.createElement('span');
rid.className = 'tnode-refid';
rid.textContent = refid;
const text = document.createElement('span');
text.className = 'tnode-text';
const early = document.createElement('span');
early.className = 'tnode-early';
line.appendChild(badge);
line.appendChild(rid);
line.appendChild(text);
line.appendChild(early);
el.appendChild(line);
const node = { refid, el, line, badge, text, early,
childrenEl: null, resolvesEl: null };
treeNodes.set(refid, node);
const parts = refid.split('.');
let parent = null, plen = 0;
for (let i = parts.length - 1; i >= 1; i--) {
const p = treeNodes.get(parts.slice(0, i).join('.'));
if (p) { parent = p; plen = i; break; }
}
if (!parent) {
detailTree.appendChild(el);
return node;
}
if (parts[plen] === '0') {
// Glue-resolution subtree: collapsed container + per-node toggle.
if (!parent.resolvesEl) {
parent.resolvesEl = document.createElement('div');
parent.resolvesEl.className = 'tnode-resolves hidden';
// Resolution happens before querying: keep resolves above children.
parent.el.insertBefore(parent.resolvesEl, parent.childrenEl);
const t = document.createElement('button');
t.className = 'resolve-toggle';
t.textContent = 'show resolve';
t.addEventListener('click', () => {
const hidden = parent.resolvesEl.classList.toggle('hidden');
t.textContent = hidden ? 'show resolve' : 'hide resolve';
});
parent.line.appendChild(t);
}
parent.resolvesEl.appendChild(el);
} else {
if (!parent.childrenEl) {
parent.childrenEl = document.createElement('div');
parent.childrenEl.className = 'tnode-children';
parent.el.appendChild(parent.childrenEl);
}
parent.childrenEl.appendChild(el);
}
return node;
}
// ── Results rendering ─────────────────────────────────────────────
function renderResults(job) {
detailDomain.textContent = job.domain || '—';
@@ -942,6 +1176,183 @@
}
}
// ── Servers map + table ───────────────────────────────────────────
// Fetched once the backend publishes {"stage":"servers"} (or as a polling
// fallback after done/error). Geolocation happens client-side via
// get.geojs.io with at most 4 concurrent lookups and an in-page cache;
// servers that cannot be located still show in the table with a dash
// location, and the section works without the map when offline.
function fetchServers() {
if (!currentJobId || serversFetched) return;
const id = currentJobId;
fetch(`/api/traverse/${id}/servers`)
.then(async res => {
if (id !== currentJobId) return;
if (res.status === 202) { scheduleServersRetry(); return; }
if (!res.ok) return;
const data = await res.json();
if (id !== currentJobId || serversFetched) return;
serversFetched = true;
renderServers(data.servers || []);
})
.catch(() => { scheduleServersRetry(); });
}
function scheduleServersRetry() {
if (serversRetryTimer || serversRetriesLeft <= 0) return;
serversRetriesLeft--;
serversRetryTimer = setTimeout(() => {
serversRetryTimer = null;
fetchServers();
}, 2000);
}
async function renderServers(servers) {
serversCard.classList.remove('hidden');
serversCount.textContent = servers.length;
serversTableBody.innerHTML = '';
if (servers.length === 0) {
appendServersRow('—', '—', 'No servers recorded.', '—');
serverMap.classList.add('hidden');
return;
}
appendServersRow('…', '', 'Locating servers…', '');
await geolocateAll([...new Set(servers.map(s => s.ip))]);
serversTableBody.innerHTML = '';
// Group servers by geolocation (like the reference servers page); all
// unlocatable IPs share the single dash row at the bottom.
const groups = new Map();
servers.forEach(s => {
const geo = geoCache.get(s.ip) || null;
const key = geo ? [geo.country, geo.city, geo.latitude, geo.longitude].join('|') : '';
if (!groups.has(key)) groups.set(key, { geo, servers: [] });
groups.get(key).servers.push(s);
});
const sorted = [...groups.values()].sort((a, b) => {
if (!a.geo) return 1;
if (!b.geo) return -1;
return ((a.geo.country || '') + (a.geo.city || ''))
.localeCompare((b.geo.country || '') + (b.geo.city || ''));
});
sorted.forEach(g => {
const names = g.servers.map(s => `${s.name} (${s.ip})`).join('\n');
const soft = g.servers.map(s => s.version || '—').join('\n');
appendServersRow(
g.geo ? (g.geo.country || '—') : '—',
g.geo ? (g.geo.city || '—') : '—',
names, soft);
});
const located = sorted.filter(g =>
g.geo && isFinite(parseFloat(g.geo.latitude)) && isFinite(parseFloat(g.geo.longitude)));
if (located.length === 0) {
serverMap.classList.add('hidden');
return;
}
try {
await loadLeaflet();
drawMap(located);
} catch (_) {
// Leaflet unavailable (offline / CDN blocked): table already rendered.
serverMap.classList.add('hidden');
}
}
function appendServersRow(country, city, servers, software) {
const tr = document.createElement('tr');
[[country, 'loc'], [city, 'loc'], [servers, ''], [software, '']].forEach(([val, cls]) => {
const td = document.createElement('td');
if (cls) td.className = cls;
td.style.whiteSpace = 'pre-line';
td.textContent = val;
tr.appendChild(td);
});
serversTableBody.appendChild(tr);
}
// geolocateAll fills geoCache for every ip, max 4 concurrent lookups,
// 5 s per lookup; failures cache as null so they are not retried.
function geolocateAll(ips) {
const queue = ips.filter(ip => !geoCache.has(ip));
const worker = async () => {
while (queue.length > 0) {
const ip = queue.shift();
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
const res = await fetch(
`https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ip)}.json`,
{ signal: ctrl.signal });
clearTimeout(timer);
const geo = res.ok ? await res.json() : null;
geoCache.set(ip, geo && geo.latitude ? geo : null);
} catch (_) {
geoCache.set(ip, null);
}
}
};
const workers = [];
for (let i = 0; i < Math.min(4, queue.length); i++) workers.push(worker());
return Promise.all(workers);
}
// loadLeaflet injects the Leaflet CSS+JS from unpkg the first time the
// map is needed (never on page load).
function loadLeaflet() {
if (window.L) return Promise.resolve();
if (leafletPromise) return leafletPromise;
leafletPromise = new Promise((resolve, reject) => {
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
document.head.appendChild(css);
const js = document.createElement('script');
js.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
js.onload = () => resolve();
js.onerror = () => { leafletPromise = null; reject(new Error('leaflet failed to load')); };
document.head.appendChild(js);
});
return leafletPromise;
}
function drawMap(groups) {
serverMap.classList.remove('hidden');
if (!mapInstance) {
mapInstance = L.map(serverMap, { scrollWheelZoom: false });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(mapInstance);
mapMarkers = L.layerGroup().addTo(mapInstance);
}
mapMarkers.clearLayers();
const bounds = [];
groups.forEach(g => {
const lat = parseFloat(g.geo.latitude);
const lon = parseFloat(g.geo.longitude);
bounds.push([lat, lon]);
const html = g.servers.map(s =>
escapeHtml(`${s.name} (${s.ip})`) +
(s.version ? ' — ' + escapeHtml(s.version) : '')).join('<br>');
L.marker([lat, lon]).bindPopup(html).addTo(mapMarkers);
});
mapInstance.invalidateSize();
if (bounds.length === 1) mapInstance.setView(bounds[0], 5);
else mapInstance.fitBounds(bounds, { padding: [24, 24] });
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
// ── Stats ─────────────────────────────────────────────────────────
function updateStats(job) {
const items = job.results || [];
@@ -973,6 +1384,16 @@
progressItems = [];
progressRows = new Map();
progressCount.textContent = '0';
detailTree.innerHTML = '';
treeNodes = new Map();
resultsFetched = false;
serversFetched = false;
serversRetriesLeft = 30;
if (serversRetryTimer) { clearTimeout(serversRetryTimer); serversRetryTimer = null; }
serversCard.classList.add('hidden');
serversTableBody.innerHTML = '';
serversCount.textContent = '0';
if (mapMarkers) mapMarkers.clearLayers();
summaryPre.textContent = '';
resultItems = [];
resultsCount.textContent = '0';