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 %}
| Code | Name | Typ | Herkunft | Beschreibung | Globaler Wert | Aktionen |
|---|---|---|---|---|---|---|
| {{ d.code }} | {{ d.name }} | @@ -209,18 +210,23 @@ +- {% 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 %} - |