Proxy fixes, streaming support, Admin-UI overhaul

Backend:
- Fix Content-Length mismatch by not forwarding client headers to Ollama
- Proxy /v1/chat/completions directly to Ollama's OpenAI-compatible endpoint
  (eliminates manual Ollama↔OpenAI format conversion, fixes tool use)
- Add streaming support via SSE passthrough
- Fix ollama_url /v1 suffix stripped on save
- Replace BaseHTTPMiddleware with FastAPI global dependency (fixes double logging)
- Add rotating usage log (8 KB, logs key name + model + token estimate + prompt preview)
- Add httpx timeout 300s
- Add activate and delete endpoints for API keys
- Return usage data (tokens/requests) in GET /api/api-keys

Frontend:
- Admin table: remove ID column, status as icon, icon-only action buttons with CSS tooltips
- Add activate + delete buttons; edit available for inactive keys too
- Quota columns: fixed equal width, progress bars with k-unit formatting
- Create form: structured layout matching edit form style
- Edit form: token inputs in k units (÷1000 display, ×1000 on save)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Oliver Hofmann 2026-04-29 07:48:10 +02:00
parent c62cafc202
commit dd8f69ecb6
6 changed files with 2687 additions and 167 deletions

View File

@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
import crud, schemas import crud, schemas
from models import APIKey as APIKeyModel from models import APIKey as APIKeyModel, Usage as UsageModel
app = FastAPI(title="Ollama Proxy Admin API") app = FastAPI(title="Ollama Proxy Admin API")
@ -31,13 +31,24 @@ def require_admin_auth(request: Request):
if not secrets.compare_digest(token, ADMIN_PASSWORD): if not secrets.compare_digest(token, ADMIN_PASSWORD):
raise HTTPException(status_code=401, detail="Invalid admin password") raise HTTPException(status_code=401, detail="Invalid admin password")
@app.get("/api/api-keys", response_model=list[schemas.APIKey]) @app.get("/api/api-keys", response_model=list[schemas.APIKeyWithUsage])
async def read_api_keys( async def read_api_keys(
skip: int = 0, limit: int = 100, skip: int = 0, limit: int = 100,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth),
): ):
return db.query(APIKeyModel).offset(skip).limit(limit).all() keys = db.query(APIKeyModel).offset(skip).limit(limit).all()
result = []
for key in keys:
item = schemas.APIKeyWithUsage.model_validate(key)
usage = db.query(UsageModel).filter(UsageModel.api_key_id == key.id).first()
if usage:
item.tokens_used_today = usage.tokens_used_today or 0
item.tokens_used_month = usage.tokens_used_month or 0
item.requests_today = usage.requests_today or 0
item.requests_month = usage.requests_month or 0
result.append(item)
return result
@app.post("/api/api-keys", response_model=schemas.APIKeyCreated) @app.post("/api/api-keys", response_model=schemas.APIKeyCreated)
async def create_api_key( async def create_api_key(
@ -87,6 +98,32 @@ async def deactivate_api_key(
db.commit() db.commit()
return {"message": "API key deactivated"} return {"message": "API key deactivated"}
@app.put("/api/api-keys/{api_key_id}/activate")
async def activate_api_key(
api_key_id: int,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first()
if not db_key:
raise HTTPException(status_code=404, detail="API key not found")
db_key.is_active = True
db.commit()
return {"message": "API key activated"}
@app.delete("/api/api-keys/{api_key_id}")
async def delete_api_key(
api_key_id: int,
db: Session = Depends(get_db),
_ = Depends(require_admin_auth),
):
db_key = db.query(APIKeyModel).filter(APIKeyModel.id == api_key_id).first()
if not db_key:
raise HTTPException(status_code=404, detail="API key not found")
db.delete(db_key)
db.commit()
return {"message": "API key deleted"}
@app.get("/api/proxy-info") @app.get("/api/proxy-info")
async def get_proxy_info(_ = Depends(require_admin_auth)): async def get_proxy_info(_ = Depends(require_admin_auth)):
host = os.getenv("PROXY_HOST", "0.0.0.0") host = os.getenv("PROXY_HOST", "0.0.0.0")
@ -107,9 +144,10 @@ async def update_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth),
): ):
crud.set_setting(db, "ollama_url", settings.ollama_url) ollama_url = settings.ollama_url.rstrip('/').removesuffix('/v1')
crud.set_setting(db, "ollama_url", ollama_url)
crud.set_setting(db, "default_model", settings.default_model) crud.set_setting(db, "default_model", settings.default_model)
return settings return schemas.Settings(ollama_url=ollama_url, default_model=settings.default_model)
@app.get("/api/ollama-models") @app.get("/api/ollama-models")
async def get_ollama_models( async def get_ollama_models(

View File

@ -1,135 +1,133 @@
import time import logging
import uuid import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from fastapi import FastAPI, HTTPException, Depends, Request from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db, SessionLocal from database import get_db
import crud import crud
import httpx import httpx
import os
app = FastAPI(title="Ollama Proxy") # Rotating usage log (8 KB per file, 3 backups)
_log_path = Path(os.getenv("LOG_FILE", "logs/usage.log"))
_log_path.parent.mkdir(parents=True, exist_ok=True)
_handler = RotatingFileHandler(str(_log_path), maxBytes=8192, backupCount=3, encoding="utf-8")
_handler.setFormatter(logging.Formatter("%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
usage_log = logging.getLogger("proxy.usage")
usage_log.setLevel(logging.INFO)
usage_log.addHandler(_handler)
usage_log.propagate = False
async def proxy_request(url: str, method: str = "GET", json_data: dict = None, headers: dict = None): def _last_user_msg(messages: list, max_len: int = 120) -> str:
async with httpx.AsyncClient() as client: for msg in reversed(messages):
response = await client.request(method=method, url=url, json=json_data, headers=headers) if msg.get("role") == "user":
return response text = (msg.get("content") or "").replace("\n", " ").strip()
return text[:max_len] + ("" if len(text) > max_len else "")
return ""
@app.middleware("http") async def require_api_key(request: Request, db: Session = Depends(get_db)):
async def authenticate_and_quota(request: Request, call_next):
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
api_key = auth_header.replace("Bearer ", "") api_key = auth_header[7:]
elif auth_header.startswith("sk-"): elif auth_header.startswith("sk-"):
api_key = auth_header api_key = auth_header
else: else:
return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"}) raise HTTPException(status_code=401, detail="Invalid or missing API key")
db_key = crud.verify_api_key(db, api_key)
if not db_key:
raise HTTPException(status_code=401, detail="Invalid API key")
request.state.api_key_id = db_key.id
request.state.api_key_name = db_key.name
# Uses its own session since middleware cannot use Depends app = FastAPI(title="Ollama Proxy", dependencies=[Depends(require_api_key)])
db = SessionLocal()
try:
db_key = crud.verify_api_key(db, api_key)
if not db_key:
return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
request.state.api_key_id = db_key.id
finally:
db.close()
response = await call_next(request) async def proxy_request(url: str, method: str = "GET", json_data: dict = None):
return response async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.request(method=method, url=url, json=json_data)
return response
@app.post("/api/generate") @app.post("/api/generate")
async def generate(request: Request, db: Session = Depends(get_db)): async def generate(request: Request, db: Session = Depends(get_db)):
api_key_id = request.state.api_key_id
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
body = await request.json() body = await request.json()
prompt_tokens = crud.count_tokens(body.get("prompt", "")) prompt_tokens = crud.count_tokens(body.get("prompt", ""))
if not crud.check_and_increment_quota(db, api_key_id, tokens=prompt_tokens, requests=1):
if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1):
raise HTTPException(status_code=429, detail="Quota exceeded") raise HTTPException(status_code=429, detail="Quota exceeded")
response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body, headers=dict(request.headers)) prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120]
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) usage_log.info('%s | /api/generate | %s | ~%d tokens | "%s"',
request.state.api_key_name, body.get("model", "?"), prompt_tokens, prompt_preview)
response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.post("/api/chat") @app.post("/api/chat")
async def chat(request: Request, db: Session = Depends(get_db)): async def chat(request: Request, db: Session = Depends(get_db)):
api_key_id = request.state.api_key_id
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
body = await request.json() body = await request.json()
messages = body.get("messages", [])
prompt_tokens = sum(crud.count_tokens(msg.get("content") or "") for msg in messages)
prompt_tokens = sum(crud.count_tokens(msg.get("content", "")) for msg in body.get("messages", [])) if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1):
if not crud.check_and_increment_quota(db, api_key_id, tokens=prompt_tokens, requests=1):
raise HTTPException(status_code=429, detail="Quota exceeded") raise HTTPException(status_code=429, detail="Quota exceeded")
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body, headers=dict(request.headers)) usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"',
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) request.state.api_key_name, body.get("model", "?"), prompt_tokens, _last_user_msg(messages))
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/api/tags") @app.get("/api/tags")
async def list_models(request: Request, db: Session = Depends(get_db)): async def list_models(db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/tags", method="GET", headers=dict(request.headers)) response = await proxy_request(f"{ollama_url}/api/tags", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/api/versions") @app.get("/api/versions")
async def versions(request: Request, db: Session = Depends(get_db)): async def versions(db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/versions", method="GET", headers=dict(request.headers)) response = await proxy_request(f"{ollama_url}/api/versions", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers)) return JSONResponse(content=response.json(), status_code=response.status_code)
@app.get("/v1/models") @app.get("/v1/models")
async def list_openai_models(request: Request, db: Session = Depends(get_db)): async def list_openai_models(db: Session = Depends(get_db)):
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
response = await proxy_request(f"{ollama_url}/api/tags", method="GET", headers=dict(request.headers)) response = await proxy_request(f"{ollama_url}/v1/models", method="GET")
ollama_models = response.json() return JSONResponse(content=response.json(), status_code=response.status_code)
openai_models = {
"object": "list",
"data": [
{
"id": model["name"],
"object": "model",
"created": int(model["modified_at"][:10].replace("-", "")) * 1000 if "modified_at" in model else 0,
"owned_by": "ollama"
}
for model in ollama_models.get("models", [])
]
}
return JSONResponse(content=openai_models, status_code=200, headers=dict(response.headers))
@app.post("/v1/chat/completions") @app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request, db: Session = Depends(get_db)): async def openai_chat_completions(request: Request, db: Session = Depends(get_db)):
api_key_id = request.state.api_key_id
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434")) ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
default_model = crud.get_setting(db, "default_model", os.getenv("DEFAULT_MODEL", "llama3")) default_model = crud.get_setting(db, "default_model", os.getenv("DEFAULT_MODEL", "llama3"))
body = await request.json() body = await request.json()
messages = body.get("messages", []) messages = body.get("messages", [])
prompt_tokens = sum(crud.count_tokens(msg.get("content", "")) for msg in messages) prompt_tokens = sum(crud.count_tokens(msg.get("content") or "") for msg in messages)
if not crud.check_and_increment_quota(db, api_key_id, tokens=prompt_tokens, requests=1): if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1):
raise HTTPException(status_code=429, detail="Quota exceeded") raise HTTPException(status_code=429, detail="Quota exceeded")
ollama_body = { if "model" not in body:
"model": body.get("model", default_model), body = {**body, "model": default_model}
"messages": messages,
"stream": body.get("stream", False)
}
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=ollama_body, headers=dict(request.headers)) model_name = body["model"]
response_content = response.json().get("message", {}).get("content", "") usage_log.info('%s | /v1/chat/completions | %s | ~%d tokens | "%s"',
completion_tokens = crud.count_tokens(response_content) request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(messages))
openai_response = { target = f"{ollama_url}/v1/chat/completions"
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion", if body.get("stream"):
"created": int(time.time()), async def generate():
"model": body.get("model", default_model), async with httpx.AsyncClient(timeout=300.0) as client:
"choices": [{"index": 0, "message": {"role": "assistant", "content": response_content}, "finish_reason": "stop"}], async with client.stream("POST", target, json=body) as resp:
"usage": { async for chunk in resp.aiter_bytes():
"prompt_tokens": prompt_tokens, yield chunk
"completion_tokens": completion_tokens, return StreamingResponse(
"total_tokens": prompt_tokens + completion_tokens, generate(),
}, media_type="text/event-stream",
} headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
return JSONResponse(content=openai_response, status_code=200, headers={"Content-Type": "application/json"}) )
response = await proxy_request(target, method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code)

View File

@ -52,3 +52,12 @@ class UsageStats(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class APIKeyWithUsage(APIKey):
tokens_used_today: int = 0
tokens_used_month: int = 0
requests_today: int = 0
requests_month: int = 0
class Config:
from_attributes = True

2116
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,23 @@ function authHeaders(token) {
return { Authorization: `Bearer ${token}` }; return { Authorization: `Bearer ${token}` };
} }
const fmtK = (n) => { const k = n / 1000; return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`; };
function QuotaBar({ used, limit, isToken = false }) {
if (limit == null) return <span className="quota-unlimited"></span>;
const pct = Math.min(100, (used / limit) * 100);
const color = pct >= 90 ? '#e74c3c' : pct >= 70 ? '#e67e22' : '#27ae60';
const fmt = isToken ? fmtK : (n) => n.toLocaleString('de-DE');
return (
<div className="quota-cell">
<span className="quota-label">{fmt(used)} / {fmt(limit)}</span>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
);
}
function Login({ onLogin }) { function Login({ onLogin }) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -162,6 +179,8 @@ function App() {
const [newKey, setNewKey] = useState(null); const [newKey, setNewKey] = useState(null);
const [form, setForm] = useState(EMPTY_KEY_FORM); const [form, setForm] = useState(EMPTY_KEY_FORM);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [editKey, setEditKey] = useState(null);
const [editForm, setEditForm] = useState({});
useEffect(() => { useEffect(() => {
if (!password) { setLoading(false); return; } if (!password) { setLoading(false); return; }
@ -183,8 +202,8 @@ function App() {
try { try {
const payload = { name: form.name }; const payload = { name: form.name };
if (form.expires_at) payload.expires_at = new Date(form.expires_at).toISOString(); if (form.expires_at) payload.expires_at = new Date(form.expires_at).toISOString();
if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens); if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens) * 1000;
if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens); if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens) * 1000;
if (form.daily_requests) payload.daily_requests = Number(form.daily_requests); if (form.daily_requests) payload.daily_requests = Number(form.daily_requests);
if (form.monthly_requests) payload.monthly_requests = Number(form.monthly_requests); if (form.monthly_requests) payload.monthly_requests = Number(form.monthly_requests);
@ -208,6 +227,53 @@ function App() {
} }
}; };
const handleActivate = async (id) => {
try {
await axios.put(`/api/api-keys/${id}/activate`, {}, { headers: authHeaders(password) });
await fetchApiKeys();
} catch {
setError('Fehler beim Aktivieren.');
}
};
const handleDelete = async (id, name) => {
if (!window.confirm(`API-Key "${name}" wirklich löschen?`)) return;
try {
await axios.delete(`/api/api-keys/${id}`, { headers: authHeaders(password) });
if (editKey?.id === id) setEditKey(null);
await fetchApiKeys();
} catch {
setError('Fehler beim Löschen.');
}
};
const handleEdit = (key) => {
setEditKey(key);
setEditForm({
daily_tokens: key.daily_tokens != null ? key.daily_tokens / 1000 : '',
monthly_tokens: key.monthly_tokens != null ? key.monthly_tokens / 1000 : '',
daily_requests: key.daily_requests ?? '',
monthly_requests: key.monthly_requests ?? '',
});
};
const handleSaveEdit = async (e) => {
e.preventDefault();
try {
const payload = {
daily_tokens: editForm.daily_tokens !== '' ? Number(editForm.daily_tokens) * 1000 : null,
monthly_tokens: editForm.monthly_tokens !== '' ? Number(editForm.monthly_tokens) * 1000 : null,
daily_requests: editForm.daily_requests !== '' ? Number(editForm.daily_requests) : null,
monthly_requests: editForm.monthly_requests !== '' ? Number(editForm.monthly_requests) : null,
};
await axios.patch(`/api/api-keys/${editKey.id}/quota`, payload, { headers: authHeaders(password) });
setEditKey(null);
await fetchApiKeys();
} catch {
setError('Fehler beim Speichern der Limits.');
}
};
const logout = () => { const logout = () => {
sessionStorage.removeItem('admin_password'); sessionStorage.removeItem('admin_password');
setPassword(null); setPassword(null);
@ -229,43 +295,76 @@ function App() {
<section> <section>
<h2>Neuer API-Key</h2> <h2>Neuer API-Key</h2>
<form onSubmit={handleCreate} className="create-form"> <form onSubmit={handleCreate} className="create-form">
<input <div className="edit-form">
placeholder="Name" <label className="create-name">
value={form.name} Name
onChange={(e) => setForm({ ...form, name: e.target.value })} <small>&nbsp;</small>
required <input
/> value={form.name}
<input onChange={(e) => setForm({ ...form, name: e.target.value })}
type="date" required
placeholder="Ablaufdatum (leer = unbegrenzt)" placeholder="z. B. alice"
value={form.expires_at} />
onChange={(e) => setForm({ ...form, expires_at: e.target.value })} </label>
/> <label className="create-date">
<input Ablaufdatum
type="number" <small>leer = unbegrenzt</small>
placeholder="Tokens/Tag (leer = unbegrenzt)" <input
value={form.daily_tokens} type="date"
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })} value={form.expires_at}
/> onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
<input />
type="number" </label>
placeholder="Tokens/Monat" <div className="create-btn-wrap">
value={form.monthly_tokens} <button type="submit" disabled={creating} className="btn-save">Erstellen</button>
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })} </div>
/> </div>
<input <div className="edit-form">
type="number" <label>
placeholder="Requests/Tag" Tokens/Tag (k)
value={form.daily_requests} <small>leer = unbegrenzt</small>
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })} <input
/> type="number"
<input min="0"
type="number" placeholder="∞"
placeholder="Requests/Monat" value={form.daily_tokens}
value={form.monthly_requests} onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })} />
/> </label>
<button type="submit" disabled={creating}>Erstellen</button> <label>
Tokens/Monat (k)
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
placeholder="∞"
value={form.monthly_tokens}
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })}
/>
</label>
<label>
Requests/Tag
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
placeholder="∞"
value={form.daily_requests}
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })}
/>
</label>
<label>
Requests/Monat
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
placeholder="∞"
value={form.monthly_requests}
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })}
/>
</label>
</div>
</form> </form>
{newKey && ( {newKey && (
<div className="new-key-box"> <div className="new-key-box">
@ -281,41 +380,99 @@ function App() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th></th>
<th>Name</th> <th>Name</th>
<th>Key</th> <th>Key</th>
<th>Status</th>
<th>Läuft ab</th> <th>Läuft ab</th>
<th>Tokens/Tag</th> <th className="th-quota">Tokens/Tag</th>
<th>Tokens/Monat</th> <th className="th-quota">Tokens/Monat</th>
<th>Req/Tag</th> <th className="th-quota">Req/Tag</th>
<th>Req/Monat</th> <th className="th-quota">Req/Monat</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{apiKeys.map(key => ( {apiKeys.map(key => (
<tr key={key.id}> <tr key={key.id} className={editKey?.id === key.id ? 'row-editing' : ''}>
<td>{key.id}</td> <td className="td-status">
<span className={key.is_active ? 'status-active' : 'status-inactive'} title={key.is_active ? 'Aktiv' : 'Inaktiv'}></span>
</td>
<td>{key.name}</td> <td>{key.name}</td>
<td>{displayKey(key.key_prefix)}</td> <td>{displayKey(key.key_prefix)}</td>
<td>{key.is_active ? 'Aktiv' : 'Inaktiv'}</td>
<td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td> <td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td>
<td>{key.daily_tokens ?? '∞'}</td> <td><QuotaBar used={key.tokens_used_today} limit={key.daily_tokens} isToken /></td>
<td>{key.monthly_tokens ?? '∞'}</td> <td><QuotaBar used={key.tokens_used_month} limit={key.monthly_tokens} isToken /></td>
<td>{key.daily_requests ?? '∞'}</td> <td><QuotaBar used={key.requests_today} limit={key.daily_requests} /></td>
<td>{key.monthly_requests ?? '∞'}</td> <td><QuotaBar used={key.requests_month} limit={key.monthly_requests} /></td>
<td> <td className="action-cell">
{key.is_active && ( <button className="btn-icon btn-icon-edit" data-tooltip="Bearbeiten" onClick={() => handleEdit(key)}></button>
<button className="btn-danger" onClick={() => handleDeactivate(key.id)}> {key.is_active ? (
Deaktivieren <button className="btn-icon btn-icon-warn" data-tooltip="Deaktivieren" onClick={() => handleDeactivate(key.id)}></button>
</button> ) : (
<button className="btn-icon btn-icon-ok" data-tooltip="Aktivieren" onClick={() => handleActivate(key.id)}></button>
)} )}
<button className="btn-icon btn-icon-danger" data-tooltip="Löschen" onClick={() => handleDelete(key.id, key.name)}></button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{editKey && (
<div className="edit-section">
<h3>Limits bearbeiten: <em>{editKey.name}</em></h3>
<form onSubmit={handleSaveEdit} className="edit-form">
<label>
Tokens/Tag (k)
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.daily_tokens}
onChange={(e) => setEditForm({ ...editForm, daily_tokens: e.target.value })}
placeholder="∞"
/>
</label>
<label>
Tokens/Monat (k)
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.monthly_tokens}
onChange={(e) => setEditForm({ ...editForm, monthly_tokens: e.target.value })}
placeholder="∞"
/>
</label>
<label>
Requests/Tag
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.daily_requests}
onChange={(e) => setEditForm({ ...editForm, daily_requests: e.target.value })}
placeholder="∞"
/>
</label>
<label>
Requests/Monat
<small>leer = unbegrenzt</small>
<input
type="number"
min="0"
value={editForm.monthly_requests}
onChange={(e) => setEditForm({ ...editForm, monthly_requests: e.target.value })}
placeholder="∞"
/>
</label>
<div className="edit-actions">
<button type="submit" className="btn-save">Speichern</button>
<button type="button" className="btn-cancel" onClick={() => setEditKey(null)}>Abbrechen</button>
</div>
</form>
</div>
)}
</section> </section>
</div> </div>
); );

View File

@ -92,30 +92,37 @@ tr:hover {
.create-form { .create-form {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 10px; gap: 12px;
}
.create-form .edit-form {
margin-bottom: 0;
}
.create-name {
flex: 0 0 auto !important;
}
.create-name input {
width: 200px;
}
.create-date {
flex: 0 0 auto !important;
}
.create-date input[type="date"] {
width: 150px;
}
.create-btn-wrap {
flex: 0 0 auto;
display: flex;
align-items: flex-end; align-items: flex-end;
} }
.create-form input { .create-form .btn-save:disabled {
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
flex: 1 1 160px;
}
.create-form button {
padding: 8px 20px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.create-form button:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
@ -218,3 +225,198 @@ tr:hover {
color: #999; color: #999;
font-size: 12px; font-size: 12px;
} }
/* Quota progress bars */
.th-quota {
width: 90px;
}
.quota-cell {
width: 100%;
}
.quota-label {
font-size: 12px;
color: #555;
white-space: nowrap;
}
.quota-unlimited {
color: #aaa;
font-size: 14px;
}
.progress-bar {
height: 4px;
background: #e2e8f0;
border-radius: 2px;
margin-top: 4px;
}
.progress-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
/* Edit form */
.td-status {
width: 20px;
text-align: center;
}
.status-active { color: #27ae60; font-size: 12px; }
.status-inactive { color: #bdc3c7; font-size: 12px; }
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
margin-right: 2px;
border-radius: 3px;
line-height: 1;
position: relative;
}
.btn-icon:hover {
background: rgba(0, 0, 0, 0.06);
}
.btn-icon-edit { color: #2980b9; }
.btn-icon-warn { color: #e67e22; }
.btn-icon-ok { color: #27ae60; }
.btn-icon-danger { color: #e74c3c; }
.btn-icon::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #2c3e50;
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
font-weight: 400;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 1000;
}
.btn-icon::before {
content: '';
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #2c3e50;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 1000;
}
.btn-icon:hover::after,
.btn-icon:hover::before {
opacity: 1;
}
.action-cell {
white-space: nowrap;
position: relative;
overflow: visible;
}
.row-editing {
background: #eaf4fb !important;
}
.edit-section {
margin-top: 20px;
padding: 16px 20px;
background: #f0f7ff;
border: 1px solid #3498db;
border-radius: 6px;
}
.edit-section h3 {
margin: 0 0 14px;
font-size: 15px;
color: #2c3e50;
font-weight: 600;
}
.edit-section h3 em {
font-style: normal;
color: #2980b9;
}
.edit-form {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-end;
}
.edit-form label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 13px;
font-weight: 500;
color: #34495e;
}
.edit-form label small {
font-weight: 400;
color: #999;
font-size: 11px;
}
.edit-form input {
padding: 6px 8px;
border: 1px solid #bdc3c7;
border-radius: 4px;
font-size: 14px;
width: 130px;
}
.edit-actions {
display: flex;
gap: 8px;
padding-bottom: 2px;
}
.btn-save {
padding: 6px 18px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.btn-save:hover {
background: #219a52;
}
.btn-cancel {
padding: 6px 18px;
background: #95a5a6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.btn-cancel:hover {
background: #7f8c8d;
}