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:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user