-
- Docker non scanné
-
-
-
- Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
+
+
+
+ {enabledExists && (
+
+ )}
+ {lastScan && scan {formatDate(lastScan)}}
+
+ {showRoots && (
+
+ Racines Compose (une par ligne)
+
+ )}
+
+ {stacks === null ? (
+
Chargement…
+ ) : stacks.length === 0 ? (
+
+ Aucun stack détecté. Déclare des racines Compose puis lance un scan.
+
+ ) : (
+
+ {stacks.map((stack) => (
+ 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.`,
+ })
+ }
+ />
+ ))}
+
+ )}
+
+ {msg &&
{msg.text}
}
+
+
setConfirm(null)}
+ title={confirm?.label ?? ""}
+ width={420}
+ footer={
+ <>
+
+
+ >
+ }
+ >
+ {confirm?.detail}
+
+ Action tracée comme demande validée (action_request).
+
+
);
}
+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 (
+
+
+ {stack.name}
+
+ {stack.detectedBy && {stack.detectedBy}}
+
+
+
+ {stack.status === "candidate" && (
+ <>
+
+
+ >
+ )}
+ {isEnabled && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {stack.services.length > 0 && (
+
+ {stack.services.map((svc) => (
+
+ {svc.imageRef ?? svc.serviceName}
+
+ {svc.status === "updates_available" && (
+
+ {shortId(svc.currentImageId)} → {shortId(svc.candidateImageId)}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
+
+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
{label};
+}
+
+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 (
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index 87e7d25..8bcb00e 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -35,10 +35,34 @@ export const api = {
createMachine: (body: unknown) => req
("/machines", { method: "POST", body: JSON.stringify(body) }),
refresh: (id: string) => req(`/machines/${id}/refresh`, { method: "POST" }),
snapshot: (id: string) => req(`/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(`/machines/${id}/docker/settings`),
+ dockerSetRoots: (id: string, paths: string[], scanDepth = 4) =>
+ req(`/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(`/machines/${id}/docker/stacks`),
+ setStackStatus: (id: string, stackId: string, status: StackStatus) =>
+ req(`/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(`/machines/${id}/action-requests`, { method: "POST", body: JSON.stringify(body) }),
+ approveActionRequest: (reqId: string, approvedBy = "ui") =>
+ req(`/action-requests/${reqId}/approve`, { method: "POST", body: JSON.stringify({ approvedBy }) }),
+
// --- Sauvegarde / restauration de la base ---
dbInfo: () => req("/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;
+}
diff --git a/client/src/styles/app.css b/client/src/styles/app.css
index 52bddae..ae1ff1c 100644
--- a/client/src/styles/app.css
+++ b/client/src/styles/app.css
@@ -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) {