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:
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user