feat: initial Dockerized domain intelligence dashboard

- FastAPI backend with DuckDB pushdown queries on 72M parquet
- Async enrichment worker: HTTP, SSL, DNS MX, CMS fingerprint, ip-api.com
- Resumable parquet download with HTTP Range support
- Lead scoring engine (max 100 pts, target countries ES,GB,DE,FR,RO,PT,AD,IT)
- Single-file Alpine.js + Chart.js dashboard on port 6677
- SQLite enrichment DB with job queue and scores tables
- Dockerized with persistent /data volume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 16:22:30 +02:00
commit b2e7a2f2db
11 changed files with 1467 additions and 0 deletions

600
app/static/index.html Normal file
View File

@@ -0,0 +1,600 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DomGod — Domain Intelligence Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #222638;
--border: #2e3250;
--accent: #6c63ff;
--accent2: #00d4aa;
--danger: #ff4f6d;
--warn: #ffb347;
--text: #e8eaf0;
--muted: #8891b0;
--hot: #ff4f6d;
--warm: #ffb347;
--cold: #6c7aff;
--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 */
.shell { display: flex; flex-direction: column; min-height: 100vh; }
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 { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
header h1 span { color: var(--accent); }
.badge { background: var(--accent); color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 99px; }
main { padding: 20px 24px; display: flex; flex-direction: column; gap: 20px; max-width: 1400px; margin: 0 auto; width: 100%; }
/* Cards */
.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 */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; }
.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 */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.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.active { background: var(--surface2); color: var(--text); border-color: var(--border); }
.tab:hover:not(.active) { color: var(--text); }
/* Filters */
.filter-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 14px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 11px; color: var(--muted); text-transform: uppercase; }
input[type=text], input[type=number], select {
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 */
.btn { padding: 7px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; transition: opacity .15s; }
.btn:hover { opacity: .85; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-success { background: var(--accent2); color: #111; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-warn { background: var(--warn); color: #111; }
.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
/* Table */
.table-wrap { overflow-x: auto; }
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; }
td { padding: 8px 10px; border-bottom: 1px solid var(--border); }
tr:hover td { background: var(--surface2); }
.pill { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600; }
.pill-green { background: #00d4aa22; color: var(--accent2); }
.pill-red { background: #ff4f6d22; color: var(--danger); }
.pill-grey { background: #ffffff11; color: var(--muted); }
.pill-cms { background: #6c63ff22; color: var(--accent); }
/* Score badge */
.score-badge { display: inline-block; padding: 2px 7px; border-radius: 6px; font-weight: 700; font-size: 12px; }
/* 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 { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
/* Enrichment status */
.enrich-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 14px; }
.enrich-stat { background: var(--surface2); border-radius: 8px; padding: 10px 14px; text-align: center; }
.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 { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="shell" x-data="dashboard()" x-init="init()">
<header>
<h1>Dom<span>God</span></h1>
<span class="badge" x-text="'v1.0'"></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>
</header>
<main>
<!-- ① Stats Bar -->
<div class="card">
<div class="card-title">Overview</div>
<div class="stats-grid">
<div class="stat-box">
<div class="label">Total Domains</div>
<div class="value v-accent" x-text="stats.total_domains ? stats.total_domains.toLocaleString() : '—'"></div>
<div class="sub">in parquet</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>
<!-- Tabs -->
<div class="tabs">
<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==='pipeline'}" @click="tab='pipeline'">Lead Pipeline</div>
<div class="tab" :class="{active: tab==='chart'}" @click="tab='chart'; renderChart()">TLD Chart</div>
</div>
<!-- ② Browse & Filter -->
<div class="card" x-show="tab==='browse'">
<div class="filter-row">
<div class="field">
<label>TLD</label>
<input type="text" x-model="filter.tld" placeholder="es, com…" @keydown.enter="loadDomains()">
</div>
<div class="field">
<label>Country</label>
<input type="text" x-model="filter.country" placeholder="ES, GB…">
</div>
<div class="field">
<label>Min Score: <strong x-text="filter.min_score"></strong></label>
<input type="range" x-model="filter.min_score" min="0" max="100" step="5">
</div>
<div class="field">
<label>CMS</label>
<select x-model="filter.cms">
<option value="">Any</option>
<option>wordpress</option><option>joomla</option><option>drupal</option>
<option>wix</option><option>squarespace</option><option>shopify</option>
<option>prestashop</option><option>magento</option><option>typo3</option><option>opencart</option>
</select>
</div>
<label class="toggle field">
<label>Live only</label>
<input type="checkbox" x-model="filter.live_only">
</label>
<button class="btn btn-primary" @click="loadDomains(1)">Search</button>
<button class="btn btn-success" @click="enqueueSelected()" :disabled="selected.length===0">
Enrich selected (<span x-text="selected.length"></span>)
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th><input type="checkbox" @change="toggleAll($event)"></th>
<th>Domain</th>
<th>Score</th>
<th>CMS</th>
<th>SSL days</th>
<th>Country</th>
<th>Live</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<template x-for="row in domains" :key="row.domain">
<tr>
<td><input type="checkbox" :value="row.domain" x-model="selected"></td>
<td>
<a :href="'http://'+row.domain" target="_blank" x-text="row.domain"></a>
</td>
<td>
<span class="score-badge"
:style="scoreBg(row.score)"
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 x-text="row.ssl_expiry_days ?? '—'"></td>
<td x-text="row.ip_country ?? '—'"></td>
<td>
<span class="pill" :class="row.is_live ? 'pill-green' : 'pill-grey'" x-text="row.is_live ? 'Yes' : '—'"></span>
</td>
<td x-text="row.status_code ?? '—'"></td>
</tr>
</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>
</table>
</div>
<div class="pagination">
<button class="btn btn-ghost" @click="loadDomains(page-1)" :disabled="page<=1">← Prev</button>
<span style="color:var(--muted)">Page <strong x-text="page"></strong></span>
<button class="btn btn-ghost" @click="loadDomains(page+1)" :disabled="domains.length < filter.limit">Next →</button>
<span style="color:var(--muted);margin-left:8px">Limit:
<select x-model="filter.limit" @change="loadDomains(1)" style="width:80px">
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</span>
</div>
</div>
<!-- ③ Enrichment Queue -->
<div class="card" x-show="tab==='enrichment'">
<div class="enrich-grid">
<div class="enrich-stat">
<div class="val v-warn" x-text="queueStatus.pending ?? '—'"></div>
<div class="lbl">Pending</div>
</div>
<div class="enrich-stat">
<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 style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="font-size:12px;color:var(--muted)" x-text="progressLabel()"></span>
<div style="display:flex;gap:8px">
<button class="btn btn-success" @click="startEnrich()" x-show="!enrichRunning">▶ Start</button>
<button class="btn btn-warn" @click="pauseEnrich()" x-show="enrichRunning">⏸ Pause</button>
<button class="btn btn-ghost" @click="retryFailed()">↺ Retry Failed</button>
</div>
</div>
<div class="progress-wrap">
<div class="progress-bar" :style="'width:' + progressPct() + '%'"></div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" x-text="progressPct().toFixed(1) + '% complete'"></div>
</div>
<div style="margin-top:20px">
<div class="card-title">Enrich custom domains</div>
<div style="display:flex;gap:8px;align-items:flex-start">
<textarea
x-model="customDomains"
placeholder="example.com&#10;another.es&#10;third.net"
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"
></textarea>
<button class="btn btn-primary" @click="enqueueCustom()" style="align-self:flex-end">Queue</button>
</div>
</div>
</div>
<!-- ④ Lead Pipeline -->
<div class="card" x-show="tab==='pipeline'">
<div style="display:flex;justify-content:flex-end;gap:8px;margin-bottom:14px">
<button class="btn btn-ghost" @click="loadPipeline()">↻ Refresh</button>
</div>
<div class="pipeline">
<!-- Hot -->
<div class="pipe-col" style="border-top:3px solid var(--hot)">
<h3>🔥 Hot</h3>
<div style="color:var(--muted);font-size:12px">Score 80100</div>
<div class="count" style="color:var(--hot)" x-text="pipeline.hot.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.hot.samples" :key="d.domain">
<div class="sample">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain"></a>
<span class="score-badge" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn btn-danger" style="margin-top:12px;width:100%" @click="exportTier('hot')">⬇ Export Hot CSV</button>
</div>
<!-- Warm -->
<div class="pipe-col" style="border-top:3px solid var(--warm)">
<h3>♨️ Warm</h3>
<div style="color:var(--muted);font-size:12px">Score 5079</div>
<div class="count" style="color:var(--warm)" x-text="pipeline.warm.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.warm.samples" :key="d.domain">
<div class="sample">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain"></a>
<span class="score-badge" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn btn-warn" style="margin-top:12px;width:100%" @click="exportTier('warm')">⬇ Export Warm CSV</button>
</div>
<!-- Cold -->
<div class="pipe-col" style="border-top:3px solid var(--cold)">
<h3>🧊 Cold</h3>
<div style="color:var(--muted);font-size:12px">Score &lt; 50</div>
<div class="count" style="color:var(--cold)" x-text="pipeline.cold.count.toLocaleString()"></div>
<div class="samples">
<template x-for="d in pipeline.cold.samples" :key="d.domain">
<div class="sample">
<a :href="'http://'+d.domain" target="_blank" x-text="d.domain"></a>
<span class="score-badge" :style="scoreBg(d.score)" x-text="d.score"></span>
</div>
</template>
</div>
<button class="btn btn-ghost" style="margin-top:12px;width:100%" @click="exportTier('cold')">⬇ Export Cold CSV</button>
</div>
</div>
</div>
<!-- ⑤ TLD Chart -->
<div class="card" x-show="tab==='chart'">
<div class="card-title">Top 20 TLDs</div>
<div class="chart-wrap">
<canvas id="tldChart"></canvas>
</div>
</div>
</main>
</div>
<script>
function dashboard() {
return {
tab: 'browse',
stats: {},
domains: [],
selected: [],
loading: false,
page: 1,
filter: { tld: '', country: '', min_score: 0, cms: '', live_only: false, limit: '100' },
queueStatus: {},
enrichRunning: false,
customDomains: '',
pipeline: {
hot: { count: 0, samples: [] },
warm: { count: 0, samples: [] },
cold: { count: 0, samples: [] },
},
_chart: null,
_pollInterval: null,
async init() {
await this.loadStats();
this.startPolling();
},
startPolling() {
this._pollInterval = setInterval(async () => {
await this.loadStats();
if (this.tab === 'enrichment') await this.loadQueueStatus();
if (this.tab === 'pipeline') await this.loadPipeline();
}, 3000);
},
async loadStats() {
try {
const r = await fetch('/api/stats');
this.stats = await r.json();
} catch(e) {}
},
async loadQueueStatus() {
try {
const r = await fetch('/api/enrich/status');
this.queueStatus = await r.json();
this.enrichRunning = this.queueStatus.worker_running;
} catch(e) {}
},
async loadDomains(p) {
if (p !== undefined) this.page = p;
this.loading = true;
const params = new URLSearchParams({
page: this.page,
limit: this.filter.limit,
...(this.filter.tld && { tld: this.filter.tld }),
...(this.filter.live_only && { live_only: 'true' }),
});
try {
const r = await fetch('/api/domains?' + params);
const data = await r.json();
// Filter by country/min_score client-side for enriched rows
this.domains = data.results.filter(row => {
if (this.filter.min_score > 0 && (row.score ?? 0) < this.filter.min_score) return false;
if (this.filter.country && row.ip_country !== this.filter.country.toUpperCase()) return false;
if (this.filter.cms && row.cms !== this.filter.cms) return false;
return true;
});
} catch(e) { this.domains = []; }
this.loading = false;
},
toggleAll(e) {
if (e.target.checked) {
this.selected = this.domains.map(d => d.domain);
} else {
this.selected = [];
}
},
async enqueueSelected() {
if (!this.selected.length) return;
await fetch('/api/enrich/batch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domains: this.selected }),
});
this.selected = [];
alert('Queued for enrichment!');
},
async enqueueCustom() {
const domains = this.customDomains.split('\n').map(d => d.trim()).filter(Boolean);
if (!domains.length) return;
await fetch('/api/enrich/batch', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domains }),
});
this.customDomains = '';
await this.loadQueueStatus();
},
async startEnrich() {
await fetch('/api/enrich/resume', { method: 'POST' });
this.enrichRunning = true;
await this.loadQueueStatus();
},
async pauseEnrich() {
await fetch('/api/enrich/pause', { method: 'POST' });
this.enrichRunning = false;
},
async retryFailed() {
// Mark failed jobs as pending
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() {
try {
const [hot, warm, cold] = await Promise.all([
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=0&limit=5').then(r => r.json()),
]);
// Fetch counts separately
const [hc, wc, cc] = await Promise.all([
fetch('/api/enriched?min_score=80&limit=1').then(r => r.json()),
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) {
window.location = `/api/export?tier=${tier}`;
},
scoreBg(score) {
if (score == null) return 'background:#333;color:#aaa';
if (score >= 80) return 'background:#ff4f6d33;color:#ff4f6d';
if (score >= 50) return 'background:#ffb34733;color:#ffb347';
return 'background:#6c7aff33;color:#6c7aff';
},
async renderChart() {
await this.$nextTick();
const canvas = document.getElementById('tldChart');
if (!canvas) return;
if (this._chart) { this._chart.destroy(); this._chart = null; }
const tlds = this.stats.tld_breakdown || [];
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, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Domains',
data,
backgroundColor: 'rgba(108, 99, 255, 0.7)',
borderColor: 'rgba(108, 99, 255, 1)',
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#8891b0' }, grid: { color: '#2e3250' } },
y: { ticks: { color: '#8891b0' }, grid: { color: '#2e3250' } },
}
}
});
},
}
}
</script>
</body>
</html>