feat(web): live detail tree, server map, favicon
dns.squish.net-style traversal detail tree (refid-parented via longest prefix, collapsible .0 resolve subtrees, completed-earlier markers, raw log fallback), a servers card with Leaflet/OSM map lazy-loaded from CDN and client-side geojs.io geolocation with graceful degradation, and a delegation-tree favicon (ico + svg). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d8ef805a6a
commit
8593e0f322
+427
-6
@@ -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: '© <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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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';
|
||||
|
||||
Reference in New Issue
Block a user