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,240 @@
|
||||
"""
|
||||
ZMB Webui API - aiohttp version (Low-RAM)
|
||||
Single service: API + Static Files + WebSocket
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
from aiohttp import web
|
||||
from aiohttp.web_runner import AppRunner, TCPSite
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from services.zfs_runner import zfs_runner
|
||||
from services.auth import auth_service
|
||||
from routers_aiohttp import setup_all_routes
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WebSocket clients for broadcasting
|
||||
ws_clients: Set[web.WebSocketResponse] = set()
|
||||
|
||||
|
||||
async def ws_broadcast(data: dict):
|
||||
"""Broadcast message to all connected WebSocket clients"""
|
||||
message = json.dumps(data)
|
||||
dead_clients = set()
|
||||
|
||||
for ws in ws_clients:
|
||||
try:
|
||||
if not ws.is_closed():
|
||||
await ws.send_str(message)
|
||||
else:
|
||||
dead_clients.add(ws)
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket broadcast error: {e}")
|
||||
dead_clients.add(ws)
|
||||
|
||||
# Clean up dead connections
|
||||
for ws in dead_clients:
|
||||
ws_clients.discard(ws)
|
||||
|
||||
|
||||
async def pool_status_broadcaster():
|
||||
"""Background task: broadcast pool status every 30 seconds"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(30)
|
||||
pools = zfs_runner.list_pools()
|
||||
if pools:
|
||||
await ws_broadcast({
|
||||
"type": "pool_status",
|
||||
"pools": pools
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Pool status broadcast error: {e}")
|
||||
|
||||
|
||||
async def startup(app):
|
||||
"""Startup handler"""
|
||||
logger.info("Starting ZMB Webui API (aiohttp)")
|
||||
app['broadcaster_task'] = asyncio.create_task(pool_status_broadcaster())
|
||||
|
||||
|
||||
async def shutdown(app):
|
||||
"""Shutdown handler"""
|
||||
logger.info("Shutting down ZMB Webui API")
|
||||
if 'broadcaster_task' in app:
|
||||
app['broadcaster_task'].cancel()
|
||||
|
||||
# Close all WebSocket connections
|
||||
for ws in ws_clients:
|
||||
await ws.close()
|
||||
|
||||
|
||||
async def handle_websocket(request):
|
||||
"""WebSocket endpoint for live pool updates"""
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
ws_clients.add(ws)
|
||||
|
||||
try:
|
||||
# Send initial pool status
|
||||
pools = zfs_runner.list_pools()
|
||||
await ws.send_json({
|
||||
"type": "initial",
|
||||
"pools": pools
|
||||
})
|
||||
|
||||
# Keep connection open
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
# Echo back (optional)
|
||||
pass
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
logger.error(f'WebSocket error: {ws.exception()}')
|
||||
finally:
|
||||
ws_clients.discard(ws)
|
||||
await ws.close()
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
async def handle_status(request):
|
||||
"""System status endpoint (no auth required)"""
|
||||
# Check if ZFS is available
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(["zpool", "list"], capture_output=True, timeout=5)
|
||||
zfs_available = result.returncode == 0
|
||||
except Exception:
|
||||
zfs_available = False
|
||||
|
||||
return web.json_response({
|
||||
"status": "healthy",
|
||||
"zfs_available": zfs_available,
|
||||
"version": "1.0.0"
|
||||
})
|
||||
|
||||
|
||||
async def handle_health(request):
|
||||
"""Health check endpoint"""
|
||||
return web.json_response({
|
||||
"status": "healthy",
|
||||
"version": "1.0.0"
|
||||
})
|
||||
|
||||
|
||||
async def handle_static(request):
|
||||
"""Serve static files (HTML, JS, CSS)"""
|
||||
path = request.match_info['path']
|
||||
|
||||
# Security: prevent directory traversal
|
||||
if '..' in path or path.startswith('/'):
|
||||
return web.Response(status=400, text="Invalid path")
|
||||
|
||||
# Try to find file in frontend/out directory
|
||||
static_dir = Path(__file__).parent.parent / "frontend" / "out"
|
||||
file_path = static_dir / path
|
||||
|
||||
# Ensure file is within static_dir
|
||||
try:
|
||||
file_path.resolve().relative_to(static_dir.resolve())
|
||||
except ValueError:
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return web.FileResponse(file_path)
|
||||
|
||||
# If requesting a directory or file not found, try index.html (SPA routing)
|
||||
index_path = static_dir / "index.html"
|
||||
if index_path.exists():
|
||||
return web.FileResponse(index_path)
|
||||
|
||||
return web.Response(status=404, text="Not found")
|
||||
|
||||
|
||||
async def handle_root(request):
|
||||
"""Root endpoint - serve index.html"""
|
||||
static_dir = Path(__file__).parent.parent / "frontend" / "out"
|
||||
index_path = static_dir / "index.html"
|
||||
|
||||
if index_path.exists():
|
||||
return web.FileResponse(index_path)
|
||||
|
||||
return web.json_response({
|
||||
"name": "ZMB Webui API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/api/docs",
|
||||
"login": "/login"
|
||||
})
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the aiohttp application"""
|
||||
app = web.Application()
|
||||
|
||||
# Store WebSocket clients and broadcaster task
|
||||
app['ws_clients'] = ws_clients
|
||||
|
||||
# Startup/Shutdown handlers
|
||||
app.on_startup.append(startup)
|
||||
app.on_shutdown.append(shutdown)
|
||||
|
||||
# Routes
|
||||
app.router.add_get('/health', handle_health)
|
||||
app.router.add_get('/api/status', handle_status)
|
||||
app.router.add_get('/ws', handle_websocket)
|
||||
|
||||
# API Routes (all routers)
|
||||
setup_all_routes(app)
|
||||
|
||||
# Static file serving (must be last - catch-all)
|
||||
app.router.add_get('/{path_info:.*}', handle_static)
|
||||
app.router.add_get('/', handle_root)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
app = create_app()
|
||||
runner = AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
# Start server on port 8000
|
||||
site = TCPSite(runner, '0.0.0.0', 8000)
|
||||
await site.start()
|
||||
|
||||
logger.info("ZMB Webui API listening on http://0.0.0.0:8000")
|
||||
logger.info("Available endpoints:")
|
||||
logger.info(" GET /health - Health check")
|
||||
logger.info(" GET /api/status - System status (ZFS available)")
|
||||
logger.info(" POST /api/auth/login - Login with username/password")
|
||||
logger.info(" GET /api/pools - List all pools")
|
||||
logger.info(" GET /api/datasets - List datasets")
|
||||
logger.info(" GET /api/snapshots - List snapshots")
|
||||
logger.info(" GET /ws - WebSocket (live updates)")
|
||||
logger.info(" GET /* (except /api) - Static files (HTML/CSS/JS)")
|
||||
logger.info("")
|
||||
|
||||
# Keep running
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user