Files
zmb-webui/backend/main.py
T
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

202 lines
6.1 KiB
Python

"""
ZMB Webui API
FastAPI backend for ZFS pool management
"""
import asyncio
import json
import logging
import sys
from pathlib import Path
from typing import Set
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
# Add backend to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from routers import auth, pools, datasets, snapshots, navigator, identities, shares, system
from services.zfs_runner import zfs_runner
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="ZMB Webui API",
description="API for managing ZFS pools, datasets, and snapshots",
version="1.0.0"
)
# CORS middleware (adjust origins for production!)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Change to specific origins in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Connected WebSocket clients
ws_clients: Set[WebSocket] = set()
async def ws_broadcast(message: dict):
"""Broadcast JSON message to all connected WebSocket clients"""
if not ws_clients:
return
dead = set()
data = json.dumps(message)
for ws in ws_clients:
try:
await ws.send_text(data)
except Exception:
dead.add(ws)
ws_clients.difference_update(dead)
async def pool_status_broadcaster():
"""Background task: broadcast pool status every 30s"""
while True:
await asyncio.sleep(30)
try:
pools_data = zfs_runner.list_pools()
if pools_data:
await ws_broadcast({"type": "pool_status", "data": pools_data})
except Exception as e:
logger.warning(f"WS broadcaster error: {e}")
@app.on_event("startup")
async def startup_event():
asyncio.create_task(pool_status_broadcaster())
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
ws_clients.add(websocket)
try:
# Send initial pool status immediately on connect
try:
pools_data = zfs_runner.list_pools()
await websocket.send_text(json.dumps({"type": "pool_status", "data": pools_data}))
except Exception:
pass
# Keep alive
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
ws_clients.discard(websocket)
# Include routers (must be before static files mounting)
app.include_router(auth.router)
app.include_router(pools.router)
app.include_router(datasets.router)
app.include_router(snapshots.router)
app.include_router(navigator.router)
app.include_router(identities.router)
app.include_router(shares.router)
app.include_router(system.router)
# Health check endpoint (no auth required)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "version": "1.0.0"}
# Status endpoint - check if ZFS is available (no auth required)
@app.get("/api/status")
async def status_check():
"""Check system status (ZFS availability)"""
# Try to list pools to see if ZFS is available
pools = zfs_runner.list_pools()
zfs_available = len(pools) >= 0 # list_pools returns [] if ZFS unavailable
# More accurate: check if zpool command works
import subprocess
try:
result = subprocess.run(["zpool", "list"], capture_output=True, timeout=5)
zfs_available = result.returncode == 0
except Exception:
zfs_available = False
return {
"status": "healthy",
"zfs_available": zfs_available,
"version": "1.0.0"
}
# Root endpoint - API info only
@app.get("/")
async def root():
"""API info"""
return {
"name": "ZMB Webui API",
"version": "1.0.0",
"docs": "/docs",
"frontend": "http://192.168.1.179:3000"
}
# Error handler
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Global exception handler"""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error"}
)
if __name__ == "__main__":
import uvicorn
logger.info("Starting ZMB Webui API")
logger.info("Available endpoints:")
logger.info(" POST /api/auth/login - Login with username/password")
logger.info(" GET /api/pools - List all pools")
logger.info(" GET /api/pools/{name} - Get pool details")
logger.info(" POST /api/pools/{name}/scrub - Start scrub")
logger.info(" GET /api/datasets - List datasets")
logger.info(" POST /api/datasets - Create dataset")
logger.info(" DELETE /api/datasets/{name} - Delete dataset")
logger.info(" GET /api/snapshots - List snapshots")
logger.info(" POST /api/snapshots - Create snapshot")
logger.info(" DELETE /api/snapshots/{name} - Delete snapshot")
logger.info(" POST /api/snapshots/rollback - Rollback to snapshot")
logger.info(" GET /api/navigator/browse - Browse directory")
logger.info(" GET /api/navigator/read - Read file")
logger.info(" GET /api/navigator/download - Download file")
logger.info(" POST /api/navigator/upload - Upload file")
logger.info(" POST /api/navigator/create - Create file")
logger.info(" POST /api/navigator/mkdir - Create directory")
logger.info(" POST /api/navigator/rename - Rename file")
logger.info(" DELETE /api/navigator/delete - Delete file/directory")
logger.info(" GET /api/navigator/space - Get space usage")
logger.info("")
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=False,
workers=1
)