Files
felhom-controller/controller/internal/web/templates/backups_restore.html
T
admin 11b790e314
gates / gates (push) Successful in 20s
v0.249.0: i18n slice 1 release B — backup pages in English, Hungarian byte-identical (R-556)
Seven backup pages + the restore-progress JS converted against fixtures captured unconverted
(f8ebc47); recovery renders through executeTemplateLang; English retrieval claims registered;
the Fut comparison left unconverted (R-563).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:01:09 +02:00

429 lines
23 KiB
HTML

{{define "backups_restore"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>{{T "backups_restore.biztonsagi_mentes_visszaallitas"}}</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{template "backups_flash" .}}
{{with .InterruptedRestores}}
<!-- R-550: a restore the box was running when it stopped. Persisted, so it survives the restart that
interrupted it; shown until that app's next restore. -->
<div class="settings-card">
<h3>{{T "backups_restore.megszakadt_visszaallitas"}}</h3>
{{range .}}
<div class="alert alert-error"><strong>{{.Stack}}</strong>: {{.Message}}</div>
{{end}}
<p class="form-hint">{{T "backups_restore.a_doboz_ujraindult_mikozben_a"}}</p>
</div>
{{end}}
{{template "restore_banner" .}}
{{if not .Backup}}
{{template "backups_empty" .}}
{{else}}
<!-- Section 7: Restore -->
{{if .Backup.AppDataInfo}}
<div class="backup-section-card">
<h3>{{T "backups_restore.visszaallitas"}}</h3>
<div class="restore-section">
<div class="restore-form-row">
<label class="restore-label">{{T "backups_restore.alkalmazas"}}</label>
<select id="restore-app" class="restore-select" onchange="onRestoreAppChange()">
<option value="">{{T "backups_restore.valasszon"}}</option>
{{range .Backup.AppDataInfo}}
<option value="{{.StackName}}" data-has-hdd="{{.HasHDDData}}" data-has-db="{{.HasDBDump}}" data-has-volumes="{{.HasVolumeData}}">{{.DisplayName}}</option>
{{end}}
{{/* R-487: removed apps whose unit was kept — the restore reinstalls them (R-253). */}}
{{if .RemovedApps}}
<optgroup label="{{T "backups_restore.eltavolitott_alkalmazasok_mentes_megtart"}}">
{{range .RemovedApps}}
<option value="{{.StackName}}" data-removed="true" data-has-hdd="false" data-has-db="false" data-has-volumes="true">{{T "backups_restore.eltavolitva"}}</option>
{{end}}
</optgroup>
{{end}}
</select>
</div>
<div class="restore-form-row">
<label class="restore-label">{{T "backups_restore.pillanatkep"}}</label>
<select id="restore-snapshot" class="restore-select" onchange="onRestoreConfirmChange()">
<option value="">{{T "backups_restore.valasszon_alkalmazast"}}</option>
</select>
</div>
<div id="restore-type-info" class="restore-info" style="display:none;margin-bottom:0.5rem">
</div>
<div id="restore-no-snapshots" class="restore-warning" style="display:none;">
{{T "backups_restore.meg_nincs_mentes_felhasznaloi_adattal"}}
</div>
<div class="restore-warning">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "backups_restore.a_visszaallitas_felulirja_az_alkalmazas"}}
</div>
<div class="restore-confirm">
<label>
<input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()">
{{T "backups_restore.megertettem_visszaallitas_inditasa"}}
</label>
</div>
<div class="restore-actions">
<button type="button" class="btn btn-sm btn-danger" id="restore-btn" disabled onclick="submitRestore()">{{T "backups_restore.visszaallitas_inditasa"}}</button>
</div>
<div style="margin-top: 1rem; text-align: center; border-top: 1px solid var(--line); padding-top: 1rem;">
<a href="/import" class="btn btn-sm btn-outline">{{T "backups_restore.importalas_mentett_csomagbol_fab"}}</a>
</div>
</div>
</div>
{{end}}
<!-- Visszaállítás a távoli (3. mentés) tárolóból. Display list = the offbox-toggled apps.
v0.124.0 moved these rows here from the Távoli mentés toggle list; v0.154.0 (R-48) reduced each
row to a SINGLE „Visszaállítás…" entry — the actions themselves are unchanged, they moved into
the wizard at /backups/restore/app. -->
{{if .OffboxConfigured}}
<div class="backup-section-card">
<h3>{{T "backups_restore.visszaallitas_a_tavoli_tarolobol"}}</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">{{T "backups_restore.valaszd_ki_az_alkalmazast_es"}}</p>
<!-- R-237: driven by what is IN THE STORE, not by what is deployed and toggled. A rebuilt box has
neither and used to be told there was nothing to restore, while its snapshots sat in the
repository the whole time. Installed-ness is a property OF a row, never a filter on it. -->
<!-- R-252: the precondition a rebuilt box fails, said BEFORE the customer presses a button that
would refuse. Rendered only when it is true — a healthy box sees nothing new here. -->
<!-- R-280: the „két kattintás" promise is conditional on the picker it points at being non-empty.
It was printed unconditionally, and on a rebuilt box the picker had nothing in it — the
sentence sent the customer to an empty page and the wall had no way past it. The false branch
says what is true and names a route, rather than promising a click that does not exist. -->
{{if .NoRestoreDestination}}
<p class="form-hint" style="border-left:2px solid var(--amber);padding-left:.75rem">
{{T "backups_restore.elobb_csatold_vissza_az_adatmeghajtot"}}
{{if .HasAttachDestination}}
{{T "backups_restore.ez_ket_kattintas_tarhely_meghajtok"}}
{{else}}
{{T "backups_restore.csatolhato_meghajtot_viszont_most_nem"}}
{{end}}
</p>
{{end}}
{{if eq .OffsiteStoreState "unreadable"}}
<p class="form-hint">{{T "backups_restore.nem_tudjuk_elolvasni_a_tavoli"}}</p>
{{else if eq .OffsiteStoreState "no-target"}}
<p class="form-hint">{{T "backups_restore.a_tavoli_tarolo_kapcsolodasi_adatai"}}</p>
{{end}}
{{if .OffsiteRestoreRows}}
<div class="app-row-list">
{{range .OffsiteRestoreRows}}
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
{{if .Restorable}}
<!-- R-253: this row used to promise that the restore would reinstall the app first.
It cannot: reconstitution writes to the app's OWN data path (GetStackHDDPath),
which exists only once the customer has chosen a drive during deploy — the
restore has no answer to that question and must not invent one. The copy now
says what the handler does and routes to the place that does it. (The old
sentence is deliberately NOT quoted here: an HTML comment ships in the response
body, so quoting it would keep the contradiction on the page and would make the
test that forbids it unfailable.) -->
{{if and .InStore (not .Installed)}}
<span class="form-hint" style="margin-right:.5rem">{{T "backups_restore.nincs_telepitve_elobb_telepitsd_ujra"}}</span>
{{end}}
{{if .StoreUnknown}}
<span class="form-hint" style="margin-right:.5rem">{{T "backups_restore.nem_tudjuk_van_e_mentese"}}</span>
{{end}}
<!-- R-48: ONE entry per app. The five inline forms that used to live here — verify,
prepare, the revealed commit, the missing-only merge and the true reconstitution —
were separable only by layout, and two of them differed by whether the customer's
data comes back at all. They are now described intents inside the wizard. -->
<a href="/backups/restore/app?name={{.App}}" class="btn btn-xs btn-outline">{{T "backups_restore.visszaallitas_2"}}</a>
{{else}}
<!-- Shown, not hidden: "installed but nothing in the store" is an answer. Silently
dropping the row is what made R-220's empty list unreadable, one screen over. -->
<span class="form-hint">{{T "backups_restore.nincs_mentese_a_tavoli_taroloban"}}</span>
{{end}}
{{template "app_list_row_end"}}
{{end}}
</div>
{{else if eq .OffsiteStoreState "known"}}
<p class="form-hint">{{T "backups_restore.a_tavoli_tarolo_ures_nincs"}}</p>
{{end}}
<!-- R-7b: the shares source. Not an app row — it has no per-app toggle and no recovery unit —
so it is its own entry. The reserved `_shares` key never appears here; the label always
comes from the display mapping. -->
{{if .SharesRestoreOffered}}
<div class="app-row-list" style="margin-top:1rem">
{{template "app_list_row" dict "Slug" "" "Name" .SharesDisplayName}}
<form method="POST" action="/backup/shares/restore" style="display:inline">{{$.CSRFField}}
<button type="submit" class="btn btn-xs btn-outline">{{T "backups_restore.megosztasok_visszaallitasa"}}</button>
</form>
{{if .SharesScratchReady}}
<form method="POST" action="/backup/shares/place" style="display:inline">{{$.CSRFField}}
<button type="submit" class="btn btn-xs btn-outline">{{T "backups_restore.helyreallitas_az_elo_adatok_koze"}}</button>
</form>
<span class="form-hint" style="display:block;margin-top:.25rem">{{T "backups_restore.a_meglevo_fajlokat_nem_irja"}}</span>
{{end}}
{{template "app_list_row_end"}}
</div>
{{end}}
<!-- v0.147.0 (4a): what the verification restores actually LEFT on disk. Until now the result of
an ellenőrző visszaállítás was invisible — the flash said "a verification folder on the
drive" without naming it, and nothing listed what had accumulated. Full path, size and date,
so the copy can be found, opened, and cleaned up. -->
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">{{T "backups_restore.meglevo_ellenorzo_masolatok"}}</h3>
{{if .OffsiteRestoreCopies}}
<p class="form-hint" style="margin-bottom:.75rem">{{T "backups_restore.ezek_a_visszaallitott_masolatok_helyet"}}</p>
<div class="app-row-list">
{{range .OffsiteRestoreCopies}}
{{template "app_list_row" dict "Slug" .Stack "Name" .Stack "Secondary" (printf "%s · %s · %s" .SizeHuman (.Created.Format "2006. 01. 02. 15:04") .Path)}}
<form method="POST" action="/backup/offbox/verify-copy/delete" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="stack" value="{{.Stack}}">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-danger-outline"
data-copy-path="{{.Path}}"
onclick="confirmDeleteVerifyCopy(this)">{{T "backups_restore.masolat_torlese"}}</button>
</form>
{{template "app_list_row_end"}}
{{end}}
</div>
{{else}}
<p class="form-hint">{{T "backups_restore.nincs_ellenorzo_masolat_a_meghajton"}}</p>
{{end}}
</div>
{{end}}
<!-- Hordozható mentéscsomag (.fab) — v0.124.0, decision 3: PORTABILITY, not a backup tier (no
scheduling, no status surface; point-in-time framing everywhere). The download reuses the
EXISTING export pipeline (same producer as a drive export → byte-identical bundle) staged
under the data dir, then streams through a guarded endpoint. Import stays drive-scan. -->
<div class="backup-section-card">
<h3>{{T "backups_restore.hordozhato_mentescsomag_fab"}}</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">{{T "backups_restore.hordozhato_pillanatfelvetel_barhol_tarol"}}</p>
{{if .OffboxApps}}
<div class="form-row" style="max-width:420px"><label>{{T "backups_restore.jelszavas_titkositas_opcionalis"}}</label>
<input type="password" id="fab-dl-password" class="form-input" autocomplete="new-password" placeholder="{{T "backups_restore.opcionalis_jelszo"}}">
<span class="form-hint">{{T "backups_restore.uresen_hagyva_a_csomag_titkositas"}}</span>
</div>
<div class="app-row-list">
{{range .OffboxApps}}
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<button type="button" class="btn btn-xs btn-outline fab-dl-btn" data-stack="{{.Name}}" onclick="fabDownload(this)">{{T "backups_restore.letoltes_fab"}}</button>
{{template "app_list_row_end"}}
{{end}}
</div>
<div class="schedule-actions" style="margin-top:.75rem">
<button type="button" class="btn btn-xs btn-outline" id="fab-dl-all" onclick="fabDownloadAll()">{{T "backups_restore.osszes_letoltese_egyenkent"}}</button>
<span class="form-hint" style="margin-left:.5rem">{{T "backups_restore.a_csomagok_egyenkent_keszulnek_es"}}</span>
</div>
<div id="fab-dl-status" class="form-hint" style="margin-top:.5rem"></div>
{{else}}
<p class="form-hint">{{T "backups_restore.nincs_telepitett_alkalmazas"}}</p>
{{end}}
</div>
{{end}}
<script>
{{template "restore_banner_js"}}
// .fab download flow (v0.124.0): estimate → inline confirm (size shown BEFORE starting) →
// start (the EXISTING async export, staging dest) → poll → browser GET (guarded stream; the
// server removes the staged bundle after the stream). The batch runs apps ONE AT A TIME.
var fabQueue = [];
function fabSetStatus(msg){ document.getElementById('fab-dl-status').textContent = msg; }
function fabPassword(){ var el = document.getElementById('fab-dl-password'); return el ? el.value : ''; }
function fabDownload(btn){
var stack = btn.getAttribute('data-stack');
fetch('/api/export/download/estimate?stack=' + encodeURIComponent(stack))
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('{{T "backups_restore.hiba"}} ' + (j.error || '{{T "backups_restore.a_becsles_sikertelen"}}')); return; }
var size = (j.data && j.data.total_size_human) || '?';
felhomConfirm(btn, '{{T "backups_restore.a_csomag_becsult_merete"}} ' + size + '{{T "backups_restore.elinditod_a_letoltest_pillanatfelvetel_a"}}', function(){
fabStart(stack, null);
});
})
.catch(function(){ fabSetStatus('{{T "backups_restore.hiba_a_becsles_nem_erheto"}}'); });
}
// v0.147.0 (4a): the ONLY delete this page offers. Two inline acknowledgements — the house
// double-confirm idiom (deploy.html's stale-data delete), never native confirm(), which is an
// OS-modal that blocks browser automation (F-11). The first step names the exact path so the
// customer is agreeing to a specific directory, not to the word "delete".
function confirmDeleteVerifyCopy(btn){
var path = btn.getAttribute('data-copy-path') || '';
felhomConfirm(btn, '{{T "backups_restore.biztosan_torlod_ezt_az_ellenorzo"}} ' + path + ' {{T "backups_restore.a_tenyleges_adataid_valtozatlanok_maradn"}}', function(){
felhomConfirm(btn, '{{T "backups_restore.utolso_megerosites_a_masolat_veglegesen"}}', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
});
}
function fabStart(stack, next){
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('{{T "backups_restore.hiba_2"}}' + stack + '): ' + (j.error || '{{T "backups_restore.az_export_nem_indult_el"}}')); if (next) next(); return; }
fabSetStatus('{{T "backups_restore.csomag_keszitese"}} ' + stack + '…');
fabPoll(stack, next);
})
.catch(function(){ fabSetStatus('{{T "backups_restore.hiba_az_export_nem_indult"}}'); if (next) next(); });
}
function fabPoll(stack, next){
var iv = setInterval(function(){
fetch('/api/export/status').then(function(r){ return r.json(); }).then(function(j){
if (!j || j.running || !j.done) return; // keep polling until the job reports done
clearInterval(iv);
if (j.error) { fabSetStatus('{{T "backups_restore.hiba_2"}}' + stack + '): ' + j.error); if (next) next(); return; }
var base = (j.output_path || '').split('/').pop();
if (!base) { fabSetStatus('{{T "backups_restore.hiba_a_csomag_utvonala_hianyzik"}}'); if (next) next(); return; }
fabSetStatus('{{T "backups_restore.letoltes"}} ' + base + (j.output_size ? ' (' + j.output_size + ')' : ''));
window.location.href = '/api/export/download?file=' + encodeURIComponent(base);
if (next) setTimeout(next, 3000); // let the stream begin before the next export starts
}).catch(function(){});
}, 2000);
}
function fabDownloadAll(){
var btns = document.querySelectorAll('.fab-dl-btn');
fabQueue = Array.prototype.map.call(btns, function(b){ return b.getAttribute('data-stack'); });
fabRunQueue();
}
function fabRunQueue(){
var stack = fabQueue.shift();
if (!stack) { fabSetStatus('{{T "backups_restore.minden_csomag_elkeszult"}}'); return; }
fabStart(stack, fabRunQueue);
}
// Restore section
var huDays = ['{{T "backups_restore.vasarnap"}}', '{{T "backups_restore.hetfo"}}', '{{T "backups_restore.kedd"}}', '{{T "backups_restore.szerda"}}', '{{T "backups_restore.csutortok"}}', '{{T "backups_restore.pentek"}}', '{{T "backups_restore.szombat"}}'];
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '{{T "backups_restore.2_szint"}}' : '{{T "backups_restore.1_szint"}}';
if (s.drive_label) {
label += ' — ' + tierLabel + ', ' + s.drive_label;
} else {
label += ' — ' + tierLabel;
}
return label;
}
function onRestoreAppChange() {
var sel = document.getElementById('restore-app');
var appName = sel.value;
var snapSel = document.getElementById('restore-snapshot');
var noSnaps = document.getElementById('restore-no-snapshots');
var typeInfo = document.getElementById('restore-type-info');
document.getElementById('restore-confirm-cb').checked = false;
document.getElementById('restore-btn').disabled = true;
noSnaps.style.display = 'none';
typeInfo.style.display = 'none';
if (!appName) {
snapSel.innerHTML = '<option value="">{{T "backups_restore.valasszon_alkalmazast"}}</option>';
return;
}
// Determine restore type from data attributes
var opt = sel.options[sel.selectedIndex];
var hasHDD = opt.getAttribute('data-has-hdd') === 'true';
var hasDB = opt.getAttribute('data-has-db') === 'true';
var hasVolumes = opt.getAttribute('data-has-volumes') === 'true';
if (hasHDD || hasVolumes) {
typeInfo.innerHTML = '{{T "backups_restore.teljes_visszaallitas_adatbazis_konfigura"}}';
typeInfo.className = 'restore-info';
} else if (hasDB) {
typeInfo.innerHTML = '{{T "backups_restore.adatbazis_es_konfiguracio_visszaallitasa"}}';
typeInfo.className = 'restore-info restore-info-partial';
} else {
typeInfo.innerHTML = '{{T "backups_restore.csak_konfiguracio_visszaallitasa_compose"}}';
typeInfo.className = 'restore-info restore-info-partial';
}
typeInfo.style.display = 'block';
snapSel.innerHTML = '<option value="">{{T "backups_restore.betoltes"}}</option>';
fetch('/api/backup/snapshots?stack=' + encodeURIComponent(appName))
.then(function(r) { return r.json(); })
.then(function(data) {
snapSel.innerHTML = '<option value="">{{T "backups_restore.valasszon"}}</option>';
if (data.ok && data.data && data.data.length > 0) {
// Group by tier
var tier1 = data.data.filter(function(s) { return s.tier !== 2; });
var tier2 = data.data.filter(function(s) { return s.tier === 2; });
if (tier1.length > 0) {
var grp1 = document.createElement('optgroup');
grp1.label = '{{T "backups_restore.1_szint_helyi_mentes_ajanlott"}}';
tier1.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp1.appendChild(o);
});
snapSel.appendChild(grp1);
}
if (tier2.length > 0) {
var grp2 = document.createElement('optgroup');
grp2.label = '{{T "backups_restore.2_szint_masodlagos_masolat"}}';
tier2.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp2.appendChild(o);
});
snapSel.appendChild(grp2);
}
} else {
snapSel.innerHTML = '<option value="">{{T "backups_restore.nincs_elerheto_mentes"}}</option>';
noSnaps.style.display = 'block';
}
});
}
function onRestoreConfirmChange() {
var cb = document.getElementById('restore-confirm-cb');
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
document.getElementById('restore-btn').disabled = !(cb.checked && app && snap);
}
function submitRestore() {
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
if (!app || !snap) return;
var btn = document.getElementById('restore-btn');
btn.disabled = true;
btn.textContent = '{{T "backups_restore.visszaallitas_folyamatban"}}';
var form = document.createElement('form');
form.method = 'POST';
form.action = '/backup/restore';
var fc = document.createElement('input');
fc.type = 'hidden'; fc.name = '_csrf';
fc.value = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
form.appendChild(fc);
var f1 = document.createElement('input');
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
form.appendChild(f1);
var f2 = document.createElement('input');
f2.type = 'hidden'; f2.name = 'snapshot_id'; f2.value = snap;
form.appendChild(f2);
document.body.appendChild(form);
form.submit();
}
</script>
{{template "layout_end" .}}
{{end}}