367 lines
14 KiB
HTML
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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>
|