feat(ui): config machine (sonde+proxy), mode Listing, défaut apt-cacher-ng
- popup Profil sur la tuile : sonde machine → propositions os_family/ machine_kind/virtualization avec Appliquer ; proxy APT (mode + url) + appliquer persistant - mode d'affichage Tuiles/Liste : toggle + bouton Ajouter déplacés dans le header de page ; vue Liste = liste compacte + panneau détail « Machine view » (sections Docker/Post-install dépliées ; pliées en mode tuile) - Popup rendu via portail document.body (position fixed, z-index 1000) : passe au premier plan, échappe au backdrop-filter des tuiles - Paramètres : onglet Proxy APT (défaut apt-cacher-ng + appliquer à toutes les machines) ; AddMachineModal pré-remplit le proxy par défaut - api client : settings, updateMachine, probe ; icônes network/grid/list Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// client/src/features/machines/AddMachineModal.tsx
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DefaultAptProxy } from "../../lib/api.js";
|
||||
import { api } from "../../lib/api.js";
|
||||
|
||||
interface Props { onClose: () => void; onCreated: () => void; }
|
||||
@@ -8,12 +9,31 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
|
||||
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [proxyDefault, setProxyDefault] = useState<DefaultAptProxy | null>(null);
|
||||
const [useProxy, setUseProxy] = useState(false);
|
||||
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await api.getSettings();
|
||||
if (s.defaultAptProxy.url) {
|
||||
setProxyDefault(s.defaultAptProxy);
|
||||
setUseProxy(true);
|
||||
}
|
||||
} catch {
|
||||
/* pas de défaut configuré */
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null });
|
||||
const proxy = useProxy && proxyDefault?.url
|
||||
? { aptProxyMode: proxyDefault.mode === "direct" ? "runtime" : proxyDefault.mode, aptProxyUrl: proxyDefault.url }
|
||||
: {};
|
||||
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null, ...proxy });
|
||||
onCreated(); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
}
|
||||
@@ -28,6 +48,12 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
|
||||
<input placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
|
||||
<input placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
|
||||
<input placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
|
||||
{proxyDefault?.url && (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--ink-2)" }}>
|
||||
<input type="checkbox" checked={useProxy} onChange={(e) => setUseProxy(e.target.checked)} />
|
||||
<span>Proxy APT par défaut <span className="mono">{proxyDefault.url}</span></span>
|
||||
</label>
|
||||
)}
|
||||
{error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>}
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose}>Annuler</button>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// client/src/features/machines/MachineTile.tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ActionType, 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 { api, type DockerSettingsView, type DockerStackRow, type StackStatus } from "../../lib/api.js";
|
||||
import { api, type DockerSettingsView, type DockerStackRow, type ProbeResultView, type StackStatus } from "../../lib/api.js";
|
||||
|
||||
interface Props {
|
||||
machine: MachineView;
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
onRefresh: (id: string) => void;
|
||||
onUpgrade: (id: string) => void;
|
||||
onReboot: (id: string) => void;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
|
||||
@@ -36,9 +37,11 @@ export function MachineTile({
|
||||
onRefresh,
|
||||
onUpgrade,
|
||||
onReboot,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [dockerOpen, setDockerOpen] = useState(false);
|
||||
const [postOpen, setPostOpen] = useState(false);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const expanded = dockerOpen || postOpen;
|
||||
const isError = machine.status === "error" || machine.status === "unknown";
|
||||
|
||||
@@ -106,8 +109,27 @@ export function MachineTile({
|
||||
primary={false}
|
||||
onClick={() => onSelect(machine.id)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="cog"
|
||||
label="Profil & proxy (sonde)"
|
||||
active={false}
|
||||
danger={false}
|
||||
primary={false}
|
||||
onClick={() => setConfigOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{configOpen && (
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<MachineConfigPopup
|
||||
machine={machine}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
onSelect={onSelect}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="machine-sections" onClick={(event) => event.stopPropagation()}>
|
||||
<SectionToggle
|
||||
icon="docker"
|
||||
@@ -162,6 +184,151 @@ function SectionToggle({
|
||||
);
|
||||
}
|
||||
|
||||
function MachineConfigPopup({
|
||||
machine,
|
||||
onClose,
|
||||
onSelect,
|
||||
onChanged,
|
||||
}: {
|
||||
machine: MachineView;
|
||||
onClose: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [probe, setProbe] = useState<ProbeResultView | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
const [proxyMode, setProxyMode] = useState<AptProxyMode>(machine.aptProxyMode);
|
||||
const [proxyUrl, setProxyUrl] = useState(machine.aptProxyUrl ?? "");
|
||||
|
||||
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 sonder = () =>
|
||||
withBusy("probe", async () => {
|
||||
onSelect(machine.id);
|
||||
setProbe(await api.probe(machine.id));
|
||||
});
|
||||
|
||||
const applyCorrection = () =>
|
||||
withBusy("apply", async () => {
|
||||
if (!probe) return;
|
||||
await api.updateMachine(machine.id, {
|
||||
osFamily: probe.proposal.osFamily,
|
||||
machineKind: probe.proposal.machineKind,
|
||||
virtualization: probe.proposal.virtualization,
|
||||
});
|
||||
onChanged?.();
|
||||
setMsg({ kind: "ok", text: "Correction appliquée au profil." });
|
||||
});
|
||||
|
||||
const saveProxy = () =>
|
||||
withBusy("proxy", async () => {
|
||||
await api.updateMachine(machine.id, { aptProxyMode: proxyMode, aptProxyUrl: proxyUrl.trim() || null });
|
||||
onChanged?.();
|
||||
setMsg({ kind: "ok", text: "Proxy enregistré." });
|
||||
});
|
||||
|
||||
const applyPersistent = () =>
|
||||
withBusy("proxyapply", async () => {
|
||||
onSelect(machine.id);
|
||||
await api.updateMachine(machine.id, { aptProxyMode: "persistent", aptProxyUrl: proxyUrl.trim() || null });
|
||||
setProxyMode("persistent");
|
||||
onChanged?.();
|
||||
await api.runAction(machine.id, "apt_proxy_persistent");
|
||||
setMsg({ kind: "ok", text: "Proxy persistant appliqué sur la machine (voir terminal de droite)." });
|
||||
});
|
||||
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Profil — ${machine.name}`}
|
||||
width={460}
|
||||
footer={<Button icon="close" variant="ghost" onClick={onClose}>Fermer</Button>}
|
||||
>
|
||||
<div className="cfg">
|
||||
<div className="cfg-current">
|
||||
<span className="label">Profil actuel</span>
|
||||
<span className="mono">
|
||||
os={machine.osFamily} · kind={machine.machineKind ?? "?"} · virt={machine.virtualization ?? "?"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="cfg-block">
|
||||
<div className="cfg-block-head">
|
||||
<span className="label">Sonde machine (lecture seule)</span>
|
||||
<Button icon="search" size="sm" variant="primary" onClick={busy ? undefined : sonder}>
|
||||
{busy === "probe" ? "Sonde…" : "Sonder"}
|
||||
</Button>
|
||||
</div>
|
||||
{probe && (
|
||||
<div className="cfg-probe">
|
||||
<div className="mono cfg-facts">
|
||||
os={probe.probe.osId} {probe.probe.osVersion} · arch={probe.probe.arch} · virt={probe.probe.virt}
|
||||
{probe.probe.isProxmox ? " · proxmox" : ""}
|
||||
{probe.probe.isRpi ? " · rpi" : ""}
|
||||
</div>
|
||||
<div className="cfg-proposal mono">
|
||||
proposition : os_family={probe.proposal.osFamily} · machine_kind={probe.proposal.machineKind} · virt=
|
||||
{probe.proposal.virtualization}
|
||||
</div>
|
||||
{probe.changes.length ? (
|
||||
<>
|
||||
<ul className="cfg-changes">
|
||||
{probe.changes.map((c, i) => (
|
||||
<li key={i} className="mono">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button icon="check" size="sm" variant="primary" onClick={busy ? undefined : applyCorrection}>
|
||||
{busy === "apply" ? "Application…" : "Appliquer la correction"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="cfg-nochange">Aucune correction : le profil correspond déjà.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cfg-block">
|
||||
<span className="label">Proxy APT</span>
|
||||
<select className="su-field" value={proxyMode} onChange={(e) => setProxyMode(e.target.value as AptProxyMode)}>
|
||||
<option value="direct">Direct (aucun proxy)</option>
|
||||
<option value="runtime">Runtime (le temps d'une exécution)</option>
|
||||
<option value="persistent">Persistant (/etc/apt/apt.conf.d/01proxy)</option>
|
||||
</select>
|
||||
<input
|
||||
className="su-field"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
placeholder="http://10.0.3.100:3142"
|
||||
/>
|
||||
<div className="cfg-actions">
|
||||
<Button icon="check" size="sm" onClick={busy ? undefined : saveProxy}>
|
||||
{busy === "proxy" ? "…" : "Enregistrer"}
|
||||
</Button>
|
||||
<Button icon="upgrade" size="sm" variant="primary" onClick={busy ? undefined : applyPersistent}>
|
||||
{busy === "proxyapply" ? "Application…" : "Appliquer persistant"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
action: ActionType;
|
||||
stackId?: string;
|
||||
@@ -481,3 +648,125 @@ function formatDate(value: string | null): string {
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mode Listing : ligne compacte + panneau détail "Machine view" ---
|
||||
|
||||
export function MachineRow({
|
||||
machine,
|
||||
packageCount,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
machine: MachineView;
|
||||
packageCount: number;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className={`machine-row interactive ${selected ? "active" : ""}`} onClick={onClick}>
|
||||
<StatusLed status={STATUS_LED[machine.status]} size={9} pulse={machine.status === "running"} />
|
||||
<span className="machine-row-name">{machine.name}</span>
|
||||
<span className="machine-row-ip mono">{machine.hostname}:{machine.port}</span>
|
||||
<span className="machine-row-os">
|
||||
<Icon name="package" size={12} style={undefined} />
|
||||
{machine.osFamily}
|
||||
</span>
|
||||
<span className="machine-row-cell">
|
||||
<span className="label">updates</span>
|
||||
<b className={packageCount > 0 ? "machine-metric-warn" : "machine-metric-ok"}>{packageCount}</b>
|
||||
</span>
|
||||
<span className="machine-row-cell">
|
||||
<span className="label">check</span>
|
||||
<span className="mono">{formatDate(machine.lastCheckedAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ k, v, mono, tone }: { k: string; v: string; mono?: boolean; tone?: "ok" | "warn" }) {
|
||||
return (
|
||||
<div className="machine-info-row">
|
||||
<span className="machine-info-k">{k}</span>
|
||||
<span className={`machine-info-v ${mono ? "mono" : ""} ${tone === "warn" ? "machine-metric-warn" : tone === "ok" ? "machine-metric-ok" : ""}`}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MachineDetailPanel({
|
||||
machine,
|
||||
packageCount,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
onUpgrade,
|
||||
onReboot,
|
||||
onChanged,
|
||||
}: {
|
||||
machine: MachineView;
|
||||
packageCount: number;
|
||||
onSelect: (id: string) => void;
|
||||
onRefresh: (id: string) => void;
|
||||
onUpgrade: (id: string) => void;
|
||||
onReboot: (id: string) => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
// Mode liste : sections dépliées par défaut (inverse du mode tuile).
|
||||
const [dockerOpen, setDockerOpen] = useState(true);
|
||||
const [postOpen, setPostOpen] = useState(true);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const isError = machine.status === "error" || machine.status === "unknown";
|
||||
|
||||
return (
|
||||
<section className="machine-detail glass">
|
||||
<header className="machine-detail-head">
|
||||
<div className="machine-title-row">
|
||||
<StatusLed status={STATUS_LED[machine.status]} size={11} pulse={machine.status === "running"} />
|
||||
<div className="machine-title-text">
|
||||
<strong>{machine.name}</strong>
|
||||
<span className="mono">{machine.hostname}:{machine.port} · {machine.osFamily}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`machine-status-pill ${isError ? "machine-status-danger" : ""}`}>{STATUS_TEXT[machine.status]}</span>
|
||||
</header>
|
||||
|
||||
<div className="machine-actions">
|
||||
<IconButton icon="refresh" label="Update + analyse" active={false} danger={false} primary={false} onClick={() => onRefresh(machine.id)} />
|
||||
<IconButton icon="upgrade" label="Upgrade système" active={false} danger={false} primary={packageCount > 0} onClick={() => onUpgrade(machine.id)} />
|
||||
<IconButton icon="power" label="Reboot" active={false} danger primary={false} onClick={() => onReboot(machine.id)} />
|
||||
<IconButton icon="terminal" label="Ouvrir les logs machine" active={false} danger={false} primary={false} onClick={() => onSelect(machine.id)} />
|
||||
<IconButton icon="cog" label="Profil & proxy (sonde)" active={false} danger={false} primary={false} onClick={() => setConfigOpen(true)} />
|
||||
</div>
|
||||
|
||||
<div className="machine-detail-cards">
|
||||
<div className="machine-detail-card">
|
||||
<span className="label">System info</span>
|
||||
<InfoRow k="Hostname" v={machine.hostname} mono />
|
||||
<InfoRow k="Port SSH" v={String(machine.port)} mono />
|
||||
<InfoRow k="OS" v={machine.osFamily} />
|
||||
<InfoRow k="Type" v={machine.machineKind ?? "—"} />
|
||||
<InfoRow k="Virtualisation" v={machine.virtualization ?? "—"} />
|
||||
<InfoRow k="Utilisateur" v={machine.username} mono />
|
||||
<InfoRow k="Proxy APT" v={machine.aptProxyMode} />
|
||||
</div>
|
||||
<div className="machine-detail-card">
|
||||
<span className="label">Update status</span>
|
||||
<InfoRow k="Statut" v={STATUS_TEXT[machine.status]} tone={isError ? "warn" : "ok"} />
|
||||
<InfoRow k="Updates" v={String(packageCount)} tone={packageCount > 0 ? "warn" : "ok"} />
|
||||
<InfoRow k="Dernier check" v={formatDate(machine.lastCheckedAt)} mono />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="machine-sections">
|
||||
<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 />}
|
||||
</div>
|
||||
|
||||
{configOpen && (
|
||||
<MachineConfigPopup machine={machine} onClose={() => setConfigOpen(false)} onSelect={onSelect} onChanged={onChanged} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user