Files
zmb-webui/backend/routers_aiohttp.py
Claude Code 6d74d874b6 ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE
============
Backend: FastAPI + uvicorn (port 8000)
  - JWT authentication with PAM system users
  - ZFS CLI wrapper with caching (30-60s TTL)
  - WebSocket pool status broadcaster (30s interval)
  - Services: auth, zfs_runner, file_manager, shares, identities, system_info
  - Routers: pools, datasets, snapshots, shares, identities, navigator, system

Frontend: Next.js 15 + TypeScript (static export)
  - Incremental Static Regeneration (ISR) for weak hardware
  - Type-safe API client (lib/api.ts)
  - Dark mode + custom Tailwind theme
  - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc.

DEPLOYMENT
==========
Test Target: 192.168.1.179:8090 (Debian LXC)
Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64)
Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh)

FEATURES COMPLETED
==================
Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage)
  - Real-time stats with color-coded progress bars
  - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns)
  - ISR-optimized for fast loads on weak hardware

REBRANDING
==========
Renamed throughout:
  - Project: 'ZFS Manager' → 'ZMB Webui'
  - Services: 'zfs-manager' → 'zmb-webui'
  - Systemd units: zfs-manager-backend → zmb-webui-backend
  - Configuration files and documentation

Co-Authored-By: Patrick <patrick@perlbach24.de>
2026-04-22 00:43:05 +02:00

383 lines
11 KiB
Python

"""
aiohttp routers for ZMB Webui API
Simplified compared to FastAPI
"""
import json
import logging
from aiohttp import web
from datetime import datetime, timedelta
from services.auth import auth_service
from services.zfs_runner import zfs_runner
logger = logging.getLogger(__name__)
def verify_token(request):
"""Extract and verify JWT token from Authorization header"""
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return None
token = auth_header[7:] # Remove 'Bearer ' prefix
username = auth_service.verify_token(token)
return username
def require_auth(func):
"""Decorator to require authentication"""
async def wrapper(request):
username = verify_token(request)
if not username:
return web.json_response(
{"detail": "Invalid token"},
status=401
)
request['username'] = username
return await func(request)
wrapper.__name__ = func.__name__ # Preserve function name
return wrapper
# ============ AUTH ROUTER ============
class AuthRouter:
@staticmethod
async def login(request):
"""POST /api/auth/login - Login with username/password"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
username = data.get('username')
password = data.get('password')
if not username or not password:
return web.json_response(
{"detail": "Username and password required"},
status=400
)
user = auth_service.authenticate_user(username, password)
if not user:
return web.json_response(
{"detail": "Invalid credentials"},
status=401
)
token = auth_service.create_access_token(username)
return web.json_response({
"access_token": token,
"token_type": "bearer"
})
@staticmethod
async def verify(request):
"""POST /api/auth/verify - Verify token"""
username = verify_token(request)
if not username:
return web.json_response(
{"detail": "Invalid token"},
status=401
)
return web.json_response({
"valid": True,
"username": username
})
auth = AuthRouter()
# ============ POOLS ROUTER ============
class PoolsRouter:
@staticmethod
@require_auth
async def list_pools(request):
"""GET /api/pools - List all pools"""
try:
pools = zfs_runner.list_pools()
return web.json_response(pools)
except Exception as e:
logger.error(f"Error listing pools: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def get_pool_status(request):
"""GET /api/pools/{name} - Get pool status"""
name = request.match_info['name']
try:
status = zfs_runner.get_pool_status(name)
return web.json_response(status)
except Exception as e:
logger.error(f"Error getting pool status: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def start_scrub(request):
"""POST /api/pools/{name}/scrub - Start scrub"""
name = request.match_info['name']
try:
zfs_runner.start_scrub(name)
return web.json_response({"status": "scrub started"})
except Exception as e:
logger.error(f"Error starting scrub: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
pools = PoolsRouter()
# ============ DATASETS ROUTER ============
class DatasetsRouter:
@staticmethod
@require_auth
async def list_datasets(request):
"""GET /api/datasets - List datasets"""
pool = request.query.get('pool', 'tank')
try:
datasets = zfs_runner.list_datasets(pool)
return web.json_response(datasets)
except Exception as e:
logger.error(f"Error listing datasets: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def update_dataset(request):
"""PATCH /api/datasets/{name} - Update dataset properties"""
name = request.match_info['name']
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
try:
zfs_runner.set_dataset_properties(name, data)
return web.json_response({"status": "updated"})
except Exception as e:
logger.error(f"Error updating dataset: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
datasets = DatasetsRouter()
# ============ SNAPSHOTS ROUTER ============
class SnapshotsRouter:
@staticmethod
@require_auth
async def list_snapshots(request):
"""GET /api/snapshots - List snapshots"""
dataset = request.query.get('dataset')
limit = int(request.query.get('limit', 50))
try:
snapshots = zfs_runner.list_snapshots(dataset, limit)
return web.json_response(snapshots)
except Exception as e:
logger.error(f"Error listing snapshots: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def create_snapshot(request):
"""POST /api/snapshots - Create snapshot"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
dataset = data.get('dataset')
snapshot_name = data.get('snapshot_name')
if not dataset or not snapshot_name:
return web.json_response(
{"detail": "dataset and snapshot_name required"},
status=400
)
try:
zfs_runner.create_snapshot(dataset, snapshot_name)
return web.json_response({"status": "created"})
except Exception as e:
logger.error(f"Error creating snapshot: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def rollback_snapshot(request):
"""POST /api/snapshots/rollback - Rollback to snapshot"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"detail": "Invalid JSON"},
status=400
)
snapshot = data.get('snapshot')
if not snapshot:
return web.json_response(
{"detail": "snapshot required"},
status=400
)
try:
zfs_runner.rollback_snapshot(snapshot)
return web.json_response({"status": "rolled back"})
except Exception as e:
logger.error(f"Error rolling back snapshot: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def delete_snapshot(request):
"""DELETE /api/snapshots/{name} - Delete snapshot"""
name = request.match_info['name']
try:
zfs_runner.delete_snapshot(name)
return web.json_response({"status": "deleted"})
except Exception as e:
logger.error(f"Error deleting snapshot: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
snapshots = SnapshotsRouter()
# ============ FILES ROUTER ============
class FilesRouter:
@staticmethod
@require_auth
async def browse(request):
"""GET /api/files/browse - Browse directory"""
path = request.query.get('path', '/')
try:
items = zfs_runner.browse_directory(path)
return web.json_response(items)
except Exception as e:
logger.error(f"Error browsing directory: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
@staticmethod
@require_auth
async def read(request):
"""GET /api/files/read - Read file"""
path = request.query.get('path')
if not path:
return web.json_response(
{"detail": "path required"},
status=400
)
try:
content = zfs_runner.read_file(path)
return web.Response(text=content, content_type='text/plain')
except Exception as e:
logger.error(f"Error reading file: {e}")
return web.json_response(
{"detail": str(e)},
status=500
)
files = FilesRouter()
# ============ SHARES ROUTER ============
class SharesRouter:
@staticmethod
@require_auth
async def list_samba_shares(request):
"""GET /api/shares/samba - List Samba shares"""
# TODO: Implement
return web.json_response([])
@staticmethod
@require_auth
async def list_nfs_shares(request):
"""GET /api/shares/nfs - List NFS shares"""
# TODO: Implement
return web.json_response([])
shares = SharesRouter()
def setup_all_routes(app):
"""Setup all routes"""
# Auth routes
app.router.add_post('/api/auth/login', auth.login)
app.router.add_post('/api/auth/verify', auth.verify)
# Pool routes
app.router.add_get('/api/pools', pools.list_pools)
app.router.add_get('/api/pools/{name}', pools.get_pool_status)
app.router.add_post('/api/pools/{name}/scrub', pools.start_scrub)
# Dataset routes
app.router.add_get('/api/datasets', datasets.list_datasets)
app.router.add_patch('/api/datasets/{name}', datasets.update_dataset)
# Snapshot routes
app.router.add_get('/api/snapshots', snapshots.list_snapshots)
app.router.add_post('/api/snapshots', snapshots.create_snapshot)
app.router.add_post('/api/snapshots/rollback', snapshots.rollback_snapshot)
app.router.add_delete('/api/snapshots/{name}', snapshots.delete_snapshot)
# File routes
app.router.add_get('/api/files/browse', files.browse)
app.router.add_get('/api/files/read', files.read)
# Share routes
app.router.add_get('/api/shares/samba', shares.list_samba_shares)
app.router.add_get('/api/shares/nfs', shares.list_nfs_shares)