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.
This commit is contained in:
@@ -182,13 +182,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if option_defs %}
|
||||
{% 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 option_defs %}
|
||||
{% for d in visible_option_defs|sort(attribute="code") %}
|
||||
<tr>
|
||||
<td class="mono">{{ d.code }}</td>
|
||||
<td class="mono">{{ d.name }}</td>
|
||||
@@ -209,18 +210,23 @@
|
||||
<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 %}
|
||||
{% 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>
|
||||
@@ -230,7 +236,29 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Noch keine eigenen Options definiert.</p>
|
||||
<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>
|
||||
|
||||
@@ -260,7 +288,14 @@
|
||||
<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;">{{ l.expires_at 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>
|
||||
@@ -292,13 +327,19 @@
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<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-secondary">
|
||||
<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>
|
||||
In Datei schreiben
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -306,7 +347,7 @@
|
||||
<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>Client-Name</th><th>Subnetz</th><th>Optionen</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<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>
|
||||
@@ -314,6 +355,13 @@
|
||||
<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 %}
|
||||
@@ -327,11 +375,25 @@
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if option_defs and can_edit %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
@@ -355,11 +417,23 @@
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">Leer lassen, um den globalen Wert zu übernehmen.</p>
|
||||
<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 %}
|
||||
<div class="field">
|
||||
{% 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="{{ option_values.get(d.id, {}).get(r.mac, '') }}"
|
||||
<input type="text" name="opt_{{ d.id }}" value="{{ current }}"
|
||||
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -422,6 +496,39 @@
|
||||
</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>×</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">
|
||||
|
||||
Reference in New Issue
Block a user