Commit 184feb52 authored by 郭阔's avatar 郭阔

feat(panel): 新增「数据看板」——采集产出统计 dashboard

- KPI 卡:今日文章/今日场次(成/部/败)/7天文章/7天场次/7天成本/当前运行
- 每日产出堆叠柱状图(按站点着色,悬浮看站点明细)+图例
- 逐源表现表(今日/7天文章、场次✓⚠✗、最近运行状态)
- 失败原因 Top(7天,按类型计数+比例条)
- 数据一次遍历运行 JSON 聚合,5 分钟自动刷新;导航置于栏目列表之后
parent f313bd51
......@@ -1309,6 +1309,85 @@ def guide():
return render_template("guide.html", body=body, raw=text)
SITE_COLORS = {"reuters": "#e05555", "nikkei": "#6b9ef1", "wsj": "#4ec9b0", "ft": "#e8c34a",
"bloomberg": "#c586c0", "digitimes": "#8badd4", "wccftech": "#8a8f98"}
@app.route("/dashboard")
@require_auth
def dashboard():
"""数据看板:产出统计(今日/7天)、按站点堆叠的每日趋势、逐源表现、失败原因 Top。"""
now = datetime.now()
today = now.strftime("%Y-%m-%d")
skills = pl.skill_sources()
names = sorted(n for n in (set(skills) | set(pl.list_schedules())) - {"digest"}
if skills.get(n, {}).get("enabled", False) or pl.schedule_state(n) == "active")
days = [(now - timedelta(days=i)).strftime("%Y-%m-%d") for i in range(6, -1, -1)]
per_day = {d: {} for d in days}
fail_top = {}
tot = {"arts": 0, "runs": 0, "ok": 0, "warn": 0, "fail": 0, "cost": 0.0,
"today_arts": 0, "today_runs": 0, "today_ok": 0, "today_warn": 0, "today_fail": 0}
rows = []
for n in names:
site = n.split("-")[0]
row = {"name": n, "display": skills.get(n, {}).get("name") or n, "site": site,
"today": 0, "d7": 0, "runs7": 0, "ok": 0, "warn": 0, "fail": 0,
"last_status": "—", "last_at": "—"}
for run in get_history_runs(n, include_data=True):
day = run["run_at"][:10]
if day not in per_day:
continue
succ = run["succeeded_count"] or 0
status = run["status"]
data = run["data"]
if row["last_at"] == "—":
row["last_at"], row["last_status"] = run["run_at"], status
row["runs7"] += 1; row["d7"] += succ
tot["runs"] += 1; tot["arts"] += succ
tot["cost"] += float(data.get("total_cost_usd") or 0)
if status == "SUCCESS":
row["ok"] += 1; tot["ok"] += 1
elif status == "WARNING":
row["warn"] += 1; tot["warn"] += 1
else:
row["fail"] += 1; tot["fail"] += 1
if day == today:
row["today"] += succ
tot["today_arts"] += succ; tot["today_runs"] += 1
tot["today_ok" if status == "SUCCESS" else "today_warn" if status == "WARNING" else "today_fail"] += 1
per_day[day][site] = per_day[day].get(site, 0) + succ
result = data.get("result")
if isinstance(result, dict):
for f in result.get("failures", []):
key = f.get("type", "UNKNOWN")
fail_top[key] = fail_top.get(key, 0) + 1
rows.append(row)
# 图表:按站点堆叠的每日产出
sites = sorted({r["site"] for r in rows if r["d7"] > 0},
key=lambda s: -sum(per_day[d].get(s, 0) for d in days))
chart_max = max([sum(v.values()) for v in per_day.values()] or [1]) or 1
bars = []
for d in days:
segs, y = [], 100.0
total = sum(per_day[d].values())
for s in sites:
v = per_day[d].get(s, 0)
if not v:
continue
h = v / chart_max * 100
y -= h
segs.append({"site": s, "v": v, "y": round(y, 1), "h": round(h, 1),
"color": SITE_COLORS.get(s, "#8a8f98")})
bars.append({"day": d, "total": total, "segs": segs})
rows.sort(key=lambda r: (-r["today"], -r["d7"]))
active = _active_harvests()
return render_template("dashboard.html", rows=rows, bars=bars, days=days,
sites=[(s, SITE_COLORS.get(s, "#8a8f98"),
sum(per_day[d].get(s, 0) for d in days)) for s in sites],
tot=tot, today=today, active=active,
fail_top=sorted(fail_top.items(), key=lambda kv: -kv[1])[:6])
def _latest_run_articles(name):
"""源最近一次运行的文章明细(标题/链接/状态/失败原因),供最近抓取页与调度中心详情抽屉共用。"""
runs = get_history_runs(name, include_data=True)
......
......@@ -218,6 +218,7 @@
<nav>
<a class="logo" href="{{ url_for('index') }}">📰 News Harvester</a>
<a href="{{ url_for('index') }}">栏目列表</a>
<a href="{{ url_for('dashboard') }}">数据看板</a>
<a href="{{ url_for('schedule_board') }}">调度中心</a>
<a href="{{ url_for('add_source') }}">添加栏目</a>
<a href="{{ url_for('guide') }}">接入指南</a>
......
{% extends "base.html" %}
{% block title %}数据看板 — News Harvester{% endblock %}
{% block extra_head %}
<meta http-equiv="refresh" content="300">
<style>
.kpi { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:10px; margin:14px 0; }
.kpi .card { padding:14px 16px; }
.kpi .num { font-size:26px; font-weight:700; line-height:1.2; }
.kpi .sub { color:#888; font-size:12px; margin-top:2px; }
.chart { display:flex; align-items:flex-end; gap:6px; height:180px; margin:10px 0 4px; }
.chart .bar { flex:1; display:flex; flex-direction:column; justify-content:flex-end; position:relative; min-width:0; }
.chart .seg { width:100%; }
.chart .bar .total { position:absolute; top:-20px; width:100%; text-align:center; font-size:12px; color:#aaa; }
.chart-labels { display:flex; gap:6px; margin-top:22px; }
.chart-labels span { flex:1; text-align:center; font-size:11px; color:#666; }
.legend { display:flex; gap:14px; flex-wrap:wrap; margin:8px 0 0; font-size:12px; color:#aaa; }
.legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:5px; vertical-align:-1px; }
</style>
{% endblock %}
{% block content %}
<h1>数据看板</h1>
<p style="color:#666;font-size:13px">最近 7 天采集产出统计,每 5 分钟自动刷新 · 文章数=归档并推送成功的篇数</p>
<div class="kpi">
<div class="card"><div class="label">今日文章</div><div class="num" style="color:#4ec9b0">{{ tot.today_arts }}</div><div class="sub">篇已交付</div></div>
<div class="card"><div class="label">今日场次</div><div class="num">{{ tot.today_runs }}</div><div class="sub"><span style="color:#6bbb6b">{{ tot.today_ok }} 成功</span> · <span style="color:#e8c34a">{{ tot.today_warn }} 部分</span> · <span style="color:#bb6b6b">{{ tot.today_fail }} 失败</span></div></div>
<div class="card"><div class="label">7 天文章</div><div class="num">{{ tot.arts }}</div><div class="sub">篇</div></div>
<div class="card"><div class="label">7 天场次</div><div class="num">{{ tot.runs }}</div><div class="sub"><span style="color:#6bbb6b">{{ tot.ok }} ✓</span> · <span style="color:#e8c34a">{{ tot.warn }} ⚠</span> · <span style="color:#bb6b6b">{{ tot.fail }} ✗</span></div></div>
<div class="card"><div class="label">7 天成本</div><div class="num">${{ '%.2f'|format(tot.cost) }}</div><div class="sub">全部 provider 合计</div></div>
<div class="card"><div class="label">当前运行</div>
{% if active %}<div class="num" style="color:#e8c34a;font-size:20px">{{ active|length }}</div><div class="sub">{{ active|join('、') }}</div>
{% else %}<div class="num" style="color:#666">0</div><div class="sub">空闲</div>{% endif %}
</div>
</div>
<h2>每日产出(按站点堆叠)</h2>
<div class="chart">
{% for b in bars %}
<div class="bar" title="{{ b.day }} 共 {{ b.total }} 篇{% for s in b.segs %}&#10;{{ s.site }}: {{ s.v }}{% endfor %}">
{% if b.total %}<div class="total">{{ b.total }}</div>{% endif %}
{% for s in b.segs %}<div class="seg" style="height:{{ s.h }}%;background:{{ s.color }}"></div>{% endfor %}
</div>
{% endfor %}
</div>
<div class="chart-labels">{% for b in bars %}<span>{{ b.day[5:] }}</span>{% endfor %}</div>
<div class="legend">
{% for s, color, n in sites %}<span><i style="background:{{ color }}"></i>{{ s }} {{ n }} 篇</span>{% endfor %}
</div>
<div style="display:flex;gap:24px;flex-wrap:wrap;margin-top:22px;align-items:flex-start">
<div style="flex:3 1 420px;min-width:340px">
<h2 style="margin-top:0">逐源表现(7 天)</h2>
<div class="table-wrap">
<table>
<thead><tr><th>栏目</th><th>今日</th><th>7天文章</th><th>场次 ✓/⚠/✗</th><th>最近运行</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td><code>{{ r.name }}</code>{% if r.display and r.display != r.name %} <span style="color:#888">{{ r.display }}</span>{% endif %}</td>
<td style="font-weight:{{ 'bold' if r.today else 'normal' }}">{{ r.today or '—' }}</td>
<td>{{ r.d7 }}</td>
<td>{{ r.runs7 }} · <span style="color:#6bbb6b">{{ r.ok }}</span>/<span style="color:#e8c34a">{{ r.warn }}</span>/<span style="color:#bb6b6b">{{ r.fail }}</span></td>
<td>{{ r.last_at[5:16] if r.last_at != '—' else '—' }}
{% if r.last_status == 'SUCCESS' %}<span class="badge ok">✓</span>
{% elif r.last_status == 'WARNING' %}<span class="badge warn">⚠</span>
{% elif r.last_status != '—' %}<span class="badge fail">✗</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div style="flex:2 1 260px;min-width:240px">
<h2 style="margin-top:0">失败原因 Top(7 天)</h2>
{% if fail_top %}
<div class="table-wrap">
<table>
<thead><tr><th>类型</th><th>次数</th><th></th></tr></thead>
<tbody>
{% for k, v in fail_top %}
<tr><td><code>{{ k }}</code></td><td>{{ v }}</td>
<td><div style="background:#e05555;border-radius:2px;height:10px;width:{{ (v * 100 / fail_top[0][1])|round }}%"></div></td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p style="color:#666">近 7 天无失败记录 🎉</p>{% endif %}
</div>
</div>
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment