fix(web): align trend ranges, next runs and preview task records
This commit is contained in:
1 parent
19572171b8
commit
257fbe009b
7 files changed
+139
-10
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user