backup / restoreslop

This commit is contained in:
legop3
2026-09-14 19:09:02 -04:00
parent edc1b825f2
commit 1c34849bd0
32 changed files with 1220 additions and 70 deletions
+4
View File
@@ -17,6 +17,7 @@ import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
import { confirmAdminPassword, getAdminSnapshot } from './api.js';
import AdministratorAccounts from './components/AdministratorAccounts.jsx';
import AdminOverview from './components/AdminOverview.jsx';
import BackupRestorePanel from './components/BackupRestorePanel.jsx';
import ConfigurationEditor from './components/ConfigurationEditor.jsx';
import PasswordConfirmationDialog from './components/PasswordConfirmationDialog.jsx';
import './styles.css';
@@ -26,6 +27,7 @@ const TOP_LEVEL_SECTIONS = [
{ key: 'fleet', label: 'Fleet operations' },
{ key: 'users', label: 'Users and administrators', lockdownOnly: true },
{ key: 'configuration', label: 'Configuration', lockdownOnly: true },
{ key: 'backup-restore', label: 'Backup and restore', lockdownOnly: true },
];
export default function AdminApp() {
@@ -139,6 +141,8 @@ export default function AdminApp() {
);
} else if (activeSection === 'configuration') {
content = <ConfigurationEditor snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} onReload={loadSnapshot} />;
} else if (activeSection === 'backup-restore') {
content = <BackupRestorePanel socket={socket} runSensitive={runSensitive} />;
} else {
content = <AdminOverview snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />;
}
+4
View File
@@ -26,6 +26,10 @@ export const createAdministrator = (socket, payload) => emitAdminRequest(socket,
export const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);
export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
export const confirmFullRestore = (socket, restoreId) => emitAdminRequest(socket, 'backupRestore:confirmRestore', { restoreId });
export const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');
export const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);
@@ -0,0 +1,147 @@
// Backup and Restore Administration
// Purpose: Downloads one complete server backup and validates a selected archive before explicit restore confirmation.
// Scope: Transfers large bytes over one-use HTTP URLs while protected socket actions own authorization and restart intent.
import { useEffect, useState } from 'react';
import CardFrame from '../../components/CardFrame/index.jsx';
import {
confirmFullRestore,
createFullBackup,
createRestoreUpload,
getBackupRestoreStatus,
} from '../api.js';
function formatBytes(value) {
const bytes = Number(value) || 0;
if (bytes < 1024) return `${bytes} B`;
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
let amount = bytes / 1024;
let unitIndex = 0;
while (amount >= 1024 && unitIndex < units.length - 1) {
amount /= 1024;
unitIndex += 1;
}
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
export default function BackupRestorePanel({ socket, runSensitive }) {
const [backupBusy, setBackupBusy] = useState(false);
const [restoreBusy, setRestoreBusy] = useState(false);
const [backupFile, setBackupFile] = useState(null);
const [validatedRestore, setValidatedRestore] = useState(null);
const [lastRestore, setLastRestore] = useState(null);
const [message, setMessage] = useState('');
useEffect(() => {
getBackupRestoreStatus(socket)
.then((response) => setLastRestore(response.lastRestore))
.catch((error) => setMessage(error.message));
}, [socket]);
async function downloadBackup() {
setBackupBusy(true);
setMessage('Creating consistent database snapshots and copying server data…');
try {
const response = await runSensitive(() => createFullBackup(socket));
/*
A temporary anchor begins a normal streamed browser download without
navigating away from administration. The one-use URL contains no
credentials and expires if the browser never requests it.
*/
const link = document.createElement('a');
link.href = response.downloadUrl;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
const skipped = response.skippedUnstableFiles?.length || 0;
setMessage(`Backup download started with ${response.fileCount} files.${skipped ? ` ${skipped} actively changing file(s) were skipped.` : ''}`);
} catch (error) {
setMessage(error.message);
} finally {
setBackupBusy(false);
}
}
async function validateRestore() {
if (!backupFile) return;
setRestoreBusy(true);
setValidatedRestore(null);
setMessage('Uploading and validating the selected backup…');
try {
const authorization = await runSensitive(() => createRestoreUpload(socket));
const uploadResponse = await fetch(authorization.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/gzip' },
body: backupFile,
});
const result = await uploadResponse.json();
if (!uploadResponse.ok || result.error) throw new Error(result.error || 'Restore upload failed.');
setValidatedRestore(result);
setMessage('Backup validated. Review the summary before replacing server data.');
} catch (error) {
setMessage(error.message);
} finally {
setRestoreBusy(false);
}
}
async function restore() {
if (!validatedRestore) return;
const confirmed = window.confirm(
'Replace the complete server data folder with this backup and restart the application? Configuration, administrators, identities, history, and media will all be replaced.',
);
if (!confirmed) return;
setRestoreBusy(true);
try {
await runSensitive(() => confirmFullRestore(socket, validatedRestore.restoreId));
setMessage('Restore accepted. Waiting for the application to restart…');
} catch (error) {
setRestoreBusy(false);
setMessage(error.message);
}
}
return (
<div className="space-y-0.5">
<CardFrame title="Full backup" bodyClassName="space-y-0.5 p-1 text-sm">
<p className="text-slate-300">Downloads configuration, administrator accounts, secrets, identities, history, snapshots, replays, and other durable server data. Keep the archive private because it contains credentials.</p>
<button type="button" className="button-dark" disabled={backupBusy || restoreBusy} onClick={downloadBackup}>
{backupBusy ? 'Creating backup…' : 'Download full backup'}
</button>
</CardFrame>
<CardFrame title="Full restore" bodyClassName="space-y-0.5 p-1 text-sm">
<p className="text-slate-300">Upload a MultiRover full backup for validation. Nothing changes until the validated restore is confirmed.</p>
<div className="flex max-w-3xl flex-col gap-0.5 md:flex-row">
<input
className="field-input min-w-0 flex-1"
type="file"
accept=".gz,.tgz,application/gzip"
disabled={restoreBusy || backupBusy}
onChange={(event) => {
setBackupFile(event.target.files?.[0] || null);
setValidatedRestore(null);
}}
/>
<button type="button" className="button-dark" disabled={!backupFile || restoreBusy || backupBusy} onClick={validateRestore}>
{restoreBusy && !validatedRestore ? 'Validating…' : 'Validate backup'}
</button>
</div>
{validatedRestore ? (
<div className="surface max-w-3xl space-y-0.5 p-1">
<p>{validatedRestore.summary.fileCount} files, {formatBytes(validatedRestore.summary.totalBytes)}</p>
<p>Created {new Date(validatedRestore.summary.createdAt).toLocaleString()} by application version {validatedRestore.summary.applicationVersion}</p>
<button type="button" className="button-danger" disabled={restoreBusy} onClick={restore}>Replace data and restart</button>
</div>
) : null}
</CardFrame>
{message ? <CardFrame title="Backup and restore status" bodyClassName="p-1 text-sm text-slate-200"><p>{message}</p></CardFrame> : null}
{lastRestore ? (
<CardFrame title="Last restore" meta={lastRestore.status} bodyClassName="p-1 text-sm text-slate-300">
<p>{lastRestore.completedAt ? new Date(lastRestore.completedAt).toLocaleString() : 'Completion time unavailable'}{lastRestore.error ? `${lastRestore.error}` : ''}</p>
</CardFrame>
) : null}
</div>
);
}