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=lambda item: item["aid"])
|
||||||
items.sort(key=sort_key, reverse=True)
|
items.sort(key=sort_key, reverse=True)
|
||||||
total = len(items)
|
total = len(items)
|
||||||
|
growth_available = any(
|
||||||
|
item.get("growth", {}).get("available") for item in items
|
||||||
|
)
|
||||||
if not export:
|
if not export:
|
||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
items = items[start:start + page_size]
|
items = items[start:start + page_size]
|
||||||
available = True
|
available = True
|
||||||
reason = None
|
reason = None
|
||||||
if sort in {"growth", "growth_rate"} and not any(
|
if sort in {"growth", "growth_rate"} and not growth_available:
|
||||||
item.get("growth", {}).get("available") for item in items
|
|
||||||
):
|
|
||||||
available = False
|
available = False
|
||||||
reason = "需要至少两天的快照才能计算增长"
|
reason = "需要至少两天的快照才能计算增长"
|
||||||
return {
|
return {
|
||||||
@@ -678,7 +679,12 @@ class DashboardQueries:
|
|||||||
"points": [],
|
"points": [],
|
||||||
"available": False,
|
"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)
|
dates = _date_range(start, latest)
|
||||||
by_date = {str(point["stat_date"]): point for point in points}
|
by_date = {str(point["stat_date"]): point for point in points}
|
||||||
series: dict[str, list[int | None]] = {}
|
series: dict[str, list[int | None]] = {}
|
||||||
@@ -923,7 +929,8 @@ class DashboardQueries:
|
|||||||
"days": days,
|
"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"])
|
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)
|
dates = _date_range(start, latest)
|
||||||
by_date = {str(row["stat_date"]): row for row in rows}
|
by_date = {str(row["stat_date"]): row for row in rows}
|
||||||
series = []
|
series = []
|
||||||
@@ -1034,13 +1041,17 @@ class DashboardQueries:
|
|||||||
"folder_sync": int(now + cfg.schedule.folder_sync_minutes * 60),
|
"folder_sync": int(now + cfg.schedule.folder_sync_minutes * 60),
|
||||||
"snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)),
|
"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)
|
record = tasks.get(key)
|
||||||
if isinstance(record, dict) and record.get("finished_at"):
|
if isinstance(record, dict) and record.get("finished_at"):
|
||||||
next_runs[name] = int(record["finished_at"]) + (
|
interval = int(interval_minutes) * 60
|
||||||
cfg.schedule.discover_minutes if name == "discover"
|
candidate = int(record["finished_at"]) + interval
|
||||||
else cfg.schedule.folder_sync_minutes
|
while candidate <= now:
|
||||||
) * 60
|
candidate += interval
|
||||||
|
next_runs[name] = candidate
|
||||||
errors = [
|
errors = [
|
||||||
{"task": name, **record}
|
{"task": name, **record}
|
||||||
for name, record in tasks.items()
|
for name, record in tasks.items()
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
|||||||
html = """<!doctype html>
|
html = """<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
<title>登录 · Reaction 看板</title>
|
<title>登录 · Reaction 看板</title>
|
||||||
<style>
|
<style>
|
||||||
:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;
|
:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;
|
||||||
|
|||||||
@@ -194,6 +194,34 @@ def test_run_once_offline():
|
|||||||
db.close()
|
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():
|
def test_default_command_starts_web_and_scheduler():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = write_config(tmp)
|
path = write_config(tmp)
|
||||||
@@ -299,6 +327,7 @@ def main_test():
|
|||||||
test_auth_check_without_cookie()
|
test_auth_check_without_cookie()
|
||||||
test_auth_check_all_and_auth_dispatch()
|
test_auth_check_all_and_auth_dispatch()
|
||||||
test_run_once_offline()
|
test_run_once_offline()
|
||||||
|
test_serve_preview_guard()
|
||||||
test_default_command_starts_web_and_scheduler()
|
test_default_command_starts_web_and_scheduler()
|
||||||
test_snapshot_backup_failure_returns_error_and_retries()
|
test_snapshot_backup_failure_returns_error_and_retries()
|
||||||
test_candidates_and_discover_commands()
|
test_candidates_and_discover_commands()
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ def test_seed_preview_end_to_end():
|
|||||||
assert conn.execute(
|
assert conn.execute(
|
||||||
"SELECT 1 FROM up_blacklist WHERE mid=?", (FAKE_BLACKLIST_MID,)
|
"SELECT 1 FROM up_blacklist WHERE mid=?", (FAKE_BLACKLIST_MID,)
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT 1 FROM kv WHERE k='task:last:snapshot'"
|
||||||
|
).fetchone() is not None
|
||||||
up_rows = conn.execute(
|
up_rows = conn.execute(
|
||||||
"SELECT stat_date, follower FROM up_stat WHERE mid=37754047 "
|
"SELECT stat_date, follower FROM up_stat WHERE mid=37754047 "
|
||||||
"ORDER BY stat_date"
|
"ORDER BY stat_date"
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ def test_video_detail_and_trend():
|
|||||||
assert trend["available"] is True
|
assert trend["available"] is True
|
||||||
assert len(trend["points"]) == 3
|
assert len(trend["points"]) == 3
|
||||||
|
|
||||||
|
# 请求区间早于首次快照时,从首次快照开始,不制造空白历史
|
||||||
|
wide = q.video_trend(101, days=30)
|
||||||
|
assert wide["dates"] == list(DATES)
|
||||||
|
|
||||||
# 删除中间一天,折线在缺失日期应为 None,而不是插值
|
# 删除中间一天,折线在缺失日期应为 None,而不是插值
|
||||||
db.write(lambda conn: conn.execute(
|
db.write(lambda conn: conn.execute(
|
||||||
"DELETE FROM snapshot WHERE aid=101 AND stat_date=?",
|
"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_7d"] is None
|
||||||
assert fans["delta_cum"] == 20
|
assert fans["delta_cum"] == 20
|
||||||
assert fans["options"][0]["mid"] == 37754047
|
assert fans["options"][0]["mid"] == 37754047
|
||||||
|
wide = q.fans(days=30)
|
||||||
|
assert wide["dates"] == list(DATES)
|
||||||
|
|
||||||
candidates = q.candidates()
|
candidates = q.candidates()
|
||||||
assert candidates["total"] == 2
|
assert candidates["total"] == 2
|
||||||
@@ -220,6 +226,8 @@ def test_fans_candidates_system_and_export():
|
|||||||
assert system["errors"] == []
|
assert system["errors"] == []
|
||||||
assert system["preview"] is False
|
assert system["preview"] is False
|
||||||
assert system["next_runs"]["snapshot"] > 0
|
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")
|
exported = q.export_videos(sort="view")
|
||||||
assert exported["total"] == 3
|
assert exported["total"] == 3
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ def test_readonly_routes_and_static():
|
|||||||
assert "text/html" in headers["Content-Type"]
|
assert "text/html" in headers["Content-Type"]
|
||||||
assert b"<html" in payload.lower()
|
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")
|
status, _, payload = running.request("/static/../config.json")
|
||||||
assert status == 404
|
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:
|
def _seed_blacklist(conn: sqlite3.Connection, now: float) -> None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -495,6 +558,7 @@ def seed_preview(
|
|||||||
up_rows = _seed_up_stats(conn, latest_day, days, anomaly_rng)
|
up_rows = _seed_up_stats(conn, latest_day, days, anomaly_rng)
|
||||||
_seed_candidates(conn, latest_day)
|
_seed_candidates(conn, latest_day)
|
||||||
_seed_blacklist(conn, _timestamp(latest_day, hour=14))
|
_seed_blacklist(conn, _timestamp(latest_day, hour=14))
|
||||||
|
_seed_task_records(conn, latest_day)
|
||||||
else:
|
else:
|
||||||
up_rows = 0
|
up_rows = 0
|
||||||
conn.execute("DELETE FROM candidate WHERE channel = 'preview'")
|
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,
|
config_out=args.config_out,
|
||||||
with_anomalies=not args.no_anomalies,
|
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:
|
except (ValueError, FileNotFoundError, FileExistsError, RuntimeError, sqlite3.Error) as exc:
|
||||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
Reference in new issue
Block a user