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
+10 -1
View File
@@ -13,10 +13,11 @@ if str(_PROJECT_ROOT) not in sys.path:
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.core.config import settings from app.core.config import settings
from app.core.utils.logging import get_logger, setup_logging from app.core.utils.logging import get_logger, setup_logging
from app.api.routes import health, sync, datasources, schedule from app.api.routes import health, sync, datasources, schedule, dashboard, config
setup_logging() setup_logging()
logger = get_logger("api") logger = get_logger("api")
@@ -38,6 +39,14 @@ app.include_router(health.router)
app.include_router(sync.router) app.include_router(sync.router)
app.include_router(datasources.router) app.include_router(datasources.router)
app.include_router(schedule.router) app.include_router(schedule.router)
app.include_router(dashboard.router)
app.include_router(config.router)
# 看板 HTML 静态文件
import pathlib
_static_dir = pathlib.Path(__file__).resolve().parent / "static"
if _static_dir.exists():
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
# ── 启动钩子 ────────────────────────────────────────────────────────── # ── 启动钩子 ──────────────────────────────────────────────────────────
+120
View File
@@ -0,0 +1,120 @@
"""配置管理 API:读写 config 表(MAIRUI_LICENCE / XUEQIU_TOKEN 等可视化配置)。
凭证类配置(MAIRUI_LICENCE / XUEQIU_TOKEN)写到 config 表后,可在下次
任务运行时通过 settings 重新读取(注: 当前代码是 os.environ.get 一启动就
固化,改 config 表不会立即生效 — 见 README 说明)。
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Body, HTTPException, Query
from app.core.db import ops as db_ops
from app.core.db.models import Config
router = APIRouter(prefix="/api/config", tags=["config"])
# 已知凭证类配置: 写入时建议显示为占位符,读出时也建议脱敏
SENSITIVE_KEYS = {"MAIRUI_LICENCE", "XUEQIU_TOKEN", "MAIRUI_RPS_LIMIT"}
def _mask(key: str, value: str) -> str:
"""凭证类配置显示前 8 位 + ***。"""
if key in SENSITIVE_KEYS and value:
if len(value) <= 8:
return "*" * len(value)
return value[:8] + "***"
return value
@router.get("/")
def list_configs(
category: Optional[str] = Query(None),
search: Optional[str] = Query(None, description="按 key 模糊搜索"),
):
"""列出所有 config 项。凭证类自动脱敏。"""
rows = db_ops.fetch_all_config()
if category:
rows = [r for r in rows if r.get("category") == category]
if search:
s = search.lower()
rows = [r for r in rows if s in r["key"].lower()]
# 脱敏
for r in rows:
r["value"] = _mask(r["key"], r.get("value", ""))
return {"count": len(rows), "configs": rows}
@router.get("/{key}")
def get_config(key: str):
r = db_ops.fetch_config_by_key(key)
if not r:
raise HTTPException(status_code=404, detail=f"config {key!r} not found")
r["value"] = _mask(key, r.get("value", ""))
return r
@router.put("/{key}")
def upsert_config(
key: str,
body: dict = Body(...),
):
"""新建或更新一条 config。"""
value = body.get("value", "")
category = body.get("category", "general")
description = body.get("description", "")
if not isinstance(value, str):
raise HTTPException(status_code=400, detail="value must be a string")
db_ops.upsert_config(key=key, value=value, category=category, description=description)
return {"ok": True, "key": key}
@router.delete("/{key}")
def delete_config(key: str):
db_ops.delete_config(key)
return {"ok": True, "key": key}
@router.get("/categories/list")
def list_categories():
"""返回所有 category(用于前端下拉)。"""
from sqlalchemy import distinct, select
with db_ops.get_session() as s:
cats = s.execute(select(distinct(Config.category))).scalars().all()
return {"categories": sorted([c for c in cats if c])}
@router.post("/reload")
def reload_from_env():
"""把 .env / 环境变量里的已知配置回填到 config 表(覆盖式)。"""
import os
from app.core.config import settings
# 已知 env-driven 配置项
env_keys = [
("MAIRUI_LICENCE", os.environ.get("MAIRUI_LICENCE", ""), "general", "麦蕊智数 licence"),
("XUEQIU_TOKEN", os.environ.get("XUEQIU_TOKEN", ""), "credential", "雪球 token (含 xq_a_token=...;u=...)"),
("MAIRUI_RPS_LIMIT", os.environ.get("MAIRUI_RPS_LIMIT", ""), "tuning", "麦蕊智数 RPS 限速"),
("DS_BAOSTOCK_ENABLED", str(settings.ds_baostock_enabled).lower(), "datasource", "baostock 开关"),
("DS_SINA_ENABLED", str(settings.ds_sina_enabled).lower(), "datasource", "新浪 开关"),
("TRADING_HOLIDAYS", settings.trading_holidays, "trading_calendar", "A 股休市日 (YYYY-MM-DD,逗号分隔)"),
("API_PORT", str(settings.api_port), "api", "API 监听端口"),
("SCHEDULER_TICK_SECONDS", str(settings.scheduler_tick_seconds), "scheduler", "调度器扫描间隔"),
]
n = 0
for key, value, cat, desc in env_keys:
if not value:
continue
db_ops.upsert_config(key=key, value=value, category=cat, description=desc)
n += 1
return {"ok": True, "synced": n}
@router.get("/page")
def config_page():
"""配置页 HTML 入口(前端单文件 SPA)。"""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/static/config.html")
+133
View File
@@ -0,0 +1,133 @@
"""看板 API:每天同步情况 / 任务历史 / 数据集统计。
复用 db_ops.list_sync_history() + daily_sync_summary()。
"""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Query
from app.core.db import ops as db_ops
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/daily")
def daily_summary(days: int = Query(7, ge=1, le=90)):
"""按日期聚合的同步状态计数(看板首页用)。"""
return {"days": days, "summary": db_ops.daily_sync_summary(days=days)}
@router.get("/history")
def sync_history(
dataset_id: str | None = Query(None),
days: int = Query(7, ge=1, le=90),
limit: int = Query(200, ge=1, le=1000),
run_date: str | None = Query(None, description="YYYY-MM-DD 过滤某天"),
):
"""同步历史记录(每次 run 一行,append-only)。"""
rd = None
if run_date:
try:
rd = date.fromisoformat(run_date)
except ValueError:
return {"error": f"run_date must be YYYY-MM-DD, got {run_date!r}"}
rows = db_ops.list_sync_history(
dataset_id=dataset_id, run_date=rd, days=days, limit=limit,
)
return {"dataset_id": dataset_id, "days": days, "count": len(rows), "history": rows}
@router.get("/today")
def today_summary():
"""今天的同步情况(看板头部卡片用)。"""
rows = db_ops.daily_sync_summary(days=1)
today_str = date.today().isoformat()
today = next((r for r in rows if r["date"] == today_str), None) or {
"date": today_str, "ok": 0, "warning": 0, "error": 0, "blocked": 0,
"total": 0, "tasks_run": 0,
}
# 列出今天每个 task 的最新状态
history = db_ops.list_sync_history(days=1, limit=500)
return {"summary": today, "tasks": history}
@router.get("/datasets")
def dataset_overview():
"""所有 dataset 的当前状态 + 最近一次 run。
合并 dataset_registry(当前状态)+ sync_history(最近一次历史)。
"""
from app.core.sync import get_registry_status
registry = get_registry_status()
history = db_ops.list_sync_history(days=7, limit=500)
last_by_task: dict[str, dict] = {}
for h in history:
if h["dataset_id"] not in last_by_task:
last_by_task[h["dataset_id"]] = h
enriched = []
for r in registry:
ds_id = r["dataset_id"]
last = last_by_task.get(ds_id)
enriched.append({
**r,
"last_run": last,
"history_count_7d": sum(1 for h in history if h["dataset_id"] == ds_id),
})
return {"count": len(enriched), "datasets": enriched}
@router.get("/stats")
def data_stats():
"""数据集的数据范围/数据量(用于看板"数据规模"卡片)。"""
from sqlalchemy import func, select
from app.core.db.models import (
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
SectorIndices, SectorFeaturesDaily, MarketRegimeDaily,
LonghubangDaily, LonghubangSeat, Stocks, Industry,
NodeCategory, Node, StockNodeMap, KlineStockMADaily,
)
from app.core.db.orm import SessionLocal
targets = [
("stocks", Stocks),
("kline_stock", KlineStock),
("kline_index", KlineIndex),
("kline_5min", Kline5Min),
("kline_stock_ma_daily", KlineStockMADaily),
("moneyflow", Moneyflow),
("share", Share),
("tick_trade", TickTrade),
("sector_indices", SectorIndices),
("sector_features_daily", SectorFeaturesDaily),
("market_regime_daily", MarketRegimeDaily),
("longhubang_daily", LonghubangDaily),
("longhubang_seat", LonghubangSeat),
("industry", Industry),
("node_categories", NodeCategory),
("nodes", Node),
("stock_node_map", StockNodeMap),
]
out = []
with SessionLocal() as s:
for name, model in targets:
try:
count = s.execute(select(func.count()).select_from(model)).scalar() or 0
# 尝试拿最早/最新 trade_date
date_range = None
if hasattr(model, "trade_date"):
mn = s.execute(select(func.min(model.trade_date))).scalar()
mx = s.execute(select(func.max(model.trade_date))).scalar()
date_range = {"min": str(mn), "max": str(mx)} if mn else None
out.append({"table": name, "rows": int(count), "date_range": date_range})
except Exception as e:
out.append({"table": name, "rows": -1, "error": str(e)[:100]})
return {"tables": out}
@router.get("/")
def dashboard_index():
"""看板 HTML 入口(前端单文件 SPA)。"""
from fastapi.responses import FileResponse, RedirectResponse
return RedirectResponse(url="/static/dashboard.html")
+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>