b331f18424
Two defects E-2d found on a real box, fixed in this order deliberately: the
message is corrected BEFORE it is put on screen, because switching on a banner
that lies is worse than a silent one.
R-114 — the third state. resolveBackupTargetState had two outcomes: a disk
claims the target (healthy), or nothing does (degraded, "the backup is on the
system disk"). The state "configured, and its drive is gone" had no branch, so
it fell into the second and inherited its message AND its offer. Observed live
with the target detached: degraded:true, target:"felhom-backup" plus the
system-disk copy (false -- the backup was on a drive that had vanished) plus
offer_path naming that same vanished drive as the remedy.
New BackupTargetState.TargetAbsent discriminates. Degraded keeps its meaning
("is there a problem") so the wire contract is unchanged for every consumer;
TargetAbsent answers "which problem", because the two have opposite remedies --
attach any second drive, versus reconnect THAT one. Copy routed through
degradedMessageFor so one place still decides what a customer reads. The offer
is suppressed on the branch itself, NOT left to firstOfferableDrive's
Disconnected skip: that flag is set by the agent-side gate in another repo
(R-113), and this state must be correct independently of it.
R-112 — the state finally has a consumer. The endpoint was byte-correct and
nothing in the product ever asked for it: templates fetch 18 distinct
/api/storage/* endpoints and backup-target[/assign] were the only two with zero
references. Server-rendered on /backups now, following the existing
SingleCopyWarning banner pattern -- not a 19th JS fetch, because a banner that
needs JavaScript to appear is one more thing that can silently not happen.
backupTargetView returns nil for healthy and unknown so those render nothing at
all. The offer control POSTs to the existing assign endpoint behind the standard
inline confirm, never auto-submits, and surfaces restart_required honestly
instead of adding a self-restart.
Scenario E (the seam test) drives backupsHandler over httptest and asserts the
RENDERED HTML -- handler -> view -> resolver -> template. It deliberately does
not call the resolver and assert a string, which would prove the resolver that
was never broken. Deleting the one line that sets data["BackupTarget"]
reproduces the R-112 state and fails every render assertion.
Tests 326 -> 338 (+12) in internal/web; suite green (27 packages); both template
gates pass. Three red-proofs run and reverted, files byte-identical after.
MinAgent unchanged at 0.113.0: R-114 reads BackupTarget/MountPath/GuestPath/Role,
none of which R-113 altered (it changed BoundUnderParent, which this code does
not read). demo-hp on agent 0.113.0 is not held.
The absent copy is verbatim the hub's customerMessages["backup_target_absent"]
so the banner and the email tell one story -- filed as a two-repo drift risk,
not solved.
NOT LIVE-VALIDATED. Scenario C cannot occur on a healthy box; Session C proves it.
274 lines
13 KiB
HTML
274 lines
13 KiB
HTML
{{define "backups"}}
|
||
{{template "layout_start" .}}
|
||
|
||
<div class="page-header">
|
||
<h2>Biztonsági mentés</h2>
|
||
<span class="domain-badge">{{.Domain}}</span>
|
||
</div>
|
||
|
||
{{template "backups_flash" .}}
|
||
{{if .Backup}}{{if .Backup.SingleCopyWarning}}
|
||
<div class="alert alert-warning">{{.Backup.SingleCopyWarning}}</div>
|
||
{{end}}{{end}}
|
||
|
||
{{/* R-112: the whole-system backup-target state. nil = healthy or unknown = render NOTHING. */}}
|
||
{{if .BackupTarget}}
|
||
<div class="alert alert-warning" id="backup-target-alert">{{.BackupTarget.Message}}</div>
|
||
{{if .BackupTarget.OfferPath}}
|
||
<div class="alert alert-info" id="backup-target-offer">
|
||
<p>{{.BackupTarget.OfferText}}</p>
|
||
<p class="form-hint">{{.BackupTarget.OfferLabel}}</p>
|
||
<button type="button" class="btn btn-sm" id="backup-target-assign"
|
||
data-path="{{.BackupTarget.OfferPath}}"
|
||
data-confirm="Kijelölöd ezt a meghajtót a rendszermentés helyéül?">Kijelölöm</button>
|
||
<div id="backup-target-assign-result"></div>
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|
||
|
||
{{if not .Backup}}
|
||
{{template "backups_empty" .}}
|
||
{{else}}
|
||
|
||
<!-- Section 0: Storage overview -->
|
||
<div class="backup-section-card">
|
||
<h3>Tárhely áttekintés</h3>
|
||
<div class="storage-overview-grid">
|
||
<div class="storage-bars">
|
||
{{with $.SystemInfo}}
|
||
<div class="storage-item meter {{usageColor .DiskPercent}}">
|
||
<div class="storage-header">
|
||
<span class="storage-label">Rendszer (/)</span>
|
||
<span class="storage-value">{{fmtGB .DiskUsedGB}} / {{fmtGB .DiskTotalGB}} ({{printf "%.0f" .DiskPercent}}%)</span>
|
||
</div>
|
||
<div class="meter-track">
|
||
<div class="meter-fill" style="width:{{printf "%.1f" .DiskPercent}}%"></div>
|
||
</div>
|
||
</div>
|
||
{{range $.StorageBars}}
|
||
{{if .Disconnected}}
|
||
<div class="storage-item meter meter-empty storage-disconnected">
|
||
<div class="storage-header">
|
||
<span class="storage-label">{{.Label}}</span>
|
||
<span class="storage-value">— <span class="state-text-neutral">Leválasztva</span></span>
|
||
</div>
|
||
<div class="meter-track"></div>
|
||
</div>
|
||
{{else}}
|
||
<div class="storage-item meter {{usageColor .Percent}}">
|
||
<div class="storage-header">
|
||
<span class="storage-label">{{.Label}}</span>
|
||
<span class="storage-value">{{fmtGB .UsedGB}} / {{fmtGB .TotalGB}} ({{printf "%.0f" .Percent}}%)</span>
|
||
</div>
|
||
<div class="meter-track">
|
||
<div class="meter-fill" style="width:{{printf "%.1f" .Percent}}%"></div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|
||
{{end}}
|
||
</div>
|
||
<div class="storage-stats">
|
||
<div class="storage-stat-row">
|
||
<span class="storage-stat-label">DB mentések</span>
|
||
<span class="storage-stat-value mono">{{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} fájl{{else}}–{{end}}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section: Whole-guest (appliance) backup — agent-sourced, read-only + manual trigger -->
|
||
{{with .GuestBackup}}
|
||
<div class="backup-section-card">
|
||
<h3>Rendszermentés (teljes mentés)</h3>
|
||
<p class="form-hint" style="margin-bottom:1rem">A teljes szerver — alkalmazások, beállítások és adatbázisok együtt — időszakos mentése, amelyből az egész készülék visszaállítható. Ezt a host-ügynök készíti és kezeli.</p>
|
||
{{if not .Available}}
|
||
<div class="alert alert-info">{{.Note}}</div>
|
||
{{else}}
|
||
<div class="stats-grid backup-page-cards">
|
||
<div class="stat-card {{if .HasBackup}}{{if .Success}}stat-running{{else}}stat-stopped{{end}}{{end}}">
|
||
<div class="stat-value">{{if .HasBackup}}{{if .Success}}✓{{else}}✗{{end}}{{else}}–{{end}}</div>
|
||
<div class="stat-label">Utolsó teljes mentés
|
||
{{if .HasBackup}}<br><span class="relative-time">{{fmtTime .StartedAt}} ({{timeAgo .StartedAt}})</span>{{end}}
|
||
</div>
|
||
</div>
|
||
<div class="stat-card stat-total">
|
||
<div class="stat-value">{{if .HasBackup}}{{fmtBytes .SizeBytes}}{{else}}–{{end}}</div>
|
||
<div class="stat-label">{{if .HasBackup}}{{.Target}}{{else}}Méret / cél{{end}}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" style="font-size:1.15rem">{{if .Due}}Esedékes{{else}}Naprakész{{end}}</div>
|
||
<div class="stat-label">Következő mentés
|
||
{{if not .Due}}<br><span class="relative-time">{{.AgeHours}} órája — a mentési ablakon belül</span>{{end}}
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">{{if .HasRestoreTest}}{{if .RestorePass}}✓{{else}}✗{{end}}{{else}}–{{end}}</div>
|
||
<div class="stat-label">Visszaállítás ellenőrizve
|
||
{{if .HasRestoreTest}}<br><span class="relative-time">{{fmtTime .RestoreTestedAt}}</span>{{else}}<br><span class="relative-time">Még nem futott</span>{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{if .Running}}
|
||
<div class="alert alert-info" id="wg-running">Mentés folyamatban… (fázis: <span id="wg-phase">{{.Phase}}</span>)</div>
|
||
{{end}}
|
||
{{if .CanTrigger}}
|
||
<div class="schedule-actions" style="margin-top:1rem">
|
||
<button class="btn btn-sm btn-primary" id="wg-backup-btn" onclick="triggerGuestBackup()" {{if .Running}}disabled{{end}}>Mentés most</button>
|
||
{{if .StopMode}}<span class="form-hint" style="margin-left:.5rem"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A mentés idejére az alkalmazások rövid időre leállnak.</span>
|
||
{{else}}<span class="form-hint" style="margin-left:.5rem">Pillanatkép-mód: az alkalmazások csak néhány másodpercre állnak le.</span>{{end}}
|
||
<div id="wg-backup-result" style="margin-top:.6rem"></div>
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
<!-- Section: customer-configurable backup window (v0.168.0) — one setting drives every nightly leg -->
|
||
<div class="backup-section-card">
|
||
<h3>Mentési időablak</h3>
|
||
<p class="form-hint" style="margin-bottom:1rem">A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.</p>
|
||
<form method="POST" action="/backups/window" class="schedule-actions" style="display:flex;align-items:flex-end;gap:.75rem;flex-wrap:wrap">
|
||
{{.CSRFField}}
|
||
<div class="form-group" style="margin:0">
|
||
<label for="window_start">Mentési időablak kezdete</label>
|
||
<input type="time" id="window_start" name="window_start" value="{{.BackupWindow}}" class="form-control" style="max-width:9rem" required>
|
||
</div>
|
||
<button type="submit" class="btn btn-sm btn-primary">Mentés</button>
|
||
</form>
|
||
<ul class="form-hint" style="margin-top:1rem;margin-bottom:0;list-style:none;padding:0;line-height:1.9">
|
||
<li>Adatbázis-mentés: <strong>{{.BackupLegDB}}</strong></li>
|
||
<li>Helyi másolat: <strong>{{.BackupLegTier2}}</strong></li>
|
||
<li>Távoli mentés: <strong>{{.BackupLegOffbox}}</strong></li>
|
||
<li>Teljes rendszermentés: kb. <strong>{{.BackupGateFrom}}–{{.BackupGateTo}}</strong> között</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Section 1: Status overview cards -->
|
||
<div class="stats-grid backup-page-cards">
|
||
{{if .Backup.LastDBDump}}
|
||
{{if .Backup.LastDBDump.Success}}
|
||
<div class="stat-card stat-running">
|
||
<div class="stat-value">✓</div>
|
||
<div class="stat-label">Adatmentés aktív</div>
|
||
</div>
|
||
{{else}}
|
||
<div class="stat-card stat-stopped">
|
||
<div class="stat-value">✗</div>
|
||
<div class="stat-label">Adatmentés sikertelen</div>
|
||
</div>
|
||
{{end}}
|
||
{{else}}
|
||
<div class="stat-card">
|
||
<div class="stat-value">–</div>
|
||
<div class="stat-label">Adatmentés</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if and .GuestBackup .GuestBackup.Offsite}}
|
||
<div class="stat-card stat-running">
|
||
<div class="stat-value">✓</div>
|
||
<div class="stat-label">Távoli rendszermentés<br><span class="relative-time">külön hardveren (PBS)</span></div>
|
||
</div>
|
||
{{else}}
|
||
<div class="stat-card" style="border-left-color: var(--text-3);">
|
||
<div class="stat-value" style="background:var(--text-3);-webkit-background-clip:text;background-clip:text;">–</div>
|
||
<div class="stat-label">Távoli rendszermentés<br><span class="relative-time">nincs beállítva</span></div>
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="stat-card stat-total">
|
||
{{if eq .DBSectionState "embedded"}}
|
||
<div class="stat-value">–</div>
|
||
<div class="stat-label">Adatbázis mentve<br><span class="relative-time">beágyazott DB-k a kötetmentésben</span></div>
|
||
{{else}}
|
||
<div class="stat-value">
|
||
{{if .Backup.LastDBDump}}{{len .Backup.LastDBDump.Results}}{{else}}{{len .Backup.DumpFiles}}{{end}}
|
||
</div>
|
||
<div class="stat-label">Adatbázis mentve</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
{{end}}
|
||
|
||
<script>
|
||
// 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.
|
||
function triggerGuestBackup() {
|
||
var btn = document.getElementById('wg-backup-btn');
|
||
felhomConfirm(btn, 'Elindítja a teljes rendszermentést most? A mentés ideje alatt az alkalmazások rövid időre leállhatnak.', function () {
|
||
var out = document.getElementById('wg-backup-result');
|
||
btn.disabled = true;
|
||
out.innerHTML = '<span class="form-hint">Mentés indítása…</span>';
|
||
fetch('/api/guest-backup/trigger', { method: 'POST', headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()) })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (!data.ok) { out.innerHTML = '<div class="alert alert-error">' + (data.error || 'Hiba') + '</div>'; btn.disabled = false; return; }
|
||
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; });
|
||
});
|
||
}
|
||
|
||
function pollGuestBackup(out, btn) {
|
||
var tries = 0;
|
||
var iv = setInterval(function () {
|
||
tries++;
|
||
fetch('/api/guest-backup/status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.ok && data.data) {
|
||
var ph = data.data.phase;
|
||
out.innerHTML = '<span class="form-hint">Állapot: ' + ph + '</span>';
|
||
if (ph === 'done') { clearInterval(iv); out.innerHTML = '<div class="flash flash-success">A rendszermentés elkészült.</div>'; setTimeout(() => window.location.reload(), 1500); }
|
||
else if (ph === 'failed') { clearInterval(iv); out.innerHTML = '<div class="alert alert-error">A mentés sikertelen.</div>'; btn.disabled = false; }
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
if (tries > 180) { clearInterval(iv); btn.disabled = false; } // ~15 min cap at 5s polls
|
||
}, 5000);
|
||
}
|
||
|
||
// R-112 — ACCEPTING the offer. It is an OFFER: this runs only on an explicit click behind the
|
||
// standard inline confirm, and declining is simply never calling it. Nothing else on this page may
|
||
// write the role, and there is no auto-submit.
|
||
(function () {
|
||
var btn = document.getElementById('backup-target-assign');
|
||
if (!btn) { return; } // no offer in this state — nothing to wire
|
||
btn.addEventListener('click', function () {
|
||
var out = document.getElementById('backup-target-assign-result');
|
||
btn.disabled = true;
|
||
out.innerHTML = '<p class="form-hint">Kijelölés folyamatban…</p>';
|
||
fetch('/api/storage/backup-target/assign', {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
|
||
body: JSON.stringify({ path: btn.getAttribute('data-path') })
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (j) {
|
||
if (!j.ok) {
|
||
out.innerHTML = '<div class="alert alert-error">A kijelölés nem sikerült: ' + (j.error || '') + '</div>';
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
// The agent deliberately does NOT restart itself — restarting with a backup in flight
|
||
// records a spurious tier failure. So say so plainly instead of hiding it or faking it.
|
||
if (j.data && j.data.restart_required) {
|
||
out.innerHTML = '<div class="flash flash-success">A meghajtó kijelölve. A beállítás a host-ügynök következő újraindulása után lép életbe.</div>';
|
||
} else {
|
||
out.innerHTML = '<div class="flash flash-success">A meghajtó kijelölve.</div>';
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
out.innerHTML = '<div class="alert alert-error">Hiba: ' + e.message + '</div>';
|
||
btn.disabled = false;
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
{{template "layout_end" .}}
|
||
{{end}}
|