diff --git a/src/main.py b/src/main.py index 8cb4218..dee5e40 100644 --- a/src/main.py +++ b/src/main.py @@ -10,7 +10,7 @@ from config import STATIC_DIR, PORT from database import db_dependency from auth import verify_password, create_token from models import LoginRequest, TokenResponse -from routers import organizations, licenses, users, dashboard, sources +from routers import organizations, licenses, users, dashboard, sources, token_usage import aiosqlite @@ -40,6 +40,7 @@ app.include_router(licenses.router) app.include_router(users.router) app.include_router(dashboard.router) app.include_router(sources.router) +app.include_router(token_usage.router) # --- Login --- diff --git a/src/models.py b/src/models.py index dc9aac1..9530e7d 100644 --- a/src/models.py +++ b/src/models.py @@ -40,6 +40,10 @@ class LicenseCreate(BaseModel): 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) class LicenseResponse(BaseModel): @@ -51,6 +55,11 @@ class LicenseResponse(BaseModel): valid_until: Optional[str] status: str notes: Optional[str] + token_budget_usd: Optional[float] = None + credits_total: Optional[int] = None + credits_used: Optional[float] = None + cost_per_credit: Optional[float] = None + budget_warning_percent: Optional[int] = None created_at: str diff --git a/src/routers/licenses.py b/src/routers/licenses.py index 04b9404..2e15eb2 100644 --- a/src/routers/licenses.py +++ b/src/routers/licenses.py @@ -58,9 +58,11 @@ async def create_license( valid_until = (now + timedelta(days=365)).isoformat() cursor = await db.execute( - """INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status) - VALUES (?, ?, ?, ?, ?, 'active')""", - (data.organization_id, data.license_type, data.max_users, valid_from, valid_until), + """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) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)""", + (data.organization_id, data.license_type, data.max_users, valid_from, valid_until, + data.token_budget_usd, data.credits_total, data.cost_per_credit, data.budget_warning_percent), ) await db.commit() diff --git a/src/routers/token_usage.py b/src/routers/token_usage.py new file mode 100644 index 0000000..6fcecfd --- /dev/null +++ b/src/routers/token_usage.py @@ -0,0 +1,164 @@ +"""Token-Usage & Budget-Verwaltung.""" +import logging +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from auth import get_current_admin +from database import get_db + +logger = logging.getLogger("verwaltung.token_usage") +router = APIRouter(prefix="/api/token-usage", tags=["Token-Usage"]) + + +@router.get("/overview") +async def get_usage_overview(admin=Depends(get_current_admin)): + """Token-Verbrauch aller Organisationen.""" + db = await get_db() + try: + cursor = await db.execute(""" + SELECT + o.id, o.name, o.slug, + l.credits_total, l.credits_used, l.cost_per_credit, + l.token_budget_usd, l.budget_warning_percent, + 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, + COALESCE(SUM(r.api_calls), 0) as total_api_calls, + COUNT(r.id) as total_refreshes + FROM organizations o + LEFT JOIN licenses l ON l.organization_id = o.id AND l.status = 'active' + LEFT JOIN refresh_log r ON r.tenant_id = o.id AND r.status = 'completed' + GROUP BY o.id + """) + rows = await cursor.fetchall() + + result = [] + for row in rows: + credits_total = row["credits_total"] or 0 + credits_used = row["credits_used"] or 0 + credits_remaining = max(0, int(credits_total - credits_used)) if credits_total else None + percent_used = round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None + budget_usd = row["token_budget_usd"] + cost = row["total_cost"] + budget_percent = round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None + + result.append({ + "org_id": row["id"], + "org_name": row["name"], + "org_slug": row["slug"], + "credits_total": credits_total, + "credits_used": round(credits_used, 1), + "credits_remaining": credits_remaining, + "credits_percent_used": percent_used, + "token_budget_usd": budget_usd, + "total_cost_usd": round(cost, 2), + "budget_percent_used": budget_percent, + "budget_warning_percent": row["budget_warning_percent"] or 80, + "total_input_tokens": row["total_input_tokens"], + "total_output_tokens": row["total_output_tokens"], + "total_api_calls": row["total_api_calls"], + "total_refreshes": row["total_refreshes"], + "cost_per_credit": row["cost_per_credit"], + }) + return result + finally: + await db.close() + + +@router.get("/{org_id}") +async def get_org_usage(org_id: int, admin=Depends(get_current_admin)): + """Monatliche Token-Nutzung einer Organisation.""" + db = await get_db() + try: + cursor = await db.execute( + "SELECT * FROM token_usage_monthly WHERE organization_id = ? ORDER BY year_month DESC", + (org_id,)) + rows = await cursor.fetchall() + + return [{ + "year_month": row["year_month"], + "input_tokens": row["input_tokens"], + "output_tokens": row["output_tokens"], + "cache_creation_tokens": row["cache_creation_tokens"], + "cache_read_tokens": row["cache_read_tokens"], + "total_cost_usd": round(row["total_cost_usd"], 2), + "api_calls": row["api_calls"], + "refresh_count": row["refresh_count"], + } for row in rows] + finally: + await db.close() + + +@router.get("/{org_id}/current") +async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)): + """Aktueller Monat + Budget-Auslastung.""" + db = await get_db() + try: + year_month = datetime.now().strftime("%Y-%m") + + cursor = await db.execute( + "SELECT * FROM token_usage_monthly WHERE organization_id = ? AND year_month = ?", + (org_id, year_month)) + usage = await cursor.fetchone() + + cursor = await db.execute( + "SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", + (org_id,)) + lic = await cursor.fetchone() + + credits_total = lic["credits_total"] if lic else None + credits_used = lic["credits_used"] if lic else 0 + + return { + "year_month": year_month, + "usage": { + "input_tokens": usage["input_tokens"] if usage else 0, + "output_tokens": usage["output_tokens"] if usage else 0, + "total_cost_usd": round(usage["total_cost_usd"], 2) if usage else 0, + "api_calls": usage["api_calls"] if usage else 0, + "refresh_count": usage["refresh_count"] if usage else 0, + }, + "budget": { + "credits_total": credits_total, + "credits_used": round(credits_used, 1) if credits_used else 0, + "credits_remaining": max(0, int(credits_total - credits_used)) if credits_total else None, + "credits_percent_used": round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 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, + }, + } + finally: + await db.close() + + +@router.put("/budget/{license_id}") +async def update_budget(license_id: int, data: dict, admin=Depends(get_current_admin)): + """Budget einer Lizenz setzen/ändern.""" + db = await get_db() + try: + cursor = await db.execute("SELECT id FROM licenses WHERE id = ?", (license_id,)) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="Lizenz nicht gefunden") + + fields = [] + values = [] + for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"): + if key in data: + fields.append(f"{key} = ?") + values.append(data[key]) + + if "credits_used" in data: + fields.append("credits_used = ?") + values.append(data["credits_used"]) + + if not fields: + raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") + + values.append(license_id) + await db.execute(f"UPDATE licenses SET {', '.join(fields)} WHERE id = ?", values) + await db.commit() + + logger.info(f"Budget für Lizenz {license_id} aktualisiert: {data}") + return {"ok": True} + finally: + await db.close() diff --git a/src/static/css/style.css b/src/static/css/style.css index 30d34fe..a7c54e3 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -763,3 +763,17 @@ tr:hover td { .cat-header-count::after { content: ")"; } + +/* ===== Token-Nutzung Tab ===== */ +.token-overview { padding: 4px 0; } +.token-stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } +.token-stat-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; } +.token-stat-label { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } +.token-stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); } +.token-budget-bar-section { margin-bottom: 16px; } +.token-budget-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); margin-bottom: 6px; } +.token-budget-bar-container { width: 100%; height: 10px; background: var(--bg-tertiary); border-radius: 5px; overflow: hidden; } +.token-budget-bar { height: 100%; border-radius: 5px; background: var(--accent); transition: width 0.6s ease, background-color 0.3s ease; } +.token-budget-bar.warning { background: #e67e22; } +.token-budget-bar.critical { background: #e74c3c; } +@media (max-width: 768px) { .token-stats-row { grid-template-columns: repeat(2, 1fr); } } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 775931b..3fbc56b 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -88,6 +88,7 @@ + @@ -161,6 +162,52 @@ + + @@ -388,6 +435,19 @@
Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage
+ +
+ + +
+
+ + +
+
+ + +