Files
felhom-controller/controller/internal/web/templates/backups_apps.html
T
admin 4c8f0d2919
gates / gates (push) Successful in 12s
R-103: the Tier-2 refusal becomes an action
An app whose Tier-2 copy holds no file legs but a full recovery-unit mirror - 45 of the 53 catalog
templates - was told to press a button on a DIFFERENT page. Since R-102 the data it is asking for is
restorable from the copy it is looking at.

New POST /backup/tier2/unit-restore and backupTier2UnitRestoreHandler: same guards, same
restoreOpBlocked() refusal (R-351b), same async shape as the file restore beside it, plus a
fail-closed pre-flight so the app is never stopped for a mirror that could not be opened. The
outcome reuses unitRestoreOutcomeMsg and adds which copy overwrote the live data.

The row offers the action where the refusal was, in a danger style, as a SEPARATE button. The two
are not merged: one adds what is missing, the other overwrites. The confirm carries that difference
in words and names the copy's date - and says so differently when that date is only an ATTEMPT
(R-101). It is built from named Go constants rather than assembled inside an HTML attribute, so a
test can assert it verbatim; fmtTimeStr now delegates to a package-level fmtRFC3339Local so the
confirm and the outcome cannot render the same date two ways.

tier2NoCoverageMsg is NARROWED to the case that remains - no legs and no openable unit - and still
names the route that works. tier2UnitNotCoveredMsg is NOT deleted: it is appended where the FILE
restore ran and is still exactly true of it.

Tests C1-C2 and D1-D6 plus four more. Red-proofs: C1 (widen CanRestore to include HasUnit -> the
unit-only cases fail), D6 (drop EndRestoreOp from the handler goroutine -> 'the restore never
published a result').
2026-08-31 11:42:03 +02:00

398 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "backups_apps"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Biztonsági mentés — Alkalmazások</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{template "backups_flash" .}}
{{template "restore_banner" .}}
{{if not .Backup}}
{{template "backups_empty" .}}
{{else}}
<h3 class="backup-tier-divider">Alkalmazás-mentések (adatbázis + konfiguráció)</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Az egyes alkalmazások részletes, granulált mentése — adatbázis-kiírások, beállítások és alkalmazás-fájlok. A fenti teljes mentéstől függetlenül, alkalmazásonként visszaállítható.</p>
<!-- Section 2: Schedule -->
<div class="schedule-card">
<h3>Ütemezés</h3>
<div class="schedule-rows">
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">{{.Backup.DBDumpSchedule}}</span>
<span class="schedule-next">Következő: {{nextRunLabel .Backup.NextDBDump}}</span>
</div>
</div>
<div class="schedule-summary">
{{if .Backup.LastDBDump}}
<div class="schedule-summary-row">
<span>Utolsó adatbázis mentés:</span>
<span class="schedule-summary-value">{{fmtTime .Backup.LastDBDump.LastRun}} ({{timeAgo .Backup.LastDBDump.LastRun}})</span>
</div>
{{else}}
<div class="schedule-summary-row">
<span>Utolsó adatbázis mentés:</span>
<span class="schedule-summary-value relative-time">Még nem futott</span>
</div>
{{end}}
</div>
<div class="schedule-actions">
<button class="btn btn-sm btn-primary" onclick="triggerBackupFromPage()" id="backup-page-btn"
{{if .Backup.Running}}disabled{{end}}>
{{if .Backup.Running}}Mentés folyamatban...{{else}}Mentés most{{end}}
</button>
</div>
</div>
<!-- Section 3: Databases -->
<div class="backup-section-card">
<h3>Adatbázisok</h3>
{{if or .Backup.DumpFiles .Backup.DiscoveredDBs}}
<div class="backup-table-wrap">
<table class="db-table">
<thead>
<tr>
<th>Alkalmazás</th>
<th>Típus</th>
<th>Méret</th>
<th>Utolsó</th>
<th>Érvényesítés</th>
<th>Állapot</th>
</tr>
</thead>
<tbody>
{{if .Backup.LastDBDump}}
{{range .Backup.LastDBDump.Results}}
<tr>
<td>{{.DB.StackName}}</td>
<td><span class="db-type-badge db-type-{{.DB.DBType}}">{{dbTypeLabel .DB.DBType}}</span></td>
<td class="mono">{{if .Error}}–{{else}}{{fmtBytes .Size}}{{end}}</td>
<td class="mono">{{if .Error}}–{{else}}{{fmtTimeShort $.Backup.LastDBDump.LastRun}}{{end}}</td>
<td>
{{if .Error}}
<span class="validation-badge validation-na">–</span>
{{else if .Validation.Valid}}
<span class="validation-badge validation-ok">{{.Validation.TableCount}} tábla</span>
{{else if .Validation.Error}}
<span class="validation-badge validation-fail" title="{{.Validation.Error}}">Hiba</span>
{{else}}
<span class="validation-badge validation-na" title="Az érvényesítés nem futott le">–</span>
{{end}}
</td>
<td>
{{if .Error}}
<span class="validation-badge validation-fail" title="{{.Error}}">Hiba</span>
{{else}}
<span class="validation-badge validation-ok">OK</span>
{{end}}
</td>
</tr>
{{end}}
{{else}}
{{range .Backup.DumpFiles}}
<tr>
<td>{{.StackName}}</td>
<td><span class="db-type-badge db-type-{{.DBType}}">{{dbTypeLabel .DBType}}</span></td>
<td class="mono">{{fmtBytes .Size}}</td>
<td class="mono">{{fmtTimeShort .ModTime}}</td>
<td>
{{if .Validation.Valid}}
<span class="validation-badge validation-ok">{{.Validation.TableCount}} tábla</span>
{{else if .Validation.Error}}
<span class="validation-badge validation-fail" title="{{.Validation.Error}}">Hiba</span>
{{else}}
<span class="validation-badge validation-na">–</span>
{{end}}
</td>
<td><span class="validation-badge validation-ok">OK</span></td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
{{else if eq .DBSectionState "pending"}}
<div class="backup-table-empty">Még nem készült adatbázis-mentés — az első ütemezett mentés éjjel fut le.</div>
{{else}}
<div class="backup-table-empty">A telepített alkalmazások beágyazott adatbázist használnak (pl. SQLite) — adatbázisuk a fájl- és kötetmentés része, külön adatbázis-kiírás nem szükséges.</div>
{{end}}
</div>
<!-- Section 4: Unified per-app backup status -->
{{if .Backup.AppDataInfo}}
<div class="backup-section-card">
<h3>Alkalmazások mentési állapota</h3>
{{if .NoUserDataBackupWarning}}
<div class="alert alert-error" style="margin-bottom:1.5rem">
<strong>Felhasználói adatokról nincs biztonsági mentés.</strong><br>
A szerveren tárolt fotók, dokumentumok és egyéb fájlok jelenleg csak egy példányban léteznek.
Külső meghajtó csatlakoztatásával biztonsági másolat készíthető a 3-2-1 szabály szerint.
<a href="/settings" style="color:inherit;text-decoration:underline">Meghajtó beállítása →</a>
</div>
{{end}}
{{range .AppBackupRows}}
<div class="app-backup-row" data-status="{{.Status}}">
<!-- Aligned to the shared app-row grammar (icon + name left, status + chevron right);
the expander behavior is untouched — this header is the one allowlisted aligned
copy in scripts/app_row_dedup_gate.py, NOT a partial render (it owns the toggle).
The icon chain matches app_row.html: SVG -> PNG -> /static/app-placeholder.svg, so a
logo-less app shows the placeholder here too instead of a hidden dead-end (v0.163.1). -->
<div class="app-backup-row-header" onclick="toggleBackupDetail(this)">
<img class="app-row-icon" src="{{logoURL .Slug}}" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<span class="app-backup-row-name">{{.DisplayName}}</span>
<div class="app-backup-row-meta">
{{if .DriveDisconnected}}
<span class="tag tag-warn">Meghajtó leválasztva</span>
{{else if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
{{else if .HasVolumeData}}
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}} + Adatok</span>
{{else}}
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}}</span>
{{end}}
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
</div>
<span class="expand-icon">▶</span>
</div>
<div class="app-backup-row-detail" style="display:none">
<div class="backup-layers">
<!-- Tier 1: Nightly backup (mandatory, same drive) -->
<div class="backup-layer-row">
<span class="tier-label">1. mentés</span>
<span class="layer-badge">Auto</span>
<span class="tier-location">helyi</span>
{{if .Tier1LastRun}}
{{/* R-258: three states. The tick is THIS app's own most recent dump result,
not the box's most recent run — and a third value renders NO icon, which is
where "we have no result for this backup" belongs. A tick standing for
"a file exists" is what this replaced. */}}
<span class="layer-last">Utolsó: {{timeAgoStr .Tier1LastRun}}
{{if eq .Tier1LastStatus "ok"}}<span class="text-ok"><svg class="ico ico-sm"><use href="#i-check"/></svg></span>
{{else if eq .Tier1LastStatus "error"}}<span class="text-error"><svg class="ico ico-sm"><use href="#i-x"/></svg></span>
{{else}}<span class="state-text-neutral" title="Erről a mentésről nincs eredményünk.">—</span>{{end}}
</span>
{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
{{if and .HasDB (eq .Tier1DBStatus "error")}}
<span class="text-error" style="font-size:.8rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> DB dump hiba</span>
{{end}}
</div>
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
<div class="backup-layer-row">
<span class="tier-label">2. mentés</span>
{{if .Tier2UserDisabled}}
<span class="layer-unconfigured">2. mentés kikapcsolva</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if and .Tier2Configured .Tier2DestDisconnected}}
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="tag tag-warn">Cél meghajtó leválasztva</span>
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last" style="opacity:.6">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}</span>
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if and .Tier2Configured .Tier2DestInactive}}
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="tag tag-warn">Cél meghajtó inaktív</span>
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last" style="opacity:.6">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}</span>
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if .Tier2Configured}}
<span class="layer-method">rsync</span>
<span class="layer-dest">→ {{.Tier2Dest}}</span>
<span class="layer-schedule">{{.Tier2Schedule}}</span>
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last">Utolsó: {{timeAgoStr .Tier2LastRun}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{else}}
<span class="layer-last">Még nincs sikeres másolat
<span class="{{if eq .Tier2LastStatus "error"}}text-error{{else}}text-muted{{end}}">{{.Tier2StatusBadge}}</span>
</span>
{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
{{if .Tier2LastWarning}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2LastWarning}}</span>{{end}}
<span class="tier-contents">{{.BackupContents}}</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">
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<form method="POST" action="/backup/tier2/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<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: {{fmtTimeStr .Tier2LastRun}}">Fájlok visszaállítása</button>
</form>
{{end}}{{else if .Tier2LastSuccess}}
<form method="POST" action="/backup/tier2/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<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 sikeres másolat: {{fmtTimeStr .Tier2LastSuccess}}.{{if .Tier2StaleCopy}} Figyelem: a legutóbbi mentési kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek.{{end}}">Fájlok visszaállítása</button>
</form>
{{else}}
<span class="layer-reason" style="opacity:.85">Még nincs sikeres másolat, amiből vissza lehetne állítani.</span>
{{end}}
{{/* R-102/R-103: the DESTRUCTIVE twin of the button above. Offered only when the
copy holds an openable recovery unit (Tier2UnitRestorable — a second
predicate, never a widening of the file restore's). It is deliberately a
SEPARATE button in a danger style: the one beside it only adds what is
missing, this one overwrites the app's live data with the copy's. */}}
{{if .Tier2UnitRestorable}}
<form method="POST" action="/backup/tier2/unit-restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<button type="submit" class="btn btn-xs btn-danger-outline" data-confirm="{{.Tier2UnitConfirm}}">Teljes visszaállítás a másolatból</button>
</form>
{{end}}
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else}}
<span class="layer-auto-ok"><svg class="ico ico-sm"><use href="#i-check"/></svg> 1. mentés auto</span>
<span class="layer-unconfigured"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> Nincs 2. (off-drive) másolat</span>
{{if .Tier2LastError}}
<span class="layer-reason" style="opacity:.85" title="A 2. mentés automatikus — külön beállítás nem kell">{{.Tier2LastError}}</span>
{{end}}
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{end}}
</div>
<!-- Tier 3: Off-box (NAS) backup — real state (see tier3State). -->
{{if eq .Tier3State "active"}}
<div class="backup-layer-row">
<span class="tier-label">3. mentés</span>
<span class="layer-badge {{if eq $.Offbox.LastStatus "ok"}}text-ok{{else if eq $.Offbox.LastStatus "error"}}text-error{{else if eq $.Offbox.LastStatus "running"}}text-muted{{end}}">{{if eq $.Offbox.LastStatus "ok"}}Sikeres{{else if eq $.Offbox.LastStatus "error"}}Hiba{{else if eq $.Offbox.LastStatus "running"}}Fut...{{else}}—{{end}}</span>
<span class="tier-location">restic → {{$.Offbox.Host}}</span>
{{if $.Offbox.LastRun}}<span class="layer-last">Utolsó: {{timeAgoStr $.Offbox.LastRun}}</span>{{end}}
<span class="tier-contents">Helyreállítási egység, titkosítva</span>
</div>
{{else if eq .Tier3State "escrow_pending"}}
<div class="backup-layer-row">
<span class="tier-label">3. mentés</span>
<span class="layer-badge text-error">Kulcsletétre vár</span>
<span class="tier-contents">A távoli mentés a titkosítási kulcs letétbe helyezéséig szünetel</span>
</div>
{{else if eq .Tier3State "off"}}
<div class="backup-layer-row" style="opacity:.5">
<span class="tier-label">3. mentés</span>
<span class="layer-badge">Kikapcsolva</span>
<span class="tier-contents">Ez az alkalmazás nincs kijelölve távoli mentésre</span>
<div class="layer-actions">
<a href="/backups/remote#offbox-section" class="btn btn-xs btn-outline">Bekapcsolás</a>
</div>
</div>
{{else}}
<div class="backup-layer-row" style="opacity:.5">
<span class="tier-label">3. mentés</span>
<span class="layer-badge" style="background:var(--bg-2);color:var(--text-3)">Nincs beállítva</span>
<span class="tier-location">távoli (offsite)</span>
<span class="tier-contents">Nincs távoli mentési cél beállítva</span>
<div class="layer-actions">
<a href="/backups/remote#offbox-section" class="btn btn-xs btn-outline">Beállítás</a>
</div>
</div>
{{end}}
</div>
{{if .Warnings}}
<div class="layer-warnings">
{{range .Warnings}}
<div class="backup-layer-warning">{{.}}</div>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
<script>
{{template "restore_banner_js"}}
function toggleBackupDetail(header) {
var detail = header.nextElementSibling;
var icon = header.querySelector('.expand-icon');
if (detail.style.display === 'none') {
detail.style.display = 'block';
icon.textContent = '▼';
} else {
detail.style.display = 'none';
icon.textContent = '▶';
}
}
function triggerBackupFromPage() {
const btn = document.getElementById('backup-page-btn');
btn.disabled = true;
btn.textContent = 'Mentés indítása...';
fetch('/api/backup/run', { method: 'POST', headers: csrfHeaders() })
.then(r => r.json())
.then(data => {
if (data.ok) {
btn.textContent = 'Mentés folyamatban...';
btn.classList.add('loading');
startBackupPolling();
} else {
btn.textContent = data.error || 'Hiba';
setTimeout(() => { btn.textContent = 'Mentés most'; btn.disabled = false; }, 3000);
}
})
.catch(() => {
btn.textContent = 'Hiba';
setTimeout(() => { btn.textContent = 'Mentés most'; btn.disabled = false; }, 3000);
});
}
function startBackupPolling() {
const poll = setInterval(() => {
fetch('/api/backup/status')
.then(r => r.json())
.then(data => {
if (data.ok && data.data && !data.data.running) {
clearInterval(poll);
window.location.reload();
}
})
.catch(() => {});
}, 3000);
}
// Auto-poll if backup is already running on page load
{{if .Backup}}{{if .Backup.Running}}
startBackupPolling();
{{end}}{{end}}
</script>
{{template "layout_end" .}}
{{end}}