feat(sync-history): 新增同步结果查询(每次自动/手动/立即同步的记录)
后端: - db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引 - garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分 auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync 限流拦截分支同样留档; 写入失败只告警不影响同步 - scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history 按时间倒序返回(默认 50 条,上限 200) 前端: - api.ts 增加 SyncHistoryItem 类型 + getSyncHistory() - 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色, 时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时 - 同步页与设置页同步区块均加入口链接
This commit is contained in:
@@ -87,6 +87,31 @@ CREATE TABLE IF NOT EXISTS sync_status (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- One immutable row per sync attempt (auto / manual / 立即同步), so the user
|
||||||
|
-- can see what ran and what happened. sync_status above only keeps the
|
||||||
|
-- *current* state; it says nothing about a run that finished hours ago.
|
||||||
|
-- Column is trigger_kind, not trigger: TRIGGER is a MariaDB reserved word and
|
||||||
|
-- a bare `trigger` column fails with 1064 there (SQLite tolerates it, so a
|
||||||
|
-- local green suite does not catch it).
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_history (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL,
|
||||||
|
trigger_kind VARCHAR(16) NOT NULL,
|
||||||
|
status VARCHAR(24) NOT NULL,
|
||||||
|
days INT DEFAULT 0,
|
||||||
|
records_synced INT DEFAULT 0,
|
||||||
|
activities_synced INT DEFAULT 0,
|
||||||
|
badges_synced INT DEFAULT 0,
|
||||||
|
personal_records_synced INT DEFAULT 0,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
finished_at DATETIME,
|
||||||
|
duration_seconds INT DEFAULT 0,
|
||||||
|
message TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_history_user_time
|
||||||
|
ON sync_history (user_id, started_at);
|
||||||
|
|
||||||
-- Garmin OAuth tokens, obtained once through an interactive login.
|
-- Garmin OAuth tokens, obtained once through an interactive login.
|
||||||
-- Garmin accounts with two-factor auth cannot be logged into unattended: the
|
-- Garmin accounts with two-factor auth cannot be logged into unattended: the
|
||||||
-- library asks for an MFA code on stdin, which a gunicorn worker does not
|
-- library asks for an MFA code on stdin, which a gunicorn worker does not
|
||||||
|
|||||||
@@ -170,7 +170,18 @@ def sync_latest():
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
window = scheduler.SYNC_DAYS
|
window = scheduler.SYNC_DAYS
|
||||||
|
|
||||||
return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window))
|
return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window, trigger="quick"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/sync-history", methods=["GET"])
|
||||||
|
@require_auth
|
||||||
|
def sync_history():
|
||||||
|
"""Every recorded sync attempt, newest first (auto / manual / 立即同步)."""
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(request.args.get("limit", 50)), 200))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 50
|
||||||
|
return jsonify({"items": garmin_svc.get_sync_history(g.user_id, limit)})
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/auto-sync", methods=["GET"])
|
@bp.route("/auto-sync", methods=["GET"])
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
from db import execute, query_one, query_all
|
from db import execute, query_one, query_all
|
||||||
from config import DB_TYPE
|
from config import DB_TYPE
|
||||||
@@ -60,6 +61,78 @@ def _set_sync_status(user_id, status, now, last_sync_time=None, **fields):
|
|||||||
execute(sql, [user_id, status, last] + list(fields.values()))
|
execute(sql, [user_id, status, last] + list(fields.values()))
|
||||||
|
|
||||||
|
|
||||||
|
def _log_sync_history(user_id, trigger, days, started_at, result):
|
||||||
|
"""Append one immutable row per sync attempt.
|
||||||
|
|
||||||
|
`trigger` names what started it — "auto" (scheduler), "manual" (同步页
|
||||||
|
开始同步) or "quick" (设置页立即同步) — so the 同步记录 screen can show
|
||||||
|
the whole picture. A failed or refused sync is as much a record as a
|
||||||
|
successful one; the user is usually looking at this list because one of
|
||||||
|
those did not behave. A write failure must never break the sync it is
|
||||||
|
reporting, so it is logged and swallowed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
finished = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
||||||
|
r = result or {}
|
||||||
|
duration = 0
|
||||||
|
try:
|
||||||
|
s = datetime.datetime.fromisoformat(str(started_at).replace(" ", "T"))
|
||||||
|
f = datetime.datetime.fromisoformat(finished)
|
||||||
|
duration = max(0, int((f - s).total_seconds()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
execute(
|
||||||
|
"INSERT INTO sync_history (id, user_id, trigger_kind, status, days, "
|
||||||
|
"records_synced, activities_synced, badges_synced, "
|
||||||
|
"personal_records_synced, started_at, finished_at, "
|
||||||
|
"duration_seconds, message) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
str(uuid.uuid4()), user_id, trigger,
|
||||||
|
r.get("status") or "error",
|
||||||
|
days if days is not None else 0,
|
||||||
|
r.get("recordsSynced") or 0,
|
||||||
|
r.get("activitiesSynced") or 0,
|
||||||
|
r.get("badgesSynced") or 0,
|
||||||
|
r.get("personalRecordsSynced") or 0,
|
||||||
|
started_at, finished, duration,
|
||||||
|
(r.get("message") or "")[:500],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 - history must never break a sync
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"sync history write failed for %s: %s", user_id, e
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_history(user_id, limit=50):
|
||||||
|
"""Most recent sync attempts for the user, newest first."""
|
||||||
|
rows = query_all(
|
||||||
|
"SELECT trigger_kind, status, days, records_synced, activities_synced, "
|
||||||
|
"badges_synced, personal_records_synced, started_at, finished_at, "
|
||||||
|
"duration_seconds, message "
|
||||||
|
"FROM sync_history WHERE user_id = ? "
|
||||||
|
"ORDER BY started_at DESC LIMIT ?",
|
||||||
|
[user_id, limit],
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"triggerKind": r["trigger_kind"],
|
||||||
|
"status": r["status"],
|
||||||
|
"days": r["days"],
|
||||||
|
"recordsSynced": r["records_synced"],
|
||||||
|
"activitiesSynced": r["activities_synced"],
|
||||||
|
"badgesSynced": r["badges_synced"],
|
||||||
|
"personalRecordsSynced": r["personal_records_synced"],
|
||||||
|
"startedAt": r["started_at"],
|
||||||
|
"finishedAt": r["finished_at"],
|
||||||
|
"durationSeconds": r["duration_seconds"],
|
||||||
|
"message": r["message"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_sync_status(user_id):
|
def get_sync_status(user_id):
|
||||||
row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id])
|
row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id])
|
||||||
# Count distinct days actually stored in the database for this user.
|
# Count distinct days actually stored in the database for this user.
|
||||||
@@ -991,6 +1064,11 @@ def start_sync(user_id, creds, days=None):
|
|||||||
records_synced=0, progress_current=0, progress_total=days,
|
records_synced=0, progress_current=0, progress_total=days,
|
||||||
started_at=now, last_error=msg,
|
started_at=now, last_error=msg,
|
||||||
)
|
)
|
||||||
|
_log_sync_history(user_id, "manual", days, now, {
|
||||||
|
"status": "rate_limited",
|
||||||
|
"recordsSynced": 0,
|
||||||
|
"message": msg,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
"status": "rate_limited",
|
"status": "rate_limited",
|
||||||
"message": msg,
|
"message": msg,
|
||||||
@@ -1008,15 +1086,24 @@ def start_sync(user_id, creds, days=None):
|
|||||||
return {"status": "syncing", "days": days}
|
return {"status": "syncing", "days": days}
|
||||||
|
|
||||||
|
|
||||||
def sync_data(user_id, creds, days=None, client=None):
|
def sync_data(user_id, creds, days=None, client=None, trigger="manual"):
|
||||||
"""Pull the last `days` days from Garmin Connect into the local database.
|
"""Pull the last `days` days from Garmin Connect into the local database.
|
||||||
|
|
||||||
`client` exists so tests can inject a stub instead of reaching Garmin.
|
`client` exists so tests can inject a stub instead of reaching Garmin.
|
||||||
|
`trigger` ("auto" / "manual" / "quick") names what started this run and is
|
||||||
|
recorded on the sync_history row every exit path appends.
|
||||||
"""
|
"""
|
||||||
days = DEFAULT_SYNC_DAYS if days is None else days
|
days = DEFAULT_SYNC_DAYS if days is None else days
|
||||||
if days == 0:
|
if days == 0:
|
||||||
days = MAX_HISTORY_DAYS # 全部历史 → 走到数据尽头为止
|
days = MAX_HISTORY_DAYS # 全部历史 → 走到数据尽头为止
|
||||||
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
|
||||||
|
started = now
|
||||||
|
|
||||||
|
def finish(result):
|
||||||
|
"""Record the attempt, then hand the result to the caller."""
|
||||||
|
_log_sync_history(user_id, trigger, days, started, result)
|
||||||
|
return result
|
||||||
|
|
||||||
until, msg = _rate_limit_block(user_id)
|
until, msg = _rate_limit_block(user_id)
|
||||||
if until:
|
if until:
|
||||||
# A blocked account must issue zero Garmin requests — that is the whole
|
# A blocked account must issue zero Garmin requests — that is the whole
|
||||||
@@ -1030,12 +1117,12 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
last_sync_time=row["last_sync_time"] if row else None,
|
last_sync_time=row["last_sync_time"] if row else None,
|
||||||
records_synced=0, last_error=msg,
|
records_synced=0, last_error=msg,
|
||||||
)
|
)
|
||||||
return {
|
return finish({
|
||||||
"status": "rate_limited",
|
"status": "rate_limited",
|
||||||
"recordsSynced": 0,
|
"recordsSynced": 0,
|
||||||
"message": msg,
|
"message": msg,
|
||||||
"retryAfterSeconds": int((until - datetime.datetime.utcnow()).total_seconds()),
|
"retryAfterSeconds": int((until - datetime.datetime.utcnow()).total_seconds()),
|
||||||
}
|
})
|
||||||
_set_sync_status(
|
_set_sync_status(
|
||||||
user_id, "syncing", now,
|
user_id, "syncing", now,
|
||||||
records_synced=0, progress_current=0, progress_total=days,
|
records_synced=0, progress_current=0, progress_total=days,
|
||||||
@@ -1047,13 +1134,13 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
message = describe(e)
|
message = describe(e)
|
||||||
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
||||||
return {
|
return finish({
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"recordsSynced": 0,
|
"recordsSynced": 0,
|
||||||
"message": message,
|
"message": message,
|
||||||
"mfaRequired": isinstance(e, MFARequired),
|
"mfaRequired": isinstance(e, MFARequired),
|
||||||
"lastSyncTime": now,
|
"lastSyncTime": now,
|
||||||
}
|
})
|
||||||
|
|
||||||
today = datetime.date.today()
|
today = datetime.date.today()
|
||||||
|
|
||||||
@@ -1156,7 +1243,7 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
progress_current=days_synced, progress_total=days, stage=None,
|
progress_current=days_synced, progress_total=days, stage=None,
|
||||||
last_error=message,
|
last_error=message,
|
||||||
)
|
)
|
||||||
return {
|
return finish({
|
||||||
"status": "rate_limited",
|
"status": "rate_limited",
|
||||||
"recordsSynced": days_synced,
|
"recordsSynced": days_synced,
|
||||||
"message": f"已同步 {days_synced} 天后被 Garmin 限流:{message}",
|
"message": f"已同步 {days_synced} 天后被 Garmin 限流:{message}",
|
||||||
@@ -1164,7 +1251,7 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
int((until - datetime.datetime.utcnow()).total_seconds()) if until else None
|
int((until - datetime.datetime.utcnow()).total_seconds()) if until else None
|
||||||
),
|
),
|
||||||
"lastSyncTime": now,
|
"lastSyncTime": now,
|
||||||
}
|
})
|
||||||
|
|
||||||
activities_synced = 0
|
activities_synced = 0
|
||||||
_set_sync_status(user_id, "syncing", now, records_synced=days_synced,
|
_set_sync_status(user_id, "syncing", now, records_synced=days_synced,
|
||||||
@@ -1236,8 +1323,8 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
if days_synced == 0 and days_attempted > 0 and len(day_errors) >= days_attempted:
|
if days_synced == 0 and days_attempted > 0 and len(day_errors) >= days_attempted:
|
||||||
message = "; ".join(day_errors[:3])
|
message = "; ".join(day_errors[:3])
|
||||||
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
||||||
return {"status": "error", "recordsSynced": 0,
|
return finish({"status": "error", "recordsSynced": 0,
|
||||||
"message": f"同步失败:{message}", "lastSyncTime": now}
|
"message": f"同步失败:{message}", "lastSyncTime": now})
|
||||||
|
|
||||||
_set_sync_status(
|
_set_sync_status(
|
||||||
user_id, "idle", now, records_synced=days_synced,
|
user_id, "idle", now, records_synced=days_synced,
|
||||||
@@ -1251,7 +1338,7 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
)
|
)
|
||||||
if day_errors:
|
if day_errors:
|
||||||
message += f"({len(day_errors)} 项跳过)"
|
message += f"({len(day_errors)} 项跳过)"
|
||||||
return {
|
return finish({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"recordsSynced": days_synced,
|
"recordsSynced": days_synced,
|
||||||
"activitiesSynced": activities_synced,
|
"activitiesSynced": activities_synced,
|
||||||
@@ -1259,4 +1346,4 @@ def sync_data(user_id, creds, days=None, client=None):
|
|||||||
"personalRecordsSynced": records_synced_pr,
|
"personalRecordsSynced": records_synced_pr,
|
||||||
"message": message,
|
"message": message,
|
||||||
"lastSyncTime": now,
|
"lastSyncTime": now,
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ def sync_all_accounts(days=None, respect_schedule=False):
|
|||||||
"retryAfterSeconds": int((blocked - _now()).total_seconds()),
|
"retryAfterSeconds": int((blocked - _now()).total_seconds()),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
out = garmin_svc.sync_data(uid, {}, days=d)
|
out = garmin_svc.sync_data(uid, {}, days=d, trigger="auto")
|
||||||
results.append({"user": uid, "status": out.get("status"),
|
results.append({"user": uid, "status": out.get("status"),
|
||||||
"records": out.get("recordsSynced")})
|
"records": out.get("recordsSynced")})
|
||||||
# New data invalidates every stored insight, so queue them all at
|
# New data invalidates every stored insight, so queue them all at
|
||||||
|
|||||||
@@ -845,3 +845,80 @@ class TestSyncWindow:
|
|||||||
assert out["recordsSynced"] == 2, "the days pulled before the 429 are kept"
|
assert out["recordsSynced"] == 2, "the days pulled before the 429 are kept"
|
||||||
assert len([c for c in client.calls if c[0] == "summary"]) == 3
|
assert len([c for c in client.calls if c[0] == "summary"]) == 3
|
||||||
assert garmin_svc.rate_limited_until(user["id"]) is not None
|
assert garmin_svc.rate_limited_until(user["id"]) is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncHistory:
|
||||||
|
"""Every sync attempt gets one immutable row, whatever its outcome — the
|
||||||
|
record is what lets the 同步记录 screen show what ran, when, and whether
|
||||||
|
it worked. sync_status only keeps the latest state."""
|
||||||
|
|
||||||
|
def test_success_records_one_row(self, db, user):
|
||||||
|
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||||
|
assert out["status"] == "success"
|
||||||
|
|
||||||
|
rows = garmin_svc.get_sync_history(user["id"])
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["triggerKind"] == "manual"
|
||||||
|
assert row["status"] == "success"
|
||||||
|
assert row["days"] == 1
|
||||||
|
assert row["recordsSynced"] == 1
|
||||||
|
assert row["startedAt"] and row["finishedAt"]
|
||||||
|
assert row["durationSeconds"] >= 0
|
||||||
|
|
||||||
|
def test_auto_and_quick_triggers_are_tagged(self, db, user):
|
||||||
|
garmin_svc.sync_data(
|
||||||
|
user["id"], CREDS, days=1, client=StubClient(), trigger="auto"
|
||||||
|
)
|
||||||
|
garmin_svc.sync_data(
|
||||||
|
user["id"], CREDS, days=1, client=StubClient(), trigger="quick"
|
||||||
|
)
|
||||||
|
rows = garmin_svc.get_sync_history(user["id"])
|
||||||
|
assert [r["triggerKind"] for r in rows] == ["quick", "auto"], "newest first"
|
||||||
|
|
||||||
|
def test_newest_first(self, db, user):
|
||||||
|
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||||
|
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||||
|
rows = garmin_svc.get_sync_history(user["id"])
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0]["finishedAt"] >= rows[1]["finishedAt"]
|
||||||
|
|
||||||
|
def test_total_failure_is_recorded(self, db, user):
|
||||||
|
out = garmin_svc.sync_data(
|
||||||
|
user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)])
|
||||||
|
)
|
||||||
|
assert out["status"] == "error"
|
||||||
|
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "error"
|
||||||
|
|
||||||
|
def test_a_429_mid_run_is_recorded(self, db, user):
|
||||||
|
class Throttled(StubClient):
|
||||||
|
def get_user_summary(self, cdate):
|
||||||
|
self.calls.append(("summary", cdate))
|
||||||
|
if cdate == day(1):
|
||||||
|
raise RuntimeError("too many 429 error responses")
|
||||||
|
return summary()
|
||||||
|
|
||||||
|
garmin_svc.sync_data(user["id"], CREDS, days=2, client=Throttled())
|
||||||
|
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
|
||||||
|
garmin_svc._rate_limited_until.clear()
|
||||||
|
|
||||||
|
def test_a_refused_sync_is_still_a_record(self, db, user, monkeypatch):
|
||||||
|
future = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
|
||||||
|
monkeypatch.setitem(garmin_svc._rate_limited_until, user["id"], future)
|
||||||
|
|
||||||
|
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||||
|
assert out["status"] == "rate_limited"
|
||||||
|
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
|
||||||
|
|
||||||
|
def test_history_is_per_user(self, db, user, make_user):
|
||||||
|
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
|
||||||
|
other = make_user("other@example.com")
|
||||||
|
assert garmin_svc.get_sync_history(other["id"]) == []
|
||||||
|
|
||||||
|
def test_history_endpoint_requires_auth(self, client):
|
||||||
|
assert client.get("/api/garmin/sync-history").status_code == 401
|
||||||
|
|
||||||
|
def test_history_endpoint_returns_empty_list(self, client, auth):
|
||||||
|
r = client.get("/api/garmin/sync-history", headers=auth)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json() == {"items": []}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class TestSyncAllAccounts:
|
|||||||
seen = []
|
seen = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
garmin_svc, "sync_data",
|
garmin_svc, "sync_data",
|
||||||
lambda uid, creds, days=None, client=None: seen.append(uid)
|
lambda uid, creds, days=None, client=None, trigger="manual": seen.append(uid)
|
||||||
or {"status": "success", "recordsSynced": days},
|
or {"status": "success", "recordsSynced": days},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ class TestSyncAllAccounts:
|
|||||||
garmin_svc.save_token(user["id"], "t1")
|
garmin_svc.save_token(user["id"], "t1")
|
||||||
garmin_svc.save_token(other["id"], "t2")
|
garmin_svc.save_token(other["id"], "t2")
|
||||||
|
|
||||||
def flaky(uid, creds, days=None, client=None):
|
def flaky(uid, creds, days=None, client=None, trigger="manual"):
|
||||||
if uid == user["id"]:
|
if uid == user["id"]:
|
||||||
raise RuntimeError("token expired")
|
raise RuntimeError("token expired")
|
||||||
return {"status": "success", "recordsSynced": 2}
|
return {"status": "success", "recordsSynced": 2}
|
||||||
@@ -160,7 +160,7 @@ class TestEndpoints:
|
|||||||
garmin_svc.save_token(user["id"], "blob")
|
garmin_svc.save_token(user["id"], "blob")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
garmin_svc, "sync_data",
|
garmin_svc, "sync_data",
|
||||||
lambda uid, creds, days=None, client=None: {
|
lambda uid, creds, days=None, client=None, trigger="manual": {
|
||||||
"status": "success", "recordsSynced": days, "message": "ok",
|
"status": "success", "recordsSynced": days, "message": "ok",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -173,7 +173,7 @@ class TestEndpoints:
|
|||||||
garmin_svc.save_token(user["id"], "blob")
|
garmin_svc.save_token(user["id"], "blob")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
garmin_svc, "sync_data",
|
garmin_svc, "sync_data",
|
||||||
lambda uid, creds, days=None, client=None: {
|
lambda uid, creds, days=None, client=None, trigger="manual": {
|
||||||
"status": "success", "recordsSynced": days,
|
"status": "success", "recordsSynced": days,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -200,8 +200,9 @@ class TestSyncWindowResolution:
|
|||||||
garmin_svc.save_token(user["id"], "t1")
|
garmin_svc.save_token(user["id"], "t1")
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def record(uid, creds, days=None, client=None):
|
def record(uid, creds, days=None, client=None, trigger="manual"):
|
||||||
seen["days"] = days
|
seen["days"] = days
|
||||||
|
seen["trigger"] = trigger
|
||||||
return {"status": "success", "recordsSynced": 0}
|
return {"status": "success", "recordsSynced": 0}
|
||||||
|
|
||||||
monkeypatch.setattr(garmin_svc, "sync_data", record)
|
monkeypatch.setattr(garmin_svc, "sync_data", record)
|
||||||
|
|||||||
@@ -299,3 +299,26 @@
|
|||||||
background: var(--danger, #d23b3b);
|
background: var(--danger, #d23b3b);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 同步记录 entry ----------------------------------------------------------- */
|
||||||
|
.sync-records-link {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.7rem 0.95rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.sync-records-link > span:first-child {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
.sync-records-link:active {
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -280,6 +280,14 @@ function SettingsPage() {
|
|||||||
<span className="set-value">去同步页</span>
|
<span className="set-value">去同步页</span>
|
||||||
<span className="set-chevron" aria-hidden="true">›</span>
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/sync-history/" className="set-row">
|
||||||
|
<span className="set-label">
|
||||||
|
同步记录
|
||||||
|
<span className="set-sub">每次自动与手动同步的结果</span>
|
||||||
|
</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
70
client/src/pages/SyncHistory.css
Normal file
70
client/src/pages/SyncHistory.css
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/* Sync history (同步记录) ------------------------------------------------ */
|
||||||
|
.sh-list { display: grid; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.sh-card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 0.95rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-time {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-trigger {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-0);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.08rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-chip {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-chip-success { color: #0a7d0a; background: rgba(12, 163, 12, 0.12); }
|
||||||
|
.sh-chip-error { color: var(--status-critical); background: rgba(208, 59, 59, 0.12); }
|
||||||
|
.sh-chip-rate_limited { color: #9a6b00; background: rgba(250, 178, 25, 0.18); }
|
||||||
|
.sh-chip-syncing { color: var(--accent-solid); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
.sh-body {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.sh-chip-success { color: #3fcf3f; background: rgba(12, 163, 12, 0.22); }
|
||||||
|
.sh-chip-error { color: #ff7a7a; background: rgba(208, 59, 59, 0.22); }
|
||||||
|
.sh-chip-rate_limited { color: #ffcf6b; background: rgba(250, 178, 25, 0.2); }
|
||||||
|
}
|
||||||
114
client/src/pages/SyncHistoryPage.tsx
Normal file
114
client/src/pages/SyncHistoryPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'framework7-react';
|
||||||
|
import { apiClient, errorMessage, parseUtc, SyncHistoryItem } from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import './SyncHistory.css';
|
||||||
|
|
||||||
|
const TRIGGER_LABEL: Record<SyncHistoryItem['triggerKind'], string> = {
|
||||||
|
auto: '自动同步',
|
||||||
|
manual: '手动同步',
|
||||||
|
quick: '立即同步',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<SyncHistoryItem['status'], string> = {
|
||||||
|
success: '成功',
|
||||||
|
error: '失败',
|
||||||
|
rate_limited: '被限流',
|
||||||
|
syncing: '同步中',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 2026-09-02T19:30:00 (UTC) → 今天 03:30 / 昨天 23:10 / 9/1 20:05 / 2025/12/1 … */
|
||||||
|
const fmtTime = (value: string | null | undefined) => {
|
||||||
|
const d = parseUtc(value);
|
||||||
|
if (!d) return '—';
|
||||||
|
const now = new Date();
|
||||||
|
const hm = d.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
});
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
if (sameDay) return `今天 ${hm}`;
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(now.getDate() - 1);
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return `昨天 ${hm}`;
|
||||||
|
const md = d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
|
||||||
|
const y = d.getFullYear() === now.getFullYear() ? '' : `${d.getFullYear()}/`;
|
||||||
|
return `${y}${md} ${hm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDuration = (seconds: number) => {
|
||||||
|
if (seconds < 1) return '1 秒内';
|
||||||
|
if (seconds < 60) return `${seconds} 秒`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return s > 0 ? `${m} 分 ${s} 秒` : `${m} 分钟`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** days is what the run asked for: -1 增量 / 0 全部历史 / N 最近 N 天. */
|
||||||
|
const rangeLabel = (days: number) =>
|
||||||
|
days === -1 ? '增量'
|
||||||
|
: days === 0 ? '全部历史'
|
||||||
|
: `最近 ${days} 天`;
|
||||||
|
|
||||||
|
function SyncHistoryPage() {
|
||||||
|
const [rows, setRows] = useState<SyncHistoryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const items = await apiClient.getSyncHistory(100);
|
||||||
|
setRows(items);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, '加载同步记录失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Screen title="同步记录" backLink><Skeleton count={6} /></Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="同步记录" backLink>
|
||||||
|
{error && <div className="screen-error">{error}</div>}
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还没有同步记录。自动或手动同步后,每次的结果都会出现在这里。</p>
|
||||||
|
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sh-list">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div className={`sh-card sh-${r.status}`} key={`${r.startedAt}-${i}`}>
|
||||||
|
<div className="sh-head">
|
||||||
|
<span className="sh-time">{fmtTime(r.startedAt)}</span>
|
||||||
|
<span className="sh-trigger">{TRIGGER_LABEL[r.triggerKind]}</span>
|
||||||
|
<span className={`sh-chip sh-chip-${r.status}`}>
|
||||||
|
{STATUS_LABEL[r.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sh-body">{r.message || '—'}</div>
|
||||||
|
<div className="sh-meta">
|
||||||
|
<span>范围 {rangeLabel(r.days)}</span>
|
||||||
|
{r.durationSeconds > 0 && <span>耗时 {fmtDuration(r.durationSeconds)}</span>}
|
||||||
|
{r.status === 'success' && r.recordsSynced > 0 && (
|
||||||
|
<span>更新 {r.recordsSynced} 天</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncHistoryPage;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { f7 } from 'framework7-react';
|
import { f7, Link } from 'framework7-react';
|
||||||
import {
|
import {
|
||||||
apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus,
|
apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus,
|
||||||
parseUtc, SettingsOptions, SyncStatus,
|
parseUtc, SettingsOptions, SyncStatus,
|
||||||
@@ -508,6 +508,16 @@ function SyncPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link href="/sync-history/" className="sync-records-link">
|
||||||
|
<span>
|
||||||
|
<span className="sync-btn-label">同步记录</span>
|
||||||
|
<span className="sync-btn-sub">
|
||||||
|
每次自动、手动与立即同步的结果,按时间倒序
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="set-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<section className="sync-note">
|
<section className="sync-note">
|
||||||
<h3 className="sec-title">同步会取哪些数据</h3>
|
<h3 className="sec-title">同步会取哪些数据</h3>
|
||||||
<ul className="sync-list">
|
<ul className="sync-list">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import DevicesPage from './pages/DevicesPage';
|
|||||||
import ExercisePage from './pages/ExercisePage';
|
import ExercisePage from './pages/ExercisePage';
|
||||||
import SleepPage from './pages/SleepPage';
|
import SleepPage from './pages/SleepPage';
|
||||||
import SyncPage from './pages/SyncPage';
|
import SyncPage from './pages/SyncPage';
|
||||||
|
import SyncHistoryPage from './pages/SyncHistoryPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import AiQueuePage from './pages/AiQueuePage';
|
import AiQueuePage from './pages/AiQueuePage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
@@ -44,6 +45,7 @@ const SCREENS: Router.RouteParameters[] = [
|
|||||||
{ path: '/challenges/', component: ChallengesPage },
|
{ path: '/challenges/', component: ChallengesPage },
|
||||||
{ path: '/devices/', component: DevicesPage },
|
{ path: '/devices/', component: DevicesPage },
|
||||||
{ path: '/sync/', component: SyncPage },
|
{ path: '/sync/', component: SyncPage },
|
||||||
|
{ path: '/sync-history/', component: SyncHistoryPage },
|
||||||
{ path: '/settings/', component: SettingsPage },
|
{ path: '/settings/', component: SettingsPage },
|
||||||
{ path: '/ai-queue/', component: AiQueuePage },
|
{ path: '/ai-queue/', component: AiQueuePage },
|
||||||
{ path: '/login/', component: LoginPage },
|
{ path: '/login/', component: LoginPage },
|
||||||
@@ -86,7 +88,7 @@ const TAB_OWNERS: Array<[RegExp, string]> = [
|
|||||||
[/^\/metric\//, 'trends'],
|
[/^\/metric\//, 'trends'],
|
||||||
[/^\/(exercise|race|challenges)\/?$/, 'exercise'],
|
[/^\/(exercise|race|challenges)\/?$/, 'exercise'],
|
||||||
[/^\/activity\//, 'exercise'],
|
[/^\/activity\//, 'exercise'],
|
||||||
[/^\/(settings|sync|devices|rating-basis|ai-queue)\/?$/, 'settings'],
|
[/^\/(settings|sync|sync-history|devices|rating-basis|ai-queue)\/?$/, 'settings'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The tab a cold URL should open in, or null for the root and unknown paths. */
|
/** The tab a cold URL should open in, or null for the root and unknown paths. */
|
||||||
|
|||||||
@@ -129,6 +129,26 @@ export interface SyncResult {
|
|||||||
lastSyncTime: string;
|
lastSyncTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One recorded sync attempt, for the 同步记录 screen. */
|
||||||
|
export interface SyncHistoryItem {
|
||||||
|
/** What started the run: the scheduler, the sync page, or 立即同步. */
|
||||||
|
triggerKind: 'auto' | 'manual' | 'quick';
|
||||||
|
status: 'success' | 'error' | 'rate_limited' | 'syncing';
|
||||||
|
days: number;
|
||||||
|
recordsSynced: number;
|
||||||
|
activitiesSynced: number;
|
||||||
|
badgesSynced: number;
|
||||||
|
personalRecordsSynced: number;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
durationSeconds: number;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncHistoryResponse {
|
||||||
|
items: SyncHistoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Recommendation {
|
export interface Recommendation {
|
||||||
id: string;
|
id: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -664,6 +684,14 @@ class ApiClient {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every recorded sync attempt, newest first (auto / manual / 立即同步). */
|
||||||
|
async getSyncHistory(limit = 50) {
|
||||||
|
const { data } = await this.client.get<SyncHistoryResponse>('/garmin/sync-history', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
// --- health ---
|
// --- health ---
|
||||||
private range(startDate?: string, endDate?: string) {
|
private range(startDate?: string, endDate?: string) {
|
||||||
return { params: { startDate, endDate } };
|
return { params: { startDate, endDate } };
|
||||||
|
|||||||
Reference in New Issue
Block a user