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
@@ -1,5 +1,6 @@
// client/src/features/machines/AddMachineModal.tsx
import { useState } from "react";
import { api } from "../../lib/api.js";
interface Props { onClose: () => void; onCreated: () => void; }
@@ -12,11 +13,7 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
async function submit() {
setBusy(true); setError(null);
try {
const res = await fetch("/api/machines", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null });
onCreated(); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
+185 -18
View File
@@ -1,5 +1,7 @@
// client/src/features/machines/MachineTile.tsx
import type { MachineView } from "@shared/types.js";
import { useState } from "react";
import type { MachineStatus, MachineView } from "@shared/types.js";
import { Button, Icon, IconButton, StatusLed } from "../../components/ui-kit.js";
interface Props {
machine: MachineView;
@@ -10,30 +12,195 @@ interface Props {
onReboot: (id: string) => void;
}
const STATUS_COLOR: Record<string, string> = {
ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)",
running: "var(--info)", unknown: "var(--ink-4)",
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok",
updates_available: "warn",
error: "err",
running: "info",
unknown: "off",
};
export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
const STATUS_TEXT: Record<MachineStatus, string> = {
ok: "OK",
updates_available: "Updates",
error: "Erreur",
running: "Action en cours",
unknown: "Inconnu",
};
export function MachineTile({
machine,
packageCount,
onSelect,
onRefresh,
onUpgrade,
onReboot,
}: Props) {
const [dockerOpen, setDockerOpen] = useState(false);
const [postOpen, setPostOpen] = useState(false);
const expanded = dockerOpen || postOpen;
const isError = machine.status === "error" || machine.status === "unknown";
return (
<div className="glass" style={{ padding: 16, borderRadius: 10 }} onClick={() => onSelect(machine.id)}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 10, height: 10, borderRadius: 999, background: STATUS_COLOR[machine.status] }} />
<strong>{machine.name}</strong>
<article
className={`machine-tile glass ${expanded ? "machine-tile-expanded" : ""}`}
onClick={() => onSelect(machine.id)}
>
<header className="machine-tile-head">
<div className="machine-title-row">
<StatusLed status={STATUS_LED[machine.status]} size={10} pulse={machine.status === "running"} />
<div className="machine-title-text">
<strong>{machine.name}</strong>
<span className="mono">{machine.hostname}:{machine.port} · {machine.osFamily}</span>
</div>
</div>
<span className={`machine-status-pill ${isError ? "machine-status-danger" : ""}`}>
{STATUS_TEXT[machine.status]}
</span>
</header>
<div className="machine-summary">
<Metric label="Updates" value={packageCount.toString()} tone={packageCount > 0 ? "warn" : "ok"} />
<Metric label="Reboot" value="-" />
<Metric label="Dernier check" value={formatDate(machine.lastCheckedAt)} />
</div>
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
{machine.hostname}:{machine.port} · {machine.osFamily}
{isError && (
<div className="machine-alert">
<Icon name="alert" size={14} style={undefined} />
<span>État machine à vérifier avant toute action sensible.</span>
</div>
)}
<div className="machine-actions" onClick={(event) => event.stopPropagation()}>
<IconButton
icon="refresh"
label="Update + analyse"
active={false}
danger={false}
primary={false}
onClick={() => onRefresh(machine.id)}
/>
<IconButton
icon="upgrade"
label="Upgrade système"
active={false}
danger={false}
primary={packageCount > 0}
onClick={() => onUpgrade(machine.id)}
/>
<IconButton
icon="power"
label="Reboot"
active={false}
danger
primary={false}
onClick={() => onReboot(machine.id)}
/>
<IconButton
icon="terminal"
label="Ouvrir les logs machine"
active={false}
danger={false}
primary={false}
onClick={() => onSelect(machine.id)}
/>
</div>
<div style={{ margin: "10px 0", fontSize: 13 }}>
<span className="label">UPDATES</span>{" "}
<span className="mono">{packageCount}</span>
<div className="machine-sections" onClick={(event) => event.stopPropagation()}>
<SectionToggle
icon="docker"
title="Docker"
open={dockerOpen}
onToggle={() => setDockerOpen((value) => !value)}
/>
{dockerOpen && <DockerSection />}
<SectionToggle
icon="script"
title="Post-install"
open={postOpen}
onToggle={() => setPostOpen((value) => !value)}
/>
{postOpen && <PostInstallSection />}
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
<button className="interactive" onClick={() => onRefresh(machine.id)}>Refresh</button>
<button className="interactive" onClick={() => onUpgrade(machine.id)}>Upgrade</button>
<button className="interactive" onClick={() => onReboot(machine.id)}>Reboot</button>
</article>
);
}
function Metric({ label, value, tone }: { label: string; value: string; tone?: "ok" | "warn" }) {
return (
<div className="machine-metric">
<span className="label">{label}</span>
<span className={`mono ${tone === "warn" ? "machine-metric-warn" : tone === "ok" ? "machine-metric-ok" : ""}`}>
{value}
</span>
</div>
);
}
function SectionToggle({
icon,
title,
open,
onToggle,
}: {
icon: string;
title: string;
open: boolean;
onToggle: () => void;
}) {
return (
<button className="machine-section-toggle interactive" onClick={onToggle}>
<span className="machine-section-title">
<Icon name={icon} size={14} style={undefined} />
<span>{title}</span>
</span>
<Icon name={open ? "chevD" : "chevR"} size={12} style={undefined} />
</button>
);
}
function DockerSection() {
return (
<div className="machine-section-body">
<div className="machine-section-row">
<span className="mono">Docker non scanné</span>
<Button icon="cog" size="sm" onClick={() => undefined}>Paramètres</Button>
</div>
<div className="machine-placeholder">
Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
</div>
</div>
);
}
function PostInstallSection() {
return (
<div className="machine-section-body">
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil network tools</span>
</label>
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil partage Samba/NFS</span>
</label>
<div className="machine-placeholder">
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
</div>
</div>
);
}
function formatDate(value: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}