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
This commit is contained in:
gao
2026-07-07 17:54:05 +08:00
parent d217553d79
commit 5cb64f181a
5 changed files with 758 additions and 1 deletions
+264
View File
@@ -0,0 +1,264 @@
<!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; }
.meta a { color: #0969da; text-decoration: none; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
select, input, button, textarea {
padding: 6px 10px; border: 1px solid #d0d7de; border-radius: 6px;
background: #fff; font-size: 13px; font-family: inherit;
}
button { cursor: pointer; }
button:hover { background: #f3f4f6; }
button.primary { background: #1f883d; color: #fff; border-color: #1a7f37; }
button.primary:hover { background: #1a7f37; }
button.danger { background: #cf222e; color: #fff; border-color: #cf222e; }
button.danger:hover { background: #b51a24; }
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; vertical-align: middle; }
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; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
background: #eff1f3; padding: 1px 6px; border-radius: 3px; }
.category-pill { display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; background: #ddf4ff; color: #0969da; }
.muted { color: #6a737d; font-size: 12px; }
.alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
.alert.info { background: #ddf4ff; color: #0969da; border: 1px solid #b6e3ff; }
.alert.warn { background: #fff8c5; color: #9a6700; border: 1px solid #d4a72c; }
.alert.ok { background: #dafbe1; color: #1a7f37; border: 1px solid #4ac26b; }
input[type=text] { width: 100%; }
td.actions { white-space: nowrap; text-align: right; }
td.actions button { margin-left: 4px; padding: 3px 8px; font-size: 12px; }
.sensitive { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #8250df; }
</style>
</head>
<body>
<h1>⚙️ 参数配置</h1>
<div class="meta">
读写 <code>market_data.config</code> 表。
· <a href="/api/dashboard/">📊 数据看板</a>
· <a href="/docs" target="_blank">FastAPI Docs</a>
</div>
<div id="alert"></div>
<div class="toolbar">
<label>分类:</label>
<select id="catFilter">
<option value="">— 全部 —</option>
</select>
<label>搜索:</label>
<input type="text" id="searchBox" placeholder="按 key 模糊搜索..." style="width: 240px;">
<button onclick="loadConfigs()">刷新</button>
<button onclick="reloadFromEnv()">从环境变量回填</button>
<button class="primary" onclick="showCreate()">+ 新建</button>
</div>
<table id="cfgTable">
<thead><tr>
<th>Key</th><th>Value</th><th>分类</th><th>说明</th>
<th>更新时间</th><th class="actions">操作</th>
</tr></thead>
<tbody><tr><td colspan="6" class="muted">加载中...</td></tr></tbody>
</table>
<!-- 编辑/新建弹窗 -->
<div id="modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.4);
z-index:100; align-items:center; justify-content:center;">
<div style="background:#fff; border-radius:8px; padding:20px; width:520px; max-width:90vw;">
<h3 id="modalTitle">新建配置</h3>
<div style="margin:12px 0;">
<label>Key</label>
<input type="text" id="modalKey" placeholder="例: MAIRUI_LICENCE" style="width:100%;">
</div>
<div style="margin:12px 0;">
<label>Value <span id="sensitiveHint" style="color:#8250df; display:none;">(凭证类,输入完整值;显示时会脱敏)</span></label>
<textarea id="modalValue" rows="3" style="width:100%; font-family:monospace;"></textarea>
</div>
<div style="margin:12px 0;">
<label>分类</label>
<input type="text" id="modalCategory" placeholder="general / credential / scheduler / api / datasource / ..." style="width:100%;">
</div>
<div style="margin:12px 0;">
<label>说明</label>
<input type="text" id="modalDesc" placeholder="可选, 描述这个配置干嘛的" style="width:100%;">
</div>
<div style="text-align:right; margin-top:16px;">
<button onclick="hideModal()">取消</button>
<button class="primary" onclick="saveModal()">保存</button>
</div>
</div>
</div>
<script>
const SENSITIVE = new Set(['MAIRUI_LICENCE', 'XUEQIU_TOKEN', 'MAIRUI_RPS_LIMIT']);
let _allConfigs = [];
let _editingKey = null;
function alertBox(msg, type='info', timeout=4000) {
const el = document.getElementById('alert');
el.innerHTML = `<div class="alert ${type}">${msg}</div>`;
if (timeout > 0) setTimeout(() => el.innerHTML = '', timeout);
}
async function fetchJson(p, opts) {
const r = await fetch(p, opts);
if (!r.ok) {
const t = await r.text();
throw new Error(`HTTP ${r.status}: ${t}`);
}
return r.json();
}
async function loadConfigs() {
try {
const r = await fetchJson('/api/config/');
_allConfigs = r.configs;
// 填充 category 下拉
const cats = await fetchJson('/api/config/categories/list');
const sel = document.getElementById('catFilter');
const cur = sel.value;
sel.innerHTML = '<option value="">— 全部 —</option>' +
cats.categories.map(c => `<option value="${c}">${c}</option>`).join('');
sel.value = cur;
renderTable();
} catch (e) {
alertBox('加载失败: ' + e.message, 'warn');
}
}
function renderTable() {
const cat = document.getElementById('catFilter').value;
const search = document.getElementById('searchBox').value.toLowerCase();
const rows = _allConfigs.filter(r =>
(!cat || r.category === cat) &&
(!search || r.key.toLowerCase().includes(search))
);
const tbody = document.querySelector('#cfgTable tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">没有匹配的配置</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const sensitive = SENSITIVE.has(r.key);
const valDisplay = sensitive
? `<code class="sensitive" title="点击查看完整值 → 编辑时输入新值">${r.value || '(empty)'}</code>`
: `<code>${(r.value || '').slice(0, 80)}${(r.value || '').length > 80 ? '...' : ''}</code>`;
return `<tr>
<td><code>${r.key}</code></td>
<td>${valDisplay}</td>
<td><span class="category-pill">${r.category || '-'}</span></td>
<td class="muted">${r.description || ''}</td>
<td class="muted">${r.updated_at ? new Date(r.updated_at).toLocaleString('zh-CN') : '-'}</td>
<td class="actions">
<button onclick="editConfig('${r.key}')">编辑</button>
<button class="danger" onclick="deleteConfig('${r.key}')">删除</button>
</td>
</tr>`;
}).join('');
}
function showCreate() {
_editingKey = null;
document.getElementById('modalTitle').textContent = '新建配置';
document.getElementById('modalKey').value = '';
document.getElementById('modalKey').disabled = false;
document.getElementById('modalValue').value = '';
document.getElementById('modalCategory').value = 'general';
document.getElementById('modalDesc').value = '';
document.getElementById('sensitiveHint').style.display = 'none';
document.getElementById('modal').style.display = 'flex';
document.getElementById('modalKey').oninput = () => {
const k = document.getElementById('modalKey').value;
document.getElementById('sensitiveHint').style.display =
SENSITIVE.has(k) ? 'inline' : 'none';
};
}
async function editConfig(key) {
try {
const r = await fetchJson(`/api/config/${encodeURIComponent(key)}`);
_editingKey = key;
document.getElementById('modalTitle').textContent = '编辑配置 ' + key;
document.getElementById('modalKey').value = key;
document.getElementById('modalKey').disabled = true;
document.getElementById('modalValue').value = ''; // 编辑凭证类时强制重新输入完整值
document.getElementById('modalValue').placeholder = SENSITIVE.has(key)
? '凭证类 — 输入完整值(显示已脱敏)' : '新值(留空保留原值不可行,PUT 接口会覆盖)';
document.getElementById('modalCategory').value = r.category || 'general';
document.getElementById('modalDesc').value = r.description || '';
document.getElementById('sensitiveHint').style.display =
SENSITIVE.has(key) ? 'inline' : 'none';
document.getElementById('modal').style.display = 'flex';
} catch (e) {
alertBox('加载失败: ' + e.message, 'warn');
}
}
function hideModal() { document.getElementById('modal').style.display = 'none'; }
async function saveModal() {
const key = document.getElementById('modalKey').value.trim();
const value = document.getElementById('modalValue').value;
const category = document.getElementById('modalCategory').value.trim() || 'general';
const description = document.getElementById('modalDesc').value;
if (!key) { alertBox('Key 不能为空', 'warn'); return; }
try {
await fetchJson(`/api/config/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value, category, description}),
});
hideModal();
alertBox(`✓ 已保存 ${key}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('保存失败: ' + e.message, 'warn');
}
}
async function deleteConfig(key) {
if (!confirm(`确认删除 ${key} ?`)) return;
try {
await fetchJson(`/api/config/${encodeURIComponent(key)}`, {method: 'DELETE'});
alertBox(`✓ 已删除 ${key}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('删除失败: ' + e.message, 'warn');
}
}
async function reloadFromEnv() {
if (!confirm('把 .env / 环境变量里已知配置回填到 config 表(会覆盖)?')) return;
try {
const r = await fetchJson('/api/config/reload', {method: 'POST'});
alertBox(`✓ 已回填 ${r.synced}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('回填失败: ' + e.message, 'warn');
}
}
document.getElementById('catFilter').addEventListener('change', renderTable);
document.getElementById('searchBox').addEventListener('input', renderTable);
loadConfigs();
</script>
</body>
</html>
+231
View File
@@ -0,0 +1,231 @@
<!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>