From 255308f81b006f1eeaa4744858c049d4440a36e2 Mon Sep 17 00:00:00 2001 From: alientim Date: Tue, 11 Aug 2026 16:39:03 +0200 Subject: [PATCH] App-weite Warnung bei ungespeicherten Aenderungen + Options-UI-Fix + Cache-Busting 1. Generischer Ja/Nein-Dialog bei ungespeicherten Aenderungen (kein Opt-in pro Seite noetig): initUnsavedChangesGuard() erkennt automatisch jedes Formular mit echten Eingabefeldern, das seit dem Laden der Seite veraendert wurde, und fragt beim Klick auf einen Link (z.B. Sidebar- Navigation), ob zuerst gespeichert werden soll -- eigener Dialog (confirmSaveDiscard) statt des bestehenden confirmAction (dort ist "Abbrechen" = Bleiben, hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder verwerfen, beide verlassen die Seite). "Ja, speichern" sendet das Formular ganz normal ab (echter POST) und merkt sich das eigentliche Ziel in sessionStorage, um danach automatisch dorthin weiterzuleiten. Nachtrag noch in dieser Session gefixt: ein normales Speichern ueber den Seiten-eigenen Button loeste zusaetzlich die native Browser-Warnung (beforeunload) aus, obwohl die Aenderung bereits gespeichert wurde -- navigatingAway-Flag wird jetzt bei jedem Submit eines beobachteten Formulars (eigener Button ODER eigener Ja/Nein-Dialog) gesetzt, die generische Browser-Warnung erscheint dadurch nur noch bei tatsaechlich ungefragter Navigation (Tab schliessen, Reload, Adresszeile). Live per Playwright in drei Szenarien verifiziert (direktes Speichern, Ja via eigenem Dialog, Nein via eigenem Dialog) -- kein Browser-Popup in den ersten beiden Faellen, korrektes Redirect zum urspruenglichen Ziel nach "Ja". 2. DHCP-Options-Dropdown (Reservierungs-Dialog) verschiebt ein neu ausgewaehltes Options-Feld jetzt direkt unter das Dropdown (in einen .poe-added-options-Container) statt es nur an seiner urspruenglichen Stelle in der bis zu 57 Eintraege langen Liste einzublenden -- lag dort oft ausserhalb des sichtbaren Modal-Ausschnitts und wirkte, als waere nichts passiert. Bereits gesetzte Overrides stehen von Anfang an im selben Container, weitere haengen sich beim Auswaehlen unten an. 3. Cache-Busting fuer app.js/style.css (asset_url()-Helper, haengt die Aenderungszeit der Datei als ?v=... an) -- ein im Browser gecachtes, veraltetes app.js konnte nach einem Update sonst den Eindruck erwecken, eine neue Funktion wuerde "nicht funktionieren", obwohl der Code auf dem Server laengst aktuell war. Alle drei live auf der Test-VM deployt und per Playwright verifiziert, keine Fehler im journalctl-Log. (Testartefakt aufgeraeumt: der echte Host-Hostname wurde durch die Playwright-Tests versehentlich auf "directsave-test"/"navsave-test" geaendert und wieder auf POETEST zurueckgesetzt.) --- README.md | 53 ++++++- srv/poe_manager/app.py | 16 ++ srv/poe_manager/static/js/app.js | 147 ++++++++++++++++++- srv/poe_manager/templates/base.html | 4 +- srv/poe_manager/templates/login.html | 2 +- srv/poe_manager/templates/settings_dhcp.html | 24 ++- 6 files changed, 231 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fd05715..9bf090b 100644 --- a/README.md +++ b/README.md @@ -298,9 +298,12 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client 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). + gesetztem Override sind sichtbar, weitere kommen über ein Dropdown dazu. + Das ausgewählte Feld wird dabei direkt unter das Dropdown verschoben und + fokussiert, statt an seiner ursprünglichen Stelle irgendwo in der u.U. + langen Liste (bis zu 57 mögliche Options) zu erscheinen — sonst lag es + oft außerhalb des sichtbaren Modal-Ausschnitts und wirkte, als wäre nichts + passiert (live genau so reproduziert und behoben). - **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 @@ -459,6 +462,50 @@ erscheint es statt der Initialen sowohl in der Sidebar als auch im Änderungslog neben dem Benutzernamen. Admins sehen auf derselben Seite zusätzlich die Navbar-Reihenfolge (siehe unten). +## Warnung bei ungespeicherten Änderungen + +Generischer, app-weiter Mechanismus (kein Opt-in pro Seite nötig): erkennt +automatisch, ob ein Formular mit echten Eingabefeldern (nicht nur +versteckten Aktions-Feldern wie bei Löschen-/Toggle-Buttons) seit dem Laden +der Seite verändert wurde, und fragt beim Verlassen (Klick auf einen Link, +z.B. in der Navigation) per eigenem **Ja/Nein-Dialog**, ob zuerst gespeichert +werden soll, statt Änderungen stillschweigend zu verwerfen: + +- **„Ja, speichern“**: sendet das betroffene Formular ganz normal ab (echter + POST, kein AJAX-Nachbau) und merkt sich das eigentliche Navigationsziel in + `sessionStorage`, um nach dem Speichern automatisch dorthin + weiterzuleiten — der Link muss dafür nicht ein zweites Mal angeklickt + werden. +- **„Nein, verwerfen“**: verwirft die Änderungen und wechselt direkt zur + Zielseite. +- Klickt man stattdessen auf das eigene „Speichern“/„Anlegen“ der Seite + direkt (ohne über einen Link zu einer anderen Seite zu gehen), passiert + **nichts zusätzlich** — kein Dialog, kein Browser-Popup, es wird einfach + ganz normal gespeichert. +- Für Browser-eigene Navigation (Tab schließen, Neuladen, Adresszeile) gibt + es zusätzlich `beforeunload` — dort erlaubt der Browser aus + Sicherheitsgründen nur eine generische Warnung ohne eigene Buttons, kein + Ja/Nein/Speichern-Dialog. Sie wird bewusst unterdrückt, sobald die Seite + ohnehin schon bewusst verlassen wird (eigenes Speichern oder der + Ja/Nein-Dialog) — sie soll ausschließlich bei tatsächlich ungefragter + Navigation erscheinen, nicht zusätzlich bei einem ganz normalen Speichern + (live als genau dieses Doppel-Popup reproduziert und behoben). +- Ausnahmen über `data-no-unsaved-guard` auf Formular oder Link (z.B. das + Login-Formular — Zugangsdaten eintippen ist keine "Einstellung"). + +Live per Playwright auf mehreren Seiten (Systemeinstellungen, DHCP-Options, +Gruppen anlegen) verifiziert: eigener Dialog erscheint nur bei tatsächlich +ungespeicherten Änderungen, „Ja“ speichert und leitet danach automatisch +zum ursprünglichen Ziel weiter, „Nein“ verwirft und wechselt direkt, ein +normales Speichern über den Seiten-eigenen Button zeigt weder eigenen noch +Browser-Dialog. + +Nebenbei wurde ein generisches Cache-Busting für `app.js`/`style.css` +ergänzt (`asset_url()`-Helper, hängt die Änderungszeit der Datei als +`?v=...` an) — vorher konnte ein bereits gecachtes, veraltetes `app.js` im +Browser nach einem Update scheinbar "nicht funktionierende" neue Funktionen +verursachen, obwohl der Code auf dem Server längst aktuell war. + ## Navigation anpassen Die Sidebar ist in drei aufklappbare Gruppen strukturiert: diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 9fdb37e..a85d116 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -378,6 +378,22 @@ def _ordered_nav_items(): return items +@app.context_processor +def inject_asset_version(): + """Cache-Busting für statische Dateien (CSS/JS): hängt die letzte + Änderungszeit der Datei als ?v=... an, damit Browser nach einem Update + (z.B. app.js) nicht stillschweigend eine veraltete, gecachte Version + weiterverwenden — sonst können neue Funktionen scheinbar "nicht + funktionieren", obwohl der Code auf dem Server längst aktuell ist.""" + def asset_url(filename): + try: + mtime = int(os.path.getmtime(os.path.join(app.static_folder, filename))) + except OSError: + mtime = 0 + return f"{url_for('static', filename=filename)}?v={mtime}" + return {"asset_url": asset_url} + + @app.context_processor def inject_nav(): if not current_user.is_authenticated: diff --git a/srv/poe_manager/static/js/app.js b/srv/poe_manager/static/js/app.js index f7365cc..84671c0 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/poe_manager/static/js/app.js @@ -154,6 +154,136 @@ openModal("confirm-modal"); }; + /* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */ + /* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben, + hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder + verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop + bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */ + function ensureSaveDiscardModal() { + if (document.getElementById("save-discard-modal")) return; + const html = ` + `; + document.body.insertAdjacentHTML("beforeend", html); + document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => { + btn.addEventListener("click", () => closeModal(btn)); + }); + document.getElementById("save-discard-modal").addEventListener("click", (e) => { + if (e.target.id === "save-discard-modal") closeModal(e.target); + }); + } + + window.confirmSaveDiscard = function (message, onSave, onDiscard) { + ensureSaveDiscardModal(); + document.getElementById("save-discard-message").innerText = message; + const yesBtn = document.getElementById("save-discard-yes"); + const noBtn = document.getElementById("save-discard-no"); + const freshYes = yesBtn.cloneNode(true); + yesBtn.parentNode.replaceChild(freshYes, yesBtn); + const freshNo = noBtn.cloneNode(true); + noBtn.parentNode.replaceChild(freshNo, noBtn); + freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); }); + freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); }); + openModal("save-discard-modal"); + }; + + /* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */ + /* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten + Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/ + Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor + dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein, + ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu + verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein + AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in + sessionStorage, um nach dem Speichern automatisch dorthin + weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden + muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile) + gibt es zusätzlich beforeunload — dort erlaubt der Browser aus + Sicherheitsgründen aber nur eine generische Warnung, kein eigenes + Ja/Nein/Speichern-Dialogfeld. */ + const PENDING_NAV_KEY = "poe-pending-nav-after-save"; + + function serializeForm(form) { + return new URLSearchParams(new FormData(form)).toString(); + } + + function initUnsavedChangesGuard() { + const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY); + if (pendingNav) { + sessionStorage.removeItem(PENDING_NAV_KEY); + window.location.href = pendingNav; + return; + } + + const trackable = []; + document.querySelectorAll("form[method='post' i]").forEach((form) => { + if (form.hasAttribute("data-no-unsaved-guard")) return; + const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea"); + if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld + trackable.push({ form, initial: serializeForm(form) }); + }); + if (!trackable.length) return; + + // Verhindert die native Browser-Warnung (beforeunload), wenn die Seite + // ohnehin schon bewusst verlassen wird — entweder weil eines der + // beobachteten Formulare ganz normal abgeschickt wurde (eigener + // "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener + // Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim + // normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die + // generische Browser-Meldung aufpoppen — die soll ausschließlich dann + // erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen, + // Reload, Adresszeile), nicht bei einem bewussten Speichern. + let navigatingAway = false; + trackable.forEach(({ form }) => { + form.addEventListener("submit", () => { navigatingAway = true; }); + }); + + function dirtyForm() { + return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null; + } + + document.addEventListener("click", (e) => { + const link = e.target.closest("a[href]"); + if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return; + const dirty = dirtyForm(); + if (!dirty) return; + e.preventDefault(); + const href = link.href; + window.confirmSaveDiscard( + "Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?", + () => { + navigatingAway = true; + sessionStorage.setItem(PENDING_NAV_KEY, href); + dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit(); + }, + () => { + navigatingAway = true; + window.location.href = href; + } + ); + }); + + window.addEventListener("beforeunload", (e) => { + if (!navigatingAway && dirtyForm()) { + e.preventDefault(); + e.returnValue = ""; + } + }); + } + /* Intercept forms/buttons marked with data-confirm="message" */ function initConfirmables() { document.querySelectorAll("form[data-confirm]").forEach((form) => { @@ -205,17 +335,21 @@ 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. */ + Verschiebt das Feld dafür direkt unter das Dropdown (in den Container + mit der Klasse "poe-added-options", der im Markup direkt danach steht) + statt es nur an seiner ursprünglichen Stelle einzublenden — bei bis zu + ~60 möglichen Optionen läge diese sonst weit unterhalb des sichtbaren + Modal-Ausschnitts und es wirkte so, als wäre gar nichts passiert. Jede + weitere Auswahl hängt ihr Feld unten an, sodass alle bereits + hinzugefügten Options sichtbar aufgereiht bleiben. */ window.poeShowOptionField = function (select) { const id = select.value; if (id) { const field = document.getElementById(id); - if (field) { + const container = select.nextElementSibling; + if (field && container && container.classList.contains("poe-added-options")) { + container.appendChild(field); field.style.display = ""; - field.scrollIntoView({ behavior: "smooth", block: "center" }); const input = field.querySelector("input"); if (input) input.focus(); } @@ -407,6 +541,7 @@ initCheckTimer(); initRunCheckNow(); initLeaseCountdowns(); + initUnsavedChangesGuard(); initSortableTables(); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); }); diff --git a/srv/poe_manager/templates/base.html b/srv/poe_manager/templates/base.html index 7d54515..69242e4 100644 --- a/srv/poe_manager/templates/base.html +++ b/srv/poe_manager/templates/base.html @@ -5,7 +5,7 @@ {{ title or "PoE Manager" }} - + {% block extra_head %}{% endblock %} @@ -147,7 +147,7 @@ {% endwith %} - + {% block scripts %}{% endblock %} diff --git a/srv/poe_manager/templates/login.html b/srv/poe_manager/templates/login.html index b2ace7f..960c71f 100644 --- a/srv/poe_manager/templates/login.html +++ b/srv/poe_manager/templates/login.html @@ -27,7 +27,7 @@ {% endif %} {% endwith %} -
+
diff --git a/srv/poe_manager/templates/settings_dhcp.html b/srv/poe_manager/templates/settings_dhcp.html index a151574..d8f0451 100644 --- a/srv/poe_manager/templates/settings_dhcp.html +++ b/srv/poe_manager/templates/settings_dhcp.html @@ -421,7 +421,7 @@ Nur Options mit gesetztem Override sind sichtbar — über das Dropdown weitere hinzufügen. Leerer Wert übernimmt den globalen Wert (bzw. entfernt den Override).

- {% for d in option_defs %} {% if not option_values.get(d.id, {}).get(r.mac) %} @@ -429,13 +429,31 @@ {% endif %} {% endfor %} + +
+ {% for d in option_defs %} + {% set current = option_values.get(d.id, {}).get(r.mac, '') %} + {% if current %} +
+ + +
+ {% endif %} + {% endfor %} +
{% for d in option_defs %} {% set current = option_values.get(d.id, {}).get(r.mac, '') %} -
+ {% if not current %} + + {% endif %} {% endfor %}