feat: restyle admin UI and add per-user sync run history

Adds a self-hosted stylesheet (no CDN dependencies) with a card-based
dashboard and color-coded status badges, and shows the last 10 sync
runs per user on the detail page.
This commit is contained in:
Bastian Wagner
2026-08-15 20:19:31 +02:00
parent 990a55af14
commit 85b0d861b4
13 changed files with 597 additions and 126 deletions

View File

@@ -204,6 +204,16 @@ class SyncRunRepository:
def get(self, sync_run_id: int) -> SyncRun | None: def get(self, sync_run_id: int) -> SyncRun | None:
return self.session.get(SyncRun, sync_run_id) return self.session.get(SyncRun, sync_run_id)
def list_recent_for_user(self, user_id: int, limit: int = 10) -> list[SyncRun]:
return list(
self.session.scalars(
select(SyncRun)
.where(SyncRun.user_id == user_id)
.order_by(SyncRun.started_at.desc())
.limit(limit)
)
)
def finish( def finish(
self, self,
sync_run_id: int, sync_run_id: int,

View File

@@ -1,6 +1,8 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.config import Settings, get_settings from app.config import Settings, get_settings
@@ -65,6 +67,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
) )
app.include_router(web_router) app.include_router(web_router)
app.include_router(operations_router) app.include_router(operations_router)
app.mount(
"/static",
StaticFiles(directory=str(Path(__file__).resolve().parent / "web" / "static")),
name="static",
)
@app.get("/healthz") @app.get("/healthz")
def healthz() -> dict[str, str]: def healthz() -> dict[str, str]:

View File

@@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates
from app.auth.admin import password_matches, require_admin from app.auth.admin import password_matches, require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.models import SyncUser from app.db.models import SyncUser
from app.db.repositories import ActivityRepository, UserRepository from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
from app.web.forms import UserFormData from app.web.forms import UserFormData
@@ -132,6 +132,7 @@ def user_detail(request: Request, user_id: int):
with request.app.state.session_factory() as session: with request.app.state.session_factory() as session:
user = _get_user_or_404(UserRepository(session), user_id) user = _get_user_or_404(UserRepository(session), user_id)
activities = ActivityRepository(session).list_pending_for_user(user_id) activities = ActivityRepository(session).list_pending_for_user(user_id)
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"users/detail.html", "users/detail.html",
@@ -139,6 +140,7 @@ def user_detail(request: Request, user_id: int):
"csrf_token": ensure_csrf_token(request), "csrf_token": ensure_csrf_token(request),
"user": user, "user": user,
"activities": activities, "activities": activities,
"recent_runs": recent_runs,
}, },
) )

326
app/web/static/style.css Normal file
View File

@@ -0,0 +1,326 @@
:root {
--bg: #f4f5f9;
--surface: #ffffff;
--border: #e3e6ee;
--text: #1c2130;
--text-muted: #6b7185;
--primary: #4c5fd5;
--primary-hover: #3d4dc0;
--danger: #d64550;
--danger-bg: #fdeceb;
--success: #1f9d63;
--success-bg: #e7f7ee;
--warning: #b8860b;
--warning-bg: #fdf3d9;
--neutral: #6b7185;
--neutral-bg: #eef0f5;
--info: #2f7ec2;
--info-bg: #e8f2fb;
--radius: 10px;
--shadow: 0 1px 2px rgba(28, 33, 48, 0.06), 0 1px 8px rgba(28, 33, 48, 0.04);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.9rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar .brand {
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
}
.topbar nav {
display: flex;
gap: 1.25rem;
font-size: 0.9rem;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1 {
font-size: 1.5rem;
margin: 0 0 1.25rem;
}
h2 {
font-size: 1.1rem;
margin: 2rem 0 0.75rem;
}
.page-actions {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem 1.4rem;
margin-bottom: 1rem;
}
.user-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.user-card {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1.25rem;
}
.user-card .user-main {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 220px;
}
.user-card .user-name {
font-weight: 600;
font-size: 1rem;
}
.user-card .user-meta {
color: var(--text-muted);
font-size: 0.85rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.9rem;
}
.user-card .user-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
}
.badge-healthy {
background: var(--success-bg);
color: var(--success);
}
.badge-syncing {
background: var(--info-bg);
color: var(--info);
}
.badge-degraded {
background: var(--warning-bg);
color: var(--warning);
}
.badge-action_required {
background: var(--danger-bg);
color: var(--danger);
}
.badge-disabled {
background: var(--neutral-bg);
color: var(--neutral);
}
.badge-success {
background: var(--success-bg);
color: var(--success);
}
.badge-partial {
background: var(--warning-bg);
color: var(--warning);
}
.badge-failed,
.badge-error {
background: var(--danger-bg);
color: var(--danger);
}
.badge-running {
background: var(--info-bg);
color: var(--info);
}
.action-required {
color: var(--danger);
font-size: 0.85rem;
}
button, .btn {
font: inherit;
background: var(--primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.45rem 0.9rem;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
}
button:hover, .btn:hover {
background: var(--primary-hover);
text-decoration: none;
}
button.secondary, .btn.secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
button.secondary:hover, .btn.secondary:hover {
background: var(--bg);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
table th, table td {
text-align: left;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
}
table th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table tr:last-child td {
border-bottom: none;
}
dl.info-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1.5rem;
margin: 0;
}
dl.info-grid dt {
color: var(--text-muted);
font-size: 0.85rem;
}
dl.info-grid dd {
margin: 0;
}
.error {
background: var(--danger-bg);
color: var(--danger);
padding: 0.6rem 0.9rem;
border-radius: 8px;
font-size: 0.9rem;
}
.hint {
color: var(--text-muted);
font-size: 0.8rem;
margin: -0.4rem 0 0.6rem;
}
.summary-error {
color: var(--danger);
font-size: 0.82rem;
}
.empty-state {
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
form.stacked-form {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-width: 420px;
}
form.stacked-form label {
font-size: 0.85rem;
font-weight: 500;
margin-top: 0.6rem;
}
form.stacked-form input[type="text"],
form.stacked-form input[type="password"],
form.stacked-form input[type="email"] {
font: inherit;
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
form.stacked-form button {
margin-top: 1rem;
align-self: flex-start;
}
.inline-form {
display: inline;
}

View File

@@ -2,9 +2,20 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MyWhoosh Garmin Sync{% endblock %}</title> <title>{% block title %}MyWhoosh Garmin Sync{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<header class="topbar">
<span class="brand">MyWhoosh &rarr; Garmin Sync</span>
<nav>
<a href="/">Dashboard</a>
<a href="/system">System</a>
</nav>
</header>
<main class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main>
</body> </body>
</html> </html>

View File

@@ -4,31 +4,39 @@
{% block content %} {% block content %}
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p><a href="/users/new">Add user</a> | <a href="/system">System</a></p>
<form method="post" action="/sync-all"> <div class="page-actions">
<a class="btn secondary" href="/users/new">Add user</a>
<form method="post" action="/sync-all" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button> <button type="submit">Sync all now</button>
</form>
<ul>
{% for row in rows %}
<li>
<a href="/users/{{ row.id }}">{{ row.name }}</a>
&mdash; {{ "enabled" if row.enabled else "disabled" }}
&mdash; {{ row.health_state }}
&mdash; last sync: {{ row.last_sync_at or "-" }}
&mdash; last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required — <a href="/users/{{ row.id }}">resolve</a></span>
{% endif %}
<form method="post" action="/users/{{ row.id }}/sync" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button>
</form> </form>
</div>
<ul class="user-list">
{% for row in rows %}
<li class="card user-card">
<div class="user-main">
<a class="user-name" href="/users/{{ row.id }}">{{ row.name }}</a>
<div class="user-meta">
<span class="badge badge-{{ row.health_state }}">{{ row.health_state.replace("_", " ") }}</span>
<span>{{ "enabled" if row.enabled else "disabled" }}</span>
<span>last sync: {{ row.last_sync_at or "-" }}</span>
<span>last activity: {{ row.last_activity_name or "-" }} ({{ row.last_activity_status or "-" }})</span>
</div>
{% if row.action_reason == "garmin_mfa_required" %}
<span class="action-required">Garmin MFA required &mdash; <a href="/users/{{ row.id }}">resolve</a></span>
{% endif %}
</div>
<div class="user-actions">
<form method="post" action="/users/{{ row.id }}/sync" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Sync now</button>
</form>
</div>
</li> </li>
{% else %} {% else %}
<li>No users yet.</li> <li class="card empty-state">No users yet.</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
<form method="post" action="/users/{{ user.id }}/garmin-mfa"> <form method="post" action="/users/{{ user.id }}/garmin-mfa" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Garmin MFA code: <input type="text" name="code" maxlength="20" required></label> <label for="mfa_code">Garmin MFA code</label>
<input type="text" id="mfa_code" name="code" maxlength="20" required>
<button type="submit">Submit code</button> <button type="submit">Submit code</button>
</form> </form>

View File

@@ -1,17 +1,45 @@
<ul> {% extends "base.html" %}
{% block title %}Sync result - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Sync result</h1>
<div class="page-actions">
<a class="btn secondary" href="/">Back to dashboard</a>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>User</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for outcome in outcomes %} {% for outcome in outcomes %}
<li> <tr>
User {{ outcome.user_id if outcome.user_id is not none else "unknown" }}: <td>{{ outcome.user_id if outcome.user_id is not none else "unknown" }}</td>
status={{ outcome.status }} <td><span class="badge badge-{{ outcome.status }}">{{ outcome.status }}</span></td>
discovered={{ outcome.discovered }} <td>{{ outcome.discovered }}</td>
imported={{ outcome.imported }} <td>{{ outcome.imported }}</td>
skipped={{ outcome.skipped }} <td>{{ outcome.skipped }}</td>
failed={{ outcome.failed }} <td>{{ outcome.failed }}</td>
</tr>
{% if outcome.message %} {% if outcome.message %}
&mdash; {{ outcome.message }} <tr>
<td colspan="6" class="summary-error">{{ outcome.message }}</td>
</tr>
{% endif %} {% endif %}
</li>
{% else %} {% else %}
<li>No outcomes.</li> <tr>
<td colspan="6" class="empty-state">No outcomes.</td>
</tr>
{% endfor %} {% endfor %}
</ul> </tbody>
</table>
</div>
{% endblock %}

View File

@@ -4,13 +4,15 @@
{% block content %} {% block content %}
<h1>Admin Login</h1> <h1>Admin Login</h1>
{% if error %} <div class="card">
<p class="error">{{ error }}</p> {% if error %}
{% endif %} <p class="error">{{ error }}</p>
<form method="post" action="/login"> {% endif %}
<form method="post" action="/login" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus> <input type="password" id="password" name="password" required autofocus>
<button type="submit">Log in</button> <button type="submit">Log in</button>
</form> </form>
</div>
{% endblock %} {% endblock %}

View File

@@ -4,9 +4,9 @@
{% block content %} {% block content %}
<h1>System</h1> <h1>System</h1>
<p><a href="/">Back to dashboard</a></p>
<dl> <div class="card">
<dl class="info-grid">
<dt>Application version</dt> <dt>Application version</dt>
<dd>{{ app_version }}</dd> <dd>{{ app_version }}</dd>
@@ -24,9 +24,10 @@
<dt>Activity count</dt> <dt>Activity count</dt>
<dd>{{ activity_count }}</dd> <dd>{{ activity_count }}</dd>
</dl> </dl>
</div>
<form method="post" action="/sync-all"> <form method="post" action="/sync-all" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync all now</button> <button type="submit">Sync all now</button>
</form> </form>

View File

@@ -4,15 +4,19 @@
{% block content %} {% block content %}
<h1>{{ user.name }}</h1> <h1>{{ user.name }}</h1>
<p><a href="/users/{{ user.id }}/edit">Edit</a> | <a href="/">Back to dashboard</a></p> <div class="page-actions">
<a class="btn secondary" href="/users/{{ user.id }}/edit">Edit</a>
<a class="btn secondary" href="/">Back to dashboard</a>
</div>
<div class="card">
<dl class="info-grid">
<dt>Status</dt>
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
<dl>
<dt>Enabled</dt> <dt>Enabled</dt>
<dd>{{ "Yes" if user.enabled else "No" }}</dd> <dd>{{ "Yes" if user.enabled else "No" }}</dd>
<dt>Health state</dt>
<dd>{{ user.health_state.value }}</dd>
<dt>MyWhoosh state</dt> <dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd> <dd>{{ user.mywhoosh_state }}</dd>
@@ -27,27 +31,71 @@
<dt>Updated at</dt> <dt>Updated at</dt>
<dd>{{ user.updated_at }}</dd> <dd>{{ user.updated_at }}</dd>
</dl> </dl>
</div>
{% if user.action_reason == "garmin_mfa_required" %} {% if user.action_reason == "garmin_mfa_required" %}
<h2>Garmin MFA required</h2> <h2>Garmin MFA required</h2>
{% include "fragments/mfa_form.html" %} <div class="card">
{% include "fragments/mfa_form.html" %}
</div>
{% endif %} {% endif %}
<h2>Recent sync runs</h2>
<div class="card">
{% if recent_runs %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Finished</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for run in recent_runs %}
<tr>
<td>{{ run.started_at }}</td>
<td>{{ run.finished_at or "-" }}</td>
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
<td>{{ run.discovered_count }}</td>
<td>{{ run.imported_count }}</td>
<td>{{ run.skipped_count }}</td>
<td>{{ run.failed_count }}</td>
</tr>
{% if run.summary_error %}
<tr>
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No sync runs yet.</p>
{% endif %}
</div>
<h2>Activities</h2> <h2>Activities</h2>
<ul> <div class="card">
<ul class="user-list">
{% for activity in activities %} {% for activity in activities %}
<li> <li>
{{ activity.activity_name }} &mdash; {{ activity.status.value }} {{ activity.activity_name }} &mdash; {{ activity.status.value }}
{% if activity.status.value == "failed" and activity.retryable %} {% if activity.status.value == "failed" and activity.retryable %}
<form method="post" action="/activities/{{ activity.id }}/retry" style="display:inline"> <form method="post" action="/activities/{{ activity.id }}/retry" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Retry</button> <button type="submit" class="secondary">Retry</button>
</form> </form>
{% endif %} {% endif %}
</li> </li>
{% else %} {% else %}
<li>No pending activities.</li> <li class="empty-state">No pending activities.</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
{% endblock %} {% endblock %}

View File

@@ -4,7 +4,8 @@
{% block content %} {% block content %}
<h1>{% if user %}Edit User{% else %}New User{% endif %}</h1> <h1>{% if user %}Edit User{% else %}New User{% endif %}</h1>
<form method="post" action="{{ form_action }}"> <div class="card">
<form method="post" action="{{ form_action }}" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="name">Name</label> <label for="name">Name</label>
@@ -36,5 +37,6 @@
</label> </label>
<button type="submit">{% if user %}Save{% else %}Create{% endif %}</button> <button type="submit">{% if user %}Save{% else %}Create{% endif %}</button>
</form> </form>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,7 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.db.repositories import UserRepository from app.db.models import SyncRunStatus
from app.db.repositories import SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
@@ -171,6 +172,30 @@ def test_user_detail_page_shows_no_secrets(client: TestClient) -> None:
assert "Max" in response.text assert "Max" in response.text
def test_user_detail_page_shows_recent_sync_runs(client: TestClient) -> None:
login(client)
user_id = create_user_via_http(client)
with client.app.state.session_factory() as session:
repo = SyncRunRepository(session)
run = repo.start(user_id)
repo.finish(
run.id,
status=SyncRunStatus.SUCCESS,
discovered=3,
imported=2,
skipped=1,
failed=0,
)
response = client.get(f"/users/{user_id}")
assert response.status_code == 200
assert "Recent sync runs" in response.text
assert "success" in response.text
assert ">3<" in response.text
assert ">2<" in response.text
def test_unknown_user_returns_404_for_detail(client: TestClient) -> None: def test_unknown_user_returns_404_for_detail(client: TestClient) -> None:
login(client) login(client)
response = client.get("/users/999999") response = client.get("/users/999999")