Initial commit: Whisper API with FastAPI, GPU support and Admin Dashboard

This commit is contained in:
Dominic Ballenthin
2026-01-28 23:16:44 +01:00
commit 008ef63bfd
28 changed files with 1871 additions and 0 deletions

0
src/__init__.py Normal file
View File

0
src/api/__init__.py Normal file
View File

43
src/api/health.py Normal file
View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter
import torch
import psutil
from src.config import settings
from src.services.whisper_service import get_model_info
router = APIRouter()
@router.get("/health")
async def health_check():
"""Health check endpoint with GPU status"""
# Get GPU info if available
gpu_info = {"available": False}
if torch.cuda.is_available():
gpu_name = torch.cuda.get_device_name(0)
vram_total = torch.cuda.get_device_properties(0).total_memory / (1024**3)
vram_allocated = torch.cuda.memory_allocated(0) / (1024**3)
gpu_info = {
"available": True,
"name": gpu_name,
"vram_total_gb": round(vram_total, 2),
"vram_used_gb": round(vram_allocated, 2),
"vram_free_gb": round(vram_total - vram_allocated, 2)
}
# Get system info
memory = psutil.virtual_memory()
return {
"status": "healthy",
"version": "1.0.0",
"model": settings.whisper_model,
"gpu": gpu_info,
"system": {
"cpu_percent": psutil.cpu_percent(interval=1),
"memory_percent": memory.percent,
"memory_available_gb": round(memory.available / (1024**3), 2)
}
}

160
src/api/transcriptions.py Normal file
View File

@@ -0,0 +1,160 @@
from fastapi import APIRouter, File, UploadFile, Form, HTTPException, Depends, Header
from fastapi.responses import JSONResponse
from typing import Optional, List
import time
import os
import hashlib
from src.config import settings
from src.services.whisper_service import transcribe_audio
from src.services.stats_service import log_usage
from src.database.db import get_db
from sqlalchemy.orm import Session
router = APIRouter()
def verify_api_key(authorization: Optional[str] = Header(None)):
"""Verify API key from Authorization header"""
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header missing")
# Extract Bearer token
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid authorization format")
api_key = authorization.replace("Bearer ", "").strip()
valid_keys = settings.get_api_keys_list()
if not valid_keys:
raise HTTPException(status_code=500, detail="No API keys configured")
if api_key not in valid_keys:
raise HTTPException(status_code=401, detail="Invalid API key")
return api_key
@router.get("/models")
async def list_models(api_key: str = Depends(verify_api_key)):
"""List available models (OpenAI compatible)"""
return {
"data": [
{
"id": "whisper-1",
"object": "model",
"created": 1677532384,
"owned_by": "openai"
},
{
"id": "large-v3",
"object": "model",
"created": 1698796800,
"owned_by": "openai"
}
]
}
@router.post("/audio/transcriptions")
async def create_transcription(
file: UploadFile = File(...),
model: str = Form("whisper-1"),
language: Optional[str] = Form(None),
prompt: Optional[str] = Form(None),
response_format: str = Form("json"),
temperature: float = Form(0.0),
timestamp_granularities: Optional[List[str]] = Form(None),
api_key: str = Depends(verify_api_key),
db: Session = Depends(get_db)
):
"""
Transcribe audio file (OpenAI compatible endpoint)
- **file**: Audio file (mp3, mp4, mpeg, mpga, m4a, wav, webm)
- **model**: Model ID (whisper-1 or large-v3)
- **language**: Language code (e.g., 'de', 'en')
- **response_format**: json, text, srt, verbose_json, vtt
- **timestamp_granularities**: word, segment (for verbose_json)
"""
start_time = time.time()
temp_path = None
try:
# Validate file type
allowed_extensions = {'.mp3', '.mp4', '.mpeg', '.mpga', '.m4a', '.wav', '.webm'}
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Unsupported file format: {file_ext}"
)
# Save uploaded file
temp_filename = f"{api_key[:8]}_{int(time.time())}_{file.filename}"
temp_path = os.path.join(settings.uploads_path, temp_filename)
with open(temp_path, "wb") as f:
content = await file.read()
f.write(content)
file_size = len(content)
# Transcribe
include_word_timestamps = timestamp_granularities and "word" in timestamp_granularities
result = await transcribe_audio(
audio_path=temp_path,
language=language,
include_word_timestamps=include_word_timestamps
)
processing_time = int((time.time() - start_time) * 1000)
# Log usage
await log_usage(
db=db,
api_key=api_key,
endpoint="/v1/audio/transcriptions",
file_size=file_size,
duration=result.get("duration"),
processing_time=processing_time,
model=settings.whisper_model,
status="success"
)
# Format response based on requested format
if response_format == "text":
return result["text"]
elif response_format == "verbose_json":
return result
else:
return {"text": result["text"]}
except Exception as e:
processing_time = int((time.time() - start_time) * 1000)
# Log error
await log_usage(
db=db,
api_key=api_key,
endpoint="/v1/audio/transcriptions",
file_size=file_size if 'file_size' in locals() else None,
duration=None,
processing_time=processing_time,
model=settings.whisper_model,
status="error",
error_message=str(e)
)
raise HTTPException(status_code=500, detail=str(e))
finally:
# Cleanup temp file
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass

51
src/config.py Normal file
View File

@@ -0,0 +1,51 @@
import os
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
# Server
port: int = 8000
host: str = "0.0.0.0"
# Whisper
whisper_model: str = "large-v3"
whisper_device: str = "cuda"
whisper_compute_type: str = "float16"
# Auth
api_keys: str = ""
admin_user: str = "admin"
admin_password: str = "-whisper12510-"
# Database
database_url: str = "sqlite:///app/data/whisper_api.db"
# Retention
log_retention_days: int = 30
# Paths
models_path: str = "/app/models"
uploads_path: str = "/app/uploads"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
env_prefix = ""
case_sensitive = False
extra = "ignore"
def get_api_keys_list(self):
"""Get list of valid API keys"""
if not self.api_keys:
return []
return [key.strip() for key in self.api_keys.split(",") if key.strip()]
@lru_cache()
def get_settings():
return Settings()
settings = get_settings()

32
src/database/__init__.py Normal file
View File

@@ -0,0 +1,32 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from src.config import settings
# Create engine
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {}
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
async def init_db():
"""Initialize database tables"""
from src.database import models
Base.metadata.create_all(bind=engine)
def get_db():
"""Dependency to get database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()

32
src/database/db.py Normal file
View File

@@ -0,0 +1,32 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from src.config import settings
# Create engine
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {}
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
async def init_db():
"""Initialize database tables"""
from src.database import models
Base.metadata.create_all(bind=engine)
def get_db():
"""Dependency to get database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()

30
src/database/models.py Normal file
View File

@@ -0,0 +1,30 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Float, Text
from sqlalchemy.sql import func
from src.database.db import Base
class ApiKey(Base):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
key_hash = Column(String(64), unique=True, index=True)
description = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_used_at = Column(DateTime(timezone=True), nullable=True)
is_active = Column(Boolean, default=True)
usage_count = Column(Integer, default=0)
class UsageLog(Base):
__tablename__ = "usage_logs"
id = Column(Integer, primary_key=True, index=True)
api_key_id = Column(Integer, nullable=True)
endpoint = Column(String(100))
file_size_bytes = Column(Integer, nullable=True)
duration_seconds = Column(Float, nullable=True)
processing_time_ms = Column(Integer)
model_used = Column(String(50))
status = Column(String(20)) # success, error
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())

49
src/main.py Normal file
View File

@@ -0,0 +1,49 @@
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from contextlib import asynccontextmanager
import os
from src.api import transcriptions, health
from src.web import routes as web_routes
from src.database.db import init_db
from src.services.cleanup_service import start_cleanup_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager"""
# Startup
await init_db()
start_cleanup_scheduler()
yield
# Shutdown
pass
app = FastAPI(
title="Whisper API",
description="Local Whisper API with GPU acceleration",
version="1.0.0",
lifespan=lifespan
)
# Mount static files
app.mount("/static", StaticFiles(directory="src/web/static"), name="static")
# Include API routes
app.include_router(health.router, tags=["Health"])
app.include_router(transcriptions.router, prefix="/v1", tags=["Transcriptions"])
# Include web routes
app.include_router(web_routes.router, prefix="/admin", tags=["Admin"])
# Root redirect to admin
@app.get("/")
async def root():
return {"message": "Whisper API is running", "admin": "/admin", "docs": "/docs"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

0
src/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,44 @@
import asyncio
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from src.config import settings
from src.database.db import SessionLocal
from src.services.stats_service import cleanup_old_logs
scheduler = None
async def cleanup_job():
"""Daily cleanup job"""
print(f"[{datetime.now()}] Running cleanup job...")
db = SessionLocal()
try:
deleted = await cleanup_old_logs(db, settings.log_retention_days)
print(f"[{datetime.now()}] Cleaned up {deleted} old log entries")
finally:
db.close()
def start_cleanup_scheduler():
"""Start the background cleanup scheduler"""
global scheduler
scheduler = AsyncIOScheduler()
# Run cleanup daily at 3 AM
scheduler.add_job(
cleanup_job,
trigger=CronTrigger(hour=3, minute=0),
id="cleanup_logs",
replace_existing=True
)
scheduler.start()
print("Cleanup scheduler started (runs daily at 3:00 AM)")
def stop_cleanup_scheduler():
"""Stop the scheduler"""
if scheduler:
scheduler.shutdown()

View File

@@ -0,0 +1,112 @@
import hashlib
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import func
from src.database.models import ApiKey, UsageLog
def hash_api_key(key: str) -> str:
"""Hash API key for storage"""
return hashlib.sha256(key.encode()).hexdigest()
async def log_usage(
db: Session,
api_key: str,
endpoint: str,
file_size: Optional[int],
duration: Optional[float],
processing_time: int,
model: str,
status: str,
error_message: Optional[str] = None
):
"""Log API usage"""
key_hash = hash_api_key(api_key)
# Find or create API key record
db_key = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
if not db_key:
db_key = ApiKey(
key_hash=key_hash,
description=f"Auto-created for key {api_key[:8]}..."
)
db.add(db_key)
db.commit()
db.refresh(db_key)
# Update last used
db_key.last_used_at = datetime.utcnow()
db_key.usage_count += 1
# Create usage log
log = UsageLog(
api_key_id=db_key.id,
endpoint=endpoint,
file_size_bytes=file_size,
duration_seconds=duration,
processing_time_ms=processing_time,
model_used=model,
status=status,
error_message=error_message
)
db.add(log)
db.commit()
async def get_usage_stats(db: Session, days: int = 30):
"""Get usage statistics for dashboard"""
since = datetime.utcnow() - timedelta(days=days)
# Total requests
total_requests = db.query(UsageLog).filter(UsageLog.created_at >= since).count()
# Success rate
success_count = db.query(UsageLog).filter(
UsageLog.created_at >= since,
UsageLog.status == "success"
).count()
success_rate = (success_count / total_requests * 100) if total_requests > 0 else 0
# Average processing time
avg_time = db.query(func.avg(UsageLog.processing_time_ms)).filter(
UsageLog.created_at >= since,
UsageLog.status == "success"
).scalar() or 0
# Daily stats for chart
daily_stats = db.query(
func.date(UsageLog.created_at).label("date"),
func.count().label("count"),
func.avg(UsageLog.processing_time_ms).label("avg_time")
).filter(
UsageLog.created_at >= since
).group_by(
func.date(UsageLog.created_at)
).order_by("date").all()
# Recent logs
recent_logs = db.query(UsageLog).order_by(
UsageLog.created_at.desc()
).limit(50).all()
return {
"total_requests": total_requests,
"success_rate": round(success_rate, 2),
"avg_processing_time_ms": round(avg_time, 2),
"daily_stats": [
{"date": str(stat.date), "count": stat.count, "avg_time": round(stat.avg_time or 0, 2)}
for stat in daily_stats
],
"recent_logs": recent_logs
}
async def cleanup_old_logs(db: Session, retention_days: int):
"""Delete logs older than retention period"""
cutoff = datetime.utcnow() - timedelta(days=retention_days)
deleted = db.query(UsageLog).filter(UsageLog.created_at < cutoff).delete()
db.commit()
return deleted

View File

@@ -0,0 +1,109 @@
import whisper
import torch
import os
from typing import Optional, Dict, Any
import asyncio
from concurrent.futures import ThreadPoolExecutor
from src.config import settings
# Global model cache
_model = None
_executor = ThreadPoolExecutor(max_workers=1)
def load_model():
"""Load Whisper model"""
global _model
if _model is None:
print(f"Loading Whisper model: {settings.whisper_model}")
_model = whisper.load_model(
settings.whisper_model,
device=settings.whisper_device,
download_root=settings.models_path
)
print(f"Model loaded on {settings.whisper_device}")
return _model
def get_model_info():
"""Get model information"""
model = load_model()
return {
"name": settings.whisper_model,
"device": settings.whisper_device,
"loaded": _model is not None
}
def _transcribe_sync(
audio_path: str,
language: Optional[str] = None,
include_word_timestamps: bool = False
) -> Dict[str, Any]:
"""Synchronous transcription (runs in thread pool)"""
model = load_model()
# Set decode options
decode_options = {}
if language:
decode_options["language"] = language
# Transcribe
result = model.transcribe(
audio_path,
**decode_options,
word_timestamps=include_word_timestamps
)
# Format result
output = {
"text": result["text"].strip(),
"task": "transcribe",
"language": result.get("language", "unknown"),
"duration": result.get("duration", 0)
}
# Add segments if available
if "segments" in result:
segments = []
for segment in result["segments"]:
seg_data = {
"id": segment["id"],
"start": segment["start"],
"end": segment["end"],
"text": segment["text"].strip()
}
# Add word timestamps if requested
if include_word_timestamps and "words" in segment:
seg_data["words"] = [
{
"word": word["word"].strip(),
"start": word["start"],
"end": word["end"]
}
for word in segment["words"]
]
segments.append(seg_data)
output["segments"] = segments
return output
async def transcribe_audio(
audio_path: str,
language: Optional[str] = None,
include_word_timestamps: bool = False
) -> Dict[str, Any]:
"""Async wrapper for transcription"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
_executor,
_transcribe_sync,
audio_path,
language,
include_word_timestamps
)

229
src/templates/base.html Normal file
View File

@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Whisper API Admin{% endblock %}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
margin-bottom: 30px;
border-radius: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 24px;
}
.nav {
display: flex;
gap: 10px;
}
.nav a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 5px;
transition: background 0.3s;
}
.nav a:hover, .nav a.active {
background: rgba(255,255,255,0.2);
}
.card {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.card h2 {
margin-bottom: 20px;
color: #667eea;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 10px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 36px;
font-weight: bold;
color: #667eea;
}
.stat-label {
color: #666;
margin-top: 5px;
}
.btn {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.3s;
}
.btn:hover {
background: #5568d3;
}
.btn-success {
background: #48bb78;
}
.btn-success:hover {
background: #38a169;
}
.btn-danger {
background: #f56565;
}
.btn-danger:hover {
background: #e53e3e;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
th {
background: #f7fafc;
font-weight: 600;
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.badge-success {
background: #c6f6d5;
color: #22543d;
}
.badge-danger {
background: #fed7d7;
color: #742a2a;
}
.alert {
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.alert-success {
background: #c6f6d5;
color: #22543d;
border: 1px solid #9ae6b4;
}
.alert-danger {
background: #fed7d7;
color: #742a2a;
border: 1px solid #fc8181;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 5px;
font-size: 14px;
}
.api-key-display {
background: #f7fafc;
padding: 15px;
border-radius: 5px;
font-family: monospace;
font-size: 14px;
margin: 10px 0;
word-break: break-all;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
text-align: center;
gap: 15px;
}
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block title %}Dashboard - Whisper API Admin{% endblock %}
{% block content %}
<div class="container">
<div class="header">
<h1>🎯 Whisper API Dashboard</h1>
<div class="nav">
<a href="/admin" class="active">Dashboard</a>
<a href="/admin/keys">API Keys</a>
<a href="/admin/logout">Logout</a>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_requests }}</div>
<div class="stat-label">Requests (30 Tage)</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.success_rate }}%</div>
<div class="stat-label">Success Rate</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.avg_processing_time_ms }}ms</div>
<div class="stat-label">Ø Processing Time</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ model }}</div>
<div class="stat-label">Model</div>
</div>
</div>
<div class="card">
<h2>📊 Usage Chart (Last 30 Days)</h2>
<canvas id="usageChart" height="100"></canvas>
</div>
<div class="card">
<h2>📝 Recent Activity</h2>
<table>
<thead>
<tr>
<th>Time</th>
<th>Endpoint</th>
<th>Duration</th>
<th>Status</th>
<th>Processing</th>
</tr>
</thead>
<tbody>
{% for log in stats.recent_logs[:10] %}
<tr>
<td>{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ log.endpoint }}</td>
<td>
{% if log.duration_seconds %}
{{ "%.1f"|format(log.duration_seconds) }}s
{% else %}
-
{% endif %}
</td>
<td>
<span class="badge badge-{{ 'success' if log.status == 'success' else 'danger' }}">
{{ log.status }}
</span>
</td>
<td>{{ log.processing_time_ms }}ms</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2> System Info</h2>
<p><strong>Model:</strong> {{ model }}</p>
<p><strong>Log Retention:</strong> {{ retention_days }} days</p>
<p><strong>API Version:</strong> v1.0.0</p>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const ctx = document.getElementById('usageChart').getContext('2d');
const dailyStats = {{ stats.daily_stats | tojson }};
new Chart(ctx, {
type: 'line',
data: {
labels: dailyStats.map(s => s.date),
datasets: [{
label: 'Requests',
data: dailyStats.map(s => s.count),
backgroundColor: 'rgba(102, 126, 234, 0.2)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
tension: 0.4
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
</script>
{% endblock %}

110
src/templates/keys.html Normal file
View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}API Keys - Whisper API Admin{% endblock %}
{% block content %}
<div class="container">
<div class="header">
<h1>🔐 API Key Management</h1>
<div class="nav">
<a href="/admin">Dashboard</a>
<a href="/admin/keys" class="active">API Keys</a>
<a href="/admin/logout">Logout</a>
</div>
</div>
{% if message %}
<div class="alert alert-success">
{{ message }}
</div>
{% endif %}
{% if new_key %}
<div class="card" style="background: #c6f6d5; border: 2px solid #48bb78;">
<h2>✨ New API Key Generated</h2>
<p>Copy this key now - it will not be shown again!</p>
<div class="api-key-display">{{ new_key }}</div>
<button onclick="copyToClipboard('{{ new_key }}')" class="btn">Copy to Clipboard</button>
</div>
{% endif %}
<div class="card">
<h2>Create New API Key</h2>
<form method="POST" action="/admin/keys/create">
<div class="form-group">
<label for="description">Description (optional)</label>
<input type="text" id="description" name="description" placeholder="e.g., Clawdbot Integration">
</div>
<button type="submit" class="btn btn-success">Generate New API Key</button>
</form>
</div>
<div class="card">
<h2>Active API Keys</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Description</th>
<th>Created</th>
<th>Last Used</th>
<th>Usage Count</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for key in keys %}
<tr>
<td>{{ key.id }}</td>
<td>{{ key.description or '-' }}</td>
<td>{{ key.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
{% if key.last_used_at %}
{{ key.last_used_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
Never
{% endif %}
</td>
<td>{{ key.usage_count }}</td>
<td>
<span class="badge badge-{{ 'success' if key.is_active else 'danger' }}">
{{ 'Active' if key.is_active else 'Inactive' }}
</span>
</td>
<td>
<form method="POST" action="/admin/keys/{{ key.id }}/toggle" style="display: inline;">
<button type="submit" class="btn" style="padding: 5px 10px; font-size: 12px;">
{{ 'Deactivate' if key.is_active else 'Activate' }}
</button>
</form>
<form method="POST" action="/admin/keys/{{ key.id }}/delete" style="display: inline; margin-left: 5px;"
onsubmit="return confirm('Are you sure you want to delete this API key?');">
<button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not keys %}
<p style="text-align: center; color: #666; margin-top: 20px;">
No API keys found. Create one above.
</p>
{% endif %}
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
alert('API Key copied to clipboard!');
}, function(err) {
console.error('Could not copy text: ', err);
});
}
</script>
{% endblock %}

39
src/templates/login.html Normal file
View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Login - Whisper API Admin{% endblock %}
{% block content %}
<div class="container">
<div style="max-width: 400px; margin: 100px auto;">
<div class="card">
<h2 style="text-align: center; margin-bottom: 30px;">🔑 Admin Login</h2>
{% if error %}
<div class="alert alert-danger">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/admin/login">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 10px;">
Anmelden
</button>
</form>
<p style="text-align: center; margin-top: 20px; color: #666; font-size: 12px;">
Default: admin / -whisper12510-
</p>
</div>
</div>
</div>
{% endblock %}

0
src/web/__init__.py Normal file
View File

187
src/web/routes.py Normal file
View File

@@ -0,0 +1,187 @@
import uuid
import hashlib
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Request, Form, HTTPException, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from src.config import settings
from src.database.db import get_db
from src.database.models import ApiKey, UsageLog
from src.services.stats_service import get_usage_stats, hash_api_key
router = APIRouter()
templates = Jinja2Templates(directory="src/templates")
# Simple session store (in production, use Redis or proper session management)
active_sessions = {}
def generate_api_key():
"""Generate a new API key"""
return f"sk-{uuid.uuid4().hex}"
def check_admin_auth(request: Request):
"""Check if user is authenticated as admin"""
session_token = request.cookies.get("admin_session")
if not session_token or session_token not in active_sessions:
raise HTTPException(status_code=302, detail="/admin/login")
return True
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None):
"""Show login page"""
return templates.TemplateResponse("login.html", {
"request": request,
"error": error
})
@router.post("/login")
async def login(
request: Request,
username: str = Form(...),
password: str = Form(...)
):
"""Process login"""
if username == settings.admin_user and password == settings.admin_password:
session_token = uuid.uuid4().hex
active_sessions[session_token] = {
"username": username,
"login_time": datetime.utcnow()
}
response = RedirectResponse(url="/admin", status_code=302)
response.set_cookie(
key="admin_session",
value=session_token,
httponly=True,
max_age=86400 # 24 hours
)
return response
return templates.TemplateResponse("login.html", {
"request": request,
"error": "Invalid username or password"
})
@router.get("/logout")
async def logout(request: Request):
"""Logout user"""
session_token = request.cookies.get("admin_session")
if session_token in active_sessions:
del active_sessions[session_token]
response = RedirectResponse(url="/admin/login", status_code=302)
response.delete_cookie("admin_session")
return response
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db)):
"""Admin dashboard"""
try:
check_admin_auth(request)
except HTTPException as e:
return RedirectResponse(url="/admin/login", status_code=302)
stats = await get_usage_stats(db, days=30)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"stats": stats,
"model": settings.whisper_model,
"retention_days": settings.log_retention_days
})
@router.get("/keys", response_class=HTMLResponse)
async def manage_keys(request: Request, db: Session = Depends(get_db)):
"""API key management page"""
try:
check_admin_auth(request)
except HTTPException as e:
return RedirectResponse(url="/admin/login", status_code=302)
keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
return templates.TemplateResponse("keys.html", {
"request": request,
"keys": keys
})
@router.post("/keys/create")
async def create_key(
request: Request,
description: str = Form(""),
db: Session = Depends(get_db)
):
"""Create new API key"""
try:
check_admin_auth(request)
except HTTPException as e:
return RedirectResponse(url="/admin/login", status_code=302)
new_key = generate_api_key()
key_hash = hash_api_key(new_key)
api_key = ApiKey(
key_hash=key_hash,
description=description or f"Created {datetime.now().strftime('%Y-%m-%d %H:%M')}"
)
db.add(api_key)
db.commit()
# Store the plain key temporarily to show to user (only once)
return templates.TemplateResponse("keys.html", {
"request": request,
"keys": db.query(ApiKey).order_by(ApiKey.created_at.desc()).all(),
"new_key": new_key,
"message": "API Key created successfully! Copy it now - it won't be shown again."
})
@router.post("/keys/{key_id}/toggle")
async def toggle_key(
request: Request,
key_id: int,
db: Session = Depends(get_db)
):
"""Toggle API key active status"""
try:
check_admin_auth(request)
except HTTPException as e:
return RedirectResponse(url="/admin/login", status_code=302)
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if api_key:
api_key.is_active = not api_key.is_active
db.commit()
return RedirectResponse(url="/admin/keys", status_code=302)
@router.post("/keys/{key_id}/delete")
async def delete_key(
request: Request,
key_id: int,
db: Session = Depends(get_db)
):
"""Delete API key"""
try:
check_admin_auth(request)
except HTTPException as e:
return RedirectResponse(url="/admin/login", status_code=302)
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if api_key:
db.delete(api_key)
db.commit()
return RedirectResponse(url="/admin/keys", status_code=302)