From e0c39de21f3675fc028aab825f991adf4b1a764a Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 13:11:02 +0000 Subject: [PATCH 01/41] feat(kontingent): Credits-Stellwerte je Lizenz + Lizenz beim Org-Anlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedienseite für das Credits-Monatskontingent des Monitors. Das Portal setzt jetzt alle Stellwerte, der Monitor bleibt Herr der Laufzeitfelder. - Lizenz-Modal und Budget-Formular um Abrechnungszeitraum (monthly/total), Übertrag-Checkbox und Warnschwelle erweitert, Vorbelegungen auf das beschlossene Modell (10.000 Credits, monatlich, Übertrag an, 0,20 USD). - Neue Organisation kann direkt eine Lizenz mit Kontingent anlegen (OrgCreate.license, verschachtelt). Org- und Lizenz-Insert laufen in EINER Transaktion, schlägt der Lizenz-Teil fehl, entsteht keine halbe Organisation. Gemeinsamer Helper insert_license_row statt Duplikation der Widerruf- und Audit-Logik. - PUT /api/token-usage/budget validiert credits_period (monthly|total) und budget_warning_percent (1 bis 100), credits_rollover wird wie unlimited_budget normalisiert. Laufzeitfelder bleiben bewusst außerhalb der Whitelist. - Overview und Org-Detail rechnen den Rest jetzt gegen verfügbar = Kontingent + Übertrag, dieselbe Bezugsgröße wie der Hard-Stop im Monitor. Neuer read-only Block Laufzeitwerte im Token-Subtab. - Startup-Check loggt einen deutlichen Fehler, wenn die licenses-Tabelle die Credits-Spalten noch nicht hat (falsche Deploy-Reihenfolge). - Migration migrations/2026-07-25_credits_period.py (idempotent, wortgleich mit der Monitor-Migration) für die Portal-Staging-DB, auf Live gilt: Monitor VOR Portal promoten. - 13 neue Modell-Tests, pytest grün (die zwei bekannten shared-Import-Fehlschläge bestehen unverändert seit vorher). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 5 +- migrations/2026-07-25_credits_period.py | 57 +++++++++ src/main.py | 20 ++++ src/models.py | 42 +++++-- src/routers/licenses.py | 148 ++++++++++++++---------- src/routers/organizations.py | 15 +++ src/routers/token_usage.py | 55 ++++++++- src/static/dashboard.html | 128 +++++++++++++++++++- src/static/js/app.js | 134 +++++++++++++++++++-- tests/test_models.py | 54 ++++++++- 10 files changed, 566 insertions(+), 92 deletions(-) create mode 100644 migrations/2026-07-25_credits_period.py diff --git a/CLAUDE.md b/CLAUDE.md index 854ba9c..e8c6515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,11 +46,11 @@ src/: routers/: organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)" - licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module)" + licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode/Übertrag; Helper insert_license_row wird auch vom Org-Anlegen genutzt)" users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de" sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge" dashboard.py: "Aggregat-Endpoints für Übersichts-Tab" - token_usage.py: "Token-Verbrauch pro Org/Monat, Budget-Steuerung" + token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_carried, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte" audit.py: "Audit-Log-Abfrage, Filter" email_utils/: @@ -70,6 +70,7 @@ src/: migrations/: einmal_migrationen: "Backfill-Skripte (DE-Übersetzungen, Umlaute, HTML-Strip etc.)" + 2026-07-25_credits_period.py: "Credits-Perioden-Spalten in licenses (idempotent, identisch zur Monitor-Migration). Nötig für die Portal-Staging-DB, auf Live legt sie normalerweise der Monitor an. Reihenfolge: Monitor VOR Portal promoten." ``` ## Datenbank-Tabellen (relevant fürs Portal) diff --git a/migrations/2026-07-25_credits_period.py b/migrations/2026-07-25_credits_period.py new file mode 100644 index 0000000..13d3cdc --- /dev/null +++ b/migrations/2026-07-25_credits_period.py @@ -0,0 +1,57 @@ +"""Migration 2026-07-25: Credits-Perioden-Spalten in licenses. + +Legt die fünf Spalten des Credits-Monatskontingents an, exakt dieselben +Statements wie die Monitor-Migration (AegisSight-Monitor, src/database.py, +Block "Credits-Periode"). Idempotent, ein zweiter Lauf ist ein No-op. + +Hintergrund: Der Monitor legt diese Spalten beim Start selbst an. Die +Portal-Staging-Umgebung nutzt aber eine EIGENE Datenbank, auf der kein +Monitor läuft, deshalb braucht sie dieses Skript. Auf der Live-DB ist es +nur ein Fallback für den Fall, dass das Portal vor dem Monitor deployt wird. + +Ausführung: + DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-07-25_credits_period.py + DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-07-25_credits_period.py +""" +import os +import sqlite3 +import sys + +# Spaltenname -> ALTER-Statement, wortgleich mit der Monitor-Migration +COLUMNS = { + "credits_period": "ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'", + "credits_period_start": "ALTER TABLE licenses ADD COLUMN credits_period_start TEXT", + "credits_rollover": "ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0", + "credits_carried": "ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0", + "budget_warning_sent": "ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0", +} + + +def main(db_path: str) -> int: + if not os.path.exists(db_path): + print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr) + return 1 + + conn = sqlite3.connect(db_path, timeout=60) + conn.execute("PRAGMA busy_timeout = 60000") + conn.execute("PRAGMA journal_mode = WAL") + + print(f"Migration auf {db_path}") + + cols = [c[1] for c in conn.execute("PRAGMA table_info(licenses)")] + for name, stmt in COLUMNS.items(): + if name not in cols: + conn.execute(stmt) + print(f" + licenses.{name} Spalte hinzugefügt") + else: + print(f" = licenses.{name} war bereits da") + + conn.commit() + conn.close() + print("Migration abgeschlossen.") + return 0 + + +if __name__ == "__main__": + db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db") + sys.exit(main(db_path)) diff --git a/src/main.py b/src/main.py index e780557..b5e20fb 100644 --- a/src/main.py +++ b/src/main.py @@ -22,6 +22,26 @@ logger = logging.getLogger("verwaltung") @asynccontextmanager async def lifespan(app: FastAPI): + # Schutz gegen falsche Deploy-Reihenfolge: Die Credits-Spalten in licenses + # legt die Monitor-Migration an. Fehlen sie, scheitern Lizenz-Anlage und + # Token-Tab mit "no such column". Hier nur ein deutlicher Log-Hinweis, + # kein Hard-Fail. + try: + from database import get_db + db = await get_db() + try: + cursor = await db.execute("PRAGMA table_info(licenses)") + cols = [row[1] for row in await cursor.fetchall()] + if "credits_period" not in cols: + logger.error( + "licenses-Tabelle ohne Credits-Spalten (credits_period fehlt)! " + "Zuerst den Monitor deployen (dessen Migration legt sie an) " + "oder migrations/2026-07-25_credits_period.py gegen diese DB fahren." + ) + finally: + await db.close() + except Exception as exc: + logger.warning("Startup-Schema-Check uebersprungen: %s", exc) logger.info("Verwaltungsportal gestartet auf Port %s", PORT) yield logger.info("Verwaltungsportal beendet") diff --git a/src/models.py b/src/models.py index 573afa2..d362fec 100644 --- a/src/models.py +++ b/src/models.py @@ -22,10 +22,36 @@ class TokenResponse(BaseModel): email: str = "" +class LicenseParams(BaseModel): + """Stellwerte einer Lizenz ohne Org-Bezug. + + Wird zweifach genutzt, direkt beim Lizenz-Anlegen (LicenseCreate) und + verschachtelt beim Org-Anlegen (OrgCreate.license). Die Laufzeitfelder + (credits_used, credits_carried, credits_period_start, budget_warning_sent) + verwaltet der Monitor, das Portal setzt nur Stellwerte. + """ + license_type: str = Field(pattern="^(trial|annual|permanent)$") + max_users: int = Field(default=5, ge=1, le=1000) + duration_days: Optional[int] = Field(default=None, ge=1, le=3650) + token_budget_usd: Optional[float] = None + credits_total: Optional[int] = None + cost_per_credit: Optional[float] = None + budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100) + unlimited_budget: bool = False + # 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt (der Monitor + # setzt den Verbrauch traege zurueck), 'total' = gilt fuer die Laufzeit. + credits_period: str = Field(default="monthly", pattern="^(monthly|total)$") + # Uebertrag ungenutzter Credits in den Folgemonat, der Monitor deckelt + # auf ein Monatskontingent. Empfohlen an, faengt Krisenspitzen ab. + credits_rollover: bool = True + + class OrgCreate(BaseModel): name: str = Field(min_length=1, max_length=200) slug: str = Field(min_length=1, max_length=100, pattern="^[a-z0-9-]+$") output_language: str = Field(default="de", pattern="^(de|en)$") + # Optional direkt eine Lizenz mit Credits-Kontingent anlegen + license: Optional[LicenseParams] = None class OrgUpdate(BaseModel): @@ -48,16 +74,8 @@ class OrgResponse(BaseModel): output_language: str = "de" -class LicenseCreate(BaseModel): +class LicenseCreate(LicenseParams): organization_id: int - license_type: str = Field(pattern="^(trial|annual|permanent)$") - max_users: int = Field(default=5, ge=1, le=1000) - duration_days: Optional[int] = Field(default=None, ge=1, le=3650) - token_budget_usd: Optional[float] = None - credits_total: Optional[int] = None - cost_per_credit: Optional[float] = None - budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100) - unlimited_budget: bool = False class LicenseResponse(BaseModel): @@ -75,6 +93,12 @@ class LicenseResponse(BaseModel): cost_per_credit: Optional[float] = None budget_warning_percent: Optional[int] = None unlimited_budget: bool = False + # Neue Kontingent-Felder, alle optional mit Default, damit Responses auch + # von einer noch nicht migrierten Datenbank validieren. + credits_period: Optional[str] = "monthly" + credits_rollover: Optional[bool] = False + credits_carried: Optional[float] = 0 + credits_period_start: Optional[str] = None created_at: str globe_access: bool = False network_access: bool = False diff --git a/src/routers/licenses.py b/src/routers/licenses.py index c83d427..05398a8 100644 --- a/src/routers/licenses.py +++ b/src/routers/licenses.py @@ -1,7 +1,7 @@ """Lizenz-CRUD.""" from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status, Request -from models import LicenseCreate, LicenseResponse +from models import LicenseCreate, LicenseParams, LicenseResponse from auth import get_current_admin from database import db_dependency from audit import log_action, get_client_ip, row_to_dict @@ -10,6 +10,90 @@ import aiosqlite router = APIRouter(prefix="/api/licenses", tags=["licenses"]) +async def insert_license_row( + db: aiosqlite.Connection, org_id: int, params: LicenseParams +) -> tuple[int, list[dict]]: + """Widerruft aktive Lizenzen der Org und legt die neue Zeile an. + + Committet bewusst NICHT, die Transaktionsgrenze bestimmt der Aufrufer. + So kann das Org-Anlegen Organisation und Lizenz atomar anlegen. + Gibt (neue Lizenz-ID, Snapshots der widerrufenen Lizenzen) zurueck, + die Audit-Eintraege schreibt audit_license_creation() nach dem Commit. + """ + cursor = await db.execute( + "SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'", + (org_id,), + ) + revoked_lics = [dict(r) for r in await cursor.fetchall()] + if revoked_lics: + await db.execute( + "UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'", + (org_id,), + ) + + now = datetime.now(timezone.utc) + valid_from = now.isoformat() + valid_until = None + + if params.license_type == "permanent": + valid_until = None + elif params.duration_days: + valid_until = (now + timedelta(days=params.duration_days)).isoformat() + elif params.license_type == "trial": + valid_until = (now + timedelta(days=14)).isoformat() + elif params.license_type == "annual": + valid_until = (now + timedelta(days=365)).isoformat() + + # Bei unlimited_budget: Kontingent-Felder ignorieren, Uebertrag aus + if params.unlimited_budget: + token_budget_usd = None + credits_total = None + cost_per_credit = None + credits_rollover = 0 + else: + token_budget_usd = params.token_budget_usd + credits_total = params.credits_total + cost_per_credit = params.cost_per_credit + credits_rollover = 1 if params.credits_rollover else 0 + + # credits_period_start bleibt bewusst NULL, der Monitor setzt die + # Periodenmarke traege bei der naechsten Lizenzpruefung selbst. + cursor = await db.execute( + """INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status, + token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget, + credits_period, credits_rollover) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)""", + (org_id, params.license_type, params.max_users, valid_from, valid_until, + token_budget_usd, credits_total, cost_per_credit, params.budget_warning_percent, + 1 if params.unlimited_budget else 0, + params.credits_period, credits_rollover), + ) + return cursor.lastrowid, revoked_lics + + +async def audit_license_creation( + db: aiosqlite.Connection, admin: dict, ip: str, lic_id: int, revoked_lics: list[dict] +) -> dict: + """Schreibt die Audit-Eintraege fuers Lizenz-Anlegen (nach dem Commit).""" + for old_lic in revoked_lics: + new_lic = dict(old_lic) + new_lic["status"] = "revoked" + await log_action( + db, admin, ip, + action="update", resource_type="license", resource_id=old_lic["id"], + before=old_lic, after=new_lic, + ) + + cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,)) + new_lic = dict(await cursor.fetchone()) + await log_action( + db, admin, ip, + action="create", resource_type="license", resource_id=lic_id, + after=new_lic, + ) + return new_lic + + @router.get("", response_model=list[LicenseResponse]) async def list_licenses( org_id: int = None, @@ -39,68 +123,12 @@ async def create_license( if not await cursor.fetchone(): raise HTTPException(status_code=404, detail="Organisation nicht gefunden") - # Bestehende aktive Lizenz widerrufen + Snapshot fuer Audit - cursor = await db.execute( - "SELECT * FROM licenses WHERE organization_id = ? AND status = 'active'", - (data.organization_id,), - ) - revoked_lics = [dict(r) for r in await cursor.fetchall()] - if revoked_lics: - await db.execute( - "UPDATE licenses SET status = 'revoked' WHERE organization_id = ? AND status = 'active'", - (data.organization_id,), - ) - for old_lic in revoked_lics: - new_lic = dict(old_lic) - new_lic["status"] = "revoked" - await log_action( - db, admin, get_client_ip(request), - action="update", resource_type="license", resource_id=old_lic["id"], - before=old_lic, after=new_lic, - ) - - now = datetime.now(timezone.utc) - valid_from = now.isoformat() - valid_until = None - - if data.license_type == "permanent": - valid_until = None - elif data.duration_days: - valid_until = (now + timedelta(days=data.duration_days)).isoformat() - elif data.license_type == "trial": - valid_until = (now + timedelta(days=14)).isoformat() - elif data.license_type == "annual": - valid_until = (now + timedelta(days=365)).isoformat() - - # Bei unlimited_budget: Credits/Cost/Budget ignorieren - if data.unlimited_budget: - token_budget_usd = None - credits_total = None - cost_per_credit = None - else: - token_budget_usd = data.token_budget_usd - credits_total = data.credits_total - cost_per_credit = data.cost_per_credit - - cursor = await db.execute( - """INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status, - token_budget_usd, credits_total, cost_per_credit, budget_warning_percent, unlimited_budget) - VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)""", - (data.organization_id, data.license_type, data.max_users, valid_from, valid_until, - token_budget_usd, credits_total, cost_per_credit, data.budget_warning_percent, - 1 if data.unlimited_budget else 0), - ) - lic_id = cursor.lastrowid + lic_id, revoked_lics = await insert_license_row(db, data.organization_id, data) await db.commit() - cursor = await db.execute("SELECT * FROM licenses WHERE id = ?", (lic_id,)) - new_lic = dict(await cursor.fetchone()) - await log_action( - db, admin, get_client_ip(request), - action="create", resource_type="license", resource_id=lic_id, - after=new_lic, + return await audit_license_creation( + db, admin, get_client_ip(request), lic_id, revoked_lics ) - return new_lic @router.put("/{license_id}/revoke") diff --git a/src/routers/organizations.py b/src/routers/organizations.py index a936665..28f38e7 100644 --- a/src/routers/organizations.py +++ b/src/routers/organizations.py @@ -64,6 +64,16 @@ async def create_organization( (data.name, data.slug, now, now), ) org_id = cursor.lastrowid + + # Optional direkt eine Lizenz mit Credits-Kontingent anlegen. Beide Inserts + # liegen VOR dem Commit, schlaegt der Lizenz-Teil fehl (z.B. fehlende + # Spalten), entsteht keine halb angelegte Organisation. + lic_id = None + revoked_lics: list = [] + if data.license is not None: + from routers.licenses import insert_license_row + lic_id, revoked_lics = await insert_license_row(db, org_id, data.license) + await db.commit() # output_language als organization_settings-Eintrag persistieren @@ -77,6 +87,11 @@ async def create_organization( action="create", resource_type="organization", resource_id=org_id, after=dict(new_row_obj), ) + if lic_id is not None: + from routers.licenses import audit_license_creation + await audit_license_creation( + db, admin, get_client_ip(request), lic_id, revoked_lics + ) return await _enrich_org(db, new_row_obj) diff --git a/src/routers/token_usage.py b/src/routers/token_usage.py index af02c81..e14faf2 100644 --- a/src/routers/token_usage.py +++ b/src/routers/token_usage.py @@ -20,6 +20,7 @@ async def get_usage_overview(admin=Depends(get_current_admin)): o.id, o.name, o.slug, l.credits_total, l.credits_used, l.cost_per_credit, l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget, + l.credits_period, l.credits_rollover, l.credits_carried, l.credits_period_start, COALESCE(SUM(r.total_cost_usd), 0) as total_cost, COALESCE(SUM(r.input_tokens), 0) as total_input_tokens, COALESCE(SUM(r.output_tokens), 0) as total_output_tokens, @@ -36,9 +37,14 @@ async def get_usage_overview(admin=Depends(get_current_admin)): for row in rows: credits_total = row["credits_total"] or 0 credits_used = row["credits_used"] or 0 + credits_carried = row["credits_carried"] or 0 unlimited = bool(row["unlimited_budget"]) - credits_remaining = None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None) - percent_used = None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None) + # Verfuegbar = Kontingent plus Uebertrag. Der Monitor gewaehrt beim + # Hard-Stop inklusive Uebertrag, die Anzeige muss dieselbe + # Bezugsgroesse nutzen, sonst zeigt sie zu wenig Rest an. + credits_available = credits_total + credits_carried + credits_remaining = None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None) + percent_used = None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None) budget_usd = row["token_budget_usd"] cost = row["total_cost"] budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None) @@ -51,6 +57,11 @@ async def get_usage_overview(admin=Depends(get_current_admin)): "credits_used": round(credits_used, 1), "credits_remaining": credits_remaining, "credits_percent_used": percent_used, + "credits_available": credits_available, + "credits_carried": credits_carried, + "credits_period": row["credits_period"] or "monthly", + "credits_rollover": bool(row["credits_rollover"]), + "credits_period_start": row["credits_period_start"], "token_budget_usd": budget_usd, "total_cost_usd": round(cost, 2), "budget_percent_used": budget_percent, @@ -122,13 +133,19 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)): } cursor = await db.execute( - "SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", + "SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget, " + "credits_period, credits_rollover, credits_carried, credits_period_start " + "FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", (org_id,)) lic = await cursor.fetchone() unlimited = bool(lic["unlimited_budget"]) if lic else False credits_total = lic["credits_total"] if lic else None credits_used = lic["credits_used"] if lic else 0 + credits_carried = (lic["credits_carried"] or 0) if lic else 0 + # Verfuegbar = Kontingent plus Uebertrag, dieselbe Bezugsgroesse wie + # der Hard-Stop im Monitor. + credits_available = (credits_total or 0) + credits_carried return { "year_month": year_month, @@ -144,8 +161,13 @@ async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)): "unlimited_budget": unlimited, "credits_total": credits_total, "credits_used": round(credits_used, 1) if credits_used else 0, - "credits_remaining": None if unlimited else (max(0, int(credits_total - credits_used)) if credits_total else None), - "credits_percent_used": None if unlimited else (round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None), + "credits_remaining": None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None), + "credits_percent_used": None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None), + "credits_available": credits_available, + "credits_carried": credits_carried, + "credits_period": (lic["credits_period"] or "monthly") if lic else "monthly", + "credits_rollover": bool(lic["credits_rollover"]) if lic else False, + "credits_period_start": lic["credits_period_start"] if lic else None, "token_budget_usd": lic["token_budget_usd"] if lic else None, "cost_per_credit": lic["cost_per_credit"] if lic else None, "budget_warning_percent": lic["budget_warning_percent"] if lic else 80, @@ -164,9 +186,23 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep if not before: raise HTTPException(status_code=404, detail="Lizenz nicht gefunden") + # Vorab-Validierung, der Endpoint nimmt ein rohes dict entgegen. + # Ein Tippfehler wie 'weekly' wuerde sonst still in der DB landen und + # der Monitor behandelte die Lizenz wie 'total' (kein Monatsreset). + if "credits_period" in data and data["credits_period"] not in ("monthly", "total"): + raise HTTPException(status_code=400, detail="credits_period muss 'monthly' oder 'total' sein") + if "budget_warning_percent" in data: + try: + pct = int(data["budget_warning_percent"]) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="budget_warning_percent muss eine Zahl sein") + if not 1 <= pct <= 100: + raise HTTPException(status_code=400, detail="budget_warning_percent muss zwischen 1 und 100 liegen") + data["budget_warning_percent"] = pct + fields = [] values = [] - for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"): + for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent", "credits_period"): if key in data: fields.append(f"{key} = ?") values.append(data[key]) @@ -179,6 +215,13 @@ async def update_budget(license_id: int, data: dict, request: Request, admin=Dep fields.append("unlimited_budget = ?") values.append(1 if data["unlimited_budget"] else 0) + if "credits_rollover" in data: + fields.append("credits_rollover = ?") + values.append(1 if data["credits_rollover"] else 0) + + # Bewusst NICHT aenderbar: credits_carried, credits_period_start und + # budget_warning_sent sind Laufzeitfelder, die verwaltet der Monitor. + if not fields: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") diff --git a/src/static/dashboard.html b/src/static/dashboard.html index b1fe1e8..30d8f30 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -267,18 +267,35 @@
- + +
+
+ + +
+
+ +
+
+ +
- +
- +
@@ -288,6 +305,31 @@
+ +

Laufzeitwerte

+
+
+

+ Diese Werte verwaltet der Monitor selbst, sie sind hier nicht änderbar. + Stand ist die letzte Lizenzprüfung des Monitors, nach einem Monatswechsel + können sie kurzzeitig noch die Vorperiode zeigen. +

+
+
+ +
-
+
+
+ +
-
+
+
+ +
-
+
+
+
+
@@ -574,6 +616,63 @@ Steuert die Ausgabesprache der KI-Pipeline (Lagebild, Faktencheck, Recherche) und die sichtbarsten UI-Strings im Monitor. + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
- + +
+
+ + +
+
+ +
+
+ +
- +
@@ -1038,7 +1154,7 @@
- + diff --git a/src/static/js/app.js b/src/static/js/app.js index cf47dab..9c047be 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -442,14 +442,42 @@ function setupForms() { e.preventDefault(); const errEl = document.getElementById("newOrgError"); errEl.style.display = "none"; + const body = { + name: document.getElementById("newOrgName").value, + slug: document.getElementById("newOrgSlug").value, + output_language: document.getElementById("newOrgLanguage").value || "de", + }; + // Optional direkt eine Lizenz mit Credits-Kontingent mitgeben + const withLic = document.getElementById("newOrgWithLicense"); + if (withLic && withLic.checked) { + const licType = document.getElementById("newOrgLicType").value; + const lic = { + license_type: licType, + max_users: parseInt(document.getElementById("newOrgLicMaxUsers").value) || 5, + unlimited_budget: !!document.getElementById("newOrgLicUnlimited").checked, + // Periode und Uebertrag IMMER senden, sonst laesst sich der + // Uebertrag ueber das Formular nie abschalten + credits_period: document.getElementById("newOrgLicCreditsPeriod").value, + credits_rollover: !!document.getElementById("newOrgLicRollover").checked, + }; + if (licType !== "permanent") { + lic.duration_days = parseInt(document.getElementById("newOrgLicDuration").value) || undefined; + } + if (!lic.unlimited_budget) { + const ct = document.getElementById("newOrgLicCreditsTotal"); + const cpc = document.getElementById("newOrgLicCostPerCredit"); + if (ct && ct.value) lic.credits_total = parseInt(ct.value); + if (cpc && cpc.value) lic.cost_per_credit = parseFloat(cpc.value); + } + const warn = document.getElementById("newOrgLicWarnPercent"); + if (warn && warn.value) lic.budget_warning_percent = parseInt(warn.value); + body.license = lic; + } try { - await API.post("/api/orgs", { - name: document.getElementById("newOrgName").value, - slug: document.getElementById("newOrgSlug").value, - output_language: document.getElementById("newOrgLanguage").value || "de", - }); + await API.post("/api/orgs", body); closeModal("modalNewOrg"); document.getElementById("newOrgForm").reset(); + onNewOrgLicenseToggle(); loadOrgs(); loadDashboard(); } catch (err) { @@ -515,6 +543,10 @@ function setupForms() { } const unlimitedEl = document.getElementById('newLicUnlimited'); body.unlimited_budget = !!(unlimitedEl && unlimitedEl.checked); + // Periode und Uebertrag IMMER senden (nicht nur bei gesetztem Wert), + // sonst laesst sich der Uebertrag nie abschalten + body.credits_period = document.getElementById('newLicCreditsPeriod').value; + body.credits_rollover = !!document.getElementById('newLicRollover').checked; if (!body.unlimited_budget) { const creditsTotal = document.getElementById('newLicCreditsTotal'); const costPerCredit = document.getElementById('newLicCostPerCredit'); @@ -523,6 +555,8 @@ function setupForms() { if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value); if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value); } + const warnEl = document.getElementById('newLicWarnPercent'); + if (warnEl && warnEl.value) body.budget_warning_percent = parseInt(warnEl.value); try { await API.post("/api/licenses", body); closeModal("modalNewLicense"); @@ -818,7 +852,11 @@ async function loadOrgTokenUsage(orgId) { el('tokenCreditsRemaining', '∞ Unbegrenzt'); el('tokenBudgetUsd', '—'); } else { - el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-'); + var remainingTxt = budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-'; + if (budget.credits_carried > 0 && budget.credits_remaining != null) { + remainingTxt += ' (inkl. ' + Math.round(budget.credits_carried).toLocaleString('de-DE') + ' Übertrag)'; + } + el('tokenCreditsRemaining', remainingTxt); el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-'); } el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-'); @@ -887,37 +925,109 @@ function fillBudgetForm(budget) { el('editCostPerCredit', budget.cost_per_credit); el('editBudgetUsd', budget.token_budget_usd); el('editCreditsUsed', budget.credits_used ? Math.round(budget.credits_used) : 0); + el('editWarnPercent', budget.budget_warning_percent); + const periodSel = document.getElementById('editCreditsPeriod'); + if (periodSel) periodSel.value = budget.credits_period || 'monthly'; + const rolloverCb = document.getElementById('editCreditsRollover'); + if (rolloverCb) rolloverCb.checked = !!budget.credits_rollover; + onEditPeriodChange(); const cb = document.getElementById('editUnlimitedBudget'); if (cb) { cb.checked = !!budget.unlimited_budget; onUnlimitedToggle(); } + + // Laufzeitwerte (verwaltet der Monitor, hier nur Anzeige) + const txt = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; }; + const carried = budget.credits_carried || 0; + txt('viewCreditsCarried', carried > 0 ? Math.round(carried).toLocaleString('de-DE') + ' Credits' : 'Kein Übertrag'); + txt('viewCreditsPeriodStart', budget.credits_period_start || 'Noch nicht initialisiert'); + txt('viewCreditsAvailable', budget.unlimited_budget + ? '∞ Unbegrenzt' + : (budget.credits_available != null ? Math.round(budget.credits_available).toLocaleString('de-DE') + ' Credits' : '-')); } // Unlimited-Toggle: Felder ausgrauen wenn aktiv function onUnlimitedToggle() { const cb = document.getElementById('editUnlimitedBudget'); const isUnlimited = cb && cb.checked; - ['editCreditsTotal','editCostPerCredit','editBudgetUsd'].forEach(function(id) { + ['editCreditsTotal','editCostPerCredit','editBudgetUsd','editCreditsPeriod','editCreditsRollover','editWarnPercent'].forEach(function(id) { const e = document.getElementById(id); if (e) { e.disabled = isUnlimited; e.style.opacity = isUnlimited ? '0.4' : '1'; } }); + if (!isUnlimited) onEditPeriodChange(); +} + +// Bei Periode "Gesamtlaufzeit" ist der Monats-Uebertrag bedeutungslos +function onEditPeriodChange() { + const sel = document.getElementById('editCreditsPeriod'); + const cb = document.getElementById('editCreditsRollover'); + if (!sel || !cb) return; + const unlimitedCb = document.getElementById('editUnlimitedBudget'); + cb.disabled = sel.value === 'total' || !!(unlimitedCb && unlimitedCb.checked); + cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1'; } // Unlimited-Toggle im Lizenz-Modal function onNewLicUnlimitedToggle() { const cb = document.getElementById('newLicUnlimited'); const isUnlimited = cb && cb.checked; - ['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd'].forEach(function(id) { + ['newLicCreditsTotal','newLicCostPerCredit','newLicBudgetUsd','newLicCreditsPeriod','newLicRollover','newLicWarnPercent'].forEach(function(id) { const e = document.getElementById(id); if (e) { e.disabled = isUnlimited; e.style.opacity = isUnlimited ? '0.4' : '1'; } }); + if (!isUnlimited) onNewLicPeriodChange(); +} + +function onNewLicPeriodChange() { + const sel = document.getElementById('newLicCreditsPeriod'); + const cb = document.getElementById('newLicRollover'); + if (!sel || !cb) return; + cb.disabled = sel.value === 'total'; + cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1'; +} + +// === Lizenz-Abschnitt im Org-Modal === +function onNewOrgLicenseToggle() { + const cb = document.getElementById('newOrgWithLicense'); + const fields = document.getElementById('newOrgLicenseFields'); + if (fields) fields.style.display = (cb && cb.checked) ? '' : 'none'; +} + +function onNewOrgLicTypeChange() { + const type = document.getElementById('newOrgLicType').value; + const group = document.getElementById('newOrgLicDurationGroup'); + if (group) group.style.display = type === 'permanent' ? 'none' : ''; + if (type !== 'permanent') { + document.getElementById('newOrgLicDuration').value = type === 'trial' ? '14' : '365'; + } +} + +function onNewOrgLicUnlimitedToggle() { + const cb = document.getElementById('newOrgLicUnlimited'); + const isUnlimited = cb && cb.checked; + ['newOrgLicCreditsTotal','newOrgLicCostPerCredit','newOrgLicCreditsPeriod','newOrgLicRollover','newOrgLicWarnPercent'].forEach(function(id) { + const e = document.getElementById(id); + if (e) { + e.disabled = isUnlimited; + e.style.opacity = isUnlimited ? '0.4' : '1'; + } + }); + if (!isUnlimited) onNewOrgLicPeriodChange(); +} + +function onNewOrgLicPeriodChange() { + const sel = document.getElementById('newOrgLicCreditsPeriod'); + const cb = document.getElementById('newOrgLicRollover'); + if (!sel || !cb) return; + cb.disabled = sel.value === 'total'; + cb.parentElement.style.opacity = cb.disabled ? '0.4' : '1'; } async function loadDashboardTokenStats() { @@ -977,6 +1087,14 @@ document.addEventListener('DOMContentLoaded', function() { if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value); if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value); if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value); + // Periode und Uebertrag IMMER senden (nicht ueber das if-value-Muster), + // sonst laesst sich der Uebertrag nie abschalten und die Periode nie + // zurueckstellen. Laufzeitfelder (credits_carried, credits_period_start, + // budget_warning_sent) sendet das Portal grundsaetzlich NICHT. + body.credits_period = document.getElementById('editCreditsPeriod').value; + body.credits_rollover = !!document.getElementById('editCreditsRollover').checked; + var warnPercent = document.getElementById('editWarnPercent'); + if (warnPercent && warnPercent.value) body.budget_warning_percent = parseInt(warnPercent.value); } // credits_used immer mitsenden (auch im Unlimited-Modus, fuer Korrekturen) if (creditsUsed && creditsUsed.value !== '') body.credits_used = parseFloat(creditsUsed.value); diff --git a/tests/test_models.py b/tests/test_models.py index e42e58a..6c7006f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,7 +4,7 @@ from pydantic import ValidationError from models import ( MagicLinkRequest, MagicLinkResponse, VerifyTokenRequest, TokenResponse, - OrgCreate, LicenseCreate, UserCreate, + OrgCreate, LicenseCreate, LicenseParams, LicenseResponse, UserCreate, ) @@ -46,6 +46,58 @@ def test_license_create_type_pattern(): LicenseCreate(organization_id=1, license_type="lifetime") +def test_license_params_credits_defaults(): + """Monatskontingent mit Übertrag ist die Voreinstellung.""" + p = LicenseParams(license_type="trial") + assert p.credits_period == "monthly" + assert p.credits_rollover is True + assert p.budget_warning_percent == 80 + + +def test_license_params_period_pattern(): + LicenseParams(license_type="trial", credits_period="monthly") + LicenseParams(license_type="trial", credits_period="total") + with pytest.raises(ValidationError): + LicenseParams(license_type="trial", credits_period="weekly") + + +def test_license_create_inherits_credits_fields(): + lic = LicenseCreate( + organization_id=1, license_type="annual", + credits_total=10000, credits_period="monthly", credits_rollover=False, + ) + assert lic.credits_total == 10000 + assert lic.credits_rollover is False + + +def test_org_create_without_license_still_valid(): + """Rückwärtskompatibilität, das Lizenz-Objekt ist optional.""" + org = OrgCreate(name="Test", slug="test") + assert org.license is None + + +def test_org_create_with_nested_license(): + org = OrgCreate( + name="Test", slug="test", + license={"license_type": "trial", "credits_total": 10000}, + ) + assert org.license.credits_total == 10000 + assert org.license.credits_period == "monthly" + with pytest.raises(ValidationError): + OrgCreate(name="Test", slug="test", license={"license_type": "trial", "credits_period": "weekly"}) + + +def test_license_response_without_new_columns(): + """Responses müssen auch von einer noch nicht migrierten DB validieren.""" + r = LicenseResponse( + id=1, organization_id=1, license_type="trial", max_users=5, + valid_from="2026-07-25", valid_until=None, status="active", + notes=None, created_at="2026-07-25", + ) + assert r.credits_period == "monthly" + assert r.credits_carried == 0 + + def test_user_create_role_pattern(): UserCreate(email="a@b.de", role="member") UserCreate(email="a@b.de", role="org_admin") -- 2.49.1 From eeb74c74c1ba395fea3c607caa0501c3549af296 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 13:22:54 +0000 Subject: [PATCH 02/41] =?UTF-8?q?fix(kontingent):=20=C3=9Cbertrag=20in=20d?= =?UTF-8?q?en=20Folgemonat=20standardm=C3=A4=C3=9Fig=20aus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf Nutzerwunsch findet ohne bewusste Entscheidung kein Übertrag statt. Modell-Default, beide Checkboxen (Lizenz-Modal, Org-Modal) und der Modell-Test stehen jetzt auf aus, der Übertrag bleibt pro Kunde zuschaltbar. Der DB-Spalten-Default stand ohnehin schon auf 0. Co-Authored-By: Claude Fable 5 --- src/models.py | 5 +++-- src/static/dashboard.html | 4 ++-- tests/test_models.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/models.py b/src/models.py index d362fec..072198c 100644 --- a/src/models.py +++ b/src/models.py @@ -42,8 +42,9 @@ class LicenseParams(BaseModel): # setzt den Verbrauch traege zurueck), 'total' = gilt fuer die Laufzeit. credits_period: str = Field(default="monthly", pattern="^(monthly|total)$") # Uebertrag ungenutzter Credits in den Folgemonat, der Monitor deckelt - # auf ein Monatskontingent. Empfohlen an, faengt Krisenspitzen ab. - credits_rollover: bool = True + # auf ein Monatskontingent. Standard AUS (Entscheidung Nutzer 25.07.2026), + # pro Kunde bewusst zuschaltbar. + credits_rollover: bool = False class OrgCreate(BaseModel): diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 30d8f30..302a2ac 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -660,7 +660,7 @@
@@ -759,7 +759,7 @@
diff --git a/tests/test_models.py b/tests/test_models.py index 6c7006f..0da0ca0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -47,10 +47,10 @@ def test_license_create_type_pattern(): def test_license_params_credits_defaults(): - """Monatskontingent mit Übertrag ist die Voreinstellung.""" + """Monatskontingent OHNE Übertrag ist die Voreinstellung.""" p = LicenseParams(license_type="trial") assert p.credits_period == "monthly" - assert p.credits_rollover is True + assert p.credits_rollover is False assert p.budget_warning_percent == 80 -- 2.49.1 From 8fd087ea0c1ed2b1c73ef974cefc74c276fb9333 Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 13:57:59 +0000 Subject: [PATCH 03/41] =?UTF-8?q?feat(design):=20AegisSight-Design,=20Luci?= =?UTF-8?q?de-Icons=20und=20aufger=C3=A4umter=20Verbrauchs-Bereich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Pakete auf Nutzerwunsch. 1. Design-Angleichung an das AegisSight-Design-System. Navy/Gold-Token statt Slate/Amber, Light- UND Dark-Theme mit goldenem Schiebeschalter oben rechts (Dark = Standard, Wahl persistiert), Barrierefreiheits- Panel über js/a11y.js (identische Datei wie im Monitor), echtes Logo mit heller Plakette in Kopfzeile und Login, Poppins/Inter-Typografie, Radius 6/8 nach Styleguide. Automatisch erzeugte Light-Theme- Überschreibungen für alle Badge-Pastelltöne. 2. Lucide-Icons (Inline-SVG, keine externen Quellen) für alle Navigations-Tabs inkl. nachgeladenem Verbrauchsrechner, die Anlege-Knöpfe, Abmelden und Speichern. 3. Verbrauchs-Bereich aufgeräumt. Reiter heißt jetzt Verbrauch, Statistik in Kundensicht (Credits) und Interne Kosten (Einkauf, USD) getrennt, Leiste heißt Kontingent-Auslastung, Sätze-Infozeile mit Sprung zum Verbrauchsrechner, Altmodus-Felder (USD) und die Verbrauchs-Korrektur liegen in einem eingeklappten Bereich Erweitert mit Erklärtext. Der Rechner trägt einen deutlichen Simulations-Hinweis. Lizenz verlängern nutzt ein eigenes Modal statt des Browser-prompt(). Cache-Buster auf 20260725 gebumpt, alle Restfarben tokenisiert. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 +- src/static/css/style.css | 307 +++++++++++++++++++++++++++++++++++--- src/static/dashboard.html | 169 +++++++++++++++------ src/static/index.html | 16 +- src/static/js/a11y.js | 162 ++++++++++++++++++++ src/static/js/app.js | 74 +++++++-- src/static/js/rechner.js | 3 +- 7 files changed, 654 insertions(+), 80 deletions(-) create mode 100644 src/static/js/a11y.js diff --git a/CLAUDE.md b/CLAUDE.md index e8c6515..99cf8ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,8 @@ src/: favicon.svg: "AegisSight Logo" css/: "Stylesheets (Dark Theme)" js/: - app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render" + app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render, ThemeManager (Dark/Light, localStorage portal_theme)" + a11y.js: "Barrierefreiheits-Panel (Kontrast, Focus, Schrift, Animationen). IDENTISCHE Datei wie im Monitor-Repo, bei Aenderungen dort mitziehen" sources.js: "Grundquellen + Kundenquellen Management" source-health.js: "Quellen-Health & KI-Vorschläge" audit.js: "Audit-Log Tab" diff --git a/src/static/css/style.css b/src/static/css/style.css index ecf1ca6..40badd4 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -1,32 +1,63 @@ -/* AegisSight Dark Theme - Verwaltungsportal */ +/* AegisSight Design System - Verwaltungsportal (Navy/Gold, Dark = Standard, Light per Umschalter) */ :root { - --bg-primary: #0f172a; - --bg-secondary: #1e293b; - --bg-tertiary: #334155; - --text-primary: #e2e8f0; - --text-secondary: #94a3b8; - --text-muted: #64748b; - --accent: #f0b429; - --accent-hover: #d4a017; - --success: #22c55e; - --warning: #f59e0b; - --danger: #ef4444; - --info: #3b82f6; - --border: #334155; - --radius: 8px; - --radius-lg: 12px; + --bg-primary: #0B1121; + --bg-secondary: #151D2E; + --bg-tertiary: #1A2440; + --bg-hover: #1A2440; + --text-primary: #E8ECF4; + --text-secondary: #8896AB; + --text-muted: #95A3B8; + --accent: #C8A851; + --accent-hover: #B5923E; + --success: #10B981; + --warning: #F59E0B; + --danger: #EF4444; + --info: #7C8DB5; + --border: #1E2D45; + --radius: 6px; + --radius-lg: 8px; + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5); + --font-title: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; + /* Das Navy-Logo liegt im Dark-Theme auf einer hellen Plakette */ + --logo-plaque: rgba(232, 236, 240, 0.9); +} + +/* Light-Theme. Akzent wie im Monitor auf das dunklere Gold abgesenkt, + #C8A851 schafft auf hellem Grund den WCAG-Kontrast nicht. */ +[data-theme="light"] { + --bg-primary: #F4F5F7; + --bg-secondary: #FFFFFF; + --bg-tertiary: #E8EBF0; + --bg-hover: #E8EBF0; + --text-primary: #1A202C; + --text-secondary: #4A5568; + --text-muted: #A0AEC0; + --accent: #96791A; + --accent-hover: #7D6516; + --success: #059669; + --warning: #D97706; + --danger: #DC2626; + --info: #4A5568; + --border: #E2E8F0; + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12); + --logo-plaque: transparent; } * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif; + font-family: var(--font-body); background: var(--bg-primary); color: var(--text-primary); line-height: 1.6; min-height: 100vh; } +h1, h2, h3, .logo, .stat-value, .token-stat-value { + font-family: var(--font-title); +} + /* --- Login Page --- */ .login-page { display: flex; @@ -143,7 +174,7 @@ input:focus, select:focus, textarea:focus { color: var(--text-primary); } .btn-secondary:hover:not(:disabled) { - background: #475569; + background: var(--border); } .btn-danger { @@ -189,6 +220,9 @@ input:focus, select:focus, textarea:focus { } .app-header .logo { + display: flex; + align-items: center; + gap: 10px; color: var(--accent); font-size: 18px; font-weight: 700; @@ -198,9 +232,19 @@ input:focus, select:focus, textarea:focus { color: var(--text-secondary); font-weight: 400; font-size: 13px; - margin-left: 8px; + margin-left: 2px; } +/* Navy-Logo bleibt auch im Dark-Theme, liegt dort auf heller Plakette */ +.logo-plaque { + display: flex; + align-items: center; + background: var(--logo-plaque); + border-radius: 8px; + padding: 3px 6px; +} +.logo-plaque img { height: 24px; display: block; } + .header-right { display: flex; align-items: center; @@ -237,7 +281,11 @@ input:focus, select:focus, textarea:focus { border-bottom: 2px solid transparent; margin-bottom: -1px; transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 7px; } +.nav-tab svg { flex-shrink: 0; } .nav-tab:hover { color: var(--text-primary); @@ -1121,3 +1169,224 @@ input[type="date"].filter-select { padding: 6px 10px; } border-color: var(--accent); } + +/* === Theme-Umschalter (goldener Schiebeschalter, wie im Monitor) === */ +.theme-switch { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + -webkit-user-select: none; +} +.theme-switch-icon { + font-size: 14px; + line-height: 1; + opacity: 0.4; + transition: opacity 0.3s; +} +.theme-switch.dark .theme-switch-moon, +.theme-switch.light .theme-switch-sun { + opacity: 1; +} +.theme-switch-track { + position: relative; + width: 40px; + height: 22px; + border-radius: 11px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + transition: background 0.3s, border-color 0.3s; + flex-shrink: 0; +} +.theme-switch-knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px rgba(200, 168, 81, 0.3); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s; +} +.theme-switch.dark .theme-switch-knob { transform: translateX(18px); } +.theme-switch.light .theme-switch-knob { transform: translateX(0); } +.theme-switch:hover .theme-switch-track { border-color: var(--accent); } +.theme-switch:hover .theme-switch-knob { box-shadow: 0 0 12px rgba(200, 168, 81, 0.5); } + +/* === Kleine Schalter-Optik (fuer das Barrierefreiheits-Panel) === */ +.toggle-switch { + position: relative; + display: inline-block; + width: 34px; + height: 18px; + border-radius: 9px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + transition: background 0.2s ease; +} +.toggle-switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--text-secondary); + transition: transform 0.2s ease, background 0.2s ease; +} + +/* === Barrierefreiheits-Panel (a11y.js haengt sich in .header-right ein) === */ +.a11y-center { position: relative; } +.a11y-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px 8px; + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease, background 0.2s ease; + width: 36px; + height: 36px; +} +.a11y-btn:hover { color: var(--accent); background: var(--bg-hover); } +.a11y-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 240px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 200; + padding: 16px; +} +.a11y-panel-title { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.5px; + margin-bottom: 12px; +} +.a11y-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + cursor: pointer; + font-size: 13px; + color: var(--text-primary); + user-select: none; +} +.a11y-option input[type="checkbox"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.a11y-option .toggle-switch { flex-shrink: 0; } +.a11y-option input:focus-visible + .toggle-switch { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.a11y-option input:checked + .toggle-switch { background: var(--accent); } +.a11y-option input:checked + .toggle-switch::after { + transform: translateX(16px); + background: var(--bg-primary); +} + +/* === Barrierefreiheits-Modi === */ +[data-a11y-contrast="true"] { + --text-muted: #B0BDD0; + --border: #3A4A66; +} +[data-a11y-contrast="true"] .btn-primary { color: #1A1A1A; } +[data-a11y-contrast="true"][data-theme="light"] { + --accent: #6B5714; + --accent-hover: #5A4A11; + --text-muted: #718096; + --border: #94A3B8; +} +[data-a11y-focus="true"] a:focus-visible, +[data-a11y-focus="true"] button:focus-visible, +[data-a11y-focus="true"] input:focus-visible, +[data-a11y-focus="true"] select:focus-visible, +[data-a11y-focus="true"] textarea:focus-visible, +[data-a11y-focus="true"] [tabindex]:focus-visible, +[data-a11y-focus="true"] [role="button"]:focus-visible { + outline: 3px solid var(--accent) !important; + outline-offset: 2px !important; + box-shadow: 0 0 0 4px rgba(200, 168, 81, 0.3) !important; +} +[data-a11y-fontsize="true"] body { zoom: 1.15; } +[data-a11y-motion="true"] *, +[data-a11y-motion="true"] *::before, +[data-a11y-motion="true"] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; +} + +/* === Icon-Ausrichtung in Knoepfen === */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; } +.btn svg { flex-shrink: 0; } + +/* === Aufklappbarer Bereich (z.B. "Erweitert" im Verbrauchs-Formular) === */ +details.adv-section { grid-column: 1 / -1; border-top: 1px solid var(--border); padding-top: 10px; } +details.adv-section summary { + cursor: pointer; + color: var(--text-secondary); + font-size: 13px; + user-select: none; + display: flex; + align-items: center; + gap: 6px; +} +details.adv-section summary:hover { color: var(--text-primary); } +details.adv-section[open] summary { margin-bottom: 10px; } +details.adv-section .adv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +details.adv-section .adv-hint { grid-column: 1 / -1; font-size: 12px; color: var(--text-muted); margin: 0; } + +/* === Light-Theme: dunklere Text-Farben fuer Badges/Status === + (Die Dark-Pastelltoene sind auf hellem Grund unlesbar. Automatisch + abgeleitete Gegenstuecke, je Original-Farbton eine dunkle Variante.) */ +[data-theme="light"] .error-msg { color: #B91C1C; } +[data-theme="light"] .btn-danger { color: #B91C1C; } +[data-theme="light"] .btn-success { color: #047857; } +[data-theme="light"] .badge-active { color: #047857; } +[data-theme="light"] .badge-inactive { color: #B91C1C; } +[data-theme="light"] .badge-trial { color: #1D4ED8; } +[data-theme="light"] .badge-annual { color: #7E22CE; } +[data-theme="light"] .badge-permanent { color: #B45309; } +[data-theme="light"] .badge-expired { color: #B91C1C; } +[data-theme="light"] .badge-revoked { color: #475569; } +[data-theme="light"] .badge-none { color: #475569; } +[data-theme="light"] .activity-icon.org { color: #1D4ED8; } +[data-theme="light"] .activity-icon.user { color: #047857; } +[data-theme="light"] /* --- Health & Suggestion Badges --- */ +.badge-health-error { color: #B91C1C; } +[data-theme="light"] .badge-health-warning { color: #B45309; } +[data-theme="light"] .badge-health-ok { color: #047857; } +[data-theme="light"] .badge-suggestion-add_source { color: #1D4ED8; } +[data-theme="light"] .badge-suggestion-deactivate_source { color: #B45309; } +[data-theme="light"] .badge-suggestion-remove_source { color: #B91C1C; } +[data-theme="light"] .badge-suggestion-fix_url { color: #7E22CE; } +[data-theme="light"] .badge-priority-high { color: #B91C1C; } +[data-theme="light"] .badge-priority-medium { color: #B45309; } +[data-theme="light"] .badge-priority-low { color: #475569; } +[data-theme="light"] .sources-stat-item { color: #475569; } +[data-theme="light"] .health-badge-unknown { color: #475569; } +[data-theme="light"] .audit-entry-meta { color: #475569; } +[data-theme="light"] .audit-entry-detail summary { color: #475569; } +[data-theme="light"] /* === Verwendungs-Sicht (Phase 6) === */ +.activity-cell { color: #475569; } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 302a2ac..2dfa3b7 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -6,32 +6,63 @@ AegisSight Monitor-Verwaltung - + +
- +
+
+ ☀︎ +
+
+
+ +
- +
- + @@ -93,7 +124,7 @@
- +
@@ -123,14 +154,14 @@ - +
- +
@@ -155,7 +186,7 @@
- +
@@ -211,34 +242,46 @@
+ +

Kundensicht (Credits)

-
Verbrauchte Credits
+
Verbrauchte Credits (Periode)
-
Verbleibend
-
-
-
Budget (USD)
-
-
-
-
-
Kosten (USD)
-
-
-
-
- Budget-Auslastung + Kontingent-Auslastung (laufende Periode) 0%
+

+ + Zur Einordnung. Ein Live-Lauf kostet 45 Credits, das Anlegen einer Recherche 120 (drei Durchläufe), jede Vertiefung 40, Chat und Beschreibungs-Assistent je 1 Credit. + Verbrauchsrechner öffnen +

+ + +

Interne Kosten (Einkauf, USD)

+
+
+
API-Kosten aktueller Monat
+
-
+
+
+
Budget-Limit (USD, Altmodus)
+
-
+
+
+

Monatlicher Verlauf

@@ -255,11 +298,11 @@
-

Budget bearbeiten

+

Credits-Kontingent bearbeiten

-
+
-
- - -
-
- - -
-
- - -
+
+ + + Erweitert (Altmodus und Korrektur) + +

+ Die beiden USD-Felder gehören zum alten Abrechnungsmodus (BILLING_MODE=actual) + und haben im heutigen Pauschalmodus keine Wirkung auf den Kunden. Das + Korrektur-Feld überschreibt den Verbrauch der laufenden Periode direkt, + nur für bewusste Eingriffe gedacht. +

+
+
+ + +
+
+ + +
+
+ + +
+
+
- +
@@ -786,6 +846,30 @@
+ + + - + + diff --git a/src/static/index.html b/src/static/index.html index 97272b2..37a334f 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -7,12 +7,14 @@ AegisSight Monitor-Verwaltung - Anmeldung - + + @@ -54,13 +56,13 @@