This commit is contained in:
Bastian Wagner
2026-08-15 20:54:35 +02:00
parent 2aba1265af
commit 420d089760
9 changed files with 178 additions and 8 deletions

View File

@@ -79,6 +79,16 @@ class Activity(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class SystemLogEntry(Base):
__tablename__ = "system_log_entries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
source: Mapped[str] = mapped_column(String(64), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[int | None] = mapped_column(ForeignKey("sync_users.id", ondelete="SET NULL"))
class SyncRun(Base):
__tablename__ = "sync_runs"

View File

@@ -5,7 +5,7 @@ from sqlalchemy import and_, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SyncUser, utcnow
from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SystemLogEntry, SyncUser, utcnow
@dataclass(frozen=True)
@@ -191,6 +191,26 @@ class ActivityRepository:
)
class SystemLogRepository:
def __init__(self, session: Session) -> None:
self.session = session
def add(self, *, source: str, message: str, user_id: int | None = None) -> SystemLogEntry:
entry = SystemLogEntry(source=source, message=message[:2000], user_id=user_id)
self.session.add(entry)
self.session.commit()
return entry
def list_recent(self, limit: int = 50) -> list[SystemLogEntry]:
return list(
self.session.scalars(
select(SystemLogEntry)
.order_by(SystemLogEntry.created_at.desc(), SystemLogEntry.id.desc())
.limit(limit)
)
)
class SyncRunRepository:
def __init__(self, session: Session) -> None:
self.session = session

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from typing import Any, Callable
from app.db.models import ActivityStatus, HealthState, SyncRunStatus
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.db.repositories import ActivityRepository, SyncRunRepository, SystemLogRepository, UserRepository
from app.fit.rewriter import FitFormatError
from app.garmin.uploader import (
GarminAuthError,
@@ -76,7 +76,7 @@ class SyncManager:
self._locks: dict[int, asyncio.Lock] = {}
self._locks_guard = asyncio.Lock()
def _notify_action_required(self, user: Any, message: str | None) -> None:
def _notify_action_required(self, session: Any, user: Any, message: str | None) -> None:
if self.notifier is None or not user.notify_email_enabled or not user.notification_email:
return
try:
@@ -85,10 +85,21 @@ class SyncManager:
subject=f"MyWhoosh-Garmin Sync: action required for {user.name}",
body=message or "Your sync requires attention. Check the dashboard for details.",
)
except Exception:
except Exception as exc:
logger.warning(
"sync_user: failed to send action-required notification for user %s", user.id, exc_info=True
)
try:
SystemLogRepository(session).add(
source="email_notification",
message=(
f"Failed to email {user.notification_email} for user {user.id} ({user.name}): "
f"{type(exc).__name__}: {exc}"
),
user_id=user.id,
)
except Exception:
logger.warning("sync_user: failed to record email-notification failure in system log", exc_info=True)
async def _lock_for(self, user_id: int) -> asyncio.Lock:
async with self._locks_guard:
@@ -389,7 +400,7 @@ class SyncManager:
)
session.commit()
if user.action_reason is not None and user.action_reason != previous_action_reason:
self._notify_action_required(user, summary_error)
self._notify_action_required(session, user, summary_error)
return SyncOutcome(
user_id=user.id,
status=status.value,

View File

@@ -5,7 +5,7 @@ from sqlalchemy import func, select
from app.auth.admin import require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import Activity
from app.db.repositories import ActivityRepository, UserRepository
from app.db.repositories import ActivityRepository, SystemLogRepository, UserRepository
from app.sync.manager import SyncAlreadyRunning
from app.web.routes import templates
@@ -103,6 +103,7 @@ def system_page(request: Request):
with request.app.state.session_factory() as session:
user_count = len(UserRepository(session).list_all())
activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0
log_entries = SystemLogRepository(session).list_recent(limit=50)
return templates.TemplateResponse(request, "system.html", {
"csrf_token": ensure_csrf_token(request),
"app_version": APP_VERSION,
@@ -111,4 +112,5 @@ def system_page(request: Request):
"next_tick": scheduler.next_tick,
"user_count": user_count,
"activity_count": activity_count,
"log_entries": log_entries,
})

View File

@@ -31,4 +31,30 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button>
</form>
<h2>System log</h2>
<div class="card">
{% if log_entries %}
<table>
<thead>
<tr>
<th>Time</th>
<th>Source</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for entry in log_entries %}
<tr>
<td>{{ entry.created_at }}</td>
<td>{{ entry.source }}</td>
<td>{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No system log entries.</p>
{% endif %}
</div>
{% endblock %}