feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust

Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK).
- tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/
  hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002.
- tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif,
  dual-read + backfill). Migration 0003. Fix séquence journal de migration.
- tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate),
  SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH),
  SJ-3 (reboot vérifié boot_id).
- WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics,
  scaffold app_rust, ajustements frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:50:25 +02:00
parent 0fbca06d3d
commit 08919752e3
69 changed files with 7785 additions and 102 deletions
+52 -14
View File
@@ -1,35 +1,73 @@
// client/src/panels/Dashboard.tsx
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { MachineView } from "@shared/types.js";
import { api } from "../lib/api.js";
import { MachineTile } from "../features/machines/MachineTile.js";
import { AddMachineModal } from "../features/machines/AddMachineModal.js";
import { sumUpdates } from "../lib/stats.js";
interface Props { onSelect: (id: string) => void; }
export interface DashboardSummary {
machines: number;
updates: number;
errors: number;
running: number;
}
export function Dashboard({ onSelect }: Props) {
interface Props {
onSelect: (id: string) => void;
onSummaryChange?: (summary: DashboardSummary) => void;
}
export function Dashboard({ onSelect, onSummaryChange }: Props) {
const [machines, setMachines] = useState<MachineView[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
async function load() {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}));
setCounts(Object.fromEntries(entries));
setError(null);
try {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}));
setCounts(Object.fromEntries(entries));
} catch (err) {
setMachines([]);
setCounts({});
setError((err as Error).message);
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, []);
const summary = useMemo<DashboardSummary>(() => ({
machines: machines.length,
updates: sumUpdates(counts),
errors: machines.filter((m) => m.status === "error").length,
running: machines.filter((m) => m.status === "running").length,
}), [machines, counts]);
useEffect(() => {
onSummaryChange?.(summary);
}, [onSummaryChange, summary]);
return (
<main className="su-center">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Machines</h2>
<button className="interactive" onClick={() => setAdding(true)}>+ Ajouter</button>
<div className="su-dashboard-head">
<div>
<h2>Machines</h2>
<p>{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs</p>
</div>
<button className="interactive su-add-button" onClick={() => setAdding(true)}>+ Ajouter</button>
</div>
{machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
{error && <p style={{ color: "var(--err)" }}>{error}</p>}
{!error && loading && <p style={{ color: "var(--ink-3)" }}>Chargement des machines</p>}
{!error && !loading && machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
+259
View File
@@ -0,0 +1,259 @@
// client/src/panels/SettingsModal.tsx
import { useState } from "react";
import { Icon } from "../components/ui-kit.js";
interface Props {
open: boolean;
onClose: () => void;
}
type SettingsTab =
| "appearance"
| "tiles"
| "layout"
| "docker"
| "scripts"
| "hermes"
| "terminal"
| "retention";
const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
{ id: "appearance", label: "Apparence", icon: "cog" },
{ id: "tiles", label: "Tuiles", icon: "grid" },
{ id: "layout", label: "Volets", icon: "collapse" },
{ id: "docker", label: "Docker", icon: "docker" },
{ id: "scripts", label: "Scripts", icon: "script" },
{ id: "hermes", label: "Hermes", icon: "node" },
{ id: "terminal", label: "Terminal", icon: "terminal" },
{ id: "retention", label: "Nettoyage", icon: "logs" },
];
export function SettingsModal({ open, onClose }: Props) {
const [active, setActive] = useState<SettingsTab>("appearance");
if (!open) return null;
return (
<div className="settings-backdrop" onClick={onClose}>
<section className="settings-modal glass-strong" onClick={(event) => event.stopPropagation()}>
<header className="settings-head">
<div>
<span className="label">PARAMÈTRES</span>
<h2>System Update</h2>
</div>
<button className="interactive settings-close" onClick={onClose} aria-label="Fermer">
<Icon name="close" size={14} style={undefined} />
</button>
</header>
<div className="settings-body">
<nav className="settings-nav">
{TABS.map((tab) => (
<button
key={tab.id}
className={`interactive settings-nav-item ${active === tab.id ? "active" : ""}`}
onClick={() => setActive(tab.id)}
>
<Icon name={tab.icon} size={14} style={undefined} />
<span>{tab.label}</span>
</button>
))}
</nav>
<div className="settings-content">
{active === "appearance" && <AppearanceSettings />}
{active === "tiles" && <TileSettings />}
{active === "layout" && <LayoutSettings />}
{active === "docker" && <DockerSettings />}
{active === "scripts" && <ScriptsSettings />}
{active === "hermes" && <HermesSettings />}
{active === "terminal" && <TerminalSettings />}
{active === "retention" && <RetentionSettings />}
</div>
</div>
<footer className="settings-footer">
<span className="mono">settings backend pending</span>
<button className="interactive settings-secondary" onClick={onClose}>Fermer</button>
<button className="interactive settings-primary" onClick={onClose}>Sauvegarder</button>
</footer>
</section>
</div>
);
}
function AppearanceSettings() {
return (
<SettingsSection title="Apparence">
<Field label="Thème">
<select className="su-field" defaultValue="system">
<option value="system">Système</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</Field>
<Field label="Zoom UI">
<input className="su-field" type="number" min="80" max="130" defaultValue="100" />
</Field>
<Field label="Densité">
<select className="su-field" defaultValue="compact">
<option value="compact">Compact</option>
<option value="comfortable">Confort</option>
</select>
</Field>
</SettingsSection>
);
}
function TileSettings() {
return (
<SettingsSection title="Tuiles machine">
<Field label="Largeur minimale">
<input className="su-field" type="number" min="240" max="420" defaultValue="280" />
</Field>
<Field label="Sections ouvertes par défaut">
<div className="settings-checks">
<Check label="Docker" />
<Check label="Post-install" />
<Check label="Hardware" />
</div>
</Field>
<Field label="Mode erreur">
<select className="su-field" defaultValue="expanded">
<option value="badge">Badge</option>
<option value="expanded">Alerte visible</option>
</select>
</Field>
</SettingsSection>
);
}
function LayoutSettings() {
return (
<SettingsSection title="Volets">
<Field label="Hermes largeur">
<input className="su-field" type="number" min="200" max="300" defaultValue="240" />
</Field>
<Field label="Terminal largeur">
<input className="su-field" type="number" min="320" max="460" defaultValue="380" />
</Field>
<Field label="Mobile">
<select className="su-field" defaultValue="tabs">
<option value="tabs">Onglets</option>
<option value="bottom">Barre basse</option>
</select>
</Field>
</SettingsSection>
);
}
function DockerSettings() {
return (
<SettingsSection title="Docker">
<Field label="Roots Compose">
<textarea className="su-field settings-textarea" defaultValue={"/home/gilles/docker\n/opt/docker"} />
</Field>
<Field label="Prune">
<select className="su-field" defaultValue="safe">
<option value="safe">Images inutilisées</option>
<option value="aggressive">Agressif avec validation</option>
</select>
</Field>
</SettingsSection>
);
}
function ScriptsSettings() {
return (
<SettingsSection title="Scripts">
<Field label="Catalogue">
<select className="su-field" defaultValue="local">
<option value="local">Scripts locaux</option>
<option value="shared">Scripts partagés</option>
</select>
</Field>
<Field label="Profils visibles">
<div className="settings-checks">
<Check label="Network" checked />
<Check label="Dev tools" checked />
<Check label="Domotique" />
</div>
</Field>
</SettingsSection>
);
}
function HermesSettings() {
return (
<SettingsSection title="Hermes">
<Field label="Endpoint">
<input className="su-field" defaultValue="http://10.0.0.80:8000" />
</Field>
<Field label="Contexte max">
<input className="su-field" type="number" min="1000" max="64000" defaultValue="12000" />
</Field>
</SettingsSection>
);
}
function TerminalSettings() {
return (
<SettingsSection title="Terminal">
<Field label="Mode">
<select className="su-field" defaultValue="logs">
<option value="logs">Logs actions</option>
<option value="ssh">SSH interactif</option>
</select>
</Field>
<Field label="Police">
<input className="su-field" type="number" min="10" max="18" defaultValue="12" />
</Field>
</SettingsSection>
);
}
function RetentionSettings() {
return (
<SettingsSection title="Nettoyage">
<Field label="Logs bruts">
<input className="su-field" type="number" min="7" max="365" defaultValue="90" />
</Field>
<Field label="Rapports">
<input className="su-field" type="number" min="30" max="730" defaultValue="365" />
</Field>
<Field label="Messages importants">
<select className="su-field" defaultValue="keep">
<option value="keep">Conserver non acquittés</option>
<option value="archive">Archiver</option>
</select>
</Field>
</SettingsSection>
);
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="settings-section">
<h3>{title}</h3>
<div className="settings-fields">{children}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="settings-field">
<span className="label">{label}</span>
{children}
</label>
);
}
function Check({ label, checked }: { label: string; checked?: boolean }) {
return (
<label className="settings-check">
<input type="checkbox" defaultChecked={checked} />
<span>{label}</span>
</label>
);
}
+23 -3
View File
@@ -18,11 +18,31 @@ export function TerminalPanel({ machineId }: { machineId: string | null }) {
const fit = new FitAddon();
term.loadAddon(fit);
term.open(ref.current);
fit.fit();
const fitTerminal = () => {
try { fit.fit(); } catch { /* xterm peut être entre deux cycles de layout. */ }
};
const frame = window.requestAnimationFrame(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine");
const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {};
return () => { disconnect(); term.dispose(); };
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
disconnect();
term.dispose();
};
}, [machineId]);
return <div className="su-terminal" ref={ref} style={{ padding: 6 }} />;
return (
<aside className="su-terminal-wrap">
<div className="su-terminal-head">
<span className="label">TERMINAL</span>
<span className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
{machineId ?? "aucune machine"}
</span>
</div>
<div className="su-terminal" ref={ref} />
</aside>
);
}