feat: web frontend SPA for ExploreDNS (HAN-388) #18
+975
-3
@@ -1,12 +1,984 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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>
|
||||
<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; }
|
||||
.pe-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.pe-badge.start { background: rgba(74,124,246,0.18); color: var(--primary); }
|
||||
.pe-badge.complete { background: rgba(62,207,142,0.18); color: var(--success); }
|
||||
.pe-badge.resolve { background: rgba(56,189,248,0.18); color: var(--info); }
|
||||
.pe-depth { color: var(--text-dim); }
|
||||
.pe-name { color: var(--text); }
|
||||
.pe-server { 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;
|
||||
}
|
||||
.result-item {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-card);
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.result-item:hover { border-color: var(--border-focus); }
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.result-indent { color: var(--text-dim); flex-shrink: 0; }
|
||||
.rtype-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rtype-answer { background: rgba(62,207,142,0.15); color: var(--success); }
|
||||
.rtype-referral { background: rgba(74,124,246,0.15); color: var(--primary); }
|
||||
.rtype-nxdomain,
|
||||
.rtype-servfail { background: rgba(240,68,56,0.15); color: var(--danger); }
|
||||
.rtype-timeout { background: rgba(245,166,35,0.15); color: var(--warning); }
|
||||
.rtype-nodata { background: rgba(56,189,248,0.15); color: var(--info); }
|
||||
.rtype-cname { background: rgba(168,85,247,0.15); color: #c084fc; }
|
||||
.rtype-other { background: rgba(139,143,168,0.15);color: var(--text-muted); }
|
||||
|
||||
.result-prob {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.result-server { color: var(--text-dim); font-size: 0.72rem; margin-left: auto; }
|
||||
.result-answers {
|
||||
margin-top: 0.35rem;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.result-answers span { display: block; }
|
||||
.cname-chain {
|
||||
color: #c084fc;
|
||||
font-size: 0.72rem;
|
||||
margin-top: 0.2rem;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
/* ── 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>
|
||||
<h1>ExploreDNS</h1>
|
||||
<p>Frontend coming soon.</p>
|
||||
<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"
|
||||
/>
|
||||
<select id="typeSelect" class="type-select" title="DNS record type">
|
||||
<option>A</option>
|
||||
<option>AAAA</option>
|
||||
<option>MX</option>
|
||||
<option>NS</option>
|
||||
<option>CNAME</option>
|
||||
<option>SOA</option>
|
||||
<option>TXT</option>
|
||||
<option>PTR</option>
|
||||
<option>SRV</option>
|
||||
<option>CAA</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>
|
||||
<!-- Progress feed -->
|
||||
<div class="card" id="progressCard" style="margin-bottom:1.25rem;">
|
||||
<div class="card-title">
|
||||
⚡ Live Progress
|
||||
<span class="badge-count" id="progressCount">0</span>
|
||||
</div>
|
||||
<div class="progress-feed" 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</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">Steps</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 -->
|
||||
|
||||
</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 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');
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
let currentJobId = null;
|
||||
let eventSource = null;
|
||||
let startTime = null;
|
||||
let timerInterval = null;
|
||||
let progressItems = [];
|
||||
let resultItems = [];
|
||||
|
||||
// ── 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);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('done', () => {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
fetchResults(id);
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
// Fall back to polling
|
||||
fetchResults(id);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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 ────────────────────────────────────────────
|
||||
function appendProgress(ev) {
|
||||
progressItems.push(ev);
|
||||
progressCount.textContent = progressItems.length;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pe';
|
||||
|
||||
const badgeClass = ev.is_resolve ? 'resolve' : ev.stage;
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `pe-badge ${badgeClass}`;
|
||||
badge.textContent = ev.is_resolve ? 'glue' : ev.stage;
|
||||
|
||||
const depth = document.createElement('span');
|
||||
depth.className = 'pe-depth';
|
||||
depth.textContent = `[d${ev.depth}]`;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'pe-name';
|
||||
name.textContent = ev.name + ' (' + ev.qtype + ')';
|
||||
|
||||
row.appendChild(badge);
|
||||
row.appendChild(depth);
|
||||
row.appendChild(name);
|
||||
|
||||
if (ev.server) {
|
||||
const srv = document.createElement('span');
|
||||
srv.className = 'pe-server';
|
||||
srv.textContent = '@ ' + ev.server;
|
||||
row.appendChild(srv);
|
||||
}
|
||||
|
||||
progressFeed.appendChild(row);
|
||||
progressFeed.scrollTop = progressFeed.scrollHeight;
|
||||
}
|
||||
|
||||
// ── 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(buildResultItem(item));
|
||||
});
|
||||
|
||||
setStatus('complete', `Traversal complete — ${items.length} step${items.length === 1 ? '' : 's'}`);
|
||||
updateStats(job);
|
||||
}
|
||||
|
||||
function buildResultItem(item) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'result-item';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'result-header';
|
||||
|
||||
// Depth indent
|
||||
const indent = document.createElement('span');
|
||||
indent.className = 'result-indent';
|
||||
indent.textContent = ' '.repeat(item.depth) + (item.depth > 0 ? '└─ ' : '');
|
||||
|
||||
// Type badge
|
||||
const rtype = (item.response_type || 'unknown').toLowerCase();
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `rtype-badge rtype-${rtypeCls(rtype)}`;
|
||||
badge.textContent = item.response_type || '?';
|
||||
|
||||
// Probability
|
||||
const prob = document.createElement('span');
|
||||
prob.className = 'result-prob';
|
||||
if (typeof item.probability === 'number' && item.probability > 0) {
|
||||
prob.textContent = (item.probability * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
// Server
|
||||
const server = document.createElement('span');
|
||||
server.className = 'result-server';
|
||||
server.textContent = item.server || '';
|
||||
|
||||
header.appendChild(indent);
|
||||
header.appendChild(badge);
|
||||
if (prob.textContent) header.appendChild(prob);
|
||||
if (server.textContent) header.appendChild(server);
|
||||
|
||||
el.appendChild(header);
|
||||
|
||||
// CNAME chain
|
||||
if (item.cname_chain && item.cname_chain.length > 0) {
|
||||
const chain = document.createElement('div');
|
||||
chain.className = 'cname-chain';
|
||||
chain.textContent = 'CNAME → ' + item.cname_chain.join(' → ');
|
||||
el.appendChild(chain);
|
||||
}
|
||||
|
||||
// Answers
|
||||
if (item.answers && item.answers.length > 0) {
|
||||
const ansBox = document.createElement('div');
|
||||
ansBox.className = 'result-answers';
|
||||
item.answers.forEach(a => {
|
||||
const s = document.createElement('span');
|
||||
s.textContent = a;
|
||||
ansBox.appendChild(s);
|
||||
});
|
||||
el.appendChild(ansBox);
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function rtypeCls(rtype) {
|
||||
if (rtype.includes('answer')) return 'answer';
|
||||
if (rtype.includes('referral')) return 'referral';
|
||||
if (rtype.includes('nxdomain')) return 'nxdomain';
|
||||
if (rtype.includes('servfail')) return 'servfail';
|
||||
if (rtype.includes('timeout')) return 'timeout';
|
||||
if (rtype.includes('nodata')) return 'nodata';
|
||||
if (rtype.includes('cname')) return 'cname';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────────
|
||||
function updateStats(job) {
|
||||
const items = job.results || [];
|
||||
const steps = items.length;
|
||||
const answers = items.filter(r => (r.response_type||'').toLowerCase().includes('answer')).length;
|
||||
const errors = items.filter(r => {
|
||||
const t = (r.response_type||'').toLowerCase();
|
||||
return t.includes('error') || t.includes('servfail') || t.includes('timeout') || t.includes('nxdomain');
|
||||
}).length;
|
||||
const maxDepth = items.reduce((m, r) => Math.max(m, r.depth || 0), 0);
|
||||
|
||||
const statusCls = job.status === 'complete' ? 'ok' : job.status === 'error' ? 'err' : '';
|
||||
statStatus.textContent = job.status || '—';
|
||||
statStatus.className = `stat-value ${statusCls}`;
|
||||
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 = [];
|
||||
progressCount.textContent = '0';
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user