From 4eb03359007e4b03c1fac1fb0f7141b402158ce5 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sat, 6 Jun 2026 10:57:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20section=20post-install=20interactiv?= =?UTF-8?q?e=20(profils=20+=20preview)=20(t=C3=A2che=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branche le frontend sur le moteur post-install (SJ-8) : - liste des profils (badge de risque), dépliage → champs de formulaire typés (text/select/bool/secret), pré-remplis depuis defaults + utilisateur SSH - Preview (script rendu, secrets masqués) en Popup - Exécuter : profils sûrs en direct, profils à risque (identity_network) via confirmation Popup → action_request approuvé ; auto-sélection machine → flux visible dans le terminal - api client : getProfiles / previewProfile / runProfile + types Co-Authored-By: Claude Opus 4.8 --- client/src/features/machines/MachineTile.tsx | 202 +++++++++++++++++-- client/src/lib/api.ts | 47 +++++ client/src/styles/app.css | 17 ++ 3 files changed, 252 insertions(+), 14 deletions(-) diff --git a/client/src/features/machines/MachineTile.tsx b/client/src/features/machines/MachineTile.tsx index 55e9bf1..0167e6d 100644 --- a/client/src/features/machines/MachineTile.tsx +++ b/client/src/features/machines/MachineTile.tsx @@ -2,7 +2,15 @@ import { useEffect, useState } from "react"; import type { ActionType, AptProxyMode, 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 ProbeResultView, + type ProfileManifestView, + type ProfileValues, + type StackStatus, +} from "../../lib/api.js"; interface Props { machine: MachineView; @@ -145,7 +153,7 @@ export function MachineTile({ open={postOpen} onToggle={() => setPostOpen((value) => !value)} /> - {postOpen && } + {postOpen && } ); @@ -619,24 +627,190 @@ 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(null); + const [open, setOpen] = useState>(new Set()); + const [values, setValues] = useState>({}); + const [busy, setBusy] = useState(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(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 = {}; + 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) { + 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
Chargement des profils…
; + return (
- - -
- Les champs dynamiques seront dépliés ici selon les profils sélectionnés. +
+ {profiles.map((p) => { + const isOpen = open.has(p.id); + return ( +
+ + {isOpen && ( +
+

{p.description}

+ {p.fields.map((f) => ( + + ))} +
+ + +
+
+ )} +
+ ); + })}
+ + {msg &&

{msg.text}

} + + setPreview(null)} title={`Preview — ${preview?.title ?? ""}`} width={560} footer={}> +
{preview?.script}
+
+ + setConfirm(null)} + title={`Confirmer — ${confirm?.label ?? ""}`} + width={440} + footer={ + <> + + + + } + > +

Le profil {confirm?.label} ({riskLabel(confirm?.risk ?? "")}) sera exécuté sur {machine.name}.

+

Action tracée comme demande validée (action_request).

+
); } +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 ( + + onChange(e.target.checked)} /> + + ); + } + if (field.type === "select" && field.options?.length) { + return ( + + ); + } + return ( + onChange(e.target.value)} + /> + ); +} + function formatDate(value: string | null): string { if (!value) return "-"; const date = new Date(value); @@ -761,7 +935,7 @@ export function MachineDetailPanel({ setDockerOpen((v) => !v)} /> {dockerOpen && } setPostOpen((v) => !v)} /> - {postOpen && } + {postOpen && }
{configOpen && ( diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index a74ca12..467b22d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -42,6 +42,13 @@ export const api = { }), deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }), + // --- Post-install (profils) --- + getProfiles: () => req("/profiles"), + previewProfile: (id: string, profileId: string, values: ProfileValues) => + req(`/machines/${id}/profiles/${profileId}/preview`, { method: "POST", body: JSON.stringify({ values }) }), + runProfile: (id: string, profileId: string, values: ProfileValues) => + req(`/machines/${id}/profiles/${profileId}/run`, { method: "POST", body: JSON.stringify({ values }) }), + // --- Réglages globaux --- getSettings: () => req("/settings"), setDefaultAptProxy: (body: DefaultAptProxy) => @@ -118,6 +125,46 @@ export interface DbRestoreResult { message: string; } +export type ProfileValues = Record; + +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; diff --git a/client/src/styles/app.css b/client/src/styles/app.css index 569c0bb..c3748a5 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -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;