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 { useEffect, useState } from "react";
|
||||||
import type { ActionType, AptProxyMode, MachineStatus, MachineView } from "@shared/types.js";
|
import type { ActionType, AptProxyMode, MachineStatus, MachineView } from "@shared/types.js";
|
||||||
import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.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 {
|
interface Props {
|
||||||
machine: MachineView;
|
machine: MachineView;
|
||||||
@@ -145,7 +153,7 @@ export function MachineTile({
|
|||||||
open={postOpen}
|
open={postOpen}
|
||||||
onToggle={() => setPostOpen((value) => !value)}
|
onToggle={() => setPostOpen((value) => !value)}
|
||||||
/>
|
/>
|
||||||
{postOpen && <PostInstallSection />}
|
{postOpen && <PostInstallSection machine={machine} onSelect={onSelect} />}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -619,24 +627,190 @@ function shortId(id: string | null): string {
|
|||||||
return hex.slice(0, 10);
|
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 (
|
return (
|
||||||
<div className="machine-section-body">
|
<div className="machine-section-body">
|
||||||
<label className="machine-check-row">
|
<div className="pi-list">
|
||||||
<input type="checkbox" />
|
{profiles.map((p) => {
|
||||||
<span>Profil network tools</span>
|
const isOpen = open.has(p.id);
|
||||||
</label>
|
return (
|
||||||
<label className="machine-check-row">
|
<div key={p.id} className="pi-profile">
|
||||||
<input type="checkbox" />
|
<button className="pi-profile-head interactive" onClick={() => toggle(p.id)}>
|
||||||
<span>Profil partage Samba/NFS</span>
|
<Icon name={isOpen ? "chevD" : "chevR"} size={12} style={undefined} />
|
||||||
</label>
|
<span className="pi-profile-name">{p.label}</span>
|
||||||
<div className="machine-placeholder">
|
<span className={`docker-badge docker-badge-${riskTone(p.risk)}`}>{riskLabel(p.risk)}</span>
|
||||||
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
|
</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>
|
</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>
|
</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 {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) return "-";
|
if (!value) return "-";
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -761,7 +935,7 @@ export function MachineDetailPanel({
|
|||||||
<SectionToggle icon="docker" title="Docker" open={dockerOpen} onToggle={() => setDockerOpen((v) => !v)} />
|
<SectionToggle icon="docker" title="Docker" open={dockerOpen} onToggle={() => setDockerOpen((v) => !v)} />
|
||||||
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
|
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
|
||||||
<SectionToggle icon="script" title="Post-install" open={postOpen} onToggle={() => setPostOpen((v) => !v)} />
|
<SectionToggle icon="script" title="Post-install" open={postOpen} onToggle={() => setPostOpen((v) => !v)} />
|
||||||
{postOpen && <PostInstallSection />}
|
{postOpen && <PostInstallSection machine={machine} onSelect={onSelect} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{configOpen && (
|
{configOpen && (
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
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 ---
|
// --- Réglages globaux ---
|
||||||
getSettings: () => req<AppSettingsView>("/settings"),
|
getSettings: () => req<AppSettingsView>("/settings"),
|
||||||
setDefaultAptProxy: (body: DefaultAptProxy) =>
|
setDefaultAptProxy: (body: DefaultAptProxy) =>
|
||||||
@@ -118,6 +125,46 @@ export interface DbRestoreResult {
|
|||||||
message: string;
|
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 {
|
export interface DefaultAptProxy {
|
||||||
mode: AptProxyMode;
|
mode: AptProxyMode;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ body {
|
|||||||
.machine-info-k { color: var(--ink-3); font-size: 12px; }
|
.machine-info-k { color: var(--ink-3); font-size: 12px; }
|
||||||
.machine-info-v { color: var(--ink-1); font-size: 13px; text-align: right; }
|
.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 {
|
.machine-tile {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user