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:
+10
-1
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
# ── 启动钩子 ──────────────────────────────────────────────────────────
|
# ── 启动钩子 ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user