Files
market_sync/app/api/static/dashboard.html
T
gao 5cb64f181a feat(api): dashboard 看板 + config 配置页
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
2026-07-07 17:54:05 +08:00

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>