feat(ui): section Docker interactive sur la tuile machine (tâche 3)

Branche le frontend sur le backend Docker (SJ-4/5/6) :
- scan, configuration des racines Compose, liste stacks + services avec
  badges de statut (candidat/activé/maj dispo/à jour)
- activer/ignorer/désactiver un stack ; pull-check (non destructif)
- apply/down/prune via action_request + confirmation Popup (design system)
- toute action streamée auto-sélectionne la machine → flux visible dans le
  terminal de droite (outputHub rejoue le buffer)
- api client : docker settings/roots/scan/stacks/status + action-requests
- icônes trash/check, styles docker-* (variables CSS uniquement)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:09:07 +02:00
parent 2c15b8c06b
commit c79c3e5ccb
4 changed files with 420 additions and 13 deletions
+288 -11
View File
@@ -1,7 +1,8 @@
// client/src/features/machines/MachineTile.tsx
import { useState } from "react";
import type { MachineStatus, MachineView } from "@shared/types.js";
import { Button, Icon, IconButton, StatusLed } from "../../components/ui-kit.js";
import { useEffect, useState } from "react";
import type { ActionType, MachineStatus, MachineView } from "@shared/types.js";
import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js";
import { api, type DockerSettingsView, type DockerStackRow, type StackStatus } from "../../lib/api.js";
interface Props {
machine: MachineView;
@@ -114,7 +115,7 @@ export function MachineTile({
open={dockerOpen}
onToggle={() => setDockerOpen((value) => !value)}
/>
{dockerOpen && <DockerSection />}
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
<SectionToggle
icon="script"
@@ -161,20 +162,296 @@ function SectionToggle({
);
}
function DockerSection() {
interface ConfirmState {
action: ActionType;
stackId?: string;
aggressive?: boolean;
label: string;
detail: string;
}
function DockerSection({ machineId, onSelect }: { machineId: string; onSelect: (id: string) => void }) {
const [settings, setSettings] = useState<DockerSettingsView | null>(null);
const [stacks, setStacks] = useState<DockerStackRow[] | null>(null);
const [rootsInput, setRootsInput] = useState("");
const [showRoots, setShowRoots] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
async function load() {
try {
const [s, st] = await Promise.all([api.dockerSettings(machineId), api.dockerStacks(machineId)]);
setSettings(s);
setStacks(st);
if (s.roots.length) setRootsInput(s.roots.map((r) => r.path).join("\n"));
} catch (err) {
setMsg({ kind: "err", text: (err as Error).message });
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [machineId]);
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 saveRoots = () =>
withBusy("roots", async () => {
const paths = rootsInput.split("\n").map((s) => s.trim()).filter(Boolean);
if (!paths.length) throw new Error("Indique au moins une racine Compose.");
await api.dockerSetRoots(machineId, paths);
await load();
setShowRoots(false);
setMsg({ kind: "ok", text: "Racines enregistrées." });
});
const scan = () =>
withBusy("scan", async () => {
onSelect(machineId); // bascule le terminal de droite sur cette machine
await api.dockerScan(machineId);
setMsg({ kind: "ok", text: "Scan lancé… (voir le terminal de droite)" });
window.setTimeout(() => void load(), 6000);
});
const setStatus = (stackId: string, status: StackStatus) =>
withBusy(`stack:${stackId}`, async () => {
await api.setStackStatus(machineId, stackId, status);
await load();
});
const pullCheck = (stackId: string) =>
withBusy(`pull:${stackId}`, async () => {
onSelect(machineId); // bascule le terminal de droite sur cette machine
await api.runAction(machineId, "docker_pull_check", stackId);
setMsg({ kind: "ok", text: "Pull-check lancé… (voir le terminal de droite)" });
window.setTimeout(() => void load(), 8000);
});
const runConfirmed = () =>
withBusy("confirm", async () => {
if (!confirm) return;
onSelect(machineId); // bascule le terminal de droite sur cette machine
const req = await api.createActionRequest(machineId, {
action: confirm.action,
stackId: confirm.stackId,
aggressive: confirm.aggressive,
});
await api.approveActionRequest(req.id);
setConfirm(null);
setMsg({ kind: "ok", text: `${confirm.label} : demande approuvée et lancée.` });
window.setTimeout(() => void load(), 8000);
});
const enabledExists = (stacks ?? []).some((s) => s.status === "enabled");
const lastScan = settings?.settings?.lastScanAt;
return (
<div className="machine-section-body">
<div className="machine-section-row">
<span className="mono">Docker non scanné</span>
<Button icon="cog" size="sm" onClick={() => undefined}>Paramètres</Button>
</div>
<div className="machine-placeholder">
Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
<div className="docker-toolbar">
<Button icon="refresh" size="sm" onClick={busy ? undefined : scan}>
{busy === "scan" ? "Scan…" : "Scanner"}
</Button>
<Button icon="cog" size="sm" variant="ghost" onClick={() => setShowRoots((v) => !v)}>
Racines{settings?.roots.length ? ` (${settings.roots.length})` : ""}
</Button>
{enabledExists && (
<Button
icon="trash"
size="sm"
variant="danger"
onClick={() =>
setConfirm({
action: "docker_prune_images",
label: "Prune images",
detail: "Supprime les images Docker inutilisées (mode sûr : dangling uniquement) sur cette machine.",
})
}
>
Prune
</Button>
)}
{lastScan && <span className="docker-laststamp mono">scan {formatDate(lastScan)}</span>}
</div>
{showRoots && (
<div className="docker-roots">
<span className="label">Racines Compose (une par ligne)</span>
<textarea
className="su-field settings-textarea"
value={rootsInput}
onChange={(e) => setRootsInput(e.target.value)}
placeholder={"/home/gilles/docker\n/opt/stacks"}
/>
<Button icon="check" size="sm" variant="primary" onClick={busy ? undefined : saveRoots}>
{busy === "roots" ? "Enregistrement…" : "Enregistrer & activer"}
</Button>
</div>
)}
{stacks === null ? (
<div className="machine-placeholder">Chargement</div>
) : stacks.length === 0 ? (
<div className="machine-placeholder">
Aucun stack détecté. Déclare des racines Compose puis lance un scan.
</div>
) : (
<div className="docker-stacks">
{stacks.map((stack) => (
<DockerStackCard
key={stack.id}
stack={stack}
busy={busy}
onActivate={() => setStatus(stack.id, "enabled")}
onIgnore={() => setStatus(stack.id, "ignored")}
onDisable={() => setStatus(stack.id, "candidate")}
onPullCheck={() => pullCheck(stack.id)}
onApply={() =>
setConfirm({
action: "docker_compose_apply",
stackId: stack.id,
label: `Appliquer ${stack.name}`,
detail: `Recrée les conteneurs du stack « ${stack.name} » (docker compose up -d). Bref redémarrage du service.`,
})
}
onDown={() =>
setConfirm({
action: "docker_compose_down",
stackId: stack.id,
label: `Arrêter ${stack.name}`,
detail: `Arrête le stack « ${stack.name} » (docker compose down). Les volumes sont préservés.`,
})
}
/>
))}
</div>
)}
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
<Popup
open={confirm !== null}
onClose={() => setConfirm(null)}
title={confirm?.label ?? ""}
width={420}
footer={
<>
<Button variant="ghost" icon="close" onClick={() => setConfirm(null)}>Annuler</Button>
<Button variant="danger" icon="check" onClick={busy === "confirm" ? undefined : runConfirmed}>
{busy === "confirm" ? "Lancement…" : "Confirmer"}
</Button>
</>
}
>
<p>{confirm?.detail}</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 DockerStackCard({
stack,
busy,
onActivate,
onIgnore,
onDisable,
onPullCheck,
onApply,
onDown,
}: {
stack: DockerStackRow;
busy: string | null;
onActivate: () => void;
onIgnore: () => void;
onDisable: () => void;
onPullCheck: () => void;
onApply: () => void;
onDown: () => void;
}) {
const isEnabled = stack.status === "enabled";
const stackBusy = busy === `stack:${stack.id}`;
const pullBusy = busy === `pull:${stack.id}`;
return (
<div className="docker-stack">
<div className="docker-stack-head">
<span className="docker-stack-name">{stack.name}</span>
<DockerBadge status={stack.status} />
{stack.detectedBy && <span className="docker-stack-by mono">{stack.detectedBy}</span>}
</div>
<div className="docker-stack-actions">
{stack.status === "candidate" && (
<>
<Button icon="check" size="sm" variant="primary" onClick={stackBusy ? undefined : onActivate}>Activer</Button>
<Button icon="close" size="sm" variant="ghost" onClick={stackBusy ? undefined : onIgnore}>Ignorer</Button>
</>
)}
{isEnabled && (
<>
<Button icon="download" size="sm" onClick={pullBusy ? undefined : onPullCheck}>
{pullBusy ? "Pull…" : "Pull-check"}
</Button>
<Button icon="upgrade" size="sm" variant="primary" onClick={onApply}>Appliquer</Button>
<Button icon="power" size="sm" variant="danger" onClick={onDown}>Down</Button>
<Button icon="close" size="sm" variant="ghost" onClick={stackBusy ? undefined : onDisable}>Désactiver</Button>
</>
)}
</div>
{stack.services.length > 0 && (
<div className="docker-services">
{stack.services.map((svc) => (
<div key={svc.id} className="docker-service">
<span className="docker-service-name mono">{svc.imageRef ?? svc.serviceName}</span>
<DockerBadge status={svc.status ?? "unknown"} />
{svc.status === "updates_available" && (
<span className="docker-service-diff mono">
{shortId(svc.currentImageId)} {shortId(svc.candidateImageId)}
</span>
)}
</div>
))}
</div>
)}
</div>
);
}
function DockerBadge({ status }: { status: string }) {
const tone =
status === "updates_available" ? "warn" :
status === "up_to_date" || status === "enabled" ? "ok" :
status === "error" ? "err" :
status === "candidate" ? "info" : "off";
const label =
status === "updates_available" ? "maj dispo" :
status === "up_to_date" ? "à jour" :
status === "enabled" ? "activé" :
status === "candidate" ? "candidat" :
status === "ignored" ? "ignoré" :
status === "error" ? "erreur" : status;
return <span className={`docker-badge docker-badge-${tone}`}>{label}</span>;
}
function shortId(id: string | null): string {
if (!id) return "—";
const hex = id.startsWith("sha256:") ? id.slice(7) : id;
return hex.slice(0, 10);
}
function PostInstallSection() {
return (
<div className="machine-section-body">