web dashboard

This commit is contained in:
2026-08-05 05:56:28 +02:00
parent af922f23ba
commit cf3563124f
7 changed files with 363 additions and 10 deletions
+5 -2
View File
@@ -204,12 +204,15 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich.
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
|---------|------|--------------| |---------|------|--------------|
| `GET` | `/` | Web-UI — zeigt Kontakte des eingeloggten Users (HTML) | | `GET` | `/` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) |
| `GET` | `/contacts/{id}` | Web-UI — Detailseite eines einzelnen Kontakts | | `GET` | `/search` | Web-UI -- Suchfunktion, zeigt Kontakte des eingeloggten Users |
| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts |
| `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig | | `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig |
| `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) | | `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) |
| `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen | | `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen |
| `GET` | `/api/contacts/count` | Anzahl der Kontakte des eingeloggten Users |
| `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage | | `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage |
| `GET` | `/api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (`?days=7`, Default 7) |
| `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) | | `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) |
Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
+4 -1
View File
@@ -240,12 +240,15 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft.
| Endpunkt | Beschreibung | | Endpunkt | Beschreibung |
|---|---| |---|---|
| `GET /` | Einfache HTML-Übersicht (Jinja2-Template), zeigt Kontakte des zugeordneten Accounts | | `GET /` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) |
| `GET /search` | HTML-Übersicht mit Suchfunktion, zeigt Kontakte des zugeordneten Accounts |
| `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) | | `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) |
| `GET /api/health` | Health-Check ohne Auth-Anforderung | | `GET /api/health` | Health-Check ohne Auth-Anforderung |
| `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` | | `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` |
| `GET /api/contacts/{id}` | Einzelner Kontakt (JSON) | | `GET /api/contacts/{id}` | Einzelner Kontakt (JSON) |
| `GET /api/contacts/count` | Anzahl der Kontakte des zugeordneten Accounts |
| `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) | | `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) |
| `GET /api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (Parameter `days`, Default 7) |
| `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) | | `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) |
### 12.5 Netzwerkkontext ### 12.5 Netzwerkkontext
+63 -4
View File
@@ -11,15 +11,15 @@ import json
import logging import logging
from datetime import date from datetime import date
from fastapi import FastAPI, Depends, Query, Request from fastapi import Depends, FastAPI, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from config import Config
import db import db
from api.auth import get_current_user, resolve_account_for_user from api.auth import get_current_user, resolve_account_for_user
from api.schemas import ContactListResponse, ContactOut, SyncRunOut from api.schemas import ContactListResponse, ContactOut, SyncRunOut
from config import Config
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("api") logger = logging.getLogger("api")
@@ -129,6 +129,25 @@ def birthdays_today(current_user: str = Depends(get_current_user)):
return [_row_to_contact_out(r) for r in rows] return [_row_to_contact_out(r) for r in rows]
@app.get("/api/contacts/birthdays/upcoming")
def birthdays_upcoming(
days: int = Query(default=7, ge=1, le=90),
current_user: str = Depends(get_current_user),
):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
rows = db.get_upcoming_birthdays(conn, account_name, days)
return {"days": days, "items": rows}
@app.get("/api/contacts/count")
def contact_count(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
total = db.get_contact_count(conn, account_name)
return {"total": total}
@app.get("/api/sync-runs", response_model=list[SyncRunOut]) @app.get("/api/sync-runs", response_model=list[SyncRunOut])
def list_sync_runs(current_user: str = Depends(get_current_user)): def list_sync_runs(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user) account_name, is_admin = resolve_account_for_user(current_user)
@@ -153,7 +172,47 @@ def list_sync_runs(current_user: str = Depends(get_current_user)):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def web_index( def web_dashboard(
request: Request,
current_user: str = Depends(get_current_user),
):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
contact_count = db.get_contact_count(conn, account_name)
upcoming_birthdays = db.get_upcoming_birthdays(conn, account_name, 7)
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, sync_type, started_at, finished_at, status,
contacts_upserted, contacts_deleted, error_message
FROM sync_runs {where_clause}
ORDER BY started_at DESC
LIMIT 1""",
params,
)
last_sync = cur.fetchone()
if last_sync:
last_sync["started_at"] = str(last_sync["started_at"])
last_sync["finished_at"] = str(last_sync["finished_at"]) if last_sync["finished_at"] else None
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"current_user": current_user,
"is_admin": is_admin,
"account_name": account_name or "alle Accounts",
"contact_count": contact_count,
"upcoming_birthdays": upcoming_birthdays,
"last_sync": last_sync,
},
)
@app.get("/search", response_class=HTMLResponse)
def web_search(
request: Request, request: Request,
search: str | None = Query(default=None), search: str | None = Query(default=None),
current_user: str = Depends(get_current_user), current_user: str = Depends(get_current_user),
@@ -215,7 +274,7 @@ def web_contact(
if not row: if not row:
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/search", status_code=303)
contact = _row_to_contact_out(row) contact = _row_to_contact_out(row)
+1 -1
View File
@@ -182,7 +182,7 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<a href="/{% if search %}?search={{ search }}{% endif %}" class="back-link">← Zurück zur Übersicht</a> <a href="/search{% if search %}?search={{ search }}{% endif %}" class="back-link">← Zurück zur Übersicht</a>
<div class="contact-card"> <div class="contact-card">
<div class="contact-header"> <div class="contact-header">
+251
View File
@@ -0,0 +1,251 @@
<!-- Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) -->
<!-- Licensed under the MIT License. See LICENSE file in project root for details. -->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<title>Dashboard {{ account_name }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h1 {
font-size: 1.5rem;
color: #222;
}
.nav-links {
display: flex;
gap: 0.75rem;
}
.nav-links a {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: #0066cc;
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 0.875rem;
transition: background 0.15s;
}
.nav-links a:hover {
background: #0055aa;
}
.meta {
color: #666;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 600px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.box {
background: #fff;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.box-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
margin-bottom: 1rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #222;
}
.stat-label {
font-size: 0.875rem;
color: #666;
margin-top: 0.25rem;
}
.sync-status {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.success {
background: #28a745;
}
.status-dot.failed {
background: #dc3545;
}
.status-dot.running {
background: #ffc107;
}
.sync-detail {
font-size: 0.875rem;
color: #666;
}
.sync-detail strong {
color: #333;
}
.birthday-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.birthday-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #f8f9fa;
border-radius: 6px;
}
.birthday-name {
font-size: 0.875rem;
color: #333;
}
.birthday-date {
font-size: 0.8rem;
color: #888;
white-space: nowrap;
}
.empty-state {
color: #888;
font-size: 0.875rem;
font-style: italic;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Dashboard</h1>
<div class="nav-links">
<a href="/search">Suche</a>
</div>
</div>
<div class="meta">
Angemeldet als <strong>{{ current_user }}</strong>
{% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %}
</div>
<div class="dashboard-grid">
<div class="box">
<div class="box-title">Kontakte</div>
<div class="stat-value">{{ contact_count }}</div>
<div class="stat-label">gespeicherte Kontakte</div>
</div>
<div class="box">
<div class="box-title">Letzter Sync</div>
{% if last_sync %}
<div class="sync-status">
<div class="status-dot {{ last_sync.status }}"></div>
<div class="sync-detail">
<strong>{{ last_sync.status }}</strong> · {{ last_sync.sync_type }}
</div>
</div>
<div class="sync-detail">
{{ last_sync.started_at }}
{% if last_sync.contacts_upserted is not none %}
· {{ last_sync.contacts_upserted }} aktualisiert
{% endif %}
{% if last_sync.contacts_deleted is not none and last_sync.contacts_deleted > 0 %}
· {{ last_sync.contacts_deleted }} gelöscht
{% endif %}
</div>
{% if last_sync.error_message %}
<div class="sync-detail" style="color: #dc3545; margin-top: 0.5rem;">
{{ last_sync.error_message }}
</div>
{% endif %}
{% else %}
<div class="empty-state">Noch kein Sync durchgeführt.</div>
{% endif %}
</div>
</div>
<div class="box">
<div class="box-title">Geburtstage nächste 7 Tage</div>
{% if upcoming_birthdays %}
<div class="birthday-list">
{% for b in upcoming_birthdays %}
<div class="birthday-item">
<div class="birthday-name">
<a href="/contacts/{{ b.id }}" style="color: #0066cc; text-decoration: none;">{{ b.full_name or '(Kein Name)' }}</a>
{% if b.organization %}
<span style="color: #888; font-size: 0.8rem;"> · {{ b.organization }}</span>
{% endif %}
</div>
<div class="birthday-date">{{ b.birthday }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">Keine Geburtstage in den nächsten 7 Tagen.</div>
{% endif %}
</div>
</div>
</body>
</html>
+2 -2
View File
@@ -173,11 +173,11 @@
· {{ contacts|length }} Kontakte (max. 200 angezeigt) · {{ contacts|length }} Kontakte (max. 200 angezeigt)
</div> </div>
<form class="search-form" method="get" action="/"> <form class="search-form" method="get" action="/search">
<input type="text" name="search" value="{{ search }}" placeholder="Suche nach Name oder Organisation..."> <input type="text" name="search" value="{{ search }}" placeholder="Suche nach Name oder Organisation...">
<button type="submit">Suchen</button> <button type="submit">Suchen</button>
{% if search %} {% if search %}
<a href="/">Zurücksetzen</a> <a href="/search">Zurücksetzen</a>
{% endif %} {% endif %}
</form> </form>
+37
View File
@@ -5,6 +5,7 @@ import logging
import os import os
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date
import pymysql import pymysql
from pymysql.cursors import DictCursor from pymysql.cursors import DictCursor
@@ -121,6 +122,42 @@ def delete_contacts_by_href_uids(conn, account: str, uids: list[str]):
conn.commit() conn.commit()
def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
if account_name is None:
return "", []
return "WHERE account = %s", [account_name]
def get_contact_count(conn, account: str | None) -> int:
where_clause, params = _account_filter_clause(account)
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM contacts {where_clause}", params)
return cur.fetchone()["total"]
def get_upcoming_birthdays(conn, account: str | None, days: int = 7) -> list[dict]:
where_clause, params = _account_filter_clause(account)
today = date.today()
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, full_name, organization, birthday, account
FROM contacts {where_clause}
{"AND" if where_clause else "WHERE"} birthday IS NOT NULL
AND (
(MONTH(birthday) > %s)
OR (MONTH(birthday) = %s AND DAY(birthday) >= %s)
)
AND (
(MONTH(birthday) < %s)
OR (MONTH(birthday) = %s AND DAY(birthday) <= %s + %s)
)
ORDER BY MONTH(birthday), DAY(birthday)""",
params + [today.month, today.month, today.day,
today.month, today.month, today.day, days],
)
return cur.fetchall()
def _sanitize_contact(c: dict) -> dict: def _sanitize_contact(c: dict) -> dict:
"""Stellt sicher, dass alle Werte Skalare sind (kein tuple/list/dict).""" """Stellt sicher, dass alle Werte Skalare sind (kein tuple/list/dict)."""
sanitized = {} sanitized = {}