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:
2026-06-05 18:45:46 +02:00
parent 654df5b98f
commit 5ecd143535
44 changed files with 1123 additions and 6129 deletions
+72
View File
@@ -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">&#128241; 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>
+53
View File
@@ -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">&#9696; 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 %}
+16
View File
@@ -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 %}
+9
View File
@@ -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">&#9888; {{ 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">&#8593; Ü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">
&#128193; {{ item.name }}
</button>
{% else %}
<span style="font-family:monospace;font-size:0.85rem">&#128196; {{ 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">&#9888; {{ 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 %}
+44
View File
@@ -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 %}
+16
View File
@@ -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 %}
+30
View File
@@ -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 %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Identities ZMB Webui{% endblock %}
{% block content %}
<h2>Benutzer &amp; 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'">&#43; 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 %}
+28
View File
@@ -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>&#128241; 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>
+14
View File
@@ -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">&#8635; Aktualisieren</button>
</div>
<div id="log-content"
hx-get="/fragments/logs"
hx-trigger="load, every 15s">
<p aria-busy="true">Lade...</p>
</div>
{% endblock %}
+19
View File
@@ -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">&#128269; Ö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 %}
+14
View File
@@ -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">&#8635; Aktualisieren</button>
</div>
<div id="service-list"
hx-get="/fragments/services"
hx-trigger="load">
<p aria-busy="true">Lade...</p>
</div>
{% endblock %}
+89
View File
@@ -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'">&#43; 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'">&#43; 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 %}
+46
View File
@@ -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 %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}ZFS ZMB Webui{% endblock %}
{% block content %}
<h2>ZFS Pools &amp; 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">&#128269; Scrub</button>
<button class="outline"
hx-post="/api/pools/{{ pool.name }}/resilver"
hx-confirm="Resilver für {{ pool.name }} starten?"
hx-swap="none">&#128260; 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">&#10006; 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>&nbsp;&nbsp;&nbsp;&#8627; {{ 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 %}