Compare commits

..

No commits in common. "dd8f69ecb65a4953975d69e7493c29a47a9f09c6" and "94368670b71579fb4aae8d8b96e2cc28dd3757ef" have entirely different histories.

8 changed files with 171 additions and 2694 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, Usage as UsageModel from models import APIKey as APIKeyModel
app = FastAPI(title="Ollama Proxy Admin API") app = FastAPI(title="Ollama Proxy Admin API")
@ -31,24 +31,13 @@ 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.APIKeyWithUsage]) @app.get("/api/api-keys", response_model=list[schemas.APIKey])
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),
): ):
keys = db.query(APIKeyModel).offset(skip).limit(limit).all() return 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(
@ -98,32 +87,6 @@ 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")
@ -144,10 +107,9 @@ async def update_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_ = Depends(require_admin_auth), _ = Depends(require_admin_auth),
): ):
ollama_url = settings.ollama_url.rstrip('/').removesuffix('/v1') crud.set_setting(db, "ollama_url", settings.ollama_url)
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 schemas.Settings(ollama_url=ollama_url, default_model=settings.default_model) return settings
@app.get("/api/ollama-models") @app.get("/api/ollama-models")
async def get_ollama_models( async def get_ollama_models(

View File

@ -40,7 +40,6 @@ def create_api_key(
db_key = APIKey( db_key = APIKey(
name=name, name=name,
key=_hash_api_key(raw_key), key=_hash_api_key(raw_key),
key_prefix=raw_key[:12],
expires_at=expires_at, expires_at=expires_at,
daily_tokens=daily_tokens, daily_tokens=daily_tokens,
monthly_tokens=monthly_tokens, monthly_tokens=monthly_tokens,

View File

@ -1,133 +1,135 @@
import logging import time
import os import uuid
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, StreamingResponse from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db, SessionLocal
import crud import crud
import httpx import httpx
import os
# Rotating usage log (8 KB per file, 3 backups) app = FastAPI(title="Ollama Proxy")
_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
def _last_user_msg(messages: list, max_len: int = 120) -> str: async def proxy_request(url: str, method: str = "GET", json_data: dict = None, headers: dict = None):
for msg in reversed(messages): async with httpx.AsyncClient() as client:
if msg.get("role") == "user": response = await client.request(method=method, url=url, json=json_data, headers=headers)
text = (msg.get("content") or "").replace("\n", " ").strip() return response
return text[:max_len] + ("" if len(text) > max_len else "")
return ""
async def require_api_key(request: Request, db: Session = Depends(get_db)): @app.middleware("http")
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[7:] api_key = auth_header.replace("Bearer ", "")
elif auth_header.startswith("sk-"): elif auth_header.startswith("sk-"):
api_key = auth_header api_key = auth_header
else: else:
raise HTTPException(status_code=401, detail="Invalid or missing API key") return JSONResponse(status_code=401, content={"detail": "Invalid or missing API key"})
# Uses its own session since middleware cannot use Depends
db = SessionLocal()
try:
db_key = crud.verify_api_key(db, api_key) db_key = crud.verify_api_key(db, api_key)
if not db_key: if not db_key:
raise HTTPException(status_code=401, detail="Invalid API key") return JSONResponse(status_code=401, content={"detail": "Invalid API key"})
request.state.api_key_id = db_key.id request.state.api_key_id = db_key.id
request.state.api_key_name = db_key.name finally:
db.close()
app = FastAPI(title="Ollama Proxy", dependencies=[Depends(require_api_key)]) response = await call_next(request)
async def proxy_request(url: str, method: str = "GET", json_data: dict = None):
async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.request(method=method, url=url, json=json_data)
return response 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()
prompt_tokens = crud.count_tokens(body.get("prompt", ""))
if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1): body = await request.json()
prompt_tokens = crud.count_tokens(body.get("prompt", ""))
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")
prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120] response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body, headers=dict(request.headers))
usage_log.info('%s | /api/generate | %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, 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()
messages = body.get("messages", [])
prompt_tokens = sum(crud.count_tokens(msg.get("content") or "") for msg in messages)
if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1): body = await request.json()
prompt_tokens = sum(crud.count_tokens(msg.get("content", "")) for msg in body.get("messages", []))
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")
usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"', response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body, headers=dict(request.headers))
request.state.api_key_name, body.get("model", "?"), prompt_tokens, _last_user_msg(messages)) return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
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(db: Session = Depends(get_db)): async def list_models(request: Request, 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") response = await proxy_request(f"{ollama_url}/api/tags", method="GET", headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code) return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
@app.get("/api/versions") @app.get("/api/versions")
async def versions(db: Session = Depends(get_db)): async def versions(request: Request, 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") response = await proxy_request(f"{ollama_url}/api/versions", method="GET", headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code) return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
@app.get("/v1/models") @app.get("/v1/models")
async def list_openai_models(db: Session = Depends(get_db)): async def list_openai_models(request: Request, 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}/v1/models", method="GET") response = await proxy_request(f"{ollama_url}/api/tags", method="GET", headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code) ollama_models = response.json()
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") or "") for msg in messages) prompt_tokens = sum(crud.count_tokens(msg.get("content", "")) for msg in 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")
if "model" not in body: ollama_body = {
body = {**body, "model": default_model} "model": body.get("model", default_model),
"messages": messages,
"stream": body.get("stream", False)
}
model_name = body["model"] response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=ollama_body, headers=dict(request.headers))
usage_log.info('%s | /v1/chat/completions | %s | ~%d tokens | "%s"', response_content = response.json().get("message", {}).get("content", "")
request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(messages)) completion_tokens = crud.count_tokens(response_content)
target = f"{ollama_url}/v1/chat/completions" openai_response = {
"id": f"chatcmpl-{uuid.uuid4().hex}",
if body.get("stream"): "object": "chat.completion",
async def generate(): "created": int(time.time()),
async with httpx.AsyncClient(timeout=300.0) as client: "model": body.get("model", default_model),
async with client.stream("POST", target, json=body) as resp: "choices": [{"index": 0, "message": {"role": "assistant", "content": response_content}, "finish_reason": "stop"}],
async for chunk in resp.aiter_bytes(): "usage": {
yield chunk "prompt_tokens": prompt_tokens,
return StreamingResponse( "completion_tokens": completion_tokens,
generate(), "total_tokens": prompt_tokens + completion_tokens,
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

@ -10,7 +10,6 @@ class APIKey(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String) name = Column(String)
key = Column(String, unique=True, index=True) key = Column(String, unique=True, index=True)
key_prefix = Column(String)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), default=_now) created_at = Column(DateTime(timezone=True), default=_now)
expires_at = Column(DateTime(timezone=True), nullable=True) expires_at = Column(DateTime(timezone=True), nullable=True)

View File

@ -14,7 +14,6 @@ class APIKey(BaseModel):
id: int id: int
name: str name: str
key: str key: str
key_prefix: Optional[str] = None
is_active: bool is_active: bool
created_at: datetime created_at: datetime
expires_at: Optional[datetime] = None expires_at: Optional[datetime] = None
@ -52,12 +51,3 @@ 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

File diff suppressed because it is too large Load Diff

View File

@ -3,29 +3,12 @@ import ReactDOM from 'react-dom/client';
import axios from 'axios'; import axios from 'axios';
import './styles.css'; import './styles.css';
const displayKey = (prefix) => prefix ? `${prefix}••••••••` : '••••••••••••'; const maskKey = (key) => `••••••••${key.slice(-4)}`;
function authHeaders(token) { 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);
@ -179,8 +162,6 @@ 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; }
@ -202,8 +183,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) * 1000; if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens);
if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens) * 1000; if (form.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens);
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);
@ -227,53 +208,6 @@ 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);
@ -295,76 +229,43 @@ 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">
<div className="edit-form">
<label className="create-name">
Name
<small>&nbsp;</small>
<input <input
placeholder="Name"
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
required required
placeholder="z. B. alice"
/> />
</label>
<label className="create-date">
Ablaufdatum
<small>leer = unbegrenzt</small>
<input <input
type="date" type="date"
placeholder="Ablaufdatum (leer = unbegrenzt)"
value={form.expires_at} value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })} onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/> />
</label>
<div className="create-btn-wrap">
<button type="submit" disabled={creating} className="btn-save">Erstellen</button>
</div>
</div>
<div className="edit-form">
<label>
Tokens/Tag (k)
<small>leer = unbegrenzt</small>
<input <input
type="number" type="number"
min="0" placeholder="Tokens/Tag (leer = unbegrenzt)"
placeholder="∞"
value={form.daily_tokens} value={form.daily_tokens}
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })} onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
/> />
</label>
<label>
Tokens/Monat (k)
<small>leer = unbegrenzt</small>
<input <input
type="number" type="number"
min="0" placeholder="Tokens/Monat"
placeholder="∞"
value={form.monthly_tokens} value={form.monthly_tokens}
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })} onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })}
/> />
</label>
<label>
Requests/Tag
<small>leer = unbegrenzt</small>
<input <input
type="number" type="number"
min="0" placeholder="Requests/Tag"
placeholder="∞"
value={form.daily_requests} value={form.daily_requests}
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })} onChange={(e) => setForm({ ...form, daily_requests: e.target.value })}
/> />
</label>
<label>
Requests/Monat
<small>leer = unbegrenzt</small>
<input <input
type="number" type="number"
min="0" placeholder="Requests/Monat"
placeholder="∞"
value={form.monthly_requests} value={form.monthly_requests}
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })} onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })}
/> />
</label> <button type="submit" disabled={creating}>Erstellen</button>
</div>
</form> </form>
{newKey && ( {newKey && (
<div className="new-key-box"> <div className="new-key-box">
@ -380,99 +281,41 @@ function App() {
<table> <table>
<thead> <thead>
<tr> <tr>
<th></th> <th>ID</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 className="th-quota">Tokens/Tag</th> <th>Tokens/Tag</th>
<th className="th-quota">Tokens/Monat</th> <th>Tokens/Monat</th>
<th className="th-quota">Req/Tag</th> <th>Req/Tag</th>
<th className="th-quota">Req/Monat</th> <th>Req/Monat</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{apiKeys.map(key => ( {apiKeys.map(key => (
<tr key={key.id} className={editKey?.id === key.id ? 'row-editing' : ''}> <tr key={key.id}>
<td className="td-status"> <td>{key.id}</td>
<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>{maskKey(key.key)}</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><QuotaBar used={key.tokens_used_today} limit={key.daily_tokens} isToken /></td> <td>{key.daily_tokens ?? '∞'}</td>
<td><QuotaBar used={key.tokens_used_month} limit={key.monthly_tokens} isToken /></td> <td>{key.monthly_tokens ?? '∞'}</td>
<td><QuotaBar used={key.requests_today} limit={key.daily_requests} /></td> <td>{key.daily_requests ?? '∞'}</td>
<td><QuotaBar used={key.requests_month} limit={key.monthly_requests} /></td> <td>{key.monthly_requests ?? '∞'}</td>
<td className="action-cell"> <td>
<button className="btn-icon btn-icon-edit" data-tooltip="Bearbeiten" onClick={() => handleEdit(key)}></button> {key.is_active && (
{key.is_active ? ( <button className="btn-danger" onClick={() => handleDeactivate(key.id)}>
<button className="btn-icon btn-icon-warn" data-tooltip="Deaktivieren" onClick={() => handleDeactivate(key.id)}></button> Deaktivieren
) : ( </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,37 +92,30 @@ tr:hover {
.create-form { .create-form {
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
gap: 12px; gap: 10px;
}
.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 .btn-save:disabled { .create-form input {
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;
} }
@ -225,198 +218,3 @@ 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;
}