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
@@ -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 %}