367 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Soteria Backup Console</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe4;
--card: #fffaf1;
--ink: #1d1d1b;
--muted: #5f625b;
--line: #d8cdb8;
--accent: #0f766e;
--warn: #c2410c;
--good: #166534;
--bad: #991b1b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, #f9e8c8 0, var(--bg) 45%), var(--bg);
}
header {
padding: 24px;
border-bottom: 1px solid var(--line);
background: rgba(255,250,241,0.92);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
h1 { margin: 0 0 6px; font-size: 2rem; }
.sub { color: var(--muted); margin: 0; }
.topbar, main {
width: min(1200px, calc(100vw - 32px));
margin: 0 auto;
}
.topbar {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
main {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 20px;
padding: 20px 0 40px;
}
.panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
box-shadow: 0 10px 24px rgba(38, 35, 25, 0.06);
}
.panel h2, .panel h3 { margin-top: 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
padding: 6px 10px;
font-size: 0.92rem;
background: #efe7d6;
}
.badge.good { background: #dcfce7; color: var(--good); }
.badge.bad { background: #fee2e2; color: var(--bad); }
button {
border: 0;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
cursor: pointer;
background: var(--accent);
color: white;
}
button.secondary {
background: #efe7d6;
color: var(--ink);
}
button:disabled { opacity: 0.6; cursor: wait; }
.namespace {
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px dashed var(--line);
}
.namespace:last-child { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; }
.pvc {
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
margin: 10px 0;
background: rgba(255,255,255,0.55);
}
.row, .actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.meta {
color: var(--muted);
font-size: 0.95rem;
}
.stack { display: grid; gap: 12px; }
label { display: grid; gap: 6px; font-weight: 600; }
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
font: inherit;
background: white;
}
pre {
margin: 0;
padding: 12px;
border-radius: 12px;
background: #171717;
color: #f7f7f7;
overflow: auto;
font-size: 0.9rem;
}
.muted { color: var(--muted); }
.error { color: var(--bad); }
@media (max-width: 900px) {
main { grid-template-columns: 1fr; }
h1 { font-size: 1.7rem; }
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div>
<h1>Soteria Backup Console</h1>
<p class="sub">Namespace-grouped PVC backup and restore control plane for Atlas.</p>
</div>
<div class="row">
<span id="auth-pill" class="badge">Checking access...</span>
<button id="refresh-btn" class="secondary">Refresh inventory</button>
</div>
</div>
</header>
<main>
<section class="panel">
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
<h2 style="margin-bottom: 0;">PVC Inventory</h2>
<span id="generated-at" class="muted"></span>
</div>
<div id="inventory" class="stack">
<p class="muted">Loading PVC inventory...</p>
</div>
</section>
<aside class="stack">
<section class="panel">
<h2>Restore Workspace</h2>
<div id="details" class="muted">Choose a PVC to inspect backups or prepare a restore.</div>
</section>
<section class="panel">
<h2>Last Action</h2>
<pre id="result">No action yet.</pre>
</section>
</aside>
</main>
<script>
const inventoryEl = document.getElementById('inventory');
const detailsEl = document.getElementById('details');
const resultEl = document.getElementById('result');
const generatedAtEl = document.getElementById('generated-at');
const authPillEl = document.getElementById('auth-pill');
const refreshBtn = document.getElementById('refresh-btn');
let latestInventory = null;
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function showResult(payload) {
resultEl.textContent = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
}
function suggestTargetPVCName(sourcePVC) {
const now = new Date();
const pad = (value) => String(value).padStart(2, '0');
const suffix = [
now.getUTCFullYear(),
pad(now.getUTCMonth() + 1),
pad(now.getUTCDate()),
pad(now.getUTCHours()),
pad(now.getUTCMinutes())
].join('');
const normalized = ('restore-' + sourcePVC + '-' + suffix)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const trimmed = normalized.length <= 63 ? normalized : normalized.slice(0, 63).replace(/-+$/g, '');
return trimmed || 'restore-' + suffix;
}
async function fetchJSON(url, options) {
const response = await fetch(url, options);
const text = await response.text();
let payload = text;
try { payload = text ? JSON.parse(text) : {}; } catch (_) {}
if (!response.ok) {
const message = payload && payload.error ? payload.error : response.status + ' ' + response.statusText;
throw new Error(message);
}
return payload;
}
async function loadWhoAmI() {
try {
const who = await fetchJSON('/v1/whoami');
const label = who.authenticated
? (who.user || who.email || 'authenticated') + ' (' + ((who.groups || []).join(', ') || 'no groups') + ')'
: 'anonymous';
authPillEl.textContent = label;
authPillEl.className = 'badge ' + (who.authenticated ? 'good' : '');
} catch (error) {
authPillEl.textContent = error.message;
authPillEl.className = 'badge bad';
}
}
async function triggerBackup(namespace, pvc) {
try {
const payload = await fetchJSON('/v1/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, pvc, dry_run: false })
});
showResult(payload);
await loadInventory();
} catch (error) {
showResult({ error: error.message, namespace, pvc });
}
}
async function showRestore(namespace, pvc) {
detailsEl.innerHTML = '<p class="muted">Loading backups...</p>';
try {
const payload = await fetchJSON('/v1/backups?namespace=' + encodeURIComponent(namespace) + '&pvc=' + encodeURIComponent(pvc));
const namespaceOptions = (latestInventory && latestInventory.namespaces ? latestInventory.namespaces : [])
.map((group) => '<option value="' + escapeHtml(group.name) + '"></option>')
.join('');
const options = payload.backups
.filter((backup) => backup.state === 'Completed')
.map((backup) => '<option value="' + escapeHtml(backup.url) + '">' + escapeHtml(backup.name) + ' | ' + escapeHtml(backup.created || 'unknown time') + '</option>')
.join('');
detailsEl.innerHTML = [
'<div class="stack">',
'<div><h3 style="margin-bottom: 6px;">' + escapeHtml(namespace) + '/' + escapeHtml(pvc) + '</h3><p class="muted" style="margin-top: 0;">Source volume: ' + escapeHtml(payload.volume) + '</p></div>',
'<label>Backup snapshot<select id="restore-backup">' + (options || '<option value="">No completed backups available</option>') + '</select></label>',
'<label>Target namespace<input id="restore-namespace" list="restore-namespace-options" value="' + escapeHtml(namespace) + '"></label>',
'<datalist id="restore-namespace-options">' + namespaceOptions + '</datalist>',
'<label>Target PVC name<input id="restore-pvc" value="' + escapeHtml(suggestTargetPVCName(pvc)) + '"></label>',
'<p class="muted">Tip: keep target PVC unique for each restore drill to avoid conflicts.</p>',
'<div class="actions">',
'<button id="restore-submit"' + (options ? '' : ' disabled') + '>Create restore PVC</button>',
'<button id="restore-dry-run" class="secondary"' + (options ? '' : ' disabled') + '>Dry run restore</button>',
'<button id="restore-view" class="secondary">Show backup JSON</button>',
'</div></div>'
].join('');
const runRestore = async (dryRun) => {
const backupURL = document.getElementById('restore-backup').value;
const targetNamespace = document.getElementById('restore-namespace').value.trim();
const targetPVC = document.getElementById('restore-pvc').value.trim();
try {
const result = await fetchJSON('/v1/restores', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace,
pvc,
backup_url: backupURL,
target_namespace: targetNamespace,
target_pvc: targetPVC,
dry_run: dryRun
})
});
showResult(result);
await loadInventory();
} catch (error) {
showResult({ error: error.message, namespace, pvc, target_namespace: targetNamespace, target_pvc: targetPVC, dry_run: dryRun });
}
};
document.getElementById('restore-submit').onclick = () => runRestore(false);
document.getElementById('restore-dry-run').onclick = () => runRestore(true);
document.getElementById('restore-view').onclick = () => showResult(payload);
} catch (error) {
detailsEl.innerHTML = '<p class="error">' + escapeHtml(error.message) + '</p>';
}
}
function renderInventory(payload) {
latestInventory = payload;
generatedAtEl.textContent = payload.generated_at ? 'Updated ' + payload.generated_at : '';
if (!payload.namespaces || payload.namespaces.length === 0) {
inventoryEl.innerHTML = '<p class="muted">No bound PVCs found.</p>';
return;
}
inventoryEl.innerHTML = payload.namespaces.map((group) => {
const pvcs = group.pvcs.map((pvc) => {
const healthClass = pvc.healthy ? 'good' : 'bad';
const healthText = pvc.healthy ? 'Healthy backup window' : (pvc.health_reason || 'Needs attention');
const ageText = pvc.last_backup_at
? Number(pvc.last_backup_age_hours || 0).toFixed(1) + 'h since last success'
: 'No successful backup yet';
return [
'<article class="pvc">',
'<div class="row" style="justify-content: space-between; align-items: flex-start;">',
'<div><h3 style="margin: 0 0 4px;">' + escapeHtml(pvc.pvc) + '</h3><div class="meta">' + escapeHtml(pvc.volume) + ' • ' + escapeHtml(pvc.storage_class || 'no storage class') + ' • ' + escapeHtml(pvc.capacity || 'size unknown') + '</div></div>',
'<span class="badge ' + healthClass + '">' + escapeHtml(healthText) + '</span>',
'</div>',
'<p class="meta">' + escapeHtml(ageText) + (pvc.error ? ' • ' + escapeHtml(pvc.error) : '') + '</p>',
'<div class="actions">',
'<button data-action="backup" data-namespace="' + escapeHtml(pvc.namespace) + '" data-pvc="' + escapeHtml(pvc.pvc) + '">Backup now</button>',
'<button class="secondary" data-action="restore" data-namespace="' + escapeHtml(pvc.namespace) + '" data-pvc="' + escapeHtml(pvc.pvc) + '">Restore</button>',
'</div></article>'
].join('');
}).join('');
return '<section class="namespace"><h3>' + escapeHtml(group.name) + '</h3>' + pvcs + '</section>';
}).join('');
inventoryEl.querySelectorAll('button[data-action="backup"]').forEach((button) => {
button.addEventListener('click', () => triggerBackup(button.dataset.namespace, button.dataset.pvc));
});
inventoryEl.querySelectorAll('button[data-action="restore"]').forEach((button) => {
button.addEventListener('click', () => showRestore(button.dataset.namespace, button.dataset.pvc));
});
}
async function loadInventory() {
refreshBtn.disabled = true;
inventoryEl.innerHTML = '<p class="muted">Refreshing inventory...</p>';
try {
const payload = await fetchJSON('/v1/inventory');
renderInventory(payload);
} catch (error) {
inventoryEl.innerHTML = '<p class="error">' + escapeHtml(error.message) + '</p>';
} finally {
refreshBtn.disabled = false;
}
}
refreshBtn.addEventListener('click', loadInventory);
loadWhoAmI();
loadInventory();
</script>
</body>
</html>