575 lines
23 KiB
HTML
575 lines
23 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); }
|
|
.policy-item {
|
|
border: 1px solid var(--line);
|
|
border-radius: 12px;
|
|
padding: 10px 12px;
|
|
background: rgba(255,255,255,0.65);
|
|
}
|
|
.mono {
|
|
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
font-size: 0.88rem;
|
|
}
|
|
@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>
|
|
<section class="panel">
|
|
<h2>Backup Policies</h2>
|
|
<div class="stack" style="margin-bottom: 12px;">
|
|
<label>Namespace<input id="policy-namespace" list="policy-namespace-options" placeholder="apps"></label>
|
|
<datalist id="policy-namespace-options"></datalist>
|
|
<label>PVC (optional, blank means all PVCs in namespace)<input id="policy-pvc" placeholder="cache-data"></label>
|
|
<label>Interval hours<input id="policy-interval" type="number" min="1" step="1" value="24"></label>
|
|
<label class="row" style="font-weight: 500;">
|
|
<input id="policy-enabled" type="checkbox" checked style="width: auto;">
|
|
Enabled
|
|
</label>
|
|
<button id="policy-save">Save policy</button>
|
|
</div>
|
|
<div id="policy-list" class="stack">
|
|
<p class="muted">Loading policies...</p>
|
|
</div>
|
|
</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');
|
|
const policyListEl = document.getElementById('policy-list');
|
|
const policyNamespaceEl = document.getElementById('policy-namespace');
|
|
const policyNamespaceOptionsEl = document.getElementById('policy-namespace-options');
|
|
const policyPVCEl = document.getElementById('policy-pvc');
|
|
const policyIntervalEl = document.getElementById('policy-interval');
|
|
const policyEnabledEl = document.getElementById('policy-enabled');
|
|
const policySaveBtn = document.getElementById('policy-save');
|
|
let latestInventory = null;
|
|
let latestPolicies = [];
|
|
|
|
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;
|
|
}
|
|
|
|
function suggestNamespaceRestorePrefix() {
|
|
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('');
|
|
return '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 triggerNamespaceBackup(namespace, dryRun) {
|
|
try {
|
|
const payload = await fetchJSON('/v1/backup/namespace', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ namespace, dry_run: dryRun })
|
|
});
|
|
showResult(payload);
|
|
await loadInventory();
|
|
} catch (error) {
|
|
showResult({ error: error.message, namespace, dry_run: dryRun });
|
|
}
|
|
}
|
|
|
|
async function showNamespaceRestore(namespace) {
|
|
const namespaceOptions = (latestInventory && latestInventory.namespaces ? latestInventory.namespaces : [])
|
|
.map((group) => '<option value="' + escapeHtml(group.name) + '"></option>')
|
|
.join('');
|
|
detailsEl.innerHTML = [
|
|
'<div class="stack">',
|
|
'<div><h3 style="margin-bottom: 6px;">Namespace restore</h3><p class="muted" style="margin-top: 0;">Source namespace: ' + escapeHtml(namespace) + '</p></div>',
|
|
'<label>Target namespace<input id="namespace-restore-target" list="namespace-restore-options" value="' + escapeHtml(namespace) + '"></label>',
|
|
'<datalist id="namespace-restore-options">' + namespaceOptions + '</datalist>',
|
|
'<label>Target PVC prefix<input id="namespace-restore-prefix" value="' + escapeHtml(suggestNamespaceRestorePrefix()) + '"></label>',
|
|
'<label>Snapshot hint (optional, blank = latest completed per PVC)<input id="namespace-restore-snapshot" placeholder="blank uses latest"></label>',
|
|
'<p class="muted">A target PVC will be created for each source PVC using this prefix.</p>',
|
|
'<div class="actions">',
|
|
'<button id="namespace-restore-run">Create restore PVCs</button>',
|
|
'<button id="namespace-restore-dry" class="secondary">Dry run namespace restore</button>',
|
|
'</div></div>'
|
|
].join('');
|
|
|
|
const runRestore = async (dryRun) => {
|
|
const targetNamespace = document.getElementById('namespace-restore-target').value.trim();
|
|
const targetPrefix = document.getElementById('namespace-restore-prefix').value.trim();
|
|
const snapshot = document.getElementById('namespace-restore-snapshot').value.trim();
|
|
try {
|
|
const payload = await fetchJSON('/v1/restores/namespace', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
namespace,
|
|
target_namespace: targetNamespace,
|
|
target_prefix: targetPrefix,
|
|
snapshot,
|
|
dry_run: dryRun
|
|
})
|
|
});
|
|
showResult(payload);
|
|
await loadInventory();
|
|
} catch (error) {
|
|
showResult({ error: error.message, namespace, target_namespace: targetNamespace, target_prefix: targetPrefix, dry_run: dryRun });
|
|
}
|
|
};
|
|
|
|
document.getElementById('namespace-restore-run').onclick = () => runRestore(false);
|
|
document.getElementById('namespace-restore-dry').onclick = () => runRestore(true);
|
|
}
|
|
|
|
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 renderPolicies(payload) {
|
|
latestPolicies = payload && payload.policies ? payload.policies : [];
|
|
if (!latestPolicies.length) {
|
|
policyListEl.innerHTML = '<p class="muted">No policies yet. Add one to enable scheduled backups.</p>';
|
|
return;
|
|
}
|
|
policyListEl.innerHTML = latestPolicies.map((policy) => {
|
|
const scope = policy.pvc ? (policy.namespace + '/' + policy.pvc) : (policy.namespace + '/*');
|
|
return [
|
|
'<article class="policy-item stack">',
|
|
'<div class="row" style="justify-content: space-between;">',
|
|
'<span class="mono">' + escapeHtml(scope) + '</span>',
|
|
'<span class="badge ' + (policy.enabled ? 'good' : 'bad') + '">' + (policy.enabled ? 'Enabled' : 'Disabled') + '</span>',
|
|
'</div>',
|
|
'<div class="meta">Every ' + escapeHtml(policy.interval_hours) + 'h</div>',
|
|
'<div class="actions">',
|
|
'<button class="secondary" data-action="policy-apply" data-namespace="' + escapeHtml(policy.namespace) + '" data-pvc="' + escapeHtml(policy.pvc || '') + '" data-interval="' + escapeHtml(policy.interval_hours) + '" data-enabled="' + escapeHtml(policy.enabled) + '">Load</button>',
|
|
'<button class="secondary" data-action="policy-delete" data-policy-id="' + escapeHtml(policy.id) + '">Delete</button>',
|
|
'</div>',
|
|
'</article>'
|
|
].join('');
|
|
}).join('');
|
|
|
|
policyListEl.querySelectorAll('button[data-action="policy-delete"]').forEach((button) => {
|
|
button.addEventListener('click', () => deletePolicy(button.dataset.policyId));
|
|
});
|
|
policyListEl.querySelectorAll('button[data-action="policy-apply"]').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
policyNamespaceEl.value = button.dataset.namespace || '';
|
|
policyPVCEl.value = button.dataset.pvc || '';
|
|
policyIntervalEl.value = button.dataset.interval || '24';
|
|
policyEnabledEl.checked = String(button.dataset.enabled) === 'true';
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadPolicies() {
|
|
policyListEl.innerHTML = '<p class="muted">Refreshing policies...</p>';
|
|
try {
|
|
const payload = await fetchJSON('/v1/policies');
|
|
renderPolicies(payload);
|
|
} catch (error) {
|
|
policyListEl.innerHTML = '<p class="error">' + escapeHtml(error.message) + '</p>';
|
|
}
|
|
}
|
|
|
|
async function savePolicy() {
|
|
const namespace = policyNamespaceEl.value.trim();
|
|
const pvc = policyPVCEl.value.trim();
|
|
const intervalHours = Number(policyIntervalEl.value);
|
|
const enabled = Boolean(policyEnabledEl.checked);
|
|
try {
|
|
const payload = await fetchJSON('/v1/policies', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
namespace,
|
|
pvc,
|
|
interval_hours: intervalHours,
|
|
enabled
|
|
})
|
|
});
|
|
showResult(payload);
|
|
await loadPolicies();
|
|
} catch (error) {
|
|
showResult({ error: error.message, namespace, pvc, interval_hours: intervalHours, enabled });
|
|
}
|
|
}
|
|
|
|
async function deletePolicy(policyID) {
|
|
try {
|
|
const payload = await fetchJSON('/v1/policies/' + encodeURIComponent(policyID), { method: 'DELETE' });
|
|
showResult(payload);
|
|
await loadPolicies();
|
|
} catch (error) {
|
|
showResult({ error: error.message, policy_id: policyID });
|
|
}
|
|
}
|
|
|
|
function renderInventory(payload) {
|
|
latestInventory = payload;
|
|
generatedAtEl.textContent = payload.generated_at ? 'Updated ' + payload.generated_at : '';
|
|
policyNamespaceOptionsEl.innerHTML = (payload.namespaces || [])
|
|
.map((group) => '<option value="' + escapeHtml(group.name) + '"></option>')
|
|
.join('');
|
|
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">',
|
|
'<div class="row" style="justify-content: space-between; align-items: flex-start;">',
|
|
'<h3 style="margin: 0;">' + escapeHtml(group.name) + '</h3>',
|
|
'<div class="actions">',
|
|
'<button class="secondary" data-action="backup-namespace" data-namespace="' + escapeHtml(group.name) + '">Backup namespace</button>',
|
|
'<button class="secondary" data-action="restore-namespace" data-namespace="' + escapeHtml(group.name) + '">Restore namespace</button>',
|
|
'</div>',
|
|
'</div>',
|
|
pvcs,
|
|
'</section>'
|
|
].join('');
|
|
}).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));
|
|
});
|
|
inventoryEl.querySelectorAll('button[data-action="backup-namespace"]').forEach((button) => {
|
|
button.addEventListener('click', () => triggerNamespaceBackup(button.dataset.namespace, false));
|
|
});
|
|
inventoryEl.querySelectorAll('button[data-action="restore-namespace"]').forEach((button) => {
|
|
button.addEventListener('click', () => showNamespaceRestore(button.dataset.namespace));
|
|
});
|
|
}
|
|
|
|
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);
|
|
policySaveBtn.addEventListener('click', savePolicy);
|
|
loadWhoAmI();
|
|
loadInventory();
|
|
loadPolicies();
|
|
</script>
|
|
</body>
|
|
</html>
|