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>
1460 lines
53 KiB
HTML
1460 lines
53 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</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 {
|
|
--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;
|
|
}
|
|
[data-theme="light"] {
|
|
--bg: #f4f5f9;
|
|
--bg-surface: #ffffff;
|
|
--bg-card: #ffffff;
|
|
--bg-input: #f1f2f7;
|
|
--border: #dde0ec;
|
|
--border-focus: #4a7cf6;
|
|
--text: #1a1d27;
|
|
--text-muted: #6b7091;
|
|
--text-dim: #9ba1ba;
|
|
--shadow: 0 4px 20px rgba(0,0,0,0.08);
|
|
}
|
|
|
|
/* ── 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;
|
|
}
|
|
.theme-btn {
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-muted);
|
|
border-radius: var(--radius-sm);
|
|
padding: 5px 9px;
|
|
font-size: 1rem;
|
|
transition: color 0.15s, border-color 0.15s;
|
|
}
|
|
.theme-btn:hover { color: var(--text); border-color: var(--text-dim); }
|
|
|
|
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;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* ── Search form ────────────────────────────────────────────────── */
|
|
.search-section { margin-bottom: 1.5rem; }
|
|
.search-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.input-group {
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 200px;
|
|
}
|
|
.domain-input {
|
|
flex: 1;
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border);
|
|
border-right: none;
|
|
border-radius: var(--radius) 0 0 var(--radius);
|
|
color: var(--text);
|
|
font-size: 0.95rem;
|
|
padding: 0 1rem;
|
|
height: 42px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.domain-input:focus { border-color: var(--border-focus); }
|
|
.domain-input::placeholder { color: var(--text-dim); }
|
|
|
|
.type-select {
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border);
|
|
border-left: 1px solid var(--border);
|
|
border-radius: 0;
|
|
color: var(--text);
|
|
font-size: 0.9rem;
|
|
padding: 0 0.75rem;
|
|
height: 42px;
|
|
outline: none;
|
|
cursor: pointer;
|
|
min-width: 80px;
|
|
}
|
|
.type-select:focus { border-color: var(--border-focus); }
|
|
|
|
.submit-btn {
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 0 var(--radius) var(--radius) 0;
|
|
padding: 0 1.25rem;
|
|
height: 42px;
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
white-space: nowrap;
|
|
transition: background 0.15s, opacity 0.15s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
.submit-btn:hover:not(:disabled) { background: var(--primary-hover); }
|
|
.submit-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
|
|
/* ── Options panel ──────────────────────────────────────────────── */
|
|
.options-row {
|
|
margin-top: 0.75rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.opt-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
color: var(--text-muted);
|
|
user-select: none;
|
|
}
|
|
.opt-toggle input[type="checkbox"] {
|
|
appearance: none;
|
|
width: 16px; height: 16px;
|
|
border: 1.5px solid var(--border);
|
|
border-radius: 4px;
|
|
background: var(--bg-input);
|
|
cursor: pointer;
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
.opt-toggle input[type="checkbox"]:checked {
|
|
background: var(--primary);
|
|
border-color: var(--primary);
|
|
}
|
|
.opt-toggle input[type="checkbox"]:checked::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 4px; top: 1px;
|
|
width: 6px; height: 9px;
|
|
border: 2px solid #fff;
|
|
border-top: none;
|
|
border-left: none;
|
|
transform: rotate(45deg);
|
|
}
|
|
.opt-toggle:hover { color: var(--text); }
|
|
|
|
/* ── Two-column layout ──────────────────────────────────────────── */
|
|
.two-col {
|
|
display: grid;
|
|
grid-template-columns: 1fr 320px;
|
|
gap: 1.25rem;
|
|
align-items: start;
|
|
}
|
|
@media (max-width: 820px) {
|
|
.two-col { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
/* ── Status bar ─────────────────────────────────────────────────── */
|
|
.status-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
margin-bottom: 1.25rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.status-dot {
|
|
width: 8px; height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.status-dot.running { background: var(--warning); animation: pulse 1.2s ease infinite; }
|
|
.status-dot.complete { background: var(--success); }
|
|
.status-dot.error { background: var(--danger); }
|
|
.status-dot.idle { background: var(--text-dim); }
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.35; }
|
|
}
|
|
.status-text { flex: 1; color: var(--text-muted); }
|
|
.status-timer { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; }
|
|
|
|
/* ── Progress feed ──────────────────────────────────────────────── */
|
|
.progress-feed {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
line-height: 1.6;
|
|
max-height: 220px;
|
|
overflow-y: 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;
|
|
}
|
|
.progress-feed:empty::before {
|
|
content: 'Awaiting traversal…';
|
|
color: var(--text-dim);
|
|
}
|
|
.pe { display: flex; gap: 0.5rem; align-items: baseline; }
|
|
.pe-badge {
|
|
flex-shrink: 0;
|
|
min-width: 70px;
|
|
text-align: center;
|
|
font-size: 0.65rem;
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
background: rgba(139,143,168,0.15);
|
|
color: var(--text-muted);
|
|
}
|
|
.pe-badge.st-working { background: rgba(74,124,246,0.18); color: var(--primary); }
|
|
.pe-badge.st-answered { background: rgba(62,207,142,0.18); color: var(--success); }
|
|
.pe-badge.st-resolve { background: rgba(56,189,248,0.18); color: var(--info); }
|
|
.pe-badge.st-warn { background: rgba(245,166,35,0.18); color: var(--warning); }
|
|
.pe-badge.st-err { background: rgba(240,68,56,0.18); color: var(--danger); }
|
|
/* Progress lines mirror the CLI: "<refid> <server> (<ips>)" — keep the
|
|
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;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
margin-bottom: 0.75rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.results-empty {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: var(--text-dim);
|
|
font-size: 0.875rem;
|
|
}
|
|
/* Aggregated leaves rendered like the CLI Results section: fixed-width
|
|
text with significant leading spaces, so white-space must be pre. */
|
|
.result-block {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
line-height: 1.5;
|
|
white-space: pre;
|
|
overflow-x: auto;
|
|
margin: 0 0 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--border);
|
|
border-left-width: 3px;
|
|
border-radius: var(--radius-sm);
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
}
|
|
.result-block.st-answered { border-left-color: var(--success); }
|
|
.result-block.st-warn { border-left-color: var(--warning); }
|
|
.result-block.st-err { border-left-color: var(--danger); }
|
|
.result-block.st-other { border-left-color: var(--text-dim); }
|
|
|
|
/* ── Summary Results ───────────────────────────────────────────── */
|
|
.summary-pre {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.75rem;
|
|
line-height: 1.6;
|
|
white-space: pre;
|
|
overflow-x: auto;
|
|
margin: 0 0 1rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.summary-pre:empty::before {
|
|
content: 'No summary yet.';
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
/* ── Stats panel ────────────────────────────────────────────────── */
|
|
.stat-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.6rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.stat-item {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.6rem 0.75rem;
|
|
}
|
|
.stat-label {
|
|
font-size: 0.7rem;
|
|
color: var(--text-dim);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
.stat-value {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.05rem;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
.stat-value.ok { color: var(--success); }
|
|
.stat-value.warn { color: var(--warning); }
|
|
.stat-value.err { color: var(--danger); }
|
|
|
|
/* ── Error box ──────────────────────────────────────────────────── */
|
|
.error-box {
|
|
background: rgba(240,68,56,0.08);
|
|
border: 1px solid rgba(240,68,56,0.3);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.75rem 1rem;
|
|
color: var(--danger);
|
|
font-size: 0.85rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
/* ── Scrollbar ──────────────────────────────────────────────────── */
|
|
.progress-feed::-webkit-scrollbar { width: 5px; }
|
|
.progress-feed::-webkit-scrollbar-track { background: transparent; }
|
|
.progress-feed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
|
|
/* ── Responsive ─────────────────────────────────────────────────── */
|
|
@media (max-width: 600px) {
|
|
main { padding: 1rem; }
|
|
.search-row { flex-direction: column; }
|
|
.input-group { flex-wrap: wrap; }
|
|
.domain-input {
|
|
border-radius: var(--radius) var(--radius) 0 0;
|
|
border-right: 1px solid var(--border);
|
|
border-bottom: none;
|
|
width: 100%;
|
|
}
|
|
.type-select {
|
|
border-radius: 0;
|
|
border-bottom: none;
|
|
flex: 1;
|
|
}
|
|
.submit-btn { border-radius: 0 0 var(--radius) var(--radius); width: 100%; justify-content: center; }
|
|
}
|
|
|
|
/* ── Helpers ────────────────────────────────────────────────────── */
|
|
.hidden { display: none !important; }
|
|
.spin { animation: spin 0.9s linear infinite; display: inline-block; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.badge-count {
|
|
font-size: 0.7rem;
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 0 6px;
|
|
color: var(--text-muted);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
|
|
<!-- Header -->
|
|
<header>
|
|
<div class="logo">
|
|
<span class="logo-icon">🔍</span>
|
|
ExploreDNS
|
|
</div>
|
|
<button class="theme-btn" id="themeBtn" title="Toggle light/dark mode">☀️</button>
|
|
</header>
|
|
|
|
<!-- Main content -->
|
|
<main>
|
|
|
|
<!-- Search form card -->
|
|
<div class="card search-section">
|
|
<div class="search-row">
|
|
<div class="input-group">
|
|
<input
|
|
id="domainInput"
|
|
class="domain-input"
|
|
type="text"
|
|
placeholder="example.com"
|
|
autocomplete="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
/>
|
|
<!-- Options must match config.ParseQueryType exactly. -->
|
|
<select id="typeSelect" class="type-select" title="DNS record type">
|
|
<option>A</option>
|
|
<option>AAAA</option>
|
|
<option>NS</option>
|
|
<option>CNAME</option>
|
|
<option>MX</option>
|
|
<option>TXT</option>
|
|
<option>SOA</option>
|
|
<option>PTR</option>
|
|
<option>ANY</option>
|
|
</select>
|
|
<button id="submitBtn" class="submit-btn">
|
|
<span id="submitIcon">▶</span>
|
|
<span id="submitLabel">Traverse</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="options-row">
|
|
<label class="opt-toggle" title="Query all root servers instead of a random selection">
|
|
<input type="checkbox" id="optAllRoots" />
|
|
All root servers
|
|
</label>
|
|
<span style="color:var(--text-dim);font-size:0.75rem;">
|
|
Follows every delegation branch from the root
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status bar (hidden until query starts) -->
|
|
<div id="statusBar" class="status-bar hidden">
|
|
<span class="status-dot idle" id="statusDot"></span>
|
|
<span class="status-text" id="statusText">Ready</span>
|
|
<span class="status-timer" id="statusTimer"></span>
|
|
</div>
|
|
|
|
<!-- Error box -->
|
|
<div id="errorBox" class="error-box hidden"></div>
|
|
|
|
<!-- Two-column: results left, stats right -->
|
|
<div class="two-col" id="resultArea">
|
|
|
|
<!-- Left: progress + results -->
|
|
<div>
|
|
<!-- 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">
|
|
🌳 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="detail-tree" id="detailTree"></div>
|
|
<div class="progress-feed hidden" id="progressFeed"></div>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div class="card results-section">
|
|
<div class="card-title">
|
|
🌐 Traversal Results
|
|
<span class="badge-count" id="resultsCount">0</span>
|
|
</div>
|
|
<div id="resultsContainer">
|
|
<div class="results-empty" id="resultsEmpty">
|
|
Enter a domain above to start a traversal.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: stats panel -->
|
|
<div>
|
|
<div class="card">
|
|
<div class="card-title">📊 Summary Results</div>
|
|
<pre class="summary-pre" id="summaryPre"></pre>
|
|
|
|
<div class="card-title">Overview</div>
|
|
<div class="stat-grid" id="statGrid">
|
|
<div class="stat-item">
|
|
<div class="stat-label">Status</div>
|
|
<div class="stat-value" id="statStatus">—</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Results</div>
|
|
<div class="stat-value" id="statSteps">—</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Answers</div>
|
|
<div class="stat-value" id="statAnswers">—</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Errors</div>
|
|
<div class="stat-value" id="statErrors">—</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Max Depth</div>
|
|
<div class="stat-value" id="statDepth">—</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Duration</div>
|
|
<div class="stat-value" id="statDuration">—</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-title" style="margin-top:0.25rem;">🕐 Query Details</div>
|
|
<div style="font-size:0.8rem; color:var(--text-muted); font-family:var(--font-mono);">
|
|
<div style="margin-bottom:0.35rem;">
|
|
<span style="color:var(--text-dim);">Domain:</span>
|
|
<span id="detailDomain">—</span>
|
|
</div>
|
|
<div style="margin-bottom:0.35rem;">
|
|
<span style="color:var(--text-dim);">Type:</span>
|
|
<span id="detailType">—</span>
|
|
</div>
|
|
<div style="margin-bottom:0.35rem;">
|
|
<span style="color:var(--text-dim);">Started:</span>
|
|
<span id="detailStarted">—</span>
|
|
</div>
|
|
<div>
|
|
<span style="color:var(--text-dim);">Finished:</span>
|
|
<span id="detailFinished">—</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</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>
|
|
ExploreDNS — iterative DNS traversal explorer
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ── DOM refs ──────────────────────────────────────────────────────
|
|
const domainInput = document.getElementById('domainInput');
|
|
const typeSelect = document.getElementById('typeSelect');
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
const submitIcon = document.getElementById('submitIcon');
|
|
const submitLabel = document.getElementById('submitLabel');
|
|
const optAllRoots = document.getElementById('optAllRoots');
|
|
const statusBar = document.getElementById('statusBar');
|
|
const statusDot = document.getElementById('statusDot');
|
|
const statusText = document.getElementById('statusText');
|
|
const statusTimer = document.getElementById('statusTimer');
|
|
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');
|
|
const themeBtn = document.getElementById('themeBtn');
|
|
const statStatus = document.getElementById('statStatus');
|
|
const statSteps = document.getElementById('statSteps');
|
|
const statAnswers = document.getElementById('statAnswers');
|
|
const statErrors = document.getElementById('statErrors');
|
|
const statDepth = document.getElementById('statDepth');
|
|
const statDuration = document.getElementById('statDuration');
|
|
const detailDomain = document.getElementById('detailDomain');
|
|
const detailType = document.getElementById('detailType');
|
|
const detailStarted = document.getElementById('detailStarted');
|
|
const detailFinished= document.getElementById('detailFinished');
|
|
const summaryPre = document.getElementById('summaryPre');
|
|
|
|
// ── State ─────────────────────────────────────────────────────────
|
|
let currentJobId = null;
|
|
let eventSource = null;
|
|
let startTime = null;
|
|
let timerInterval = null;
|
|
let progressItems = [];
|
|
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;
|
|
function setTheme(t) {
|
|
html.setAttribute('data-theme', t);
|
|
themeBtn.textContent = t === 'dark' ? '☀️' : '🌙';
|
|
try { localStorage.setItem('theme', t); } catch(_) {}
|
|
}
|
|
themeBtn.addEventListener('click', () => {
|
|
setTheme(html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
|
});
|
|
try {
|
|
const saved = localStorage.getItem('theme');
|
|
if (saved === 'light' || saved === 'dark') setTheme(saved);
|
|
} catch(_) {}
|
|
|
|
// ── Form submit ───────────────────────────────────────────────────
|
|
domainInput.addEventListener('keydown', e => { if (e.key === 'Enter') startTraversal(); });
|
|
submitBtn.addEventListener('click', startTraversal);
|
|
|
|
async function startTraversal() {
|
|
const domain = domainInput.value.trim();
|
|
if (!domain) { domainInput.focus(); return; }
|
|
|
|
resetUI();
|
|
setLoading(true);
|
|
|
|
try {
|
|
const res = await fetch('/api/traverse', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
domain: domain,
|
|
type: typeSelect.value,
|
|
all_roots: optAllRoots.checked,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const e = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(e.error || res.statusText);
|
|
}
|
|
const data = await res.json();
|
|
currentJobId = data.id;
|
|
startTime = Date.now();
|
|
|
|
setStatus('running', 'Traversal running…');
|
|
statusBar.classList.remove('hidden');
|
|
startTimer();
|
|
connectSSE(currentJobId);
|
|
} catch (err) {
|
|
showError(err.message);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// ── SSE streaming ─────────────────────────────────────────────────
|
|
function connectSSE(id) {
|
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
|
|
eventSource = new EventSource(`/api/traverse/${id}/stream`);
|
|
|
|
eventSource.addEventListener('message', e => {
|
|
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;
|
|
maybeFetchResults();
|
|
fetchServers();
|
|
});
|
|
|
|
eventSource.onerror = () => {
|
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
// Fall back to polling
|
|
maybeFetchResults();
|
|
fetchServers();
|
|
};
|
|
}
|
|
|
|
function maybeFetchResults() {
|
|
if (resultsFetched || !currentJobId) return;
|
|
resultsFetched = true;
|
|
fetchResults(currentJobId);
|
|
}
|
|
|
|
// ── Fetch final results ───────────────────────────────────────────
|
|
async function fetchResults(id) {
|
|
try {
|
|
const res = await fetch(`/api/traverse/${id}`);
|
|
if (!res.ok) throw new Error('Failed to fetch results');
|
|
const job = await res.json();
|
|
renderResults(job);
|
|
} catch (err) {
|
|
showError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
stopTimer();
|
|
}
|
|
}
|
|
|
|
// ── Progress rendering ────────────────────────────────────────────
|
|
// One row per refid, updated in place as later events (start/answer/
|
|
// answer_fast) arrive for the same node. Line format mirrors the CLI:
|
|
// "<refid> <server> (<ips>)" plus resolving / completed-earlier markers.
|
|
function appendProgress(ev) {
|
|
progressItems.push(ev);
|
|
progressCount.textContent = progressItems.length;
|
|
if (!ev.refid) return;
|
|
|
|
let row = progressRows.get(ev.refid);
|
|
if (!row) {
|
|
const el = document.createElement('div');
|
|
el.className = 'pe';
|
|
const badge = document.createElement('span');
|
|
badge.className = 'pe-badge';
|
|
const text = document.createElement('span');
|
|
text.className = 'pe-text';
|
|
el.appendChild(badge);
|
|
el.appendChild(text);
|
|
row = { el, badge, text };
|
|
progressRows.set(ev.refid, row);
|
|
progressFeed.appendChild(el);
|
|
}
|
|
|
|
const status = ev.status || ev.stage;
|
|
row.badge.textContent = ev.is_resolve ? 'glue ' + status : status;
|
|
row.badge.className = 'pe-badge ' + progressBadgeCls(ev);
|
|
|
|
let line = ev.refid + ' ' + (ev.server || ev.name);
|
|
if (ev.ips) line += ' (' + ev.ips + ')';
|
|
if (ev.completed_earlier) {
|
|
line += ' -- completed earlier (' + ev.completed_earlier + ')';
|
|
} else if (ev.stage === 'start' && !ev.ips) {
|
|
line += ' -- resolving';
|
|
}
|
|
row.text.textContent = line;
|
|
|
|
progressFeed.scrollTop = progressFeed.scrollHeight;
|
|
}
|
|
|
|
function progressBadgeCls(ev) {
|
|
if (ev.is_resolve) return 'st-resolve';
|
|
switch (ev.status) {
|
|
case 'answered': return 'st-answered';
|
|
case 'referral_lame': case 'noglue': return 'st-warn';
|
|
case 'error': case 'exception':
|
|
case 'loop': case 'cname_loop': return 'st-err';
|
|
case 'referral': case 'restart': return 'st-working';
|
|
default: return 'st-working';
|
|
}
|
|
}
|
|
|
|
// ── 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 || '—';
|
|
detailType.textContent = job.query_type || '—';
|
|
detailStarted.textContent = job.started_at ? fmtTime(job.started_at) : '—';
|
|
detailFinished.textContent= job.done_at ? fmtTime(job.done_at) : '—';
|
|
|
|
if (job.error) {
|
|
showError(job.error);
|
|
setStatus('error', 'Traversal failed');
|
|
updateStats(job);
|
|
return;
|
|
}
|
|
|
|
const items = job.results || [];
|
|
resultItems = items;
|
|
resultsCount.textContent = items.length;
|
|
|
|
if (items.length === 0) {
|
|
resultsEmpty.textContent = 'No results returned.';
|
|
setStatus('complete', 'Complete — no results');
|
|
updateStats(job);
|
|
return;
|
|
}
|
|
|
|
// Clear empty message
|
|
resultsEmpty.classList.add('hidden');
|
|
|
|
items.forEach(item => {
|
|
resultsContainer.appendChild(buildResultBlock(item, job));
|
|
});
|
|
|
|
renderSummary(job.summary);
|
|
|
|
setStatus('complete', `Traversal complete — ${items.length} result${items.length === 1 ? '' : 's'}`);
|
|
updateStats(job);
|
|
}
|
|
|
|
// pct5 renders "%5.1f%%" like the CLI Results head.
|
|
function pct5(p) {
|
|
return (p * 100).toFixed(1).padStart(5) + '%';
|
|
}
|
|
|
|
// pctTrim renders the Summary Results probability: %5.1f%% with a trailing
|
|
// ".0" trimmed, right-justified to width 5.
|
|
function pctTrim(p) {
|
|
let t = (p * 100).toFixed(1) + '%';
|
|
t = t.replace('.0%', '%');
|
|
return t.padStart(5);
|
|
}
|
|
|
|
// buildResultBlock renders one aggregated leaf with the CLI Results wording
|
|
// as a pre block (leading spaces are significant).
|
|
function buildResultBlock(item, job) {
|
|
const el = document.createElement('pre');
|
|
el.className = 'result-block ' + statusCls(item.status);
|
|
el.textContent = leafLines(item, job).join('\n');
|
|
return el;
|
|
}
|
|
|
|
function leafLines(item, job) {
|
|
const indent = ' '.repeat(12);
|
|
const head = pct5(item.probability || 0) + ': ';
|
|
const where = `${item.server || ''} (${item.ip || ''})`;
|
|
const lines = [];
|
|
|
|
switch (item.status) {
|
|
case 'answered':
|
|
lines.push(head + 'Answer from ' + where);
|
|
(item.answers || []).forEach(a => lines.push(indent + a));
|
|
break;
|
|
case 'nodata':
|
|
lines.push(head + 'NODATA (for this type) at ' + where);
|
|
break;
|
|
case 'noglue':
|
|
lines.push(head + `No glue at ${item.parent || ''} (${item.ip || ''}) for ${item.server || ''}`);
|
|
break;
|
|
case 'referral_lame':
|
|
lines.push(head + `Lame referral from ${item.parent || ''} (${item.parent_ip || ''}) to ${where}`);
|
|
break;
|
|
case 'loop':
|
|
lines.push(head + `Loop encountered at ${item.server || ''}`);
|
|
break;
|
|
case 'cname_loop':
|
|
lines.push(head + `CNAME loop encountered at ${item.server || ''}`);
|
|
break;
|
|
case 'error':
|
|
case 'exception':
|
|
lines.push(head + `${item.message || item.status} at ${where}`);
|
|
break;
|
|
default:
|
|
lines.push(head + 'Stopped at ' + where);
|
|
}
|
|
|
|
if (item.status !== 'answered' && item.qname) {
|
|
const orig = (job.domain || '').replace(/\.$/, '').toLowerCase();
|
|
const qn = item.qname.replace(/\.$/, '').toLowerCase();
|
|
if (qn !== orig || (item.qtype || '') !== (job.query_type || '')) {
|
|
lines.push(indent + `While querying ${item.qname}/${item.qclass || 'IN'}/${item.qtype || ''}`);
|
|
}
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function statusCls(status) {
|
|
switch (status) {
|
|
case 'answered': return 'st-answered';
|
|
case 'referral_lame': case 'noglue': return 'st-warn';
|
|
case 'error': case 'exception':
|
|
case 'loop': case 'cname_loop': return 'st-err';
|
|
default: return 'st-other';
|
|
}
|
|
}
|
|
|
|
// ── Summary Results rendering (CLI Summary Results wording) ───────
|
|
function renderSummary(summary) {
|
|
if (!summary) { summaryPre.textContent = ''; return; }
|
|
const lines = [];
|
|
(summary.answers || []).forEach(a => {
|
|
const initial = pctTrim(a.probability) + ' answered with ';
|
|
const recs = a.records || [];
|
|
if (recs.length === 0) {
|
|
lines.push(initial.trimEnd());
|
|
return;
|
|
}
|
|
lines.push(initial + recs[0]);
|
|
for (let i = 1; i < recs.length; i++) {
|
|
lines.push(' '.repeat(initial.length) + recs[i]);
|
|
}
|
|
});
|
|
(summary.by_status || []).forEach(s => {
|
|
lines.push(pctTrim(s.probability) + ' ' + summaryStatusLabel(s.status));
|
|
});
|
|
summaryPre.textContent = lines.join('\n');
|
|
}
|
|
|
|
function summaryStatusLabel(status) {
|
|
switch (status) {
|
|
case 'nodata': return 'found no such record';
|
|
case 'referral_lame': return 'resulted in a lame referral';
|
|
case 'exception': return 'resulted in an exception';
|
|
case 'error': return 'resulted in an error';
|
|
case 'noglue': return 'found no glue';
|
|
case 'loop': return 'resulted in a loop';
|
|
case 'cname_loop': return 'resulted in a CNAME loop';
|
|
default: return status;
|
|
}
|
|
}
|
|
|
|
// ── 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 || [];
|
|
const steps = items.length;
|
|
const answers = items.filter(r => r.status === 'answered').length;
|
|
const errors = items.filter(r => statusCls(r.status) === 'st-err').length;
|
|
const maxDepth = items.reduce((m, r) => Math.max(m, r.depth || 0), 0);
|
|
|
|
const jobStateCls = job.status === 'complete' ? 'ok' : job.status === 'error' ? 'err' : '';
|
|
statStatus.textContent = job.status || '—';
|
|
statStatus.className = `stat-value ${jobStateCls}`;
|
|
statSteps.textContent = steps || '—';
|
|
statAnswers.textContent = answers || '—';
|
|
statErrors.className = `stat-value ${errors > 0 ? 'warn' : ''}`;
|
|
statErrors.textContent = errors || '0';
|
|
statDepth.textContent = maxDepth || '—';
|
|
|
|
if (job.started_at && job.done_at) {
|
|
const ms = new Date(job.done_at) - new Date(job.started_at);
|
|
statDuration.textContent = ms < 1000 ? ms + 'ms' : (ms/1000).toFixed(1) + 's';
|
|
}
|
|
}
|
|
|
|
// ── UI helpers ────────────────────────────────────────────────────
|
|
function resetUI() {
|
|
errorBox.classList.add('hidden');
|
|
errorBox.textContent = '';
|
|
progressFeed.innerHTML = '';
|
|
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';
|
|
// Remove old result items but keep resultsEmpty
|
|
Array.from(resultsContainer.children).forEach(c => {
|
|
if (c !== resultsEmpty) c.remove();
|
|
});
|
|
resultsEmpty.classList.remove('hidden');
|
|
resultsEmpty.textContent = 'Running traversal…';
|
|
statStatus.textContent = statSteps.textContent = statAnswers.textContent =
|
|
statErrors.textContent = statDepth.textContent = statDuration.textContent = '—';
|
|
statStatus.className = statErrors.className = 'stat-value';
|
|
detailDomain.textContent = detailType.textContent =
|
|
detailStarted.textContent = detailFinished.textContent = '—';
|
|
statusTimer.textContent = '';
|
|
}
|
|
|
|
function setLoading(on) {
|
|
submitBtn.disabled = on;
|
|
submitIcon.textContent = on ? '⏳' : '▶';
|
|
if (on) {
|
|
submitIcon.classList.add('spin');
|
|
submitLabel.textContent = 'Running…';
|
|
} else {
|
|
submitIcon.classList.remove('spin');
|
|
submitLabel.textContent = 'Traverse';
|
|
}
|
|
}
|
|
|
|
function setStatus(state, msg) {
|
|
statusDot.className = `status-dot ${state}`;
|
|
statusText.textContent = msg;
|
|
statusBar.classList.remove('hidden');
|
|
}
|
|
|
|
function showError(msg) {
|
|
errorBox.textContent = '⚠ ' + msg;
|
|
errorBox.classList.remove('hidden');
|
|
}
|
|
|
|
function startTimer() {
|
|
stopTimer();
|
|
timerInterval = setInterval(() => {
|
|
if (!startTime) return;
|
|
const elapsed = Date.now() - startTime;
|
|
statusTimer.textContent = elapsed < 1000 ? elapsed + 'ms' : (elapsed/1000).toFixed(1) + 's';
|
|
}, 200);
|
|
}
|
|
|
|
function stopTimer() {
|
|
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
|
}
|
|
|
|
function fmtTime(ts) {
|
|
try {
|
|
return new Date(ts).toLocaleTimeString();
|
|
} catch(_) { return ts; }
|
|
}
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|