Dashboard eingefügt
This commit is contained in:
@@ -5,6 +5,8 @@ POLL_INTERVAL_SECONDS=3600
|
||||
DATA_DIR=/data
|
||||
LOG_LEVEL=INFO
|
||||
DRY_RUN=true
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
DASHBOARD_PORT=8080
|
||||
|
||||
MYWHOOSH_LOGIN_URL=https://www.event.mywhoosh.com/login/
|
||||
MYWHOOSH_ACTIVITY_URL=https://event.mywhoosh.com/user/activities
|
||||
|
||||
10
README.md
10
README.md
@@ -45,6 +45,14 @@ docker compose logs -f sync
|
||||
|
||||
The default polling interval is hourly.
|
||||
|
||||
Open the local dashboard:
|
||||
|
||||
```text
|
||||
http://localhost:8080/
|
||||
```
|
||||
|
||||
It shows the last sync check state and the last five uploaded Garmin activities.
|
||||
|
||||
## Configuration
|
||||
|
||||
Required values:
|
||||
@@ -61,6 +69,8 @@ POLL_INTERVAL_SECONDS=3600
|
||||
DATA_DIR=/data
|
||||
LOG_LEVEL=INFO
|
||||
DRY_RUN=false
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
DASHBOARD_PORT=8080
|
||||
MYWHOOSH_LOGIN_URL=https://www.mywhoosh.com/login/
|
||||
MYWHOOSH_ACTIVITY_URL=https://www.mywhoosh.com/profile/
|
||||
MYWHOOSH_ACTIVITIES_BUTTON_TEXT=ACTIVITIES
|
||||
|
||||
@@ -57,6 +57,8 @@ class Settings:
|
||||
db_path: Path
|
||||
log_level: str
|
||||
dry_run: bool
|
||||
dashboard_host: str
|
||||
dashboard_port: int
|
||||
|
||||
mywhoosh_login_url: str
|
||||
mywhoosh_activity_url: str
|
||||
@@ -106,6 +108,8 @@ class Settings:
|
||||
db_path=Path(os.getenv("STATE_DB", str(data_dir / "state.sqlite3"))),
|
||||
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||
dry_run=_bool_env("DRY_RUN", False),
|
||||
dashboard_host=os.getenv("DASHBOARD_HOST", "0.0.0.0"),
|
||||
dashboard_port=_int_env("DASHBOARD_PORT", 8080),
|
||||
mywhoosh_login_url=os.getenv(
|
||||
"MYWHOOSH_LOGIN_URL", "https://www.mywhoosh.com/login/"
|
||||
),
|
||||
|
||||
369
src/mywhoosh_garmin_sync/dashboard.py
Normal file
369
src/mywhoosh_garmin_sync/dashboard.py
Normal file
@@ -0,0 +1,369 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .state import ActivityStore, ActivitySummary, SyncCheckSummary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DashboardServer:
|
||||
def __init__(self, store: ActivityStore, host: str, port: int) -> None:
|
||||
self.store = store
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._server: ThreadingHTTPServer | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
handler = _handler_factory(self.store)
|
||||
self._server = ThreadingHTTPServer((self.host, self.port), handler)
|
||||
self._thread = threading.Thread(
|
||||
target=self._server.serve_forever,
|
||||
name="dashboard-http",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("Dashboard listening on http://%s:%s", self.host, self.port)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
|
||||
def render_dashboard(
|
||||
latest_check: SyncCheckSummary | None,
|
||||
recent_uploads: list[ActivitySummary],
|
||||
refresh_seconds: int = 30,
|
||||
) -> str:
|
||||
status_label = _status_label(latest_check)
|
||||
status_class = _status_class(latest_check)
|
||||
message = _status_message(latest_check)
|
||||
checked_at = latest_check.finished_at if latest_check else None
|
||||
started_at = latest_check.started_at if latest_check else None
|
||||
rows = "\n".join(_activity_row(activity) for activity in recent_uploads)
|
||||
if not rows:
|
||||
rows = """
|
||||
<tr>
|
||||
<td colspan="5" class="empty">No uploaded activities recorded yet.</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
checked_markup = escape(_format_german_datetime(checked_at) or "Nie")
|
||||
started_markup = escape(_format_german_datetime(started_at) or "Nie")
|
||||
count_markup = _count_markup(latest_check)
|
||||
error_type_markup = _error_type_markup(latest_check)
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="{refresh_seconds}">
|
||||
<title>MyWhoosh Garmin Sync</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
--bg: #f5f1e8;
|
||||
--ink: #17211b;
|
||||
--muted: #667166;
|
||||
--panel: #fffaf0;
|
||||
--line: #ded5c4;
|
||||
--ok: #176b3a;
|
||||
--warn: #a8451a;
|
||||
--wait: #78633a;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(37, 89, 67, .18), transparent 28rem),
|
||||
linear-gradient(135deg, #f7f0df 0%, #ebe4d6 100%);
|
||||
}}
|
||||
main {{
|
||||
width: min(1080px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 0 0 24px;
|
||||
font-size: clamp(2rem, 5vw, 4.5rem);
|
||||
line-height: .95;
|
||||
letter-spacing: -.05em;
|
||||
}}
|
||||
.status {{
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 250, 240, .82);
|
||||
box-shadow: 0 24px 60px rgba(35, 27, 12, .12);
|
||||
}}
|
||||
.status-header {{
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.badge {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 13px;
|
||||
border-radius: 999px;
|
||||
font: 700 .78rem/1.1 ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid currentColor;
|
||||
}}
|
||||
.badge.ok {{ color: var(--ok); }}
|
||||
.badge.problem {{ color: var(--warn); }}
|
||||
.badge.running {{ color: var(--wait); }}
|
||||
.message {{
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}}
|
||||
.meta {{
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font: .95rem/1.5 ui-sans-serif, system-ui, sans-serif;
|
||||
}}
|
||||
section {{
|
||||
margin-top: 28px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: var(--panel);
|
||||
}}
|
||||
h2 {{
|
||||
margin: 0 0 18px;
|
||||
font-size: 1.3rem;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: .95rem/1.45 ui-sans-serif, system-ui, sans-serif;
|
||||
}}
|
||||
th, td {{
|
||||
padding: 13px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}}
|
||||
th {{
|
||||
color: var(--muted);
|
||||
font-size: .78rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
a {{ color: #245b44; }}
|
||||
.empty {{ color: var(--muted); text-align: center; }}
|
||||
.path {{ color: var(--muted); overflow-wrap: anywhere; }}
|
||||
@media (max-width: 760px) {{
|
||||
main {{ padding-top: 24px; }}
|
||||
section, .status {{ padding: 18px; }}
|
||||
table, thead, tbody, tr, th, td {{ display: block; }}
|
||||
thead {{ display: none; }}
|
||||
tr {{ border-top: 1px solid var(--line); padding: 10px 0; }}
|
||||
td {{ border: 0; padding: 5px 0; }}
|
||||
td::before {{
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: .75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>MyWhoosh Garmin Sync</h1>
|
||||
<div class="status">
|
||||
<div class="status-header">
|
||||
<span class="badge {status_class}">{escape(status_label)}</span>
|
||||
<span class="meta">Auto-refreshes every {refresh_seconds}s</span>
|
||||
</div>
|
||||
<p class="message">{escape(message)}</p>
|
||||
<div class="meta">
|
||||
<span>Gestartet: {started_markup}</span>
|
||||
<span>Beendet: {checked_markup}</span>
|
||||
<span>{count_markup}</span>
|
||||
{error_type_markup}
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<h2>Last 5 Uploaded Activities</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Garmin ID</th>
|
||||
<th>Source</th>
|
||||
<th>Converted File</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def render_health(latest_check: SyncCheckSummary | None) -> str:
|
||||
payload = {
|
||||
"status": latest_check.status if latest_check else "never_checked",
|
||||
"message": latest_check.message if latest_check else None,
|
||||
"error_type": latest_check.error_type if latest_check else None,
|
||||
"finished_at": latest_check.finished_at if latest_check else None,
|
||||
}
|
||||
return json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
|
||||
def _handler_factory(store: ActivityStore) -> type[BaseHTTPRequestHandler]:
|
||||
class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
try:
|
||||
store.initialize()
|
||||
if path == "/":
|
||||
latest = store.get_latest_sync_check()
|
||||
uploads = store.get_recent_uploaded_activities(5)
|
||||
body = render_dashboard(latest, uploads)
|
||||
self._send(HTTPStatus.OK, "text/html; charset=utf-8", body)
|
||||
return
|
||||
|
||||
if path == "/healthz":
|
||||
body = render_health(store.get_latest_sync_check())
|
||||
self._send(HTTPStatus.OK, "application/json", body)
|
||||
return
|
||||
|
||||
self._send(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", "Not found")
|
||||
except Exception:
|
||||
logger.exception("Dashboard request failed")
|
||||
self._send(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
"text/plain; charset=utf-8",
|
||||
"Dashboard error",
|
||||
)
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
logger.debug("Dashboard: " + fmt, *args)
|
||||
|
||||
def _send(self, status: HTTPStatus, content_type: str, body: str) -> None:
|
||||
encoded = body.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
return DashboardRequestHandler
|
||||
|
||||
|
||||
def _status_label(latest_check: SyncCheckSummary | None) -> str:
|
||||
if latest_check is None:
|
||||
return "Never checked"
|
||||
if latest_check.status == "ok":
|
||||
return "OK"
|
||||
if latest_check.status == "problem":
|
||||
return "Problem"
|
||||
return "Running"
|
||||
|
||||
|
||||
def _status_class(latest_check: SyncCheckSummary | None) -> str:
|
||||
if latest_check is None:
|
||||
return "running"
|
||||
if latest_check.status == "problem":
|
||||
return "problem"
|
||||
if latest_check.status == "ok":
|
||||
return "ok"
|
||||
return "running"
|
||||
|
||||
|
||||
def _status_message(latest_check: SyncCheckSummary | None) -> str:
|
||||
if latest_check is None:
|
||||
return "No sync check has finished yet."
|
||||
if latest_check.message:
|
||||
return latest_check.message
|
||||
if latest_check.status == "ok":
|
||||
return "Last sync check completed successfully."
|
||||
if latest_check.status == "problem":
|
||||
return "Last sync check found a problem."
|
||||
return "Sync check is running."
|
||||
|
||||
|
||||
def _count_markup(latest_check: SyncCheckSummary | None) -> str:
|
||||
if latest_check is None:
|
||||
return "Downloaded: 0, processed: 0, failed: 0"
|
||||
return escape(
|
||||
f"Downloaded: {latest_check.downloaded_count}, "
|
||||
f"processed: {latest_check.processed_count}, "
|
||||
f"failed: {latest_check.failed_count}"
|
||||
)
|
||||
|
||||
|
||||
def _error_type_markup(latest_check: SyncCheckSummary | None) -> str:
|
||||
if latest_check is None or not latest_check.error_type:
|
||||
return ""
|
||||
return f"<span>Problem type: {escape(latest_check.error_type)}</span>"
|
||||
|
||||
|
||||
def _activity_row(activity: ActivitySummary) -> str:
|
||||
title = escape(activity.title or "MyWhoosh activity")
|
||||
uploaded = escape(
|
||||
_format_german_datetime(activity.uploaded_at or activity.updated_at)
|
||||
or activity.uploaded_at
|
||||
or activity.updated_at
|
||||
)
|
||||
garmin_id = escape(activity.garmin_activity_id or "-")
|
||||
source = _link(activity.source_url, "Open") if activity.source_url else "-"
|
||||
converted = escape(activity.converted_path or "-")
|
||||
return f"""
|
||||
<tr>
|
||||
<td data-label="Title">{title}</td>
|
||||
<td data-label="Uploaded">{uploaded}</td>
|
||||
<td data-label="Garmin ID">{garmin_id}</td>
|
||||
<td data-label="Source">{source}</td>
|
||||
<td data-label="Converted File" class="path">{converted}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
|
||||
def _link(url: str, label: str) -> str:
|
||||
safe_url = escape(url, quote=True)
|
||||
return f'<a href="{safe_url}" rel="noreferrer">{escape(label)}</a>'
|
||||
|
||||
|
||||
def _format_german_datetime(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
return parsed.strftime("%d.%m.%Y, %H:%M Uhr")
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .dashboard import DashboardServer
|
||||
from .fit_device import GarminDevice, convert_fit_device
|
||||
from .garmin import GarminUploadBlocked, GarminUploader
|
||||
from .models import DownloadedActivity
|
||||
@@ -15,6 +17,13 @@ from .state import ActivityStore, sha256_file
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessingResult:
|
||||
status: str
|
||||
error_type: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -36,7 +45,14 @@ class SyncService:
|
||||
|
||||
async def serve(self) -> None:
|
||||
stop_event = asyncio.Event()
|
||||
dashboard = DashboardServer(
|
||||
self.store,
|
||||
host=self.settings.dashboard_host,
|
||||
port=self.settings.dashboard_port,
|
||||
)
|
||||
dashboard.start()
|
||||
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
@@ -61,18 +77,70 @@ class SyncService:
|
||||
)
|
||||
except TimeoutError:
|
||||
continue
|
||||
finally:
|
||||
dashboard.stop()
|
||||
|
||||
async def run_once(self) -> None:
|
||||
self.store.initialize()
|
||||
check_id = self.store.begin_sync_check()
|
||||
downloads: list[DownloadedActivity] = []
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
first_error_type: str | None = None
|
||||
first_error_message: str | None = None
|
||||
try:
|
||||
downloads = await self.crawler.download_new_activities(
|
||||
self.store.is_terminal_source
|
||||
)
|
||||
logger.info("Downloaded %d candidate activities", len(downloads))
|
||||
|
||||
for activity in downloads:
|
||||
await asyncio.to_thread(self._process_activity, activity)
|
||||
result = await asyncio.to_thread(self._process_activity, activity)
|
||||
processed_count += 1
|
||||
if result.status == "failed":
|
||||
failed_count += 1
|
||||
first_error_type = first_error_type or result.error_type
|
||||
first_error_message = first_error_message or result.message
|
||||
|
||||
def _process_activity(self, activity: DownloadedActivity) -> None:
|
||||
if failed_count:
|
||||
message = (
|
||||
f"{failed_count} of {processed_count} downloaded activities failed."
|
||||
)
|
||||
if first_error_message:
|
||||
message = f"{message} First error: {first_error_message}"
|
||||
self.store.finish_sync_check(
|
||||
check_id,
|
||||
status="problem",
|
||||
message=message,
|
||||
error_type=first_error_type or "sync_error",
|
||||
downloaded_count=len(downloads),
|
||||
processed_count=processed_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
return
|
||||
|
||||
self.store.finish_sync_check(
|
||||
check_id,
|
||||
status="ok",
|
||||
message=_ok_message(len(downloads), processed_count),
|
||||
error_type=None,
|
||||
downloaded_count=len(downloads),
|
||||
processed_count=processed_count,
|
||||
failed_count=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.store.finish_sync_check(
|
||||
check_id,
|
||||
status="problem",
|
||||
message=str(exc),
|
||||
error_type=_classify_sync_exception(exc),
|
||||
downloaded_count=len(downloads),
|
||||
processed_count=processed_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
raise
|
||||
|
||||
def _process_activity(self, activity: DownloadedActivity) -> ProcessingResult:
|
||||
try:
|
||||
raw_hash = sha256_file(activity.raw_path)
|
||||
self.store.record_downloaded(
|
||||
@@ -88,7 +156,7 @@ class SyncService:
|
||||
activity.source_ref,
|
||||
"Raw file hash was already uploaded from another source.",
|
||||
)
|
||||
return
|
||||
return ProcessingResult("duplicate")
|
||||
|
||||
converted_path = self._converted_path(activity)
|
||||
result = convert_fit_device(activity.raw_path, converted_path, self.device)
|
||||
@@ -105,29 +173,67 @@ class SyncService:
|
||||
activity.source_ref,
|
||||
"Converted file hash was already uploaded from another source.",
|
||||
)
|
||||
return
|
||||
return ProcessingResult("duplicate")
|
||||
|
||||
if self.settings.dry_run:
|
||||
logger.info("Dry run enabled; not uploading %s", converted_path)
|
||||
return
|
||||
return ProcessingResult("converted")
|
||||
|
||||
upload = self.uploader.upload(converted_path)
|
||||
if upload.duplicate:
|
||||
self.store.mark_duplicate(
|
||||
activity.source_ref, "Garmin reported a duplicate activity."
|
||||
)
|
||||
return ProcessingResult("duplicate")
|
||||
else:
|
||||
self.store.mark_uploaded(
|
||||
activity.source_ref,
|
||||
garmin_activity_id=upload.garmin_activity_id,
|
||||
)
|
||||
return ProcessingResult("uploaded")
|
||||
except GarminUploadBlocked as exc:
|
||||
logger.error("Upload blocked: %s", exc)
|
||||
self.store.mark_failed(activity.source_ref, str(exc))
|
||||
return ProcessingResult("failed", "garmin_login", str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("Failed processing %s", activity.raw_path)
|
||||
self.store.mark_failed(activity.source_ref, str(exc))
|
||||
return ProcessingResult(
|
||||
"failed",
|
||||
_classify_activity_exception(exc),
|
||||
str(exc),
|
||||
)
|
||||
|
||||
def _converted_path(self, activity: DownloadedActivity) -> Path:
|
||||
return self.settings.converted_dir / activity.raw_path.name
|
||||
|
||||
|
||||
def _ok_message(downloaded_count: int, processed_count: int) -> str:
|
||||
if downloaded_count == 0:
|
||||
return "OK: no new activities found."
|
||||
if processed_count == 1:
|
||||
return "OK: processed 1 activity."
|
||||
return f"OK: processed {processed_count} activities."
|
||||
|
||||
|
||||
def _classify_sync_exception(exc: Exception) -> str:
|
||||
if isinstance(exc, GarminUploadBlocked):
|
||||
return "garmin_login"
|
||||
|
||||
text = str(exc).lower()
|
||||
if any(token in text for token in ("captcha", "bot challenge", "challenge", "cloudflare")):
|
||||
return "mywhoosh_challenge"
|
||||
if any(token in text for token in ("mywhoosh", "login", "credential", "mfa", "auth")):
|
||||
return "mywhoosh_login"
|
||||
if "garmin" in text:
|
||||
return "garmin_upload"
|
||||
return "sync_error"
|
||||
|
||||
|
||||
def _classify_activity_exception(exc: Exception) -> str:
|
||||
text = str(exc).lower()
|
||||
if any(token in text for token in ("mfa", "login", "auth", "credential")):
|
||||
return "garmin_login"
|
||||
if any(token in text for token in ("garmin", "upload", "connect")):
|
||||
return "garmin_upload"
|
||||
return "sync_error"
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
@@ -22,6 +23,30 @@ def sha256_file(path: Path) -> str:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivitySummary:
|
||||
source_ref: str
|
||||
title: str | None
|
||||
source_url: str | None
|
||||
converted_path: str | None
|
||||
garmin_activity_id: str | None
|
||||
uploaded_at: str | None
|
||||
updated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncCheckSummary:
|
||||
id: int
|
||||
started_at: str
|
||||
finished_at: str | None
|
||||
status: str
|
||||
message: str | None
|
||||
error_type: str | None
|
||||
downloaded_count: int
|
||||
processed_count: int
|
||||
failed_count: int
|
||||
|
||||
|
||||
class ActivityStore:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.db_path = db_path
|
||||
@@ -59,6 +84,124 @@ class ActivityStore:
|
||||
ON activities(converted_sha256)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sync_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
error_type TEXT,
|
||||
downloaded_count INTEGER NOT NULL DEFAULT 0,
|
||||
processed_count INTEGER NOT NULL DEFAULT 0,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_checks_started_at
|
||||
ON sync_checks(started_at)
|
||||
"""
|
||||
)
|
||||
|
||||
def begin_sync_check(self) -> int:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO sync_checks (started_at, status)
|
||||
VALUES (?, 'running')
|
||||
""",
|
||||
(utc_now(),),
|
||||
)
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def finish_sync_check(
|
||||
self,
|
||||
check_id: int,
|
||||
status: str,
|
||||
message: str | None,
|
||||
error_type: str | None,
|
||||
downloaded_count: int,
|
||||
processed_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
self._execute(
|
||||
"""
|
||||
UPDATE sync_checks
|
||||
SET finished_at = ?,
|
||||
status = ?,
|
||||
message = ?,
|
||||
error_type = ?,
|
||||
downloaded_count = ?,
|
||||
processed_count = ?,
|
||||
failed_count = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
utc_now(),
|
||||
status,
|
||||
message[:1000] if message else None,
|
||||
error_type,
|
||||
downloaded_count,
|
||||
processed_count,
|
||||
failed_count,
|
||||
check_id,
|
||||
),
|
||||
)
|
||||
|
||||
def get_latest_sync_check(self) -> SyncCheckSummary | None:
|
||||
row = self._fetch_one(
|
||||
"""
|
||||
SELECT id, started_at, finished_at, status, message, error_type,
|
||||
downloaded_count, processed_count, failed_count
|
||||
FROM sync_checks
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return SyncCheckSummary(
|
||||
id=row["id"],
|
||||
started_at=row["started_at"],
|
||||
finished_at=row["finished_at"],
|
||||
status=row["status"],
|
||||
message=row["message"],
|
||||
error_type=row["error_type"],
|
||||
downloaded_count=row["downloaded_count"],
|
||||
processed_count=row["processed_count"],
|
||||
failed_count=row["failed_count"],
|
||||
)
|
||||
|
||||
def get_recent_uploaded_activities(self, limit: int = 5) -> list[ActivitySummary]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT source_ref, title, source_url, converted_path,
|
||||
garmin_activity_id, uploaded_at, updated_at
|
||||
FROM activities
|
||||
WHERE status = 'uploaded'
|
||||
ORDER BY uploaded_at DESC, updated_at DESC, source_ref DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
ActivitySummary(
|
||||
source_ref=row["source_ref"],
|
||||
title=row["title"],
|
||||
source_url=row["source_url"],
|
||||
converted_path=row["converted_path"],
|
||||
garmin_activity_id=row["garmin_activity_id"],
|
||||
uploaded_at=row["uploaded_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def is_terminal_source(self, source_ref: str) -> bool:
|
||||
row = self._fetch_one(
|
||||
@@ -216,4 +359,3 @@ class ActivityStore:
|
||||
) -> sqlite3.Row | None:
|
||||
with self._connect() as conn:
|
||||
return conn.execute(sql, params).fetchone()
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ from mywhoosh_garmin_sync.config import Settings
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path) -> Settings:
|
||||
return Settings(
|
||||
mywhoosh_email="whoosh@example.com",
|
||||
mywhoosh_password="whoosh-pass",
|
||||
garmin_email="garmin@example.com",
|
||||
garmin_password="garmin-pass",
|
||||
poll_interval_seconds=3600,
|
||||
@@ -22,6 +20,8 @@ def settings(tmp_path: Path) -> Settings:
|
||||
db_path=tmp_path / "state.sqlite3",
|
||||
log_level="INFO",
|
||||
dry_run=False,
|
||||
dashboard_host="127.0.0.1",
|
||||
dashboard_port=8080,
|
||||
mywhoosh_login_url="https://www.mywhoosh.com/login/",
|
||||
mywhoosh_activity_url="https://www.mywhoosh.com/profile/",
|
||||
mywhoosh_headless=True,
|
||||
|
||||
@@ -19,6 +19,8 @@ def test_settings_from_env(monkeypatch, tmp_path):
|
||||
assert settings.raw_dir == tmp_path / "raw"
|
||||
assert settings.dry_run is True
|
||||
assert settings.poll_interval_seconds == 123
|
||||
assert settings.dashboard_host == "0.0.0.0"
|
||||
assert settings.dashboard_port == 8080
|
||||
assert settings.target_garmin_product_id == 3578
|
||||
assert settings.target_garmin_serial_number == 123456
|
||||
assert settings.mywhoosh_manual_login_wait_seconds == 900
|
||||
|
||||
66
tests/test_dashboard.py
Normal file
66
tests/test_dashboard.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from mywhoosh_garmin_sync.dashboard import render_dashboard, render_health
|
||||
from mywhoosh_garmin_sync.state import ActivitySummary, SyncCheckSummary
|
||||
|
||||
|
||||
def test_dashboard_renders_latest_check_and_upload():
|
||||
html = render_dashboard(
|
||||
SyncCheckSummary(
|
||||
id=1,
|
||||
started_at="2026-05-07T10:00:00+00:00",
|
||||
finished_at="2026-05-07T10:01:00+00:00",
|
||||
status="ok",
|
||||
message="OK: processed 1 activity.",
|
||||
error_type=None,
|
||||
downloaded_count=1,
|
||||
processed_count=1,
|
||||
failed_count=0,
|
||||
),
|
||||
[
|
||||
ActivitySummary(
|
||||
source_ref="source-1",
|
||||
title="Morning Ride",
|
||||
source_url="https://example.test/ride.fit",
|
||||
converted_path="/data/converted/ride.fit",
|
||||
garmin_activity_id="12345",
|
||||
uploaded_at="2026-05-07T10:01:00+00:00",
|
||||
updated_at="2026-05-07T10:01:00+00:00",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert "OK: processed 1 activity." in html
|
||||
assert "Morning Ride" in html
|
||||
assert "12345" in html
|
||||
assert "07.05.2026, 10:01 Uhr" in html
|
||||
assert "/data/converted/ride.fit" in html
|
||||
assert '<meta http-equiv="refresh" content="30">' in html
|
||||
|
||||
|
||||
def test_dashboard_renders_empty_state_before_first_check():
|
||||
html = render_dashboard(None, [])
|
||||
|
||||
assert "Never checked" in html
|
||||
assert "No sync check has finished yet." in html
|
||||
assert "No uploaded activities recorded yet." in html
|
||||
|
||||
|
||||
def test_health_renders_latest_status():
|
||||
body = render_health(
|
||||
SyncCheckSummary(
|
||||
id=1,
|
||||
started_at="2026-05-07T10:00:00+00:00",
|
||||
finished_at="2026-05-07T10:01:00+00:00",
|
||||
status="problem",
|
||||
message="Garmin requested MFA",
|
||||
error_type="garmin_login",
|
||||
downloaded_count=1,
|
||||
processed_count=1,
|
||||
failed_count=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert body == (
|
||||
'{"status":"problem","message":"Garmin requested MFA",'
|
||||
'"error_type":"garmin_login",'
|
||||
'"finished_at":"2026-05-07T10:01:00+00:00"}'
|
||||
)
|
||||
95
tests/test_service.py
Normal file
95
tests/test_service.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mywhoosh_garmin_sync.models import DownloadedActivity
|
||||
from mywhoosh_garmin_sync.service import ProcessingResult, SyncService
|
||||
from mywhoosh_garmin_sync.state import ActivityStore
|
||||
|
||||
|
||||
class FakeCrawler:
|
||||
def __init__(self, downloads=None, exc: Exception | None = None):
|
||||
self.downloads = downloads or []
|
||||
self.exc = exc
|
||||
|
||||
async def download_new_activities(self, should_skip_source):
|
||||
if self.exc is not None:
|
||||
raise self.exc
|
||||
return self.downloads
|
||||
|
||||
|
||||
class FakeUploader:
|
||||
pass
|
||||
|
||||
|
||||
def test_run_once_records_ok_check_when_no_downloads(settings):
|
||||
store = ActivityStore(settings.db_path)
|
||||
service = SyncService(
|
||||
settings,
|
||||
crawler=FakeCrawler(),
|
||||
uploader=FakeUploader(),
|
||||
store=store,
|
||||
)
|
||||
|
||||
asyncio.run(service.run_once())
|
||||
|
||||
latest = store.get_latest_sync_check()
|
||||
assert latest is not None
|
||||
assert latest.status == "ok"
|
||||
assert latest.message == "OK: no new activities found."
|
||||
assert latest.downloaded_count == 0
|
||||
assert latest.failed_count == 0
|
||||
|
||||
|
||||
def test_run_once_records_mywhoosh_problem_before_downloads(settings):
|
||||
store = ActivityStore(settings.db_path)
|
||||
service = SyncService(
|
||||
settings,
|
||||
crawler=FakeCrawler(exc=RuntimeError("MyWhoosh login did not complete")),
|
||||
uploader=FakeUploader(),
|
||||
store=store,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(service.run_once())
|
||||
|
||||
latest = store.get_latest_sync_check()
|
||||
assert latest is not None
|
||||
assert latest.status == "problem"
|
||||
assert latest.error_type == "mywhoosh_login"
|
||||
assert latest.message == "MyWhoosh login did not complete"
|
||||
|
||||
|
||||
def test_run_once_records_activity_processing_problem(settings, tmp_path: Path):
|
||||
raw_path = tmp_path / "activity.fit"
|
||||
raw_path.write_bytes(b"not used")
|
||||
activity = DownloadedActivity(
|
||||
source_ref="source-1",
|
||||
title="Ride",
|
||||
url="https://example.test/ride.fit",
|
||||
raw_path=raw_path,
|
||||
)
|
||||
store = ActivityStore(settings.db_path)
|
||||
service = SyncService(
|
||||
settings,
|
||||
crawler=FakeCrawler(downloads=[activity]),
|
||||
uploader=FakeUploader(),
|
||||
store=store,
|
||||
)
|
||||
service._process_activity = lambda activity: ProcessingResult(
|
||||
"failed",
|
||||
"garmin_login",
|
||||
"Garmin requested MFA but GARMIN_MFA_CODE is not set.",
|
||||
)
|
||||
|
||||
asyncio.run(service.run_once())
|
||||
|
||||
latest = store.get_latest_sync_check()
|
||||
assert latest is not None
|
||||
assert latest.status == "problem"
|
||||
assert latest.error_type == "garmin_login"
|
||||
assert latest.downloaded_count == 1
|
||||
assert latest.processed_count == 1
|
||||
assert latest.failed_count == 1
|
||||
assert "1 of 1 downloaded activities failed" in latest.message
|
||||
@@ -42,3 +42,62 @@ def test_failed_activity_can_be_retried(tmp_path: Path):
|
||||
assert store.get_status("source-1") == "failed"
|
||||
assert store.is_terminal_source("source-1") is False
|
||||
|
||||
|
||||
def test_sync_check_summary_round_trip(tmp_path: Path):
|
||||
store = ActivityStore(tmp_path / "state.sqlite3")
|
||||
store.initialize()
|
||||
|
||||
check_id = store.begin_sync_check()
|
||||
store.finish_sync_check(
|
||||
check_id=check_id,
|
||||
status="problem",
|
||||
message="MyWhoosh login did not complete",
|
||||
error_type="mywhoosh_login",
|
||||
downloaded_count=2,
|
||||
processed_count=1,
|
||||
failed_count=1,
|
||||
)
|
||||
|
||||
latest = store.get_latest_sync_check()
|
||||
|
||||
assert latest is not None
|
||||
assert latest.id == check_id
|
||||
assert latest.status == "problem"
|
||||
assert latest.message == "MyWhoosh login did not complete"
|
||||
assert latest.error_type == "mywhoosh_login"
|
||||
assert latest.downloaded_count == 2
|
||||
assert latest.processed_count == 1
|
||||
assert latest.failed_count == 1
|
||||
assert latest.finished_at is not None
|
||||
|
||||
|
||||
def test_recent_uploaded_activities_are_limited_and_ordered(tmp_path: Path):
|
||||
store = ActivityStore(tmp_path / "state.sqlite3")
|
||||
store.initialize()
|
||||
|
||||
for index in range(6):
|
||||
source_ref = f"source-{index}"
|
||||
store.record_downloaded(
|
||||
source_ref=source_ref,
|
||||
title=f"Ride {index}",
|
||||
source_url=f"https://example.test/{index}",
|
||||
raw_path=tmp_path / f"raw-{index}.fit",
|
||||
raw_sha256=f"raw-hash-{index}",
|
||||
)
|
||||
store.mark_converted(
|
||||
source_ref=source_ref,
|
||||
converted_path=tmp_path / f"converted-{index}.fit",
|
||||
converted_sha256=f"converted-hash-{index}",
|
||||
patched_field_count=index,
|
||||
)
|
||||
store.mark_uploaded(source_ref, str(index))
|
||||
|
||||
recent = store.get_recent_uploaded_activities(5)
|
||||
|
||||
assert [activity.title for activity in recent] == [
|
||||
"Ride 5",
|
||||
"Ride 4",
|
||||
"Ride 3",
|
||||
"Ride 2",
|
||||
"Ride 1",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user