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:
330
app/db.py
330
app/db.py
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
72
app/main.py
72
app/main.py
@@ -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")
|
||||||
|
|||||||
@@ -3,592 +3,529 @@
|
|||||||
<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;
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
--danger: #ff4f6d;
|
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;font-size:14px}
|
||||||
--warn: #ffb347;
|
a{color:var(--accent2);text-decoration:none}
|
||||||
--text: #e8eaf0;
|
|
||||||
--muted: #8891b0;
|
|
||||||
--hot: #ff4f6d;
|
|
||||||
--warm: #ffb347;
|
|
||||||
--cold: #6c7aff;
|
|
||||||
--radius: 10px;
|
|
||||||
}
|
|
||||||
* { 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; }
|
|
||||||
|
|
||||||
/* 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; }
|
|
||||||
.stat-box .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
|
|
||||||
.stat-box .sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
|
||||||
.v-accent { color: var(--accent2); }
|
|
||||||
.v-hot { color: var(--hot); }
|
|
||||||
.v-warn { color: var(--warn); }
|
|
||||||
.v-muted { color: var(--muted); }
|
|
||||||
|
|
||||||
/* Tabs */
|
/* Stats */
|
||||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
|
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
|
||||||
.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; }
|
.stat{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);padding:12px 14px}
|
||||||
.tab.active { background: var(--surface2); color: var(--text); border-color: var(--border); }
|
.stat .lbl{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}
|
||||||
.tab:hover:not(.active) { color: var(--text); }
|
.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)}
|
||||||
|
|
||||||
/* Filters */
|
/* Tabs */
|
||||||
.filter-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 14px; }
|
.tabs{display:flex;gap:2px;padding:0 2px;border-bottom:1px solid var(--border)}
|
||||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
.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}
|
||||||
.field label { font-size: 11px; color: var(--muted); text-transform: uppercase; }
|
.tab.active{background:var(--surface);color:var(--text);border-color:var(--border)}
|
||||||
input[type=text], input[type=number], select {
|
.tab:hover:not(.active){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;
|
|
||||||
}
|
|
||||||
input[type=text]:focus, select:focus { border-color: var(--accent); }
|
|
||||||
input[type=range] { accent-color: var(--accent); width: 120px; }
|
|
||||||
.toggle { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
||||||
.toggle input { accent-color: var(--accent); width: 16px; height: 16px; cursor: pointer; }
|
|
||||||
|
|
||||||
/* Buttons */
|
/* Filters */
|
||||||
.btn { padding: 7px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity .15s; }
|
.filters{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-bottom:12px}
|
||||||
.btn:hover { opacity: .85; }
|
.field{display:flex;flex-direction:column;gap:3px}
|
||||||
.btn-primary { background: var(--accent); color: #fff; }
|
.field label{font-size:11px;color:var(--muted);text-transform:uppercase;font-weight:600}
|
||||||
.btn-success { background: var(--accent2); color: #111; }
|
input[type=text],input[type=number],select{
|
||||||
.btn-danger { background: var(--danger); color: #fff; }
|
background:var(--surface2);border:1px solid var(--border);color:var(--text);
|
||||||
.btn-warn { background: var(--warn); color: #111; }
|
padding:6px 10px;border-radius:6px;font-size:13px;outline:none
|
||||||
.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
|
}
|
||||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
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}
|
||||||
|
|
||||||
/* Table */
|
/* Buttons */
|
||||||
.table-wrap { overflow-x: auto; }
|
.btn{padding:6px 14px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:opacity .15s;white-space:nowrap}
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.btn:hover:not(:disabled){opacity:.85} .btn:disabled{opacity:.35;cursor:not-allowed}
|
||||||
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; }
|
.btn-primary{background:var(--accent);color:#fff}
|
||||||
td { padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
.btn-success{background:var(--accent2);color:#111}
|
||||||
tr:hover td { background: var(--surface2); }
|
.btn-danger{background:var(--danger);color:#fff}
|
||||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
|
.btn-warn{background:var(--warn);color:#111}
|
||||||
.pill-green { background: #00d4aa22; color: var(--accent2); }
|
.btn-ghost{background:var(--surface2);color:var(--text);border:1px solid var(--border)}
|
||||||
.pill-red { background: #ff4f6d22; color: var(--danger); }
|
.btn-sm{padding:4px 10px;font-size:12px}
|
||||||
.pill-grey { background: #ffffff11; color: var(--muted); }
|
|
||||||
.pill-cms { background: #6c63ff22; color: var(--accent); }
|
|
||||||
|
|
||||||
/* Score badge */
|
/* Table */
|
||||||
.score-badge { display: inline-block; padding: 2px 7px; border-radius: 6px; font-weight: 700; font-size: 12px; }
|
.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)}
|
||||||
|
|
||||||
/* Progress bar */
|
/* Score badge */
|
||||||
.progress-wrap { background: var(--surface2); border-radius: 99px; height: 10px; overflow: hidden; margin: 8px 0; }
|
.score{display:inline-block;padding:2px 7px;border-radius:6px;font-weight:800;font-size:12px;min-width:32px;text-align:center}
|
||||||
.progress-bar { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 99px; transition: width .4s; }
|
|
||||||
|
|
||||||
/* Pipeline columns */
|
/* Pagination */
|
||||||
.pipeline { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
|
.pager{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||||
.pipe-col { background: var(--surface2); border-radius: var(--radius); border: 1px solid var(--border); padding: 14px; }
|
.pager .info{font-size:12px;color:var(--muted)}
|
||||||
.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 */
|
/* Progress */
|
||||||
.chart-wrap { max-width: 100%; height: 280px; }
|
.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}
|
||||||
|
|
||||||
/* Pagination */
|
/* Pipeline */
|
||||||
.pagination { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
.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}
|
||||||
|
|
||||||
/* Enrichment status */
|
/* Enrich stats */
|
||||||
.enrich-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
.eq-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin-bottom:14px}
|
||||||
.enrich-stat { background: var(--surface2); border-radius: 8px; padding: 10px 14px; text-align: center; }
|
.eq-stat{background:var(--surface2);border-radius:var(--r);padding:10px;text-align:center}
|
||||||
.enrich-stat .val { font-size: 22px; font-weight: 700; }
|
.eq-stat .v{font-size:22px;font-weight:800}
|
||||||
.enrich-stat .lbl { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
.eq-stat .l{font-size:11px;color:var(--muted);margin-top:2px}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
/* Toast */
|
||||||
.pipeline { grid-template-columns: 1fr; }
|
.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}
|
||||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
.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()">
|
|
||||||
|
|
||||||
<header>
|
<!-- Toast -->
|
||||||
|
<div class="toast" :class="[toast.type, {hidden: !toast.show}]" x-text="toast.msg"></div>
|
||||||
|
|
||||||
|
<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 another.es"
|
||||||
placeholder="example.com another.es 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 80–100</div>
|
<div style="font-size:12px;color:var(--muted)">Score 80–100</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 50–79</div>
|
<div style="font-size:12px;color:var(--muted)">Score 50–79</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 < 50</div>
|
<div style="font-size:12px;color:var(--muted)">Score < 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=6').then(r=>r.json()),
|
||||||
fetch('/api/enriched?min_score=50&limit=5').then(r => r.json()),
|
fetch('/api/enriched?min_score=0&limit=6').then(r=>r.json()),
|
||||||
fetch('/api/enriched?min_score=0&limit=5').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' } },
|
y:{ticks:{color:'#8891b0'},grid:{color:'#2e3250'}},
|
||||||
y: { ticks: { color: '#8891b0' }, grid: { color: '#2e3250' } },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user