5cb64f181a
Dashboard (M3):
- 路由 /api/dashboard/{daily,history,today,datasets,stats}/
- daily_summary: 按日期聚合 ok/warning/error/blocked
- history: 同步历史详情(按 task / 日期过滤)
- today: 今日 KPI + 任务列表
- datasets: dataset_registry + 最近 7d run 合并
- stats: 各表行数 + 日期范围(17 张表)
HTML SPA: app/api/static/dashboard.html
- 5 张状态卡片 + 7 天趋势表 + 任务状态表 + 数据规模表 + 历史详情
- 自动 30s 刷新, 纯 vanilla JS (无框架依赖)
Config (M5):
- 路由 /api/config/{GET list, GET key, PUT upsert, DELETE}/
- 凭证类自动脱敏 (MAIRUI_LICENCE / XUEQIU_TOKEN / MAIRUI_RPS_LIMIT)
- POST /api/config/reload: 把 .env / 环境变量已知项回填到 config 表
- 分类过滤 + key 搜索 + 凭证提示
HTML SPA: app/api/static/config.html
- 列表 + 编辑弹窗 + 删除确认
- 凭证类编辑时强制重新输入完整值(避免显示脱敏误以为是新值)
- 一键从环境变量回填
FastAPI 主入口挂载:
- include_router(dashboard, config)
- mount('/static', StaticFiles) 提供 HTML
231 lines
9.4 KiB
HTML
231 lines
9.4 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>market_sync 数据看板</title>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
body { font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
margin: 0; padding: 20px; background: #f6f8fa; color: #24292e; }
|
|
h1 { margin: 0 0 8px; font-size: 22px; }
|
|
h2 { margin: 24px 0 12px; font-size: 16px; color: #586069; }
|
|
.meta { color: #6a737d; font-size: 13px; margin-bottom: 20px; }
|
|
.grid { display: grid; gap: 12px; }
|
|
.grid.cards { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
|
|
.card { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 14px 16px; }
|
|
.card .label { font-size: 12px; color: #6a737d; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.card .value { font-size: 26px; font-weight: 600; margin-top: 4px; }
|
|
.card.ok .value { color: #1a7f37; }
|
|
.card.warn .value { color: #9a6700; }
|
|
.card.err .value { color: #cf222e; }
|
|
.card.block .value { color: #8250df; }
|
|
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d0d7de;
|
|
border-radius: 8px; overflow: hidden; font-size: 13px; }
|
|
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eaecef; }
|
|
th { background: #f6f8fa; font-weight: 600; color: #57606a; font-size: 12px;
|
|
text-transform: uppercase; letter-spacing: 0.3px; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: #f6f8fa; }
|
|
.status-pill { display: inline-block; padding: 1px 8px; border-radius: 10px;
|
|
font-size: 11px; font-weight: 600; }
|
|
.status-ok { background: #dafbe1; color: #1a7f37; }
|
|
.status-warning { background: #fff8c5; color: #9a6700; }
|
|
.status-error { background: #ffebe9; color: #cf222e; }
|
|
.status-blocked { background: #fbefff; color: #8250df; }
|
|
.status-running { background: #ddf4ff; color: #0969da; }
|
|
.status-idle { background: #eaeef2; color: #57606a; }
|
|
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
|
|
select, button { padding: 6px 10px; border: 1px solid #d0d7de; border-radius: 6px;
|
|
background: #fff; font-size: 13px; }
|
|
button { cursor: pointer; }
|
|
button:hover { background: #f3f4f6; }
|
|
.row { display: flex; gap: 12px; align-items: center; }
|
|
.muted { color: #6a737d; font-size: 12px; }
|
|
.err-cell { color: #cf222e; font-family: monospace; font-size: 11px; max-width: 360px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.num { font-variant-numeric: tabular-nums; text-align: right; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>📊 market_sync 数据看板</h1>
|
|
<div class="meta">
|
|
<span id="updatedAt">加载中...</span>
|
|
· 自动每 30s 刷新
|
|
· 数据源: <a href="/docs" target="_blank">/api/dashboard/*</a>
|
|
</div>
|
|
|
|
<!-- 今日状态卡片 -->
|
|
<h2>今日 (今日运行情况)</h2>
|
|
<div class="grid cards" id="todayCards">
|
|
<div class="card"><div class="label">总任务</div><div class="value" id="kpi-total">-</div></div>
|
|
<div class="card ok"><div class="label">✓ OK</div><div class="value" id="kpi-ok">-</div></div>
|
|
<div class="card warn"><div class="label">⚠ Warning</div><div class="value" id="kpi-warn">-</div></div>
|
|
<div class="card err"><div class="label">✗ Error</div><div class="value" id="kpi-err">-</div></div>
|
|
<div class="card block"><div class="label">⊘ Blocked</div><div class="value" id="kpi-block">-</div></div>
|
|
</div>
|
|
|
|
<!-- 7 天趋势 -->
|
|
<h2>最近 7 天每日同步情况</h2>
|
|
<table id="dailyTable">
|
|
<thead><tr>
|
|
<th>日期</th><th class="num">OK</th><th class="num">Warning</th>
|
|
<th class="num">Error</th><th class="num">Blocked</th>
|
|
<th class="num">Total</th><th class="num">Tasks Run</th>
|
|
</tr></thead>
|
|
<tbody><tr><td colspan="7" class="muted">加载中...</td></tr></tbody>
|
|
</table>
|
|
|
|
<!-- 任务状态 -->
|
|
<h2>所有同步任务 (实时状态 + 最近 7 天)</h2>
|
|
<table id="datasetsTable">
|
|
<thead><tr>
|
|
<th>Task</th><th>Name</th><th>Source</th><th>Status</th>
|
|
<th>Last Run</th><th>7d Runs</th><th>Rows</th><th>Elapsed</th>
|
|
</tr></thead>
|
|
<tbody><tr><td colspan="8" class="muted">加载中...</td></tr></tbody>
|
|
</table>
|
|
|
|
<!-- 数据规模 -->
|
|
<h2>数据规模</h2>
|
|
<table id="statsTable">
|
|
<thead><tr><th>Table</th><th class="num">Rows</th><th>Date Range</th></tr></thead>
|
|
<tbody><tr><td colspan="3" class="muted">加载中...</td></tr></tbody>
|
|
</table>
|
|
|
|
<!-- 历史详情 -->
|
|
<h2>同步历史 (最近 200 条)</h2>
|
|
<div class="toolbar">
|
|
<label>Filter by Task:</label>
|
|
<select id="taskFilter"><option value="">— 全部 —</option></select>
|
|
<button onclick="loadHistory()">刷新</button>
|
|
</div>
|
|
<table id="historyTable">
|
|
<thead><tr>
|
|
<th>Started</th><th>Task</th><th>Status</th><th>Trigger</th>
|
|
<th class="num">Elapsed (s)</th><th class="num">Rows</th>
|
|
<th>Message</th>
|
|
</tr></thead>
|
|
<tbody><tr><td colspan="7" class="muted">加载中...</td></tr></tbody>
|
|
</table>
|
|
|
|
<script>
|
|
const fmt = n => (n == null || n === '' ? '-' : Number(n).toLocaleString());
|
|
const fmtTime = s => s ? new Date(s).toLocaleString('zh-CN') : '-';
|
|
const statusPill = s => `<span class="status-pill status-${s}">${s || '-'}</span>`;
|
|
|
|
async function fetchJson(path) {
|
|
const r = await fetch(path);
|
|
if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
async function loadToday() {
|
|
const r = await fetchJson('/api/dashboard/today');
|
|
const s = r.summary || {};
|
|
document.getElementById('kpi-total').textContent = fmt(s.total);
|
|
document.getElementById('kpi-ok').textContent = fmt(s.ok);
|
|
document.getElementById('kpi-warn').textContent = fmt(s.warning);
|
|
document.getElementById('kpi-err').textContent = fmt(s.error);
|
|
document.getElementById('kpi-block').textContent = fmt(s.blocked);
|
|
}
|
|
|
|
async function loadDaily() {
|
|
const r = await fetchJson('/api/dashboard/daily?days=7');
|
|
const tbody = document.querySelector('#dailyTable tbody');
|
|
if (!r.summary.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="muted">暂无数据</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = r.summary.map(d => `<tr>
|
|
<td>${d.date}</td>
|
|
<td class="num" style="color:#1a7f37">${fmt(d.ok)}</td>
|
|
<td class="num" style="color:#9a6700">${fmt(d.warning)}</td>
|
|
<td class="num" style="color:#cf222e">${fmt(d.error)}</td>
|
|
<td class="num" style="color:#8250df">${fmt(d.blocked)}</td>
|
|
<td class="num">${fmt(d.total)}</td>
|
|
<td class="num">${fmt(d.tasks_run)}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
async function loadDatasets() {
|
|
const r = await fetchJson('/api/dashboard/datasets');
|
|
const tbody = document.querySelector('#datasetsTable tbody');
|
|
if (!r.datasets.length) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无数据</td></tr>';
|
|
return;
|
|
}
|
|
// populate task filter
|
|
const sel = document.getElementById('taskFilter');
|
|
r.datasets.forEach(d => {
|
|
const opt = document.createElement('option');
|
|
opt.value = d.dataset_id; opt.textContent = d.dataset_id;
|
|
sel.appendChild(opt);
|
|
});
|
|
tbody.innerHTML = r.datasets.map(d => {
|
|
const last = d.last_run || {};
|
|
return `<tr>
|
|
<td><code>${d.dataset_id}</code></td>
|
|
<td>${d.name || '-'}</td>
|
|
<td class="muted">${(d.source || '').slice(0, 40)}</td>
|
|
<td>${statusPill(d.status)}</td>
|
|
<td class="muted">${fmtTime(last.started_at)}</td>
|
|
<td class="num">${fmt(d.history_count_7d)}</td>
|
|
<td class="num">${fmt(last.rows_written)}</td>
|
|
<td class="num">${last.elapsed_sec ? last.elapsed_sec.toFixed(1) : '-'}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function loadStats() {
|
|
const r = await fetchJson('/api/dashboard/stats');
|
|
const tbody = document.querySelector('#statsTable tbody');
|
|
if (!r.tables.length) {
|
|
tbody.innerHTML = '<tr><td colspan="3" class="muted">暂无数据</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = r.tables.map(t => `<tr>
|
|
<td><code>${t.table}</code></td>
|
|
<td class="num">${t.rows < 0 ? '⚠ ' + t.error : fmt(t.rows)}</td>
|
|
<td class="muted">${t.date_range ? `${t.date_range.min} ~ ${t.date_range.max}` : '-'}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
async function loadHistory() {
|
|
const taskId = document.getElementById('taskFilter').value;
|
|
const url = taskId
|
|
? `/api/dashboard/history?dataset_id=${encodeURIComponent(taskId)}&limit=200`
|
|
: `/api/dashboard/history?limit=200`;
|
|
const r = await fetchJson(url);
|
|
const tbody = document.querySelector('#historyTable tbody');
|
|
if (!r.history.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="muted">暂无数据</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = r.history.map(h => `<tr>
|
|
<td class="muted">${fmtTime(h.started_at)}</td>
|
|
<td><code>${h.dataset_id}</code></td>
|
|
<td>${statusPill(h.status)}</td>
|
|
<td class="muted">${h.trigger_source}</td>
|
|
<td class="num">${h.elapsed_sec != null ? h.elapsed_sec.toFixed(1) : '-'}</td>
|
|
<td class="num">${fmt(h.rows_written)}</td>
|
|
<td title="${(h.error || '').slice(0, 200)}" class="err-cell">${(h.message || '').slice(0, 80)}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
async function refreshAll() {
|
|
try {
|
|
await Promise.all([loadToday(), loadDaily(), loadDatasets(), loadStats(), loadHistory()]);
|
|
document.getElementById('updatedAt').textContent = '✓ 已更新 ' + new Date().toLocaleTimeString('zh-CN');
|
|
} catch (e) {
|
|
document.getElementById('updatedAt').textContent = '✗ 加载失败: ' + e.message;
|
|
}
|
|
}
|
|
|
|
refreshAll();
|
|
setInterval(refreshAll, 30000);
|
|
</script>
|
|
|
|
</body>
|
|
</html> |