← back to Domain Sniper
step 10: dashboard UX — sort tabs, click-to-copy reg cmd, audio ping on BRAND
109b3c14ea8d0909c10845db975fdd26f1569a19 · 2026-05-12 15:31:57 -0700 · Steve Abrams
Sort: time (default) / brand-first / hot-first, persisted in localStorage.
Click any row to copy 'node register.js <domain>' to clipboard with toast.
WebAudio ping (880 Hz, 220ms) on BRAND hits; toggle persisted.
README logs ~2h honeypot spot-check (10/10 baits still available).
Files touched
M README.mdM public/index.html
Diff
commit 109b3c14ea8d0909c10845db975fdd26f1569a19
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 15:31:57 2026 -0700
step 10: dashboard UX — sort tabs, click-to-copy reg cmd, audio ping on BRAND
Sort: time (default) / brand-first / hot-first, persisted in localStorage.
Click any row to copy 'node register.js <domain>' to clipboard with toast.
WebAudio ping (880 Hz, 220ms) on BRAND hits; toggle persisted.
README logs ~2h honeypot spot-check (10/10 baits still available).
---
README.md | 13 +++-
public/index.html | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 186 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index b3ab1d7..52bcffb 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,18 @@ We can't get the WHOIS query stream (it's gated to aggregator partners), but we
- Direct `curl` against rdap.verisign.com / rdap.nic.google / api.godaddy.com `/v1/domains/available` / Namecheap `domains.check`
- Backup: `~/.claude/settings.json.bak-2026-05-12-yolo-tick2`
- Does NOT affect domain-sniper scripts (honeypot.js, check.js) — those use Node's child_process / https directly and aren't routed through Claude's Bash permission layer.
-- [ ] **Step 10** — extend dashboard with sort, click-to-register, sound alerts on brand hits.
+- [x] **Step 10** — dashboard UX extension: sort tabs (time / brand-first / hot-first), click-any-row to copy `node register.js <domain>` to clipboard, audible BRAND ping via WebAudio, toast confirmation. All settings persist in localStorage (`sniper.sort`, `sniper.audio`, etc.).
+
+## Snipe-timing curve (honeypot 2026-05-12T20:36 UTC)
+
+Spot-checks via DoH at increasing intervals to find where the snipe window closes. Datapoints logged to `data/honeypot-spotcheck.jsonl`.
+
+| Elapsed | Buckets checked | Snipes |
+| --- | --- | --- |
+| ~2h | A/B/C/D/E (2 ea) | 0 / 10 |
+| ~6h | _pending_ | — |
+| ~24h | full batch | — |
+| ~48h | full batch (final) | — |
## YOLO loop notes
diff --git a/public/index.html b/public/index.html
index d68d3bb..50abeaa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -36,7 +36,7 @@
.ctrl {
padding: 12px 20px;
border-bottom: 1px solid #1f1f1f;
- display: flex; gap: 12px; align-items: center;
+ display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.ctrl input[type="text"] {
background: #161616; border: 1px solid #2a2a2a; color: var(--fg);
@@ -44,6 +44,14 @@
}
.ctrl label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: var(--dim); font-size: 12px; }
.ctrl label input { accent-color: var(--accent); }
+ .ctrl .sortgrp { display: flex; gap: 0; border: 1px solid #2a2a2a; border-radius: 4px; overflow: hidden; }
+ .ctrl .sortgrp button {
+ background: #161616; color: var(--dim); border: none; padding: 5px 10px;
+ font-family: inherit; font-size: 11px; cursor: pointer; border-right: 1px solid #2a2a2a;
+ }
+ .ctrl .sortgrp button:last-child { border-right: none; }
+ .ctrl .sortgrp button.active { background: #1f1f1f; color: var(--accent); }
+ .ctrl .sortgrp button:hover:not(.active) { color: var(--fg); }
.ctrl .chip {
padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600;
letter-spacing: 0.5px; text-transform: uppercase;
@@ -62,27 +70,39 @@
align-items: center;
gap: 16px;
transition: background 0.1s;
+ cursor: pointer;
}
.row:hover { background: var(--row-hover); }
.row.hot { background: rgba(255, 180, 84, 0.04); }
.row.brand { background: rgba(255, 59, 59, 0.06); border-left: 3px solid var(--brand); padding-left: 17px; }
+ .row.flash { animation: flash 0.4s ease-out; }
+ @keyframes flash { 0% { background: rgba(78, 201, 176, 0.25); } 100% {} }
.row .t { color: var(--dim); font-size: 11px; }
.row .tag { font-size: 10px; font-weight: 600; letter-spacing: 0.5px; }
.row.hot .tag { color: var(--hot); }
.row.brand .tag { color: var(--brand); }
.row .domain { font-weight: 500; color: var(--fg); }
.row .actions { display: flex; gap: 8px; justify-content: flex-end; }
- .row .actions a {
+ .row .actions a, .row .actions button {
color: var(--dim); text-decoration: none; font-size: 11px;
border: 1px solid #2a2a2a; padding: 3px 8px; border-radius: 3px;
+ background: transparent; font-family: inherit; cursor: pointer;
}
- .row .actions a:hover { color: var(--accent); border-color: var(--accent); }
+ .row .actions a:hover, .row .actions button:hover { color: var(--accent); border-color: var(--accent); }
.empty { padding: 40px; color: var(--dim); text-align: center; }
.note {
padding: 12px 20px; background: #141414; color: var(--dim); font-size: 11px;
border-bottom: 1px solid #1f1f1f; line-height: 1.5;
}
.note b { color: var(--fg); }
+ .toast {
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
+ background: #1f1f1f; color: var(--accent); border: 1px solid var(--accent);
+ padding: 10px 16px; border-radius: 4px; font-size: 12px;
+ opacity: 0; transition: opacity 0.2s; pointer-events: none;
+ max-width: 80vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+ .toast.show { opacity: 1; }
</style>
</head>
<body>
@@ -97,18 +117,26 @@
</div>
</header>
<div class="note">
- Data source — <b>Certificate Transparency logs</b> via CertStream. Every TLS cert issued worldwide flows through here, ~1 min after a domain gets registered. This is the closest free public real-time analog to a registrar's check-stream. Rows marked <span class="chip brand">BRAND</span> match one of your watched keywords. Rows marked <span class="chip hot">HOT</span> fit the short-pronounceable-mainstream-TLD pattern aggregators target.
+ Data source — <b>Certificate Transparency logs</b> via CertStream. Every TLS cert issued worldwide flows through here, ~1 min after a domain gets registered. This is the closest free public real-time analog to a registrar's check-stream. Rows marked <span class="chip brand">BRAND</span> match one of your watched keywords. Rows marked <span class="chip hot">HOT</span> fit the short-pronounceable-mainstream-TLD pattern aggregators target. <b>Click any row to copy a <code>node register.js <domain></code> command to the clipboard.</b>
</div>
<div class="ctrl">
<input id="filter" type="text" placeholder="filter (substring match)" autocomplete="off" />
<label><input type="checkbox" id="show-hot" checked /> hot</label>
<label><input type="checkbox" id="show-brand" checked /> brand</label>
<label><input type="checkbox" id="autoscroll" checked /> auto-scroll</label>
+ <label><input type="checkbox" id="audio" checked /> audio on brand</label>
+ <span style="color: var(--dim); font-size: 11px;">sort:</span>
+ <div class="sortgrp" id="sortgrp">
+ <button data-sort="time" class="active">time</button>
+ <button data-sort="brand">brand-first</button>
+ <button data-sort="hot">hot-first</button>
+ </div>
<span style="margin-left:auto; color: var(--dim); font-size: 11px;">
<span id="conn">connecting…</span>
</span>
</div>
<main id="feed"><div class="empty">waiting for the firehose…</div></main>
+ <div id="toast" class="toast">copied</div>
<script>
const feed = document.getElementById('feed');
@@ -117,6 +145,9 @@
const showHot = document.getElementById('show-hot');
const showBrand = document.getElementById('show-brand');
const autoscroll = document.getElementById('autoscroll');
+ const audioEl = document.getElementById('audio');
+ const toast = document.getElementById('toast');
+ const sortgrp = document.getElementById('sortgrp');
const stat = {
seen: document.getElementById('stat-seen'),
hot: document.getElementById('stat-hot'),
@@ -127,7 +158,23 @@
let lastSeen = 0;
let lastTs = Date.now();
let startedAt = Date.now();
- let isEmpty = true;
+ const hits = []; // newest-first
+ const MAX_HITS = 500;
+ let sortMode = localStorage.getItem('sniper.sort') || 'time';
+
+ // Persist filter/checkbox state too
+ for (const [el, key, attr] of [
+ [filterEl, 'sniper.filter', 'value'],
+ [showHot, 'sniper.showHot', 'checked'],
+ [showBrand, 'sniper.showBrand', 'checked'],
+ [autoscroll, 'sniper.autoscroll', 'checked'],
+ [audioEl, 'sniper.audio', 'checked'],
+ ]) {
+ const v = localStorage.getItem(key);
+ if (v !== null) el[attr] = attr === 'checked' ? v === '1' : v;
+ el.addEventListener('change', () => localStorage.setItem(key, attr === 'checked' ? (el[attr] ? '1' : '0') : el[attr]));
+ el.addEventListener('input', () => localStorage.setItem(key, attr === 'checked' ? (el[attr] ? '1' : '0') : el[attr]));
+ }
function fmt(ts) {
const d = new Date(ts);
@@ -143,31 +190,145 @@
function shouldShow(rec) {
if (rec.brand && !showBrand.checked) return false;
if (rec.hot && !rec.brand && !showHot.checked) return false;
+ if (!rec.hot && !rec.brand) return false;
const q = filterEl.value.trim().toLowerCase();
if (q && !rec.domain.toLowerCase().includes(q)) return false;
return true;
}
- function addRow(rec) {
- if (isEmpty) { feed.innerHTML = ''; isEmpty = false; }
- if (!shouldShow(rec)) return;
+ function sortedHits() {
+ if (sortMode === 'time') return hits;
+ if (sortMode === 'brand') return [...hits].sort((a, b) => {
+ const ab = a.brand ? 1 : 0, bb = b.brand ? 1 : 0;
+ if (ab !== bb) return bb - ab;
+ return b.ts - a.ts;
+ });
+ if (sortMode === 'hot') return [...hits].sort((a, b) => {
+ const ah = a.hot ? 1 : 0, bh = b.hot ? 1 : 0;
+ if (ah !== bh) return bh - ah;
+ return b.ts - a.ts;
+ });
+ return hits;
+ }
+
+ let toastTimer = null;
+ function showToast(msg) {
+ toast.textContent = msg;
+ toast.classList.add('show');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => toast.classList.remove('show'), 1600);
+ }
+
+ async function copyRegisterCmd(domain) {
+ const cmd = 'node register.js ' + domain;
+ try {
+ await navigator.clipboard.writeText(cmd);
+ showToast('copied: ' + cmd);
+ } catch {
+ // Fallback if clipboard API blocked (insecure context)
+ const ta = document.createElement('textarea');
+ ta.value = cmd; document.body.appendChild(ta); ta.select();
+ try { document.execCommand('copy'); showToast('copied (fallback): ' + cmd); }
+ catch { showToast('copy failed — ' + cmd); }
+ ta.remove();
+ }
+ }
+
+ let audioCtx = null;
+ function ping() {
+ if (!audioEl.checked) return;
+ try {
+ audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
+ const o = audioCtx.createOscillator();
+ const g = audioCtx.createGain();
+ o.type = 'sine'; o.frequency.value = 880;
+ g.gain.value = 0.0001;
+ o.connect(g); g.connect(audioCtx.destination);
+ const now = audioCtx.currentTime;
+ g.gain.exponentialRampToValueAtTime(0.18, now + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
+ o.start(now); o.stop(now + 0.24);
+ } catch {}
+ }
+
+ function buildRow(rec) {
const cls = rec.brand ? 'brand' : (rec.hot ? 'hot' : '');
const tag = rec.brand ? `BRAND · ${rec.brand}` : (rec.hot ? 'HOT' : '');
const row = document.createElement('div');
row.className = 'row ' + cls;
+ row.dataset.domain = rec.domain;
row.innerHTML = `
<span class="t">${fmt(rec.ts)}</span>
<span class="tag">${tag}</span>
<span class="domain">${rec.domain}</span>
<span class="actions">
+ <button data-act="copy">copy reg cmd</button>
<a href="https://${rec.domain}" target="_blank" rel="noopener">visit</a>
<a href="https://crt.sh/?q=${encodeURIComponent(rec.domain)}" target="_blank" rel="noopener">crt.sh</a>
</span>
`;
- feed.prepend(row);
- while (feed.children.length > 500) feed.removeChild(feed.lastChild);
- if (autoscroll.checked) feed.scrollTop = 0;
+ return row;
+ }
+
+ function render() {
+ const list = sortedHits().filter(shouldShow).slice(0, MAX_HITS);
+ if (!list.length) {
+ feed.innerHTML = '<div class="empty">waiting for matches…</div>';
+ return;
+ }
+ feed.innerHTML = '';
+ for (const rec of list) feed.appendChild(buildRow(rec));
+ if (sortMode === 'time' && autoscroll.checked) feed.scrollTop = 0;
+ }
+
+ function onHit(rec) {
+ hits.unshift(rec);
+ while (hits.length > MAX_HITS * 2) hits.pop();
+ if (rec.brand) ping();
+ if (sortMode === 'time') {
+ // Cheap path: just prepend/flash without re-rendering everything
+ if (!shouldShow(rec)) return;
+ if (feed.querySelector('.empty')) feed.innerHTML = '';
+ const row = buildRow(rec);
+ row.classList.add('flash');
+ feed.prepend(row);
+ while (feed.children.length > MAX_HITS) feed.removeChild(feed.lastChild);
+ if (autoscroll.checked) feed.scrollTop = 0;
+ } else {
+ // Re-sort throttled
+ scheduleRender();
+ }
}
+ let renderPending = false;
+ function scheduleRender() {
+ if (renderPending) return;
+ renderPending = true;
+ requestAnimationFrame(() => { renderPending = false; render(); });
+ }
+
+ // Row click — copy reg cmd. Don't hijack clicks on the <a> links.
+ feed.addEventListener('click', (e) => {
+ const row = e.target.closest('.row');
+ if (!row) return;
+ if (e.target.tagName === 'A') return;
+ const dom = row.dataset.domain;
+ if (dom) copyRegisterCmd(dom);
+ });
+
+ // Sort buttons
+ sortgrp.addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-sort]');
+ if (!btn) return;
+ sortMode = btn.dataset.sort;
+ localStorage.setItem('sniper.sort', sortMode);
+ for (const b of sortgrp.querySelectorAll('button')) b.classList.toggle('active', b === btn);
+ render();
+ });
+ for (const b of sortgrp.querySelectorAll('button')) b.classList.toggle('active', b.dataset.sort === sortMode);
+
+ for (const el of [filterEl, showHot, showBrand]) el.addEventListener('input', scheduleRender);
+ for (const el of [filterEl, showHot, showBrand]) el.addEventListener('change', scheduleRender);
+
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(proto + '//' + location.host);
@@ -176,12 +337,12 @@
ws.onerror = () => {};
ws.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
- if (m.type === 'hit') addRow(m);
+ if (m.type === 'hit') onHit(m);
else if (m.type === 'stats') {
const now = Date.now();
const dt = (now - lastTs) / 1000;
const dseen = m.seen - lastSeen;
- stat.rate.textContent = dt > 0 ? Math.round(dseen / dt) : 0;
+ stat.rate.textContent = dt > 0 ? Math.max(0, Math.round(dseen / dt)) : 0;
lastSeen = m.seen; lastTs = now;
stat.seen.textContent = m.seen.toLocaleString();
stat.hot.textContent = m.hot.toLocaleString();
← dc2abc5 step 7: brand-typo-watcher.js — Levenshtein + Punycode brand
·
back to Domain Sniper
·
note: certstream.calidog.io connect-close loop (code 1000, 0 6760b8c →