92bed208e0
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>
383 lines
11 KiB
Python
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)
|