Files
DomGod/app/static/index.html

538 lines
26 KiB
HTML
Raw Normal View History

<!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>