feat: Credits-System — Token-Usage-Router, Budget-Verwaltung, Frontend-Übersicht
- Neuer Router /api/token-usage mit Overview, Org-Detail, Monatsstatistik - Budget-Felder (credits_total, cost_per_credit, token_budget_usd) bei Lizenz-Erstellung - Token-Nutzung Sub-Tab in Org-Detail mit Verbrauchsbalken und Monatstabelle - Dashboard Stat-Card für API-Kosten gesamt - CSS Dark-Theme Styling für Token-Komponenten
Dieser Commit ist enthalten in:
@@ -10,7 +10,7 @@ from config import STATIC_DIR, PORT
|
|||||||
from database import db_dependency
|
from database import db_dependency
|
||||||
from auth import verify_password, create_token
|
from auth import verify_password, create_token
|
||||||
from models import LoginRequest, TokenResponse
|
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
|
import aiosqlite
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ app.include_router(licenses.router)
|
|||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(sources.router)
|
app.include_router(sources.router)
|
||||||
|
app.include_router(token_usage.router)
|
||||||
|
|
||||||
|
|
||||||
# --- Login ---
|
# --- Login ---
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class LicenseCreate(BaseModel):
|
|||||||
license_type: str = Field(pattern="^(trial|annual|permanent)$")
|
license_type: str = Field(pattern="^(trial|annual|permanent)$")
|
||||||
max_users: int = Field(default=5, ge=1, le=1000)
|
max_users: int = Field(default=5, ge=1, le=1000)
|
||||||
duration_days: Optional[int] = Field(default=None, ge=1, le=3650)
|
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):
|
class LicenseResponse(BaseModel):
|
||||||
@@ -51,6 +55,11 @@ class LicenseResponse(BaseModel):
|
|||||||
valid_until: Optional[str]
|
valid_until: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
notes: Optional[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
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ async def create_license(
|
|||||||
valid_until = (now + timedelta(days=365)).isoformat()
|
valid_until = (now + timedelta(days=365)).isoformat()
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status)
|
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
|
||||||
VALUES (?, ?, ?, ?, ?, 'active')""",
|
token_budget_usd, credits_total, cost_per_credit, budget_warning_percent)
|
||||||
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until),
|
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()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
164
src/routers/token_usage.py
Normale Datei
164
src/routers/token_usage.py
Normale Datei
@@ -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()
|
||||||
@@ -763,3 +763,17 @@ tr:hover td {
|
|||||||
.cat-header-count::after {
|
.cat-header-count::after {
|
||||||
content: ")";
|
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); } }
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
<button class="nav-tab active" data-subtab="users">Nutzer</button>
|
<button class="nav-tab active" data-subtab="users">Nutzer</button>
|
||||||
<button class="nav-tab" data-subtab="org-licenses">Lizenzen</button>
|
<button class="nav-tab" data-subtab="org-licenses">Lizenzen</button>
|
||||||
<button class="nav-tab" data-subtab="settings">Einstellungen</button>
|
<button class="nav-tab" data-subtab="settings">Einstellungen</button>
|
||||||
|
<button class="nav-tab" data-subtab="tokens">Token-Nutzung</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Sub-Tab -->
|
<!-- Users Sub-Tab -->
|
||||||
@@ -161,6 +162,52 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section" id="sub-tokens" style="display:none;">
|
||||||
|
<div class="token-overview">
|
||||||
|
<div class="token-stats-row" id="tokenStatsRow">
|
||||||
|
<div class="token-stat-card">
|
||||||
|
<div class="token-stat-label">Verbrauchte Credits</div>
|
||||||
|
<div class="token-stat-value" id="tokenCreditsUsed">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="token-stat-card">
|
||||||
|
<div class="token-stat-label">Verbleibend</div>
|
||||||
|
<div class="token-stat-value" id="tokenCreditsRemaining">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="token-stat-card">
|
||||||
|
<div class="token-stat-label">Budget (USD)</div>
|
||||||
|
<div class="token-stat-value" id="tokenBudgetUsd">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="token-stat-card">
|
||||||
|
<div class="token-stat-label">Kosten (USD)</div>
|
||||||
|
<div class="token-stat-value" id="tokenCostUsd">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="token-budget-bar-section">
|
||||||
|
<div class="token-budget-label">
|
||||||
|
<span>Budget-Auslastung</span>
|
||||||
|
<span id="tokenBudgetPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="token-budget-bar-container">
|
||||||
|
<div class="token-budget-bar" id="tokenBudgetBar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 style="margin-top:24px;">Monatlicher Verlauf</h3>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Monat</th>
|
||||||
|
<th>Refreshes</th>
|
||||||
|
<th>API-Calls</th>
|
||||||
|
<th>Input-Tokens</th>
|
||||||
|
<th>Output-Tokens</th>
|
||||||
|
<th>Kosten (USD)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tokenMonthlyTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,6 +435,19 @@
|
|||||||
<input type="number" id="newLicDuration" value="14" min="1" max="3650">
|
<input type="number" id="newLicDuration" value="14" min="1" max="3650">
|
||||||
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
|
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newLicCreditsTotal">Credits-Kontingent</label>
|
||||||
|
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newLicCostPerCredit">Kosten pro Credit (USD)</label>
|
||||||
|
<input type="number" id="newLicCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newLicBudgetUsd">Budget-Limit (USD)</label>
|
||||||
|
<input type="number" id="newLicBudgetUsd" step="0.01" placeholder="z.B. 2000">
|
||||||
|
</div>
|
||||||
<div id="newLicError" class="error-msg" style="display:none"></div>
|
<div id="newLicError" class="error-msg" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
setupOrgDetailTabs();
|
setupOrgDetailTabs();
|
||||||
setupForms();
|
setupForms();
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
|
loadDashboardTokenStats();
|
||||||
loadOrgs();
|
loadOrgs();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ function setupOrgDetailTabs() {
|
|||||||
tab.classList.add("active");
|
tab.classList.add("active");
|
||||||
document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active"));
|
document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active"));
|
||||||
document.getElementById(`sub-${subtab}`).classList.add("active");
|
document.getElementById(`sub-${subtab}`).classList.add("active");
|
||||||
|
if (subtab === 'tokens' && currentOrgId) loadOrgTokenUsage(currentOrgId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -465,6 +467,12 @@ function setupForms() {
|
|||||||
if (licType !== "permanent") {
|
if (licType !== "permanent") {
|
||||||
body.duration_days = parseInt(document.getElementById("newLicDuration").value);
|
body.duration_days = parseInt(document.getElementById("newLicDuration").value);
|
||||||
}
|
}
|
||||||
|
const creditsTotal = document.getElementById('newLicCreditsTotal');
|
||||||
|
const costPerCredit = document.getElementById('newLicCostPerCredit');
|
||||||
|
const budgetUsd = document.getElementById('newLicBudgetUsd');
|
||||||
|
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);
|
||||||
try {
|
try {
|
||||||
await API.post("/api/licenses", body);
|
await API.post("/api/licenses", body);
|
||||||
closeModal("modalNewLicense");
|
closeModal("modalNewLicense");
|
||||||
@@ -557,3 +565,74 @@ function formatDate(iso) {
|
|||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ===== Token-Nutzung =====
|
||||||
|
async function loadOrgTokenUsage(orgId) {
|
||||||
|
try {
|
||||||
|
const [current, monthly] = await Promise.all([
|
||||||
|
API.get('/api/token-usage/' + orgId + '/current'),
|
||||||
|
API.get('/api/token-usage/' + orgId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const budget = current.budget || {};
|
||||||
|
const usage = current.usage || {};
|
||||||
|
|
||||||
|
const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
|
||||||
|
el('tokenCreditsUsed', budget.credits_used != null ? Math.round(budget.credits_used).toLocaleString('de-DE') : '-');
|
||||||
|
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
|
||||||
|
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) : '-');
|
||||||
|
|
||||||
|
const bar = document.getElementById('tokenBudgetBar');
|
||||||
|
const percentEl = document.getElementById('tokenBudgetPercent');
|
||||||
|
const percent = budget.credits_percent_used || 0;
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = Math.min(100, percent) + '%';
|
||||||
|
bar.classList.remove('warning', 'critical');
|
||||||
|
if (percent > 80) bar.classList.add('critical');
|
||||||
|
else if (percent > 50) bar.classList.add('warning');
|
||||||
|
}
|
||||||
|
if (percentEl) percentEl.textContent = percent.toFixed(1) + '%';
|
||||||
|
|
||||||
|
const tbody = document.getElementById('tokenMonthlyTable');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = monthly.map(function(m) {
|
||||||
|
return '<tr>' +
|
||||||
|
'<td>' + esc(m.year_month) + '</td>' +
|
||||||
|
'<td>' + m.refresh_count + '</td>' +
|
||||||
|
'<td>' + m.api_calls.toLocaleString('de-DE') + '</td>' +
|
||||||
|
'<td>' + m.input_tokens.toLocaleString('de-DE') + '</td>' +
|
||||||
|
'<td>' + m.output_tokens.toLocaleString('de-DE') + '</td>' +
|
||||||
|
'<td>$' + Number(m.total_cost_usd).toFixed(2) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Token-Usage laden fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboardTokenStats() {
|
||||||
|
try {
|
||||||
|
const data = await API.get('/api/token-usage/overview');
|
||||||
|
const grid = document.getElementById('statsGrid');
|
||||||
|
if (grid && data.length > 0) {
|
||||||
|
const totals = data.reduce(function(acc, org) {
|
||||||
|
return {
|
||||||
|
cost: acc.cost + (org.total_cost_usd || 0),
|
||||||
|
refreshes: acc.refreshes + (org.total_refreshes || 0),
|
||||||
|
};
|
||||||
|
}, { cost: 0, refreshes: 0 });
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'stat-card';
|
||||||
|
card.innerHTML = '<div class="stat-label">API-Kosten gesamt</div>' +
|
||||||
|
'<div class="stat-value">$' + totals.cost.toFixed(2) + '</div>' +
|
||||||
|
'<div class="stat-sub">' + totals.refreshes + ' Refreshes</div>';
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Token-Overview laden fehlgeschlagen:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren