diff --git a/client/src/components/ui-kit.tsx b/client/src/components/ui-kit.tsx index 5a177bf..847a48d 100644 --- a/client/src/components/ui-kit.tsx +++ b/client/src/components/ui-kit.tsx @@ -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', diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 20e7150..87e7d25 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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("/system/db/info"), + /** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */ + dbBackup: async (): Promise => { + 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 => { + 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; +} diff --git a/client/src/panels/SettingsModal.tsx b/client/src/panels/SettingsModal.tsx index 8c64e93..ba586c2 100644 --- a/client/src/panels/SettingsModal.tsx +++ b/client/src/panels/SettingsModal.tsx @@ -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" && } {active === "terminal" && } {active === "retention" && } + {active === "database" && } @@ -231,6 +235,125 @@ function RetentionSettings() { ); } +function DatabaseSettings() { + const [info, setInfo] = useState(null); + const [busy, setBusy] = useState(null); + const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null); + const [pendingFile, setPendingFile] = useState(null); + const fileRef = useRef(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) { + 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 ( + +
+ + {info ? formatBytes(info.sizeBytes) : "--"} + + + {info?.modifiedAt ? new Date(info.modifiedAt).toLocaleString("fr-FR") : "--"} + +
+ + {info?.restorePending && ( +

+ Une restauration est en attente : redémarrez le serveur pour l'appliquer. +

+ )} + +
+ + + +
+ +

+ La sauvegarde produit un instantané cohérent .db (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. +

+ + {message && ( +

+ {message.text} +

+ )} + + setPendingFile(null)} + title="Confirmer la restauration" + footer={ + <> + + + + } + > +

+ La base actuelle sera entièrement remplacée par  + {pendingFile?.name} au prochain démarrage du serveur. +

+

Une sauvegarde de sécurité de la base actuelle est créée automatiquement.

+
+
+ ); +} + +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 (
diff --git a/client/src/styles/app.css b/client/src/styles/app.css index 3213fff..52bddae 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -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; diff --git a/server/db/client.ts b/server/db/client.ts index 8031ead..e62662d 100644 --- a/server/db/client.ts +++ b/server/db/client.ts @@ -1,15 +1,27 @@ // server/db/client.ts import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; -import { mkdirSync } from "node:fs"; +import { mkdirSync, existsSync, rmSync, renameSync } from "node:fs"; import { dirname } from "node:path"; import { env } from "../env.js"; import * as schema from "./schema.js"; mkdirSync(dirname(env.dbPath), { recursive: true }); + +// Restauration en attente : un fichier `.incoming` déposé par /system/db/restore +// est appliqué au démarrage (swap hors-ligne = aucune corruption d'une base ouverte). +const incoming = `${env.dbPath}.incoming`; +if (existsSync(incoming)) { + for (const ext of ["", "-wal", "-shm"]) { + const p = `${env.dbPath}${ext}`; + if (existsSync(p)) rmSync(p, { force: true }); + } + renameSync(incoming, env.dbPath); +} + const sqlite = new Database(env.dbPath); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("foreign_keys = ON"); export const db = drizzle(sqlite, { schema }); -export { schema }; +export { schema, sqlite }; diff --git a/server/routes/db.ts b/server/routes/db.ts new file mode 100644 index 0000000..6051018 --- /dev/null +++ b/server/routes/db.ts @@ -0,0 +1,37 @@ +// server/routes/db.ts +import { Hono } from "hono"; +import { createBackup, prepareRestore, dbInfo } from "../services/dbBackup.js"; + +export const dbRoutes = new Hono(); + +// Métadonnées de la base (taille, date, restauration en attente). +dbRoutes.get("/info", (c) => c.json(dbInfo())); + +// Télécharge une archive cohérente de la base courante. +dbRoutes.get("/backup", () => { + const { buffer, filename } = createBackup(); + return new Response(new Uint8Array(buffer), { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="${filename}"`, + "Content-Length": String(buffer.length), + }, + }); +}); + +// Restaure depuis une archive uploadée (corps brut). Appliquée au prochain démarrage. +dbRoutes.post("/restore", async (c) => { + try { + const ab = await c.req.arrayBuffer(); + if (!ab.byteLength) return c.json({ error: "Archive vide" }, 400); + const { safetyBackup } = prepareRestore(Buffer.from(ab)); + return c.json({ + ok: true, + restartRequired: true, + safetyBackup, + message: "Restauration préparée. Redémarrez le serveur pour l'appliquer.", + }); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } +}); diff --git a/server/routes/index.ts b/server/routes/index.ts index 3a4fe63..10e8102 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { machinesRoutes } from "./machines.js"; import { actionsRoutes } from "./actions.js"; import { actionRequestsRoutes } from "./actionRequests.js"; +import { dbRoutes } from "./db.js"; import { getServerCapabilities } from "../services/capabilities.js"; import { getSystemMetrics, getSystemStatus } from "../services/system.js"; @@ -10,6 +11,7 @@ export const api = new Hono(); api.get("/capabilities", (c) => c.json(getServerCapabilities())); api.get("/system/status", (c) => c.json(getSystemStatus())); api.get("/system/metrics", (c) => c.json(getSystemMetrics())); +api.route("/system/db", dbRoutes); api.route("/machines", machinesRoutes); api.route("/machines", actionsRoutes); api.route("/", actionRequestsRoutes); diff --git a/server/services/dbBackup.ts b/server/services/dbBackup.ts new file mode 100644 index 0000000..5bc4c79 --- /dev/null +++ b/server/services/dbBackup.ts @@ -0,0 +1,81 @@ +// server/services/dbBackup.ts +import Database from "better-sqlite3"; +import { readFileSync, writeFileSync, rmSync, existsSync, statSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { sqlite } from "../db/client.js"; +import { env } from "../env.js"; + +// En-tête SQLite : 15 octets ASCII + un octet nul terminal. +const SQLITE_HEADER = "SQLite format 3"; + +function isSqliteHeader(buffer: Buffer): boolean { + return ( + buffer.length >= 16 && + buffer.subarray(0, 15).toString("latin1") === SQLITE_HEADER && + buffer[15] === 0 + ); +} + +function stamp(): string { + return new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, ""); +} + +/** Snapshot cohérent de la base courante (VACUUM INTO → fichier unique, sans WAL). */ +export function createBackup(): { buffer: Buffer; filename: string } { + const tmp = join(dirname(env.dbPath), `.backup-${Date.now()}.db`); + rmSync(tmp, { force: true }); + sqlite.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`); + try { + return { buffer: readFileSync(tmp), filename: `system-update-${stamp()}.db` }; + } finally { + rmSync(tmp, { force: true }); + } +} + +/** Vérifie qu'un buffer est une base SQLite intègre au schéma attendu. */ +export function validateSqlite(buffer: Buffer): void { + if (!isSqliteHeader(buffer)) { + throw new Error("Fichier invalide : ce n'est pas une base SQLite."); + } + const tmp = join(dirname(env.dbPath), `.verify-${Date.now()}.db`); + writeFileSync(tmp, buffer); + try { + const test = new Database(tmp, { readonly: true }); + try { + const integrity = test.pragma("integrity_check", { simple: true }); + if (integrity !== "ok") throw new Error("Base corrompue (integrity_check)."); + const hasMachines = test + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='machines'") + .get(); + if (!hasMachines) throw new Error("Archive non reconnue : table 'machines' absente."); + } finally { + test.close(); + } + } finally { + rmSync(tmp, { force: true }); + } +} + +/** + * Prépare une restauration : sauvegarde la base courante puis dépose la nouvelle base + * en `.incoming`. Le swap réel a lieu au prochain démarrage (db/client.ts) pour + * ne jamais écraser une base ouverte. Renvoie le chemin de la sauvegarde de sécurité. + */ +export function prepareRestore(buffer: Buffer): { safetyBackup: string } { + validateSqlite(buffer); + const safety = `${env.dbPath}.pre-restore-${stamp()}.bak`; + writeFileSync(safety, createBackup().buffer); + writeFileSync(`${env.dbPath}.incoming`, buffer); + return { safetyBackup: safety }; +} + +/** Métadonnées de la base courante (pour l'UI). */ +export function dbInfo(): { sizeBytes: number; modifiedAt: string | null; restorePending: boolean } { + const exists = existsSync(env.dbPath); + const st = exists ? statSync(env.dbPath) : null; + return { + sizeBytes: st?.size ?? 0, + modifiedAt: st ? st.mtime.toISOString() : null, + restorePending: existsSync(`${env.dbPath}.incoming`), + }; +}