Files
CloudOps/platform/templates/pages/restore.html
root 9361b9c4c1 Make restore-from-standby actually work, not just look wired
Two gaps found testing this live from the standby:

1. restore.html's "Restore on This Server" was checked by default
   regardless of RUNNING_ON_MAIN_SERVER — on the standby that option is
   nonsensical (no local cluster/kubectl at all) and restore_start()
   would have just tried and failed confusingly. Now: that radio is
   disabled with an explanatory note when not on the main server,
   "External Machine" is checked instead and pre-filled with the tunnel
   details (localhost:2224, contabo-key) so restoring from the standby
   just targets the real main server without the user having to know
   any of that. Added a matching server-side guard in restore_start()
   for target=='local' + not RUNNING_ON_MAIN_SERVER (defense in depth —
   the UI already prevents it, this catches a direct API call too).
   Also fixed refreshSystemMetrics() in platform.js, which would have
   overwritten the disabled option's label with the (now-correct, see
   previous commit) main-server hostname — looking like a working local
   target when it isn't.

2. sync-standby-platform.sh only ever mirrored platform/ — but
   restore_start() references /root/CloudOps/backup/restore-k8s-apps.sh
   as a fixed absolute path to scp to the remote target, and that
   directory never existed on the VM at all. Every restore attempt from
   the standby failed immediately with "restore-k8s-apps.sh not found",
   regardless of target. Now mirrors /root/CloudOps/backup/ too.

Verified end-to-end for real: triggered a restore of frappe/erpnext from
the standby's actual web UI (target=remote, localhost:2224) — connected
over the tunnel, copied the backup archive + script to the main server,
ran restore-k8s-apps.sh there, scaled the deployment down/up, restored
the DB. Confirmed after: 740 tables in the DB, /api/method/ping
responding on the live pod. Not a dry run — a real restore, actually
initiated from the standby machine.
2026-08-21 13:55:43 +02:00

413 lines
19 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.

{% extends "base.html" %}
{% block content %}
<div class="card">
<div class="card-header">
<div class="card-title"><i class="fas fa-rotate-right"></i> Restore Configuration</div>
</div>
<div class="restore-form">
<div class="form-section">
<div class="form-section-title">STEP 1 — SELECT BACKUP SOURCE</div>
<div class="radio-group">
<label class="radio-card">
<input type="radio" name="backup_source" value="local" checked onchange="nvUpdateSource()">
<div class="radio-body"><span class="radio-icon">🖥️</span><div><div class="radio-label">Main Server</div><div class="radio-desc"><code>/root/backups/</code></div></div></div>
</label>
<label class="radio-card">
<input type="radio" name="backup_source" value="vm" onchange="nvUpdateSource()">
<div class="radio-body"><span class="radio-icon">💾</span><div><div class="radio-label">VM Backup Server</div><div class="radio-desc"><code>/backups/main-server/</code></div></div></div>
</label>
<label class="radio-card">
<input type="radio" name="backup_source" value="cloud" onchange="nvUpdateSource()">
<div class="radio-body"><span class="radio-icon">☁️</span><div><div class="radio-label">Cloudflare R2</div><div class="radio-desc">cloud storage</div></div></div>
</label>
</div>
{# Separate selects — only one shown at a time #}
<div class="form-group" style="margin-top:14px; max-width:500px;">
<label class="form-label">BACKUP FILE</label>
{# Local select #}
<select id="select-local" class="form-input">
{% for b in backups %}
<option value="{{ b }}">{{ b }}</option>
{% else %}
<option disabled>No local backup</option>
{% endfor %}
</select>
{# VM select #}
<select id="select-vm" class="form-input" style="display:none;">
{% for b in vm_backups %}
<option value="{{ b }}">{{ b }}</option>
{% else %}
<option disabled>No VM backups</option>
{% endfor %}
</select>
{# Cloud select — populated via JS #}
<select id="select-cloud" class="form-input" style="display:none;">
<option disabled selected>Loading cloud backups…</option>
</select>
</div>
{# Cloud info notice #}
<div id="cloud-notice" style="display:none;margin-top:10px;padding:10px 14px;
background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.2);
border-radius:8px;font-size:12px;color:var(--accent2);">
<i class="fas fa-info-circle"></i>
The backup will be downloaded from R2 to the server first, then restored.
This may take a few minutes depending on the file size (typically 12 GB).
</div>
</div>
<div class="form-section">
<div class="form-section-title">STEP 2 — SELECT RESTORE TARGET</div>
{% if not running_on_main_server %}
<div style="margin-bottom:12px;padding:10px 14px;background:rgba(245,158,11,0.08);
border:1px solid rgba(245,158,11,0.25);border-radius:8px;font-size:12px;color:var(--yellow);">
<i class="fas fa-triangle-exclamation"></i>
Running as the standby — there's no local cluster here to restore into.
"External Machine" is pre-filled to reach the real main server over the standby's
SSH tunnel; only change these if you actually mean a different target.
</div>
{% endif %}
<div class="radio-group">
<label class="radio-card{% if not running_on_main_server %} disabled{% endif %}">
<input type="radio" name="restore_target" value="local"
{% if running_on_main_server %}checked{% else %}disabled{% endif %}
onchange="toggleRemoteFields()">
<div class="radio-body"><span class="radio-icon">🎯</span><div><div class="radio-label">Restore on This Server</div><div class="radio-desc" id="this-server-desc">{% if running_on_main_server %}Loading hostname…{% else %}Not available on the standby{% endif %}</div></div></div>
</label>
<label class="radio-card">
<input type="radio" name="restore_target" value="remote" {% if not running_on_main_server %}checked{% endif %} onchange="toggleRemoteFields()">
<div class="radio-body"><span class="radio-icon">📡</span><div><div class="radio-label">External Machine</div><div class="radio-desc">via SSH — any IP</div></div></div>
</label>
</div>
<div id="remote-fields" style="display:{% if running_on_main_server %}none{% else %}block{% endif %}; margin-top:16px; max-width:560px;">
<div class="form-row">
<div class="form-group">
<label class="form-label">TARGET IP</label>
<input type="text" id="remote-ip" class="form-input" placeholder="192.168.x.x or IP" value="{{ tunnel_remote_ip if not running_on_main_server else '' }}">
</div>
<div class="form-group" style="max-width:100px;">
<label class="form-label">SSH PORT</label>
<input type="text" id="remote-port" class="form-input" value="{{ tunnel_remote_port if not running_on_main_server else '22' }}">
</div>
<div class="form-group" style="max-width:120px;">
<label class="form-label">SSH USER</label>
<input type="text" id="remote-user" class="form-input" value="root">
</div>
</div>
<div class="form-group" style="margin-top:8px;">
<label class="form-label">AUTHENTICATION</label>
<div class="radio-group" style="gap:8px;">
<label class="radio-card small">
<input type="radio" name="auth_method" value="key" checked onchange="toggleAuthFields()">
<div class="radio-body"><span>🔑</span><div class="radio-label">SSH Key</div></div>
</label>
<label class="radio-card small">
<input type="radio" name="auth_method" value="password" onchange="toggleAuthFields()">
<div class="radio-body"><span>🔒</span><div class="radio-label">Password</div></div>
</label>
</div>
</div>
<div id="key-field" class="form-group" style="margin-top:8px;">
<label class="form-label">SSH KEY PATH</label>
<input type="text" id="ssh-key-path" class="form-input" value="/root/.ssh/contabo-key">
</div>
<div id="password-field" class="form-group" style="display:none; margin-top:8px;">
<label class="form-label">SSH PASSWORD</label>
<input type="password" id="ssh-password" class="form-input">
</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">STEP 3 — SELECT APPS TO RESTORE</div>
<p class="app-picker-note">
<i class="fas fa-info-circle"></i>
All apps are selected by default. Uncheck any app you want to leave untouched —
only checked apps will be restored, and they are force-restored even if currently running and healthy.
</p>
<div class="app-picker-grid" id="apps-checkbox-group">
<label class="app-picker-card">
<input type="checkbox" value="frappe" checked>
<div class="app-picker-body">
<span class="app-picker-icon">🧾</span>
<div>
<div class="app-picker-name">Frappe / ERPNext</div>
<div class="app-picker-desc">ERP & business suite</div>
</div>
<span class="app-picker-check"><i class="fas fa-check"></i></span>
</div>
</label>
<label class="app-picker-card">
<input type="checkbox" value="odoo" checked>
<div class="app-picker-body">
<span class="app-picker-icon">🟣</span>
<div>
<div class="app-picker-name">Odoo</div>
<div class="app-picker-desc">CRM, inventory & accounting</div>
</div>
<span class="app-picker-check"><i class="fas fa-check"></i></span>
</div>
</label>
<label class="app-picker-card">
<input type="checkbox" value="nextcloud" checked>
<div class="app-picker-body">
<span class="app-picker-icon">☁️</span>
<div>
<div class="app-picker-name">Nextcloud</div>
<div class="app-picker-desc">Files & collaboration</div>
</div>
<span class="app-picker-check"><i class="fas fa-check"></i></span>
</div>
</label>
<label class="app-picker-card">
<input type="checkbox" value="mautic" checked>
<div class="app-picker-body">
<span class="app-picker-icon">📣</span>
<div>
<div class="app-picker-name">Mautic</div>
<div class="app-picker-desc">Marketing automation</div>
</div>
<span class="app-picker-check"><i class="fas fa-check"></i></span>
</div>
</label>
<label class="app-picker-card">
<input type="checkbox" value="n8n" checked>
<div class="app-picker-body">
<span class="app-picker-icon">🔁</span>
<div>
<div class="app-picker-name">n8n</div>
<div class="app-picker-desc">Workflow automation</div>
</div>
<span class="app-picker-check"><i class="fas fa-check"></i></span>
</div>
</label>
</div>
<div class="app-picker-actions">
<button type="button" class="btn btn-sm btn-ghost" onclick="nvSetAllApps(true)"><i class="fas fa-check-double"></i> Select All</button>
<button type="button" class="btn btn-sm btn-ghost" onclick="nvSetAllApps(false)"><i class="fas fa-ban"></i> Select None</button>
</div>
</div>
<div class="form-section">
<button class="btn btn-danger btn-lg" onclick="nvLaunchRestore()" id="restore-btn">
<i class="fas fa-play"></i> Start Restore
</button>
<p style="color:var(--text3); font-size:12px; margin-top:8px;" id="restore-warning-text">
⚠ Full restore (all apps checked): healthy running containers are skipped.
</p>
</div>
</div>
<div id="restore-log-wrapper" style="display:none; margin-top:20px;">
<div class="card-header" style="margin-bottom:10px;">
<div class="card-title"><i class="fas fa-terminal"></i> Restore Log</div>
<span class="badge" id="restore-status-badge" style="background:rgba(59,130,246,0.15);color:var(--accent2);">Running…</span>
</div>
<div id="restore-log" class="log-console"></div>
<div style="color:var(--text3);font-size:11px;margin-top:6px;font-family:var(--mono);" id="restore-elapsed"></div>
</div>
</div>
<script type="application/json" id="restore-prefill-data">{{ restore_prefill|tojson }}</script>
<script>
window.restorePrefill = JSON.parse(document.getElementById('restore-prefill-data').textContent);
// ── Cloud backup data (key lookup) ────────────────────────────────────────────
let _cloudBackups = [];
async function loadCloudSelect() {
const sel = document.getElementById('select-cloud');
try {
const res = await fetch('/api/cloud/r2/backups');
const data = await res.json();
// Only .tar.gz — no .sha256
_cloudBackups = (data.backups || []).filter(b => b.name.endsWith('.tar.gz'));
if (_cloudBackups.length === 0) {
sel.innerHTML = '<option disabled>No cloud backups found</option>';
} else {
sel.innerHTML = _cloudBackups.map(b =>
`<option value="${b.name}" data-key="${b.key}">${b.name} (${b.size_human})</option>`
).join('');
}
} catch(e) {
sel.innerHTML = '<option disabled>Failed to load R2 backups</option>';
}
}
// ── Show/hide correct select based on source radio ────────────────────────────
function nvUpdateSource() {
const source = document.querySelector('input[name="backup_source"]:checked')?.value;
document.getElementById('select-local').style.display = source === 'local' ? '' : 'none';
document.getElementById('select-vm').style.display = source === 'vm' ? '' : 'none';
document.getElementById('select-cloud').style.display = source === 'cloud' ? '' : 'none';
document.getElementById('cloud-notice').style.display = source === 'cloud' ? '' : 'none';
}
// ── Get currently selected file + source ─────────────────────────────────────
function nvGetSelection() {
const source = document.querySelector('input[name="backup_source"]:checked')?.value || 'local';
let file = '', key = '';
if (source === 'local') {
file = document.getElementById('select-local').value;
} else if (source === 'vm') {
file = document.getElementById('select-vm').value;
} else {
const sel = document.getElementById('select-cloud');
file = sel.value;
const opt = sel.options[sel.selectedIndex];
key = opt?.dataset?.key || ('backups/' + file);
}
return { source, file, key };
}
// ── App selection (Step 3) ────────────────────────────────────────────────────
function nvGetAppsCheckboxes() {
return Array.from(document.querySelectorAll('#apps-checkbox-group input[type="checkbox"]'));
}
// Returns [] when every app is checked (= full restore, no filter needed),
// otherwise returns the list of explicitly checked app keys.
function nvGetSelectedApps() {
const boxes = nvGetAppsCheckboxes();
const checked = boxes.filter(b => b.checked).map(b => b.value);
return checked.length === boxes.length ? [] : checked;
}
function nvSetAllApps(state) {
nvGetAppsCheckboxes().forEach(b => b.checked = state);
nvUpdateRestoreWarning();
}
function nvUpdateRestoreWarning() {
const selected = nvGetSelectedApps();
const el = document.getElementById('restore-warning-text');
if (selected.length === 0) {
el.textContent = '⚠ Full restore (all apps checked): healthy running containers are skipped.';
} else {
el.textContent = `⚠ Selective restore: only ${selected.join(', ')} will be touched — forced even if currently healthy.`;
}
}
document.addEventListener('change', (e) => {
if (e.target.closest('#apps-checkbox-group')) nvUpdateRestoreWarning();
});
// ── Launch restore ────────────────────────────────────────────────────────────
async function nvLaunchRestore() {
const { source, file, key } = nvGetSelection();
if (!file) { alert('Please select a backup file.'); return; }
const appsCheckboxes = nvGetAppsCheckboxes();
const checkedCount = appsCheckboxes.filter(b => b.checked).length;
if (checkedCount === 0) { alert('Select at least one app to restore.'); return; }
const selectedApps = nvGetSelectedApps();
const target = document.querySelector('input[name="restore_target"]:checked')?.value || 'local';
const authMethod = document.querySelector('input[name="auth_method"]:checked')?.value || 'key';
const body = {
backup_source: source,
backup_file: file,
cloud_key: key,
apps: selectedApps,
target,
remote_ip: document.getElementById('remote-ip')?.value || '',
remote_port: document.getElementById('remote-port')?.value || '22',
remote_user: document.getElementById('remote-user')?.value || 'root',
auth_method: authMethod,
ssh_key_path: document.getElementById('ssh-key-path')?.value || '',
ssh_password: document.getElementById('ssh-password')?.value || '',
};
const btn = document.getElementById('restore-btn');
const wrapper = document.getElementById('restore-log-wrapper');
const logEl = document.getElementById('restore-log');
const badge = document.getElementById('restore-status-badge');
const elapsed = document.getElementById('restore-elapsed');
btn.disabled = true;
wrapper.style.display = '';
const scopeMsg = selectedApps.length
? `🎯 Restoring only: ${selectedApps.join(', ')}`
: '🚀 Starting full restore…';
logEl.innerHTML = source === 'cloud'
? `<div style="color:var(--accent2)">☁️ Downloading backup from R2, please wait…</div><div style="color:var(--accent2)">${scopeMsg}</div>`
: `<div style="color:var(--accent2)">${scopeMsg}</div>`;
badge.textContent = 'Running…';
badge.style.cssText = 'background:rgba(59,130,246,0.15);color:var(--accent2);';
try {
const res = await fetch('/restore/start', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const data = await res.json();
if (data.error) {
logEl.innerHTML += `<div style="color:#f87171;">❌ ${data.error}</div>`;
badge.textContent = '❌ Error';
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
btn.disabled = false;
return;
}
const jobId = data.job_id;
const poll = setInterval(async () => {
try {
const s = await fetch(`/restore/status/${jobId}`);
const job = await s.json();
logEl.innerHTML = job.log.map(l => `<div>${l}</div>`).join('');
logEl.scrollTop = logEl.scrollHeight;
elapsed.textContent = `Elapsed: ${job.elapsed}s`;
if (job.status === 'done') {
clearInterval(poll);
badge.textContent = '✅ Done';
badge.style.cssText = 'background:rgba(34,197,94,0.12);color:var(--green);';
btn.disabled = false;
} else if (job.status === 'error') {
clearInterval(poll);
badge.textContent = '❌ Failed';
badge.style.cssText = 'background:rgba(239,68,68,0.12);color:#f87171;';
btn.disabled = false;
}
} catch(e) { clearInterval(poll); }
}, 1500);
} catch(e) {
logEl.innerHTML += `<div style="color:#f87171;">❌ ${e.message}</div>`;
btn.disabled = false;
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadCloudSelect();
nvUpdateRestoreWarning();
// Handle prefill from URL params (e.g. clicking Restore from backups page)
const pf = window.restorePrefill || {};
if (pf.source) {
const radio = document.querySelector(`input[name="backup_source"][value="${pf.source}"]`);
if (radio) { radio.checked = true; nvUpdateSource(); }
}
if (pf.file) {
// Pre-select file after a short delay to let cloud options load
setTimeout(() => {
const source = pf.source || 'local';
const selId = source === 'cloud' ? 'select-cloud' : source === 'vm' ? 'select-vm' : 'select-local';
const sel = document.getElementById(selId);
if (sel) {
for (let o of sel.options) {
if (o.value === pf.file) { o.selected = true; break; }
}
}
}, 600);
}
});
</script>
{% endblock %}