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,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