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>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user