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.)
This commit is contained in:
2026-08-11 16:39:03 +02:00
parent 1a0b55ed44
commit 255308f81b
6 changed files with 231 additions and 15 deletions
+50 -3
View File
@@ -298,9 +298,12 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
Top-Level `option-data`, Overrides im `option-data` der jeweiligen Top-Level `option-data`, Overrides im `option-data` der jeweiligen
Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt). Im Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt). Im
Reservierungs-Options-Dialog gilt dasselbe Prinzip: nur Options mit Reservierungs-Options-Dialog gilt dasselbe Prinzip: nur Options mit
gesetztem Override sind sichtbar, weitere kommen über ein Dropdown dazu gesetztem Override sind sichtbar, weitere kommen über ein Dropdown dazu.
(das neu eingeblendete Feld wird automatisch in den sichtbaren Bereich Das ausgewählte Feld wird dabei direkt unter das Dropdown verschoben und
gescrollt und fokussiert). 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 - **Reservierungen aus zwei Quellen**: automatisch für jedes aktive Gerät mit
gültiger MAC + IP (Client-Stammdaten dieser App) sowie zusätzlich **manuell gültiger MAC + IP (Client-Stammdaten dieser App) sowie zusätzlich **manuell
anlegbar** („Reservierung hinzufügen“, MAC/IP/Name) — für Geräte, die 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 Änderungslog neben dem Benutzernamen. Admins sehen auf derselben Seite
zusätzlich die Navbar-Reihenfolge (siehe unten). 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 ## Navigation anpassen
Die Sidebar ist in drei aufklappbare Gruppen strukturiert: Die Sidebar ist in drei aufklappbare Gruppen strukturiert:
+16
View File
@@ -378,6 +378,22 @@ def _ordered_nav_items():
return 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 @app.context_processor
def inject_nav(): def inject_nav():
if not current_user.is_authenticated: if not current_user.is_authenticated:
+141 -6
View File
@@ -154,6 +154,136 @@
openModal("confirm-modal"); 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 = `
<div class="modal-overlay" id="save-discard-modal">
<div class="modal" style="max-width:420px;">
<div class="modal-header">
<h3>Ungespeicherte Änderungen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-dim" id="save-discard-message"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="save-discard-no">Nein, verwerfen</button>
<button type="button" class="btn btn-primary" id="save-discard-yes">Ja, speichern</button>
</div>
</div>
</div>`;
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" */ /* Intercept forms/buttons marked with data-confirm="message" */
function initConfirmables() { function initConfirmables() {
document.querySelectorAll("form[data-confirm]").forEach((form) => { document.querySelectorAll("form[data-confirm]").forEach((form) => {
@@ -205,17 +335,21 @@
Feld (siehe settings_dhcp.html, "+ Option hinzufügen"-Auswahl bei Feld (siehe settings_dhcp.html, "+ Option hinzufügen"-Auswahl bei
Reservierungen) — so sind nur tatsächlich genutzte Options-Felder Reservierungen) — so sind nur tatsächlich genutzte Options-Felder
sichtbar, statt jede einzeln immer als leeres Eingabefeld anzuzeigen. sichtbar, statt jede einzeln immer als leeres Eingabefeld anzuzeigen.
Scrollt das neue Feld zusätzlich in den sichtbaren Bereich und fokussiert Verschiebt das Feld dafür direkt unter das Dropdown (in den Container
es — bei bis zu ~60 möglichen Optionen kann das neu eingeblendete Feld mit der Klasse "poe-added-options", der im Markup direkt danach steht)
sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen und wirkt statt es nur an seiner ursprünglichen Stelle einzublenden — bei bis zu
dann so, als wäre gar nichts passiert. */ ~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) { window.poeShowOptionField = function (select) {
const id = select.value; const id = select.value;
if (id) { if (id) {
const field = document.getElementById(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.style.display = "";
field.scrollIntoView({ behavior: "smooth", block: "center" });
const input = field.querySelector("input"); const input = field.querySelector("input");
if (input) input.focus(); if (input) input.focus();
} }
@@ -407,6 +541,7 @@
initCheckTimer(); initCheckTimer();
initRunCheckNow(); initRunCheckNow();
initLeaseCountdowns(); initLeaseCountdowns();
initUnsavedChangesGuard();
initSortableTables(); initSortableTables();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
}); });
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or "PoE Manager" }}</title> <title>{{ title or "PoE Manager" }}</title>
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}"> <link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
@@ -147,7 +147,7 @@
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script> <script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
{% endwith %} {% endwith %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script> <script src="{{ asset_url('js/app.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+1 -1
View File
@@ -27,7 +27,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="post"> <form method="post" data-no-unsaved-guard>
<div class="field"> <div class="field">
<label for="username">Benutzername</label> <label for="username">Benutzername</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus> <input type="text" id="username" name="username" autocomplete="username" required autofocus>
+21 -3
View File
@@ -421,7 +421,7 @@
Nur Options mit gesetztem Override sind sichtbar — über das Dropdown weitere hinzufügen. Nur Options mit gesetztem Override sind sichtbar — über das Dropdown weitere hinzufügen.
Leerer Wert übernimmt den globalen Wert (bzw. entfernt den Override). Leerer Wert übernimmt den globalen Wert (bzw. entfernt den Override).
</p> </p>
<select onchange="poeShowOptionField(this)" style="margin-bottom:14px;"> <select onchange="poeShowOptionField(this)">
<option value="">+ Option hinzufügen…</option> <option value="">+ Option hinzufügen…</option>
{% for d in option_defs %} {% for d in option_defs %}
{% if not option_values.get(d.id, {}).get(r.mac) %} {% if not option_values.get(d.id, {}).get(r.mac) %}
@@ -429,13 +429,31 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</select> </select>
<!-- Neu ausgewählte Felder landen hier direkt unter dem Dropdown
(siehe poeShowOptionField in app.js), statt irgendwo in der
u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte
Overrides stehen von Anfang an hier drin. -->
<div class="poe-added-options" style="margin:10px 0;">
{% for d in option_defs %}
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
{% if current %}
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}">
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
<input type="text" name="opt_{{ d.id }}" value="{{ current }}"
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
</div>
{% endif %}
{% endfor %}
</div>
{% for d in option_defs %} {% for d in option_defs %}
{% set current = option_values.get(d.id, {}).get(r.mac, '') %} {% 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 %}> {% if not current %}
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}" style="display:none;">
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label> <label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
<input type="text" name="opt_{{ d.id }}" value="{{ current }}" <input type="text" name="opt_{{ d.id }}" value=""
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}"> placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">