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:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 92bed208e0
108 changed files with 29925 additions and 0 deletions
+382
View File
@@ -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)