18 Commits
Author SHA1 Message Date
Oliver Hofmann 222b204d4b build_push.sh: show current tag, offer to set new one before build 2026-04-29 09:36:50 +02:00
Oliver Hofmann 9910e6e062 Exclude .claude from Docker build context 2026-04-29 09:35:17 +02:00
Oliver Hofmann 6e704da86b Expand .dockerignore with standard entries 2026-04-29 09:34:19 +02:00
Oliver Hofmann bd0dc0478f Add local docker-compose pulling from DockerHub with SQLite and .env 2026-04-29 09:33:04 +02:00
Oliver Hofmann 92fd66381f Add English version of DockerHub README 2026-04-29 09:19:28 +02:00
Oliver Hofmann c46c1c2b12 Clarify that Ollama can run remotely, not just on Docker host 2026-04-29 08:51:23 +02:00
Oliver Hofmann d243523906 Split host-Ollama compose into SQLite and PostgreSQL variants 2026-04-29 08:47:40 +02:00
Oliver Hofmann 2319434b0f Add host-Ollama compose variant, fix leftover port 8001 exposure 2026-04-29 08:43:54 +02:00
Oliver Hofmann 7912431ffb Remove ALLOWED_ORIGINS from DockerHub docs (dev-only setting) 2026-04-29 08:38:38 +02:00
Oliver Hofmann 114c45dc7e Remove port 8001 exposure from all Docker examples 2026-04-29 08:36:59 +02:00
Oliver Hofmann 6f67f29f68 Fix depends_on syntax in PostgreSQL compose example 2026-04-29 08:31:25 +02:00
Oliver Hofmann 3b7c88bf2c Install psycopg2-binary in Dockerfile only, not in dev requirements 2026-04-29 08:30:16 +02:00
Oliver Hofmann 03712fe401 Add DockerHub README with usage, env vars and compose examples 2026-04-29 08:26:47 +02:00
Oliver Hofmann a0512b3b2b Replace build_push.py with build_push.sh, use shell script run config 2026-04-29 08:23:07 +02:00
Oliver Hofmann 511e85ea79 Add Docker build/push run config for arm64 2026-04-29 08:20:15 +02:00
Oliver Hofmann cdaec894d8 Add PyCharm Dev run config and dev runner script
- run_dev.py: starts proxy, admin and frontend together with colored
  output per process; handles SIGTERM from PyCharm Stop button
- .idea/runConfigurations/Dev.xml: PyCharm Python run config
- .gitignore: allow Dev.xml via layered negation pattern
- vite.config.js: open browser automatically on dev server start
2026-04-29 08:14:33 +02:00
Oliver HofmannandClaude Sonnet 4.6 dd8f69ecb6 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>
2026-04-29 07:48:10 +02:00
Oliver Hofmann c62cafc202 Store key_prefix for readable key display instead of masked hash
The last-4 of the SHA-256 hash was meaningless for identification.
Now storing the first 12 chars of the plaintext key as key_prefix,
displayed as 'sk-aBcDeFgH••••••••' — consistent with what the user
sees at creation time and how GitHub/OpenAI handle it.
2026-04-28 10:23:37 +02:00
18 changed files with 3319 additions and 201 deletions

No files matched your search

+45 -6
View File
@@ -1,11 +1,50 @@
.git/
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
*.egg-info/
.pytest_cache/
# Tests
backend/tests/
# Environment & secrets
.env
*.env.local
# Databases
*.db
*.sqlite3
# Logs
logs/
*.log
# IDE & tools
.idea/
.vscode/
.claude/
# Git
.git/
.gitignore
# Frontend
frontend/node_modules/
frontend/dist/
backend/__pycache__/
backend/**/__pycache__/
backend/*.pyc
backend/test.db
backend/tests/
# Docker
docker-compose.yml
.dockerignore
# Docs
*.md
# Dev & build scripts
run_dev.py
build_push.sh
start.sh
test_api.sh
+5 -1
View File
@@ -14,7 +14,11 @@ __pycache__/
*.sqlite3
# IDE
.idea/
.idea/*
!.idea/runConfigurations/
.idea/runConfigurations/*
!.idea/runConfigurations/Dev.xml
!.idea/runConfigurations/Docker_Push.xml
.vscode/
# Frontend build
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dev" type="PythonConfigurationType" factoryName="Python">
<module name="llm_quota" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/run_dev.py" />
<option name="PARAMETERS" value="" />
<method v="2" />
</configuration>
</component>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Docker Push" type="ShConfigurationType">
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/build_push.sh" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<method v="2" />
</configuration>
</component>
+206
View File
@@ -0,0 +1,206 @@
# mediaeng/llmproxy
A lightweight reverse proxy for [Ollama](https://ollama.com) that manages API keys with configurable token and request quotas. Incoming requests in OpenAI-compatible format are authenticated, checked against the quota, and forwarded to the configured Ollama server.
Ollama does not need to run on the same host — `OLLAMA_URL` can point to any reachable server: the Docker host itself, another machine on the network, or a remote server.
## Features
- OpenAI-compatible endpoint (`/v1/chat/completions`, `/v1/models`)
- API key management with daily and monthly token/request limits
- Web-based admin interface (port 8001)
- Streaming support (Server-Sent Events)
- Tool use / function calling passthrough
- Rotating usage logs
- SQLite (default) or PostgreSQL
## Ports
| Port | Service |
|------|---------|
| `8000` | Proxy endpoint (OpenAI API) |
| `8001` | Admin API + web interface (do not expose) |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ADMIN_PASSWORD` | – | **Required.** Password for the admin interface |
| `OLLAMA_URL` | `http://localhost:11434` | URL of the Ollama server (without `/v1` suffix) |
| `DEFAULT_MODEL` | `llama3` | Model used when the client does not specify one |
| `DATABASE_URL` | `sqlite:///./test.db` | Database connection string (SQLite or PostgreSQL) |
| `PROXY_HOST` | `0.0.0.0` | Proxy bind address |
| `PROXY_PORT` | `8000` | Proxy port |
| `ADMIN_PORT` | `8001` | Admin API port |
| `APP_TZ` | `Europe/Berlin` | Timezone for daily/monthly quota resets |
| `LOG_FILE` | `logs/usage.log` | Path of the rotating usage log file |
## Docker Compose – External Ollama, SQLite
Use this when Ollama runs outside of Docker — on the Docker host or any other reachable server. Adjust `OLLAMA_URL` accordingly.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # or http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
# On Linux, add extra_hosts since host.docker.internal is not
# available automatically:
# extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
llmproxy-data:
```
## Docker Compose – External Ollama, PostgreSQL
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # or http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
volumes:
- llmproxy-data:/app/backend
depends_on:
db:
condition: service_healthy
# extra_hosts:
# - "host.docker.internal:host-gateway"
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pg-data:
```
## Docker Compose – Ollama as Container, SQLite
Ollama and llmproxy run together in Docker, data persisted in a volume.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
depends_on:
- ollama
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
llmproxy-data:
ollama-data:
```
## Docker Compose – Ollama as Container, PostgreSQL
For production environments with an external database.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
pg-data:
ollama-data:
```
## Quick Start
```bash
docker run -d \
-p 8000:8000 \
-e ADMIN_PASSWORD=changeme \
-e OLLAMA_URL=http://host.docker.internal:11434 \
-v llmproxy-data:/app/backend \
mediaeng/llmproxy:latest
```
## Client Configuration
Configure the proxy as an OpenAI-compatible endpoint:
```
Base URL: http://<host>:8000/v1
API Key: <API key created in the admin interface>
```
+206
View File
@@ -0,0 +1,206 @@
# mediaeng/llmproxy
Ein schlanker Reverse-Proxy für [Ollama](https://ollama.com), der API-Keys mit konfigurierbaren Token- und Request-Quoten verwaltet. Eingehende Anfragen im OpenAI-kompatiblen Format werden authentifiziert, auf Quota geprüft und an den konfigurierten Ollama-Server weitergeleitet.
Ollama muss dabei nicht auf demselben Host laufen — `OLLAMA_URL` kann auf jeden erreichbaren Server zeigen, also auf den Docker-Host selbst, einen anderen Rechner im Netzwerk oder einen Remote-Server.
## Funktionen
- OpenAI-kompatibler Endpunkt (`/v1/chat/completions`, `/v1/models`)
- API-Key-Verwaltung mit tages- und monatlichen Token-/Request-Limits
- Web-basierte Admin-Oberfläche (Port 8001)
- Streaming-Support (Server-Sent Events)
- Tool-Use / Function Calling wird durchgereicht
- Rotierende Nutzungs-Logs
- SQLite (Standard) oder PostgreSQL
## Ports
| Port | Dienst |
|------|--------|
| `8000` | Proxy-Endpunkt (OpenAI-API) |
| `8001` | Admin-API + Web-Oberfläche (nicht exponieren) |
## Umgebungsvariablen
| Variable | Standard | Beschreibung |
|----------|----------|--------------|
| `ADMIN_PASSWORD` | – | **Pflicht.** Passwort für die Admin-Oberfläche |
| `OLLAMA_URL` | `http://localhost:11434` | URL des Ollama-Servers (ohne `/v1`-Suffix) |
| `DEFAULT_MODEL` | `llama3` | Modell, das verwendet wird wenn der Client keines angibt |
| `DATABASE_URL` | `sqlite:///./test.db` | Datenbank-Verbindungsstring (SQLite oder PostgreSQL) |
| `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxy |
| `PROXY_PORT` | `8000` | Port des Proxy |
| `ADMIN_PORT` | `8001` | Port der Admin-API |
| `APP_TZ` | `Europe/Berlin` | Zeitzone für Tages-/Monats-Reset der Quoten |
| `LOG_FILE` | `logs/usage.log` | Pfad der rotierenden Nutzungs-Logdatei |
## Docker Compose – Ollama extern, SQLite
Wenn Ollama außerhalb von Docker läuft — auf dem Docker-Host oder einem anderen erreichbaren Server. `OLLAMA_URL` entsprechend anpassen.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # oder http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
# Auf Linux extra_hosts ergänzen, da host.docker.internal dort
# nicht automatisch verfügbar ist:
# extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
llmproxy-data:
```
## Docker Compose – Ollama extern, PostgreSQL
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://host.docker.internal:11434 # oder http://<ip>:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
volumes:
- llmproxy-data:/app/backend
depends_on:
db:
condition: service_healthy
# extra_hosts:
# - "host.docker.internal:host-gateway"
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pg-data:
```
## Docker Compose – Ollama als Container, SQLite
Ollama und llmproxy laufen gemeinsam in Docker, Daten in einem Volume.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
volumes:
- llmproxy-data:/app/backend
depends_on:
- ollama
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
llmproxy-data:
ollama-data:
```
## Docker Compose – Ollama als Container, PostgreSQL
Für Produktionsumgebungen mit externer Datenbank.
```yaml
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
ADMIN_PASSWORD: changeme
OLLAMA_URL: http://ollama:11434
DEFAULT_MODEL: llama3
APP_TZ: Europe/Berlin
DATABASE_URL: postgresql://llmproxy:secret@db:5432/llmproxy
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: llmproxy
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: secret
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U llmproxy"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama:latest
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
volumes:
pg-data:
ollama-data:
```
## Schnellstart
```bash
docker run -d \
-p 8000:8000 \
-e ADMIN_PASSWORD=changeme \
-e OLLAMA_URL=http://host.docker.internal:11434 \
-v llmproxy-data:/app/backend \
mediaeng/llmproxy:latest
```
## Client-Konfiguration
Den Proxy als OpenAI-kompatibler Endpunkt konfigurieren:
```
Base URL: http://<host>:8000/v1
API Key: <angelegter API-Key aus der Admin-Oberfläche>
```
+1 -1
View File
@@ -9,7 +9,7 @@ FROM python:3.12-slim
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt psycopg2-binary
COPY backend/ backend/
COPY --from=frontend-builder /app/frontend/dist frontend/dist
+43 -5
View File
@@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from database import get_db
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")
@@ -31,13 +31,24 @@ def require_admin_auth(request: Request):
if not secrets.compare_digest(token, 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(
skip: int = 0, limit: int = 100,
db: Session = Depends(get_db),
_ = 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)
async def create_api_key(
@@ -87,6 +98,32 @@ async def deactivate_api_key(
db.commit()
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")
async def get_proxy_info(_ = Depends(require_admin_auth)):
host = os.getenv("PROXY_HOST", "0.0.0.0")
@@ -107,9 +144,10 @@ async def update_settings(
db: Session = Depends(get_db),
_ = 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)
return settings
return schemas.Settings(ollama_url=ollama_url, default_model=settings.default_model)
@app.get("/api/ollama-models")
async def get_ollama_models(
+1
View File
@@ -40,6 +40,7 @@ def create_api_key(
db_key = APIKey(
name=name,
key=_hash_api_key(raw_key),
key_prefix=raw_key[:12],
expires_at=expires_at,
daily_tokens=daily_tokens,
monthly_tokens=monthly_tokens,
+81 -83
View File
@@ -1,135 +1,133 @@
import time
import uuid
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.orm import Session
from database import get_db, SessionLocal
from database import get_db
import crud
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):
async with httpx.AsyncClient() as client:
response = await client.request(method=method, url=url, json=json_data, headers=headers)
return response
def _last_user_msg(messages: list, max_len: int = 120) -> str:
for msg in reversed(messages):
if msg.get("role") == "user":
text = (msg.get("content") or "").replace("\n", " ").strip()
return text[:max_len] + ("…" if len(text) > max_len else "")
return ""
@app.middleware("http")
async def authenticate_and_quota(request: Request, call_next):
async def require_api_key(request: Request, db: Session = Depends(get_db)):
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
api_key = auth_header.replace("Bearer ", "")
api_key = auth_header[7:]
elif auth_header.startswith("sk-"):
api_key = auth_header
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
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()
app = FastAPI(title="Ollama Proxy", dependencies=[Depends(require_api_key)])
response = await call_next(request)
return response
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
@app.post("/api/generate")
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"))
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):
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")
response = await proxy_request(f"{ollama_url}/api/generate", method="POST", json_data=body, headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
prompt_preview = (body.get("prompt", "").replace("\n", " ").strip())[:120]
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")
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"))
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, 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")
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=body, headers=dict(request.headers))
return JSONResponse(content=response.json(), status_code=response.status_code, headers=dict(response.headers))
usage_log.info('%s | /api/chat | %s | ~%d tokens | "%s"',
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")
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"))
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, headers=dict(response.headers))
response = await proxy_request(f"{ollama_url}/api/tags", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code)
@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"))
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, headers=dict(response.headers))
response = await proxy_request(f"{ollama_url}/api/versions", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code)
@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"))
response = await proxy_request(f"{ollama_url}/api/tags", method="GET", headers=dict(request.headers))
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))
response = await proxy_request(f"{ollama_url}/v1/models", method="GET")
return JSONResponse(content=response.json(), status_code=response.status_code)
@app.post("/v1/chat/completions")
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"))
default_model = crud.get_setting(db, "default_model", os.getenv("DEFAULT_MODEL", "llama3"))
body = await request.json()
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")
ollama_body = {
"model": body.get("model", default_model),
"messages": messages,
"stream": body.get("stream", False)
}
if "model" not in body:
body = {**body, "model": default_model}
response = await proxy_request(f"{ollama_url}/api/chat", method="POST", json_data=ollama_body, headers=dict(request.headers))
response_content = response.json().get("message", {}).get("content", "")
completion_tokens = crud.count_tokens(response_content)
model_name = body["model"]
usage_log.info('%s | /v1/chat/completions | %s | ~%d tokens | "%s"',
request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(messages))
openai_response = {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": int(time.time()),
"model": body.get("model", default_model),
"choices": [{"index": 0, "message": {"role": "assistant", "content": response_content}, "finish_reason": "stop"}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
}
return JSONResponse(content=openai_response, status_code=200, headers={"Content-Type": "application/json"})
target = f"{ollama_url}/v1/chat/completions"
if body.get("stream"):
async def generate():
async with httpx.AsyncClient(timeout=300.0) as client:
async with client.stream("POST", target, json=body) as resp:
async for chunk in resp.aiter_bytes():
yield chunk
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
response = await proxy_request(target, method="POST", json_data=body)
return JSONResponse(content=response.json(), status_code=response.status_code)
+1
View File
@@ -10,6 +10,7 @@ class APIKey(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String)
key = Column(String, unique=True, index=True)
key_prefix = Column(String)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), default=_now)
expires_at = Column(DateTime(timezone=True), nullable=True)
+10
View File
@@ -14,6 +14,7 @@ class APIKey(BaseModel):
id: int
name: str
key: str
key_prefix: Optional[str] = None
is_active: bool
created_at: datetime
expires_at: Optional[datetime] = None
@@ -49,5 +50,14 @@ class UsageStats(BaseModel):
daily_reset_at: Optional[datetime] = None
monthly_reset_at: Optional[datetime] = None
class Config:
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
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
IMAGE=mediaeng/llmproxy
PLATFORM=linux/arm64
CURRENT=$(git describe --tags --always)
if [ -z "$CURRENT" ]; then
echo "Fehler: git describe liefert kein Ergebnis"
exit 1
fi
echo "Aktueller Tag: $CURRENT"
read -rp "Neuer Tag [${CURRENT}]: " INPUT
VERSION="${INPUT:-$CURRENT}"
if [ "$VERSION" != "$CURRENT" ]; then
git tag "$VERSION"
git push origin "$VERSION"
echo "Tag '$VERSION' gesetzt und gepusht."
fi
echo ""
echo "Image : $IMAGE"
echo "Platform: $PLATFORM"
echo "Tags : $IMAGE:$VERSION $IMAGE:latest"
echo ""
docker buildx build \
--platform "$PLATFORM" \
--push \
-t "$IMAGE:$VERSION" \
-t "$IMAGE:latest" \
.
echo ""
echo "Fertig. Gepusht:"
echo " docker pull $IMAGE:$VERSION"
echo " docker pull $IMAGE:latest"
+10 -24
View File
@@ -1,24 +1,10 @@
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://ollama:password@db:5432/ollama_proxy
- OLLAMA_URL=http://ollama:11434
- SECRET_KEY=your-secret-key-change-me
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_USER=ollama
- POSTGRES_PASSWORD=password
- POSTGRES_DB=ollama_proxy
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
services:
llmproxy:
image: mediaeng/llmproxy:latest
restart: unless-stopped
env_file: .env
ports:
- "${PROXY_PORT:-8000}:${PROXY_PORT:-8000}"
volumes:
- ./backend/test.db:/app/backend/test.db
- ./backend/logs:/app/backend/logs
+2116
View File
File diff suppressed because it is too large. Load diff
+217 -60
View File
@@ -3,12 +3,29 @@ import ReactDOM from 'react-dom/client';
import axios from 'axios';
import './styles.css';
const maskKey = (key) => `••••••••${key.slice(-4)}`;
const displayKey = (prefix) => prefix ? `${prefix}••••••••` : '••••••••••••';
function authHeaders(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 }) {
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
@@ -162,6 +179,8 @@ function App() {
const [newKey, setNewKey] = useState(null);
const [form, setForm] = useState(EMPTY_KEY_FORM);
const [creating, setCreating] = useState(false);
const [editKey, setEditKey] = useState(null);
const [editForm, setEditForm] = useState({});
useEffect(() => {
if (!password) { setLoading(false); return; }
@@ -183,8 +202,8 @@ function App() {
try {
const payload = { name: form.name };
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.monthly_tokens) payload.monthly_tokens = Number(form.monthly_tokens);
if (form.daily_tokens) payload.daily_tokens = Number(form.daily_tokens) * 1000;
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.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 = () => {
sessionStorage.removeItem('admin_password');
setPassword(null);
@@ -229,43 +295,76 @@ function App() {
<section>
<h2>Neuer API-Key</h2>
<form onSubmit={handleCreate} className="create-form">
<input
placeholder="Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
<input
type="date"
placeholder="Ablaufdatum (leer = unbegrenzt)"
value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/>
<input
type="number"
placeholder="Tokens/Tag (leer = unbegrenzt)"
value={form.daily_tokens}
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
/>
<input
type="number"
placeholder="Tokens/Monat"
value={form.monthly_tokens}
onChange={(e) => setForm({ ...form, monthly_tokens: e.target.value })}
/>
<input
type="number"
placeholder="Requests/Tag"
value={form.daily_requests}
onChange={(e) => setForm({ ...form, daily_requests: e.target.value })}
/>
<input
type="number"
placeholder="Requests/Monat"
value={form.monthly_requests}
onChange={(e) => setForm({ ...form, monthly_requests: e.target.value })}
/>
<button type="submit" disabled={creating}>Erstellen</button>
<div className="edit-form">
<label className="create-name">
Name
<small>&nbsp;</small>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
placeholder="z. B. alice"
/>
</label>
<label className="create-date">
Ablaufdatum
<small>leer = unbegrenzt</small>
<input
type="date"
value={form.expires_at}
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
type="number"
min="0"
placeholder="∞"
value={form.daily_tokens}
onChange={(e) => setForm({ ...form, daily_tokens: e.target.value })}
/>
</label>
<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>
{newKey && (
<div className="new-key-box">
@@ -281,41 +380,99 @@ function App() {
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th>Name</th>
<th>Key</th>
<th>Status</th>
<th>Läuft ab</th>
<th>Tokens/Tag</th>
<th>Tokens/Monat</th>
<th>Req/Tag</th>
<th>Req/Monat</th>
<th className="th-quota">Tokens/Tag</th>
<th className="th-quota">Tokens/Monat</th>
<th className="th-quota">Req/Tag</th>
<th className="th-quota">Req/Monat</th>
<th></th>
</tr>
</thead>
<tbody>
{apiKeys.map(key => (
<tr key={key.id}>
<td>{key.id}</td>
<tr key={key.id} className={editKey?.id === key.id ? 'row-editing' : ''}>
<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>{maskKey(key.key)}</td>
<td>{key.is_active ? 'Aktiv' : 'Inaktiv'}</td>
<td>{displayKey(key.key_prefix)}</td>
<td>{key.expires_at ? new Date(key.expires_at).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '∞'}</td>
<td>{key.daily_tokens ?? '∞'}</td>
<td>{key.monthly_tokens ?? '∞'}</td>
<td>{key.daily_requests ?? '∞'}</td>
<td>{key.monthly_requests ?? '∞'}</td>
<td>
{key.is_active && (
<button className="btn-danger" onClick={() => handleDeactivate(key.id)}>
Deaktivieren
</button>
<td><QuotaBar used={key.tokens_used_today} limit={key.daily_tokens} isToken /></td>
<td><QuotaBar used={key.tokens_used_month} limit={key.monthly_tokens} isToken /></td>
<td><QuotaBar used={key.requests_today} limit={key.daily_requests} /></td>
<td><QuotaBar used={key.requests_month} limit={key.monthly_requests} /></td>
<td className="action-cell">
<button className="btn-icon btn-icon-edit" data-tooltip="Bearbeiten" onClick={() => handleEdit(key)}>✏</button>
{key.is_active ? (
<button className="btn-icon btn-icon-warn" data-tooltip="Deaktivieren" onClick={() => handleDeactivate(key.id)}>⏸</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>
</tr>
))}
</tbody>
</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>
</div>
);
@@ -325,4 +482,4 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
);
+223 -21
View File
@@ -92,30 +92,37 @@ tr:hover {
.create-form {
display: flex;
flex-wrap: wrap;
gap: 10px;
flex-direction: column;
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;
}
.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 {
.create-form .btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -218,3 +225,198 @@ tr:hover {
color: #999;
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;
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Dev runner: startet Proxy, Admin-API und Frontend gemeinsam."""
import os
import sys
import signal
import subprocess
import threading
from pathlib import Path
def load_env(path: Path):
if not path.exists():
return
for line in path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, _, val = line.partition('=')
os.environ.setdefault(key.strip(), val.strip().strip('"\''))
def pipe_output(proc: subprocess.Popen, label: str, color: str):
reset = '\033[0m'
prefix = f'\033[{color}m[{label}]{reset} '
for raw in iter(proc.stdout.readline, b''):
sys.stdout.write(prefix + raw.decode(errors='replace'))
sys.stdout.flush()
def main():
root = Path(__file__).parent
load_env(root / '.env')
if not os.environ.get('ADMIN_PASSWORD'):
print('Fehler: ADMIN_PASSWORD nicht gesetzt (siehe .env)')
sys.exit(1)
python = root / '.venv' / 'bin' / 'python'
backend = root / 'backend'
frontend = root / 'frontend'
proxy_host = os.environ.get('PROXY_HOST', '0.0.0.0')
proxy_port = os.environ.get('PROXY_PORT', '8000')
admin_port = os.environ.get('ADMIN_PORT', '8001')
print('Initialisiere Datenbank...')
subprocess.run([str(python), 'init_db.py'], cwd=backend, check=True)
print(f'Starte Proxy → http://{proxy_host}:{proxy_port}')
print(f'Starte Admin-API → http://127.0.0.1:{admin_port}')
print('Starte Frontend → http://localhost:5173')
env = {**os.environ, 'PYTHONUNBUFFERED': '1'}
procs = [
(subprocess.Popen(
[str(python), '-m', 'uvicorn', 'main:app', '--reload',
'--host', proxy_host, '--port', proxy_port],
cwd=backend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
), 'Proxy ', '34'), # blau
(subprocess.Popen(
[str(python), '-m', 'uvicorn', 'admin:app', '--reload',
'--host', '127.0.0.1', '--port', admin_port],
cwd=backend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
), 'Admin ', '33'), # gelb
(subprocess.Popen(
['npm', 'run', 'dev'],
cwd=frontend, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
), 'Frontend', '32'), # grün
]
for proc, label, color in procs:
threading.Thread(target=pipe_output, args=(proc, label, color), daemon=True).start()
def stop(*_):
for p, _, _ in procs:
p.terminate()
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
for p, _, _ in procs:
p.wait()
if __name__ == '__main__':
main()