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