fix(web): align trend ranges, next runs and preview task records

This commit is contained in:
kairee committed 2026-10-31 23:46:49 +08:00
1 parent 19572171b8
commit 257fbe009b
7 files changed
+139 -10

No files matched your search

+21 -10
View File
@@ -593,14 +593,15 @@ class DashboardQueries:
items.sort(key=lambda item: item["aid"])
items.sort(key=sort_key, reverse=True)
total = len(items)
growth_available = any(
item.get("growth", {}).get("available") for item in items
)
if not export:
start = (page - 1) * page_size
items = items[start:start + page_size]
available = True
reason = None
if sort in {"growth", "growth_rate"} and not any(
item.get("growth", {}).get("available") for item in items
):
if sort in {"growth", "growth_rate"} and not growth_available:
available = False
reason = "需要至少两天的快照才能计算增长"
return {
@@ -678,7 +679,12 @@ class DashboardQueries:
"points": [],
"available": False,
}
start = _date_minus(latest, days - 1) if days is not None else str(points[0]["stat_date"])
requested_start = (
_date_minus(latest, days - 1) if days is not None
else str(points[0]["stat_date"])
)
first_point = str(points[0]["stat_date"])
start = max(requested_start, first_point)
dates = _date_range(start, latest)
by_date = {str(point["stat_date"]): point for point in points}
series: dict[str, list[int | None]] = {}
@@ -923,7 +929,8 @@ class DashboardQueries:
"days": days,
}
latest = max(str(row["stat_date"]) for row in rows if int(row["ok"]) == 1) if any(int(row["ok"]) == 1 for row in rows) else str(rows[-1]["stat_date"])
start = _date_minus(latest, days - 1)
first_date = str(rows[0]["stat_date"])
start = max(_date_minus(latest, days - 1), first_date)
dates = _date_range(start, latest)
by_date = {str(row["stat_date"]): row for row in rows}
series = []
@@ -1034,13 +1041,17 @@ class DashboardQueries:
"folder_sync": int(now + cfg.schedule.folder_sync_minutes * 60),
"snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)),
}
for name, key in (("discover", "discover"), ("folder_sync", "folder_sync")):
for name, key, interval_minutes in (
("discover", "discovery", cfg.schedule.discover_minutes),
("folder_sync", "folder_sync", cfg.schedule.folder_sync_minutes),
):
record = tasks.get(key)
if isinstance(record, dict) and record.get("finished_at"):
next_runs[name] = int(record["finished_at"]) + (
cfg.schedule.discover_minutes if name == "discover"
else cfg.schedule.folder_sync_minutes
) * 60
interval = int(interval_minutes) * 60
candidate = int(record["finished_at"]) + interval
while candidate <= now:
candidate += interval
next_runs[name] = candidate
errors = [
{"task": name, **record}
for name, record in tasks.items()
+1
View File
@@ -215,6 +215,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
html = """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<title>登录 · Reaction 看板</title>
<style>
:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;
+29
View File
@@ -194,6 +194,34 @@ def test_run_once_offline():
db.close()
def test_serve_preview_guard():
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp)
assert run_cli(["init", "--config", str(path)])[0] == 0
db_path = Path(tmp) / "app.db"
try:
cli._assert_preview_database(db_path)
except cli.ConfigError as exc:
assert "preview:seeded" in str(exc)
else:
raise AssertionError("普通数据库不应通过 --preview 校验")
db = Database(db_path)
db.initialize()
try:
Repository(db).kv_set("preview:seeded", "1")
finally:
db.close()
cli._assert_preview_database(db_path)
try:
cli._assert_preview_database(Path(tmp) / "missing.db")
except cli.ConfigError as exc:
assert "不存在" in str(exc)
else:
raise AssertionError("缺失数据库应报错")
def test_default_command_starts_web_and_scheduler():
with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp)
@@ -299,6 +327,7 @@ def main_test():
test_auth_check_without_cookie()
test_auth_check_all_and_auth_dispatch()
test_run_once_offline()
test_serve_preview_guard()
test_default_command_starts_web_and_scheduler()
test_snapshot_backup_failure_returns_error_and_retries()
test_candidates_and_discover_commands()
+3
View File
@@ -203,6 +203,9 @@ def test_seed_preview_end_to_end():
assert conn.execute(
"SELECT 1 FROM up_blacklist WHERE mid=?", (FAKE_BLACKLIST_MID,)
).fetchone() is not None
assert conn.execute(
"SELECT 1 FROM kv WHERE k='task:last:snapshot'"
).fetchone() is not None
up_rows = conn.execute(
"SELECT stat_date, follower FROM up_stat WHERE mid=37754047 "
"ORDER BY stat_date"
+8
View File
@@ -142,6 +142,10 @@ def test_video_detail_and_trend():
assert trend["available"] is True
assert len(trend["points"]) == 3
# 请求区间早于首次快照时,从首次快照开始,不制造空白历史
wide = q.video_trend(101, days=30)
assert wide["dates"] == list(DATES)
# 删除中间一天,折线在缺失日期应为 None,而不是插值
db.write(lambda conn: conn.execute(
"DELETE FROM snapshot WHERE aid=101 AND stat_date=?",
@@ -204,6 +208,8 @@ def test_fans_candidates_system_and_export():
assert fans["delta_7d"] is None
assert fans["delta_cum"] == 20
assert fans["options"][0]["mid"] == 37754047
wide = q.fans(days=30)
assert wide["dates"] == list(DATES)
candidates = q.candidates()
assert candidates["total"] == 2
@@ -220,6 +226,8 @@ def test_fans_candidates_system_and_export():
assert system["errors"] == []
assert system["preview"] is False
assert system["next_runs"]["snapshot"] > 0
assert system["next_runs"]["folder_sync"] > FIXED_NOW
assert system["next_runs"]["discover"] > FIXED_NOW
exported = q.export_videos(sort="view")
assert exported["total"] == 3
+7
View File
@@ -74,6 +74,13 @@ def test_readonly_routes_and_static():
assert "text/html" in headers["Content-Type"]
assert b"<html" in payload.lower()
for asset in ("/static/style.css", "/static/app.js", "/static/vendor/echarts.min.js"):
status, asset_headers, asset_payload = running.request(asset)
assert status == 200, asset
assert asset_payload, asset
content_type = asset_headers["Content-Type"]
assert "text/css" in content_type or "javascript" in content_type, asset
status, _, payload = running.request("/static/../config.json")
assert status == 404
+70
View File
@@ -254,6 +254,69 @@ def _insert_snapshot(
)
def _seed_task_records(conn: sqlite3.Connection, latest_day: date) -> None:
"""写入可读的最近任务状态,让预览系统页有内容可看。"""
base = _timestamp(latest_day, hour=0, minute=1)
records = {
"snapshot": {
"name": "snapshot", "ok": True,
"started_at": int(base),
"finished_at": int(base + 1124),
"error": None,
"summary": {
"stat_date": latest_day.isoformat(), "total": 796, "ok": 796,
"failed": 0, "removed": 0, "completed": True,
"backup_path": f"data/backup/app-{latest_day.strftime('%Y%m%d')}.db",
},
},
"folder_sync": {
"name": "folder_sync", "ok": True,
"started_at": int(base + 20 * 3600),
"finished_at": int(base + 20 * 3600 + 8),
"error": None,
"summary": {"folders": 2, "added": 65, "removed": 0, "ok": True},
},
"discovery": {
"name": "discovery", "ok": True,
"started_at": int(base + 21 * 3600),
"finished_at": int(base + 21 * 3600 + 24),
"error": None,
"summary": {
"searches": 4, "items": 168, "candidates": 4,
"notified": 3, "errors": [],
},
},
"candidate_cleanup": {
"name": "candidate_cleanup", "ok": True,
"started_at": int(base + 22 * 3600),
"finished_at": int(base + 22 * 3600 + 1),
"error": None,
"summary": {"candidates_removed": 0, "fav_actions_expired": 0},
},
}
for name, record in records.items():
conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES (?, ?)",
(f"task:last:{name}", json.dumps(record, ensure_ascii=False)),
)
conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES ('snapshot:last_attempt', ?)",
(latest_day.isoformat(),),
)
conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES ('snapshot:last_completed', ?)",
(latest_day.isoformat(),),
)
conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES (?, '1')",
(f"snapshot:backup:{latest_day.isoformat()}",),
)
conn.execute(
"INSERT OR REPLACE INTO kv(k, v) VALUES (?, '')",
(f"snapshot:backup_error:{latest_day.isoformat()}",),
)
def _seed_blacklist(conn: sqlite3.Connection, now: float) -> None:
conn.execute(
"""
@@ -495,6 +558,7 @@ def seed_preview(
up_rows = _seed_up_stats(conn, latest_day, days, anomaly_rng)
_seed_candidates(conn, latest_day)
_seed_blacklist(conn, _timestamp(latest_day, hour=14))
_seed_task_records(conn, latest_day)
else:
up_rows = 0
conn.execute("DELETE FROM candidate WHERE channel = 'preview'")
@@ -635,6 +699,12 @@ def main(argv: Sequence[str] | None = None) -> int:
config_out=args.config_out,
with_anomalies=not args.no_anomalies,
)
except PermissionError as exc:
print(json.dumps({
"ok": False,
"error": f"目标数据库被占用,请先停止预览服务: {exc}",
}, ensure_ascii=False))
return 2
except (ValueError, FileNotFoundError, FileExistsError, RuntimeError, sqlite3.Error) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 2