feat(probe): sonde enrichie CPU/RAM/disques + recommandations de profils (tâche 4)

- template machine-probe : lscpu Model name + nproc, MemTotal, lsblk disques
- parseProbe étendu (cpuModel/cpuCores/memoryBytes/disks) + buildRecommendations
  (KVM/QEMU → vm_guest_tools) ; tests TDD
- runProbe persiste cpu/mem/disks dans machine_hardware ; /probe renvoie recommendations
- popup Sonde affiche cpu/ram/disks + profils conseillés

tsc 0 · 110 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:36:49 +02:00
parent c390addadb
commit e3e824185f
6 changed files with 123 additions and 14 deletions
@@ -296,10 +296,24 @@ function MachineConfigPopup({
{probe.probe.isProxmox ? " · proxmox" : ""}
{probe.probe.isRpi ? " · rpi" : ""}
</div>
{(probe.probe.cpuModel || probe.probe.memoryBytes) && (
<div className="mono cfg-facts">
cpu={probe.probe.cpuModel ?? "?"} ({probe.probe.cpuCores ?? "?"}c) · ram=
{probe.probe.memoryBytes ? `${(probe.probe.memoryBytes / 1e9).toFixed(1)} Go` : "?"} · disks=
{probe.probe.disks.length}
</div>
)}
<div className="cfg-proposal mono">
proposition : os_family={probe.proposal.osFamily} · machine_kind={probe.proposal.machineKind} · virt=
{probe.proposal.virtualization}
</div>
{probe.recommendations.length > 0 && (
<ul className="cfg-changes">
{probe.recommendations.map((r, i) => (
<li key={i} className="mono"> profil conseillé : {r.profileId} {r.reason}</li>
))}
</ul>
)}
{probe.changes.length ? (
<>
<ul className="cfg-changes">
+5
View File
@@ -208,8 +208,13 @@ export interface ProbeResultView {
isRpi: boolean;
gpus: string[];
net: { iface: string; addr: string }[];
cpuModel: string | null;
cpuCores: number | null;
memoryBytes: number | null;
disks: { name: string; sizeBytes: number }[];
};
proposal: { osFamily: OsFamily; machineKind: MachineKind; virtualization: string };
recommendations: { profileId: string; reason: string }[];
changes: string[];
}
+1 -1
View File
@@ -79,7 +79,7 @@ machinesRoutes.get("/:id/hardware", (c) => {
machinesRoutes.post("/:id/probe", async (c) => {
try {
const o = await runProbe(c.req.param("id"));
return c.json({ probe: o.probe, proposal: o.proposal, changes: o.changes });
return c.json({ probe: o.probe, proposal: o.proposal, recommendations: o.recommendations, changes: o.changes });
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseProbe, proposeCorrections } from "./machineProbe.js";
import { parseProbe, proposeCorrections, buildRecommendations } from "./machineProbe.js";
const PROXMOX = [
"===SU:PROBE_OS===",
@@ -101,3 +101,46 @@ describe("proposeCorrections", () => {
expect(c.virtualization).toBe("kvm");
});
});
describe("sonde enrichie (cpu/mem/disk + recommandations)", () => {
const ENRICHED = [
"===SU:PROBE_OS===",
"ID=debian",
"===SU:PROBE_ARCH===",
"x86_64",
"amd64",
"===SU:PROBE_VIRT===",
"kvm",
"===SU:PROBE_PROXMOX===",
"PROXMOX=0",
"===SU:PROBE_RPI===",
"RPI=0",
"===SU:PROBE_GPU===",
"no-lspci",
"===SU:PROBE_NET===",
"ens18 10.0.0.8/22",
"===SU:PROBE_CPU===",
"MODEL=Intel(R) Xeon(R) CPU E5-2670",
"4",
"===SU:PROBE_MEM===",
"MemTotal: 4194304 kB",
"===SU:PROBE_DISK===",
"DISK\tsda\t34359738368",
"DISK\tsdb\t1073741824000",
"===SU:EXIT=0===",
].join("\n");
it("extrait cpuModel/cores, mémoire et disques", () => {
const p = parseProbe(ENRICHED);
expect(p.cpuModel).toBe("Intel(R) Xeon(R) CPU E5-2670");
expect(p.cpuCores).toBe(4);
expect(p.memoryBytes).toBe(4194304 * 1024);
expect(p.disks).toHaveLength(2);
expect(p.disks[0]).toEqual({ name: "sda", sizeBytes: 34359738368 });
});
it("recommande vm_guest_tools sur KVM", () => {
const recs = buildRecommendations(parseProbe(ENRICHED));
expect(recs.some((r) => r.profileId === "vm_guest_tools")).toBe(true);
});
});
+52 -12
View File
@@ -22,6 +22,10 @@ export interface ProbeResult {
isRpi: boolean;
gpus: string[];
net: { iface: string; addr: string }[];
cpuModel: string | null;
cpuCores: number | null;
memoryBytes: number | null;
disks: { name: string; sizeBytes: number }[];
}
export interface CorrectionProposal {
@@ -30,6 +34,11 @@ export interface CorrectionProposal {
virtualization: string;
}
export interface ProfileRecommendation {
profileId: string;
reason: string;
}
function section(raw: string, start: string, end?: string): string {
const i = raw.indexOf(start);
if (i < 0) return "";
@@ -51,7 +60,10 @@ export function parseProbe(raw: string): ProbeResult {
const prox = section(raw, "===SU:PROBE_PROXMOX===", "===SU:PROBE_RPI===");
const rpi = section(raw, "===SU:PROBE_RPI===", "===SU:PROBE_GPU===");
const gpuBlock = section(raw, "===SU:PROBE_GPU===", "===SU:PROBE_NET===");
const netBlock = section(raw, "===SU:PROBE_NET===", "===SU:EXIT=");
const netBlock = section(raw, "===SU:PROBE_NET===", "===SU:PROBE_CPU===");
const cpuBlock = section(raw, "===SU:PROBE_CPU===", "===SU:PROBE_MEM===");
const memBlock = section(raw, "===SU:PROBE_MEM===", "===SU:PROBE_DISK===");
const diskBlock = section(raw, "===SU:PROBE_DISK===", "===SU:EXIT=");
const gpus = gpuBlock
.split("\n")
@@ -66,6 +78,17 @@ export function parseProbe(raw: string): ProbeResult {
}
}
const cpuModelMatch = /^MODEL=(.+)$/m.exec(cpuBlock);
const coresMatch = /^\s*(\d+)\s*$/m.exec(cpuBlock);
const memMatch = /^MemTotal:\s+(\d+)\s*kB/m.exec(memBlock);
const disks: ProbeResult["disks"] = [];
for (const line of diskBlock.split("\n")) {
if (!line.startsWith("DISK\t")) continue;
const [, name, size] = line.split("\t");
if (name) disks.push({ name, sizeBytes: Number(size) || 0 });
}
return {
osId: osReleaseValue(os, "ID"),
osVersion: osReleaseValue(os, "VERSION_ID"),
@@ -77,9 +100,24 @@ export function parseProbe(raw: string): ProbeResult {
isRpi: /RPI=1/.test(rpi),
gpus,
net,
cpuModel: cpuModelMatch?.[1]?.trim() || null,
cpuCores: coresMatch?.[1] ? Number(coresMatch[1]) : null,
memoryBytes: memMatch?.[1] ? Number(memMatch[1]) * 1024 : null,
disks,
};
}
/** Recommandations de profils post-install déduites de la sonde. */
export function buildRecommendations(p: ProbeResult): ProfileRecommendation[] {
const recs: ProfileRecommendation[] = [];
if (p.virt === "kvm" || p.virt === "qemu") {
recs.push({ profileId: "vm_guest_tools", reason: "QEMU/KVM détecté → qemu-guest-agent" });
} else if (p.virt === "vmware") {
recs.push({ profileId: "vm_guest_tools", reason: "VMware détecté → open-vm-tools" });
}
return recs;
}
const VM_VIRTS = new Set(["kvm", "qemu", "vmware", "oracle", "microsoft", "xen", "bochs", "parallels"]);
const LXC_VIRTS = new Set(["lxc", "lxc-libvirt", "openvz", "systemd-nspawn", "docker", "podman"]);
@@ -112,6 +150,7 @@ export function proposeCorrections(p: ProbeResult): CorrectionProposal {
export interface ProbeOutcome {
probe: ProbeResult;
proposal: CorrectionProposal;
recommendations: ProfileRecommendation[];
raw: string;
changes: string[]; // diff entre l'actuel et la proposition (pour l'UI)
}
@@ -129,17 +168,18 @@ export async function runProbe(machineId: string, onData?: (c: string) => void):
const proposal = proposeCorrections(probe);
const now = new Date().toISOString();
const hwFields = {
cpuModel: probe.cpuModel,
cpuCores: probe.cpuCores,
memoryBytes: probe.memoryBytes,
disksJson: JSON.stringify(probe.disks),
gpusJson: JSON.stringify(probe.gpus),
networkJson: JSON.stringify(probe.net),
updatedAt: now,
};
db.insert(schema.machineHardware)
.values({
machineId,
gpusJson: JSON.stringify(probe.gpus),
networkJson: JSON.stringify(probe.net),
updatedAt: now,
})
.onConflictDoUpdate({
target: schema.machineHardware.machineId,
set: { gpusJson: JSON.stringify(probe.gpus), networkJson: JSON.stringify(probe.net), updatedAt: now },
})
.values({ machineId, ...hwFields })
.onConflictDoUpdate({ target: schema.machineHardware.machineId, set: hwFields })
.run();
const changes: string[] = [];
@@ -151,5 +191,5 @@ export async function runProbe(machineId: string, onData?: (c: string) => void):
changes.push(`virtualization: ${m.virtualization ?? "—"}${proposal.virtualization}`);
}
return { probe, proposal, raw, changes };
return { probe, proposal, recommendations: buildRecommendations(probe), raw, changes };
}
+7
View File
@@ -17,4 +17,11 @@ echo "===SU:PROBE_GPU==="
command -v lspci >/dev/null 2>&1 && lspci 2>/dev/null | grep -Ei 'vga|3d|display' || echo "no-lspci"
echo "===SU:PROBE_NET==="
ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}'
echo "===SU:PROBE_CPU==="
LANG=C lscpu 2>/dev/null | grep -E '^Model name:' | sed 's/^Model name:[[:space:]]*/MODEL=/' || true
nproc 2>/dev/null
echo "===SU:PROBE_MEM==="
grep -E '^MemTotal:' /proc/meminfo 2>/dev/null
echo "===SU:PROBE_DISK==="
lsblk -b -d -n -o NAME,TYPE,SIZE 2>/dev/null | awk '$2=="disk"{print "DISK\t"$1"\t"$3}'
echo "===SU:EXIT=0==="