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:
2026-06-06 07:53:57 +02:00
parent 2b684da9cd
commit faa654c95a
8 changed files with 588 additions and 25 deletions
+45 -15
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import type { MachineView } from "@shared/types.js";
import { api } from "../lib/api.js";
import { MachineTile } from "../features/machines/MachineTile.js";
import { MachineTile, MachineRow, MachineDetailPanel } from "../features/machines/MachineTile.js";
import { AddMachineModal } from "../features/machines/AddMachineModal.js";
import { sumUpdates } from "../lib/stats.js";
@@ -13,15 +13,20 @@ export interface DashboardSummary {
running: number;
}
export type ViewMode = "grid" | "list";
interface Props {
selectedId?: string | null;
onSelect: (id: string) => void;
onSummaryChange?: (summary: DashboardSummary) => void;
view: ViewMode;
adding: boolean;
onAddingChange: (open: boolean) => void;
}
export function Dashboard({ onSelect, onSummaryChange }: Props) {
export function Dashboard({ selectedId, onSelect, onSummaryChange, view, adding, onAddingChange }: Props) {
const [machines, setMachines] = useState<MachineView[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -56,6 +61,11 @@ export function Dashboard({ onSelect, onSummaryChange }: Props) {
onSummaryChange?.(summary);
}, [onSummaryChange, summary]);
const onRefresh = (id: string) => { onSelect(id); void api.refresh(id).then(load); };
const onUpgrade = (id: string) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); };
const onReboot = (id: string) => { onSelect(id); void api.runAction(id, "reboot"); };
const detail = machines.find((m) => m.id === selectedId) ?? machines[0] ?? null;
return (
<main className="su-center">
<div className="su-dashboard-head">
@@ -63,22 +73,42 @@ export function Dashboard({ onSelect, onSummaryChange }: Props) {
<h2>Machines</h2>
<p>{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs</p>
</div>
<button className="interactive su-add-button" onClick={() => setAdding(true)}>+ Ajouter</button>
</div>
{error && <p style={{ color: "var(--err)" }}>{error}</p>}
{!error && loading && <p style={{ color: "var(--ink-3)" }}>Chargement des machines</p>}
{!error && !loading && machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
onRefresh={(id) => { onSelect(id); void api.refresh(id).then(load); }}
onUpgrade={(id) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); }}
onReboot={(id) => { onSelect(id); void api.runAction(id, "reboot"); }}
/>
))}
</div>
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
{view === "grid" ? (
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
onRefresh={onRefresh} onUpgrade={onUpgrade} onReboot={onReboot} onChanged={load}
/>
))}
</div>
) : (
machines.length > 0 && (
<div className="machine-listing">
<div className="machine-list">
{machines.map((m) => (
<MachineRow
key={m.id} machine={m} packageCount={counts[m.id] ?? 0}
selected={detail?.id === m.id} onClick={() => onSelect(m.id)}
/>
))}
</div>
{detail && (
<MachineDetailPanel
machine={detail} packageCount={counts[detail.id] ?? 0} onSelect={onSelect}
onRefresh={onRefresh} onUpgrade={onUpgrade} onReboot={onReboot} onChanged={load}
/>
)}
</div>
)
)}
{adding && <AddMachineModal onClose={() => onAddingChange(false)} onCreated={load} />}
</main>
);
}
+84
View File
@@ -1,5 +1,6 @@
// client/src/panels/SettingsModal.tsx
import { useEffect, useRef, useState } from "react";
import type { AptProxyMode } from "@shared/types.js";
import { Icon, Popup, Button } from "../components/ui-kit.js";
import { api, type DbInfo } from "../lib/api.js";
@@ -12,6 +13,7 @@ type SettingsTab =
| "appearance"
| "tiles"
| "layout"
| "proxy"
| "docker"
| "scripts"
| "hermes"
@@ -23,6 +25,7 @@ const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
{ id: "appearance", label: "Apparence", icon: "cog" },
{ id: "tiles", label: "Tuiles", icon: "grid" },
{ id: "layout", label: "Volets", icon: "collapse" },
{ id: "proxy", label: "Proxy APT", icon: "network" },
{ id: "docker", label: "Docker", icon: "docker" },
{ id: "scripts", label: "Scripts", icon: "script" },
{ id: "hermes", label: "Hermes", icon: "node" },
@@ -67,6 +70,7 @@ export function SettingsModal({ open, onClose }: Props) {
{active === "appearance" && <AppearanceSettings />}
{active === "tiles" && <TileSettings />}
{active === "layout" && <LayoutSettings />}
{active === "proxy" && <ProxyDefaultSettings />}
{active === "docker" && <DockerSettings />}
{active === "scripts" && <ScriptsSettings />}
{active === "hermes" && <HermesSettings />}
@@ -235,6 +239,86 @@ function RetentionSettings() {
);
}
function ProxyDefaultSettings() {
const [mode, setMode] = useState<AptProxyMode>("direct");
const [url, setUrl] = useState("");
const [busy, setBusy] = useState<null | "save" | "apply">(null);
const [msg, setMsg] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
useEffect(() => {
void (async () => {
try {
const s = await api.getSettings();
setMode(s.defaultAptProxy.mode);
setUrl(s.defaultAptProxy.url ?? "");
} catch {
/* défaut direct */
}
})();
}, []);
async function save() {
setBusy("save");
setMsg(null);
try {
await api.setDefaultAptProxy({ mode, url: url.trim() || null });
setMsg({ kind: "ok", text: "Proxy par défaut enregistré." });
} catch (err) {
setMsg({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
async function applyAll() {
setBusy("apply");
setMsg(null);
try {
await api.setDefaultAptProxy({ mode, url: url.trim() || null });
const res = await api.applyProxyToAll();
setMsg({ kind: "ok", text: `Appliqué à ${res.updated} machine(s).` });
} catch (err) {
setMsg({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
return (
<SettingsSection title="Proxy APT par défaut (apt-cacher-ng)">
<div className="settings-fields">
<Field label="Mode par défaut">
<select className="su-field" value={mode} onChange={(e) => setMode(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>
</Field>
<Field label="URL apt-cacher-ng">
<input className="su-field" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://10.0.3.100:3142" />
</Field>
</div>
<div className="settings-actions">
<Button icon="check" variant="primary" onClick={busy ? undefined : save}>
{busy === "save" ? "Enregistrement…" : "Enregistrer le défaut"}
</Button>
<Button icon="network" variant="default" onClick={busy ? undefined : applyAll}>
{busy === "apply" ? "Application…" : "Appliquer à toutes les machines"}
</Button>
</div>
<p className="settings-note">
Ce proxy sert de valeur par défaut à l'ajout d'une machine (apt-cacher-ng mutualise le cache des paquets). « Appliquer à toutes les machines » écrase le réglage proxy de chaque machine existante. Le mode <span className="mono">persistant</span> n'est écrit sur disque que via l'action dédiée par machine.
</p>
{msg && (
<p className={`settings-note ${msg.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>{msg.text}</p>
)}
</SettingsSection>
);
}
function DatabaseSettings() {
const [info, setInfo] = useState<DbInfo | null>(null);
const [busy, setBusy] = useState<null | "backup" | "restore">(null);