Compare commits
14 Commits
faa654c95a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa73ab07b0 | |||
| a93a43e1c8 | |||
| ff9cfaa9e1 | |||
| bdbe7af55c | |||
| 1530409d3b | |||
| 3ea2e66359 | |||
| aaf1b4988d | |||
| d1b0290e3b | |||
| e3e824185f | |||
| c390addadb | |||
| 58abebf687 | |||
| 3b16fdd52a | |||
| 4eb0335900 | |||
| e6f4ae470b |
+34
-1
@@ -4,4 +4,37 @@
|
||||
- dans parametre ajouter d'un bacup et restore de la bdd
|
||||
- le bouton ajouter sera deplacer dans le header
|
||||
- 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
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
@@ -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
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AptProxyMode, MachineKind, OsFamily } from "@shared/types.js";
|
||||
import type { DefaultAptProxy } from "../../lib/api.js";
|
||||
import { api } from "../../lib/api.js";
|
||||
|
||||
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) {
|
||||
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 [busy, setBusy] = useState(false);
|
||||
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 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="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
|
||||
{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>
|
||||
</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>}
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>Annuler</button>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
// client/src/features/machines/MachineTile.tsx
|
||||
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 { 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 {
|
||||
machine: MachineView;
|
||||
@@ -41,8 +52,9 @@ export function MachineTile({
|
||||
}: Props) {
|
||||
const [dockerOpen, setDockerOpen] = useState(false);
|
||||
const [postOpen, setPostOpen] = useState(false);
|
||||
const [hwOpen, setHwOpen] = useState(false);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const expanded = dockerOpen || postOpen;
|
||||
const expanded = dockerOpen || postOpen || hwOpen;
|
||||
const isError = machine.status === "error" || machine.status === "unknown";
|
||||
|
||||
return (
|
||||
@@ -145,7 +157,15 @@ export function MachineTile({
|
||||
open={postOpen}
|
||||
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>
|
||||
</article>
|
||||
);
|
||||
@@ -200,6 +220,12 @@ function MachineConfigPopup({
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
const [proxyMode, setProxyMode] = useState<AptProxyMode>(machine.aptProxyMode);
|
||||
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>) {
|
||||
setBusy(key);
|
||||
@@ -278,10 +304,24 @@ function MachineConfigPopup({
|
||||
{probe.probe.isProxmox ? " · proxmox" : ""}
|
||||
{probe.probe.isRpi ? " · rpi" : ""}
|
||||
</div>
|
||||
{(probe.probe.cpuModel || probe.probe.memoryBytes) && (
|
||||
<div className="mono cfg-facts">
|
||||
cpu={probe.probe.cpuModel ?? "?"} ({probe.probe.cpuCores ?? "?"}c) · ram=
|
||||
{probe.probe.memoryBytes ? `${(probe.probe.memoryBytes / 1e9).toFixed(1)} Go` : "?"} · disks=
|
||||
{probe.probe.disks.length}
|
||||
</div>
|
||||
)}
|
||||
<div className="cfg-proposal mono">
|
||||
proposition : os_family={probe.proposal.osFamily} · machine_kind={probe.proposal.machineKind} · virt=
|
||||
{probe.proposal.virtualization}
|
||||
</div>
|
||||
{probe.recommendations.length > 0 && (
|
||||
<ul className="cfg-changes">
|
||||
{probe.recommendations.map((r, i) => (
|
||||
<li key={i} className="mono">↪ profil conseillé : {r.profileId} — {r.reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{probe.changes.length ? (
|
||||
<>
|
||||
<ul className="cfg-changes">
|
||||
@@ -323,6 +363,27 @@ function MachineConfigPopup({
|
||||
</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>}
|
||||
</div>
|
||||
</Popup>
|
||||
@@ -619,24 +680,316 @@ function shortId(id: string | null): string {
|
||||
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 (
|
||||
<div className="machine-section-body">
|
||||
<label className="machine-check-row">
|
||||
<input type="checkbox" />
|
||||
<span>Profil network tools</span>
|
||||
</label>
|
||||
<label className="machine-check-row">
|
||||
<input type="checkbox" />
|
||||
<span>Profil partage Samba/NFS</span>
|
||||
</label>
|
||||
<div className="machine-placeholder">
|
||||
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
|
||||
<div className="pi-list">
|
||||
{profiles.map((p) => {
|
||||
const isOpen = open.has(p.id);
|
||||
return (
|
||||
<div key={p.id} className="pi-profile">
|
||||
<button className="pi-profile-head interactive" onClick={() => toggle(p.id)}>
|
||||
<Icon name={isOpen ? "chevD" : "chevR"} size={12} style={undefined} />
|
||||
<span className="pi-profile-name">{p.label}</span>
|
||||
<span className={`docker-badge docker-badge-${riskTone(p.risk)}`}>{riskLabel(p.risk)}</span>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!value) return "-";
|
||||
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).
|
||||
const [dockerOpen, setDockerOpen] = useState(true);
|
||||
const [postOpen, setPostOpen] = useState(true);
|
||||
const [hwOpen, setHwOpen] = useState(true);
|
||||
const [tlOpen, setTlOpen] = useState(false);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
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)} />
|
||||
</div>
|
||||
|
||||
<MessagesCard machineId={machine.id} />
|
||||
|
||||
<div className="machine-detail-cards">
|
||||
<div className="machine-detail-card">
|
||||
<span className="label">System info</span>
|
||||
@@ -761,7 +1118,11 @@ export function MachineDetailPanel({
|
||||
<SectionToggle icon="docker" title="Docker" open={dockerOpen} onToggle={() => setDockerOpen((v) => !v)} />
|
||||
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
|
||||
<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>
|
||||
|
||||
{configOpen && (
|
||||
|
||||
+121
-1
@@ -1,5 +1,5 @@
|
||||
// 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> {
|
||||
const text = await res.text();
|
||||
@@ -42,16 +42,37 @@ export const api = {
|
||||
}),
|
||||
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 ---
|
||||
getSettings: () => req<AppSettingsView>("/settings"),
|
||||
setDefaultAptProxy: (body: DefaultAptProxy) =>
|
||||
req<DefaultAptProxy>("/settings/apt-proxy", { method: "PUT", body: JSON.stringify(body) }),
|
||||
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) ---
|
||||
updateMachine: (id: string, body: UpdateMachineBody) =>
|
||||
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
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 ---
|
||||
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
||||
@@ -118,6 +139,46 @@ export interface DbRestoreResult {
|
||||
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 {
|
||||
mode: AptProxyMode;
|
||||
url: string | null;
|
||||
@@ -135,6 +196,35 @@ export interface UpdateMachineBody {
|
||||
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 {
|
||||
probe: {
|
||||
osId: string | null;
|
||||
@@ -147,11 +237,41 @@ export interface ProbeResultView {
|
||||
isRpi: boolean;
|
||||
gpus: string[];
|
||||
net: { iface: string; addr: string }[];
|
||||
cpuModel: string | null;
|
||||
cpuCores: number | null;
|
||||
memoryBytes: number | null;
|
||||
disks: { name: string; sizeBytes: number }[];
|
||||
};
|
||||
proposal: { osFamily: OsFamily; machineKind: MachineKind; virtualization: string };
|
||||
recommendations: { profileId: string; reason: string }[];
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
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 interface DockerSettingsView {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AptProxyMode } from "@shared/types.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 {
|
||||
open: boolean;
|
||||
@@ -14,6 +14,7 @@ type SettingsTab =
|
||||
| "tiles"
|
||||
| "layout"
|
||||
| "proxy"
|
||||
| "automation"
|
||||
| "docker"
|
||||
| "scripts"
|
||||
| "hermes"
|
||||
@@ -26,6 +27,7 @@ const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
|
||||
{ id: "tiles", label: "Tuiles", icon: "grid" },
|
||||
{ id: "layout", label: "Volets", icon: "collapse" },
|
||||
{ id: "proxy", label: "Proxy APT", icon: "network" },
|
||||
{ id: "automation", label: "Automatisations", icon: "clock" },
|
||||
{ id: "docker", label: "Docker", icon: "docker" },
|
||||
{ id: "scripts", label: "Scripts", icon: "script" },
|
||||
{ id: "hermes", label: "Hermes", icon: "node" },
|
||||
@@ -71,6 +73,7 @@ export function SettingsModal({ open, onClose }: Props) {
|
||||
{active === "tiles" && <TileSettings />}
|
||||
{active === "layout" && <LayoutSettings />}
|
||||
{active === "proxy" && <ProxyDefaultSettings />}
|
||||
{active === "automation" && <AutomationSettings />}
|
||||
{active === "docker" && <DockerSettings />}
|
||||
{active === "scripts" && <ScriptsSettings />}
|
||||
{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() {
|
||||
const [mode, setMode] = useState<AptProxyMode>("direct");
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
@@ -105,6 +105,23 @@ body {
|
||||
.machine-info-k { color: var(--ink-3); font-size: 12px; }
|
||||
.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 {
|
||||
min-width: 0;
|
||||
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
@@ -50,6 +50,13 @@
|
||||
"when": 1780724800966,
|
||||
"tag": "0006_many_northstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1780766513336,
|
||||
"tag": "0007_bizarre_doctor_faustus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -319,3 +319,20 @@ export const appSettings = sqliteTable("app_settings", {
|
||||
value: text("value"),
|
||||
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
@@ -1,24 +1,11 @@
|
||||
// server/jobs/worker.ts
|
||||
import { Cron } from "croner";
|
||||
import { listMachines } from "../services/machines.js";
|
||||
import { refreshMachine } from "../services/refresh.js";
|
||||
import { reloadSchedules, stopSchedules } from "../services/scheduler.js";
|
||||
|
||||
let job: Cron | null = null;
|
||||
|
||||
/** Rafraîchit toutes les machines toutes les 30 minutes (tâche de fond). */
|
||||
/** Démarre le planificateur : enregistre les automatisations actives (cron) depuis la BDD. */
|
||||
export function startWorker(): void {
|
||||
job = new Cron("*/30 * * * *", async () => {
|
||||
for (const m of listMachines()) {
|
||||
try {
|
||||
await refreshMachine(m.id);
|
||||
} catch (err) {
|
||||
console.error(`[worker] refresh échoué pour ${m.id}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
});
|
||||
reloadSchedules();
|
||||
}
|
||||
|
||||
export function stopWorker(): void {
|
||||
job?.stop();
|
||||
job = null;
|
||||
stopSchedules();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { actionRequestsRoutes } from "./actionRequests.js";
|
||||
import { dockerRoutes } from "./docker.js";
|
||||
import { dbRoutes } from "./db.js";
|
||||
import { settingsRoutes } from "./settings.js";
|
||||
import { postInstallRoutes } from "./postInstall.js";
|
||||
import { schedulesRoutes } from "./schedules.js";
|
||||
import { getServerCapabilities } from "../services/capabilities.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.route("/system/db", dbRoutes);
|
||||
api.route("/settings", settingsRoutes);
|
||||
api.route("/schedules", schedulesRoutes);
|
||||
api.route("/machines", machinesRoutes);
|
||||
api.route("/machines", actionsRoutes);
|
||||
api.route("/machines", dockerRoutes);
|
||||
api.route("/", actionRequestsRoutes);
|
||||
api.route("/", postInstallRoutes);
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
listMachines, createMachine, deleteMachine, updateMachine, getMachineRow, getCreds, testConnection,
|
||||
getMachineHardware,
|
||||
type CreateMachineInput, type UpdateMachineInput,
|
||||
} from "../services/machines.js";
|
||||
import { refreshMachine, getLatestSnapshot } from "../services/refresh.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();
|
||||
|
||||
@@ -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.
|
||||
machinesRoutes.post("/:id/probe", async (c) => {
|
||||
try {
|
||||
const o = await runProbe(c.req.param("id"));
|
||||
return c.json({ probe: o.probe, proposal: o.proposal, changes: o.changes });
|
||||
return c.json({ probe: o.probe, proposal: o.proposal, recommendations: o.recommendations, changes: o.changes });
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,6 +16,7 @@ export const DESTRUCTIVE_ACTIONS: Partial<Record<ActionType, "medium" | "high">>
|
||||
apt_autoremove: "medium",
|
||||
reboot: "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"];
|
||||
@@ -26,7 +27,12 @@ export interface CreateRequestInput {
|
||||
requestedByType?: "user" | "hermes" | "schedule";
|
||||
requestedById?: 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) {
|
||||
@@ -96,8 +102,15 @@ export function approveActionRequest(id: string, approvedBy?: string) {
|
||||
.where(eq(schema.actionRequests.id, id))
|
||||
.run();
|
||||
|
||||
const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean }) : {};
|
||||
const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive };
|
||||
const payload = req.payloadJson
|
||||
? (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;
|
||||
runAction(machineId, req.action as ActionType, opts)
|
||||
.then((result) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ const APT_ACTION_FILE: Partial<Record<ActionType, string>> = {
|
||||
export interface RunActionOpts {
|
||||
stackId?: string;
|
||||
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;
|
||||
importantLines: string[];
|
||||
docker?: ExecutionResult["docker"];
|
||||
postInstall?: ExecutionResult["postInstall"];
|
||||
reboot?: ExecutionResult["reboot"];
|
||||
errors?: ExecutionResult["errors"];
|
||||
}): ExecutionResult {
|
||||
const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args;
|
||||
@@ -64,6 +68,8 @@ function archiveExecution(args: {
|
||||
importantLogLines: importantLines,
|
||||
rawLogRef: rawLogPath, reportRef: reportPath,
|
||||
...(args.docker ? { docker: args.docker } : {}),
|
||||
...(args.postInstall ? { postInstall: args.postInstall } : {}),
|
||||
...(args.reboot ? { reboot: args.reboot } : {}),
|
||||
...(args.errors && args.errors.length ? { errors: args.errors } : {}),
|
||||
};
|
||||
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é ---
|
||||
if (action === "machine_probe") {
|
||||
const { runProbe } = await import("./machineProbe.js");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) : [],
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseProbe, proposeCorrections } from "./machineProbe.js";
|
||||
import { parseProbe, proposeCorrections, buildRecommendations } from "./machineProbe.js";
|
||||
|
||||
const PROXMOX = [
|
||||
"===SU:PROBE_OS===",
|
||||
@@ -101,3 +101,46 @@ describe("proposeCorrections", () => {
|
||||
expect(c.virtualization).toBe("kvm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sonde enrichie (cpu/mem/disk + recommandations)", () => {
|
||||
const ENRICHED = [
|
||||
"===SU:PROBE_OS===",
|
||||
"ID=debian",
|
||||
"===SU:PROBE_ARCH===",
|
||||
"x86_64",
|
||||
"amd64",
|
||||
"===SU:PROBE_VIRT===",
|
||||
"kvm",
|
||||
"===SU:PROBE_PROXMOX===",
|
||||
"PROXMOX=0",
|
||||
"===SU:PROBE_RPI===",
|
||||
"RPI=0",
|
||||
"===SU:PROBE_GPU===",
|
||||
"no-lspci",
|
||||
"===SU:PROBE_NET===",
|
||||
"ens18 10.0.0.8/22",
|
||||
"===SU:PROBE_CPU===",
|
||||
"MODEL=Intel(R) Xeon(R) CPU E5-2670",
|
||||
"4",
|
||||
"===SU:PROBE_MEM===",
|
||||
"MemTotal: 4194304 kB",
|
||||
"===SU:PROBE_DISK===",
|
||||
"DISK\tsda\t34359738368",
|
||||
"DISK\tsdb\t1073741824000",
|
||||
"===SU:EXIT=0===",
|
||||
].join("\n");
|
||||
|
||||
it("extrait cpuModel/cores, mémoire et disques", () => {
|
||||
const p = parseProbe(ENRICHED);
|
||||
expect(p.cpuModel).toBe("Intel(R) Xeon(R) CPU E5-2670");
|
||||
expect(p.cpuCores).toBe(4);
|
||||
expect(p.memoryBytes).toBe(4194304 * 1024);
|
||||
expect(p.disks).toHaveLength(2);
|
||||
expect(p.disks[0]).toEqual({ name: "sda", sizeBytes: 34359738368 });
|
||||
});
|
||||
|
||||
it("recommande vm_guest_tools sur KVM", () => {
|
||||
const recs = buildRecommendations(parseProbe(ENRICHED));
|
||||
expect(recs.some((r) => r.profileId === "vm_guest_tools")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface ProbeResult {
|
||||
isRpi: boolean;
|
||||
gpus: string[];
|
||||
net: { iface: string; addr: string }[];
|
||||
cpuModel: string | null;
|
||||
cpuCores: number | null;
|
||||
memoryBytes: number | null;
|
||||
disks: { name: string; sizeBytes: number }[];
|
||||
}
|
||||
|
||||
export interface CorrectionProposal {
|
||||
@@ -30,6 +34,11 @@ export interface CorrectionProposal {
|
||||
virtualization: string;
|
||||
}
|
||||
|
||||
export interface ProfileRecommendation {
|
||||
profileId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function section(raw: string, start: string, end?: string): string {
|
||||
const i = raw.indexOf(start);
|
||||
if (i < 0) return "";
|
||||
@@ -51,7 +60,10 @@ export function parseProbe(raw: string): ProbeResult {
|
||||
const prox = section(raw, "===SU:PROBE_PROXMOX===", "===SU:PROBE_RPI===");
|
||||
const rpi = section(raw, "===SU:PROBE_RPI===", "===SU:PROBE_GPU===");
|
||||
const gpuBlock = section(raw, "===SU:PROBE_GPU===", "===SU:PROBE_NET===");
|
||||
const netBlock = section(raw, "===SU:PROBE_NET===", "===SU:EXIT=");
|
||||
const netBlock = section(raw, "===SU:PROBE_NET===", "===SU:PROBE_CPU===");
|
||||
const cpuBlock = section(raw, "===SU:PROBE_CPU===", "===SU:PROBE_MEM===");
|
||||
const memBlock = section(raw, "===SU:PROBE_MEM===", "===SU:PROBE_DISK===");
|
||||
const diskBlock = section(raw, "===SU:PROBE_DISK===", "===SU:EXIT=");
|
||||
|
||||
const gpus = gpuBlock
|
||||
.split("\n")
|
||||
@@ -66,6 +78,17 @@ export function parseProbe(raw: string): ProbeResult {
|
||||
}
|
||||
}
|
||||
|
||||
const cpuModelMatch = /^MODEL=(.+)$/m.exec(cpuBlock);
|
||||
const coresMatch = /^\s*(\d+)\s*$/m.exec(cpuBlock);
|
||||
const memMatch = /^MemTotal:\s+(\d+)\s*kB/m.exec(memBlock);
|
||||
|
||||
const disks: ProbeResult["disks"] = [];
|
||||
for (const line of diskBlock.split("\n")) {
|
||||
if (!line.startsWith("DISK\t")) continue;
|
||||
const [, name, size] = line.split("\t");
|
||||
if (name) disks.push({ name, sizeBytes: Number(size) || 0 });
|
||||
}
|
||||
|
||||
return {
|
||||
osId: osReleaseValue(os, "ID"),
|
||||
osVersion: osReleaseValue(os, "VERSION_ID"),
|
||||
@@ -77,9 +100,24 @@ export function parseProbe(raw: string): ProbeResult {
|
||||
isRpi: /RPI=1/.test(rpi),
|
||||
gpus,
|
||||
net,
|
||||
cpuModel: cpuModelMatch?.[1]?.trim() || null,
|
||||
cpuCores: coresMatch?.[1] ? Number(coresMatch[1]) : null,
|
||||
memoryBytes: memMatch?.[1] ? Number(memMatch[1]) * 1024 : null,
|
||||
disks,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recommandations de profils post-install déduites de la sonde. */
|
||||
export function buildRecommendations(p: ProbeResult): ProfileRecommendation[] {
|
||||
const recs: ProfileRecommendation[] = [];
|
||||
if (p.virt === "kvm" || p.virt === "qemu") {
|
||||
recs.push({ profileId: "vm_guest_tools", reason: "QEMU/KVM détecté → qemu-guest-agent" });
|
||||
} else if (p.virt === "vmware") {
|
||||
recs.push({ profileId: "vm_guest_tools", reason: "VMware détecté → open-vm-tools" });
|
||||
}
|
||||
return recs;
|
||||
}
|
||||
|
||||
const VM_VIRTS = new Set(["kvm", "qemu", "vmware", "oracle", "microsoft", "xen", "bochs", "parallels"]);
|
||||
const LXC_VIRTS = new Set(["lxc", "lxc-libvirt", "openvz", "systemd-nspawn", "docker", "podman"]);
|
||||
|
||||
@@ -112,6 +150,7 @@ export function proposeCorrections(p: ProbeResult): CorrectionProposal {
|
||||
export interface ProbeOutcome {
|
||||
probe: ProbeResult;
|
||||
proposal: CorrectionProposal;
|
||||
recommendations: ProfileRecommendation[];
|
||||
raw: string;
|
||||
changes: string[]; // diff entre l'actuel et la proposition (pour l'UI)
|
||||
}
|
||||
@@ -129,17 +168,18 @@ export async function runProbe(machineId: string, onData?: (c: string) => void):
|
||||
const proposal = proposeCorrections(probe);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const hwFields = {
|
||||
cpuModel: probe.cpuModel,
|
||||
cpuCores: probe.cpuCores,
|
||||
memoryBytes: probe.memoryBytes,
|
||||
disksJson: JSON.stringify(probe.disks),
|
||||
gpusJson: JSON.stringify(probe.gpus),
|
||||
networkJson: JSON.stringify(probe.net),
|
||||
updatedAt: now,
|
||||
};
|
||||
db.insert(schema.machineHardware)
|
||||
.values({
|
||||
machineId,
|
||||
gpusJson: JSON.stringify(probe.gpus),
|
||||
networkJson: JSON.stringify(probe.net),
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineHardware.machineId,
|
||||
set: { gpusJson: JSON.stringify(probe.gpus), networkJson: JSON.stringify(probe.net), updatedAt: now },
|
||||
})
|
||||
.values({ machineId, ...hwFields })
|
||||
.onConflictDoUpdate({ target: schema.machineHardware.machineId, set: hwFields })
|
||||
.run();
|
||||
|
||||
const changes: string[] = [];
|
||||
@@ -151,5 +191,5 @@ export async function runProbe(machineId: string, onData?: (c: string) => void):
|
||||
changes.push(`virtualization: ${m.virtualization ?? "—"} → ${proposal.virtualization}`);
|
||||
}
|
||||
|
||||
return { probe, proposal, raw, changes };
|
||||
return { probe, proposal, recommendations: buildRecommendations(probe), raw, changes };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
// server/services/machineState.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.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 {
|
||||
status: string;
|
||||
aptStatus: string;
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface CreateMachineInput {
|
||||
sudoPassword?: string | null;
|
||||
aptProxyMode?: AptProxyMode;
|
||||
aptProxyUrl?: string | null;
|
||||
osFamily?: OsFamily; // choix manuel ; sinon auto-détecté via os-release
|
||||
machineKind?: MachineKind;
|
||||
}
|
||||
|
||||
type MachineRow = typeof schema.machines.$inferSelect;
|
||||
@@ -39,6 +41,9 @@ function toView(m: MachineRow): MachineView {
|
||||
}
|
||||
|
||||
export interface UpdateMachineInput {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
osFamily?: OsFamily;
|
||||
machineKind?: MachineKind;
|
||||
virtualization?: string | null;
|
||||
@@ -46,11 +51,14 @@ export interface UpdateMachineInput {
|
||||
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 {
|
||||
const row = getMachineRow(id);
|
||||
if (!row) throw new Error("Machine introuvable");
|
||||
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.machineKind !== undefined) patch.machineKind = input.machineKind;
|
||||
if (input.virtualization !== undefined) patch.virtualization = input.virtualization;
|
||||
@@ -118,11 +126,11 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
|
||||
name: input.name,
|
||||
hostname: input.hostname,
|
||||
port: input.port,
|
||||
osFamily: os.family,
|
||||
osFamily: input.osFamily && input.osFamily !== "unknown" ? input.osFamily : os.family, // manuel prioritaire, "unknown" => auto
|
||||
osVersion: os.version || null,
|
||||
osCodename: null,
|
||||
arch: null,
|
||||
machineKind: null,
|
||||
machineKind: input.machineKind ?? null,
|
||||
virtualization: null,
|
||||
hardwareProfile: null,
|
||||
username: input.username,
|
||||
@@ -146,6 +154,26 @@ export function deleteMachine(id: string): void {
|
||||
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. */
|
||||
export function applyProxyToAllMachines(mode: AptProxyMode, url: string | null): number {
|
||||
const res = db
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { runScriptSudo } from "../ssh/client.js";
|
||||
import { buildAptSnapshotDetail } from "./aptParse.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
|
||||
import { extractImportantMessages, recordImportantMessages } from "./importantMessages.js";
|
||||
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
|
||||
|
||||
/** 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();
|
||||
|
||||
upsertMachineState(machineId, deriveAptState(snapshot));
|
||||
recordImportantMessages(machineId, extractImportantMessages(raw, "apt"), { snapshotId });
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "apt_refresh",
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -12,6 +12,21 @@ export interface TemplateVars {
|
||||
composeRoots?: string | number | null;
|
||||
composeScanDepth?: string | number | 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(
|
||||
|
||||
@@ -208,6 +208,23 @@ export interface RebootResult {
|
||||
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 {
|
||||
profilesRun: string[];
|
||||
variablesUsed: Record<string, string | number | boolean>;
|
||||
|
||||
@@ -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==="
|
||||
@@ -17,4 +17,11 @@ echo "===SU:PROBE_GPU==="
|
||||
command -v lspci >/dev/null 2>&1 && lspci 2>/dev/null | grep -Ei 'vga|3d|display' || echo "no-lspci"
|
||||
echo "===SU:PROBE_NET==="
|
||||
ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}'
|
||||
echo "===SU:PROBE_CPU==="
|
||||
LANG=C lscpu 2>/dev/null | grep -E '^Model name:' | sed 's/^Model name:[[:space:]]*/MODEL=/' || true
|
||||
nproc 2>/dev/null
|
||||
echo "===SU:PROBE_MEM==="
|
||||
grep -E '^MemTotal:' /proc/meminfo 2>/dev/null
|
||||
echo "===SU:PROBE_DISK==="
|
||||
lsblk -b -d -n -o NAME,TYPE,SIZE 2>/dev/null | awk '$2=="disk"{print "DISK\t"$1"\t"$3}'
|
||||
echo "===SU:EXIT=0==="
|
||||
|
||||
@@ -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==="
|
||||
@@ -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}==="
|
||||
@@ -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}==="
|
||||
@@ -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}==="
|
||||
@@ -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}==="
|
||||
Reference in New Issue
Block a user