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 %}