feat(ui): section post-install interactive (profils + preview) (tâche 3)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:57:44 +02:00
parent e6f4ae470b
commit 4eb0335900
3 changed files with 252 additions and 14 deletions
+188 -14
View File
@@ -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 && <PostInstallSection />}
{postOpen && <PostInstallSection machine={machine} onSelect={onSelect} />}
</div>
</article>
);
@@ -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<ProfileManifestView[] | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const [values, setValues] = useState<Record<string, ProfileValues>>({});
const [busy, setBusy] = useState<string | null>(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<ProfileManifestView | null>(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<string, ProfileValues> = {};
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<void>) {
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 <div className="machine-section-body"><span className="machine-placeholder">Chargement des profils</span></div>;
return (
<div className="machine-section-body">
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil network tools</span>
</label>
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil partage Samba/NFS</span>
</label>
<div className="machine-placeholder">
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
<div className="pi-list">
{profiles.map((p) => {
const isOpen = open.has(p.id);
return (
<div key={p.id} className="pi-profile">
<button className="pi-profile-head interactive" onClick={() => toggle(p.id)}>
<Icon name={isOpen ? "chevD" : "chevR"} size={12} style={undefined} />
<span className="pi-profile-name">{p.label}</span>
<span className={`docker-badge docker-badge-${riskTone(p.risk)}`}>{riskLabel(p.risk)}</span>
</button>
{isOpen && (
<div className="pi-profile-body">
<p className="pi-desc">{p.description}</p>
{p.fields.map((f) => (
<label key={f.name} className="pi-field">
<span className="label">{f.label ?? f.name}{f.required ? " *" : ""}</span>
<ProfileFieldInput field={f} value={values[p.id]?.[f.name]} onChange={(v) => setField(p.id, f.name, v)} />
</label>
))}
<div className="pi-actions">
<Button icon="logs" size="sm" variant="ghost" onClick={busy ? undefined : () => doPreview(p)}>
{busy === `prev:${p.id}` ? "…" : "Preview"}
</Button>
<Button icon="play" size="sm" variant={p.requiresConfirmation ? "danger" : "primary"} onClick={busy ? undefined : () => onRun(p)}>
{busy === `run:${p.id}` ? "Lancement…" : "Exécuter"}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
<Popup open={preview !== null} onClose={() => setPreview(null)} title={`Preview — ${preview?.title ?? ""}`} width={560} footer={<Button icon="close" variant="ghost" onClick={() => setPreview(null)}>Fermer</Button>}>
<pre className="pi-preview mono">{preview?.script}</pre>
</Popup>
<Popup
open={confirm !== null}
onClose={() => setConfirm(null)}
title={`Confirmer — ${confirm?.label ?? ""}`}
width={440}
footer={
<>
<Button icon="close" variant="ghost" onClick={() => setConfirm(null)}>Annuler</Button>
<Button icon="play" variant="danger" onClick={busy ? undefined : () => { const p = confirm; setConfirm(null); if (p) void withBusy(`run:${p.id}`, () => exec(p)); }}>
{busy ? "Lancement…" : "Confirmer & exécuter"}
</Button>
</>
}
>
<p>Le profil <strong>{confirm?.label}</strong> ({riskLabel(confirm?.risk ?? "")}) sera exécuté sur <span className="mono">{machine.name}</span>.</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 ProfileFieldInput({
field,
value,
onChange,
}: {
field: ProfileManifestView["fields"][number];
value: string | number | boolean | undefined;
onChange: (v: string | number | boolean) => void;
}) {
if (field.type === "bool") {
return (
<span className="pi-bool">
<input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
</span>
);
}
if (field.type === "select" && field.options?.length) {
return (
<select className="su-field" value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
<option value=""></option>
{field.options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
return (
<input
className="su-field"
type={field.type === "secret" ? "password" : "text"}
value={String(value ?? "")}
placeholder={field.label ?? field.name}
onChange={(e) => 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({
<SectionToggle icon="docker" title="Docker" open={dockerOpen} onToggle={() => setDockerOpen((v) => !v)} />
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
<SectionToggle icon="script" title="Post-install" open={postOpen} onToggle={() => setPostOpen((v) => !v)} />
{postOpen && <PostInstallSection />}
{postOpen && <PostInstallSection machine={machine} onSelect={onSelect} />}
</div>
{configOpen && (
+47
View File
@@ -42,6 +42,13 @@ export const api = {
}),
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
// --- Post-install (profils) ---
getProfiles: () => req<ProfileManifestView[]>("/profiles"),
previewProfile: (id: string, profileId: string, values: ProfileValues) =>
req<ProfilePreview>(`/machines/${id}/profiles/${profileId}/preview`, { method: "POST", body: JSON.stringify({ values }) }),
runProfile: (id: string, profileId: string, values: ProfileValues) =>
req<RunProfileResult>(`/machines/${id}/profiles/${profileId}/run`, { method: "POST", body: JSON.stringify({ values }) }),
// --- Réglages globaux ---
getSettings: () => req<AppSettingsView>("/settings"),
setDefaultAptProxy: (body: DefaultAptProxy) =>
@@ -118,6 +125,46 @@ export interface DbRestoreResult {
message: string;
}
export type ProfileValues = Record<string, string | number | boolean>;
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;
+17
View File
@@ -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;