Feature: HTMX + Jinja2 Frontend ersetzt Next.js komplett
- Kein Node.js, kein npm, kein Build-Schritt mehr - HTMX 2.0.4 + PicoCSS 2 vendored in backend/static/ - Jinja2 Templates für alle 9 Seiten (Dashboard, ZFS, Snapshots, Shares, Identities, Logs, Services, Navigator, Login) - HTMX Fragments für Live-Updates (30s Polling Dashboard) - JWT als httpOnly Cookie statt localStorage - Disk Usage zeigt TB/PB korrekt (Jinja2 serverseitig formatiert) - Update-safe: nur Python-Deps, keine npm-Abhängigkeiten Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
HTML Page routes (Jinja2 + HTMX)
|
||||
Replaces Next.js frontend entirely.
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import APIRouter, Request, Response, Form, Cookie
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from services import auth as auth_service
|
||||
from services import system_info, zfs_runner
|
||||
from services.shares import share_manager
|
||||
from services.identities import list_users, list_groups
|
||||
from services.file_manager import FileManager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
||||
|
||||
_file_manager = FileManager()
|
||||
|
||||
|
||||
def _require_user(token: str | None) -> str | None:
|
||||
if not token:
|
||||
return None
|
||||
return auth_service.verify_token(token)
|
||||
|
||||
|
||||
def _redirect_login():
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
def _ctx(**kwargs):
|
||||
return kwargs or {}
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
return templates.TemplateResponse(request, "login.html")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
if not auth_service.authenticate_user(username, password):
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html",
|
||||
{"error": "Ungültiger Benutzername oder Passwort"},
|
||||
status_code=401,
|
||||
)
|
||||
token = auth_service.create_access_token(username)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie("token", token, httponly=True, samesite="lax", max_age=86400)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
resp = RedirectResponse("/login", status_code=303)
|
||||
resp.delete_cookie("token")
|
||||
return resp
|
||||
|
||||
|
||||
# ── Pages ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
hostname = system_info.get_hostname().get("hostname", "")
|
||||
return templates.TemplateResponse(request, "dashboard.html", {
|
||||
"active": "dashboard", "hostname": hostname,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/zfs", response_class=HTMLResponse)
|
||||
async def zfs_page(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
pools_raw = zfs_runner.list_pools()
|
||||
pools = []
|
||||
for p in pools_raw:
|
||||
datasets = zfs_runner.list_datasets(p["name"])
|
||||
vdevs = zfs_runner.get_pool_status(p["name"]).get("vdevs", [])
|
||||
pools.append({**p, "datasets": datasets, "vdevs": vdevs})
|
||||
return templates.TemplateResponse(request, "zfs.html", {
|
||||
"active": "zfs", "pools": pools,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/snapshots", response_class=HTMLResponse)
|
||||
async def snapshots_page(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
snapshots = zfs_runner.list_snapshots()
|
||||
return templates.TemplateResponse(request, "snapshots.html", {
|
||||
"active": "snapshots", "snapshots": snapshots,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/shares", response_class=HTMLResponse)
|
||||
async def shares_page(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
return templates.TemplateResponse(request, "shares.html", {"active": "shares"})
|
||||
|
||||
|
||||
@router.get("/navigator", response_class=HTMLResponse)
|
||||
async def navigator_page(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
return templates.TemplateResponse(request, "navigator.html", {"active": "navigator"})
|
||||
|
||||
|
||||
@router.get("/identities", response_class=HTMLResponse)
|
||||
async def identities_page(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
return templates.TemplateResponse(request, "identities.html", {"active": "identities"})
|
||||
|
||||
|
||||
@router.get("/logs", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
return templates.TemplateResponse(request, "logs.html", {"active": "logs"})
|
||||
|
||||
|
||||
@router.get("/services", response_class=HTMLResponse)
|
||||
async def services_page(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return _redirect_login()
|
||||
return templates.TemplateResponse(request, "services.html", {"active": "services"})
|
||||
|
||||
|
||||
# ── HTMX Fragments ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/fragments/quick-stats", response_class=HTMLResponse)
|
||||
async def frag_quick_stats(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
return templates.TemplateResponse(request, "fragments/quick_stats.html", {
|
||||
"cpu": system_info.get_cpu_info(),
|
||||
"mem": system_info.get_memory(),
|
||||
"uptime": system_info.get_uptime(),
|
||||
"pools": zfs_runner.list_pools(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/pools", response_class=HTMLResponse)
|
||||
async def frag_pools(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
return templates.TemplateResponse(request, "fragments/pools.html", {
|
||||
"pools": zfs_runner.list_pools(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/disk-usage", response_class=HTMLResponse)
|
||||
async def frag_disk_usage(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
data = system_info.get_disk_usage()
|
||||
return templates.TemplateResponse(request, "fragments/disk_usage.html", {
|
||||
"filesystems": data.get("filesystems", []),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/net-traffic", response_class=HTMLResponse)
|
||||
async def frag_net_traffic(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
data = system_info.get_network_traffic()
|
||||
return templates.TemplateResponse(request, "fragments/net_traffic.html", {
|
||||
"interfaces": data.get("interfaces", []),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/samba-shares", response_class=HTMLResponse)
|
||||
async def frag_samba_shares(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
return templates.TemplateResponse(request, "fragments/samba_shares.html", {
|
||||
"shares": share_manager.list_samba_shares(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/nfs-shares", response_class=HTMLResponse)
|
||||
async def frag_nfs_shares(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
return templates.TemplateResponse(request, "fragments/nfs_shares.html", {
|
||||
"exports": share_manager.list_nfs_shares(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/samba-config", response_class=HTMLResponse)
|
||||
async def frag_samba_config(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
data = share_manager.get_samba_global_config()
|
||||
return templates.TemplateResponse(request, "fragments/samba_config.html", {
|
||||
"params": data.get("parameters", []),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/fragments/samba-config/toggle-macos", response_class=HTMLResponse)
|
||||
async def frag_toggle_macos(
|
||||
request: Request,
|
||||
enable: str = "true",
|
||||
token: str | None = Cookie(default=None),
|
||||
):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
current_data = share_manager.get_samba_global_config()
|
||||
params = {p["key"]: p["value"] for p in current_data.get("parameters", [])}
|
||||
fruit = {"fruit:encoding": "native", "fruit:metadata": "stream",
|
||||
"fruit:zero_file_id": "yes", "fruit:nfs_aces": "no"}
|
||||
if enable == "true":
|
||||
params.update(fruit)
|
||||
else:
|
||||
for k in fruit:
|
||||
params.pop(k, None)
|
||||
share_manager.set_samba_global_config(params)
|
||||
updated = share_manager.get_samba_global_config()
|
||||
return templates.TemplateResponse(request, "fragments/samba_config.html", {
|
||||
"params": updated.get("parameters", []),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/users", response_class=HTMLResponse)
|
||||
async def frag_users(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
return templates.TemplateResponse(request, "fragments/users.html", {
|
||||
"users": list_users(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/groups", response_class=HTMLResponse)
|
||||
async def frag_groups(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
return templates.TemplateResponse(request, "fragments/groups.html", {
|
||||
"groups": list_groups(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/logs", response_class=HTMLResponse)
|
||||
async def frag_logs(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
data = system_info.get_journal_logs(50)
|
||||
return templates.TemplateResponse(request, "fragments/logs.html", {
|
||||
"logs": data.get("logs", []),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/services", response_class=HTMLResponse)
|
||||
async def frag_services(request: Request, token: str | None = Cookie(default=None)):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
data = system_info.get_services()
|
||||
return templates.TemplateResponse(request, "fragments/services.html", {
|
||||
"services": data.get("services", []),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fragments/navigator", response_class=HTMLResponse)
|
||||
async def frag_navigator(
|
||||
request: Request,
|
||||
path: str = "/tank/share",
|
||||
token: str | None = Cookie(default=None),
|
||||
):
|
||||
if not _require_user(token):
|
||||
return HTMLResponse("", status_code=401)
|
||||
try:
|
||||
items = _file_manager.list_directory(path)
|
||||
parent = str(os.path.dirname(path)) if path != "/" else None
|
||||
return templates.TemplateResponse(request, "fragments/navigator.html", {
|
||||
"items": items, "current_path": path, "parent_path": parent, "error": None,
|
||||
})
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse(request, "fragments/navigator.html", {
|
||||
"items": [], "current_path": path, "parent_path": None, "error": str(e),
|
||||
})
|
||||
Reference in New Issue
Block a user