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:
@@ -1,189 +0,0 @@
|
||||
# backend – Dev Log
|
||||
|
||||
## 2026-04-14 22:06 – 22:11 (5m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** cockpit_new
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-14 22:11 – 22:15 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-14 22:15 – 22:17 (2m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-14 22:18 – 22:20 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-14 22:21 – 22:22 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-14 22:22 – 22:23 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-16 10:53 – 20:00 (57h 07m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-18 20:01 – 20:01 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-18 20:02 – 20:02 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-19 10:44 – 10:45 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-19 17:16 – 17:21 (4m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-19 17:22 – 17:23 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-19 22:27 – 22:28 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-19 22:29 – 22:30 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-04-19 22:34 – 22:35 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
Keine Änderungen ermittelbar.
|
||||
|
||||
---
|
||||
## 2026-06-03 09:22 – 09:24 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** zmb-webui
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- frontend/next.config.ts | 13 +++++++++++++
|
||||
|
||||
---
|
||||
## 2026-06-03 09:24 – 09:28 (4m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** backend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- frontend/next.config.ts | 13 +++++++++++++
|
||||
|
||||
---
|
||||
+10
-14
@@ -15,11 +15,12 @@ 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
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
# 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 routers import auth, pools, datasets, snapshots, navigator, identities, shares, system, pages
|
||||
from services.zfs_runner import zfs_runner
|
||||
|
||||
# Configure logging
|
||||
@@ -114,7 +115,11 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
ws_clients.discard(websocket)
|
||||
|
||||
|
||||
# Include routers (must be before static files mounting)
|
||||
# Static assets (HTMX, PicoCSS — vendored)
|
||||
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
|
||||
|
||||
# Include API routers first
|
||||
app.include_router(auth.router)
|
||||
app.include_router(pools.router)
|
||||
app.include_router(datasets.router)
|
||||
@@ -124,6 +129,9 @@ app.include_router(identities.router)
|
||||
app.include_router(shares.router)
|
||||
app.include_router(system.router)
|
||||
|
||||
# HTML pages router last (catches /, /login, /zfs, /fragments/*)
|
||||
app.include_router(pages.router)
|
||||
|
||||
|
||||
|
||||
# Health check endpoint (no auth required)
|
||||
@@ -156,18 +164,6 @@ async def status_check():
|
||||
}
|
||||
|
||||
|
||||
# Root endpoint - API info only
|
||||
@app.get("/")
|
||||
async def root(request: Request):
|
||||
"""API info"""
|
||||
return {
|
||||
"name": "ZMB Webui API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"frontend": str(request.base_url)
|
||||
}
|
||||
|
||||
|
||||
# Error handler
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
fastapi>=0.110.0
|
||||
jinja2>=3.1.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
pydantic>=2.6.0
|
||||
pydantic-settings>=2.2.0
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}ZMB Webui{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<style>
|
||||
:root { --pico-font-size: 87.5%; }
|
||||
nav.top-nav { padding: 0.5rem 1rem; border-bottom: 1px solid var(--pico-muted-border-color); }
|
||||
nav.top-nav ul { margin: 0; }
|
||||
nav.top-nav a { text-decoration: none; padding: 0.4rem 0.7rem; border-radius: 4px; }
|
||||
nav.top-nav a.active, nav.top-nav a:hover { background: var(--pico-primary-background); color: var(--pico-primary-inverse); }
|
||||
nav.top-nav .brand { font-weight: bold; font-size: 1.1rem; color: var(--pico-primary); }
|
||||
main.container { padding-top: 1.5rem; }
|
||||
.htmx-indicator { opacity: 0; transition: opacity 200ms; }
|
||||
.htmx-request .htmx-indicator { opacity: 1; }
|
||||
.badge { display: inline-block; padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.75rem; background: var(--pico-secondary-background); }
|
||||
.badge-green { background: #166534; color: #bbf7d0; }
|
||||
.badge-red { background: #7f1d1d; color: #fecaca; }
|
||||
.badge-yellow { background: #713f12; color: #fef08a; }
|
||||
.badge-blue { background: #1e3a5f; color: #bfdbfe; }
|
||||
.progress-bar-wrap { background: var(--pico-muted-border-color); border-radius: 4px; height: 8px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; border-radius: 4px; transition: width 0.3s; }
|
||||
.bar-blue { background: #3b82f6; }
|
||||
.bar-yellow { background: #eab308; }
|
||||
.bar-red { background: #ef4444; }
|
||||
.flash-error { background: #7f1d1d; color: #fecaca; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
|
||||
.flash-ok { background: #166534; color: #bbf7d0; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
|
||||
.grid-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
.stat-card { padding: 1rem; border: 1px solid var(--pico-muted-border-color); border-radius: 8px; }
|
||||
.stat-card h3 { margin: 0 0 0.25rem 0; font-size: 0.8rem; color: var(--pico-muted-color); text-transform: uppercase; }
|
||||
.stat-card .value { font-size: 1.4rem; font-weight: bold; margin: 0; }
|
||||
table { font-size: 0.9rem; }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.actions button, .actions a[role=button] { padding: 0.25rem 0.6rem; font-size: 0.8rem; margin: 0; }
|
||||
details summary { cursor: pointer; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="top-nav">
|
||||
<ul>
|
||||
<li><span class="brand">📱 ZMB Webui</span></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="/" class="{% if active == 'dashboard' %}active{% endif %}">Dashboard</a></li>
|
||||
<li><a href="/zfs" class="{% if active == 'zfs' %}active{% endif %}">ZFS</a></li>
|
||||
<li><a href="/snapshots" class="{% if active == 'snapshots' %}active{% endif %}">Snapshots</a></li>
|
||||
<li><a href="/shares" class="{% if active == 'shares' %}active{% endif %}">Shares</a></li>
|
||||
<li><a href="/navigator" class="{% if active == 'navigator' %}active{% endif %}">Navigator</a></li>
|
||||
<li><a href="/identities" class="{% if active == 'identities' %}active{% endif %}">Identities</a></li>
|
||||
<li><a href="/logs" class="{% if active == 'logs' %}active{% endif %}">Logs</a></li>
|
||||
<li><a href="/services" class="{% if active == 'services' %}active{% endif %}">Services</a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<form method="post" action="/logout" style="margin:0">
|
||||
<button type="submit" class="outline secondary" style="padding:0.3rem 0.7rem;font-size:0.85rem">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% if flash_error %}<div class="flash-error">{{ flash_error }}</div>{% endif %}
|
||||
{% if flash_ok %}<div class="flash-ok">{{ flash_ok }}</div>{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem">
|
||||
<div>
|
||||
<h2 style="margin:0">Dashboard</h2>
|
||||
<small style="color:var(--pico-muted-color)">{{ hostname }}</small>
|
||||
</div>
|
||||
<span class="htmx-indicator" id="spinner">◠ aktualisiert...</span>
|
||||
</div>
|
||||
|
||||
<!-- Schnellstatistiken (alle 30s) -->
|
||||
<div class="grid-stats" id="quick-stats"
|
||||
hx-get="/fragments/quick-stats"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-indicator="#spinner">
|
||||
<div class="stat-card"><h3>Lädt...</h3><p class="value">–</p></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Pools -->
|
||||
<h3>Storage Pools</h3>
|
||||
<div id="pools"
|
||||
hx-get="/fragments/pools"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-indicator="#spinner">
|
||||
<p aria-busy="true">Lade Pools...</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Disk Usage -->
|
||||
<h3>Disk Usage</h3>
|
||||
<div id="disk-usage"
|
||||
hx-get="/fragments/disk-usage"
|
||||
hx-trigger="load"
|
||||
hx-indicator="#spinner">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Netzwerk -->
|
||||
<h3>Network Traffic</h3>
|
||||
<div id="net-traffic"
|
||||
hx-get="/fragments/net-traffic"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-indicator="#spinner">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% macro fmt_bytes(b) %}
|
||||
{%- if b == 0 %}0 B
|
||||
{%- elif b < 1048576 %}{{ "%.1f"|format(b/1024) }} KB
|
||||
{%- elif b < 1073741824 %}{{ "%.1f"|format(b/1048576) }} MB
|
||||
{%- elif b < 1099511627776 %}{{ "%.1f"|format(b/1073741824) }} GB
|
||||
{%- else %}{{ "%.1f"|format(b/1099511627776) }} TB
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% if not filesystems %}
|
||||
<p>Keine Dateisysteme gefunden.</p>
|
||||
{% else %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem">
|
||||
{% for fs in filesystems %}
|
||||
<article style="margin:0;padding:1rem">
|
||||
<strong>{{ fs.mountpoint }}</strong>
|
||||
<div class="progress-bar-wrap" style="margin:0.4rem 0">
|
||||
<div class="progress-bar {% if fs.capacity > 85 %}bar-red{% elif fs.capacity > 70 %}bar-yellow{% else %}bar-blue{% endif %}" style="width:{{ fs.capacity }}%"></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;font-size:0.8rem;gap:0.25rem">
|
||||
<div><div style="color:var(--pico-muted-color)">Total</div><strong>{{ fmt_bytes(fs.total) }}</strong></div>
|
||||
<div><div style="color:var(--pico-muted-color)">Belegt</div><strong>{{ fmt_bytes(fs.used) }}</strong></div>
|
||||
<div><div style="color:var(--pico-muted-color)">Frei</div><strong>{{ fmt_bytes(fs.available) }}</strong></div>
|
||||
</div>
|
||||
<small style="color:var(--pico-muted-color)">{{ fs.filesystem }}</small>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% if not groups %}
|
||||
<p>Keine Gruppen gefunden.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Gruppe</th><th>GID</th><th>Mitglieder</th></tr></thead>
|
||||
<tbody>
|
||||
{% for group in groups %}
|
||||
<tr>
|
||||
<td><strong>{{ group.name }}</strong></td>
|
||||
<td>{{ group.gid }}</td>
|
||||
<td style="font-size:0.85rem">{{ group.members | join(', ') if group.members else '–' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,9 @@
|
||||
<div style="background:var(--pico-code-background,#1a1a2e);border-radius:6px;padding:1rem;overflow-x:auto;max-height:70vh;overflow-y:auto;font-family:monospace;font-size:0.8rem;line-height:1.5">
|
||||
{% if logs %}
|
||||
{% for line in logs %}
|
||||
<div style="{% if 'ERROR' in line or 'error' in line %}color:#fca5a5{% elif 'WARN' in line or 'warn' in line %}color:#fde68a{% elif 'INFO' in line %}color:#a5f3fc{% else %}color:var(--pico-muted-color){% endif %}">{{ line | e }}</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span style="color:var(--pico-muted-color)">Keine Logs verfügbar.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
{% if error %}
|
||||
<p style="color:#fca5a5">⚠ {{ error }}</p>
|
||||
{% else %}
|
||||
{% if parent_path %}
|
||||
<div style="margin-bottom:0.5rem">
|
||||
<button class="outline secondary"
|
||||
hx-get="/fragments/navigator?path={{ parent_path | urlencode }}"
|
||||
hx-target="#nav-content"
|
||||
hx-swap="innerHTML">↑ Übergeordnet</button>
|
||||
<span style="font-family:monospace;margin-left:0.5rem;color:var(--pico-muted-color)">{{ current_path }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Typ</th><th>Größe</th><th>Geändert</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if item.type == 'directory' %}
|
||||
<button class="outline secondary" style="padding:0.1rem 0.4rem;font-size:0.85rem"
|
||||
hx-get="/fragments/navigator?path={{ item.path | urlencode }}"
|
||||
hx-target="#nav-content"
|
||||
hx-swap="innerHTML">
|
||||
📁 {{ item.name }}
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="font-family:monospace;font-size:0.85rem">📄 {{ item.name }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge">{{ item.type }}</span></td>
|
||||
<td style="font-size:0.85rem">{{ item.size }}</td>
|
||||
<td style="font-size:0.8rem;color:var(--pico-muted-color)">{{ item.modified }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not items %}
|
||||
<p style="color:var(--pico-muted-color)">Verzeichnis ist leer.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% macro fmt_bytes(b) %}
|
||||
{%- if b < 1048576 %}{{ "%.1f"|format(b/1024) }} KB
|
||||
{%- elif b < 1073741824 %}{{ "%.1f"|format(b/1048576) }} MB
|
||||
{%- else %}{{ "%.2f"|format(b/1073741824) }} GB
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem">
|
||||
{% for iface in interfaces %}
|
||||
{% if iface.name not in ['lo'] %}
|
||||
<article style="margin:0;padding:1rem">
|
||||
<strong>{{ iface.name }}</strong>
|
||||
<table style="margin:0.5rem 0;font-size:0.85rem">
|
||||
<tr><td style="color:var(--pico-muted-color)">RX</td><td><strong>{{ fmt_bytes(iface.rx_bytes) }}</strong></td><td style="color:var(--pico-muted-color)">{{ "{:,}".format(iface.rx_packets) }} pkt</td></tr>
|
||||
<tr><td style="color:var(--pico-muted-color)">TX</td><td><strong>{{ fmt_bytes(iface.tx_bytes) }}</strong></td><td style="color:var(--pico-muted-color)">{{ "{:,}".format(iface.tx_packets) }} pkt</td></tr>
|
||||
{% if iface.rx_drops > 0 or iface.tx_drops > 0 %}
|
||||
<tr><td colspan="3" style="color:#fca5a5;font-size:0.75rem">⚠ {{ iface.rx_drops + iface.tx_drops }} drops</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
{% if not exports %}
|
||||
<p>Keine NFS Exports konfiguriert.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Pfad</th><th>Clients</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for exp in exports %}
|
||||
<tr id="nfs-{{ loop.index }}">
|
||||
<td style="font-family:monospace">{{ exp.path }}</td>
|
||||
<td style="font-size:0.85rem">{{ exp.clients }}</td>
|
||||
<td>
|
||||
<button class="outline secondary"
|
||||
hx-delete="/api/shares/nfs/{{ exp.path | urlencode }}"
|
||||
hx-confirm="NFS Export '{{ exp.path }}' löschen?"
|
||||
hx-target="#nfs-{{ loop.index }}"
|
||||
hx-swap="outerHTML">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% macro fmt_bytes(b) %}
|
||||
{%- if b == 0 %}0 B
|
||||
{%- elif b < 1048576 %}{{ "%.1f"|format(b/1024) }} KB
|
||||
{%- elif b < 1073741824 %}{{ "%.1f"|format(b/1048576) }} MB
|
||||
{%- elif b < 1099511627776 %}{{ "%.1f"|format(b/1073741824) }} GB
|
||||
{%- else %}{{ "%.1f"|format(b/1099511627776) }} TB
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% if not pools %}
|
||||
<p>Keine ZFS Pools verfügbar.</p>
|
||||
{% else %}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem">
|
||||
{% for pool in pools %}
|
||||
{% set pct = (pool.allocated / pool.size * 100)|int if pool.size else 0 %}
|
||||
<article style="margin:0;padding:1rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>{{ pool.name }}</strong>
|
||||
<span class="badge {% if pool.health == 'ONLINE' %}badge-green{% elif pool.health == 'DEGRADED' %}badge-yellow{% else %}badge-red{% endif %}">{{ pool.health }}</span>
|
||||
</div>
|
||||
<div class="progress-bar-wrap" style="margin:0.5rem 0">
|
||||
<div class="progress-bar {% if pct > 85 %}bar-red{% elif pct > 70 %}bar-yellow{% else %}bar-blue{% endif %}" style="width:{{ pct }}%"></div>
|
||||
</div>
|
||||
<small style="color:var(--pico-muted-color)">
|
||||
{{ fmt_bytes(pool.allocated) }} / {{ fmt_bytes(pool.size) }} ({{ pct }}%)
|
||||
</small>
|
||||
{% if pool.status and pool.status != 'ONLINE' %}
|
||||
<p style="margin:0.5rem 0 0;font-size:0.8rem;color:#fca5a5">{{ pool.status }}</p>
|
||||
{% endif %}
|
||||
<div class="actions" style="margin-top:0.75rem">
|
||||
<button class="outline secondary"
|
||||
hx-post="/api/pools/{{ pool.name }}/scrub"
|
||||
hx-confirm="Scrub für {{ pool.name }} starten?"
|
||||
hx-target="closest article"
|
||||
hx-swap="none">Scrub</button>
|
||||
<button class="outline secondary"
|
||||
hx-post="/api/pools/{{ pool.name }}/clear"
|
||||
hx-confirm="Fehler für {{ pool.name }} löschen?"
|
||||
hx-swap="none">Clear</button>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% macro fmt_bytes(b) %}
|
||||
{%- if b == 0 %}0 B
|
||||
{%- elif b < 1024 %}{{ b }} B
|
||||
{%- elif b < 1048576 %}{{ "%.1f"|format(b/1024) }} KB
|
||||
{%- elif b < 1073741824 %}{{ "%.1f"|format(b/1048576) }} MB
|
||||
{%- elif b < 1099511627776 %}{{ "%.1f"|format(b/1073741824) }} GB
|
||||
{%- else %}{{ "%.1f"|format(b/1099511627776) }} TB
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>CPU Load</h3>
|
||||
<p class="value">{{ cpu.load_average[0] if cpu.load_average else '–' }}</p>
|
||||
<small>{{ cpu.count }} Kerne</small>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>RAM</h3>
|
||||
<p class="value">{{ fmt_bytes(mem.used) }}</p>
|
||||
<small>von {{ fmt_bytes(mem.total) }}</small>
|
||||
{% set pct = (mem.used / mem.total * 100)|int if mem.total else 0 %}
|
||||
<div class="progress-bar-wrap" style="margin-top:0.4rem">
|
||||
<div class="progress-bar {% if pct > 85 %}bar-red{% elif pct > 70 %}bar-yellow{% else %}bar-blue{% endif %}" style="width:{{ pct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Uptime</h3>
|
||||
<p class="value">{{ uptime.uptime_string if uptime else '–' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>Pools</h3>
|
||||
<p class="value">{{ pools|length }}</p>
|
||||
<small>
|
||||
{% for p in pools %}
|
||||
<span class="badge {% if p.health == 'ONLINE' %}badge-green{% elif p.health == 'DEGRADED' %}badge-yellow{% else %}badge-red{% endif %}">{{ p.name }}</span>
|
||||
{% endfor %}
|
||||
</small>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
{% set fruit_keys = ['fruit:encoding','fruit:metadata','fruit:zero_file_id','fruit:nfs_aces'] %}
|
||||
{% set has_macos = params | selectattr('key', 'in', fruit_keys) | list | length == 4 %}
|
||||
|
||||
<h3>Samba Global Config</h3>
|
||||
|
||||
<!-- MacOS Toggle -->
|
||||
<article style="margin-bottom:1rem">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<strong>MacOS Shares (Fruit)</strong>
|
||||
<p style="margin:0;font-size:0.85rem;color:var(--pico-muted-color)">Optimiert alle Shares für macOS (fruit, catia, streams_xattr)</p>
|
||||
</div>
|
||||
<button class="{% if has_macos %}outline secondary{% else %}outline{% endif %}"
|
||||
hx-post="/fragments/samba-config/toggle-macos?enable={{ 'false' if has_macos else 'true' }}"
|
||||
hx-target="#samba-config"
|
||||
hx-swap="innerHTML">
|
||||
{% if has_macos %}MacOS deaktivieren{% else %}MacOS aktivieren{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% if has_macos %}
|
||||
<div style="margin-top:0.5rem">
|
||||
{% for p in params if p.key in fruit_keys %}
|
||||
<span class="badge badge-blue" style="margin:0.1rem">{{ p.key }} = {{ p.value }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<!-- Parameter Tabelle -->
|
||||
<table>
|
||||
<thead><tr><th>Parameter</th><th>Wert</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in params %}
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:0.85rem">{{ p.key }}</td>
|
||||
<td style="font-size:0.85rem;word-break:break-all">{{ p.value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,23 @@
|
||||
{% if not shares %}
|
||||
<p>Keine Samba Shares konfiguriert.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Pfad</th><th>Kommentar</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for share in shares %}
|
||||
<tr id="samba-{{ share.name }}">
|
||||
<td><strong>{{ share.name }}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem">{{ share.path }}</td>
|
||||
<td>{{ share.comment }}</td>
|
||||
<td>
|
||||
<button class="outline secondary"
|
||||
hx-delete="/api/shares/samba/{{ share.name }}"
|
||||
hx-confirm="Share '{{ share.name }}' löschen?"
|
||||
hx-target="#samba-{{ share.name }}"
|
||||
hx-swap="outerHTML">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% if not services %}
|
||||
<p>Keine Dienste gefunden.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Dienst</th><th>Status</th><th>Beschreibung</th></tr></thead>
|
||||
<tbody>
|
||||
{% for svc in services %}
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:0.85rem">{{ svc.name }}</td>
|
||||
<td><span class="badge {% if svc.state == 'active' or svc.active == 'active' %}badge-green{% else %}badge-red{% endif %}">{{ svc.state or svc.active }}</span></td>
|
||||
<td style="font-size:0.85rem;color:var(--pico-muted-color)">{{ svc.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% if not users %}
|
||||
<p>Keine Benutzer gefunden.</p>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead><tr><th>Benutzer</th><th>UID</th><th>Shell</th><th>Status</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr id="user-{{ user.username }}">
|
||||
<td><strong>{{ user.username }}</strong></td>
|
||||
<td>{{ user.uid }}</td>
|
||||
<td style="font-family:monospace;font-size:0.8rem">{{ user.shell }}</td>
|
||||
<td><span class="badge {% if user.locked %}badge-red{% else %}badge-green{% endif %}">{{ 'Gesperrt' if user.locked else 'Aktiv' }}</span></td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="outline secondary"
|
||||
hx-post="/api/identities/users/{{ user.username }}/samba-password"
|
||||
hx-prompt="Samba Passwort für {{ user.username }}:"
|
||||
hx-swap="none">Samba PW</button>
|
||||
<button class="outline secondary"
|
||||
hx-delete="/api/identities/users/{{ user.username }}"
|
||||
hx-confirm="Benutzer '{{ user.username }}' löschen?"
|
||||
hx-target="#user-{{ user.username }}"
|
||||
hx-swap="outerHTML">Löschen</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Identities – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Benutzer & Gruppen</h2>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1.5rem;border-bottom:1px solid var(--pico-muted-border-color);padding-bottom:0.5rem">
|
||||
<button onclick="showTab('users')" id="tab-users" class="outline">Benutzer</button>
|
||||
<button onclick="showTab('groups')" id="tab-groups" class="outline secondary">Gruppen</button>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
<div id="panel-users">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<h3 style="margin:0">Systembenutzer</h3>
|
||||
<button onclick="document.getElementById('new-user-form').style.display='block'">+ Neu</button>
|
||||
</div>
|
||||
|
||||
<div id="new-user-form" style="display:none;margin-bottom:1rem">
|
||||
<article>
|
||||
<h4>Neuer Benutzer</h4>
|
||||
<form hx-post="/api/identities/users" hx-target="#user-list" hx-swap="innerHTML"
|
||||
hx-on::after-request="this.reset();document.getElementById('new-user-form').style.display='none'">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||
<div><label>Benutzername</label><input name="username" required></div>
|
||||
<div><label>Passwort</label><input name="password" type="password" required></div>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">Erstellen</button><button type="button" class="outline secondary" onclick="document.getElementById('new-user-form').style.display='none'">Abbrechen</button></div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="user-list" hx-get="/fragments/users" hx-trigger="load">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups -->
|
||||
<div id="panel-groups" style="display:none">
|
||||
<div id="group-list" hx-get="/fragments/groups" hx-trigger="load">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showTab(name) {
|
||||
['users','groups'].forEach(t => {
|
||||
document.getElementById('panel-' + t).style.display = t === name ? 'block' : 'none';
|
||||
document.getElementById('tab-' + t).className = t === name ? 'outline' : 'outline secondary';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login – ZMB Webui</title>
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<style>
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-box { width: 100%; max-width: 380px; padding: 2rem; }
|
||||
h1 { text-align: center; margin-bottom: 1.5rem; }
|
||||
.flash-error { background: #7f1d1d; color: #fecaca; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>📱 ZMB Webui</h1>
|
||||
{% if error %}<div class="flash-error">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<button type="submit" style="width:100%">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Logs – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
|
||||
<h2 style="margin:0">System Logs</h2>
|
||||
<button hx-get="/fragments/logs" hx-target="#log-content" hx-swap="innerHTML">↻ Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div id="log-content"
|
||||
hx-get="/fragments/logs"
|
||||
hx-trigger="load, every 15s">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Navigator – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Datei Navigator</h2>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
|
||||
<input id="nav-path" type="text" value="/tank/share" style="font-family:monospace;margin:0">
|
||||
<button hx-get="/fragments/navigator"
|
||||
hx-include="#nav-path"
|
||||
hx-target="#nav-content"
|
||||
hx-swap="innerHTML">🔍 Öffnen</button>
|
||||
</div>
|
||||
|
||||
<div id="nav-content"
|
||||
hx-get="/fragments/navigator?path=/tank/share"
|
||||
hx-trigger="load">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Services – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
|
||||
<h2 style="margin:0">Systemdienste</h2>
|
||||
<button hx-get="/fragments/services" hx-target="#service-list" hx-swap="innerHTML">↻ Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div id="service-list"
|
||||
hx-get="/fragments/services"
|
||||
hx-trigger="load">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Shares – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<h2>File Sharing</h2>
|
||||
|
||||
<!-- Tabs via CSS -->
|
||||
<div role="tablist" style="display:flex;gap:0.5rem;margin-bottom:1.5rem;border-bottom:1px solid var(--pico-muted-border-color);padding-bottom:0.5rem">
|
||||
<button role="tab" onclick="showTab('samba')" id="tab-samba" class="outline">Samba (SMB)</button>
|
||||
<button role="tab" onclick="showTab('nfs')" id="tab-nfs" class="outline secondary">NFS</button>
|
||||
<button role="tab" onclick="showTab('config')" id="tab-config" class="outline secondary">Samba Config</button>
|
||||
</div>
|
||||
|
||||
<!-- Samba Shares -->
|
||||
<div id="panel-samba">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<h3 style="margin:0">Samba Shares</h3>
|
||||
<button onclick="document.getElementById('new-samba-form').style.display='block'">+ Neu</button>
|
||||
</div>
|
||||
|
||||
<div id="new-samba-form" style="display:none;margin-bottom:1rem">
|
||||
<article>
|
||||
<h4>Neuer Samba Share</h4>
|
||||
<form hx-post="/api/shares/samba" hx-target="#samba-list" hx-swap="innerHTML"
|
||||
hx-on::after-request="this.reset();document.getElementById('new-samba-form').style.display='none'">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||
<div><label>Name</label><input name="name" required placeholder="myshare"></div>
|
||||
<div><label>Pfad</label><input name="path" required placeholder="/tank/share"></div>
|
||||
<div><label>Kommentar</label><input name="comment" placeholder="Beschreibung"></div>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">Erstellen</button><button type="button" class="outline secondary" onclick="document.getElementById('new-samba-form').style.display='none'">Abbrechen</button></div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="samba-list"
|
||||
hx-get="/fragments/samba-shares"
|
||||
hx-trigger="load">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NFS -->
|
||||
<div id="panel-nfs" style="display:none">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<h3 style="margin:0">NFS Exports</h3>
|
||||
<button onclick="document.getElementById('new-nfs-form').style.display='block'">+ Neu</button>
|
||||
</div>
|
||||
|
||||
<div id="new-nfs-form" style="display:none;margin-bottom:1rem">
|
||||
<article>
|
||||
<h4>Neuer NFS Export</h4>
|
||||
<form hx-post="/api/shares/nfs" hx-target="#nfs-list" hx-swap="innerHTML"
|
||||
hx-on::after-request="this.reset();document.getElementById('new-nfs-form').style.display='none'">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||
<div><label>Pfad</label><input name="path" required placeholder="/tank/share"></div>
|
||||
<div><label>Clients</label><input name="clients" required placeholder="*(rw,sync,no_subtree_check)"></div>
|
||||
<div><label>Kommentar</label><input name="comment" placeholder="Beschreibung"></div>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">Erstellen</button><button type="button" class="outline secondary" onclick="document.getElementById('new-nfs-form').style.display='none'">Abbrechen</button></div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="nfs-list"
|
||||
hx-get="/fragments/nfs-shares"
|
||||
hx-trigger="load">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Samba Config -->
|
||||
<div id="panel-config" style="display:none">
|
||||
<div id="samba-config"
|
||||
hx-get="/fragments/samba-config"
|
||||
hx-trigger="load">
|
||||
<p aria-busy="true">Lade...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showTab(name) {
|
||||
['samba','nfs','config'].forEach(t => {
|
||||
document.getElementById('panel-' + t).style.display = t === name ? 'block' : 'none';
|
||||
const btn = document.getElementById('tab-' + t);
|
||||
btn.className = t === name ? 'outline' : 'outline secondary';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Snapshots – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem">
|
||||
<h2 style="margin:0">Snapshots</h2>
|
||||
<small style="color:var(--pico-muted-color)">{{ snapshots|length }} Snapshots</small>
|
||||
</div>
|
||||
|
||||
{% if not snapshots %}
|
||||
<p>Keine Snapshots vorhanden.</p>
|
||||
{% else %}
|
||||
<div style="overflow-x:auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Erstellt</th>
|
||||
<th>Belegt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for snap in snapshots %}
|
||||
<tr id="snap-{{ loop.index }}">
|
||||
<td style="font-family:monospace;font-size:0.85rem">{{ snap.name }}</td>
|
||||
<td>{{ snap.creation }}</td>
|
||||
<td>{{ snap.used }}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="outline secondary"
|
||||
hx-delete="/api/snapshots/{{ snap.name | urlencode }}"
|
||||
hx-confirm="Snapshot {{ snap.name }} löschen?"
|
||||
hx-target="#snap-{{ loop.index }}"
|
||||
hx-swap="outerHTML">Löschen</button>
|
||||
<button class="outline"
|
||||
hx-post="/api/snapshots/{{ snap.name | urlencode }}/clone"
|
||||
hx-swap="none">Klonen</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}ZFS – ZMB Webui{% endblock %}
|
||||
{% block content %}
|
||||
<h2>ZFS Pools & Datasets</h2>
|
||||
|
||||
{% if not pools %}
|
||||
<p>Keine ZFS Pools verfügbar.</p>
|
||||
{% else %}
|
||||
|
||||
{% for pool in pools %}
|
||||
<details open>
|
||||
<summary><strong>{{ pool.name }}</strong>
|
||||
<span class="badge {% if pool.health == 'ONLINE' %}badge-green{% elif pool.health == 'DEGRADED' %}badge-yellow{% else %}badge-red{% endif %}" style="margin-left:0.5rem">{{ pool.health }}</span>
|
||||
</summary>
|
||||
|
||||
<div class="actions" style="margin:0.75rem 0">
|
||||
<button class="outline"
|
||||
hx-post="/api/pools/{{ pool.name }}/scrub"
|
||||
hx-confirm="Scrub für {{ pool.name }} starten?"
|
||||
hx-swap="none">🔍 Scrub</button>
|
||||
<button class="outline"
|
||||
hx-post="/api/pools/{{ pool.name }}/resilver"
|
||||
hx-confirm="Resilver für {{ pool.name }} starten?"
|
||||
hx-swap="none">🔄 Resilver</button>
|
||||
<button class="outline secondary"
|
||||
hx-post="/api/pools/{{ pool.name }}/clear"
|
||||
hx-confirm="Fehler für {{ pool.name }} löschen?"
|
||||
hx-swap="none">✖ Clear Errors</button>
|
||||
</div>
|
||||
|
||||
<!-- VDev Tree -->
|
||||
{% if pool.vdevs %}
|
||||
<h4>VDev Struktur</h4>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Typ</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for vdev in pool.vdevs %}
|
||||
<tr>
|
||||
<td>{{ vdev.name }}</td>
|
||||
<td>{{ vdev.type }}</td>
|
||||
<td><span class="badge {% if vdev.state == 'ONLINE' %}badge-green{% elif vdev.state == 'DEGRADED' %}badge-yellow{% else %}badge-red{% endif %}">{{ vdev.state }}</span></td>
|
||||
</tr>
|
||||
{% if vdev.disks %}
|
||||
{% for disk in vdev.disks %}
|
||||
<tr style="font-size:0.85rem">
|
||||
<td> ↳ {{ disk.name }}</td>
|
||||
<td><span style="color:var(--pico-muted-color)">disk</span></td>
|
||||
<td><span class="badge {% if disk.state == 'ONLINE' %}badge-green{% elif disk.state == 'DEGRADED' %}badge-yellow{% else %}badge-red{% endif %}">{{ disk.state }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<!-- Datasets -->
|
||||
{% if pool.datasets %}
|
||||
<h4>Datasets</h4>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Typ</th><th>Belegt</th><th>Verfügbar</th><th>Kompression</th></tr></thead>
|
||||
<tbody>
|
||||
{% for ds in pool.datasets %}
|
||||
<tr>
|
||||
<td>{{ ds.name }}</td>
|
||||
<td><span class="badge">{{ ds.type }}</span></td>
|
||||
<td>{{ ds.used }}</td>
|
||||
<td>{{ ds.available }}</td>
|
||||
<td>{{ ds.compression }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</details>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user