From 1a0b55ed442d6901d69949fb0724e4b5c2f98a4c Mon Sep 17 00:00:00 2001 From: alientim Date: Tue, 11 Aug 2026 15:52:52 +0200 Subject: [PATCH] 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. --- README.md | 97 +++++++----- srv/poe_manager/app.py | 150 ++++++++++++++++--- srv/poe_manager/static/css/style.css | 1 + srv/poe_manager/static/js/app.js | 53 +++++++ srv/poe_manager/templates/settings_dhcp.html | 147 +++++++++++++++--- 5 files changed, 371 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 8b00e43..fd05715 100644 --- a/README.md +++ b/README.md @@ -271,45 +271,70 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client kein "ungültig", da das Fehlen des Netzes hier Absicht ist), statt es jedes Mal neu anlegen zu müssen. - **DHCP-Options** (`dhcp_option_defs`/`dhcp_option_values`): eine kuratierte - Auswahl an **47 Standard-Optionen** (analog den „Vordefinierten Optionen“ - eines Windows-DHCP-Servers — z.B. NTP-/WINS-/TFTP-/SMTP-Server, Boot-Datei, - Domain-Search, IP-Forwarding) ist automatisch vorbefüllt; Router/DNS-Server/ - Domain-Name/Lease-Zeiten sind bewusst ausgenommen, da dafür bereits eigene, - dedizierte Felder existieren (Subnetze bzw. globale Einstellungen oben). - Alle 47 Namen sind live gegen eine echte Kea-2.4.1-Instanz verifiziert - (`kea-dhcp4 -t`, jede Option einzeln getestet). Standard-Optionen bekommen - bewusst **kein eigenes `option-def`** in der generierten Konfiguration — - Kea kennt sie bereits nativ, eine Neudefinition würde die eingebaute - duplizieren; nur zusätzlich angelegte, wirklich eigene/herstellerspezifische - Options (z.B. eine Terminal-Boot-URL) bekommen eines. Beide Arten - funktionieren im UI identisch: Code, Name, Kea-Datentyp und Beschreibung - frei definierbar, jede Option hat einen **globalen** Wert sowie optional - einen **Wert pro Client** — ein Client-Override überschreibt den globalen - Wert ausschließlich für dieses eine Gerät (technisch: globale Werte landen - im Top-Level `option-data`, Overrides im `option-data` der jeweiligen - Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt). - Ein versehentlich vom Admin gelöschter Standard-Eintrag wird nach einem - Neustart nicht automatisch wieder angelegt (einmalige Migration, kein - Zurückschreiben bei jedem Start). -- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP wird eine - Kea-Reservierung im **passenden Subnetz** erzeugt (die IP entscheidet, zu - welchem der ggf. mehreren konfigurierten Subnetze ein Gerät gehört); der - Hostname wird aus dem Gerätenamen abgeleitet (nur `a-z0-9-`, Kollisionen + Auswahl an **57 Standard-Optionen** (analog den „Vordefinierten Optionen“ + eines Windows-DHCP-Servers — u.a. Hostname erzwingen, NTP-/WINS-/TFTP-/ + SMTP-Server, Boot-Datei, Domain-Search, IP-Forwarding) ist automatisch + vorbefüllt und **nicht löschbar** (Backend lehnt es ab, UI zeigt statt + eines Löschen-Buttons ein Schloss-Symbol) — Router/DNS-Server/Domain-Name/ + Lease-Zeiten sind bewusst ausgenommen, da dafür bereits eigene, dedizierte + Felder existieren (Subnetze bzw. globale Einstellungen oben); ebenso + client-/protokollinterne Optionen (message-type, parameter-request-list, + requested-address, ...), die serverseitig nicht sinnvoll setzbar sind. + Alle 57 Namen sind live gegen eine echte Kea-2.4.1-Instanz verifiziert + (`kea-dhcp4 -t`, jede Option einzeln getestet — u.a. „host-name“ mit + Bindestrich statt „hostname“ als Kea-Name richtiggestellt). Standard- + Optionen bekommen bewusst **kein eigenes `option-def`** in der generierten + Konfiguration — Kea kennt sie bereits nativ, eine Neudefinition würde die + eingebaute duplizieren; nur zusätzlich angelegte, wirklich eigene/ + herstellerspezifische Options (z.B. eine Terminal-Boot-URL) bekommen eines + und bleiben frei löschbar. + In der Tabelle sind grundsätzlich nur **tatsächlich genutzte** Options + sichtbar (eigene immer, Standard-Optionen nur mit gesetztem globalen Wert) + — weitere Standard-Optionen kommen über ein Dropdown „+ Standard-Option + hinzufügen“ dazu, statt alle 57 dauerhaft als leere Zeilen anzuzeigen. + Jede Option hat einen **globalen** Wert sowie optional einen **Wert pro + Client** — ein Client-Override überschreibt den globalen Wert + ausschließlich für dieses eine Gerät (technisch: globale Werte landen im + Top-Level `option-data`, Overrides im `option-data` der jeweiligen + Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt). Im + Reservierungs-Options-Dialog gilt dasselbe Prinzip: nur Options mit + gesetztem Override sind sichtbar, weitere kommen über ein Dropdown dazu + (das neu eingeblendete Feld wird automatisch in den sichtbaren Bereich + gescrollt und fokussiert). +- **Reservierungen aus zwei Quellen**: automatisch für jedes aktive Gerät mit + gültiger MAC + IP (Client-Stammdaten dieser App) sowie zusätzlich **manuell + anlegbar** („Reservierung hinzufügen“, MAC/IP/Name) — für Geräte, die + nicht als Client gepflegt werden (z.B. ein externes Gerät), sodass ihnen + bewusst und mit voller Kontrolle eine feste IP gegeben werden kann, statt + sie unüberwacht automatisch anzubinden. Automatische Reservierungen können + in dieser Tabelle **nicht gelöscht** werden (Schloss-Symbol statt Button — + sie entstehen/verschwinden mit den Client-Stammdaten), manuelle schon. Bei + einer MAC in beiden Quellen gewinnt immer die automatische. Jede + Reservierung wird ins **passende Subnetz** einsortiert (die IP entscheidet, + zu welchem der ggf. mehreren konfigurierten Subnetze sie gehört); der + Hostname wird aus dem Namen abgeleitet (nur `a-z0-9-`, Kollisionen innerhalb desselben Subnetzes bekommen automatisch ein `-2`/`-3`-Suffix). - Geräte ohne MAC/IP oder mit einer IP außerhalb aller konfigurierten + Einträge ohne MAC/IP oder mit einer IP außerhalb aller konfigurierten Subnetze werden mit Hinweis übersprungen — Kea lehnt eine Reservierung außerhalb ihres Subnets als Konfigurationsfehler ab (mit einer echten - Kea-Instanz verifiziert). + Kea-Instanz verifiziert, inkl. einer Mischung aus automatischer und + manueller Reservierung im selben Subnetz). - **Aktive Leases**: eigene Karte, liest die tatsächlich vergebenen Adressen direkt aus Keas Lease-Datenbank (`kea-leases4.csv`, memfile-Backend — kein Control-Agent nötig, reiner Lesezugriff auf die Datei). Zeigt damit auch Clients **ohne eigene Reservierung**, die sich einfach eine freie IP aus dem Pool genommen haben — deutlich als „Ohne Reservierung“ markiert, - statt nur die (unvollständige) Reservierungsliste zu zeigen. -- **Schreiben**: „In Datei schreiben“ (nur mit `settings_dhcp.edit`) - schreibt die vollständige, generierte Kea-JSON-Konfiguration an den - konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem - Dienst-Neustart über den separaten Button. + statt nur die (unvollständige) Reservierungsliste zu zeigen. Die + Ablaufzeit wird explizit als **UTC** ausgewiesen (Kea arbeitet intern in + UTC-Epoch) und zusätzlich als live tickender Countdown angezeigt — eine + reine Absolutzeit auf einer nicht selbst aktualisierenden Seite wirkte bei + den kurzen Standard-Lease-Zeiten (600s) schnell „schon abgelaufen“, sobald + etwas Zeit vergangen war, bis man tatsächlich hinschaut (live auf der + Test-VM als genau dieses Missverständnis reproduziert). +- **Schreiben**: „Speichern“ bei den Reservierungen (nur mit + `settings_dhcp.edit`) schreibt die vollständige, generierte Kea-JSON- + Konfiguration an den konfigurierten Ausgabepfad. Wirksam wird sie erst + nach einem Dienst-Neustart über den separaten Button. - **Ampel im Topbar**: neben dem Prüfintervall-Timer zeigt ein grüner/roter Punkt, ob der Kea-Dienst läuft — nur sichtbar mit `settings_dhcp.view`. - Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche @@ -331,12 +356,10 @@ Netzes (identischer `subnet`-CIDR, unterschiedliche Pools); ob Kea zwei `subnet4`-Blöcke mit identischem `subnet`-Wert akzeptiert oder als Fehler ablehnt, wurde nicht gegen eine echte Kea-Instanz geprüft — bei tatsächlich unterschiedlichen Netzen (der eigentliche Anwendungsfall) besteht dieses -Risiko nicht. Die Leases-Anzeige wurde gegen eine echte (leere) -`kea-leases4.csv` verifiziert (korrektes Parsen, keine Endlos-/Absturz- -Fälle) — eine tatsächlich unreservierte Lease eines fremden Clients live -zu erzeugen und die „Ohne Reservierung“-Markierung damit End-to-End zu -zeigen, wurde nicht getestet (hätte einen echten, unkontrollierten -DHCP-Request auf dem gemeinsam genutzten Testnetz erfordert). +Risiko nicht. Die Leases-Anzeige wurde vollständig End-to-End im echten +Betrieb bestätigt: ein tatsächlicher, unreservierter Client im Testnetz +(„shellyhtg3-...“) hat sich selbstständig eine IP aus dem Pool geholt und +wurde korrekt mit der „Ohne Reservierung“-Markierung angezeigt. ## Netzwerkeinstellungen des Hosts (Systemeinstellungen) diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 64334e2..9fdb37e 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -496,8 +496,13 @@ def get_db_connection(): # Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen. # (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/ # Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte -# Felder und deshalb bewusst nicht enthalten. Alle 47 Namen live gegen eine -# echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option einzeln). +# Felder und deshalb bewusst nicht enthalten. Client-/Protokoll-interne +# Optionen (message-type, parameter-request-list, requested-address, +# client-identifier, server-identifier, ...) ebenfalls bewusst nicht +# enthalten — die sind serverseitig nicht sinnvoll setzbar (auch Windows +# Server listet sie nicht unter "Predefined Options"). Alle 57 Namen live +# gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option +# einzeln) — u.a. "host-name" (Kea-Name mit Bindestrich, NICHT "hostname"). DHCP_STANDARD_OPTIONS = [ (4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"), (7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"), @@ -505,11 +510,19 @@ DHCP_STANDARD_OPTIONS = [ (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"), (10, "impress-servers", "ipv4-address", "Impress-Server"), (11, "resource-location-servers", "ipv4-address", "Resource Location Server"), + (12, "host-name", "string", "Hostname (für Reservierung erzwingbar)"), + (13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"), + (14, "merit-dump", "string", "Crash-Dump-Datei"), (16, "swap-server", "ipv4-address", "Swap-Server"), (17, "root-path", "string", "Root-Pfad (NFS)"), (18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"), (19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"), (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"), + (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"), + (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"), + (23, "default-ip-ttl", "uint8", "Standard-TTL für IP"), + (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"), + (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"), (26, "interface-mtu", "uint16", "MTU des Interfaces"), (27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"), (28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"), @@ -532,6 +545,7 @@ DHCP_STANDARD_OPTIONS = [ (47, "netbios-scope", "string", "NetBIOS-Scope-ID"), (48, "font-servers", "ipv4-address", "X-Window-Font-Server"), (49, "x-display-manager", "ipv4-address", "X-Window Display Manager"), + (60, "vendor-class-identifier", "string", "Vendor Class Identifier"), (64, "nisplus-domain-name", "string", "NIS+-Domain"), (65, "nisplus-servers", "ipv4-address", "NIS+-Server"), (66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"), @@ -545,6 +559,7 @@ DHCP_STANDARD_OPTIONS = [ (74, "irc-server", "ipv4-address", "IRC-Server"), (75, "streettalk-server", "ipv4-address", "StreetTalk-Server"), (76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"), + (118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"), (119, "domain-search", "string", "DNS-Suchliste (Domain Search)"), ] @@ -687,6 +702,34 @@ def _ensure_schema(): (code, name, opt_type, description), ) conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,)) + # v2: Liste um weitere verifizierte Standard-Optionen erweitert (u.a. + # host-name/12) — eigener Guard, damit bereits auf v1 migrierte + # Installationen die neu hinzugekommenen Optionen ebenfalls bekommen, + # ohne die gesamte Seed-Logik erneut über alle Zeilen laufen zu lassen. + _migration_key_std_opts_v2 = "_seeded_dhcp_standard_options_v2" + if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v2,)).fetchone(): + for code, name, opt_type, description in DHCP_STANDARD_OPTIONS: + conn.execute( + "INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) " + "VALUES (?, ?, ?, ?, 1)", + (code, name, opt_type, description), + ) + conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v2,)) + + # DHCP: manuelle Reservierungen — im Unterschied zu den automatisch aus + # den Clients (devices-Tabelle) erzeugten Reservierungen für Geräte, die + # NICHT als Client in dieser App gepflegt werden (z.B. ein fremdes/ + # externes Gerät, dem bewusst und mit voller Kontrolle eine feste IP + # gegeben werden soll, statt es unüberwacht automatisch anzubinden). + # Können im Gegensatz zu automatischen Reservierungen gelöscht werden. + conn.execute(""" + CREATE TABLE IF NOT EXISTS dhcp_manual_reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT NOT NULL UNIQUE, + ip TEXT NOT NULL, + name TEXT NOT NULL + ) + """) # DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen # Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz @@ -2025,25 +2068,44 @@ def _dhcp_valid_subnets(subnets): def _dhcp_reservation_candidates(conn, valid_subnets): - """Aktive Geräte mit gültiger MAC + IP, aus denen sich eine feste - DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden + """Baut die Liste der Kea-Reservierungen aus ZWEI Quellen: automatisch + aus aktiven Geräten mit gültiger MAC + IP (Clients dieser App) sowie + zusätzlich aus manuell angelegten Reservierungen (dhcp_manual_reservations + — für Geräte, die nicht als Client gepflegt werden, z.B. ein externes + Gerät, dem bewusst eine feste IP gegeben werden soll). Bei einer MAC in + beiden Quellen gewinnt die automatische (echte Client-Daten vor einer + ggf. veralteten manuellen Zeile). Geräte ohne rpi_ip/mac werden übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso - übersprungen: Geräte, deren IP zu KEINEM der übergebenen (bereits + übersprungen: Einträge, deren IP zu KEINEM der übergebenen (bereits validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb ihres subnet4-Blocks als Konfigurationsfehler ab (mit einem Live-Test gegen echtes Kea verifiziert). Gibt (reservations_by_subnet_id, - out_of_subnet_count) zurück — jedes Gerät landet bei genau einem + out_of_subnet_count) zurück — jeder Eintrag landet bei genau einem Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere Subnetze gleichzeitig anbieten).""" - rows = conn.execute( - "SELECT mac, rpi_ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC" + auto_rows = conn.execute( + "SELECT mac, rpi_ip AS ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC" ).fetchall() + manual_rows = conn.execute("SELECT id, mac, ip, name FROM dhcp_manual_reservations ORDER BY name ASC").fetchall() + + # Case-insensitiv nach MAC dedupliziert (Geräte/manuelle Zeilen können + # unterschiedlich geschriebene MACs haben) — automatisch gewinnt immer. + candidates = {} # MAC (upper) -> {mac, ip, name, source, manual_id} + for row in manual_rows: + candidates[row["mac"].upper()] = { + "mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "manual", "manual_id": row["id"], + } + for row in auto_rows: + candidates[row["mac"].upper()] = { + "mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "auto", "manual_id": None, + } + seen_hostnames_by_subnet = {s["id"]: set() for s in valid_subnets} reservations_by_subnet = {s["id"]: [] for s in valid_subnets} out_of_subnet = 0 - for row in rows: + for c in sorted(candidates.values(), key=lambda c: c["name"]): try: - ip = ipaddress.IPv4Address(row["rpi_ip"]) + ip = ipaddress.IPv4Address(c["ip"]) except ValueError: out_of_subnet += 1 continue @@ -2051,7 +2113,7 @@ def _dhcp_reservation_candidates(conn, valid_subnets): if matched is None: out_of_subnet += 1 continue - hostname = _dhcp_safe_hostname(row["name"], row["mac"]) + hostname = _dhcp_safe_hostname(c["name"], c["mac"]) # Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem # Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit # demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen). @@ -2062,8 +2124,8 @@ def _dhcp_reservation_candidates(conn, valid_subnets): suffix += 1 seen.add(hostname) reservations_by_subnet[matched["id"]].append({ - "mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"], - "subnet_id": matched["id"], + "mac": c["mac"], "ip": c["ip"], "hostname": hostname, "name": c["name"], "source": c["source"], + "manual_id": c["manual_id"], "subnet_id": matched["id"], "subnet_label": f"{matched['interface']} ({matched['net_info']['network']})", }) return reservations_by_subnet, out_of_subnet @@ -2118,7 +2180,14 @@ def _dhcp_read_leases(): "ip": row.get("address") or "", "mac": (row.get("hwaddr") or "").upper(), "hostname": row.get("hostname") or "", - "expires_at": datetime.fromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None, + # Absolut UND als Epoch-ms für einen live tickenden Countdown im + # Browser (siehe initLeaseCountdowns in app.js) — eine reine + # Absolutzeit beim Seitenaufruf wirkt bei kurzen Lease-Zeiten + # (Default 600s) schnell "schon abgelaufen", sobald etwas Zeit + # vergangen ist, bis man sie tatsächlich anschaut. Explizit als + # UTC ausgewiesen, da Kea intern in UTC-Epoch arbeitet. + "expires_at": datetime.utcfromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None, + "expire_epoch_ms": expire * 1000 if expire else None, }) try: leases.sort(key=lambda l: ipaddress.IPv4Address(l["ip"])) @@ -2347,12 +2416,15 @@ def settings_dhcp(): elif "delete_dhcp_option" in request.form: option_def_id = request.form.get("delete_dhcp_option") - option_def = conn.execute("SELECT name FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone() - conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (option_def_id,)) - conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (option_def_id,)) - conn.commit() - log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id) - flash("Option gelöscht.", "success") + option_def = conn.execute("SELECT name, is_standard FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone() + if option_def and option_def["is_standard"]: + flash("Standard-Optionen können nicht gelöscht werden — leeren Wert speichern, um sie auszublenden.", "danger") + else: + conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (option_def_id,)) + conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (option_def_id,)) + conn.commit() + log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id) + flash("Option gelöscht.", "success") elif "save_dhcp_option_global" in request.form: option_def_id = request.form.get("option_def_id") @@ -2385,6 +2457,44 @@ def settings_dhcp(): log_action("settings.dhcp_option_value", mac, "Client-Override") flash(f"DHCP-Optionen für {mac} gespeichert.", "success") + elif "add_dhcp_reservation" in request.form: + mac = request.form.get("mac", "").strip().upper() + ip = request.form.get("ip", "").strip() + name = request.form.get("name", "").strip() + if not re.match(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", mac): + flash("Ungültige MAC-Adresse (Format xx:xx:xx:xx:xx:xx).", "danger") + elif not name: + flash("Name darf nicht leer sein.", "danger") + else: + try: + ipaddress.IPv4Address(ip) + except ValueError: + flash("Ungültige IPv4-Adresse.", "danger") + else: + existing_device = conn.execute( + "SELECT 1 FROM devices WHERE is_active=1 AND UPPER(mac)=?", (mac,) + ).fetchone() + if existing_device: + flash(f"MAC {mac} gehört bereits zu einem Client — dafür entsteht die Reservierung automatisch.", "danger") + else: + try: + conn.execute( + "INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?)", (mac, ip, name) + ) + conn.commit() + log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})") + flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success") + except sqlite3.IntegrityError: + flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger") + + elif "delete_dhcp_reservation" in request.form: + reservation_id = request.form.get("delete_dhcp_reservation") + row = conn.execute("SELECT mac FROM dhcp_manual_reservations WHERE id=?", (reservation_id,)).fetchone() + conn.execute("DELETE FROM dhcp_manual_reservations WHERE id=?", (reservation_id,)) + conn.commit() + log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id) + flash("Manuelle Reservierung gelöscht.", "success") + elif "write_dhcp_file" in request.form: cfg = _load_dhcp_settings() valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn)) diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index 2ff0ded..b43bf30 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -1202,6 +1202,7 @@ select { .mt-3 { margin-top: 14px; } .text-dim { color: var(--text-dim); } .text-faint { color: var(--text-faint); } +.text-danger { color: var(--danger); } .hidden { display: none !important; } /* ========================================================================== diff --git a/srv/poe_manager/static/js/app.js b/srv/poe_manager/static/js/app.js index 89f0b9b..f7365cc 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/poe_manager/static/js/app.js @@ -201,6 +201,28 @@ setTimeout(remove, 5000); }; + /* Zeigt ein per Dropdown ausgewähltes, bis dahin verstecktes DHCP-Options- + Feld (siehe settings_dhcp.html, "+ Option hinzufügen"-Auswahl bei + Reservierungen) — so sind nur tatsächlich genutzte Options-Felder + sichtbar, statt jede einzeln immer als leeres Eingabefeld anzuzeigen. + Scrollt das neue Feld zusätzlich in den sichtbaren Bereich und fokussiert + es — bei bis zu ~60 möglichen Optionen kann das neu eingeblendete Feld + sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen und wirkt + dann so, als wäre gar nichts passiert. */ + window.poeShowOptionField = function (select) { + const id = select.value; + if (id) { + const field = document.getElementById(id); + if (field) { + field.style.display = ""; + field.scrollIntoView({ behavior: "smooth", block: "center" }); + const input = field.querySelector("input"); + if (input) input.focus(); + } + select.value = ""; + } + }; + function initFlashedMessages() { const data = document.getElementById("flashed-data"); if (!data) return; @@ -384,9 +406,40 @@ initNavGroups(); initCheckTimer(); initRunCheckNow(); + initLeaseCountdowns(); initSortableTables(); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); }); + /* ---------------- DHCP-Lease-Countdown (settings_dhcp.html) ---------------- */ + /* Ergänzt die absolute (UTC-)Ablaufzeit einer Lease um einen live tickenden + Countdown — bei den kurzen Standard-Lease-Zeiten (600s) wirkt eine reine + Absolutzeit sonst schnell "schon abgelaufen", sobald etwas Zeit vergangen + ist, bis man die (nicht selbst aktualisierende) Seite tatsächlich + anschaut. Gleiches Tick-Prinzip wie initCheckTimer oben. */ + function formatLeaseCountdown(remainingMs) { + const abs = Math.abs(Math.round(remainingMs / 1000)); + const m = Math.floor(abs / 60); + const s = abs % 60; + const duration = m > 0 ? `${m}m ${s}s` : `${s}s`; + return remainingMs >= 0 ? `läuft ab in ${duration}` : `abgelaufen vor ${duration}`; + } + + function initLeaseCountdowns() { + const els = document.querySelectorAll(".lease-countdown"); + if (!els.length) return; + function tick() { + const now = Date.now(); + els.forEach((el) => { + const expireMs = parseInt(el.dataset.expireMs, 10); + if (isNaN(expireMs)) return; + el.textContent = formatLeaseCountdown(expireMs - now); + el.classList.toggle("text-danger", expireMs < now); + }); + } + tick(); + setInterval(tick, 1000); + } + window.PoeUI = { openModal, closeModal }; })(); diff --git a/srv/poe_manager/templates/settings_dhcp.html b/srv/poe_manager/templates/settings_dhcp.html index 2b72ef3..a151574 100644 --- a/srv/poe_manager/templates/settings_dhcp.html +++ b/srv/poe_manager/templates/settings_dhcp.html @@ -182,13 +182,14 @@ {% endif %} - {% 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 %}
- {% for d in option_defs %} + {% for d in visible_option_defs|sort(attribute="code") %} @@ -209,18 +210,23 @@ +
Leeren Wert speichern, um eine Standard-Option wieder auszublenden.
{% else %} {{ option_values.get(d.id, {}).get('', '—') }} {% endif %} @@ -230,7 +236,29 @@ {% else %} -

Noch keine eigenen Options definiert.

+

Noch keine Options in Verwendung.

+ {% 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 %} + + +
+ + +
+
+ + +
+ + + {% endif %} {% endif %} @@ -260,7 +288,14 @@ - +
CodeNameTypHerkunftBeschreibungGlobaler WertAktionen
{{ d.code }} {{ d.name }} - {% if can_edit %} + {% if can_edit and not d.is_standard %}
+ {% elif d.is_standard %} + + + {% endif %}
{{ l.ip }} {{ l.mac }} {{ l.hostname or '—' }}{{ l.expires_at or '—' }} + {% if l.expires_at %} + {{ l.expires_at }} UTC +
+ {% else %} + — + {% endif %} +
{% if l.reserved %} Reserviert @@ -292,13 +327,19 @@ {% if can_edit %} -
- - -
+
+ + +
+ {% endif %} @@ -306,7 +347,7 @@
- + {% for r in reservations %} @@ -314,6 +355,13 @@ + {% endfor %} @@ -355,11 +417,23 @@ + +
HostnameMACIP-AdresseClient-NameSubnetzOptionenAktionen
HostnameMACIP-AdresseNameQuelleSubnetzOptionenAktionen
{{ r.mac }} {{ r.ip }} {{ r.name }} + {% if r.source == 'manual' %} + Manuell + {% else %} + Auto (Client) + {% endif %} + {{ r.subnet_label }} {% for d in option_defs %} @@ -327,11 +375,25 @@ {% endfor %} - {% if option_defs and can_edit %} - - {% endif %} +
+ {% if option_defs and can_edit %} + + {% endif %} + {% if can_edit and r.source == 'manual' %} +
+ + +
+ {% elif r.source == 'auto' %} + + + + {% endif %} +