5cb64f181a
Dashboard (M3):
- 路由 /api/dashboard/{daily,history,today,datasets,stats}/
- daily_summary: 按日期聚合 ok/warning/error/blocked
- history: 同步历史详情(按 task / 日期过滤)
- today: 今日 KPI + 任务列表
- datasets: dataset_registry + 最近 7d run 合并
- stats: 各表行数 + 日期范围(17 张表)
HTML SPA: app/api/static/dashboard.html
- 5 张状态卡片 + 7 天趋势表 + 任务状态表 + 数据规模表 + 历史详情
- 自动 30s 刷新, 纯 vanilla JS (无框架依赖)
Config (M5):
- 路由 /api/config/{GET list, GET key, PUT upsert, DELETE}/
- 凭证类自动脱敏 (MAIRUI_LICENCE / XUEQIU_TOKEN / MAIRUI_RPS_LIMIT)
- POST /api/config/reload: 把 .env / 环境变量已知项回填到 config 表
- 分类过滤 + key 搜索 + 凭证提示
HTML SPA: app/api/static/config.html
- 列表 + 编辑弹窗 + 删除确认
- 凭证类编辑时强制重新输入完整值(避免显示脱敏误以为是新值)
- 一键从环境变量回填
FastAPI 主入口挂载:
- include_router(dashboard, config)
- mount('/static', StaticFiles) 提供 HTML
133 lines
4.8 KiB
Python
133 lines
4.8 KiB
Python
"""看板 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") |