controller: drill F-11 — inline two-step confirm (felhomConfirm + data-confirm) replaces every native confirm(); native_confirm_gate.py enforces zero; stale-data delete keeps its double acknowledgement inline

Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
This commit is contained in:
2026-07-13 08:19:39 +02:00
parent 40b53047f1
commit 8987ce0f67
8 changed files with 141 additions and 62 deletions
+6 -2
View File
@@ -119,9 +119,13 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
**unified drive view**: server-rendered registry cards enriched in place from the agent `/api/disks` **unified drive view**: server-rendered registry cards enriched in place from the agent `/api/disks`
(role/durable-id/actions, joined on mount path), plus read-only **Rendszermeghajtók** and (role/durable-id/actions, joined on mount path), plus read-only **Rendszermeghajtók** and
**Nem regisztrált meghajtók** groups; agent-down degrades to a single warn note. Consequential **Nem regisztrált meghajtók** groups; agent-down degrades to a single warn note. Consequential
actions use an in-page `.confirm-overlay` (`openDialog`), never native `confirm()`/`prompt()`. actions use an in-page `.confirm-overlay` (`openDialog`) or the LIGHT inline two-step
(`felhomConfirm` in layout.html, v0.123.0: the trigger swaps in place to "kérdés + Igen/Mégse";
form buttons opt in via `data-confirm="…"`) — never native `confirm()`/`prompt()` (OS-modals
freeze browser automation; drill F-11).
**Enforcement:** `scripts/template_id_gate.py` (JS element-ID integrity) + `scripts/emoji_gate.py` **Enforcement:** `scripts/template_id_gate.py` (JS element-ID integrity) + `scripts/emoji_gate.py`
(no emoji — run after any template change; the Go `TestNoEmojiInTemplates` mirrors it). (no emoji) + `scripts/native_confirm_gate.py` (zero native confirm/prompt) — run after any
template change; the Go `TestNoEmojiInTemplates` mirrors the emoji gate.
- **Europe/Budapest timezone** — All scheduled jobs, timestamps, and UI labels use Hungarian timezone. - **Europe/Budapest timezone** — All scheduled jobs, timestamps, and UI labels use Hungarian timezone.
### Module Map ### Module Map
@@ -71,7 +71,7 @@
<option value="">Válassz céltárhelyet…</option> <option value="">Válassz céltárhelyet…</option>
{{range .MigrateTargets}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}} {{range .MigrateTargets}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}
</select> </select>
<button class="btn btn-sm btn-outline" onclick="appMigrate('{{.Stack.Name}}','{{.Meta.DisplayName}}')">Áthelyezés</button> <button class="btn btn-sm btn-outline" onclick="appMigrate(this,'{{.Stack.Name}}','{{.Meta.DisplayName}}')">Áthelyezés</button>
</div> </div>
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--blue);border-radius:6px;background:rgba(0,136,204,0.06)"></div> <div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--blue);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
</div> </div>
@@ -98,14 +98,15 @@ function appMigWatch(){
} }
tick(); tick();
} }
function appMigrate(app,label){ function appMigrate(btn,app,label){
var sel=document.getElementById('app-migrate-target'); var sel=document.getElementById('app-migrate-target');
var target=sel?sel.value:''; var target=sel?sel.value:'';
if(!target){ alert('Válassz céltárhelyet.'); return; } if(!target){ alert('Válassz céltárhelyet.'); return; }
if(!confirm('Áthelyezed a(z) '+label+' adatait ide: '+target+'?\n\nAz alkalmazás rövid időre leáll. A régi adatok csak sikeres áthelyezés után törlődnek.')) return; felhomConfirm(btn,'Áthelyezed a(z) '+label+' adatait ide: '+target+'? Az alkalmazás rövid időre leáll. A régi adatok csak sikeres áthelyezés után törlődnek.',function(){
fetch('/api/storage/migrate-app',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({app:app,target:target})}) fetch('/api/storage/migrate-app',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({app:app,target:target})})
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ appMigWatch(); } else { alert('Hiba: '+(d.error||'ismeretlen')); } }) .then(function(r){return r.json();}).then(function(d){ if(d.ok){ appMigWatch(); } else { alert('Hiba: '+(d.error||'ismeretlen')); } })
.catch(function(e){ alert('Hiba: '+e); }); .catch(function(e){ alert('Hiba: '+e); });
});
} }
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ appMigWatch(); } }).catch(function(){}); })(); (function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ appMigWatch(); } }).catch(function(){}); })();
</script> </script>
+17 -16
View File
@@ -182,9 +182,9 @@
<button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}NAS-mentés kikapcsolása{{else}}NAS-mentés bekapcsolása{{end}}</button> <button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}NAS-mentés kikapcsolása{{else}}NAS-mentés bekapcsolása{{end}}</button>
</form> </form>
{{if .Enabled}} {{if .Enabled}}
<form method="POST" action="/backup/offbox/restore" style="display:inline" onsubmit="return confirm('Visszaállítja a(z) {{.DisplayName}} adatait a NAS-ról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.')">{{$.CSRFField}} <form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}"> <input type="hidden" name="app" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-outline">Visszaállítás (ellenőrzéshez)</button> <button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a(z) {{.DisplayName}} adatait a távoli tárolóról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.">Visszaállítás (ellenőrzéshez)</button>
</form> </form>
{{end}} {{end}}
</div> </div>
@@ -469,9 +469,9 @@
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span> <span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions"> <div class="layer-actions">
{{if .Tier2LastRun}} {{if .Tier2LastRun}}
<form method="POST" action="/backup/tier2/restore" style="display:inline" onsubmit="return confirm('Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{.Tier2LastRun}}')">{{$.CSRFField}} <form method="POST" action="/backup/tier2/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}"> <input type="hidden" name="stack_name" value="{{.StackName}}">
<button type="submit" class="btn btn-xs btn-outline">Fájlok visszaállítása</button> <button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{.Tier2LastRun}}">Fájlok visszaállítása</button>
</form> </form>
{{end}} {{end}}
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a> <a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
@@ -673,19 +673,20 @@ function startBackupPolling() {
// Whole-guest (appliance) backup — manual trigger via the quiesce loop (controller-owned quiesce: // Whole-guest (appliance) backup — manual trigger via the quiesce loop (controller-owned quiesce:
// stop stacks → agent vzdump → resume). Returns immediately; we poll /api/guest-backup/status. // stop stacks → agent vzdump → resume). Returns immediately; we poll /api/guest-backup/status.
function triggerGuestBackup() { function triggerGuestBackup() {
if (!confirm('Elindítja a teljes rendszermentést most? A mentés ideje alatt az alkalmazások rövid időre leállhatnak.')) return;
var btn = document.getElementById('wg-backup-btn'); var btn = document.getElementById('wg-backup-btn');
var out = document.getElementById('wg-backup-result'); felhomConfirm(btn, 'Elindítja a teljes rendszermentést most? A mentés ideje alatt az alkalmazások rövid időre leállhatnak.', function () {
btn.disabled = true; var out = document.getElementById('wg-backup-result');
out.innerHTML = '<span class="form-hint">Mentés indítása…</span>'; btn.disabled = true;
fetch('/api/guest-backup/trigger', { method: 'POST', headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()) }) out.innerHTML = '<span class="form-hint">Mentés indítása…</span>';
.then(r => r.json()) fetch('/api/guest-backup/trigger', { method: 'POST', headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()) })
.then(data => { .then(r => r.json())
if (!data.ok) { out.innerHTML = '<div class="alert alert-error">' + (data.error || 'Hiba') + '</div>'; btn.disabled = false; return; } .then(data => {
out.innerHTML = '<span class="form-hint">Mentés folyamatban…</span>'; if (!data.ok) { out.innerHTML = '<div class="alert alert-error">' + (data.error || 'Hiba') + '</div>'; btn.disabled = false; return; }
pollGuestBackup(out, btn); out.innerHTML = '<span class="form-hint">Mentés folyamatban…</span>';
}) pollGuestBackup(out, btn);
.catch(e => { out.innerHTML = '<div class="alert alert-error">Hiba: ' + e + '</div>'; btn.disabled = false; }); })
.catch(e => { out.innerHTML = '<div class="alert alert-error">Hiba: ' + e + '</div>'; btn.disabled = false; });
});
} }
function pollGuestBackup(out, btn) { function pollGuestBackup(out, btn) {
+33 -31
View File
@@ -457,7 +457,7 @@ function renderWatchdogStatus(paths) {
var simBadge = p.simulated ? ' <span class="badge-warn">SIM</span>' : ''; var simBadge = p.simulated ? ' <span class="badge-warn">SIM</span>' : '';
var action = ''; var action = '';
if (p.status === 'connected' && !p.simulated) { if (p.status === 'connected' && !p.simulated) {
action = '<button class="btn btn-xs btn-secondary" onclick="simulateDisconnect(\'' + p.path + '\')">Leválasztás</button>'; action = '<button class="btn btn-xs btn-secondary" onclick="simulateDisconnect(this,\'' + p.path + '\')">Leválasztás</button>';
} else if (p.simulated) { } else if (p.simulated) {
action = '<button class="btn btn-xs btn-primary" onclick="simulateReconnect(\'' + p.path + '\')">Visszacsatl.</button>'; action = '<button class="btn btn-xs btn-primary" onclick="simulateReconnect(\'' + p.path + '\')">Visszacsatl.</button>';
} }
@@ -468,16 +468,17 @@ function renderWatchdogStatus(paths) {
html += '</table>'; html += '</table>';
document.getElementById('watchdog-status').innerHTML = html; document.getElementById('watchdog-status').innerHTML = html;
} }
function simulateDisconnect(path) { function simulateDisconnect(btn, path) {
if (!confirm('Biztosan szimulálja a leválasztást?\n\nEz leállítja az érintett alkalmazásokat.')) return; felhomConfirm(btn, 'Biztosan szimulálja a leválasztást? Ez leállítja az érintett alkalmazásokat.', function () {
fetch('/api/debug/storage/simulate-disconnect', { fetch('/api/debug/storage/simulate-disconnect', {
method: 'POST', method: 'POST',
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()), headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
body: JSON.stringify({path: path}) body: JSON.stringify({path: path})
}).then(function(r){return r.json()}).then(function(data) { }).then(function(r){return r.json()}).then(function(data) {
if (!data.ok) alert('Hiba: ' + (data.error || 'ismeretlen')); if (!data.ok) alert('Hiba: ' + (data.error || 'ismeretlen'));
loadWatchdogStatus(); loadWatchdogStatus();
}).catch(function(e) { alert('Hiba: ' + e.message); }); }).catch(function(e) { alert('Hiba: ' + e.message); });
});
} }
function simulateReconnect(path) { function simulateReconnect(path) {
fetch('/api/debug/storage/simulate-reconnect', { fetch('/api/debug/storage/simulate-reconnect', {
@@ -560,30 +561,31 @@ function triggerDR() {
result.textContent = 'Írja be: RESET'; result.textContent = 'Írja be: RESET';
return; return;
} }
if (!confirm('FIGYELEM! Ez újraindítja a controllert setup módba.\n\nBiztosan folytatja?')) return;
var btn = document.getElementById('btn-dr-trigger'); var btn = document.getElementById('btn-dr-trigger');
var result = document.getElementById('btn-dr-trigger-result'); felhomConfirm(btn, 'FIGYELEM! Ez újraindítja a controllert setup módba. Biztosan folytatja?', function () {
btn.disabled = true; var result = document.getElementById('btn-dr-trigger-result');
btn.textContent = 'Folyamatban...'; btn.disabled = true;
fetch('/api/debug/dr/trigger-setup', { btn.textContent = 'Folyamatban...';
method: 'POST', fetch('/api/debug/dr/trigger-setup', {
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()), method: 'POST',
body: JSON.stringify({confirm: 'RESET'}) headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
}).then(function(r){return r.json()}).then(function(data) { body: JSON.stringify({confirm: 'RESET'})
if (data.ok) { }).then(function(r){return r.json()}).then(function(data) {
result.className = 'debug-result debug-result-ok'; if (data.ok) {
result.textContent = 'Controller újraindulása...'; result.className = 'debug-result debug-result-ok';
} else { result.textContent = 'Controller újraindulása...';
} else {
result.className = 'debug-result debug-result-error';
result.textContent = data.error || 'Hiba';
btn.disabled = false;
btn.textContent = btn.dataset.label;
}
}).catch(function(e) {
result.className = 'debug-result debug-result-error'; result.className = 'debug-result debug-result-error';
result.textContent = data.error || 'Hiba'; result.textContent = 'Hiba: ' + e.message;
btn.disabled = false; btn.disabled = false;
btn.textContent = btn.dataset.label; btn.textContent = btn.dataset.label;
} });
}).catch(function(e) {
result.className = 'debug-result debug-result-error';
result.textContent = 'Hiba: ' + e.message;
btn.disabled = false;
btn.textContent = btn.dataset.label;
}); });
} }
@@ -793,14 +793,15 @@ function copyPostDeployCred(btn, val) {
} }
function deleteStaleData(stackName, stalePath, btn) { function deleteStaleData(stackName, stalePath, btn) {
if (!confirm('Biztosan törölni szeretnéd a korábbi adatokat?\n\nTárhely: ' + stalePath + '\n\nFIGYELEM: Ez a művelet visszavonhatatlan!\nElőtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.')) { // Two-step inline confirm (the irreversible delete keeps its double acknowledgement).
return; felhomConfirm(btn, 'Biztosan törölni szeretnéd a korábbi adatokat? Tárhely: ' + stalePath + ' — FIGYELEM: a művelet visszavonhatatlan! Előtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.', function () {
} felhomConfirm(btn, 'UTOLSÓ FIGYELMEZTETÉS! A törlés visszavonhatatlan. Biztosan folytatod?', function () {
// Second confirmation deleteStaleDataRun(stackName, stalePath, btn);
if (!confirm('UTOLSÓ FIGYELMEZTETÉS!\n\nA törlés visszavonhatatlan. Biztosan folytatod?')) { });
return; });
} }
function deleteStaleDataRun(stackName, stalePath, btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Törlés folyamatban...'; btn.textContent = 'Törlés folyamatban...';
@@ -10,6 +10,33 @@
<meta name="csrf-token" content="{{.CSRFToken}}"> <meta name="csrf-token" content="{{.CSRFToken}}">
<script> <script>
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};} function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
// Inline two-step confirm (drill F-11): the trigger swaps IN PLACE to "<question> Igen/Mégse".
// Never native confirm() — it is an OS-modal that blocks browser automation — and no overlay:
// this is the LIGHT pattern for one-click consequential actions. Form buttons opt in with
// data-confirm="…" (submitted via requestSubmit so formaction/name-value are honored);
// JS flows call felhomConfirm(el, question, onYes) directly.
function felhomConfirm(el,question,onYes){
if(!el||el.dataset.fcOpen)return;
el.dataset.fcOpen='1';
var wrap=document.createElement('span');wrap.className='inline-confirm';
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
no.addEventListener('click',close);
yes.addEventListener('click',function(){close();onYes();});
}
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
if(!btn)return;
e.preventDefault();
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
var form=btn.closest('form');
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
});
});
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);} function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
</script> </script>
</head> </head>
@@ -3270,3 +3270,7 @@ span.mono, .mono { font-family: var(--font-data); }
.drive-agent-extra .drive-actions { .drive-agent-extra .drive-actions {
margin: 0; margin: 0;
} }
/* Inline two-step confirm (drill F-11) — the in-place replacement for native confirm() */
.inline-confirm { display: inline-flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.inline-confirm-q { font-size: .8rem; color: var(--warn); }
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""Native-confirm gate (drill F-11) — native confirm()/prompt() dialogs are OS-modals that block
browser automation (CDP freezes) and are banned from the UI; consequential actions use the inline
felhomConfirm helper (layout.html) or the .confirm-overlay dialog.
Run from controller/: python scripts/native_confirm_gate.py
Exit 1 if any native confirm(/prompt( call remains in the templates.
"""
import io, os, re, sys
ROOTS = [
os.path.join("internal", "web", "templates"),
os.path.join("internal", "setup", "templates"),
]
# A bare confirm(/prompt( CALL with an argument: not preceded by an identifier character, so felhomConfirm( and
# onRestoreConfirmChange( never match. window.confirm( still matches ('.' is not identifier).
NATIVE = re.compile(r"(?<![A-Za-z0-9_$])(?:confirm|prompt)\(\s*[^)\s]")
def main():
total = 0
for root in ROOTS:
for fn in sorted(os.listdir(root)):
if not fn.endswith(".html"):
continue
path = os.path.join(root, fn)
for lineno, line in enumerate(io.open(path, encoding="utf-8"), 1):
if NATIVE.search(line):
total += 1
print("%s:%d %s" % (fn, lineno, line.strip()[:120].encode('ascii', 'backslashreplace').decode()))
if total:
print("NATIVE CONFIRM GATE FAILED: %d native confirm()/prompt() call(s) remain" % total)
sys.exit(1)
print("native-confirm gate OK — no native confirm()/prompt() in templates")
if __name__ == "__main__":
main()