Files
DomGod/app/static/index.html
Malin 7acff12242 feat: persistent DuckDB index, new filters, pagination fix, enrich UX
- Build /data/domains.duckdb on first run (tld+parts columns + ART index)
  → TLD filter goes from ~60s full scan to <100ms index lookup
  → System still works (slower) while index builds in background
- New /api/domains params: alpha_only, no_sld, keyword
  → alpha_only: domains with only letters (no hyphens/numbers)
  → no_sld: parts=2, excludes com.es / net.es patterns
  → keyword: LIKE '%term%' niche search
- /api/domains and /api/enriched now return total count for pagination
- Pagination: shows total matches, page X of Y, Next disabled at last page
- Enrich button: toast notifications instead of alert(), error handling
- Select all on page button, clear selection button
- Stats/TLD breakdown cached after first load (no repeat full scan)
- Header shows index build status (building → ready)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:00:08 +02:00

538 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DomGod — Domain Intelligence</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--bg:#0f1117; --surface:#1a1d27; --surface2:#222638; --border:#2e3250;
--accent:#6c63ff; --accent2:#00d4aa; --danger:#ff4f6d; --warn:#ffb347;
--text:#e8eaf0; --muted:#8891b0; --hot:#ff4f6d; --warm:#ffb347; --cold:#6c7aff;
--r:8px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;font-size:14px}
a{color:var(--accent2);text-decoration:none}
header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;gap:10px;position:sticky;top:0;z-index:100}
header h1{font-size:20px;font-weight:800;letter-spacing:-1px}
header h1 span{color:var(--accent)}
.badge{background:var(--surface2);border:1px solid var(--border);color:var(--muted);font-size:11px;padding:2px 8px;border-radius:99px}
.index-pill{font-size:11px;padding:3px 10px;border-radius:99px;font-weight:600}
.index-building{background:#ffb34722;color:var(--warn)}
.index-ready{background:#00d4aa22;color:var(--accent2)}
main{padding:16px 20px;display:flex;flex-direction:column;gap:16px;max-width:1440px;margin:0 auto;width:100%}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px}
.card-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:12px}
/* Stats */
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.stat{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:12px 14px}
.stat .lbl{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}
.stat .val{font-size:24px;font-weight:800;margin-top:2px}
.stat .sub{font-size:11px;color:var(--muted);margin-top:1px}
.c-accent{color:var(--accent2)} .c-hot{color:var(--hot)} .c-warn{color:var(--warn)} .c-muted{color:var(--muted)}
/* Tabs */
.tabs{display:flex;gap:2px;padding:0 2px;border-bottom:1px solid var(--border)}
.tab{padding:8px 16px;cursor:pointer;font-size:13px;font-weight:500;color:var(--muted);border-radius:6px 6px 0 0;border:1px solid transparent;border-bottom:none;user-select:none}
.tab.active{background:var(--surface);color:var(--text);border-color:var(--border)}
.tab:hover:not(.active){color:var(--text)}
/* Filters */
.filters{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-bottom:12px}
.field{display:flex;flex-direction:column;gap:3px}
.field label{font-size:11px;color:var(--muted);text-transform:uppercase;font-weight:600}
input[type=text],input[type=number],select{
background:var(--surface2);border:1px solid var(--border);color:var(--text);
padding:6px 10px;border-radius:6px;font-size:13px;outline:none
}
input[type=text]:focus,select:focus{border-color:var(--accent)}
input[type=range]{accent-color:var(--accent);width:110px;cursor:pointer}
.toggle{display:flex;align-items:center;gap:6px;cursor:pointer;padding:6px 0}
.toggle input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
/* Buttons */
.btn{padding:6px 14px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:opacity .15s;white-space:nowrap}
.btn:hover:not(:disabled){opacity:.85} .btn:disabled{opacity:.35;cursor:not-allowed}
.btn-primary{background:var(--accent);color:#fff}
.btn-success{background:var(--accent2);color:#111}
.btn-danger{background:var(--danger);color:#fff}
.btn-warn{background:var(--warn);color:#111}
.btn-ghost{background:var(--surface2);color:var(--text);border:1px solid var(--border)}
.btn-sm{padding:4px 10px;font-size:12px}
/* Table */
.table-wrap{overflow-x:auto;border-radius:var(--r);border:1px solid var(--border)}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;padding:8px 10px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;background:var(--surface2);border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surface2)}
.pill{display:inline-block;padding:2px 7px;border-radius:99px;font-size:11px;font-weight:600}
.p-green{background:#00d4aa22;color:var(--accent2)} .p-red{background:#ff4f6d22;color:var(--danger)}
.p-grey{background:#ffffff11;color:var(--muted)} .p-cms{background:#6c63ff22;color:var(--accent)}
/* Score badge */
.score{display:inline-block;padding:2px 7px;border-radius:6px;font-weight:800;font-size:12px;min-width:32px;text-align:center}
/* Pagination */
.pager{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
.pager .info{font-size:12px;color:var(--muted)}
/* Progress */
.prog-wrap{background:var(--surface2);border-radius:99px;height:10px;overflow:hidden;margin:8px 0}
.prog-bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));border-radius:99px;transition:width .5s}
/* Pipeline */
.pipeline{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
.pipe-col{background:var(--surface2);border-radius:var(--r);border:1px solid var(--border);padding:14px;display:flex;flex-direction:column;gap:8px}
.pipe-col h3{font-size:15px;font-weight:700}
.pipe-col .count{font-size:30px;font-weight:900;line-height:1}
.samples{display:flex;flex-direction:column;gap:4px}
.sample{font-size:12px;color:var(--muted);padding:4px 8px;background:var(--surface);border-radius:6px;display:flex;justify-content:space-between;align-items:center;gap:8px}
/* Enrich stats */
.eq-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin-bottom:14px}
.eq-stat{background:var(--surface2);border-radius:var(--r);padding:10px;text-align:center}
.eq-stat .v{font-size:22px;font-weight:800}
.eq-stat .l{font-size:11px;color:var(--muted);margin-top:2px}
/* Toast */
.toast{position:fixed;bottom:24px;right:24px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:12px 18px;font-size:13px;font-weight:600;z-index:9999;display:flex;align-items:center;gap:10px;box-shadow:0 4px 24px #0008;transition:opacity .3s}
.toast.hidden{opacity:0;pointer-events:none}
.toast.success{border-color:var(--accent2);color:var(--accent2)}
.toast.error{border-color:var(--danger);color:var(--danger)}
/* Chart */
.chart-wrap{height:280px}
@media(max-width:700px){.pipeline{grid-template-columns:1fr}.stats-grid{grid-template-columns:1fr 1fr}}
</style>
</head>
<body x-data="app()" x-init="init()">
<!-- Toast -->
<div class="toast" :class="[toast.type, {hidden: !toast.show}]" x-text="toast.msg"></div>
<header>
<h1>Dom<span>God</span></h1>
<span class="badge" x-text="stats.total_domains ? stats.total_domains.toLocaleString() + ' domains' : 'Loading…'"></span>
<span class="index-pill"
:class="indexSt.ready ? 'index-ready' : 'index-building'"
x-text="indexSt.ready ? '⚡ Index ready' : '⏳ Building index…'">
</span>
<span style="flex:1"></span>
<span style="font-size:12px;color:var(--muted)" x-text="stats.enriched ? stats.enriched.toLocaleString() + ' enriched' : ''"></span>
</header>
<main>
<!-- Stats bar -->
<div class="card">
<div class="card-title">Overview</div>
<div class="stats-grid">
<div class="stat"><div class="lbl">Total Domains</div><div class="val c-accent" x-text="stats.total_domains?.toLocaleString() ?? '—'"></div><div class="sub">in dataset</div></div>
<div class="stat"><div class="lbl">Enriched</div><div class="val c-accent" x-text="stats.enriched?.toLocaleString() ?? '0'"></div><div class="sub" x-text="stats.total_domains ? ((stats.enriched/stats.total_domains*100).toFixed(3)+'%') : ''"></div></div>
<div class="stat"><div class="lbl">Hot Leads</div><div class="val c-hot" x-text="stats.hot_leads?.toLocaleString() ?? '0'"></div><div class="sub">score ≥ 60</div></div>
<div class="stat"><div class="lbl">Queue Pending</div><div class="val c-warn" x-text="stats.queue?.pending?.toLocaleString() ?? '0'"></div><div class="sub" x-text="(stats.queue?.running ?? 0) + ' running'"></div></div>
<div class="stat"><div class="lbl">Done / Failed</div><div class="val c-muted" x-text="stats.queue?.done?.toLocaleString() ?? '0'"></div><div class="sub" x-text="(stats.queue?.failed ?? 0) + ' failed'"></div></div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<div class="tab" :class="{active:tab==='browse'}" @click="tab='browse'">Browse & Filter</div>
<div class="tab" :class="{active:tab==='enrich'}" @click="tab='enrich'; loadQueue()">Enrichment Queue</div>
<div class="tab" :class="{active:tab==='pipeline'}" @click="tab='pipeline'; loadPipeline()">Lead Pipeline</div>
<div class="tab" :class="{active:tab==='chart'}" @click="tab='chart'; renderChart()">TLD Chart</div>
</div>
<!-- ② Browse & Filter -->
<div class="card" x-show="tab==='browse'">
<div class="filters">
<div class="field">
<label>TLD</label>
<input type="text" x-model="f.tld" placeholder="es, com…" style="width:90px" @keydown.enter="search()">
</div>
<div class="field">
<label>Keyword</label>
<input type="text" x-model="f.keyword" placeholder="hotel, dental…" style="width:130px" @keydown.enter="search()">
</div>
<div class="field">
<label>Min Score: <strong x-text="f.min_score"></strong></label>
<input type="range" x-model="f.min_score" min="0" max="100" step="5">
</div>
<div class="field">
<label>CMS</label>
<select x-model="f.cms" style="width:120px">
<option value="">Any CMS</option>
<option>wordpress</option><option>joomla</option><option>drupal</option>
<option>wix</option><option>squarespace</option><option>shopify</option>
<option>prestashop</option><option>magento</option><option>typo3</option><option>opencart</option>
</select>
</div>
<div class="field">
<label>Per page</label>
<select x-model="f.limit" style="width:80px">
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</div>
<label class="toggle field"><label>Live only</label><input type="checkbox" x-model="f.live_only"></label>
<label class="toggle field">
<label>Alpha only</label>
<input type="checkbox" x-model="f.alpha_only">
<span style="font-size:11px;color:var(--muted)">(no hyphens/numbers)</span>
</label>
<label class="toggle field">
<label>No SLD</label>
<input type="checkbox" x-model="f.no_sld">
<span style="font-size:11px;color:var(--muted)">(skip com.es etc)</span>
</label>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" @click="search()">Search</button>
<button class="btn btn-ghost" @click="resetFilters()">Reset</button>
<button class="btn btn-success" @click="enqueueSelected()" :disabled="selected.length===0">
+ Enrich (<span x-text="selected.length"></span>) selected
</button>
<button class="btn btn-ghost btn-sm" @click="selectAll()" x-show="domains.length > 0">Select all on page</button>
<button class="btn btn-ghost btn-sm" @click="selected=[]" x-show="selected.length > 0">Clear selection</button>
<span class="info" style="font-size:12px;color:var(--muted)" x-show="searchTotal > 0">
<strong x-text="searchTotal.toLocaleString()"></strong> matches
</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:32px"></th>
<th>Domain</th>
<th>Score</th>
<th>CMS</th>
<th>SSL days</th>
<th>Country</th>
<th>Live</th>
<th>Server</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<template x-if="loading">
<tr><td colspan="9" style="text-align:center;padding:28px;color:var(--muted)">Searching… (first query without index may take 30-60s)</td></tr>
</template>
<template x-if="!loading && domains.length === 0">
<tr><td colspan="9" style="text-align:center;padding:28px;color:var(--muted)">No results — enter a TLD or keyword and click Search</td></tr>
</template>
<template x-for="row in domains" :key="row.domain">
<tr>
<td><input type="checkbox" :value="row.domain" x-model="selected"></td>
<td><a :href="'http://'+row.domain" target="_blank" rel="noopener" x-text="row.domain"></a></td>
<td>
<template x-if="row.score != null">
<span class="score" :style="scoreBg(row.score)" x-text="row.score"></span>
</template>
<template x-if="row.score == null">
<span class="pill p-grey"></span>
</template>
</td>
<td>
<span x-show="row.cms" class="pill p-cms" x-text="row.cms"></span>
<span x-show="!row.cms" class="pill p-grey"></span>
</td>
<td x-text="row.ssl_expiry_days ?? '—'"></td>
<td x-text="row.ip_country ?? '—'"></td>
<td><span class="pill" :class="row.is_live ? 'p-green' : 'p-grey'" x-text="row.is_live ? 'Yes' : '—'"></span></td>
<td style="font-size:11px;color:var(--muted);max-width:120px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis" x-text="row.server ?? '—'"></td>
<td x-text="row.status_code ?? '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pager">
<button class="btn btn-ghost btn-sm" @click="goPage(page-1)" :disabled="page<=1 || loading">← Prev</button>
<span class="info">Page <strong x-text="page"></strong>
<template x-if="searchTotal > 0">
<span x-text="' of ' + Math.ceil(searchTotal / Number(f.limit))"></span>
</template>
</span>
<button class="btn btn-ghost btn-sm" @click="goPage(page+1)" :disabled="loading || domains.length < Number(f.limit)">Next →</button>
<span class="info" x-show="searchTotal > 0" x-text="searchTotal.toLocaleString() + ' total results'"></span>
</div>
</div>
<!-- ③ Enrichment Queue -->
<div class="card" x-show="tab==='enrich'">
<div class="eq-grid">
<div class="eq-stat"><div class="v c-warn" x-text="qst.pending ?? '—'"></div><div class="l">Pending</div></div>
<div class="eq-stat"><div class="v c-accent" x-text="qst.running ?? '—'"></div><div class="l">Running</div></div>
<div class="eq-stat"><div class="v c-accent" x-text="qst.done ?? '—'"></div><div class="l">Done</div></div>
<div class="eq-stat"><div class="v c-hot" x-text="qst.failed ?? '—'"></div><div class="l">Failed</div></div>
<div class="eq-stat"><div class="v c-muted" x-text="qst.eta_seconds ? Math.ceil(qst.eta_seconds/60)+'m' : '—'"></div><div class="l">ETA</div></div>
</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;flex-wrap:wrap;gap:8px">
<span style="font-size:12px;color:var(--muted)" x-text="qLabel()"></span>
<div style="display:flex;gap:8px">
<button class="btn btn-success" x-show="!qst.worker_running" @click="startEnrich()">▶ Start</button>
<button class="btn btn-warn" x-show="qst.worker_running" @click="pauseEnrich()">⏸ Pause</button>
<button class="btn btn-ghost" @click="retryFailed()">↺ Retry Failed</button>
<button class="btn btn-ghost" @click="runScoring()">★ Score All</button>
</div>
</div>
<div class="prog-wrap"><div class="prog-bar" :style="'width:'+qPct()+'%'"></div></div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" x-text="qPct().toFixed(1)+'% complete'"></div>
</div>
<div style="margin-top:20px">
<div class="card-title">Queue custom domains</div>
<div style="display:flex;gap:8px;align-items:flex-end">
<div style="flex:1">
<textarea x-model="customDomains" placeholder="example.com&#10;another.es"
style="width:100%;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;min-height:80px;font-size:12px;resize:vertical"></textarea>
</div>
<button class="btn btn-primary" @click="enqueueCustom()">Queue</button>
</div>
</div>
</div>
<!-- ④ Lead Pipeline -->
<div class="card" x-show="tab==='pipeline'">
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
<button class="btn btn-ghost btn-sm" @click="loadPipeline()">↻ Refresh</button>
</div>
<div class="pipeline">
<div class="pipe-col" style="border-top:3px solid var(--hot)">
<h3>🔥 Hot</h3>
<div style="font-size:12px;color:var(--muted)">Score 80100</div>
<div class="count c-hot" x-text="pipeline.hot.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.hot.samples" :key="d.domain">
<div class="sample"><a :href="'http://'+d.domain" target="_blank" x-text="d.domain"></a><span class="score" :style="scoreBg(d.score)" x-text="d.score"></span></div>
</template>
</div>
<button class="btn btn-danger btn-sm" style="margin-top:auto" @click="exportTier('hot')">⬇ Export Hot CSV</button>
</div>
<div class="pipe-col" style="border-top:3px solid var(--warm)">
<h3>♨️ Warm</h3>
<div style="font-size:12px;color:var(--muted)">Score 5079</div>
<div class="count c-warn" x-text="pipeline.warm.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.warm.samples" :key="d.domain">
<div class="sample"><a :href="'http://'+d.domain" target="_blank" x-text="d.domain"></a><span class="score" :style="scoreBg(d.score)" x-text="d.score"></span></div>
</template>
</div>
<button class="btn btn-warn btn-sm" style="margin-top:auto" @click="exportTier('warm')">⬇ Export Warm CSV</button>
</div>
<div class="pipe-col" style="border-top:3px solid var(--cold)">
<h3>🧊 Cold</h3>
<div style="font-size:12px;color:var(--muted)">Score &lt; 50</div>
<div class="count" style="color:var(--cold)" x-text="pipeline.cold.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.cold.samples" :key="d.domain">
<div class="sample"><a :href="'http://'+d.domain" target="_blank" x-text="d.domain"></a><span class="score" :style="scoreBg(d.score)" x-text="d.score"></span></div>
</template>
</div>
<button class="btn btn-ghost btn-sm" style="margin-top:auto" @click="exportTier('cold')">⬇ Export Cold CSV</button>
</div>
</div>
</div>
<!-- ⑤ TLD Chart -->
<div class="card" x-show="tab==='chart'">
<div class="card-title">Top 20 TLDs in dataset</div>
<div class="chart-wrap"><canvas id="tldChart"></canvas></div>
</div>
</main>
<script>
function app() {
return {
tab: 'browse',
stats: {},
indexSt: { ready: false, building: false, total: 0 },
domains: [],
selected: [],
loading: false,
page: 1,
searchTotal: 0,
f: { tld:'', keyword:'', min_score:0, cms:'', live_only:false, alpha_only:false, no_sld:false, limit:'100' },
qst: {},
customDomains: '',
pipeline: { hot:{count:0,samples:[]}, warm:{count:0,samples:[]}, cold:{count:0,samples:[]} },
toast: { show:false, msg:'', type:'success' },
_chart: null,
_poll: null,
_toastTimer: null,
async init() {
await this.loadStats();
this._poll = setInterval(() => {
this.loadStats();
this.pollIndex();
if (this.tab === 'enrich') this.loadQueue();
if (this.tab === 'pipeline') this.loadPipeline();
}, 3000);
},
async loadStats() {
try { this.stats = await fetch('/api/stats').then(r=>r.json()); } catch(e){}
},
async pollIndex() {
try { this.indexSt = await fetch('/api/index/status').then(r=>r.json()); } catch(e){}
},
async search() {
this.page = 1;
await this._fetchDomains();
},
async goPage(p) {
if (p < 1) return;
this.page = p;
await this._fetchDomains();
},
async _fetchDomains() {
this.loading = true;
const p = new URLSearchParams({ page: this.page, limit: this.f.limit });
if (this.f.tld) p.set('tld', this.f.tld.trim());
if (this.f.keyword) p.set('keyword', this.f.keyword.trim());
if (this.f.live_only) p.set('live_only', 'true');
if (this.f.alpha_only) p.set('alpha_only', 'true');
if (this.f.no_sld) p.set('no_sld', 'true');
try {
const data = await fetch('/api/domains?' + p).then(r => r.json());
this.searchTotal = data.total ?? 0;
// Client-side score/cms filter (only applies to already-enriched rows)
this.domains = data.results.filter(row => {
if (this.f.min_score > 0 && row.score != null && row.score < Number(this.f.min_score)) return false;
if (this.f.cms && row.cms !== this.f.cms) return false;
return true;
});
} catch(e) {
this.domains = [];
this.notify('Search failed: ' + e.message, 'error');
}
this.loading = false;
},
selectAll() { this.selected = this.domains.map(d => d.domain); },
resetFilters() { this.f = { tld:'', keyword:'', min_score:0, cms:'', live_only:false, alpha_only:false, no_sld:false, limit:'100' }; },
async enqueueSelected() {
if (!this.selected.length) return;
try {
const r = await fetch('/api/enrich/batch', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ domains: this.selected }),
});
const data = await r.json();
if (r.ok) {
this.notify(`Queued ${data.queued} domain(s) for enrichment`, 'success');
this.selected = [];
} else {
this.notify('Error: ' + (data.error || r.statusText), 'error');
}
} catch(e) { this.notify('Request failed: ' + e.message, 'error'); }
},
async enqueueCustom() {
const domains = this.customDomains.split('\n').map(d=>d.trim()).filter(Boolean);
if (!domains.length) return;
const r = await fetch('/api/enrich/batch', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ domains }),
});
const data = await r.json();
if (r.ok) { this.notify(`Queued ${data.queued} domains`, 'success'); this.customDomains = ''; }
else { this.notify('Error: ' + data.error, 'error'); }
await this.loadQueue();
},
async loadQueue() {
try { this.qst = await fetch('/api/enrich/status').then(r=>r.json()); } catch(e){}
},
async startEnrich() { await fetch('/api/enrich/resume',{method:'POST'}); this.notify('Worker started','success'); await this.loadQueue(); },
async pauseEnrich() { await fetch('/api/enrich/pause',{method:'POST'}); this.notify('Worker paused','success'); await this.loadQueue(); },
async retryFailed() { await fetch('/api/enrich/retry',{method:'POST'}); this.notify('Retrying failed jobs','success'); await this.loadQueue(); },
async runScoring() { const r = await fetch('/api/score/run',{method:'POST'}); const d = await r.json(); this.notify(`Scored ${d.scored} domains`,'success'); },
qPct() { const q=this.qst; if(!q||!q.total) return 0; return (q.done/q.total)*100; },
qLabel() { const q=this.qst; return `${q.done??0} done · ${q.pending??0} pending · ${q.running??0} running · ${q.failed??0} failed`; },
async loadPipeline() {
const [hot, warm, cold, hotC, warmC, coldC] = await Promise.all([
fetch('/api/enriched?min_score=80&limit=5').then(r=>r.json()),
fetch('/api/enriched?min_score=50&limit=6').then(r=>r.json()),
fetch('/api/enriched?min_score=0&limit=6').then(r=>r.json()),
fetch('/api/enriched?min_score=80&limit=1000').then(r=>r.json()),
fetch('/api/enriched?min_score=50&limit=1000').then(r=>r.json()),
fetch('/api/enriched?min_score=0&limit=1000').then(r=>r.json()),
]);
this.pipeline.hot = { count: hotC.total ?? hot.results.length, samples: hot.results.slice(0,5) };
this.pipeline.warm = { count: (warmC.total ?? warm.results.length) - (hotC.total ?? 0), samples: warm.results.filter(d=>d.score<80).slice(0,5) };
this.pipeline.cold = { count: (coldC.total ?? cold.results.length) - (warmC.total ?? 0), samples: cold.results.filter(d=>d.score<50).slice(0,5) };
},
exportTier(tier) { window.location = `/api/export?tier=${tier}`; },
scoreBg(s) {
if (s == null) return 'background:#333;color:#aaa';
if (s >= 80) return 'background:#ff4f6d33;color:#ff4f6d';
if (s >= 50) return 'background:#ffb34733;color:#ffb347';
return 'background:#6c7aff33;color:#6c7aff';
},
notify(msg, type='success') {
clearTimeout(this._toastTimer);
this.toast = { show:true, msg, type };
this._toastTimer = setTimeout(() => { this.toast.show = false; }, 3500);
},
async renderChart() {
await this.$nextTick();
const canvas = document.getElementById('tldChart');
if (!canvas) return;
if (this._chart) { this._chart.destroy(); this._chart = null; }
const tlds = this.stats.tld_breakdown || [];
this._chart = new Chart(canvas, {
type:'bar',
data:{
labels: tlds.map(t=>'.'+t.tld),
datasets:[{ label:'Domains', data:tlds.map(t=>t.count),
backgroundColor:'rgba(108,99,255,0.7)', borderColor:'rgba(108,99,255,1)',
borderWidth:1, borderRadius:4 }]
},
options:{
responsive:true, maintainAspectRatio:false,
plugins:{ legend:{display:false} },
scales:{
x:{ticks:{color:'#8891b0'},grid:{color:'#2e3250'}},
y:{ticks:{color:'#8891b0'},grid:{color:'#2e3250'}},
}
}
});
},
}
}
</script>
</body>
</html>