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:
@@ -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
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user