diff --git a/app/db/repositories.py b/app/db/repositories.py index 4710a0b..254d5b0 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -204,6 +204,16 @@ class SyncRunRepository: def get(self, sync_run_id: int) -> SyncRun | None: 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( self, sync_run_id: int, diff --git a/app/main.py b/app/main.py index eae2fd9..f9dc5cc 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,8 @@ from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware 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(operations_router) + app.mount( + "/static", + StaticFiles(directory=str(Path(__file__).resolve().parent / "web" / "static")), + name="static", + ) @app.get("/healthz") def healthz() -> dict[str, str]: diff --git a/app/web/routes.py b/app/web/routes.py index 0beef9a..e217e43 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates from app.auth.admin import password_matches, require_admin from app.auth.csrf import ensure_csrf_token, validate_csrf 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.web.forms import UserFormData @@ -132,6 +132,7 @@ def user_detail(request: Request, user_id: int): with request.app.state.session_factory() as session: user = _get_user_or_404(UserRepository(session), 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( request, "users/detail.html", @@ -139,6 +140,7 @@ def user_detail(request: Request, user_id: int): "csrf_token": ensure_csrf_token(request), "user": user, "activities": activities, + "recent_runs": recent_runs, }, ) diff --git a/app/web/static/style.css b/app/web/static/style.css new file mode 100644 index 0000000..c391e41 --- /dev/null +++ b/app/web/static/style.css @@ -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; +} diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 285049a..597d3cc 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -2,9 +2,20 @@
+| User | +Status | +Discovered | +Imported | +Skipped | +Failed | +
|---|---|---|---|---|---|
| {{ outcome.user_id if outcome.user_id is not none else "unknown" }} | +{{ outcome.status }} | +{{ outcome.discovered }} | +{{ outcome.imported }} | +{{ outcome.skipped }} | +{{ outcome.failed }} | +
| {{ outcome.message }} | +|||||
| No outcomes. | +|||||
{{ error }}
-{% endif %} - +{{ error }}
+ {% endif %} + +| Started | +Finished | +Status | +Discovered | +Imported | +Skipped | +Failed | +
|---|---|---|---|---|---|---|
| {{ run.started_at }} | +{{ run.finished_at or "-" }} | +{{ run.status.value }} | +{{ run.discovered_count }} | +{{ run.imported_count }} | +{{ run.skipped_count }} | +{{ run.failed_count }} | +
| {{ run.summary_error }} | +||||||
No sync runs yet.
+ {% endif %} +