feat(settings): backup/restore de la base de données (amelioration #4)

- service dbBackup : createBackup (VACUUM INTO → archive .db cohérente),
  validateSqlite (header + integrity_check + schéma), prepareRestore
  (sauvegarde de sécurité auto + dépôt <db>.incoming)
- swap hors-ligne au démarrage (db/client.ts) : aucune corruption d'une base
  ouverte ; restauration appliquée au redémarrage
- routes GET /system/db/info|backup, POST /system/db/restore
- lib api : dbInfo / dbBackup (download navigateur) / dbRestore (upload)
- SettingsModal : onglet « Base de données » (taille, télécharger, restaurer
  avec confirmation Popup), icônes database/upload, styles DS variables only

Testé end-to-end : backup 184 Ko valide, restore + safety .bak + swap au boot,
fichier invalide rejeté. tsc 0 erreur · 91 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 06:13:03 +02:00
parent edb22a59c7
commit 47fe952240
8 changed files with 330 additions and 5 deletions
+2
View File
@@ -45,6 +45,8 @@ const ICON_MAP = {
plus: 'plus',
filter: 'filter',
download: 'download',
upload: 'upload',
database: 'database',
folder: 'folder',
docker: 'boxes-stacked',
package: 'box-open',
+43
View File
@@ -38,4 +38,47 @@ export const api = {
runAction: (id: string, action: ActionType) =>
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
// --- Sauvegarde / restauration de la base ---
dbInfo: () => req<DbInfo>("/system/db/info"),
/** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */
dbBackup: async (): Promise<void> => {
const res = await fetch("/api/system/db/backup");
if (!res.ok) throw new Error("Échec de la sauvegarde");
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const filename = /filename="([^"]+)"/.exec(cd)?.[1] ?? "system-update-backup.db";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
},
/** Envoie une archive `.db` à restaurer (appliquée au redémarrage). */
dbRestore: async (file: File): Promise<DbRestoreResult> => {
const res = await fetch("/api/system/db/restore", {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: file,
});
const body = (await readJsonBody(res)) as DbRestoreResult & { error?: string };
if (!res.ok) throw new Error(body?.error ?? "Échec de la restauration");
return body;
},
};
export interface DbInfo {
sizeBytes: number;
modifiedAt: string | null;
restorePending: boolean;
}
export interface DbRestoreResult {
ok: boolean;
restartRequired: boolean;
safetyBackup: string;
message: string;
}
+126 -3
View File
@@ -1,6 +1,7 @@
// client/src/panels/SettingsModal.tsx
import { useState } from "react";
import { Icon } from "../components/ui-kit.js";
import { useEffect, useRef, useState } from "react";
import { Icon, Popup, Button } from "../components/ui-kit.js";
import { api, type DbInfo } from "../lib/api.js";
interface Props {
open: boolean;
@@ -15,7 +16,8 @@ type SettingsTab =
| "scripts"
| "hermes"
| "terminal"
| "retention";
| "retention"
| "database";
const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
{ id: "appearance", label: "Apparence", icon: "cog" },
@@ -26,6 +28,7 @@ const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
{ id: "hermes", label: "Hermes", icon: "node" },
{ id: "terminal", label: "Terminal", icon: "terminal" },
{ id: "retention", label: "Nettoyage", icon: "logs" },
{ id: "database", label: "Base de données", icon: "database" },
];
export function SettingsModal({ open, onClose }: Props) {
@@ -69,6 +72,7 @@ export function SettingsModal({ open, onClose }: Props) {
{active === "hermes" && <HermesSettings />}
{active === "terminal" && <TerminalSettings />}
{active === "retention" && <RetentionSettings />}
{active === "database" && <DatabaseSettings />}
</div>
</div>
@@ -231,6 +235,125 @@ function RetentionSettings() {
);
}
function DatabaseSettings() {
const [info, setInfo] = useState<DbInfo | null>(null);
const [busy, setBusy] = useState<null | "backup" | "restore">(null);
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function loadInfo() {
try {
setInfo(await api.dbInfo());
} catch {
setInfo(null);
}
}
useEffect(() => {
void loadInfo();
}, []);
async function onBackup() {
setBusy("backup");
setMessage(null);
try {
await api.dbBackup();
setMessage({ kind: "ok", text: "Archive téléchargée." });
} catch (err) {
setMessage({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
function onPickFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
if (file) setPendingFile(file);
event.target.value = "";
}
async function confirmRestore() {
if (!pendingFile) return;
setBusy("restore");
setMessage(null);
try {
const res = await api.dbRestore(pendingFile);
setMessage({ kind: "ok", text: res.message });
void loadInfo();
} catch (err) {
setMessage({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
setPendingFile(null);
}
}
return (
<SettingsSection title="Base de données">
<div className="settings-fields">
<Field label="Taille actuelle">
<span className="mono">{info ? formatBytes(info.sizeBytes) : "--"}</span>
</Field>
<Field label="Dernière modification">
<span className="mono">{info?.modifiedAt ? new Date(info.modifiedAt).toLocaleString("fr-FR") : "--"}</span>
</Field>
</div>
{info?.restorePending && (
<p className="settings-note settings-note-warn">
<Icon name="alert" size={13} style={undefined} /> Une restauration est en attente : redémarrez le serveur pour l'appliquer.
</p>
)}
<div className="settings-actions">
<Button icon="download" variant="primary" onClick={busy ? undefined : onBackup}>
{busy === "backup" ? "Sauvegarde…" : "Télécharger la sauvegarde"}
</Button>
<Button icon="upload" variant="default" onClick={busy ? undefined : () => fileRef.current?.click()}>
Restaurer une archive
</Button>
<input ref={fileRef} type="file" accept=".db,application/octet-stream" hidden onChange={onPickFile} />
</div>
<p className="settings-note">
La sauvegarde produit un instantané cohérent <span className="mono">.db</span> (machines, credentials chiffrés, exécutions, rapports). La restauration remplace toute la base au prochain démarrage ; une sauvegarde de sécurité est créée automatiquement avant.
</p>
{message && (
<p className={`settings-note ${message.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>
{message.text}
</p>
)}
<Popup
open={pendingFile !== null}
onClose={() => setPendingFile(null)}
title="Confirmer la restauration"
footer={
<>
<Button variant="ghost" icon="close" onClick={() => setPendingFile(null)}>Annuler</Button>
<Button variant="danger" icon="upload" onClick={busy === "restore" ? undefined : confirmRestore}>
{busy === "restore" ? "Restauration…" : "Remplacer la base"}
</Button>
</>
}
>
<p>
La base actuelle sera <strong>entièrement remplacée</strong> par&nbsp;
<span className="mono">{pendingFile?.name}</span> au prochain démarrage du serveur.
</p>
<p>Une sauvegarde de sécurité de la base actuelle est créée automatiquement.</p>
</Popup>
</SettingsSection>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="settings-section">
+25
View File
@@ -285,6 +285,31 @@ body {
min-height: 96px;
resize: vertical;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 4px;
}
.settings-note {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--ink-2);
}
.settings-note .mono { color: var(--ink-1); }
.settings-note-ok { color: var(--ok); }
.settings-note-err { color: var(--err); }
.settings-note-warn {
color: var(--warn);
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--warn);
background: var(--bg-1);
}
.settings-checks {
display: flex;
flex-direction: column;