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>
This commit is contained in:
2026-04-13 17:00:08 +02:00
parent 2db95cc727
commit 7acff12242
3 changed files with 662 additions and 641 deletions

330
app/db.py
View File

@@ -1,10 +1,15 @@
import os import os
import asyncio
import logging
import aiosqlite import aiosqlite
import duckdb import duckdb
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__)
DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
PARQUET_PATH = DATA_DIR / "domains.parquet" PARQUET_PATH = DATA_DIR / "domains.parquet"
DUCKDB_PATH = DATA_DIR / "domains.duckdb"
SQLITE_PATH = DATA_DIR / "enrichment.db" SQLITE_PATH = DATA_DIR / "enrichment.db"
SCHEMA = """ SCHEMA = """
@@ -23,7 +28,6 @@ CREATE TABLE IF NOT EXISTS enriched_domains (
error TEXT, error TEXT,
score INTEGER DEFAULT 0 score INTEGER DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS job_queue ( CREATE TABLE IF NOT EXISTS job_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT UNIQUE NOT NULL, domain TEXT UNIQUE NOT NULL,
@@ -33,7 +37,6 @@ CREATE TABLE IF NOT EXISTS job_queue (
completed_at TEXT, completed_at TEXT,
error TEXT error TEXT
); );
CREATE TABLE IF NOT EXISTS scores ( CREATE TABLE IF NOT EXISTS scores (
domain TEXT PRIMARY KEY, domain TEXT PRIMARY KEY,
score INTEGER NOT NULL, score INTEGER NOT NULL,
@@ -41,6 +44,15 @@ CREATE TABLE IF NOT EXISTS scores (
); );
""" """
# Index build state
_index_ready = False
_index_building = False
_index_total = 0
# Cached stats (TLD breakdown is expensive — compute once)
_tld_cache: list = []
_total_cache: int = 0
async def init_db(): async def init_db():
async with aiosqlite.connect(SQLITE_PATH) as db: async with aiosqlite.connect(SQLITE_PATH) as db:
@@ -48,142 +60,219 @@ async def init_db():
await db.commit() await db.commit()
async def get_db(): # ── DuckDB persistent index ──────────────────────────────────────────────────
return await aiosqlite.connect(SQLITE_PATH)
def _build_index_sync():
def duckdb_query(sql: str, params=None): global _index_ready, _index_building, _index_total
conn = duckdb.connect(database=":memory:", read_only=False) _index_building = True
conn.execute(f"SET threads=4") try:
if params: conn = duckdb.connect(str(DUCKDB_PATH))
result = conn.execute(sql, params).fetchall()
else:
result = conn.execute(sql).fetchall()
conn.close()
return result
def duckdb_query_df(sql: str, params=None):
conn = duckdb.connect(database=":memory:", read_only=False)
conn.execute("SET threads=4") conn.execute("SET threads=4")
if params: conn.execute("SET memory_limit='2GB'")
result = conn.execute(sql, params).df()
else: # Check if already built
result = conn.execute(sql).df() try:
n = conn.execute("SELECT COUNT(*) FROM domains").fetchone()[0]
if n > 0:
_index_total = n
_index_ready = True
_index_building = False
logger.info("DuckDB index already ready (%d rows)", n)
conn.close() conn.close()
return result return
except Exception:
pass
logger.info("Building DuckDB index from parquet (one-time ~2-3 min)...")
conn.execute("""
CREATE OR REPLACE TABLE domains AS
SELECT
domain,
lower(regexp_extract(domain, '\\.([^.]+)$', 1)) AS tld,
len(string_split(domain, '.')) AS parts
FROM read_parquet(?)
""", [str(PARQUET_PATH)])
conn.execute("CREATE INDEX IF NOT EXISTS idx_tld ON domains(tld)")
_index_total = conn.execute("SELECT COUNT(*) FROM domains").fetchone()[0]
conn.close()
_index_ready = True
logger.info("DuckDB index built: %d rows", _index_total)
except Exception as e:
logger.error("DuckDB index build failed: %s", e)
finally:
_index_building = False
async def build_duckdb_index():
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _build_index_sync)
def index_status() -> dict:
return {
"ready": _index_ready,
"building": _index_building,
"total": _index_total,
}
# ── Domain queries ───────────────────────────────────────────────────────────
def _domains_sync(tld, page, limit, alpha_only, no_sld, keyword):
conditions = []
params_count = []
params_data = []
if _index_ready:
source = "domains"
def _add(clause, val=None):
conditions.append(clause)
if val is not None:
params_count.append(val)
params_data.append(val)
else:
source = f"read_parquet('{PARQUET_PATH}')"
def _add(clause, val=None):
conditions.append(clause)
if val is not None:
params_count.append(val)
params_data.append(val)
if tld:
if _index_ready:
_add("tld = ?", tld.lower().lstrip("."))
else:
_add("lower(regexp_extract(domain, '\\.([^.]+)$', 1)) = ?", tld.lower().lstrip("."))
if no_sld:
if _index_ready:
_add("parts = 2")
else:
_add("len(string_split(domain, '.')) = 2")
if alpha_only:
_add("NOT regexp_matches(domain, '[^a-zA-Z.]')")
if keyword:
_add("domain LIKE ?", f"%{keyword.lower()}%")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
offset = (page - 1) * limit
if _index_ready:
conn = duckdb.connect(str(DUCKDB_PATH), read_only=True)
else:
conn = duckdb.connect(":memory:")
conn.execute("SET threads=4")
total = conn.execute(f"SELECT COUNT(*) FROM {source} {where}", params_count).fetchone()[0]
rows = conn.execute(
f"SELECT domain FROM {source} {where} LIMIT {limit} OFFSET {offset}", params_data
).fetchall()
conn.close()
return total, [r[0] for r in rows]
async def get_domains(tld=None, page=1, limit=100, alpha_only=False, no_sld=False, keyword=None, live_only=False):
loop = asyncio.get_event_loop()
total, domain_list = await loop.run_in_executor(
None, _domains_sync, tld, page, limit, alpha_only, no_sld, keyword
)
if not domain_list:
return total, []
placeholders = ",".join("?" * len(domain_list))
async with aiosqlite.connect(SQLITE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
f"SELECT * FROM enriched_domains WHERE domain IN ({placeholders})",
domain_list,
) as cur:
enriched_map = {r["domain"]: dict(r) async for r in cur}
results = []
for d in domain_list:
row = enriched_map.get(d, {"domain": d})
if live_only and not row.get("is_live"):
continue
results.append(row)
return total, results
# ── Stats ────────────────────────────────────────────────────────────────────
def _tld_stats_sync() -> tuple[int, list]:
if _index_ready:
conn = duckdb.connect(str(DUCKDB_PATH), read_only=True)
total = conn.execute("SELECT COUNT(*) FROM domains").fetchone()[0]
rows = conn.execute("""
SELECT tld, COUNT(*) AS cnt FROM domains
WHERE tld != ''
GROUP BY tld ORDER BY cnt DESC LIMIT 20
""").fetchall()
conn.close()
else:
p = str(PARQUET_PATH)
conn = duckdb.connect(":memory:")
conn.execute("SET threads=4")
total = conn.execute(f"SELECT COUNT(*) FROM read_parquet('{p}')").fetchone()[0]
rows = conn.execute(f"""
SELECT lower(regexp_extract(domain, '\\.([^.]+)$', 1)) AS tld, COUNT(*) AS cnt
FROM read_parquet('{p}')
GROUP BY tld ORDER BY cnt DESC LIMIT 20
""").fetchall()
conn.close()
return total, [{"tld": r[0], "count": r[1]} for r in rows]
async def get_stats(): async def get_stats():
parquet = str(PARQUET_PATH) global _tld_cache, _total_cache
# Total count + TLD breakdown via DuckDB pushdown # Compute TLD breakdown once and cache it
total = duckdb_query(f"SELECT COUNT(*) FROM read_parquet('{parquet}')")[0][0] if not _tld_cache:
loop = asyncio.get_event_loop()
tld_rows = duckdb_query(f""" _total_cache, _tld_cache = await loop.run_in_executor(None, _tld_stats_sync)
SELECT
regexp_extract(domain, '\\.([a-zA-Z0-9]+)$', 1) AS tld,
COUNT(*) AS cnt
FROM read_parquet('{parquet}')
GROUP BY tld
ORDER BY cnt DESC
LIMIT 20
""")
async with aiosqlite.connect(SQLITE_PATH) as db: async with aiosqlite.connect(SQLITE_PATH) as db:
async with db.execute("SELECT COUNT(*) FROM enriched_domains") as cur: async with db.execute("SELECT COUNT(*) FROM enriched_domains") as cur:
enriched = (await cur.fetchone())[0] enriched = (await cur.fetchone())[0]
threshold = int(os.getenv("SCORE_THRESHOLD", "60")) threshold = int(os.getenv("SCORE_THRESHOLD", "60"))
async with db.execute( async with db.execute("SELECT COUNT(*) FROM enriched_domains WHERE score >= ?", (threshold,)) as cur:
"SELECT COUNT(*) FROM enriched_domains WHERE score >= ?", (threshold,)
) as cur:
hot_leads = (await cur.fetchone())[0] hot_leads = (await cur.fetchone())[0]
async with db.execute( async with db.execute("SELECT status, COUNT(*) FROM job_queue GROUP BY status") as cur:
"SELECT COUNT(*) FROM job_queue WHERE status='pending'" q = {r[0]: r[1] async for r in cur}
) as cur:
queue_pending = (await cur.fetchone())[0]
async with db.execute(
"SELECT COUNT(*) FROM job_queue WHERE status='running'"
) as cur:
queue_running = (await cur.fetchone())[0]
async with db.execute(
"SELECT COUNT(*) FROM job_queue WHERE status='done'"
) as cur:
queue_done = (await cur.fetchone())[0]
async with db.execute(
"SELECT COUNT(*) FROM job_queue WHERE status='failed'"
) as cur:
queue_failed = (await cur.fetchone())[0]
return { return {
"total_domains": total, "total_domains": _total_cache,
"enriched": enriched, "enriched": enriched,
"hot_leads": hot_leads, "hot_leads": hot_leads,
"tld_breakdown": [{"tld": r[0], "count": r[1]} for r in tld_rows], "tld_breakdown": _tld_cache,
"index_status": index_status(),
"queue": { "queue": {
"pending": queue_pending, "pending": q.get("pending", 0),
"running": queue_running, "running": q.get("running", 0),
"done": queue_done, "done": q.get("done", 0),
"failed": queue_failed, "failed": q.get("failed", 0),
}, },
} }
async def get_domains(tld=None, page=1, limit=100, live_only=False): # ── Enrichment helpers ───────────────────────────────────────────────────────
parquet = str(PARQUET_PATH)
conditions = []
params = []
if tld:
conditions.append(f"regexp_extract(domain, '\\.([a-zA-Z0-9]+)$', 1) = '{tld}'")
if live_only:
# Join with enriched_domains to check is_live
pass
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
offset = (page - 1) * limit
sql = f"""
SELECT domain
FROM read_parquet('{parquet}')
{where}
LIMIT {limit} OFFSET {offset}
"""
rows = duckdb_query(sql)
domains = [r[0] for r in rows]
# Merge enrichment data from SQLite
if domains:
placeholders = ",".join("?" * len(domains))
async with aiosqlite.connect(SQLITE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
f"SELECT * FROM enriched_domains WHERE domain IN ({placeholders})",
domains,
) as cur:
enriched = {r["domain"]: dict(r) async for r in cur}
result = []
for d in domains:
if d in enriched:
result.append(enriched[d])
else:
result.append({"domain": d})
return result
return []
async def get_enriched(min_score=0, cms=None, country=None, page=1, limit=100): async def get_enriched(min_score=0, cms=None, country=None, page=1, limit=100):
offset = (page - 1) * limit offset = (page - 1) * limit
conditions = ["score >= ?"] conditions = ["score >= ?"]
params = [min_score] params: list = [min_score]
if cms: if cms:
conditions.append("cms = ?") conditions.append("cms = ?")
params.append(cms) params.append(cms)
if country: if country:
conditions.append("ip_country = ?") conditions.append("ip_country = ?")
params.append(country) params.append(country)
where = "WHERE " + " AND ".join(conditions) where = "WHERE " + " AND ".join(conditions)
async with aiosqlite.connect(SQLITE_PATH) as db: async with aiosqlite.connect(SQLITE_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
@@ -192,7 +281,11 @@ async def get_enriched(min_score=0, cms=None, country=None, page=1, limit=100):
params + [limit, offset], params + [limit, offset],
) as cur: ) as cur:
rows = [dict(r) async for r in cur] rows = [dict(r) async for r in cur]
return rows async with db.execute(
f"SELECT COUNT(*) FROM enriched_domains {where}", params
) as cur:
total = (await cur.fetchone())[0]
return total, rows
async def queue_domains(domains: list[str]): async def queue_domains(domains: list[str]):
@@ -206,26 +299,13 @@ async def queue_domains(domains: list[str]):
async def get_queue_status(): async def get_queue_status():
async with aiosqlite.connect(SQLITE_PATH) as db: async with aiosqlite.connect(SQLITE_PATH) as db:
async with db.execute( async with db.execute("SELECT status, COUNT(*) FROM job_queue GROUP BY status") as cur:
"SELECT status, COUNT(*) FROM job_queue GROUP BY status"
) as cur:
rows = {r[0]: r[1] async for r in cur} rows = {r[0]: r[1] async for r in cur}
total = sum(rows.values())
done = rows.get("done", 0)
pending = rows.get("pending", 0) pending = rows.get("pending", 0)
running = rows.get("running", 0) running = rows.get("running", 0)
done = rows.get("done", 0)
failed = rows.get("failed", 0) failed = rows.get("failed", 0)
total = sum(rows.values())
eta_seconds = None
if running > 0 or pending > 0:
rate = int(os.getenv("CONCURRENCY_LIMIT", "50")) rate = int(os.getenv("CONCURRENCY_LIMIT", "50"))
eta_seconds = (pending + running) / max(rate / 10, 1) eta_seconds = (pending + running) / max(rate / 10, 1) if (pending + running) > 0 else None
return {"total": total, "pending": pending, "running": running, "done": done, "failed": failed, "eta_seconds": eta_seconds}
return {
"total": total,
"pending": pending,
"running": running,
"done": done,
"failed": failed,
"eta_seconds": eta_seconds,
}

View File

@@ -1,12 +1,10 @@
import os import os
import sys
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import httpx import httpx
import duckdb
import aiosqlite import aiosqlite
from fastapi import FastAPI, Query from fastapi import FastAPI, Query
from fastapi.responses import StreamingResponse, JSONResponse from fastapi.responses import StreamingResponse, JSONResponse
@@ -18,7 +16,7 @@ load_dotenv()
from app.db import ( from app.db import (
DATA_DIR, PARQUET_PATH, SQLITE_PATH, DATA_DIR, PARQUET_PATH, SQLITE_PATH,
init_db, get_stats, get_domains, get_enriched, init_db, get_stats, get_domains, get_enriched,
queue_domains, get_queue_status, queue_domains, get_queue_status, build_duckdb_index, index_status,
) )
from app.enricher import start_worker, pause_worker, resume_worker, is_running from app.enricher import start_worker, pause_worker, resume_worker, is_running
from app.scorer import run_scoring from app.scorer import run_scoring
@@ -37,16 +35,13 @@ async def download_parquet():
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
tmp_path = PARQUET_PATH.with_suffix(".tmp") tmp_path = PARQUET_PATH.with_suffix(".tmp")
# Resumable download via Range header
downloaded = tmp_path.stat().st_size if tmp_path.exists() else 0 downloaded = tmp_path.stat().st_size if tmp_path.exists() else 0
headers = {"Range": f"bytes={downloaded}-"} if downloaded > 0 else {} headers = {"Range": f"bytes={downloaded}-"} if downloaded > 0 else {}
logger.info("Downloading parquet from %s (offset=%d)...", PARQUET_URL, downloaded) logger.info("Downloading parquet from %s (offset=%d)...", PARQUET_URL, downloaded)
async with httpx.AsyncClient(follow_redirects=True, timeout=None) as client: async with httpx.AsyncClient(follow_redirects=True, timeout=None) as client:
async with client.stream("GET", PARQUET_URL, headers=headers) as resp: async with client.stream("GET", PARQUET_URL, headers=headers) as resp:
if resp.status_code == 416: if resp.status_code == 416:
# Already fully downloaded
tmp_path.rename(PARQUET_PATH) tmp_path.rename(PARQUET_PATH)
return return
resp.raise_for_status() resp.raise_for_status()
@@ -58,41 +53,54 @@ async def download_parquet():
f.write(chunk) f.write(chunk)
received += len(chunk) received += len(chunk)
if total: if total:
pct = received / total * 100 logger.info("Download: %.1f%% (%d/%d)", received / total * 100, received, total)
logger.info("Download progress: %.1f%% (%d/%d bytes)", pct, received, total)
tmp_path.rename(PARQUET_PATH) tmp_path.rename(PARQUET_PATH)
logger.info("Parquet download complete: %s", PARQUET_PATH) logger.info("Parquet download complete")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await download_parquet() await download_parquet()
await init_db() await init_db()
# Build DuckDB index in background — queries still work (slower) while building
asyncio.create_task(build_duckdb_index())
start_worker() start_worker()
logger.info("DomGod dashboard ready on port 6677") logger.info("DomGod ready on port 6677")
yield yield
app = FastAPI(title="DomGod", lifespan=lifespan) app = FastAPI(title="DomGod", lifespan=lifespan)
# ── API routes ────────────────────────────────────────────────────────────── # ── API ──────────────────────────────────────────────────────────────────────
@app.get("/api/stats") @app.get("/api/stats")
async def stats(): async def stats():
return await get_stats() return await get_stats()
@app.get("/api/index/status")
async def get_index_status():
return index_status()
@app.get("/api/domains") @app.get("/api/domains")
async def domains( async def domains(
tld: str = Query(None), tld: str = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=500),
live_only: bool = Query(False), live_only: bool = Query(False),
alpha_only: bool = Query(False),
no_sld: bool = Query(False),
keyword: str = Query(None),
): ):
rows = await get_domains(tld=tld, page=page, limit=limit, live_only=live_only) total, rows = await get_domains(
return {"page": page, "limit": limit, "results": rows} tld=tld, page=page, limit=limit,
alpha_only=alpha_only, no_sld=no_sld,
keyword=keyword, live_only=live_only,
)
return {"page": page, "limit": limit, "total": total, "results": rows}
@app.post("/api/enrich/batch") @app.post("/api/enrich/batch")
@@ -118,7 +126,7 @@ async def enrich_retry():
await db.execute("UPDATE job_queue SET status='pending', error=NULL WHERE status='failed'") await db.execute("UPDATE job_queue SET status='pending', error=NULL WHERE status='failed'")
await db.commit() await db.commit()
resume_worker() resume_worker()
return {"status": "retrying failed jobs"} return {"status": "retrying"}
@app.post("/api/enrich/pause") @app.post("/api/enrich/pause")
@@ -141,8 +149,8 @@ async def enriched(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
): ):
rows = await get_enriched(min_score=min_score, cms=cms, country=country, page=page, limit=limit) total, rows = await get_enriched(min_score=min_score, cms=cms, country=country, page=page, limit=limit)
return {"page": page, "limit": limit, "results": rows} return {"page": page, "limit": limit, "total": total, "results": rows}
@app.get("/api/export") @app.get("/api/export")
@@ -157,46 +165,42 @@ async def export_csv(
elif tier == "warm": elif tier == "warm":
min_score = 50 min_score = 50
max_score = 79 if tier == "warm" else 100
async def generate(): async def generate():
yield "domain,score,cms,ssl_expiry_days,ip_country,is_live,status_code,has_mx,server,page_title,enriched_at\n" yield "domain,score,cms,ssl_expiry_days,ip_country,is_live,status_code,has_mx,server,page_title,enriched_at\n"
page = 1 p = 1
while True: while True:
rows = await get_enriched(min_score=min_score, cms=cms, country=country, page=page, limit=500) _, rows = await get_enriched(min_score=min_score, cms=cms, country=country, page=p, limit=500)
if not rows: if not rows:
break break
for r in rows: for r in rows:
# Apply warm tier upper bound if r.get("score", 0) > max_score:
if tier == "warm" and r.get("score", 0) >= 80:
continue continue
line = ",".join( line = ",".join(
f'"{str(r.get(col) or "").replace(chr(34), chr(39))}"' f'"{str(r.get(col) or "").replace(chr(34), chr(39))}"'
for col in [ for col in ["domain", "score", "cms", "ssl_expiry_days", "ip_country",
"domain", "score", "cms", "ssl_expiry_days", "ip_country", "is_live", "status_code", "has_mx", "server", "page_title", "enriched_at"]
"is_live", "status_code", "has_mx", "server", "page_title", "enriched_at"
]
) )
yield line + "\n" yield line + "\n"
page += 1 p += 1
filename = f"domgod_leads_score{min_score}{'_' + tier if tier else ''}.csv" fname = f"domgod_{tier or 'export'}_score{min_score}.csv"
return StreamingResponse( return StreamingResponse(
generate(), generate(), media_type="text/csv",
media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="{fname}"'},
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
) )
@app.post("/api/score/run") @app.post("/api/score/run")
async def score_run(): async def score_run():
result = await run_scoring() return await run_scoring()
return result
# ── Static UI ─────────────────────────────────────────────────────────────── # ── Static UI ───────────────────────────────────────────────────────────────
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=6677, log_level="info") uvicorn.run("app.main:app", host="0.0.0.0", port=6677, log_level="info")

View File

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