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
+2
View File
@@ -47,6 +47,8 @@ const ICON_MAP = {
download: 'download',
upload: 'upload',
database: 'database',
trash: 'trash',
check: 'check',
folder: 'folder',
docker: 'boxes-stacked',
package: 'box-open',
+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">
+69 -2
View File
@@ -35,10 +35,34 @@ export const api = {
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
snapshot: (id: string) => req<UpdateSnapshot>(`/machines/${id}/snapshot`),
runAction: (id: string, action: ActionType) =>
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
runAction: (id: string, action: ActionType, stackId?: string) =>
req<{ ok: boolean }>(`/machines/${id}/actions`, {
method: "POST",
body: JSON.stringify(stackId ? { action, stackId } : { action }),
}),
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
// --- Docker ---
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
dockerSetRoots: (id: string, paths: string[], scanDepth = 4) =>
req<DockerSettingsView>(`/machines/${id}/docker/roots`, {
method: "POST",
body: JSON.stringify({ paths, scanDepth }),
}),
dockerScan: (id: string) => req<{ ok: boolean }>(`/machines/${id}/docker/scan`, { method: "POST" }),
dockerStacks: (id: string) => req<DockerStackRow[]>(`/machines/${id}/docker/stacks`),
setStackStatus: (id: string, stackId: string, status: StackStatus) =>
req<DockerStackRow>(`/machines/${id}/docker/stacks/${stackId}`, {
method: "PATCH",
body: JSON.stringify({ status }),
}),
// --- Demandes d'action destructive ---
createActionRequest: (id: string, body: { action: ActionType; stackId?: string; aggressive?: boolean; summary?: string }) =>
req<ActionRequestRow>(`/machines/${id}/action-requests`, { method: "POST", body: JSON.stringify(body) }),
approveActionRequest: (reqId: string, approvedBy = "ui") =>
req<ActionRequestRow>(`/action-requests/${reqId}/approve`, { method: "POST", body: JSON.stringify({ approvedBy }) }),
// --- Sauvegarde / restauration de la base ---
dbInfo: () => req<DbInfo>("/system/db/info"),
/** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */
@@ -82,3 +106,46 @@ export interface DbRestoreResult {
safetyBackup: string;
message: string;
}
export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
export interface DockerSettingsView {
settings: { machineId: string; enabled: number; scanDepth: number; pruneMode: string; lastScanAt: string | null; lastPullCheckAt: string | null } | null;
roots: Array<{ id: string; path: string; enabled: number }>;
}
export interface DockerServiceRow {
id: string;
stackId: string;
serviceName: string;
imageRef: string | null;
currentImageId: string | null;
currentDigest: string | null;
candidateImageId: string | null;
candidateDigest: string | null;
versionLabel: string | null;
status: string | null;
}
export interface DockerStackRow {
id: string;
machineId: string;
name: string;
workingDir: string;
status: StackStatus;
detectedBy: string | null;
lastScanAt: string | null;
lastUpdateAt: string | null;
composeFiles: string[];
services: DockerServiceRow[];
}
export interface ActionRequestRow {
id: string;
machineId: string;
action: ActionType;
risk: string | null;
status: "pending" | "approved" | "rejected" | "executed" | "expired";
summary: string | null;
executionId: string | null;
}
+61
View File
@@ -158,6 +158,67 @@ body {
.machine-section-row { justify-content: space-between; gap: 8px; }
.machine-placeholder { color: var(--ink-3); font-size: 12px; line-height: 1.35; }
.machine-check-row { gap: 8px; color: var(--ink-2); font-size: 13px; }
/* --- Docker section --- */
.docker-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.docker-laststamp { color: var(--ink-3); font-size: 11px; margin-left: auto; }
.docker-roots {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-2);
}
.docker-roots .settings-textarea { min-height: 60px; }
.docker-stacks { display: flex; flex-direction: column; gap: 8px; }
.docker-stack {
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-2);
}
.docker-stack-head { display: flex; align-items: center; gap: 8px; }
.docker-stack-name { font-weight: 600; color: var(--ink-1); font-size: 13px; }
.docker-stack-by { color: var(--ink-3); font-size: 11px; margin-left: auto; }
.docker-stack-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.docker-services {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-1);
}
.docker-service { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.docker-service-name { color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docker-service-diff { color: var(--ink-3); font-size: 11px; margin-left: auto; }
.docker-badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--border-2);
white-space: nowrap;
}
.docker-badge-ok { color: var(--ok); border-color: var(--ok); }
.docker-badge-warn { color: var(--warn); border-color: var(--warn); }
.docker-badge-err { color: var(--err); border-color: var(--err); }
.docker-badge-info { color: var(--accent); border-color: var(--accent-soft); }
.docker-badge-off { color: var(--ink-3); }
.docker-msg { font-size: 12px; margin: 4px 0 0; }
.docker-msg-ok { color: var(--ok); }
.docker-msg-err { color: var(--err); }
.docker-confirm-note { display: flex; align-items: center; gap: 7px; color: var(--ink-3); font-size: 12px; margin-top: 10px; }
.machine-check-row input { accent-color: var(--accent); }
@media (max-width: 1180px) {