Files
tesm/srv/poe_manager/templates/settings_dhcp.html
T
alientim 1a0b55ed44 DHCP: manuelle Reservierungen, Options-Schutz+Dropdown-UI, Lease-Countdown-Fix
1. Manuelle Reservierungen (dhcp_manual_reservations) fuer Geraete, die
   nicht als Client in dieser App gepflegt werden -- bewusst eine eigene,
   explizite Aktion (MAC/IP/Name), damit kein externes Geraet unueberwacht
   automatisch eine feste IP bekommt. _dhcp_reservation_candidates fuehrt
   automatische (aus devices) und manuelle Reservierungen jetzt zusammen,
   case-insensitiv nach MAC dedupliziert (automatisch gewinnt bei Konflikt).
   Automatische Reservierungen sind in der Tabelle nicht loeschbar
   (Schloss-Symbol statt Button, sie entstehen/verschwinden mit den
   Client-Stammdaten), manuelle schon. Live verifiziert: Hinzufuegen,
   doppelte MAC ablehnen, ungueltige MAC ablehnen, generierte Kea-Config
   mit gemischten Auto-/manuellen Reservierungen besteht kea-dhcp4 -t.

2. Standard-DHCP-Optionen um 10 weitere verifizierte Optionen erweitert
   (u.a. host-name/12 -- Kea-Name mit Bindestrich, NICHT "hostname", live
   gegen echtes Kea geprueft) -- jetzt 57 insgesamt. Standard-Optionen sind
   nicht mehr loeschbar (Backend lehnt es ab, UI zeigt ein Schloss-Symbol
   statt Loeschen-Button) -- vorher liess sich eine vorbefuellte Options-
   Definition versehentlich entfernen.

3. Options-Tabellen (global UND pro Reservierung) zeigen jetzt nur noch
   tatsaechlich genutzte Options -- eigene immer, Standard-Optionen nur mit
   gesetztem Wert. Weitere Standard-Optionen kommen ueber ein Dropdown
   "+ Option hinzufuegen" dazu statt permanent alle 57 als leere Felder zu
   zeigen. Das neu eingeblendete Feld wird automatisch in den sichtbaren
   Bereich gescrollt und fokussiert (bei bis zu 57 moeglichen Optionen kann
   das Feld sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen
   und es wirkt so, als waere nichts passiert).

4. Lease-Countdown-Fix: die Ablaufzeit wird jetzt explizit als UTC
   ausgewiesen (Kea arbeitet intern in UTC-Epoch) und zusaetzlich als live
   tickender Countdown angezeigt (gleiches Prinzip wie der bestehende
   "Naechste Pruefung"-Timer). Vorher wirkte eine reine Absolutzeit auf der
   nicht selbst aktualisierenden Seite bei den kurzen Standard-Lease-Zeiten
   (600s) schnell "schon abgelaufen", sobald etwas Zeit vergangen war, bis
   man tatsaechlich hinschaut -- live im echten Betrieb genau als dieses
   Missverstaendnis beobachtet und anhand der rohen Kea-CSV verifiziert,
   dass die richtige Spalte (expire) korrekt gelesen wird.

5. UI-Feinschliff: "In Datei schreiben" bei den Reservierungen heisst jetzt
   "Speichern" und ist der primaere (orange) Button, "Reservierung
   hinzufuegen" sekundaer -- das Hinzufuegen legt nur einen Datensatz an,
   erst "Speichern" schreibt die tatsaechlich wirksame Kea-Config.

Alles live auf der Test-VM deployt und verifiziert (Playwright fuer die
Dropdown-Sichtbarkeit/Fokus-Interaktion, curl fuer die Backend-Validierung,
kea-dhcp4 -t fuer die generierte Konfiguration), keine Fehler im
journalctl-Log.
2026-08-11 15:52:52 +02:00

618 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% set active_page = "settings_dhcp" %}
{% set can_edit = current_user.has_permission('settings_dhcp.edit') %}
{% block page_title %}DHCP{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">DHCP-Server-Status</h2>
<div class="hint">Installation/Dienststeuerung nur über die Buttons unten.</div>
</div>
</div>
{% if status.installed %}
<div class="flex gap-2" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
<span class="pill online">Installiert</span>
{% if status.active is sameas true %}
<span class="pill online">Dienst aktiv</span>
{% elif status.active is sameas false %}
<span class="pill offline">Dienst inaktiv</span>
{% else %}
<span class="pill unknown">Dienst-Status unbekannt</span>
{% endif %}
{% if status.enabled is sameas true %}<span class="pill user">Autostart an</span>{% endif %}
</div>
{% if status.version %}<div class="text-faint mono" style="font-size:12px; margin-bottom:12px;">{{ status.version }}</div>{% endif %}
{% if can_edit %}
<div class="flex gap-2" style="flex-wrap:wrap;">
<form method="post" data-confirm="{{ dhcp_service }} aktivieren und (neu) starten? Übernimmt die zuletzt geschriebene Konfiguration.">
<input type="hidden" name="dhcp_service_action" value="enable_restart">
<button type="submit" class="btn btn-secondary btn-sm">Aktivieren &amp; (neu) starten</button>
</form>
{% if status.active is sameas true %}
<form method="post" data-confirm="{{ dhcp_service }} stoppen und deaktivieren? Startet nach einem Host-Neustart dann nicht automatisch wieder.">
<input type="hidden" name="dhcp_service_action" value="stop">
<button type="submit" class="btn btn-secondary btn-sm" style="color:var(--danger);">Stoppen</button>
</form>
{% endif %}
</div>
{% endif %}
{% else %}
<span class="pill disabled">Nicht installiert</span>
<p class="text-faint" style="font-size:11.5px; margin:10px 0;">
Kein <code>kea-dhcp4</code> auf diesem Host gefunden.
</p>
{% if can_edit %}
<form method="post" data-confirm="{{ dhcp_package }} jetzt installieren (apt-get)? Startet den Dienst noch nicht.">
<input type="hidden" name="install_dhcp_package" value="1">
<button type="submit" class="btn btn-primary btn-sm">{{ dhcp_package }} installieren</button>
</form>
{% endif %}
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Globale DHCP-Einstellungen</h2>
<div class="hint">Gelten für den gesamten Dienst, unabhängig von den Subnetzen unten.</div>
</div>
</div>
{% if can_edit %}
<form method="post">
<input type="hidden" name="save_dhcp_config" value="1">
<div class="field"><label>Domain</label>
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
</div>
<div class="field"><label>Lease-Zeit Standard (Sek.)</label>
<input type="number" name="dhcp_lease_default" value="{{ cfg.dhcp_lease_default }}">
</div>
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
</div>
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Nach dem Schreiben den Dienst per Button neu starten, damit die Änderung wirksam wird.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Konfiguration speichern
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „DHCP ändern“.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Subnetze</h2>
<div class="hint">
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Subnetz hinzufügen
</button>
{% endif %}
</div>
{% if subnets %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Interface</th><th>Range</th><th>Gateway</th><th>DNS</th><th>Erkanntes Netz</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for s in subnets %}
{% set st = subnet_status.get(s.id) %}
<tr {% if not s.enabled %}style="opacity:.55;"{% endif %}>
<td class="mono">{{ s.interface }}</td>
<td class="mono">{{ s.range_start }} {{ s.range_end }}</td>
<td class="mono text-faint">{{ s.gateway or '(automatisch)' }}</td>
<td class="mono text-faint">{{ s.dns or '—' }}</td>
<td>
{% if not s.enabled %}
<span class="pill disabled">Deaktiviert</span>
{% elif st and st.ok %}
<span class="pill online">Erkannt</span>
<span class="mono text-faint" style="font-size:11.5px;">{{ st.network }}</span>
{% else %}
<span class="pill offline">Nicht erkannt</span>
{% if st %}<span class="text-faint" style="font-size:11px;">{{ st.error }}</span>{% endif %}
{% endif %}
</td>
<td>
{% if can_edit %}
<div class="flex gap-2">
<form method="post">
<input type="hidden" name="toggle_dhcp_subnet" value="{{ s.id }}">
<button type="submit" class="icon-btn" title="{{ 'Deaktivieren' if s.enabled else 'Aktivieren' }}">
{% if s.enabled %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.9 4.9l14.2 14.2"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
{% endif %}
</button>
</form>
<button type="button" class="icon-btn" title="Bearbeiten" data-open-modal="editSubnetModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Subnetz {{ s.range_start }}{{ s.range_end }} löschen?">
<input type="hidden" name="delete_dhcp_subnet" value="{{ s.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch kein Subnetz konfiguriert — der Dienst kann so nicht gestartet werden.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">DHCP-Options</h2>
<div class="hint">
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Option
</button>
{% endif %}
</div>
{% set visible_option_defs = option_defs|rejectattr("is_standard")|list + option_defs|selectattr("is_standard")|selectattr("id", "in", option_values.keys()|list)|list %}
{% if visible_option_defs %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Herkunft</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for d in visible_option_defs|sort(attribute="code") %}
<tr>
<td class="mono">{{ d.code }}</td>
<td class="mono">{{ d.name }}</td>
<td>{{ d.type }}</td>
<td>
{% if d.is_standard %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Standard</span>
{% else %}
<span class="pill user">Eigen</span>
{% endif %}
</td>
<td class="text-faint" style="font-size:12px;">{{ d.description or '—' }}</td>
<td>
{% if can_edit %}
<form method="post" class="flex gap-2">
<input type="hidden" name="save_dhcp_option_global" value="1">
<input type="hidden" name="option_def_id" value="{{ d.id }}">
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
</form>
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div>
{% else %}
{{ option_values.get(d.id, {}).get('', '—') }}
{% endif %}
</td>
<td>
{% if can_edit and not d.is_standard %}
<form method="post" data-confirm="Option „{{ d.name }}“ inkl. aller Werte/Overrides löschen?">
<input type="hidden" name="delete_dhcp_option" value="{{ d.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% elif d.is_standard %}
<span class="text-faint" title="Standard-Optionen können nicht gelöscht werden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Options in Verwendung.</p>
{% endif %}
{% if can_edit %}
{% set unused_standard_defs = option_defs|selectattr("is_standard")|rejectattr("id", "in", option_values.keys()|list)|sort(attribute="code")|list %}
{% if unused_standard_defs %}
<form method="post" class="flex gap-2" style="margin-top:14px; align-items:flex-end; flex-wrap:wrap;">
<input type="hidden" name="save_dhcp_option_global" value="1">
<div class="field" style="margin:0; min-width:260px;">
<label>Standard-Option hinzufügen</label>
<select name="option_def_id">
{% for d in unused_standard_defs %}
<option value="{{ d.id }}">{{ d.name }} (Code {{ d.code }}) — {{ d.description }}</option>
{% endfor %}
</select>
</div>
<div class="field" style="margin:0;">
<label>Wert</label>
<input type="text" name="value" placeholder="Wert eintragen…" required>
</div>
<button type="submit" class="btn btn-secondary">Hinzufügen</button>
</form>
{% endif %}
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Aktive Leases</h2>
<div class="hint">
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
die sich einfach eine freie IP aus dem Pool genommen haben.
</div>
</div>
</div>
{% if leases is none %}
<p class="text-faint" style="font-size:12.5px;">
Keine Lease-Datei gefunden — der Dienst wurde vermutlich noch nie gestartet oder hat noch keine Adresse vergeben.
</p>
{% elif leases %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>IP-Adresse</th><th>MAC</th><th>Hostname</th><th>Läuft ab</th><th>Status</th></tr></thead>
<tbody>
{% for l in leases %}
<tr>
<td class="mono">{{ l.ip }}</td>
<td class="mono">{{ l.mac }}</td>
<td>{{ l.hostname or '—' }}</td>
<td class="text-faint mono" style="font-size:12px;">
{% if l.expires_at %}
{{ l.expires_at }} UTC
<br><span class="lease-countdown" data-expire-ms="{{ l.expire_epoch_ms }}"></span>
{% else %}
{% endif %}
</td>
<td>
{% if l.reserved %}
<span class="pill online">Reserviert</span>
{% else %}
<span class="pill unknown" title="Client hat sich diese IP selbst aus dem Pool geholt, ohne eigene Reservierung">
⚠ Ohne Reservierung
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Aktuell keine aktiven Leases vergeben.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Reservierungen aus den Clients</h2>
<div class="hint">
{{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP in einem der konfigurierten Subnetze.
{% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %}
{% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} aller konfigurierten Subnetze übersprungen.{% endif %}
</div>
</div>
{% if can_edit %}
<div class="flex gap-2">
<button type="button" class="btn btn-secondary" data-open-modal="addReservationModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Reservierung hinzufügen
</button>
<form method="post" data-confirm="Konfiguration nach „{{ cfg.dhcp_output_path }}“ schreiben? Der Dienst übernimmt die Änderung erst nach einem Neustart.">
<input type="hidden" name="write_dhcp_file" value="1">
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Speichern
</button>
</form>
</div>
{% endif %}
</div>
{% if reservations %}
<div class="table-wrap" style="margin-bottom:18px;">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Hostname</th><th>MAC</th><th>IP-Adresse</th><th>Name</th><th>Quelle</th><th>Subnetz</th><th>Optionen</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for r in reservations %}
<tr>
<td class="mono">{{ r.hostname }}</td>
<td class="mono">{{ r.mac }}</td>
<td class="mono">{{ r.ip }}</td>
<td>{{ r.name }}</td>
<td>
{% if r.source == 'manual' %}
<span class="pill user">Manuell</span>
{% else %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Auto (Client)</span>
{% endif %}
</td>
<td class="mono text-faint" style="font-size:12px;">{{ r.subnet_label }}</td>
<td style="font-size:12px;">
{% for d in option_defs %}
{% set override = option_values.get(d.id, {}).get(r.mac) %}
{% set global_val = option_values.get(d.id, {}).get('') %}
{% if override %}
<span class="pill user" title="{{ d.name }} = {{ override }}">{{ d.name }} (eigen)</span>
{% elif global_val %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);" title="{{ d.name }} = {{ global_val }}">{{ d.name }} (global)</span>
{% endif %}
{% endfor %}
</td>
<td>
<div class="flex gap-2">
{% if option_defs and can_edit %}
<button type="button" class="icon-btn" title="DHCP-Optionen für diesen Client" data-open-modal="deviceOptionsModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/></svg>
</button>
{% endif %}
{% if can_edit and r.source == 'manual' %}
<form method="post" data-confirm="Manuelle Reservierung für {{ r.mac }} löschen?">
<input type="hidden" name="delete_dhcp_reservation" value="{{ r.manual_id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% elif r.source == 'auto' %}
<span class="text-faint" title="Automatische Reservierungen entstehen aus den Client-Stammdaten und können hier nicht gelöscht werden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist
ungültiges HTML und wird vom Browser aus der Tabelle heraus
"foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen
Formular und Feldern zerrissen werden. -->
{% if option_defs and can_edit %}
{% for r in reservations %}
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
<div class="modal">
<form method="post">
<input type="hidden" name="save_dhcp_device_options" value="1">
<input type="hidden" name="mac" value="{{ r.mac }}">
<div class="modal-header">
<h3>DHCP-Optionen — {{ r.name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">
Nur Options mit gesetztem Override sind sichtbar — über das Dropdown weitere hinzufügen.
Leerer Wert übernimmt den globalen Wert (bzw. entfernt den Override).
</p>
<select onchange="poeShowOptionField(this)" style="margin-bottom:14px;">
<option value="">+ Option hinzufügen…</option>
{% for d in option_defs %}
{% if not option_values.get(d.id, {}).get(r.mac) %}
<option value="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}">{{ d.name }} (Code {{ d.code }})</option>
{% endif %}
{% endfor %}
</select>
{% for d in option_defs %}
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}" {% if not current %}style="display:none;"{% endif %}>
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
<input type="text" name="opt_{{ d.id }}" value="{{ current }}"
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
</div>
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p>
{% endif %}
<div class="permission-group-title">Generierte Konfiguration (Vorschau, Kea-JSON)</div>
<pre class="code-preview">{{ preview }}</pre>
</div>
</div>
{% if can_edit %}
<div class="modal-overlay" id="addOptionModal">
<div class="modal" style="max-width:1000px;">
<form method="post">
<input type="hidden" name="add_dhcp_option" value="1">
<div class="modal-header">
<h3>Neue DHCP-Option</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Code</label>
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
</div>
<div class="field"><label>Name</label>
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
</div>
<div class="field"><label>Datentyp</label>
<select name="type">
{% for key, label in option_types %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Beschreibung</label>
<input type="text" name="description" placeholder="z.B. Terminal-Boot-URL für Thin Clients">
</div>
<div class="field"><label>Globaler Wert (optional)</label>
<input type="text" name="initial_value" placeholder="z.B. http://bde/webmmi?M=SGM11">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="addReservationModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_dhcp_reservation" value="1">
<div class="modal-header">
<h3>Reservierung hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">
Für Geräte, die nicht als Client in dieser App gepflegt werden (z.B. ein externes Gerät) — bewusst
eine eigene, manuelle Aktion, damit keine unüberwachten Clients automatisch eine feste IP bekommen.
</p>
<div class="field"><label>MAC-Adresse</label>
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="ip" required placeholder="z.B. 192.168.80.50">
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
</div>
<div class="field"><label>Name</label>
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="addSubnetModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_dhcp_subnet" value="1">
<div class="modal-header">
<h3>Subnetz hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Interface</label>
<select name="interface" required>
{% for iface in interfaces %}
<option value="{{ iface }}">{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Range Start</label>
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
</div>
<div class="field"><label>Range Ende</label>
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div>
<div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div>
<div class="field"><label>DNS-Server (optional)</label>
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben
bei den Reservierungen — dieselbe HTML-Validität-Begründung). -->
{% if can_edit %}
{% for s in subnets %}
<div class="modal-overlay" id="editSubnetModal{{ loop.index }}">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_dhcp_subnet" value="{{ s.id }}">
<div class="modal-header">
<h3>Subnetz bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Interface</label>
<select name="interface" required>
{% for iface in interfaces %}
<option value="{{ iface }}" {% if iface == s.interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Range Start</label>
<input type="text" name="range_start" value="{{ s.range_start }}" required>
</div>
<div class="field"><label>Range Ende</label>
<input type="text" name="range_end" value="{{ s.range_end }}" required>
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div>
<div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div>
<div class="field"><label>DNS-Server (optional)</label>
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}