Files
DomGod/app/static/index.html
Malin b2e7a2f2db 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>
2026-04-13 16:22:30 +02:00

601 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DomGod — Domain Intelligence 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>