Compare commits

...

14 Commits

Author SHA1 Message Date
gilles fa73ab07b0 feat(events): timeline d'événements machine (tâche 5 backlog)
- listMachineEvents (machine_events, 30 derniers, desc) + route GET /machines/:id/events
- api machineEvents ; section repliable « Timeline » dans le panneau détail
  (badge sévérité + horodatage), exploite les events déjà enregistrés par recordEvent

tsc 0 · 118 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:36:06 +02:00
gilles a93a43e1c8 feat(messages): extraction des messages importants APT (tâche 5 backlog)
- extractImportantMessages (TDD) : E:/dpkg error → error, W:/GPG → warning,
  déprecations/EOL → future_major_change ; nettoyage des secrets dans les URLs
- recordImportantMessages : dédup par (machine, source, message) non acquitté →
  maj lastSeenAt, sinon insert (firstSeen/lastSeen) dans important_messages
- branché dans refreshMachine (sortie APT) avec snapshotId
- routes GET /machines/:id/messages + POST .../:msgId/ack
- UI : carte « Messages importants » (badge sévérité + ack) dans le panneau détail

tsc 0 · 118 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:30:07 +02:00
gilles ff9cfaa9e1 feat(scheduler): automatisations planifiées (cron) — tâche 5
- table schedules (migration 0007) + service scheduler (croner) : CRUD,
  runSchedule avec scope (all/liste), pool de concurrence et verrou par machine,
  mapping actions → refresh/metrics/docker_scan ; reloadSchedules au boot
- worker = reloadSchedules (remplace le refresh 30 min en dur)
- routes /api/schedules (CRUD + :id/run) ; cron invalide rejeté (validation croner)
- UI Paramètres : onglet « Automatisations » (liste, activer/lancer/supprimer, création)

tsc 0 · 113 tests · build OK · boot OK (migration 0007, CRUD vérifié).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:25:44 +02:00
gilles bdbe7af55c docs(amelioration): backlog — update-all, listing double-scroll, su fallback, scripts configurables, partages Samba/NFS
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:20:24 +02:00
gilles 1530409d3b feat(post-install): identity_network reboote et rebascule l'IP en BDD (tâche 4)
- updateMachine accepte name/hostname/port (correction d'identité réseau)
- rebootAndRebind : reboot sur l'ancienne connexion → attente du retour sur la
  NOUVELLE IP (verifyReboot avec host cible) → maj BDD (hostname + nom) si OK.
  Sécurité : BDD inchangée si la machine ne revient pas (récupération console/backups)
- execute post_install : si identity_network + reboot coché + succès, déclenche
  rebootAndRebind et joint le RebootResult ; statut error si reconnexion échoue

tsc 0 · 113 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:12:39 +02:00
gilles 3ea2e66359 fix(post-install): identity_network cadré Debian/ifupdown (VM) avec précheck
- précheck en tête : refuse proprement si OS != debian (os_not_supported),
  si netplan présent (unsupported_network_manager) ou si /etc/network/interfaces
  absent (ifupdown_not_found) — au lieu d'écrire une conf inopérante
- manifeste : label « (Debian/VM) » + description précisant la cible ifupdown
  et l'application au reboot

Validé en réel sur Debian VM (ens18) : strophe DHCP commentée + drop-in statique.
sh -n OK · tsc 0 · 113 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:03:28 +02:00
gilles aaf1b4988d fix(post-install): identity_network écrit réellement hostname + /etc/hosts + IP statique
Le template SJ-8 était un stub (echo STATIC_TARGET sans écriture). Désormais :
- /etc/hostname + hostnamectl, ligne 127.0.1.1 <fqdn> <host> dans /etc/hosts
- IP statique via drop-in /etc/network/interfaces.d/<iface>.cfg (ifupdown),
  neutralisation awk de la strophe DHCP existante + source interfaces.d
- sauvegardes horodatées avant écriture ; réseau appliqué AU REBOOT (ne coupe
  jamais SSH en live) ; FILE_MODIFIED émis après écriture réelle

Cible Debian/ifupdown (netinstall). syntaxe sh -n validée sur rendu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:47:44 +02:00
gilles d1b0290e3b feat(apt): analyse des dépôts APT (lecture seule) (tâche 4)
- template repositories (deb lines + deb822), non destructif
- analyzeRepositories (TDD) : composants, repos, détection Proxmox
  enterprise/no-subscription, warnings (pve_enterprise_without_subscription,
  pve_repo_missing) + notes Debian/Ubuntu composants manquants
- route POST /machines/:id/apt-repositories ; api analyzeRepositories
- popup config : bloc « Dépôts APT » (composants + warnings + notes)

Analyse uniquement (modification = action validée séparée, future). tsc 0 · 113 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:41:11 +02:00
gilles e3e824185f 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>
2026-06-06 18:36:49 +02:00
gilles c390addadb feat(metrics): machine_metrics_simple — CPU/RAM/disque live par machine (tâche 4)
- template machine-metrics (loadavg/nproc, /proc/meminfo, df -B1) non destructif
- parseMetrics (TDD) → cpu load/cores, mémoire kB→B + %, filesystems, warnings >=90%
- collectMetrics (SSH léger) persiste machine_metrics_latest ; getLatestMetrics (sans SSH)
- routes GET /machines/:id/metrics + POST /metrics/collect ; api latestMetrics/collectMetrics
- section Hardware : bloc métriques live (CPU/RAM/disques + alertes) + bouton Collecter
  → comble le gap « Health » de la tâche 3

tsc 0 · 108 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:01:45 +02:00
gilles 58abebf687 feat(ui): ajout machine OS/type, section Hardware, identité app (tâche 3)
- AddMachineModal : sélecteurs OS + Type machine ; createMachine accepte
  osFamily/machineKind (manuel prioritaire, "Autre/auto" → détection os-release)
- section Hardware sur la tuile + panneau détail : os/type/virt/arch/gpu/réseau
  depuis machine_hardware (sonde) via GET /machines/:id/hardware
- identité : favicon.svg (serveur + LED Gruvbox), favicon.ico, apple-touch-icon,
  PWA 192/512, site.webmanifest ; liens + theme-color dans index.html

tsc 0 · 104 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:07:46 +02:00
gilles 3b16fdd52a feat(post-install): catalogue de profils — paquets, Docker officiel, partages, VM tools (tâche 2 SJ-9)
- mécanisme presetVars (variables fixes injectées au rendu, surchargées par le formulaire)
- 6 profils : base_tools / network_tools / dev_git (listes de paquets, low),
  docker_official (dépôt officiel Debian, confirmation), sharing (Samba/NFS/mDNS, confirmation),
  vm_guest_tools (qemu/vmware)
- 4 templates custom (install-package-groups, docker-official-debian, sharing, vm-guest-tools)
  émettant PKG_INSTALLED/SERVICE_ENABLED/ERR → réutilise buildPostInstallResult
- l'UI post-install générique les expose automatiquement (manifeste → formulaire → run)

tsc 0 · 104 tests · build OK · boot OK (8 profils servis). Clôt le volet moteur tâche 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:40:20 +02:00
gilles 4eb0335900 feat(ui): section post-install interactive (profils + preview) (tâche 3)
Branche le frontend sur le moteur post-install (SJ-8) :
- liste des profils (badge de risque), dépliage → champs de formulaire typés
  (text/select/bool/secret), pré-remplis depuis defaults + utilisateur SSH
- Preview (script rendu, secrets masqués) en Popup
- Exécuter : profils sûrs en direct, profils à risque (identity_network) via
  confirmation Popup → action_request approuvé ; auto-sélection machine →
  flux visible dans le terminal
- api client : getProfiles / previewProfile / runProfile + types

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 10:57:44 +02:00
gilles e6f4ae470b feat(post-install): moteur de profils + bootstrap + identité/réseau (tâche 2 SJ-8)
- templates custom/bootstrap-root + identity-network (sortie structurée parsable,
  sauvegardes, échec contrôlé, jamais de coupure réseau sans reconnexion)
- postInstall: registre de manifestes (champs typés + defaults/defaultFrom),
  validateProfileValues + maskSecretValues + buildPostInstallResult (TDD),
  renderProfile/previewProfile (masquage secrets), runPostInstall (SSH)
- execute: RunActionOpts.profileId/values + branche post_install (bloc postInstall)
- action_requests: post_install accepté, payload profileId/values transmis à approve
- routes: GET /profiles, POST .../preview (script masqué + validation),
  POST .../run (action_request si requiresConfirmation, sinon direct)

Champs = formulaire (pas de question SSH interactive) ; secrets jamais sérialisés ;
identity_network exige confirmation. tsc 0 · 101 tests · build OK · boot OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:02:32 +02:00
52 changed files with 4975 additions and 57 deletions
+33
View File
@@ -5,3 +5,36 @@
- le bouton ajouter sera deplacer dans le header - le bouton ajouter sera deplacer dans le header
- ajout de bouton dans le header (toggle entre mode tuilenet mode listing) - ajout de bouton dans le header (toggle entre mode tuilenet mode listing)
- dans le header ajouter bouton pour un mode update all qui permet d executer update sur chacune des machine ( mettre en la machine qui est en cours d 'update via un style --shadow-press) - dans le header ajouter bouton pour un mode update all qui permet d executer update sur chacune des machine ( mettre en la machine qui est en cours d 'update via un style --shadow-press)
- petite modification sur le mode listing 2 ascenseurs verticaux independant: si ma souris survol la zone de listing , lascenseur agit sur la liste des machines, si je survol la zone detail, ma souris agit sur la zone detail seulment
- si sudo n'est pas installer utiliser "su -"
- les script peuvent etre configurable via parametre ex: Preview — Docker (dépôt officiel)
#!/bin/sh
# Docker Engine depuis le dépôt officiel Debian (docs.docker.com/engine/install/debian).
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_DOCKER==="
apt-get update -qq 2>&1
apt-get install -y ca-certificates curl 2>&1
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc 2>&1
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && echo "FILE_MODIFIED=/etc/apt/sources.list.d/docker.list"
apt-get update -qq 2>&1
if apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>&1; then
for p in docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; do echo "PKG_INSTALLED=$p"; done
echo "SERVICE_ENABLED=docker"
CODE=0
else
echo "ERR=docker_install_failed"
CODE=1
fi
usermod -aG docker "gilles" 2>&1 && echo "GROUP_ADDED=docker:gilles" || echo "ERR=docker_group_failed"
mkdir -p "/home/gilles/docker" 2>&1 && echo "FILE_MODIFIED=/home/gilles/docker"
echo "DOCKER_GROUP_RELOGIN_REQUIRED=1"
echo "REBOOT_REQUESTED=1"
echo "===SU:EXIT=${CODE}==="
( il faut integrer un editeur de texte avec coloration syntaxique et validation du code?)
- script partage reseau ajouter le dossier partagé gilles a samba avec les parametre : accessible en temps que guest ( lecture ecriture sans mot de pass) samba : workgroup: home
- script partage reseau: nfs : ajoute le partage home/gilles en lecture ecriture
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>System Update</title> <title>System Update</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="mask-icon" href="/favicon.svg" color="#fe8019" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#fe8019" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="System Update">
<rect x="1" y="1" width="30" height="30" rx="6" fill="#2a231d" stroke="#fe8019" stroke-width="2"/>
<rect x="7" y="8" width="18" height="6" rx="1.5" fill="none" stroke="#f2e5c7" stroke-width="1.6"/>
<circle cx="10" cy="11" r="1.3" fill="#4dbb26"/>
<line x1="14" y1="11" x2="22" y2="11" stroke="#d5c4a1" stroke-width="1.4" stroke-linecap="round"/>
<rect x="7" y="18" width="18" height="6" rx="1.5" fill="none" stroke="#f2e5c7" stroke-width="1.6"/>
<circle cx="10" cy="21" r="1.3" fill="#fe8019"/>
<line x1="14" y1="21" x2="22" y2="21" stroke="#d5c4a1" stroke-width="1.4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

+14
View File
@@ -0,0 +1,14 @@
{
"name": "System Update",
"short_name": "SysUpdate",
"description": "Dashboard de mise à jour distante de machines Linux (SSH agentless).",
"start_url": "/",
"display": "standalone",
"background_color": "#2a231d",
"theme_color": "#fe8019",
"icons": [
{ "src": "/favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" },
{ "src": "/web-app-manifest-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any maskable" },
{ "src": "/web-app-manifest-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any maskable" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

@@ -1,12 +1,33 @@
// client/src/features/machines/AddMachineModal.tsx // client/src/features/machines/AddMachineModal.tsx
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { AptProxyMode, MachineKind, OsFamily } from "@shared/types.js";
import type { DefaultAptProxy } from "../../lib/api.js"; import type { DefaultAptProxy } from "../../lib/api.js";
import { api } from "../../lib/api.js"; import { api } from "../../lib/api.js";
interface Props { onClose: () => void; onCreated: () => void; } interface Props { onClose: () => void; onCreated: () => void; }
const OS_OPTIONS: { value: OsFamily; label: string }[] = [
{ value: "debian", label: "Debian" },
{ value: "ubuntu", label: "Ubuntu" },
{ value: "proxmox", label: "Proxmox VE" },
{ value: "raspbian", label: "Raspberry Pi OS" },
{ value: "unknown", label: "Autre / auto" },
];
const KIND_OPTIONS: { value: MachineKind; label: string }[] = [
{ value: "vm", label: "VM" },
{ value: "physical", label: "Physique" },
{ value: "proxmox_host", label: "Hôte Proxmox" },
{ value: "lxc", label: "LXC / conteneur" },
{ value: "raspberry_pi", label: "Raspberry Pi" },
{ value: "workstation", label: "Workstation / GPU" },
{ value: "unknown", label: "Inconnu" },
];
export function AddMachineModal({ onClose, onCreated }: Props) { export function AddMachineModal({ onClose, onCreated }: Props) {
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" }); const [form, setForm] = useState({
name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "",
osFamily: "debian" as OsFamily, machineKind: "vm" as MachineKind,
});
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [proxyDefault, setProxyDefault] = useState<DefaultAptProxy | null>(null); const [proxyDefault, setProxyDefault] = useState<DefaultAptProxy | null>(null);
@@ -46,6 +67,18 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
<input key={k} placeholder={k} value={form[k]} onChange={(e) => set(k, e.target.value)} /> <input key={k} placeholder={k} value={form[k]} onChange={(e) => set(k, e.target.value)} />
))} ))}
<input placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} /> <input placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
<label style={{ display: "grid", gap: 4 }}>
<span className="label">OS</span>
<select value={form.osFamily} onChange={(e) => setForm({ ...form, osFamily: e.target.value as OsFamily })}>
{OS_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<label style={{ display: "grid", gap: 4 }}>
<span className="label">Type machine</span>
<select value={form.machineKind} onChange={(e) => setForm({ ...form, machineKind: e.target.value as MachineKind })}>
{KIND_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<input placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} /> <input placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
<input placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} /> <input placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
{proxyDefault?.url && ( {proxyDefault?.url && (
@@ -54,6 +87,9 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
<span>Proxy APT par défaut <span className="mono">{proxyDefault.url}</span></span> <span>Proxy APT par défaut <span className="mono">{proxyDefault.url}</span></span>
</label> </label>
)} )}
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
« Autre / auto » détecte l'OS via os-release. Détection complète (type, virt) ensuite via Sonder.
</div>
{error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>} {error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose}>Annuler</button> <button onClick={onClose}>Annuler</button>
+377 -16
View File
@@ -1,8 +1,19 @@
// client/src/features/machines/MachineTile.tsx // client/src/features/machines/MachineTile.tsx
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ActionType, AptProxyMode, MachineStatus, MachineView } from "@shared/types.js"; import type { ActionType, AptProxyMode, AptRepositoriesAnalysis, MachineMetricsSimple, MachineStatus, MachineView } from "@shared/types.js";
import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js"; import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js";
import { api, type DockerSettingsView, type DockerStackRow, type ProbeResultView, type StackStatus } from "../../lib/api.js"; import {
api,
type DockerSettingsView,
type DockerStackRow,
type ImportantMessageView,
type MachineEventView,
type MachineHardwareView,
type ProbeResultView,
type ProfileManifestView,
type ProfileValues,
type StackStatus,
} from "../../lib/api.js";
interface Props { interface Props {
machine: MachineView; machine: MachineView;
@@ -41,8 +52,9 @@ export function MachineTile({
}: Props) { }: Props) {
const [dockerOpen, setDockerOpen] = useState(false); const [dockerOpen, setDockerOpen] = useState(false);
const [postOpen, setPostOpen] = useState(false); const [postOpen, setPostOpen] = useState(false);
const [hwOpen, setHwOpen] = useState(false);
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const expanded = dockerOpen || postOpen; const expanded = dockerOpen || postOpen || hwOpen;
const isError = machine.status === "error" || machine.status === "unknown"; const isError = machine.status === "error" || machine.status === "unknown";
return ( return (
@@ -145,7 +157,15 @@ export function MachineTile({
open={postOpen} open={postOpen}
onToggle={() => setPostOpen((value) => !value)} onToggle={() => setPostOpen((value) => !value)}
/> />
{postOpen && <PostInstallSection />} {postOpen && <PostInstallSection machine={machine} onSelect={onSelect} />}
<SectionToggle
icon="cpu"
title="Hardware"
open={hwOpen}
onToggle={() => setHwOpen((value) => !value)}
/>
{hwOpen && <HardwareSection machineId={machine.id} />}
</div> </div>
</article> </article>
); );
@@ -200,6 +220,12 @@ function MachineConfigPopup({
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [proxyMode, setProxyMode] = useState<AptProxyMode>(machine.aptProxyMode); const [proxyMode, setProxyMode] = useState<AptProxyMode>(machine.aptProxyMode);
const [proxyUrl, setProxyUrl] = useState(machine.aptProxyUrl ?? ""); const [proxyUrl, setProxyUrl] = useState(machine.aptProxyUrl ?? "");
const [repos, setRepos] = useState<AptRepositoriesAnalysis | null>(null);
const analyzeRepos = () =>
withBusy("repos", async () => {
setRepos(await api.analyzeRepositories(machine.id));
});
async function withBusy(key: string, fn: () => Promise<void>) { async function withBusy(key: string, fn: () => Promise<void>) {
setBusy(key); setBusy(key);
@@ -278,10 +304,24 @@ function MachineConfigPopup({
{probe.probe.isProxmox ? " · proxmox" : ""} {probe.probe.isProxmox ? " · proxmox" : ""}
{probe.probe.isRpi ? " · rpi" : ""} {probe.probe.isRpi ? " · rpi" : ""}
</div> </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"> <div className="cfg-proposal mono">
proposition : os_family={probe.proposal.osFamily} · machine_kind={probe.proposal.machineKind} · virt= proposition : os_family={probe.proposal.osFamily} · machine_kind={probe.proposal.machineKind} · virt=
{probe.proposal.virtualization} {probe.proposal.virtualization}
</div> </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 ? ( {probe.changes.length ? (
<> <>
<ul className="cfg-changes"> <ul className="cfg-changes">
@@ -323,6 +363,27 @@ function MachineConfigPopup({
</div> </div>
</div> </div>
<div className="cfg-block">
<div className="cfg-block-head">
<span className="label">Dépôts APT (analyse)</span>
<Button icon="logs" size="sm" onClick={busy ? undefined : analyzeRepos}>
{busy === "repos" ? "Analyse…" : "Analyser"}
</Button>
</div>
{repos && (
<div className="cfg-probe">
<div className="mono cfg-facts">composants : {repos.components.join(", ") || "—"}</div>
{repos.proxmox && (
<div className="mono cfg-facts">
pve enterprise={String(repos.proxmox.enterprise)} · no-subscription={String(repos.proxmox.noSubscription)}
</div>
)}
{repos.warnings.map((w, i) => <span key={i} className="docker-msg docker-msg-err">{w.message}</span>)}
{repos.notes.map((n, i) => <span key={i} className="cfg-nochange">{n}</span>)}
</div>
)}
</div>
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>} {msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
</div> </div>
</Popup> </Popup>
@@ -619,24 +680,316 @@ function shortId(id: string | null): string {
return hex.slice(0, 10); return hex.slice(0, 10);
} }
function PostInstallSection() { function riskTone(risk: string): string {
return risk === "low" ? "ok" : risk === "medium" ? "warn" : "err";
}
function riskLabel(risk: string): string {
return risk === "low" ? "sûr" : risk === "medium" ? "moyen" : "réseau";
}
function PostInstallSection({ machine, onSelect }: { machine: MachineView; onSelect: (id: string) => void }) {
const [profiles, setProfiles] = useState<ProfileManifestView[] | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const [values, setValues] = useState<Record<string, ProfileValues>>({});
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [preview, setPreview] = useState<{ title: string; script: string } | null>(null);
const [confirm, setConfirm] = useState<ProfileManifestView | null>(null);
useEffect(() => {
void (async () => {
try {
const ps = await api.getProfiles();
setProfiles(ps);
// Pré-remplissage : defaults du manifeste + operatorUser depuis l'utilisateur SSH.
const init: Record<string, ProfileValues> = {};
for (const p of ps) {
const v: ProfileValues = {};
for (const f of p.fields) {
if (f.default !== undefined) v[f.name] = f.default;
if (f.defaultFrom === "sshUser") v[f.name] = machine.username;
}
init[p.id] = v;
}
setValues(init);
} catch (err) {
setMsg({ kind: "err", text: (err as Error).message });
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [machine.id]);
function toggle(id: string) {
setOpen((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function setField(profileId: string, name: string, value: string | number | boolean) {
setValues((prev) => ({ ...prev, [profileId]: { ...prev[profileId], [name]: value } }));
}
async function withBusy(key: string, fn: () => Promise<void>) {
setBusy(key);
setMsg(null);
try {
await fn();
} catch (err) {
setMsg({ kind: "err", text: (err as Error).message });
} finally {
setBusy(null);
}
}
const doPreview = (p: ProfileManifestView) =>
withBusy(`prev:${p.id}`, async () => {
const res = await api.previewProfile(machine.id, p.id, values[p.id] ?? {});
setPreview({ title: p.label, script: res.script });
});
async function exec(p: ProfileManifestView) {
onSelect(machine.id);
const res = await api.runProfile(machine.id, p.id, values[p.id] ?? {});
if (res.requiresConfirmation && res.actionRequest) {
await api.approveActionRequest(res.actionRequest.id);
setMsg({ kind: "ok", text: `${p.label} : demande approuvée et lancée (voir terminal).` });
} else {
setMsg({ kind: "ok", text: `${p.label} : lancé (voir terminal).` });
}
}
const onRun = (p: ProfileManifestView) => {
if (p.requiresConfirmation) setConfirm(p);
else void withBusy(`run:${p.id}`, () => exec(p));
};
if (profiles === null) return <div className="machine-section-body"><span className="machine-placeholder">Chargement des profils</span></div>;
return ( return (
<div className="machine-section-body"> <div className="machine-section-body">
<label className="machine-check-row"> <div className="pi-list">
<input type="checkbox" /> {profiles.map((p) => {
<span>Profil network tools</span> const isOpen = open.has(p.id);
</label> return (
<label className="machine-check-row"> <div key={p.id} className="pi-profile">
<input type="checkbox" /> <button className="pi-profile-head interactive" onClick={() => toggle(p.id)}>
<span>Profil partage Samba/NFS</span> <Icon name={isOpen ? "chevD" : "chevR"} size={12} style={undefined} />
</label> <span className="pi-profile-name">{p.label}</span>
<div className="machine-placeholder"> <span className={`docker-badge docker-badge-${riskTone(p.risk)}`}>{riskLabel(p.risk)}</span>
Les champs dynamiques seront dépliés ici selon les profils sélectionnés. </button>
{isOpen && (
<div className="pi-profile-body">
<p className="pi-desc">{p.description}</p>
{p.fields.map((f) => (
<label key={f.name} className="pi-field">
<span className="label">{f.label ?? f.name}{f.required ? " *" : ""}</span>
<ProfileFieldInput field={f} value={values[p.id]?.[f.name]} onChange={(v) => setField(p.id, f.name, v)} />
</label>
))}
<div className="pi-actions">
<Button icon="logs" size="sm" variant="ghost" onClick={busy ? undefined : () => doPreview(p)}>
{busy === `prev:${p.id}` ? "…" : "Preview"}
</Button>
<Button icon="play" size="sm" variant={p.requiresConfirmation ? "danger" : "primary"} onClick={busy ? undefined : () => onRun(p)}>
{busy === `run:${p.id}` ? "Lancement…" : "Exécuter"}
</Button>
</div>
</div>
)}
</div>
);
})}
</div> </div>
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
<Popup open={preview !== null} onClose={() => setPreview(null)} title={`Preview — ${preview?.title ?? ""}`} width={560} footer={<Button icon="close" variant="ghost" onClick={() => setPreview(null)}>Fermer</Button>}>
<pre className="pi-preview mono">{preview?.script}</pre>
</Popup>
<Popup
open={confirm !== null}
onClose={() => setConfirm(null)}
title={`Confirmer — ${confirm?.label ?? ""}`}
width={440}
footer={
<>
<Button icon="close" variant="ghost" onClick={() => setConfirm(null)}>Annuler</Button>
<Button icon="play" variant="danger" onClick={busy ? undefined : () => { const p = confirm; setConfirm(null); if (p) void withBusy(`run:${p.id}`, () => exec(p)); }}>
{busy ? "Lancement…" : "Confirmer & exécuter"}
</Button>
</>
}
>
<p>Le profil <strong>{confirm?.label}</strong> ({riskLabel(confirm?.risk ?? "")}) sera exécuté sur <span className="mono">{machine.name}</span>.</p>
<p className="docker-confirm-note"><Icon name="shield" size={13} style={undefined} /> Action tracée comme demande validée (action_request).</p>
</Popup>
</div> </div>
); );
} }
function fmtBytes(b: number | null): string {
if (b === null) return "—";
if (b >= 1e9) return `${(b / 1e9).toFixed(1)} Go`;
if (b >= 1e6) return `${(b / 1e6).toFixed(0)} Mo`;
return `${b} o`;
}
function TimelineSection({ machineId }: { machineId: string }) {
const [events, setEvents] = useState<MachineEventView[] | null>(null);
useEffect(() => {
void api.machineEvents(machineId).then(setEvents).catch(() => setEvents([]));
}, [machineId]);
if (events === null) return <div className="machine-section-body"><span className="machine-placeholder">Chargement</span></div>;
if (events.length === 0) return <div className="machine-section-body"><span className="machine-placeholder">Aucun événement.</span></div>;
return (
<div className="machine-section-body">
{events.map((e) => (
<div key={e.id} className="docker-service">
<DockerBadge status={e.severity === "error" ? "error" : e.severity === "warning" ? "updates_available" : "candidate"} />
<span className="docker-service-name">{e.message ?? e.eventType}</span>
<span className="docker-service-diff mono">{new Date(e.createdAt).toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}</span>
</div>
))}
</div>
);
}
function MessagesCard({ machineId }: { machineId: string }) {
const [msgs, setMsgs] = useState<ImportantMessageView[]>([]);
const load = () => { void api.machineMessages(machineId).then(setMsgs).catch(() => setMsgs([])); };
useEffect(load, [machineId]);
if (msgs.length === 0) return null;
return (
<div className="machine-detail-card">
<span className="label">Messages importants ({msgs.length})</span>
{msgs.slice(0, 8).map((m) => (
<div key={m.id} className="docker-service">
<DockerBadge status={m.severity === "error" ? "error" : m.severity === "warning" ? "updates_available" : "candidate"} />
<span className="docker-service-name mono" title={m.message}>{m.message}</span>
<button className="interactive su-viewtoggle-btn" style={{ padding: "2px 7px", fontSize: 11 }}
onClick={() => api.ackMessage(machineId, m.id).then(load)}>ack</button>
</div>
))}
{msgs.length > 8 && <span className="machine-placeholder">+{msgs.length - 8} autres</span>}
</div>
);
}
function HardwareSection({ machineId }: { machineId: string }) {
const [hw, setHw] = useState<MachineHardwareView | null>(null);
const [metrics, setMetrics] = useState<MachineMetricsSimple | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
void (async () => {
try {
const [h, m] = await Promise.all([api.machineHardware(machineId), api.latestMetrics(machineId)]);
setHw(h);
setMetrics(m);
} catch (e) {
setErr((e as Error).message);
}
})();
}, [machineId]);
const collect = async () => {
setBusy(true);
setErr(null);
try {
setMetrics(await api.collectMetrics(machineId));
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
if (err) return <div className="machine-section-body"><span className="docker-msg docker-msg-err">{err}</span></div>;
if (!hw) return <div className="machine-section-body"><span className="machine-placeholder">Chargement</span></div>;
return (
<div className="machine-section-body">
<div className="machine-detail-card">
<div className="cfg-block-head">
<span className="label">Métriques (CPU / RAM / disque)</span>
<Button icon="refresh" size="sm" onClick={busy ? undefined : collect}>{busy ? "…" : "Collecter"}</Button>
</div>
{metrics ? (
<>
<InfoRow k="CPU load" v={`${metrics.cpu.load1 ?? "—"} / ${metrics.cpu.cores ?? "?"}c`} mono />
<InfoRow k="RAM" v={`${metrics.memory.usedPercent ?? "—"}% · ${fmtBytes(metrics.memory.usedBytes)} / ${fmtBytes(metrics.memory.totalBytes)}`} mono tone={(metrics.memory.usedPercent ?? 0) >= 90 ? "warn" : undefined} />
{metrics.filesystems.map((fs) => (
<InfoRow key={fs.mount} k={fs.mount} v={`${fs.usedPercent}% · ${fmtBytes(fs.usedBytes)} / ${fmtBytes(fs.sizeBytes)}`} mono tone={fs.usedPercent >= 90 ? "warn" : undefined} />
))}
{metrics.warnings.map((w, i) => <span key={i} className="docker-msg docker-msg-err">{w}</span>)}
</>
) : (
<span className="machine-placeholder">Aucune métrique. Clique sur « Collecter ».</span>
)}
</div>
<div className="machine-detail-card">
<InfoRow k="OS" v={`${hw.osFamily}${hw.osVersion ? ` ${hw.osVersion}` : ""}`} />
<InfoRow k="Type" v={hw.machineKind ?? "—"} />
<InfoRow k="Virtualisation" v={hw.virtualization ?? "—"} />
<InfoRow k="Architecture" v={hw.arch ?? "—"} mono />
<InfoRow k="GPU" v={hw.gpus.length ? `${hw.gpus.length} détecté(s)` : "aucun"} />
</div>
{hw.gpus.length > 0 && (
<div className="machine-detail-card">
<span className="label">GPU</span>
{hw.gpus.map((g, i) => <span key={i} className="mono pi-desc">{g}</span>)}
</div>
)}
{hw.network.length > 0 && (
<div className="machine-detail-card">
<span className="label">Réseau</span>
{hw.network.map((n, i) => <InfoRow key={i} k={n.iface} v={n.addr} mono />)}
</div>
)}
{!hw.probed && <span className="machine-placeholder">Données limitées lance Sonder pour détecter GPU/réseau.</span>}
</div>
);
}
function ProfileFieldInput({
field,
value,
onChange,
}: {
field: ProfileManifestView["fields"][number];
value: string | number | boolean | undefined;
onChange: (v: string | number | boolean) => void;
}) {
if (field.type === "bool") {
return (
<span className="pi-bool">
<input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
</span>
);
}
if (field.type === "select" && field.options?.length) {
return (
<select className="su-field" value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
<option value=""></option>
{field.options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
return (
<input
className="su-field"
type={field.type === "secret" ? "password" : "text"}
value={String(value ?? "")}
placeholder={field.label ?? field.name}
onChange={(e) => onChange(e.target.value)}
/>
);
}
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
@@ -714,6 +1067,8 @@ export function MachineDetailPanel({
// Mode liste : sections dépliées par défaut (inverse du mode tuile). // Mode liste : sections dépliées par défaut (inverse du mode tuile).
const [dockerOpen, setDockerOpen] = useState(true); const [dockerOpen, setDockerOpen] = useState(true);
const [postOpen, setPostOpen] = useState(true); const [postOpen, setPostOpen] = useState(true);
const [hwOpen, setHwOpen] = useState(true);
const [tlOpen, setTlOpen] = useState(false);
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const isError = machine.status === "error" || machine.status === "unknown"; const isError = machine.status === "error" || machine.status === "unknown";
@@ -738,6 +1093,8 @@ export function MachineDetailPanel({
<IconButton icon="cog" label="Profil & proxy (sonde)" active={false} danger={false} primary={false} onClick={() => setConfigOpen(true)} /> <IconButton icon="cog" label="Profil & proxy (sonde)" active={false} danger={false} primary={false} onClick={() => setConfigOpen(true)} />
</div> </div>
<MessagesCard machineId={machine.id} />
<div className="machine-detail-cards"> <div className="machine-detail-cards">
<div className="machine-detail-card"> <div className="machine-detail-card">
<span className="label">System info</span> <span className="label">System info</span>
@@ -761,7 +1118,11 @@ export function MachineDetailPanel({
<SectionToggle icon="docker" title="Docker" open={dockerOpen} onToggle={() => setDockerOpen((v) => !v)} /> <SectionToggle icon="docker" title="Docker" open={dockerOpen} onToggle={() => setDockerOpen((v) => !v)} />
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />} {dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
<SectionToggle icon="script" title="Post-install" open={postOpen} onToggle={() => setPostOpen((v) => !v)} /> <SectionToggle icon="script" title="Post-install" open={postOpen} onToggle={() => setPostOpen((v) => !v)} />
{postOpen && <PostInstallSection />} {postOpen && <PostInstallSection machine={machine} onSelect={onSelect} />}
<SectionToggle icon="cpu" title="Hardware" open={hwOpen} onToggle={() => setHwOpen((v) => !v)} />
{hwOpen && <HardwareSection machineId={machine.id} />}
<SectionToggle icon="clock" title="Timeline" open={tlOpen} onToggle={() => setTlOpen((v) => !v)} />
{tlOpen && <TimelineSection machineId={machine.id} />}
</div> </div>
{configOpen && ( {configOpen && (
+121 -1
View File
@@ -1,5 +1,5 @@
// client/src/lib/api.ts // client/src/lib/api.ts
import type { ActionType, AptProxyMode, MachineKind, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js"; import type { ActionType, AptProxyMode, AptRepositoriesAnalysis, MachineKind, MachineMetricsSimple, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
async function readJsonBody(res: Response): Promise<unknown> { async function readJsonBody(res: Response): Promise<unknown> {
const text = await res.text(); const text = await res.text();
@@ -42,16 +42,37 @@ export const api = {
}), }),
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }), deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
// --- Post-install (profils) ---
getProfiles: () => req<ProfileManifestView[]>("/profiles"),
previewProfile: (id: string, profileId: string, values: ProfileValues) =>
req<ProfilePreview>(`/machines/${id}/profiles/${profileId}/preview`, { method: "POST", body: JSON.stringify({ values }) }),
runProfile: (id: string, profileId: string, values: ProfileValues) =>
req<RunProfileResult>(`/machines/${id}/profiles/${profileId}/run`, { method: "POST", body: JSON.stringify({ values }) }),
// --- Réglages globaux --- // --- Réglages globaux ---
getSettings: () => req<AppSettingsView>("/settings"), getSettings: () => req<AppSettingsView>("/settings"),
setDefaultAptProxy: (body: DefaultAptProxy) => setDefaultAptProxy: (body: DefaultAptProxy) =>
req<DefaultAptProxy>("/settings/apt-proxy", { method: "PUT", body: JSON.stringify(body) }), req<DefaultAptProxy>("/settings/apt-proxy", { method: "PUT", body: JSON.stringify(body) }),
applyProxyToAll: () => req<{ ok: boolean; updated: number }>("/settings/apt-proxy/apply-all", { method: "POST" }), applyProxyToAll: () => req<{ ok: boolean; updated: number }>("/settings/apt-proxy/apply-all", { method: "POST" }),
// --- Automatisations planifiées ---
getSchedules: () => req<ScheduleView[]>("/schedules"),
createSchedule: (body: ScheduleInput) => req<ScheduleView>("/schedules", { method: "POST", body: JSON.stringify(body) }),
updateSchedule: (id: string, body: Partial<ScheduleInput>) => req<ScheduleView>(`/schedules/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteSchedule: (id: string) => req<{ ok: boolean }>(`/schedules/${id}`, { method: "DELETE" }),
runScheduleNow: (id: string) => req<{ ok: boolean }>(`/schedules/${id}/run`, { method: "POST" }),
// --- Profil machine (SJ-7) --- // --- Profil machine (SJ-7) ---
updateMachine: (id: string, body: UpdateMachineBody) => updateMachine: (id: string, body: UpdateMachineBody) =>
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }), req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }), probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
machineMessages: (id: string) => req<ImportantMessageView[]>(`/machines/${id}/messages`),
machineEvents: (id: string) => req<MachineEventView[]>(`/machines/${id}/events`),
ackMessage: (id: string, msgId: string) => req<{ ok: boolean }>(`/machines/${id}/messages/${msgId}/ack`, { method: "POST" }),
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
analyzeRepositories: (id: string) => req<AptRepositoriesAnalysis>(`/machines/${id}/apt-repositories`, { method: "POST" }),
// --- Docker --- // --- Docker ---
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`), dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
@@ -118,6 +139,46 @@ export interface DbRestoreResult {
message: string; message: string;
} }
export type ProfileValues = Record<string, string | number | boolean>;
export interface ProfileFieldView {
name: string;
type: "string" | "hostname" | "ipv4" | "ipv4_cidr" | "ipv4_list" | "select" | "bool" | "int" | "path" | "secret";
required: boolean;
label?: string;
default?: string | number | boolean;
defaultFrom?: string;
options?: string[];
}
export interface ProfileManifestView {
id: string;
label: string;
description: string;
risk: "low" | "medium" | "network_change";
requiresConfirmation: boolean;
fields: ProfileFieldView[];
}
export interface ProfileValidation {
ok: boolean;
errors: { field: string; message: string }[];
}
export interface ProfilePreview {
script: string;
validation: ProfileValidation;
requiresConfirmation: boolean;
}
export interface RunProfileResult {
ok?: boolean;
action?: string;
profileId?: string;
requiresConfirmation?: boolean;
actionRequest?: ActionRequestRow;
}
export interface DefaultAptProxy { export interface DefaultAptProxy {
mode: AptProxyMode; mode: AptProxyMode;
url: string | null; url: string | null;
@@ -135,6 +196,35 @@ export interface UpdateMachineBody {
aptProxyUrl?: string | null; aptProxyUrl?: string | null;
} }
export interface MachineEventView {
id: string;
eventType: string;
severity: "info" | "warning" | "error";
createdAt: string;
message: string | null;
}
export interface ImportantMessageView {
id: string;
source: string;
category: string;
severity: "error" | "warning" | "info";
message: string;
packageName: string | null;
lastSeenAt: string;
}
export interface MachineHardwareView {
osFamily: string;
osVersion: string | null;
arch: string | null;
machineKind: string | null;
virtualization: string | null;
gpus: string[];
network: { iface: string; addr: string }[];
probed: boolean;
}
export interface ProbeResultView { export interface ProbeResultView {
probe: { probe: {
osId: string | null; osId: string | null;
@@ -147,11 +237,41 @@ export interface ProbeResultView {
isRpi: boolean; isRpi: boolean;
gpus: string[]; gpus: string[];
net: { iface: string; addr: 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 }; proposal: { osFamily: OsFamily; machineKind: MachineKind; virtualization: string };
recommendations: { profileId: string; reason: string }[];
changes: string[]; changes: string[];
} }
export type ScheduleAction = "apt_update_analyze" | "machine_metrics_simple" | "docker_scan";
export interface ScheduleView {
id: string;
name: string;
enabled: boolean;
cron: string;
timezone: string | null;
scope: { machineIds: "all" | string[] };
actions: ScheduleAction[];
concurrency: number;
lastRunAt: string | null;
lastStatus: string | null;
}
export interface ScheduleInput {
name: string;
cron: string;
timezone?: string | null;
enabled?: boolean;
scope?: { machineIds: "all" | string[] };
actions: ScheduleAction[];
concurrency?: number;
}
export type StackStatus = "candidate" | "enabled" | "ignored" | "error"; export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
export interface DockerSettingsView { export interface DockerSettingsView {
+109 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { AptProxyMode } from "@shared/types.js"; import type { AptProxyMode } from "@shared/types.js";
import { Icon, Popup, Button } from "../components/ui-kit.js"; import { Icon, Popup, Button } from "../components/ui-kit.js";
import { api, type DbInfo } from "../lib/api.js"; import { api, type DbInfo, type ScheduleView, type ScheduleAction } from "../lib/api.js";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -14,6 +14,7 @@ type SettingsTab =
| "tiles" | "tiles"
| "layout" | "layout"
| "proxy" | "proxy"
| "automation"
| "docker" | "docker"
| "scripts" | "scripts"
| "hermes" | "hermes"
@@ -26,6 +27,7 @@ const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
{ id: "tiles", label: "Tuiles", icon: "grid" }, { id: "tiles", label: "Tuiles", icon: "grid" },
{ id: "layout", label: "Volets", icon: "collapse" }, { id: "layout", label: "Volets", icon: "collapse" },
{ id: "proxy", label: "Proxy APT", icon: "network" }, { id: "proxy", label: "Proxy APT", icon: "network" },
{ id: "automation", label: "Automatisations", icon: "clock" },
{ id: "docker", label: "Docker", icon: "docker" }, { id: "docker", label: "Docker", icon: "docker" },
{ id: "scripts", label: "Scripts", icon: "script" }, { id: "scripts", label: "Scripts", icon: "script" },
{ id: "hermes", label: "Hermes", icon: "node" }, { id: "hermes", label: "Hermes", icon: "node" },
@@ -71,6 +73,7 @@ export function SettingsModal({ open, onClose }: Props) {
{active === "tiles" && <TileSettings />} {active === "tiles" && <TileSettings />}
{active === "layout" && <LayoutSettings />} {active === "layout" && <LayoutSettings />}
{active === "proxy" && <ProxyDefaultSettings />} {active === "proxy" && <ProxyDefaultSettings />}
{active === "automation" && <AutomationSettings />}
{active === "docker" && <DockerSettings />} {active === "docker" && <DockerSettings />}
{active === "scripts" && <ScriptsSettings />} {active === "scripts" && <ScriptsSettings />}
{active === "hermes" && <HermesSettings />} {active === "hermes" && <HermesSettings />}
@@ -239,6 +242,111 @@ function RetentionSettings() {
); );
} }
const SCHEDULE_ACTIONS: { id: ScheduleAction; label: string }[] = [
{ id: "apt_update_analyze", label: "Analyse APT" },
{ id: "machine_metrics_simple", label: "Métriques" },
{ id: "docker_scan", label: "Scan Docker" },
];
function AutomationSettings() {
const [schedules, setSchedules] = useState<ScheduleView[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const [name, setName] = useState("Analyse quotidienne");
const [cron, setCron] = useState("0 6 * * *");
const [actions, setActions] = useState<ScheduleAction[]>(["apt_update_analyze", "machine_metrics_simple"]);
async function load() {
try {
setSchedules(await api.getSchedules());
} catch (e) {
setMsg({ kind: "error", text: (e as Error).message });
}
}
useEffect(() => {
void load();
}, []);
async function withBusy(key: string, fn: () => Promise<void>) {
setBusy(key);
setMsg(null);
try {
await fn();
} catch (e) {
setMsg({ kind: "error", text: (e as Error).message });
} finally {
setBusy(null);
}
}
const toggleAction = (a: ScheduleAction) =>
setActions((prev) => (prev.includes(a) ? prev.filter((x) => x !== a) : [...prev, a]));
const create = () =>
withBusy("create", async () => {
if (!actions.length) throw new Error("Sélectionne au moins une action.");
await api.createSchedule({ name, cron, actions, scope: { machineIds: "all" } });
await load();
setMsg({ kind: "ok", text: "Automatisation créée." });
});
return (
<SettingsSection title="Automatisations planifiées">
<div className="machine-list" style={{ gap: 8 }}>
{schedules.length === 0 && <span className="machine-placeholder">Aucune automatisation. Crée-en une ci-dessous.</span>}
{schedules.map((s) => (
<div key={s.id} className="docker-stack">
<div className="docker-stack-head">
<span className="docker-stack-name">{s.name}</span>
<span className={`docker-badge docker-badge-${s.enabled ? "ok" : "off"}`}>{s.enabled ? "actif" : "off"}</span>
<span className="docker-stack-by mono">{s.cron}</span>
</div>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>
{s.actions.join(" · ")} · toutes machines{s.lastRunAt ? ` · dernier ${new Date(s.lastRunAt).toLocaleString("fr-FR")} (${s.lastStatus})` : ""}
</div>
<div className="docker-stack-actions">
<Button icon="play" size="sm" onClick={busy ? undefined : () => withBusy(`run:${s.id}`, async () => { await api.runScheduleNow(s.id); setMsg({ kind: "ok", text: `${s.name} lancé.` }); })}>
{busy === `run:${s.id}` ? "…" : "Lancer"}
</Button>
<Button icon="check" size="sm" variant="ghost" onClick={busy ? undefined : () => withBusy(`tog:${s.id}`, async () => { await api.updateSchedule(s.id, { enabled: !s.enabled }); await load(); })}>
{s.enabled ? "Désactiver" : "Activer"}
</Button>
<Button icon="trash" size="sm" variant="danger" onClick={busy ? undefined : () => withBusy(`del:${s.id}`, async () => { await api.deleteSchedule(s.id); await load(); })}>
Supprimer
</Button>
</div>
</div>
))}
</div>
<div className="cfg-block" style={{ marginTop: 14 }}>
<span className="label">Nouvelle automatisation</span>
<div className="settings-fields">
<Field label="Nom"><input className="su-field" value={name} onChange={(e) => setName(e.target.value)} /></Field>
<Field label="Cron (min h j m jsem)"><input className="su-field" value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 6 * * *" /></Field>
</div>
<Field label="Actions">
<div className="settings-checks">
{SCHEDULE_ACTIONS.map((a) => (
<label key={a.id} className="settings-check">
<input type="checkbox" checked={actions.includes(a.id)} onChange={() => toggleAction(a.id)} />
<span>{a.label}</span>
</label>
))}
</div>
</Field>
<div className="settings-actions">
<Button icon="check" variant="primary" onClick={busy ? undefined : create}>
{busy === "create" ? "Création…" : "Créer (toutes machines)"}
</Button>
</div>
</div>
{msg && <p className={`settings-note ${msg.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>{msg.text}</p>}
</SettingsSection>
);
}
function ProxyDefaultSettings() { function ProxyDefaultSettings() {
const [mode, setMode] = useState<AptProxyMode>("direct"); const [mode, setMode] = useState<AptProxyMode>("direct");
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
+17
View File
@@ -105,6 +105,23 @@ body {
.machine-info-k { color: var(--ink-3); font-size: 12px; } .machine-info-k { color: var(--ink-3); font-size: 12px; }
.machine-info-v { color: var(--ink-1); font-size: 13px; text-align: right; } .machine-info-v { color: var(--ink-1); font-size: 13px; text-align: right; }
/* --- Post-install (profils) --- */
.pi-list { display: flex; flex-direction: column; gap: 8px; }
.pi-profile { border: 1px solid var(--border-2); border-radius: 8px; background: var(--bg-2); overflow: hidden; }
.pi-profile-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 10px; background: transparent; border: none; color: var(--ink-1); text-align: left; }
.pi-profile-name { font-weight: 600; font-size: 13px; flex: 1 1 auto; }
.pi-profile-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 10px 10px; border-top: 1px solid var(--border-1); }
.pi-desc { margin: 6px 0 2px; color: var(--ink-3); font-size: 12px; line-height: 1.4; }
.pi-field { display: flex; flex-direction: column; gap: 4px; }
.pi-bool { padding: 4px 0; }
.pi-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.pi-preview {
margin: 0; max-height: 50vh; overflow: auto;
padding: 12px; border-radius: 8px;
background: var(--bg-0); border: 1px solid var(--border-1);
color: var(--ink-2); font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word;
}
.machine-tile { .machine-tile {
min-width: 0; min-width: 0;
padding: 14px; padding: 14px;
@@ -0,0 +1,48 @@
# Tâche 2 — SJ-8 : Post-install (moteur de profils + bootstrap + identité/réseau)
> Statut : **backend implémenté** (2026-06-06). tsc 0 · 101 tests · build OK · boot OK.
> Réf. design : `docs/design/tache2/30-scripts-custom.md`, `40-contrats-json.md §4`, `80-sous-jalons.md` SJ-8.
> Non testé en live (post-install destructif : modifie sudo/réseau d'une vraie machine).
## Périmètre livré
Moteur de profils post-install non interactif : tout choix devient un **champ de
formulaire** validé côté backend ; preview avec **masquage des secrets** ; exécution
SSH + parsing `PostInstallResult` ; confirmation explicite (`action_request`) pour les
profils à risque.
## Composants
- **Templates** `templates/custom/bootstrap-root.sh.tpl` (sudo + ca-certificates + curl,
ajout groupe sudo) et `identity-network.sh.tpl` (hostname + IP statique, sauvegarde des
fichiers, jamais de coupure sans reconnexion planifiée). Sortie structurée parsable
(`PKG_INSTALLED=`, `FILE_MODIFIED=`, `OLD/NEW_ENDPOINT=`, `REBOOT_REQUESTED=1`, `ERR=`).
- **`server/services/postInstall.ts`** :
- registre `PROFILES` (manifestes : `id`, `label`, `risk`, `requiresConfirmation`,
`fields[]` avec types `string|hostname|ipv4|ipv4_cidr|ipv4_list|select|bool|int|path|secret`,
`default`/`defaultFrom`).
- `validateProfileValues` (requis + formats IPv4/CIDR/hostname) — TDD.
- `maskSecretValues` (champs `secret``********`) — TDD ; `previewProfile` masque avant rendu.
- `buildPostInstallResult` (parse → filesModified/packagesInstalled/networkChange/reboot/errors) — TDD.
- `renderProfile` / `runPostInstall` (valide puis SSH, statut ok/error).
- **`execute.ts`** : `RunActionOpts.profileId/values`, branche `post_install`
(archiveExecution + bloc `postInstall`).
- **`actionRequests.ts`** : `post_install` accepté ; payload transporte `profileId`/`values` ;
`approve` les repasse à `runAction`.
- **Routes** : `GET /api/profiles`, `POST /machines/:id/profiles/:id/preview`
(script masqué + validation), `POST /machines/:id/profiles/:id/run`
(→ `action_request` si `requiresConfirmation`, sinon exécution directe).
## Sécurité / invariants
- Aucune question interactive SSH ; échec contrôlé si décision manquante.
- Secrets jamais sérialisés : `previewProfile` masque, `variablesUsed` = non sensible only.
- `identity_network` (network_change) exige confirmation explicite via `action_request`.
- Reconnexion réseau : `OLD/NEW_ENDPOINT` + `RECONNECT_REQUIRED` remontés ; reboot via `reboot_verified` (futur).
## Reste (SJ-9 + tâche 4)
SJ-9 : profils `base_tools`, `network_tools`, `docker_official`, `sharing`, `vm_guest_tools`
(+ `install-package-groups`). Persistance `install_profiles`/`machine_profile_state`/
`script_variables_presets` et catalogue détaillé = tâche 4. UI (formulaires de profils,
preview) = tâche 3.
@@ -0,0 +1,36 @@
# Tâche 2 — SJ-9 : Catalogue post-install (paquets de base, Docker officiel, partages, VM tools)
> Statut : **implémenté** (2026-06-06). tsc 0 · 104 tests · build OK · boot OK (8 profils servis).
> Réf. design : `docs/design/tache2/30-scripts-custom.md §2/§4`, `80-sous-jalons.md` SJ-9.
> Clôt la tâche 2. Non testé en live (installe des paquets / Docker sur une vraie machine).
## Périmètre livré
6 profils ajoutés au registre + 4 templates. L'UI post-install (générique, SJ-8/tâche 3)
les affiche et exécute **sans modification** — manifeste → formulaire → preview → run.
- **Mécanisme `presetVars`** : variables fixes (non-champs) injectées au rendu, surchargées
par les valeurs de formulaire. Permet des listes de paquets prédéfinies sans champ utilisateur.
- **Templates** (`templates/custom/`) :
- `install-package-groups.sh.tpl` (générique, `{{packages}}` shell-safe).
- `docker-official-debian.sh.tpl` (clé GPG keyrings + docker.list par codename + paquets
+ groupe docker + dossier compose ; relogin/reboot signalés).
- `sharing.sh.tpl` (Samba/NFS/mDNS via sections Mustache selon cases cochées).
- `vm-guest-tools.sh.tpl` (`qemu-guest-agent` ou `open-vm-tools`).
- **Manifestes** : `base_tools`, `network_tools`, `dev_git` (presetVars, sans champ, low) ;
`docker_official` (medium, confirmation, champs dockerUser/composeRoot/reboot) ;
`sharing` (medium, confirmation, bools Samba/NFS/mDNS) ; `vm_guest_tools` (low, select agent).
- Tous émettent `PKG_INSTALLED=` / `SERVICE_ENABLED=` / `ERR=``buildPostInstallResult`
(SJ-8) les parse sans changement.
## Tests
3 cas ajoutés : `base_tools` injecte bien sa liste fixe (preset), `sharing` ne rend que les
paquets cochés (sections Mustache), `docker_official` exige confirmation.
## Bilan tâche 2
APT (SJ-0→3) · Docker scan/pull-check/apply-prune-down (SJ-4→6) · profils OS Proxmox/RPi +
sonde + proxy persistant (SJ-7) · post-install moteur+bootstrap+identité (SJ-8) · catalogue
post-install (SJ-9). **Volet moteur tâche 2 complet.** Catalogue détaillé/config fine
(partages, presets réutilisables, `install_profiles`/`machine_profile_state` en base) = tâche 4.
@@ -0,0 +1,35 @@
# Tâche 5 — Automatisations planifiées (scheduler croner)
> Statut : **implémenté** (2026-06-06). tsc 0 · 113 tests · build OK · boot OK.
> Réf. : `tache5.md §4` (automatisations backend), `validation_tache5.md`.
## Périmètre livré (1re tranche tâche 5)
Planificateur piloté par la BDD : exécuter `apt_update_analyze` / `machine_metrics_simple` /
`docker_scan` sur un périmètre de machines à heure fixe (cron), avec concurrence + verrou.
- **Table `schedules`** (migration 0007) : name, enabled, cron, timezone, scope_json,
actions_json, concurrency, notify_on_json, last_run_at, last_status.
- **`server/services/scheduler.ts`** :
- CRUD (validation cron via `new Cron()` à la création/maj).
- `runSchedule` : résout le scope (`all` ou liste), exécute les actions par machine avec
**pool de concurrence** + **verrou par machine** (in-process Set, évite 2 actions
simultanées), met à jour last_run_at/last_status, `recordEvent` sur échec.
- mapping actions → `refreshMachine` / `collectMetrics` / `scanDockerStacks`.
- `reloadSchedules` : (ré)enregistre les crons actifs via croner (timezone par schedule).
- **`worker.ts`** : `startWorker` = `reloadSchedules()` (remplace le refresh 30 min en dur).
- **Routes** `/api/schedules` : list / create / get / patch / delete / `:id/run` (lancement immédiat).
- **UI** : onglet Paramètres « Automatisations » — liste (cron, actions, actif, dernier run),
activer/désactiver, lancer maintenant, supprimer, et formulaire de création (nom, cron,
cases d'actions, toutes machines).
## Vérifié
CRUD via API, cron invalide rejeté proprement (message croner), init scheduler sans erreur,
migration 0007 appliquée. Verrou empêche les exécutions concurrentes sur une même machine.
## Reste tâche 5 (backlog)
Notifications (`notifyOn`), tags de scope, retries persistants, extraction structurée des
**messages importants** (E:/W:/dépréciations → `important_messages` + tuile), timeline
d'événements machine, politiques de rétention. (pg-boss = piste future si jobs distribués.)
@@ -0,0 +1,15 @@
CREATE TABLE `schedules` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`enabled` integer DEFAULT 1 NOT NULL,
`cron` text NOT NULL,
`timezone` text,
`scope_json` text NOT NULL,
`actions_json` text NOT NULL,
`concurrency` integer DEFAULT 2 NOT NULL,
`notify_on_json` text,
`last_run_at` text,
`last_status` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -50,6 +50,13 @@
"when": 1780724800966, "when": 1780724800966,
"tag": "0006_many_northstar", "tag": "0006_many_northstar",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1780766513336,
"tag": "0007_bizarre_doctor_faustus",
"breakpoints": true
} }
] ]
} }
+17
View File
@@ -319,3 +319,20 @@ export const appSettings = sqliteTable("app_settings", {
value: text("value"), value: text("value"),
updatedAt: text("updated_at").notNull(), updatedAt: text("updated_at").notNull(),
}); });
// Automatisations planifiées (cron) : analyse/metrics/scan sur un périmètre de machines.
export const schedules = sqliteTable("schedules", {
id: text("id").primaryKey(),
name: text("name").notNull(),
enabled: integer("enabled").notNull().default(1),
cron: text("cron").notNull(),
timezone: text("timezone"),
scopeJson: text("scope_json").notNull(), // {"machineIds":"all"|string[]}
actionsJson: text("actions_json").notNull(), // ["apt_update_analyze","machine_metrics_simple",...]
concurrency: integer("concurrency").notNull().default(2),
notifyOnJson: text("notify_on_json"),
lastRunAt: text("last_run_at"),
lastStatus: text("last_status"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
+4 -17
View File
@@ -1,24 +1,11 @@
// server/jobs/worker.ts // server/jobs/worker.ts
import { Cron } from "croner"; import { reloadSchedules, stopSchedules } from "../services/scheduler.js";
import { listMachines } from "../services/machines.js";
import { refreshMachine } from "../services/refresh.js";
let job: Cron | null = null; /** Démarre le planificateur : enregistre les automatisations actives (cron) depuis la BDD. */
/** Rafraîchit toutes les machines toutes les 30 minutes (tâche de fond). */
export function startWorker(): void { export function startWorker(): void {
job = new Cron("*/30 * * * *", async () => { reloadSchedules();
for (const m of listMachines()) {
try {
await refreshMachine(m.id);
} catch (err) {
console.error(`[worker] refresh échoué pour ${m.id}:`, (err as Error).message);
}
}
});
} }
export function stopWorker(): void { export function stopWorker(): void {
job?.stop(); stopSchedules();
job = null;
} }
+4
View File
@@ -6,6 +6,8 @@ import { actionRequestsRoutes } from "./actionRequests.js";
import { dockerRoutes } from "./docker.js"; import { dockerRoutes } from "./docker.js";
import { dbRoutes } from "./db.js"; import { dbRoutes } from "./db.js";
import { settingsRoutes } from "./settings.js"; import { settingsRoutes } from "./settings.js";
import { postInstallRoutes } from "./postInstall.js";
import { schedulesRoutes } from "./schedules.js";
import { getServerCapabilities } from "../services/capabilities.js"; import { getServerCapabilities } from "../services/capabilities.js";
import { getSystemMetrics, getSystemStatus } from "../services/system.js"; import { getSystemMetrics, getSystemStatus } from "../services/system.js";
@@ -15,7 +17,9 @@ api.get("/system/status", (c) => c.json(getSystemStatus()));
api.get("/system/metrics", (c) => c.json(getSystemMetrics())); api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
api.route("/system/db", dbRoutes); api.route("/system/db", dbRoutes);
api.route("/settings", settingsRoutes); api.route("/settings", settingsRoutes);
api.route("/schedules", schedulesRoutes);
api.route("/machines", machinesRoutes); api.route("/machines", machinesRoutes);
api.route("/machines", actionsRoutes); api.route("/machines", actionsRoutes);
api.route("/machines", dockerRoutes); api.route("/machines", dockerRoutes);
api.route("/", actionRequestsRoutes); api.route("/", actionRequestsRoutes);
api.route("/", postInstallRoutes);
+45 -1
View File
@@ -2,10 +2,15 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { import {
listMachines, createMachine, deleteMachine, updateMachine, getMachineRow, getCreds, testConnection, listMachines, createMachine, deleteMachine, updateMachine, getMachineRow, getCreds, testConnection,
getMachineHardware,
type CreateMachineInput, type UpdateMachineInput, type CreateMachineInput, type UpdateMachineInput,
} from "../services/machines.js"; } from "../services/machines.js";
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js"; import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
import { runProbe } from "../services/machineProbe.js"; import { runProbe } from "../services/machineProbe.js";
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
import { analyzeMachineRepositories } from "../services/aptRepositories.js";
import { listImportantMessages, acknowledgeMessage } from "../services/importantMessages.js";
import { listMachineEvents } from "../services/machineState.js";
export const machinesRoutes = new Hono(); export const machinesRoutes = new Hono();
@@ -53,11 +58,50 @@ machinesRoutes.patch("/:id", async (c) => {
} }
}); });
// Dernières métriques stockées (sans SSH).
machinesRoutes.get("/:id/metrics", (c) => c.json(getLatestMetrics(c.req.param("id"))));
// Collecte fraîche (SSH léger, non destructif).
machinesRoutes.post("/:id/metrics/collect", async (c) => {
try {
return c.json(await collectMetrics(c.req.param("id")));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
// Analyse des dépôts APT (lecture seule).
machinesRoutes.post("/:id/apt-repositories", async (c) => {
try {
return c.json(await analyzeMachineRepositories(c.req.param("id")));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
// Timeline d'événements machine.
machinesRoutes.get("/:id/events", (c) => c.json(listMachineEvents(c.req.param("id"))));
// Messages importants (warnings/erreurs/évolutions) extraits des sorties.
machinesRoutes.get("/:id/messages", (c) => c.json(listImportantMessages(c.req.param("id"))));
machinesRoutes.post("/:id/messages/:msgId/ack", (c) => {
acknowledgeMessage(c.req.param("msgId"));
return c.json({ ok: true });
});
machinesRoutes.get("/:id/hardware", (c) => {
try {
return c.json(getMachineHardware(c.req.param("id")));
} catch (err) {
return c.json({ error: (err as Error).message }, 404);
}
});
// Sonde synchrone (lecture seule) : renvoie faits + proposition de correction. // Sonde synchrone (lecture seule) : renvoie faits + proposition de correction.
machinesRoutes.post("/:id/probe", async (c) => { machinesRoutes.post("/:id/probe", async (c) => {
try { try {
const o = await runProbe(c.req.param("id")); 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) { } catch (err) {
return c.json({ error: (err as Error).message }, 400); return c.json({ error: (err as Error).message }, 400);
} }
+46
View File
@@ -0,0 +1,46 @@
// server/routes/postInstall.ts
import { Hono } from "hono";
import { PROFILES, previewProfile, validateProfileValues } from "../services/postInstall.js";
import { createActionRequest } from "../services/actionRequests.js";
import { runAction } from "../services/execute.js";
export const postInstallRoutes = new Hono();
// Catalogue des profils (manifestes, sans secret).
postInstallRoutes.get("/profiles", (c) => c.json(Object.values(PROFILES)));
// Preview du script rendu (secrets masqués) + validation des champs.
postInstallRoutes.post("/machines/:id/profiles/:profileId/preview", async (c) => {
const profileId = c.req.param("profileId");
const manifest = PROFILES[profileId];
if (!manifest) return c.json({ error: "Profil inconnu" }, 404);
const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record<string, string | number | boolean> };
const validation = validateProfileValues(manifest, values ?? {});
return c.json({ script: previewProfile(profileId, values ?? {}), validation, requiresConfirmation: manifest.requiresConfirmation });
});
// Exécute un profil : confirmation explicite (action_request) si requise, sinon direct.
postInstallRoutes.post("/machines/:id/profiles/:profileId/run", async (c) => {
const machineId = c.req.param("id");
const profileId = c.req.param("profileId");
const manifest = PROFILES[profileId];
if (!manifest) return c.json({ error: "Profil inconnu" }, 404);
const { values } = (await c.req.json().catch(() => ({}))) as { values?: Record<string, string | number | boolean> };
const validation = validateProfileValues(manifest, values ?? {});
if (!validation.ok) return c.json({ error: "Champs invalides", validation }, 400);
if (manifest.requiresConfirmation) {
const reqRow = createActionRequest({
machineId,
action: "post_install",
summary: `Profil ${manifest.label}`,
payload: { profileId, values: values ?? {} },
});
return c.json({ actionRequest: reqRow, requiresConfirmation: true }, 202);
}
runAction(machineId, "post_install", { profileId, values: values ?? {} }).catch((err) =>
console.error("[post_install]", (err as Error).message),
);
return c.json({ ok: true, action: "post_install", profileId }, 202);
});
+49
View File
@@ -0,0 +1,49 @@
// server/routes/schedules.ts
import { Hono } from "hono";
import {
listSchedules,
getSchedule,
createSchedule,
updateSchedule,
deleteSchedule,
runSchedule,
type ScheduleInput,
} from "../services/scheduler.js";
export const schedulesRoutes = new Hono();
schedulesRoutes.get("/", (c) => c.json(listSchedules()));
schedulesRoutes.post("/", async (c) => {
const body = (await c.req.json()) as ScheduleInput;
try {
return c.json(createSchedule(body), 201);
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
schedulesRoutes.get("/:id", (c) => {
const s = getSchedule(c.req.param("id"));
return s ? c.json(s) : c.json({ error: "Schedule introuvable" }, 404);
});
schedulesRoutes.patch("/:id", async (c) => {
const body = (await c.req.json()) as Partial<ScheduleInput>;
try {
return c.json(updateSchedule(c.req.param("id"), body));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
schedulesRoutes.delete("/:id", (c) => {
deleteSchedule(c.req.param("id"));
return c.json({ ok: true });
});
// Lancement immédiat (hors planning).
schedulesRoutes.post("/:id/run", (c) => {
runSchedule(c.req.param("id")).catch((err) => console.error("[schedule run]", (err as Error).message));
return c.json({ ok: true }, 202);
});
+16 -3
View File
@@ -16,6 +16,7 @@ export const DESTRUCTIVE_ACTIONS: Partial<Record<ActionType, "medium" | "high">>
apt_autoremove: "medium", apt_autoremove: "medium",
reboot: "high", reboot: "high",
reboot_verified: "high", reboot_verified: "high",
post_install: "high", // risque réel porté par le manifeste du profil
}; };
const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"]; const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"];
@@ -26,7 +27,12 @@ export interface CreateRequestInput {
requestedByType?: "user" | "hermes" | "schedule"; requestedByType?: "user" | "hermes" | "schedule";
requestedById?: string | null; requestedById?: string | null;
summary?: string | null; summary?: string | null;
payload?: { stackId?: string; aggressive?: boolean } | null; payload?: {
stackId?: string;
aggressive?: boolean;
profileId?: string;
values?: Record<string, string | number | boolean | undefined>;
} | null;
} }
export function createActionRequest(input: CreateRequestInput) { export function createActionRequest(input: CreateRequestInput) {
@@ -96,8 +102,15 @@ export function approveActionRequest(id: string, approvedBy?: string) {
.where(eq(schema.actionRequests.id, id)) .where(eq(schema.actionRequests.id, id))
.run(); .run();
const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean }) : {}; const payload = req.payloadJson
const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive }; ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean; profileId?: string; values?: Record<string, string | number | boolean | undefined> })
: {};
const opts: RunActionOpts = {
stackId: payload.stackId,
aggressive: payload.aggressive,
profileId: payload.profileId,
values: payload.values,
};
const machineId = req.machineId; const machineId = req.machineId;
runAction(machineId, req.action as ActionType, opts) runAction(machineId, req.action as ActionType, opts)
.then((result) => { .then((result) => {
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { analyzeRepositories } from "./aptRepositories.js";
const DEBIAN = [
"===SU:REPO_DEB===",
"deb http://deb.debian.org/debian bookworm main contrib",
"deb http://security.debian.org/debian-security bookworm-security main",
"===SU:REPO_DEB822===",
"===SU:EXIT=0===",
].join("\n");
const PROXMOX_ENTERPRISE = [
"===SU:REPO_DEB===",
"deb http://ftp.debian.org/debian bookworm main contrib",
"deb https://enterprise.proxmox.com/debian/pve bookworm pve-enterprise",
"===SU:REPO_DEB822===",
"===SU:EXIT=0===",
].join("\n");
describe("analyzeRepositories", () => {
it("Debian : composants détectés et non-free-firmware absent → note", () => {
const a = analyzeRepositories("debian", DEBIAN);
expect(a.components).toContain("main");
expect(a.components).toContain("contrib");
expect(a.repos.length).toBeGreaterThanOrEqual(2);
expect(a.notes.some((n) => /non-free-firmware/.test(n))).toBe(true);
});
it("Proxmox : dépôt enterprise sans no-subscription → warning", () => {
const a = analyzeRepositories("proxmox", PROXMOX_ENTERPRISE);
expect(a.proxmox?.enterprise).toBe(true);
expect(a.proxmox?.noSubscription).toBe(false);
expect(a.warnings.some((w) => w.kind === "pve_enterprise_without_subscription")).toBe(true);
});
it("Proxmox : aucun dépôt PVE → warning", () => {
const a = analyzeRepositories("proxmox", DEBIAN);
expect(a.warnings.some((w) => w.kind === "pve_repo_missing")).toBe(true);
});
});
+80
View File
@@ -0,0 +1,80 @@
// server/services/aptRepositories.ts
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { runScriptSudo } from "../ssh/client.js";
import type { AptRepositoriesAnalysis, OsFamily } from "@shared/types.js";
function section(raw: string, start: string, end?: string): string {
const i = raw.indexOf(start);
if (i < 0) return "";
const from = i + start.length;
const j = end ? raw.indexOf(end, from) : -1;
return raw.slice(from, j < 0 ? undefined : j).trim();
}
interface Repo {
uri: string;
suite: string;
components: string[];
}
/** Parse les lignes `deb [opts] URI suite comp...` (format une-ligne). */
function parseDebLines(block: string): Repo[] {
const repos: Repo[] = [];
for (const line of block.split("\n")) {
const t = line.trim();
if (!t.startsWith("deb ") && !t.startsWith("deb\t")) continue;
// retire le mot-clé deb et les options [arch=...]
const rest = t.replace(/^deb\s+/, "").replace(/^\[[^\]]*\]\s*/, "");
const parts = rest.split(/\s+/).filter(Boolean);
if (parts.length < 2) continue;
const [uri, suite, ...components] = parts;
repos.push({ uri: uri!, suite: suite!, components });
}
return repos;
}
export function analyzeRepositories(osFamily: OsFamily, raw: string): AptRepositoriesAnalysis {
const repos = parseDebLines(section(raw, "===SU:REPO_DEB===", "===SU:REPO_DEB822==="));
const components = [...new Set(repos.flatMap((r) => r.components))].sort();
const warnings: AptRepositoriesAnalysis["warnings"] = [];
const notes: string[] = [];
if (osFamily === "proxmox") {
const enterprise = repos.some((r) => /enterprise\.proxmox\.com/.test(r.uri));
const noSubscription = repos.some((r) => /download\.proxmox\.com/.test(r.uri) && r.components.includes("pve-no-subscription"));
if (enterprise && !noSubscription) {
warnings.push({
kind: "pve_enterprise_without_subscription",
message: "Dépôt PVE entreprise actif sans dépôt no-subscription : `apt update` échouera sans abonnement.",
});
}
if (!enterprise && !noSubscription) {
warnings.push({ kind: "pve_repo_missing", message: "Aucun dépôt PVE détecté (ni enterprise ni no-subscription)." });
}
return { osFamily, components, repos, proxmox: { enterprise, noSubscription }, warnings, notes };
}
if (osFamily === "debian") {
for (const comp of ["contrib", "non-free", "non-free-firmware"]) {
if (!components.includes(comp)) notes.push(`Composant « ${comp} » absent (requis pour firmware/drivers propriétaires).`);
}
} else if (osFamily === "ubuntu") {
for (const comp of ["universe", "restricted", "multiverse"]) {
if (!components.includes(comp)) notes.push(`Composant « ${comp} » absent (drivers/paquets supplémentaires indisponibles).`);
}
}
if (repos.length === 0) warnings.push({ kind: "no_sources", message: "Aucune source APT détectée." });
return { osFamily, components, repos, warnings, notes };
}
/** Analyse les dépôts APT d'une machine via SSH (lecture seule). */
export async function analyzeMachineRepositories(machineId: string): Promise<AptRepositoriesAnalysis> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const script = renderTemplate("apt/repositories.sh.tpl", {});
const res = await runScriptSudo(getCreds(m), script, () => {});
return analyzeRepositories(m.osFamily as OsFamily, res.stdout);
}
+45
View File
@@ -33,6 +33,8 @@ const APT_ACTION_FILE: Partial<Record<ActionType, string>> = {
export interface RunActionOpts { export interface RunActionOpts {
stackId?: string; stackId?: string;
aggressive?: boolean; // docker_prune_images aggressive?: boolean; // docker_prune_images
profileId?: string; // post_install
values?: Record<string, string | number | boolean | undefined>;
} }
/** /**
@@ -49,6 +51,8 @@ function archiveExecution(args: {
raw: string; raw: string;
importantLines: string[]; importantLines: string[];
docker?: ExecutionResult["docker"]; docker?: ExecutionResult["docker"];
postInstall?: ExecutionResult["postInstall"];
reboot?: ExecutionResult["reboot"];
errors?: ExecutionResult["errors"]; errors?: ExecutionResult["errors"];
}): ExecutionResult { }): ExecutionResult {
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args; const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
@@ -64,6 +68,8 @@ function archiveExecution(args: {
importantLogLines: importantLines, importantLogLines: importantLines,
rawLogRef: rawLogPath, reportRef: reportPath, rawLogRef: rawLogPath, reportRef: reportPath,
...(args.docker ? { docker: args.docker } : {}), ...(args.docker ? { docker: args.docker } : {}),
...(args.postInstall ? { postInstall: args.postInstall } : {}),
...(args.reboot ? { reboot: args.reboot } : {}),
...(args.errors && args.errors.length ? { errors: args.errors } : {}), ...(args.errors && args.errors.length ? { errors: args.errors } : {}),
}; };
writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8"); writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8");
@@ -333,6 +339,45 @@ export async function runAction(
} }
} }
// --- SJ-8 : post-install (profil + champs de formulaire) ---
if (action === "post_install") {
if (!opts?.profileId) throw new Error("post_install requiert un profileId");
const { runPostInstall, rebootAndRebind } = await import("./postInstall.js");
try {
const o = await runPostInstall(machineId, opts.profileId, opts.values ?? {}, () => {});
const r = o.result;
const important = [
`post_install ${opts.profileId} : ${r.packagesInstalled.length} paquet(s), ${r.filesModified.length} fichier(s) modifié(s)${r.rebootsRequested ? " · reboot demandé" : ""}`,
...r.packagesInstalled.map((p) => ` + ${p}`),
...r.filesModified.map((f) => ` ~ ${f}`),
...(r.networkChange ? [` réseau : ${r.networkChange.oldEndpoint ?? "?"}${r.networkChange.newEndpoint ?? "?"} (reconnexion ${r.networkChange.reconnectHost ?? "?"})`] : []),
...(r.errors?.map((e) => ` [${e.kind}] ${e.message}`) ?? []),
];
// identity_network + reboot coché + succès : reboote, attend la nouvelle IP, corrige la BDD.
let rebootResult: ExecutionResult["reboot"];
const newHost = r.networkChange?.reconnectHost ?? r.networkChange?.newEndpoint ?? null;
if (o.status === "ok" && opts.profileId === "identity_network" && opts.values?.rebootAfterInstall && newHost) {
const newName = opts.values?.newHostname != null ? String(opts.values.newHostname) : null;
rebootResult = await rebootAndRebind(machineId, newHost, newName, () => {});
important.push(
rebootResult.status === "ok"
? ` reboot vérifié → machine basculée sur ${newHost} (BDD mise à jour)`
: ` reboot/reconnexion : ${rebootResult.status} (BDD inchangée)`,
);
}
const finalStatus: ExecutionStatus = rebootResult && rebootResult.status !== "ok" ? "error" : o.status;
outputHub.publish(machineId, `\n===SU:DONE status=${finalStatus}===\n`);
return archiveExecution({
machineId, machineName: m.name, executionId, action, startedAt, status: finalStatus, raw: o.raw,
importantLines: important, postInstall: r, reboot: rebootResult, errors: r.errors,
});
} catch (err) {
return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] });
}
}
// --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié --- // --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié ---
if (action === "machine_probe") { if (action === "machine_probe") {
const { runProbe } = await import("./machineProbe.js"); const { runProbe } = await import("./machineProbe.js");
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { extractImportantMessages } from "./importantMessages.js";
describe("extractImportantMessages", () => {
it("classe les erreurs APT (E:, dpkg) en error", () => {
const raw = [
"E: Unable to locate package toto",
"dpkg: error processing package nginx (--configure):",
"Inst libc6 [2.36] (2.37 Debian:13)",
].join("\n");
const msgs = extractImportantMessages(raw, "apt");
expect(msgs.filter((m) => m.severity === "error").length).toBe(2);
expect(msgs.every((m) => m.category === "error")).toBe(true);
});
it("classe W: et erreurs GPG en warning", () => {
const raw = [
"W: GPG error: http://deb.debian.org ... NO_PUBKEY 1234ABCD",
"W: Target Packages is configured multiple times",
].join("\n");
const msgs = extractImportantMessages(raw, "apt");
expect(msgs.length).toBe(2);
expect(msgs.every((m) => m.severity === "warning")).toBe(true);
});
it("détecte les annonces de dépréciation / changement majeur", () => {
const raw = "Note: package foo is deprecated and will be removed in the next release";
const msgs = extractImportantMessages(raw, "apt");
expect(msgs.some((m) => m.category === "future_major_change")).toBe(true);
});
it("nettoie les secrets éventuels dans les URLs", () => {
const raw = "E: Failed to fetch https://user:pass@repo.example/x";
const msgs = extractImportantMessages(raw, "apt");
expect(msgs[0]!.message).not.toContain("user:pass");
});
it("ignore les lignes normales", () => {
const raw = "Reading package lists...\nBuilding dependency tree...";
expect(extractImportantMessages(raw, "apt")).toHaveLength(0);
});
});
+120
View File
@@ -0,0 +1,120 @@
// server/services/importantMessages.ts
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
export type MessageSource = "apt" | "docker" | "post_install" | "ssh" | "system";
export type MessageCategory = "error" | "warning" | "future_major_change" | "security";
export interface ExtractedMessage {
source: MessageSource;
category: MessageCategory;
severity: "error" | "warning" | "info";
message: string;
packageName?: string | null;
}
/** Masque les identifiants éventuels dans une ligne (URLs user:pass@, tokens). */
function clean(line: string): string {
return line
.replace(/https?:\/\/[^/@\s]+:[^/@\s]+@/gi, "https://<redacted>@")
.replace(/\b(token|bearer|password|secret|key)=\S+/gi, "$1=<redacted>")
.trim();
}
const DEPRECATION = /\b(deprecat|will be removed|no longer supported|end of life|end-of-life|\bEOL\b|obsolete)\b/i;
const GPG = /\b(GPG error|NO_PUBKEY|KEYEXPIRED|EXPKEYSIG|not signed)\b/i;
/** Extrait les messages importants (erreurs/warnings/évolutions) d'une sortie brute. */
export function extractImportantMessages(raw: string, source: MessageSource): ExtractedMessage[] {
const out: ExtractedMessage[] = [];
const seen = new Set<string>();
for (const line of raw.split("\n")) {
const t = line.trim();
if (!t) continue;
let category: MessageCategory | null = null;
let severity: ExtractedMessage["severity"] = "warning";
if (/^E:/.test(t) || /dpkg:\s*error/i.test(t) || /unmet dependencies|unable to correct problems/i.test(t)) {
category = "error";
severity = "error";
} else if (DEPRECATION.test(t)) {
category = "future_major_change";
severity = "warning";
} else if (/^W:/.test(t) || GPG.test(t)) {
category = "warning";
severity = "warning";
} else if (source === "docker" && /\b(error|unauthorized|denied|failed)\b/i.test(t)) {
category = "error";
severity = "error";
} else {
continue;
}
const message = clean(t);
if (!message || seen.has(message)) continue;
seen.add(message);
out.push({ source, category, severity, message });
}
return out;
}
/** Persiste les messages (dédup par machine+source+message non acquitté → maj lastSeen). */
export function recordImportantMessages(
machineId: string,
messages: ExtractedMessage[],
refs: { snapshotId?: string | null; executionId?: string | null } = {},
): void {
const now = new Date().toISOString();
for (const m of messages) {
const existing = db
.select()
.from(schema.importantMessages)
.where(
and(
eq(schema.importantMessages.machineId, machineId),
eq(schema.importantMessages.source, m.source),
eq(schema.importantMessages.message, m.message),
eq(schema.importantMessages.acknowledged, 0),
),
)
.get();
if (existing) {
db.update(schema.importantMessages).set({ lastSeenAt: now }).where(eq(schema.importantMessages.id, existing.id)).run();
} else {
db.insert(schema.importantMessages).values({
id: randomUUID(),
machineId,
source: m.source,
category: m.category,
severity: m.severity,
packageName: m.packageName ?? null,
message: m.message,
snapshotId: refs.snapshotId ?? null,
executionId: refs.executionId ?? null,
firstSeenAt: now,
lastSeenAt: now,
acknowledged: 0,
}).run();
}
}
}
export function listImportantMessages(machineId: string, includeAck = false) {
const rows = db
.select()
.from(schema.importantMessages)
.where(eq(schema.importantMessages.machineId, machineId))
.all();
return rows
.filter((r) => includeAck || !r.acknowledged)
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
}
export function acknowledgeMessage(id: string, by = "ui") {
db.update(schema.importantMessages)
.set({ acknowledged: 1, acknowledgedAt: new Date().toISOString(), acknowledgedBy: by })
.where(eq(schema.importantMessages.id, id))
.run();
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { parseMetrics } from "./machineMetrics.js";
const RAW = [
"===SU:METRICS_CPU===",
"0.08 0.12 0.09 1/234 5678",
"4",
"===SU:METRICS_MEM===",
"MemTotal: 4194304 kB",
"MemAvailable: 2097152 kB",
"===SU:METRICS_FS===",
"FS\t/\text4\t32000000000\t9280000000\t29%",
"FS\t/boot\text2\t500000000\t475000000\t95%",
"===SU:EXIT=0===",
].join("\n");
describe("parseMetrics", () => {
it("lit load average et cores", () => {
const m = parseMetrics(RAW);
expect(m.cpu.load1).toBe(0.08);
expect(m.cpu.load5).toBe(0.12);
expect(m.cpu.cores).toBe(4);
});
it("calcule la mémoire en octets (kB→B) et le pourcentage utilisé", () => {
const m = parseMetrics(RAW);
expect(m.memory.totalBytes).toBe(4194304 * 1024);
expect(m.memory.availableBytes).toBe(2097152 * 1024);
expect(m.memory.usedBytes).toBe((4194304 - 2097152) * 1024);
expect(m.memory.usedPercent).toBe(50);
});
it("liste les systèmes de fichiers", () => {
const m = parseMetrics(RAW);
expect(m.filesystems).toHaveLength(2);
expect(m.filesystems[0]).toEqual({
mount: "/",
fstype: "ext4",
sizeBytes: 32000000000,
usedBytes: 9280000000,
usedPercent: 29,
});
});
it("émet un warning pour un FS quasi plein (>=90%)", () => {
const m = parseMetrics(RAW);
expect(m.warnings.some((w) => w.includes("/boot"))).toBe(true);
});
});
+126
View File
@@ -0,0 +1,126 @@
// server/services/machineMetrics.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { runScriptSudo } from "../ssh/client.js";
import type { MachineMetricsSimple } from "@shared/types.js";
function section(raw: string, start: string, end?: string): string {
const i = raw.indexOf(start);
if (i < 0) return "";
const from = i + start.length;
const j = end ? raw.indexOf(end, from) : -1;
return raw.slice(from, j < 0 ? undefined : j).trim();
}
const num = (s: string | undefined): number | null => {
if (s === undefined) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
};
export function parseMetrics(raw: string): MachineMetricsSimple {
const cpuLines = section(raw, "===SU:METRICS_CPU===", "===SU:METRICS_MEM===").split("\n").filter(Boolean);
const loadParts = (cpuLines[0] ?? "").trim().split(/\s+/);
const cpu = {
load1: num(loadParts[0]),
load5: num(loadParts[1]),
cores: num((cpuLines[1] ?? "").trim()),
};
const memBlock = section(raw, "===SU:METRICS_MEM===", "===SU:METRICS_FS===");
const memKb = (key: string): number | null => {
const m = new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, "m").exec(memBlock);
return m?.[1] ? Number(m[1]) * 1024 : null;
};
const totalBytes = memKb("MemTotal");
const availableBytes = memKb("MemAvailable");
const usedBytes = totalBytes !== null && availableBytes !== null ? totalBytes - availableBytes : null;
const usedPercent = totalBytes && usedBytes !== null ? Math.round((usedBytes / totalBytes) * 100) : null;
const memory = { totalBytes, usedBytes, availableBytes, usedPercent };
const filesystems: MachineMetricsSimple["filesystems"] = [];
for (const line of section(raw, "===SU:METRICS_FS===", "===SU:EXIT=").split("\n")) {
if (!line.startsWith("FS\t")) continue;
const [, mount, fstype, size, used, pcent] = line.split("\t");
filesystems.push({
mount: mount ?? "",
fstype: fstype ?? "",
sizeBytes: Number(size) || 0,
usedBytes: Number(used) || 0,
usedPercent: Number((pcent ?? "").replace("%", "")) || 0,
});
}
const warnings: string[] = [];
for (const fs of filesystems) {
if (fs.usedPercent >= 90) warnings.push(`Disque ${fs.mount} à ${fs.usedPercent}%`);
}
if (usedPercent !== null && usedPercent >= 90) warnings.push(`Mémoire à ${usedPercent}%`);
return { collectedAt: new Date().toISOString(), cpu, memory, filesystems, warnings };
}
/** Collecte les métriques d'une machine via SSH et persiste machine_metrics_latest. */
export async function collectMetrics(machineId: string): Promise<MachineMetricsSimple> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const script = renderTemplate("apt/machine-metrics.sh.tpl", {});
const res = await runScriptSudo(getCreds(m), script, () => {});
const metrics = parseMetrics(res.stdout);
const root = metrics.filesystems.find((f) => f.mount === "/");
db.insert(schema.machineMetricsLatest)
.values({
machineId,
collectedAt: metrics.collectedAt,
cpuLoad1: metrics.cpu.load1,
cpuLoad5: metrics.cpu.load5,
cpuCores: metrics.cpu.cores,
memoryTotalBytes: metrics.memory.totalBytes,
memoryUsedBytes: metrics.memory.usedBytes,
memoryAvailableBytes: metrics.memory.availableBytes,
memoryUsedPercent: metrics.memory.usedPercent,
filesystemsJson: JSON.stringify(metrics.filesystems),
rootUsedPercent: root?.usedPercent ?? null,
warningsJson: JSON.stringify(metrics.warnings),
})
.onConflictDoUpdate({
target: schema.machineMetricsLatest.machineId,
set: {
collectedAt: metrics.collectedAt,
cpuLoad1: metrics.cpu.load1,
cpuLoad5: metrics.cpu.load5,
cpuCores: metrics.cpu.cores,
memoryTotalBytes: metrics.memory.totalBytes,
memoryUsedBytes: metrics.memory.usedBytes,
memoryAvailableBytes: metrics.memory.availableBytes,
memoryUsedPercent: metrics.memory.usedPercent,
filesystemsJson: JSON.stringify(metrics.filesystems),
rootUsedPercent: root?.usedPercent ?? null,
warningsJson: JSON.stringify(metrics.warnings),
},
})
.run();
return metrics;
}
/** Dernières métriques stockées (sans SSH), si présentes. */
export function getLatestMetrics(machineId: string): MachineMetricsSimple | null {
const row = db.select().from(schema.machineMetricsLatest).where(eq(schema.machineMetricsLatest.machineId, machineId)).get();
if (!row) return null;
return {
collectedAt: row.collectedAt,
cpu: { load1: row.cpuLoad1, load5: row.cpuLoad5, cores: row.cpuCores },
memory: {
totalBytes: row.memoryTotalBytes,
usedBytes: row.memoryUsedBytes,
availableBytes: row.memoryAvailableBytes,
usedPercent: row.memoryUsedPercent,
},
filesystems: row.filesystemsJson ? JSON.parse(row.filesystemsJson) : [],
warnings: row.warningsJson ? JSON.parse(row.warningsJson) : [],
};
}
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseProbe, proposeCorrections } from "./machineProbe.js"; import { parseProbe, proposeCorrections, buildRecommendations } from "./machineProbe.js";
const PROXMOX = [ const PROXMOX = [
"===SU:PROBE_OS===", "===SU:PROBE_OS===",
@@ -101,3 +101,46 @@ describe("proposeCorrections", () => {
expect(c.virtualization).toBe("kvm"); 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; isRpi: boolean;
gpus: string[]; gpus: string[];
net: { iface: string; addr: string }[]; net: { iface: string; addr: string }[];
cpuModel: string | null;
cpuCores: number | null;
memoryBytes: number | null;
disks: { name: string; sizeBytes: number }[];
} }
export interface CorrectionProposal { export interface CorrectionProposal {
@@ -30,6 +34,11 @@ export interface CorrectionProposal {
virtualization: string; virtualization: string;
} }
export interface ProfileRecommendation {
profileId: string;
reason: string;
}
function section(raw: string, start: string, end?: string): string { function section(raw: string, start: string, end?: string): string {
const i = raw.indexOf(start); const i = raw.indexOf(start);
if (i < 0) return ""; 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 prox = section(raw, "===SU:PROBE_PROXMOX===", "===SU:PROBE_RPI===");
const rpi = section(raw, "===SU:PROBE_RPI===", "===SU:PROBE_GPU==="); const rpi = section(raw, "===SU:PROBE_RPI===", "===SU:PROBE_GPU===");
const gpuBlock = section(raw, "===SU:PROBE_GPU===", "===SU:PROBE_NET==="); 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 const gpus = gpuBlock
.split("\n") .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 { return {
osId: osReleaseValue(os, "ID"), osId: osReleaseValue(os, "ID"),
osVersion: osReleaseValue(os, "VERSION_ID"), osVersion: osReleaseValue(os, "VERSION_ID"),
@@ -77,9 +100,24 @@ export function parseProbe(raw: string): ProbeResult {
isRpi: /RPI=1/.test(rpi), isRpi: /RPI=1/.test(rpi),
gpus, gpus,
net, 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 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"]); 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 { export interface ProbeOutcome {
probe: ProbeResult; probe: ProbeResult;
proposal: CorrectionProposal; proposal: CorrectionProposal;
recommendations: ProfileRecommendation[];
raw: string; raw: string;
changes: string[]; // diff entre l'actuel et la proposition (pour l'UI) 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 proposal = proposeCorrections(probe);
const now = new Date().toISOString(); 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) db.insert(schema.machineHardware)
.values({ .values({ machineId, ...hwFields })
machineId, .onConflictDoUpdate({ target: schema.machineHardware.machineId, set: hwFields })
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 },
})
.run(); .run();
const changes: string[] = []; const changes: string[] = [];
@@ -151,5 +191,5 @@ export async function runProbe(machineId: string, onData?: (c: string) => void):
changes.push(`virtualization: ${m.virtualization ?? "—"}${proposal.virtualization}`); changes.push(`virtualization: ${m.virtualization ?? "—"}${proposal.virtualization}`);
} }
return { probe, proposal, raw, changes }; return { probe, proposal, recommendations: buildRecommendations(probe), raw, changes };
} }
+12
View File
@@ -1,8 +1,20 @@
// server/services/machineState.ts // server/services/machineState.ts
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { desc, eq } from "drizzle-orm";
import { db, schema } from "../db/client.js"; import { db, schema } from "../db/client.js";
import type { UpdateSnapshot } from "@shared/types.js"; import type { UpdateSnapshot } from "@shared/types.js";
/** Derniers événements d'une machine (timeline), du plus récent au plus ancien. */
export function listMachineEvents(machineId: string, limit = 30) {
return db
.select()
.from(schema.machineEvents)
.where(eq(schema.machineEvents.machineId, machineId))
.orderBy(desc(schema.machineEvents.createdAt))
.limit(limit)
.all();
}
export interface AptDerivedState { export interface AptDerivedState {
status: string; status: string;
aptStatus: string; aptStatus: string;
+31 -3
View File
@@ -17,6 +17,8 @@ export interface CreateMachineInput {
sudoPassword?: string | null; sudoPassword?: string | null;
aptProxyMode?: AptProxyMode; aptProxyMode?: AptProxyMode;
aptProxyUrl?: string | null; aptProxyUrl?: string | null;
osFamily?: OsFamily; // choix manuel ; sinon auto-détecté via os-release
machineKind?: MachineKind;
} }
type MachineRow = typeof schema.machines.$inferSelect; type MachineRow = typeof schema.machines.$inferSelect;
@@ -39,6 +41,9 @@ function toView(m: MachineRow): MachineView {
} }
export interface UpdateMachineInput { export interface UpdateMachineInput {
name?: string;
hostname?: string;
port?: number;
osFamily?: OsFamily; osFamily?: OsFamily;
machineKind?: MachineKind; machineKind?: MachineKind;
virtualization?: string | null; virtualization?: string | null;
@@ -46,11 +51,14 @@ export interface UpdateMachineInput {
aptProxyUrl?: string | null; aptProxyUrl?: string | null;
} }
/** Met à jour les champs de profil/proxy d'une machine (jamais les secrets). */ /** Met à jour les champs de profil/proxy/identité d'une machine (jamais les secrets). */
export function updateMachine(id: string, input: UpdateMachineInput): MachineView { export function updateMachine(id: string, input: UpdateMachineInput): MachineView {
const row = getMachineRow(id); const row = getMachineRow(id);
if (!row) throw new Error("Machine introuvable"); if (!row) throw new Error("Machine introuvable");
const patch: Partial<MachineRow> = { updatedAt: new Date().toISOString() }; const patch: Partial<MachineRow> = { updatedAt: new Date().toISOString() };
if (input.name !== undefined) patch.name = input.name;
if (input.hostname !== undefined) patch.hostname = input.hostname;
if (input.port !== undefined) patch.port = input.port;
if (input.osFamily !== undefined) patch.osFamily = input.osFamily; if (input.osFamily !== undefined) patch.osFamily = input.osFamily;
if (input.machineKind !== undefined) patch.machineKind = input.machineKind; if (input.machineKind !== undefined) patch.machineKind = input.machineKind;
if (input.virtualization !== undefined) patch.virtualization = input.virtualization; if (input.virtualization !== undefined) patch.virtualization = input.virtualization;
@@ -118,11 +126,11 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
name: input.name, name: input.name,
hostname: input.hostname, hostname: input.hostname,
port: input.port, port: input.port,
osFamily: os.family, osFamily: input.osFamily && input.osFamily !== "unknown" ? input.osFamily : os.family, // manuel prioritaire, "unknown" => auto
osVersion: os.version || null, osVersion: os.version || null,
osCodename: null, osCodename: null,
arch: null, arch: null,
machineKind: null, machineKind: input.machineKind ?? null,
virtualization: null, virtualization: null,
hardwareProfile: null, hardwareProfile: null,
username: input.username, username: input.username,
@@ -146,6 +154,26 @@ export function deleteMachine(id: string): void {
db.delete(schema.machines).where(eq(schema.machines.id, id)).run(); db.delete(schema.machines).where(eq(schema.machines.id, id)).run();
} }
/** Faits matériels d'une machine (machine_hardware rempli par machine_probe + colonnes machines). */
export function getMachineHardware(id: string) {
const m = getMachineRow(id);
if (!m) throw new Error("Machine introuvable");
const hw = db.select().from(schema.machineHardware).where(eq(schema.machineHardware.machineId, id)).get();
const parse = <T>(j: string | null | undefined): T[] => {
try { return j ? (JSON.parse(j) as T[]) : []; } catch { return []; }
};
return {
osFamily: m.osFamily,
osVersion: m.osVersion,
arch: m.arch,
machineKind: m.machineKind,
virtualization: m.virtualization,
gpus: parse<string>(hw?.gpusJson),
network: parse<{ iface: string; addr: string }>(hw?.networkJson),
probed: !!hw,
};
}
/** Applique un proxy APT à toutes les machines. Renvoie le nombre de machines modifiées. */ /** Applique un proxy APT à toutes les machines. Renvoie le nombre de machines modifiées. */
export function applyProxyToAllMachines(mode: AptProxyMode, url: string | null): number { export function applyProxyToAllMachines(mode: AptProxyMode, url: string | null): number {
const res = db const res = db
+130
View File
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import {
PROFILES,
validateProfileValues,
maskSecretValues,
buildPostInstallResult,
previewProfile,
type ProfileManifest,
} from "./postInstall.js";
describe("validateProfileValues", () => {
const identity = PROFILES.identity_network!;
it("échoue si un champ requis manque", () => {
const r = validateProfileValues(identity, { newHostname: "srv1" });
expect(r.ok).toBe(false);
expect(r.errors.some((e) => e.field === "interfaceName")).toBe(true);
});
it("échoue sur une IP/CIDR invalide", () => {
const r = validateProfileValues(identity, {
newHostname: "srv1",
domain: "home",
interfaceName: "eth0",
staticAddress: "999.1.1.1/24",
gateway: "10.0.0.1",
dnsNameservers: "10.0.0.1",
reconnectHost: "10.0.0.50",
});
expect(r.ok).toBe(false);
expect(r.errors.some((e) => e.field === "staticAddress")).toBe(true);
});
it("passe avec des valeurs valides", () => {
const r = validateProfileValues(identity, {
newHostname: "srv1",
domain: "home",
interfaceName: "eth0",
staticAddress: "10.0.0.50/22",
gateway: "10.0.0.1",
dnsNameservers: "10.0.0.1 10.0.0.10",
reconnectHost: "10.0.0.50",
});
expect(r.ok).toBe(true);
expect(r.errors).toHaveLength(0);
});
});
describe("maskSecretValues", () => {
const manifest: ProfileManifest = {
id: "x",
label: "x",
description: "",
risk: "low",
requiresConfirmation: false,
template: "custom/bootstrap-root.sh.tpl",
fields: [
{ name: "user", type: "string", required: true },
{ name: "token", type: "secret", required: true },
],
};
it("masque les champs secret et conserve les autres", () => {
const masked = maskSecretValues(manifest, { user: "gilles", token: "s3cr3t-ABC" });
expect(masked.user).toBe("gilles");
expect(masked.token).toBe("********");
expect(JSON.stringify(masked)).not.toContain("s3cr3t");
});
});
describe("profils SJ-9 (presetVars + sections)", () => {
it("base_tools injecte la liste de paquets fixe", () => {
expect(PROFILES.base_tools).toBeTruthy();
const script = previewProfile("base_tools", {});
expect(script).toContain("nano");
expect(script).toContain("htop");
});
it("sharing ne rend que les paquets cochés", () => {
const script = previewProfile("sharing", { installSamba: true, installNfs: false, installMdns: true });
expect(script).toContain("samba");
expect(script).toContain("avahi-daemon");
expect(script).not.toContain("nfs-kernel-server");
});
it("docker_official exige une confirmation", () => {
expect(PROFILES.docker_official!.requiresConfirmation).toBe(true);
});
});
describe("buildPostInstallResult", () => {
const raw = [
"===SU:CUSTOM_IDENTITY===",
"FILE_MODIFIED=/etc/hosts",
"FILE_MODIFIED=/etc/network/interfaces",
"OLD_ENDPOINT=10.0.0.99",
"HOSTNAME_SET=srv1",
"ERR=interface_not_found",
"NEW_ENDPOINT=10.0.0.50",
"RECONNECT_REQUIRED=1",
"REBOOT_REQUESTED=1",
"===SU:EXIT=0===",
].join("\n");
it("extrait fichiers modifiés, reboot, changement réseau et erreurs", () => {
const r = buildPostInstallResult(raw, ["identity_network"], { newHostname: "srv1" });
expect(r.profilesRun).toEqual(["identity_network"]);
expect(r.filesModified).toContain("/etc/hosts");
expect(r.filesModified).toContain("/etc/network/interfaces");
expect(r.rebootsRequested).toBe(true);
expect(r.networkChange).toEqual({ oldEndpoint: "10.0.0.99", newEndpoint: "10.0.0.50", reconnectHost: "10.0.0.50" });
expect(r.errors?.some((e) => e.kind === "interface_not_found")).toBe(true);
expect(r.variablesUsed.newHostname).toBe("srv1");
});
it("parse les paquets installés du bootstrap", () => {
const boot = [
"===SU:CUSTOM_BOOTSTRAP===",
"PKG_INSTALLED=sudo",
"PKG_INSTALLED=curl",
"GROUP_ADDED=sudo:gilles",
"SUDO_OK=1",
"===SU:EXIT=0===",
].join("\n");
const r = buildPostInstallResult(boot, ["bootstrap_root"], { operatorUser: "gilles" });
expect(r.packagesInstalled).toEqual(["sudo", "curl"]);
expect(r.rebootsRequested).toBe(false);
expect(r.errors ?? []).toHaveLength(0);
});
});
+368
View File
@@ -0,0 +1,368 @@
// server/services/postInstall.ts
import { getMachineRow, getCreds, updateMachine } from "./machines.js";
import { renderTemplate, type TemplateVars } from "../templates/render.js";
import { runScriptSudo } from "../ssh/client.js";
import { outputHub } from "../ws/outputHub.js";
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
import type { PostInstallResult, RebootResult, SnapshotError } from "@shared/types.js";
// ----------------------------------------------------------------------------
// Manifestes de profils (registre versionné en code ; templates versionnés sur disque).
// ----------------------------------------------------------------------------
export type FieldType =
| "string" | "hostname" | "ipv4" | "ipv4_cidr" | "ipv4_list"
| "select" | "bool" | "int" | "path" | "secret";
export interface ProfileField {
name: string;
type: FieldType;
required: boolean;
label?: string;
default?: string | number | boolean;
defaultFrom?: string; // valeur détectée par machine_probe (ex. detected.primaryInterface)
options?: string[]; // pour select
}
export interface ProfileManifest {
id: string;
label: string;
description: string;
risk: "low" | "medium" | "network_change";
requiresConfirmation: boolean;
template: string; // chemin relatif sous templates/
fields: ProfileField[];
presetVars?: Record<string, string | number | boolean>; // variables fixes (ex. liste de paquets)
}
export const PROFILES: Record<string, ProfileManifest> = {
bootstrap_root: {
id: "bootstrap_root",
label: "Bootstrap (sudo + base)",
description: "Installe sudo, ca-certificates, curl et ajoute l'opérateur au groupe sudo.",
risk: "low",
requiresConfirmation: false,
template: "custom/bootstrap-root.sh.tpl",
fields: [
{ name: "operatorUser", type: "string", required: true, label: "Utilisateur opérateur", defaultFrom: "sshUser" },
],
},
identity_network: {
id: "identity_network",
label: "Hostname + IP statique (Debian/VM)",
description: "Définit hostname, domaine et IP statique. Cible Debian + ifupdown (VM netinstall) ; refuse proprement les autres cas (Ubuntu/netplan…). Réseau appliqué au reboot.",
risk: "network_change",
requiresConfirmation: true,
template: "custom/identity-network.sh.tpl",
fields: [
{ name: "newHostname", type: "hostname", required: true, label: "Nouveau hostname" },
{ name: "domain", type: "string", required: true, label: "Domaine", default: "home" },
{ name: "interfaceName", type: "select", required: true, label: "Interface", defaultFrom: "detected.primaryInterface" },
{ name: "staticAddress", type: "ipv4_cidr", required: true, label: "Adresse statique (CIDR)" },
{ name: "gateway", type: "ipv4", required: true, label: "Passerelle", default: "10.0.0.1" },
{ name: "dnsNameservers", type: "ipv4_list", required: true, label: "DNS", default: "10.0.0.1 10.0.0.10" },
{ name: "reconnectHost", type: "ipv4", required: true, label: "IP de reconnexion", defaultFrom: "staticAddress.ip" },
{ name: "rebootAfterInstall", type: "bool", required: false, label: "Reboot après application" },
],
},
base_tools: {
id: "base_tools",
label: "Outils de base",
description: "nano, less, bash-completion, tmux, screen, htop, iotop, ncdu, tree, rsync, unzip, zip, tar.",
risk: "low",
requiresConfirmation: false,
template: "custom/install-package-groups.sh.tpl",
fields: [],
presetVars: { packages: "nano less bash-completion tmux screen htop iotop ncdu tree rsync unzip zip tar" },
},
network_tools: {
id: "network_tools",
label: "Outils réseau",
description: "iproute2, iputils-ping, dnsutils, traceroute, tcpdump, nmap, mtr-tiny, lsof, netcat-openbsd.",
risk: "low",
requiresConfirmation: false,
template: "custom/install-package-groups.sh.tpl",
fields: [],
presetVars: { packages: "iproute2 iputils-ping dnsutils traceroute tcpdump nmap mtr-tiny lsof netcat-openbsd" },
},
dev_git: {
id: "dev_git",
label: "Dev / Git",
description: "git, curl, wget, jq, gnupg, lsb-release.",
risk: "low",
requiresConfirmation: false,
template: "custom/install-package-groups.sh.tpl",
fields: [],
presetVars: { packages: "git curl wget jq gnupg lsb-release" },
},
docker_official: {
id: "docker_official",
label: "Docker (dépôt officiel)",
description: "Docker Engine depuis le dépôt officiel Debian + plugin compose ; ajoute l'utilisateur au groupe docker.",
risk: "medium",
requiresConfirmation: true,
template: "custom/docker-official-debian.sh.tpl",
fields: [
{ name: "dockerUser", type: "string", required: true, label: "Utilisateur docker", defaultFrom: "sshUser" },
{ name: "composeRoot", type: "path", required: true, label: "Dossier Compose", default: "/home/gilles/docker" },
{ name: "rebootAfterInstall", type: "bool", required: false, label: "Reboot après installation" },
],
},
sharing: {
id: "sharing",
label: "Partage réseau",
description: "Installe Samba / NFS / mDNS selon les cases cochées (configuration détaillée renvoyée à la tâche 4).",
risk: "medium",
requiresConfirmation: true,
template: "custom/sharing.sh.tpl",
fields: [
{ name: "installSamba", type: "bool", required: false, label: "Samba" },
{ name: "installNfs", type: "bool", required: false, label: "NFS" },
{ name: "installMdns", type: "bool", required: false, label: "mDNS (avahi)" },
],
},
vm_guest_tools: {
id: "vm_guest_tools",
label: "Outils invité VM",
description: "Agent invité selon l'hyperviseur (qemu-guest-agent ou open-vm-tools).",
risk: "low",
requiresConfirmation: false,
template: "custom/vm-guest-tools.sh.tpl",
fields: [
{ name: "guestAgent", type: "select", required: true, label: "Agent invité", default: "qemu-guest-agent", options: ["qemu-guest-agent", "open-vm-tools"] },
],
},
};
// ----------------------------------------------------------------------------
// Validation (pure, testable).
// ----------------------------------------------------------------------------
export type ProfileValues = Record<string, string | number | boolean | undefined>;
export interface ValidationResult {
ok: boolean;
errors: { field: string; message: string }[];
}
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const HOSTNAME = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
function isIpv4(v: string): boolean {
const m = IPV4.exec(v.trim());
return !!m && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255);
}
function isIpv4Cidr(v: string): boolean {
const [ip, mask] = v.trim().split("/");
return !!ip && !!mask && isIpv4(ip) && Number(mask) >= 0 && Number(mask) <= 32;
}
function validateField(field: ProfileField, raw: string | number | boolean): string | null {
const v = String(raw).trim();
switch (field.type) {
case "hostname":
return HOSTNAME.test(v) ? null : "hostname invalide";
case "ipv4":
return isIpv4(v) ? null : "adresse IPv4 invalide";
case "ipv4_cidr":
return isIpv4Cidr(v) ? null : "adresse CIDR invalide (ex. 10.0.0.50/22)";
case "ipv4_list":
return v.split(/[\s,]+/).filter(Boolean).every(isIpv4) ? null : "liste d'IPv4 invalide";
case "int":
return /^-?\d+$/.test(v) ? null : "entier attendu";
default:
return null;
}
}
export function validateProfileValues(manifest: ProfileManifest, values: ProfileValues): ValidationResult {
const errors: { field: string; message: string }[] = [];
for (const field of manifest.fields) {
const v = values[field.name];
const empty = v === undefined || v === null || String(v).trim() === "";
if (empty) {
if (field.required) errors.push({ field: field.name, message: "champ requis" });
continue;
}
const err = validateField(field, v);
if (err) errors.push({ field: field.name, message: err });
}
return { ok: errors.length === 0, errors };
}
// ----------------------------------------------------------------------------
// Masquage des secrets (jamais en clair vers UI/MCP/preview).
// ----------------------------------------------------------------------------
export function maskSecretValues(manifest: ProfileManifest, values: ProfileValues): ProfileValues {
const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name));
const out: ProfileValues = {};
for (const [k, v] of Object.entries(values)) {
out[k] = secrets.has(k) && v !== undefined && v !== "" ? "********" : v;
}
return out;
}
/** Valeurs non sensibles uniquement (pour variablesUsed / persistance / Hermes). */
function nonSecretValues(manifest: ProfileManifest, values: ProfileValues): Record<string, string | number | boolean> {
const secrets = new Set(manifest.fields.filter((f) => f.type === "secret").map((f) => f.name));
const out: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(values)) {
if (!secrets.has(k) && v !== undefined) out[k] = v;
}
return out;
}
// ----------------------------------------------------------------------------
// Rendu + preview.
// ----------------------------------------------------------------------------
function toTemplateVars(values: ProfileValues): TemplateVars {
const vars: TemplateVars = {};
for (const [k, v] of Object.entries(values)) vars[k] = v as never;
return vars;
}
export function renderProfile(profileId: string, values: ProfileValues): string {
const manifest = PROFILES[profileId];
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
return renderTemplate(manifest.template, toTemplateVars({ ...manifest.presetVars, ...values }));
}
/** Preview du script rendu avec masquage des secrets. */
export function previewProfile(profileId: string, values: ProfileValues): string {
const manifest = PROFILES[profileId];
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
return renderTemplate(manifest.template, toTemplateVars({ ...manifest.presetVars, ...maskSecretValues(manifest, values) }));
}
// ----------------------------------------------------------------------------
// Parsing du résultat (pure, testable).
// ----------------------------------------------------------------------------
function collectPrefixed(raw: string, prefix: string): string[] {
const out: string[] = [];
for (const line of raw.split("\n")) {
const t = line.trim();
if (t.startsWith(prefix)) out.push(t.slice(prefix.length));
}
return out;
}
function firstPrefixed(raw: string, prefix: string): string | null {
for (const line of raw.split("\n")) {
const t = line.trim();
if (t.startsWith(prefix)) return t.slice(prefix.length);
}
return null;
}
export function buildPostInstallResult(
raw: string,
profilesRun: string[],
variablesUsed: Record<string, string | number | boolean>,
): PostInstallResult {
const errors: SnapshotError[] = collectPrefixed(raw, "ERR=").map((kind) => ({
source: "post_install",
kind,
severity: "error",
message: `Échec post-install : ${kind}`,
}));
const oldEndpoint = firstPrefixed(raw, "OLD_ENDPOINT=");
const newEndpoint = firstPrefixed(raw, "NEW_ENDPOINT=");
const networkChange = oldEndpoint !== null || newEndpoint !== null
? { oldEndpoint: oldEndpoint || null, newEndpoint: newEndpoint || null, reconnectHost: newEndpoint || null }
: undefined;
return {
profilesRun,
variablesUsed,
filesModified: collectPrefixed(raw, "FILE_MODIFIED="),
packagesInstalled: collectPrefixed(raw, "PKG_INSTALLED="),
servicesEnabled: collectPrefixed(raw, "SERVICE_ENABLED="),
rebootsRequested: /REBOOT_REQUESTED=1/.test(raw),
...(networkChange ? { networkChange } : {}),
...(errors.length ? { errors } : {}),
};
}
// ----------------------------------------------------------------------------
// Orchestration (SSH).
// ----------------------------------------------------------------------------
export interface PostInstallOutcome {
result: PostInstallResult;
raw: string;
status: "ok" | "error";
}
export async function runPostInstall(
machineId: string,
profileId: string,
values: ProfileValues,
onData?: (c: string) => void,
): Promise<PostInstallOutcome> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const manifest = PROFILES[profileId];
if (!manifest) throw new Error(`Profil inconnu : ${profileId}`);
const validation = validateProfileValues(manifest, values);
if (!validation.ok) {
throw new Error("Champs invalides : " + validation.errors.map((e) => `${e.field} (${e.message})`).join(", "));
}
const script = renderProfile(profileId, values);
const res = await runScriptSudo(getCreds(m), script, (c) => {
onData?.(c);
outputHub.publish(machineId, c);
});
const raw = res.stdout;
const result = buildPostInstallResult(raw, [profileId], nonSecretValues(manifest, values));
const failed = (result.errors?.length ?? 0) > 0 || (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw));
return { result, raw, status: failed ? "error" : "ok" };
}
/**
* Reboote la machine puis attend son retour sur la NOUVELLE IP, et corrige la BDD
* (hostname SSH + nom) si la reconnexion réussit. Sécurité : si la machine ne revient
* pas sur la nouvelle IP, la BDD n'est PAS modifiée (récupération via console + backups).
*/
export async function rebootAndRebind(
machineId: string,
newHost: string,
newName: string | null,
onData?: (c: string) => void,
): Promise<RebootResult> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const creds = getCreds(m);
// 1) Reboot sur l'ancienne connexion (capture boot_id avant).
let raw = "";
try {
const res = await runScriptSudo(creds, renderTemplate("apt/reboot.sh.tpl", {}), (c) => {
raw += c;
onData?.(c);
outputHub.publish(machineId, c);
}, 0);
raw = res.stdout;
} catch {
/* la connexion tombe pendant le reboot : normal */
}
const beforeBootId = parseBootIdBefore(raw);
outputHub.publish(machineId, `\n[reboot] attente du retour sur ${newHost}...\n`);
// 2) Reconnexion sur la NOUVELLE IP.
const reboot = await verifyReboot({ ...creds, hostname: newHost }, {
beforeBootId,
requestedAt: new Date().toISOString(),
});
// 3) Bascule BDD uniquement si la machine est bien revenue sur la nouvelle IP.
if (reboot.status === "ok") {
updateMachine(machineId, { hostname: newHost, ...(newName ? { name: newName } : {}) });
outputHub.publish(machineId, `\n[reboot] machine basculée sur ${newHost}, BDD mise à jour.\n`);
} else {
outputHub.publish(machineId, `\n[reboot] reconnexion ${newHost} échouée (${reboot.status}) — BDD inchangée.\n`);
}
return reboot;
}
+2
View File
@@ -9,6 +9,7 @@ import { runScriptSudo } from "../ssh/client.js";
import { buildAptSnapshotDetail } from "./aptParse.js"; import { buildAptSnapshotDetail } from "./aptParse.js";
import { outputHub } from "../ws/outputHub.js"; import { outputHub } from "../ws/outputHub.js";
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js"; import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
import { extractImportantMessages, recordImportantMessages } from "./importantMessages.js";
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js"; import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */ /** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
@@ -82,6 +83,7 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run(); db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
upsertMachineState(machineId, deriveAptState(snapshot)); upsertMachineState(machineId, deriveAptState(snapshot));
recordImportantMessages(machineId, extractImportantMessages(raw, "apt"), { snapshotId });
recordEvent({ recordEvent({
machineId, machineId,
eventType: "apt_refresh", eventType: "apt_refresh",
+206
View File
@@ -0,0 +1,206 @@
// server/services/scheduler.ts
import { randomUUID } from "node:crypto";
import { Cron } from "croner";
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { listMachines } from "./machines.js";
import { refreshMachine } from "./refresh.js";
import { collectMetrics } from "./machineMetrics.js";
import { recordEvent } from "./machineState.js";
export type ScheduleAction = "apt_update_analyze" | "machine_metrics_simple" | "docker_scan";
export interface ScheduleScope {
machineIds: "all" | string[];
}
export interface ScheduleView {
id: string;
name: string;
enabled: boolean;
cron: string;
timezone: string | null;
scope: ScheduleScope;
actions: ScheduleAction[];
concurrency: number;
lastRunAt: string | null;
lastStatus: string | null;
}
type ScheduleRow = typeof schema.schedules.$inferSelect;
function toView(r: ScheduleRow): ScheduleView {
return {
id: r.id,
name: r.name,
enabled: !!r.enabled,
cron: r.cron,
timezone: r.timezone,
scope: JSON.parse(r.scopeJson) as ScheduleScope,
actions: JSON.parse(r.actionsJson) as ScheduleAction[],
concurrency: r.concurrency,
lastRunAt: r.lastRunAt,
lastStatus: r.lastStatus,
};
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
export function listSchedules(): ScheduleView[] {
return db.select().from(schema.schedules).all().map(toView);
}
export function getSchedule(id: string): ScheduleView | null {
const r = db.select().from(schema.schedules).where(eq(schema.schedules.id, id)).get();
return r ? toView(r) : null;
}
export interface ScheduleInput {
name: string;
cron: string;
timezone?: string | null;
enabled?: boolean;
scope?: ScheduleScope;
actions: ScheduleAction[];
concurrency?: number;
}
export function createSchedule(input: ScheduleInput): ScheduleView {
// Valide l'expression cron (lève si invalide), sans planifier.
new Cron(input.cron).stop();
const id = randomUUID();
const now = new Date().toISOString();
db.insert(schema.schedules).values({
id,
name: input.name,
enabled: input.enabled === false ? 0 : 1,
cron: input.cron,
timezone: input.timezone ?? "Europe/Paris",
scopeJson: JSON.stringify(input.scope ?? { machineIds: "all" }),
actionsJson: JSON.stringify(input.actions),
concurrency: input.concurrency ?? 2,
createdAt: now,
updatedAt: now,
}).run();
reloadSchedules();
return getSchedule(id)!;
}
export function updateSchedule(id: string, input: Partial<ScheduleInput>): ScheduleView {
const cur = db.select().from(schema.schedules).where(eq(schema.schedules.id, id)).get();
if (!cur) throw new Error("Schedule introuvable");
if (input.cron) new Cron(input.cron).stop(); // valide sans planifier
db.update(schema.schedules).set({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled ? 1 : 0 } : {}),
...(input.cron !== undefined ? { cron: input.cron } : {}),
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
...(input.scope !== undefined ? { scopeJson: JSON.stringify(input.scope) } : {}),
...(input.actions !== undefined ? { actionsJson: JSON.stringify(input.actions) } : {}),
...(input.concurrency !== undefined ? { concurrency: input.concurrency } : {}),
updatedAt: new Date().toISOString(),
}).where(eq(schema.schedules.id, id)).run();
reloadSchedules();
return getSchedule(id)!;
}
export function deleteSchedule(id: string): void {
db.delete(schema.schedules).where(eq(schema.schedules.id, id)).run();
reloadSchedules();
}
// ---------------------------------------------------------------------------
// Exécution
// ---------------------------------------------------------------------------
const locked = new Set<string>();
function resolveMachineIds(scope: ScheduleScope): string[] {
const all = listMachines().map((m) => m.id);
return scope.machineIds === "all" ? all : scope.machineIds.filter((id) => all.includes(id));
}
async function runActionOnMachine(machineId: string, action: ScheduleAction): Promise<void> {
if (action === "apt_update_analyze") {
await refreshMachine(machineId);
} else if (action === "machine_metrics_simple") {
await collectMetrics(machineId);
} else if (action === "docker_scan") {
const { scanDockerStacks } = await import("./dockerScan.js");
await scanDockerStacks(machineId);
}
}
/** Exécute un schedule : actions sur le périmètre, avec verrou par machine et concurrence. */
export async function runSchedule(id: string): Promise<{ ran: number; errors: number }> {
const sched = getSchedule(id);
if (!sched) throw new Error("Schedule introuvable");
const machineIds = resolveMachineIds(sched.scope);
let ran = 0;
let errors = 0;
const queue = [...machineIds];
const worker = async () => {
for (;;) {
const machineId = queue.shift();
if (!machineId) break;
if (locked.has(machineId)) continue; // une action tourne déjà sur cette machine
locked.add(machineId);
try {
for (const action of sched.actions) {
await runActionOnMachine(machineId, action);
}
ran++;
} catch (err) {
errors++;
recordEvent({
machineId,
eventType: "schedule_action_failed",
severity: "warning",
message: `Schedule « ${sched.name} » : ${(err as Error).message}`,
});
} finally {
locked.delete(machineId);
}
}
};
const pool = Math.max(1, Math.min(sched.concurrency, machineIds.length || 1));
await Promise.all(Array.from({ length: pool }, () => worker()));
db.update(schema.schedules)
.set({ lastRunAt: new Date().toISOString(), lastStatus: errors ? `partial (${errors} err)` : "ok" })
.where(eq(schema.schedules.id, id))
.run();
return { ran, errors };
}
// ---------------------------------------------------------------------------
// Enregistrement croner
// ---------------------------------------------------------------------------
let jobs: Cron[] = [];
export function reloadSchedules(): void {
for (const j of jobs) j.stop();
jobs = [];
for (const s of listSchedules()) {
if (!s.enabled) continue;
try {
jobs.push(
new Cron(s.cron, { timezone: s.timezone ?? undefined, name: s.id }, () => {
runSchedule(s.id).catch((err) => console.error(`[scheduler] ${s.name}:`, (err as Error).message));
}),
);
} catch (err) {
console.error(`[scheduler] cron invalide pour ${s.name}:`, (err as Error).message);
}
}
}
export function stopSchedules(): void {
for (const j of jobs) j.stop();
jobs = [];
}
+15
View File
@@ -12,6 +12,21 @@ export interface TemplateVars {
composeRoots?: string | number | null; composeRoots?: string | number | null;
composeScanDepth?: string | number | null; composeScanDepth?: string | number | null;
stackDir?: string | null; stackDir?: string | null;
// Post-install (SJ-8) — toutes optionnelles, jamais de secret.
operatorUser?: string | null;
packages?: string | null; // liste shell-safe rendue par le backend
newHostname?: string | null;
domain?: string | null;
interfaceName?: string | null;
staticAddress?: string | null;
gateway?: string | null;
dnsNameservers?: string | null;
reconnectHost?: string | null;
dhcpEndpoint?: string | null;
dockerUser?: string | null;
composeRoot?: string | null;
rebootAfterInstall?: boolean;
[key: string]: unknown; // champs de profil dynamiques (typés au cas par cas)
} }
export function renderTemplate( export function renderTemplate(
+17
View File
@@ -208,6 +208,23 @@ export interface RebootResult {
errors?: SnapshotError[]; errors?: SnapshotError[];
} }
export interface AptRepositoriesAnalysis {
osFamily: OsFamily;
components: string[];
repos: { uri: string; suite: string; components: string[] }[];
proxmox?: { enterprise: boolean; noSubscription: boolean };
warnings: { kind: string; message: string }[];
notes: string[];
}
export interface MachineMetricsSimple {
collectedAt: string;
cpu: { load1: number | null; load5: number | null; cores: number | null };
memory: { totalBytes: number | null; usedBytes: number | null; availableBytes: number | null; usedPercent: number | null };
filesystems: { mount: string; fstype: string; sizeBytes: number; usedBytes: number; usedPercent: number }[];
warnings: string[];
}
export interface PostInstallResult { export interface PostInstallResult {
profilesRun: string[]; profilesRun: string[];
variablesUsed: Record<string, string | number | boolean>; variablesUsed: Record<string, string | number | boolean>;
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Métriques légères CPU/RAM/disque. Non destructif, rapide, sans installation.
export LC_ALL=C
echo "===SU:METRICS_CPU==="
cat /proc/loadavg 2>/dev/null
nproc 2>/dev/null
echo "===SU:METRICS_MEM==="
grep -E '^(MemTotal|MemAvailable):' /proc/meminfo 2>/dev/null
echo "===SU:METRICS_FS==="
df -B1 -T -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null \
| awk 'NR>1 {print "FS\t"$7"\t"$2"\t"$3"\t"$4"\t"$6}'
echo "===SU:EXIT=0==="
+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" command -v lspci >/dev/null 2>&1 && lspci 2>/dev/null | grep -Ei 'vga|3d|display' || echo "no-lspci"
echo "===SU:PROBE_NET===" echo "===SU:PROBE_NET==="
ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}' 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===" echo "===SU:EXIT=0==="
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Analyse des dépôts APT (lecture seule). Ne modifie rien.
export LC_ALL=C
echo "===SU:REPO_DEB==="
grep -rhE '^[[:space:]]*deb[[:space:]]' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null | grep -vE '^[[:space:]]*#'
echo "===SU:REPO_DEB822==="
grep -rhE '^(URIs|Suites|Components|Enabled):' /etc/apt/sources.list.d/ 2>/dev/null
echo "===SU:EXIT=0==="
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Bootstrap première prépa (après DHCP / su -) : sudo + outils de base, ajout au groupe sudo.
# Non interactif. Échec contrôlé. Aucun secret (operatorUser = champ de formulaire).
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_BOOTSTRAP==="
apt-get update -qq 2>&1
if apt-get install -y sudo resolvconf ca-certificates curl 2>&1; then
for p in sudo resolvconf ca-certificates curl; do echo "PKG_INSTALLED=$p"; done
CODE=0
else
echo "ERR=package_install_failed"
CODE=1
fi
if usermod -aG sudo "{{operatorUser}}" 2>&1; then
echo "GROUP_ADDED=sudo:{{operatorUser}}"
else
echo "ERR=sudo_setup_failed"
fi
if su - "{{operatorUser}}" -c 'sudo -n true' 2>/dev/null; then
echo "SUDO_OK=1"
else
echo "SUDO_CHECK_PENDING=1"
fi
echo "===SU:EXIT=${CODE}==="
@@ -0,0 +1,26 @@
#!/bin/sh
# Docker Engine depuis le dépôt officiel Debian (docs.docker.com/engine/install/debian).
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_DOCKER==="
apt-get update -qq 2>&1
apt-get install -y ca-certificates curl 2>&1
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc 2>&1
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && echo "FILE_MODIFIED=/etc/apt/sources.list.d/docker.list"
apt-get update -qq 2>&1
if apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>&1; then
for p in docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin; do echo "PKG_INSTALLED=$p"; done
echo "SERVICE_ENABLED=docker"
CODE=0
else
echo "ERR=docker_install_failed"
CODE=1
fi
usermod -aG docker "{{dockerUser}}" 2>&1 && echo "GROUP_ADDED=docker:{{dockerUser}}" || echo "ERR=docker_group_failed"
mkdir -p "{{composeRoot}}" 2>&1 && echo "FILE_MODIFIED={{composeRoot}}"
echo "DOCKER_GROUP_RELOGIN_REQUIRED=1"
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
echo "===SU:EXIT=${CODE}==="
+97
View File
@@ -0,0 +1,97 @@
#!/bin/sh
# Identité + réseau : hostname, /etc/hosts, IP statique (ifupdown drop-in).
# Le changement d'IP s'applique AU REBOOT (on ne coupe jamais SSH en live).
# Sauvegardes horodatées avant toute écriture. Échec contrôlé.
export LC_ALL=C
HOST="{{newHostname}}"
DOMAIN="{{domain}}"
IFACE="{{interfaceName}}"
ADDR="{{staticAddress}}"
GW="{{gateway}}"
DNS="{{dnsNameservers}}"
echo "===SU:CUSTOM_IDENTITY==="
# --- Précheck : Debian + ifupdown uniquement (MVP, cible VM netinstall) ---
. /etc/os-release 2>/dev/null
if [ "$ID" != "debian" ]; then
echo "ERR=os_not_supported"
echo "DETAIL=identity_network ne gère que Debian (ID=$ID)"
echo "===SU:EXIT=2==="
exit 2
fi
if ls /etc/netplan/*.yaml >/dev/null 2>&1; then
echo "ERR=unsupported_network_manager"
echo "DETAIL=netplan détecté ; ce profil cible ifupdown"
echo "===SU:EXIT=2==="
exit 2
fi
if [ ! -f /etc/network/interfaces ]; then
echo "ERR=ifupdown_not_found"
echo "DETAIL=/etc/network/interfaces absent"
echo "===SU:EXIT=2==="
exit 2
fi
# --- Sauvegardes ---
TS=$(date +%s)
cp -a /etc/hosts "/etc/hosts.su.bak.${TS}" 2>/dev/null
[ -f /etc/network/interfaces ] && cp -a /etc/network/interfaces "/etc/network/interfaces.su.bak.${TS}" 2>/dev/null
[ -f /etc/hostname ] && cp -a /etc/hostname "/etc/hostname.su.bak.${TS}" 2>/dev/null
echo "OLD_ENDPOINT={{dhcpEndpoint}}"
# --- Hostname (immédiat, ne coupe pas SSH) ---
if hostnamectl set-hostname "$HOST" 2>/dev/null || { printf '%s\n' "$HOST" > /etc/hostname; }; then
printf '%s\n' "$HOST" > /etc/hostname
echo "HOSTNAME_SET=$HOST"
echo "FILE_MODIFIED=/etc/hostname"
else
echo "ERR=hostname_failed"
fi
# --- /etc/hosts : ligne 127.0.1.1 <fqdn> <host> ---
FQDN="$HOST"
[ -n "$DOMAIN" ] && FQDN="$HOST.$DOMAIN"
if grep -qE '^127\.0\.1\.1' /etc/hosts 2>/dev/null; then
sed -i -E "s|^127\.0\.1\.1.*|127.0.1.1\t${FQDN} ${HOST}|" /etc/hosts && echo "FILE_MODIFIED=/etc/hosts"
else
printf '127.0.1.1\t%s %s\n' "$FQDN" "$HOST" >> /etc/hosts && echo "FILE_MODIFIED=/etc/hosts"
fi
# --- IP statique (ifupdown drop-in, appliqué au reboot) ---
if ip link show "$IFACE" >/dev/null 2>&1; then
echo "IFACE_OK=$IFACE"
mkdir -p /etc/network/interfaces.d
# S'assure que le fichier principal source le répertoire interfaces.d.
if [ -f /etc/network/interfaces ] && ! grep -qE '^[[:space:]]*source(-directory)?[[:space:]]+/etc/network/interfaces\.d' /etc/network/interfaces; then
printf '\nsource /etc/network/interfaces.d/*\n' >> /etc/network/interfaces
fi
# Neutralise (commente) toute strophe existante de l'interface dans le fichier principal.
if [ -f /etc/network/interfaces ]; then
awk -v IFACE="$IFACE" '
$0 ~ "^[[:space:]]*(auto|allow-hotplug)[[:space:]]+" IFACE "([[:space:]]|$)" { print "#SU# " $0; next }
$0 ~ "^[[:space:]]*iface[[:space:]]+" IFACE "([[:space:]]|$)" { inblk=1; print "#SU# " $0; next }
inblk==1 && $0 ~ /^[[:space:]]+[^[:space:]]/ { print "#SU# " $0; next }
inblk==1 { inblk=0 }
{ print }
' /etc/network/interfaces > /etc/network/interfaces.su.tmp && cat /etc/network/interfaces.su.tmp > /etc/network/interfaces && rm -f /etc/network/interfaces.su.tmp
fi
# Écrit la configuration statique en drop-in.
{
echo "auto $IFACE"
echo "iface $IFACE inet static"
echo " address $ADDR"
echo " gateway $GW"
[ -n "$DNS" ] && echo " dns-nameservers $DNS"
} > "/etc/network/interfaces.d/${IFACE}.cfg"
echo "FILE_MODIFIED=/etc/network/interfaces.d/${IFACE}.cfg"
echo "STATIC_TARGET=$ADDR gw $GW dns $DNS"
else
echo "ERR=interface_not_found"
fi
echo "NEW_ENDPOINT={{reconnectHost}}"
echo "RECONNECT_REQUIRED=1"
echo "NETWORK_APPLIES_ON=reboot"
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
echo "===SU:EXIT=0==="
@@ -0,0 +1,14 @@
#!/bin/sh
# Installe un groupe de paquets. {{packages}} = liste shell-safe fournie par le backend.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_PKGGROUPS==="
apt-get update -qq 2>&1
if apt-get install -y {{packages}} 2>&1; then
for p in {{packages}}; do echo "PKG_INSTALLED=$p"; done
CODE=0
else
echo "ERR=package_install_failed"
CODE=1
fi
echo "===SU:EXIT=${CODE}==="
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Partage réseau : installe les paquets cochés (Samba/NFS/mDNS). Config détaillée = tâche 4.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_SHARING==="
PKGS=""
{{#installSamba}}PKGS="$PKGS samba"{{/installSamba}}
{{#installNfs}}PKGS="$PKGS nfs-kernel-server"{{/installNfs}}
{{#installMdns}}PKGS="$PKGS avahi-daemon libnss-mdns"{{/installMdns}}
if [ -z "$PKGS" ]; then
echo "ERR=no_sharing_selected"
echo "===SU:EXIT=2==="
exit 2
fi
apt-get update -qq 2>&1
if apt-get install -y $PKGS 2>&1; then
for p in $PKGS; do echo "PKG_INSTALLED=$p"; done
{{#installSamba}}echo "SERVICE_ENABLED=smbd"{{/installSamba}}
{{#installNfs}}echo "SERVICE_ENABLED=nfs-kernel-server"{{/installNfs}}
{{#installMdns}}echo "SERVICE_ENABLED=avahi-daemon"{{/installMdns}}
CODE=0
else
echo "ERR=sharing_install_failed"
CODE=1
fi
echo "===SU:EXIT=${CODE}==="
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Agent invité VM : {{guestAgent}} = qemu-guest-agent ou open-vm-tools.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_VMTOOLS==="
apt-get update -qq 2>&1
if apt-get install -y {{guestAgent}} 2>&1; then
echo "PKG_INSTALLED={{guestAgent}}"
echo "SERVICE_ENABLED={{guestAgent}}"
CODE=0
else
echo "ERR=vmtools_install_failed"
CODE=1
fi
echo "===SU:EXIT=${CODE}==="