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:
+27
-2
@@ -2,11 +2,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { SystemMetrics } from "@shared/types.js";
|
import type { SystemMetrics } from "@shared/types.js";
|
||||||
import { api } from "./lib/api.js";
|
import { api } from "./lib/api.js";
|
||||||
import type { DashboardSummary } from "./panels/Dashboard.js";
|
import type { DashboardSummary, ViewMode } from "./panels/Dashboard.js";
|
||||||
import { HermesPanel } from "./panels/HermesPanel.js";
|
import { HermesPanel } from "./panels/HermesPanel.js";
|
||||||
import { Dashboard } from "./panels/Dashboard.js";
|
import { Dashboard } from "./panels/Dashboard.js";
|
||||||
import { TerminalPanel } from "./panels/TerminalPanel.js";
|
import { TerminalPanel } from "./panels/TerminalPanel.js";
|
||||||
import { SettingsModal } from "./panels/SettingsModal.js";
|
import { SettingsModal } from "./panels/SettingsModal.js";
|
||||||
|
import { Icon } from "./components/ui-kit.js";
|
||||||
import { applyTheme, getInitialTheme, nextTheme, type Theme } from "./lib/theme.js";
|
import { applyTheme, getInitialTheme, nextTheme, type Theme } from "./lib/theme.js";
|
||||||
|
|
||||||
const EMPTY_SUMMARY: DashboardSummary = { machines: 0, updates: 0, errors: 0, running: 0 };
|
const EMPTY_SUMMARY: DashboardSummary = { machines: 0, updates: 0, errors: 0, running: 0 };
|
||||||
@@ -17,6 +18,14 @@ export function App() {
|
|||||||
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
|
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
|
||||||
const [theme, setTheme] = useState<Theme>(() => getInitialTheme());
|
const [theme, setTheme] = useState<Theme>(() => getInitialTheme());
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [view, setView] = useState<ViewMode>(
|
||||||
|
() => (localStorage.getItem("su-view") as ViewMode) ?? "grid",
|
||||||
|
);
|
||||||
|
function changeView(mode: ViewMode) {
|
||||||
|
setView(mode);
|
||||||
|
localStorage.setItem("su-view", mode);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
@@ -57,6 +66,15 @@ export function App() {
|
|||||||
<span>{summary.errors} erreurs</span>
|
<span>{summary.errors} erreurs</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="su-spacer" />
|
<div className="su-spacer" />
|
||||||
|
<div className="su-viewtoggle" role="group" aria-label="Mode d'affichage">
|
||||||
|
<button className={`interactive su-viewtoggle-btn ${view === "grid" ? "active" : ""}`} onClick={() => changeView("grid")}>
|
||||||
|
<Icon name="grid" size={13} style={undefined} /> Tuiles
|
||||||
|
</button>
|
||||||
|
<button className={`interactive su-viewtoggle-btn ${view === "list" ? "active" : ""}`} onClick={() => changeView("list")}>
|
||||||
|
<Icon name="list" size={13} style={undefined} /> Liste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="interactive su-header-button" onClick={() => setAdding(true)}>+ Ajouter</button>
|
||||||
<button className="interactive su-header-button" onClick={() => setTheme(nextTheme(theme))}>
|
<button className="interactive su-header-button" onClick={() => setTheme(nextTheme(theme))}>
|
||||||
{theme === "dark" ? "Light" : "Dark"}
|
{theme === "dark" ? "Light" : "Dark"}
|
||||||
</button>
|
</button>
|
||||||
@@ -66,7 +84,14 @@ export function App() {
|
|||||||
</header>
|
</header>
|
||||||
<div className="su-row">
|
<div className="su-row">
|
||||||
<HermesPanel />
|
<HermesPanel />
|
||||||
<Dashboard onSelect={setSelected} onSummaryChange={setSummary} />
|
<Dashboard
|
||||||
|
selectedId={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
onSummaryChange={setSummary}
|
||||||
|
view={view}
|
||||||
|
adding={adding}
|
||||||
|
onAddingChange={setAdding}
|
||||||
|
/>
|
||||||
<TerminalPanel machineId={selected} />
|
<TerminalPanel machineId={selected} />
|
||||||
</div>
|
</div>
|
||||||
<footer className="su-statusbar">
|
<footer className="su-statusbar">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
ui-kit.jsx
|
ui-kit.jsx
|
||||||
Composants haute-fid Gruvbox Seventies.
|
Composants haute-fid Gruvbox Seventies.
|
||||||
@@ -433,9 +434,11 @@ function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
// Portail vers <body> : échappe aux contextes d'empilement des tuiles (backdrop-filter
|
||||||
|
// glass piège même position:fixed) pour rester au premier plan global.
|
||||||
|
return createPortal((
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', inset: 0, zIndex: 100,
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
background: 'rgba(0,0,0,0.45)',
|
background: 'rgba(0,0,0,0.45)',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -472,7 +475,7 @@ function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
), document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// client/src/features/machines/AddMachineModal.tsx
|
// 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";
|
import { api } from "../../lib/api.js";
|
||||||
|
|
||||||
interface Props { onClose: () => void; onCreated: () => void; }
|
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 [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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 });
|
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() {
|
async function submit() {
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
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();
|
onCreated(); onClose();
|
||||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
} 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="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="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)} />
|
<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>}
|
{error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>}
|
||||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||||
<button onClick={onClose}>Annuler</button>
|
<button onClick={onClose}>Annuler</button>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// client/src/features/machines/MachineTile.tsx
|
// client/src/features/machines/MachineTile.tsx
|
||||||
import { useEffect, useState } from "react";
|
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 { 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 {
|
interface Props {
|
||||||
machine: MachineView;
|
machine: MachineView;
|
||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
onRefresh: (id: string) => void;
|
onRefresh: (id: string) => void;
|
||||||
onUpgrade: (id: string) => void;
|
onUpgrade: (id: string) => void;
|
||||||
onReboot: (id: string) => void;
|
onReboot: (id: string) => void;
|
||||||
|
onChanged?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
|
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
|
||||||
@@ -36,9 +37,11 @@ export function MachineTile({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onUpgrade,
|
onUpgrade,
|
||||||
onReboot,
|
onReboot,
|
||||||
|
onChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [dockerOpen, setDockerOpen] = useState(false);
|
const [dockerOpen, setDockerOpen] = useState(false);
|
||||||
const [postOpen, setPostOpen] = useState(false);
|
const [postOpen, setPostOpen] = useState(false);
|
||||||
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const expanded = dockerOpen || postOpen;
|
const expanded = dockerOpen || postOpen;
|
||||||
const isError = machine.status === "error" || machine.status === "unknown";
|
const isError = machine.status === "error" || machine.status === "unknown";
|
||||||
|
|
||||||
@@ -106,8 +109,27 @@ export function MachineTile({
|
|||||||
primary={false}
|
primary={false}
|
||||||
onClick={() => onSelect(machine.id)}
|
onClick={() => onSelect(machine.id)}
|
||||||
/>
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="cog"
|
||||||
|
label="Profil & proxy (sonde)"
|
||||||
|
active={false}
|
||||||
|
danger={false}
|
||||||
|
primary={false}
|
||||||
|
onClick={() => setConfigOpen(true)}
|
||||||
|
/>
|
||||||
</div>
|
</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()}>
|
<div className="machine-sections" onClick={(event) => event.stopPropagation()}>
|
||||||
<SectionToggle
|
<SectionToggle
|
||||||
icon="docker"
|
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 {
|
interface ConfirmState {
|
||||||
action: ActionType;
|
action: ActionType;
|
||||||
stackId?: string;
|
stackId?: string;
|
||||||
@@ -481,3 +648,125 @@ function formatDate(value: string | null): string {
|
|||||||
minute: "2-digit",
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+46
-1
@@ -1,5 +1,5 @@
|
|||||||
// client/src/lib/api.ts
|
// client/src/lib/api.ts
|
||||||
import type { ActionType, MachineView, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
|
import type { ActionType, AptProxyMode, MachineKind, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
|
||||||
|
|
||||||
async function readJsonBody(res: Response): Promise<unknown> {
|
async function readJsonBody(res: Response): Promise<unknown> {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -42,6 +42,17 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// --- Réglages globaux ---
|
||||||
|
getSettings: () => req<AppSettingsView>("/settings"),
|
||||||
|
setDefaultAptProxy: (body: DefaultAptProxy) =>
|
||||||
|
req<DefaultAptProxy>("/settings/apt-proxy", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
applyProxyToAll: () => req<{ ok: boolean; updated: number }>("/settings/apt-proxy/apply-all", { method: "POST" }),
|
||||||
|
|
||||||
|
// --- Profil machine (SJ-7) ---
|
||||||
|
updateMachine: (id: string, body: UpdateMachineBody) =>
|
||||||
|
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
|
||||||
|
|
||||||
// --- Docker ---
|
// --- Docker ---
|
||||||
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
||||||
dockerSetRoots: (id: string, paths: string[], scanDepth = 4) =>
|
dockerSetRoots: (id: string, paths: string[], scanDepth = 4) =>
|
||||||
@@ -107,6 +118,40 @@ export interface DbRestoreResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DefaultAptProxy {
|
||||||
|
mode: AptProxyMode;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettingsView {
|
||||||
|
defaultAptProxy: DefaultAptProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMachineBody {
|
||||||
|
osFamily?: OsFamily;
|
||||||
|
machineKind?: MachineKind;
|
||||||
|
virtualization?: string | null;
|
||||||
|
aptProxyMode?: AptProxyMode;
|
||||||
|
aptProxyUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbeResultView {
|
||||||
|
probe: {
|
||||||
|
osId: string | null;
|
||||||
|
osVersion: string | null;
|
||||||
|
osCodename: string | null;
|
||||||
|
arch: string | null;
|
||||||
|
dpkgArch: string | null;
|
||||||
|
virt: string | null;
|
||||||
|
isProxmox: boolean;
|
||||||
|
isRpi: boolean;
|
||||||
|
gpus: string[];
|
||||||
|
net: { iface: string; addr: string }[];
|
||||||
|
};
|
||||||
|
proposal: { osFamily: OsFamily; machineKind: MachineKind; virtualization: string };
|
||||||
|
changes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
|
export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
|
||||||
|
|
||||||
export interface DockerSettingsView {
|
export interface DockerSettingsView {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { MachineView } from "@shared/types.js";
|
import type { MachineView } from "@shared/types.js";
|
||||||
import { api } from "../lib/api.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 { AddMachineModal } from "../features/machines/AddMachineModal.js";
|
||||||
import { sumUpdates } from "../lib/stats.js";
|
import { sumUpdates } from "../lib/stats.js";
|
||||||
|
|
||||||
@@ -13,15 +13,20 @@ export interface DashboardSummary {
|
|||||||
running: number;
|
running: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "list";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
selectedId?: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onSummaryChange?: (summary: DashboardSummary) => 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 [machines, setMachines] = useState<MachineView[]>([]);
|
||||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -56,6 +61,11 @@ export function Dashboard({ onSelect, onSummaryChange }: Props) {
|
|||||||
onSummaryChange?.(summary);
|
onSummaryChange?.(summary);
|
||||||
}, [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 (
|
return (
|
||||||
<main className="su-center">
|
<main className="su-center">
|
||||||
<div className="su-dashboard-head">
|
<div className="su-dashboard-head">
|
||||||
@@ -63,22 +73,42 @@ export function Dashboard({ onSelect, onSummaryChange }: Props) {
|
|||||||
<h2>Machines</h2>
|
<h2>Machines</h2>
|
||||||
<p>{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs</p>
|
<p>{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="interactive su-add-button" onClick={() => setAdding(true)}>+ Ajouter</button>
|
|
||||||
</div>
|
</div>
|
||||||
{error && <p style={{ color: "var(--err)" }}>{error}</p>}
|
{error && <p style={{ color: "var(--err)" }}>{error}</p>}
|
||||||
{!error && loading && <p style={{ color: "var(--ink-3)" }}>Chargement des machines…</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>}
|
{!error && !loading && machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
|
||||||
<div className="su-tiles">
|
|
||||||
{machines.map((m) => (
|
{view === "grid" ? (
|
||||||
<MachineTile
|
<div className="su-tiles">
|
||||||
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
|
{machines.map((m) => (
|
||||||
onRefresh={(id) => { onSelect(id); void api.refresh(id).then(load); }}
|
<MachineTile
|
||||||
onUpgrade={(id) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); }}
|
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
|
||||||
onReboot={(id) => { onSelect(id); void api.runAction(id, "reboot"); }}
|
onRefresh={onRefresh} onUpgrade={onUpgrade} onReboot={onReboot} onChanged={load}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
|
) : (
|
||||||
|
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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// client/src/panels/SettingsModal.tsx
|
// client/src/panels/SettingsModal.tsx
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { AptProxyMode } from "@shared/types.js";
|
||||||
import { Icon, Popup, Button } from "../components/ui-kit.js";
|
import { Icon, Popup, Button } from "../components/ui-kit.js";
|
||||||
import { api, type DbInfo } from "../lib/api.js";
|
import { api, type DbInfo } from "../lib/api.js";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ type SettingsTab =
|
|||||||
| "appearance"
|
| "appearance"
|
||||||
| "tiles"
|
| "tiles"
|
||||||
| "layout"
|
| "layout"
|
||||||
|
| "proxy"
|
||||||
| "docker"
|
| "docker"
|
||||||
| "scripts"
|
| "scripts"
|
||||||
| "hermes"
|
| "hermes"
|
||||||
@@ -23,6 +25,7 @@ const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
|
|||||||
{ id: "appearance", label: "Apparence", icon: "cog" },
|
{ id: "appearance", label: "Apparence", icon: "cog" },
|
||||||
{ id: "tiles", label: "Tuiles", icon: "grid" },
|
{ id: "tiles", label: "Tuiles", icon: "grid" },
|
||||||
{ id: "layout", label: "Volets", icon: "collapse" },
|
{ id: "layout", label: "Volets", icon: "collapse" },
|
||||||
|
{ id: "proxy", label: "Proxy APT", icon: "network" },
|
||||||
{ id: "docker", label: "Docker", icon: "docker" },
|
{ id: "docker", label: "Docker", icon: "docker" },
|
||||||
{ id: "scripts", label: "Scripts", icon: "script" },
|
{ id: "scripts", label: "Scripts", icon: "script" },
|
||||||
{ id: "hermes", label: "Hermes", icon: "node" },
|
{ id: "hermes", label: "Hermes", icon: "node" },
|
||||||
@@ -67,6 +70,7 @@ export function SettingsModal({ open, onClose }: Props) {
|
|||||||
{active === "appearance" && <AppearanceSettings />}
|
{active === "appearance" && <AppearanceSettings />}
|
||||||
{active === "tiles" && <TileSettings />}
|
{active === "tiles" && <TileSettings />}
|
||||||
{active === "layout" && <LayoutSettings />}
|
{active === "layout" && <LayoutSettings />}
|
||||||
|
{active === "proxy" && <ProxyDefaultSettings />}
|
||||||
{active === "docker" && <DockerSettings />}
|
{active === "docker" && <DockerSettings />}
|
||||||
{active === "scripts" && <ScriptsSettings />}
|
{active === "scripts" && <ScriptsSettings />}
|
||||||
{active === "hermes" && <HermesSettings />}
|
{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() {
|
function DatabaseSettings() {
|
||||||
const [info, setInfo] = useState<DbInfo | null>(null);
|
const [info, setInfo] = useState<DbInfo | null>(null);
|
||||||
const [busy, setBusy] = useState<null | "backup" | "restore">(null);
|
const [busy, setBusy] = useState<null | "backup" | "restore">(null);
|
||||||
|
|||||||
@@ -66,6 +66,45 @@ body {
|
|||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Toggle d'affichage Tuiles / Liste --- */
|
||||||
|
.su-head-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.su-viewtoggle { display: inline-flex; border: 1px solid var(--border-2); border-radius: 8px; overflow: hidden; background: var(--bg-2); }
|
||||||
|
.su-viewtoggle-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 7px 12px; font-size: 12px; font-family: var(--font-ui);
|
||||||
|
color: var(--ink-2); background: transparent; border: none;
|
||||||
|
}
|
||||||
|
.su-viewtoggle-btn.active { background: var(--accent); color: var(--bg-1); }
|
||||||
|
|
||||||
|
/* --- Mode Listing : liste compacte + panneau détail --- */
|
||||||
|
.machine-listing { display: flex; gap: 14px; align-items: flex-start; }
|
||||||
|
.machine-list { flex: 0 0 clamp(280px, 32%, 420px); display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.machine-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 9px 11px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-1); background: var(--bg-2);
|
||||||
|
text-align: left; width: 100%; color: var(--ink-1);
|
||||||
|
}
|
||||||
|
.machine-row.active { border-color: var(--accent); background: var(--accent-tint); }
|
||||||
|
.machine-row-name { font-weight: 600; font-size: 13px; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.machine-row-ip { color: var(--ink-3); font-size: 11px; flex: 0 0 auto; }
|
||||||
|
.machine-row-os { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-2); font-size: 12px; flex: 0 0 auto; }
|
||||||
|
.machine-row-cell { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; flex: 0 0 auto; min-width: 56px; }
|
||||||
|
.machine-row-cell b { font-size: 13px; }
|
||||||
|
.machine-row-cell .mono { font-size: 11px; color: var(--ink-3); }
|
||||||
|
|
||||||
|
.machine-detail { flex: 1 1 auto; min-width: 0; padding: 16px; border-radius: 10px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.machine-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.machine-detail-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||||
|
.machine-detail-card {
|
||||||
|
display: flex; flex-direction: column; gap: 7px;
|
||||||
|
padding: 12px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-1); background: var(--bg-1);
|
||||||
|
}
|
||||||
|
.machine-info-row { display: flex; align-items: baseline; justify-content: space-between; gap: 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-tile {
|
.machine-tile {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
@@ -219,6 +258,28 @@ body {
|
|||||||
.docker-msg-ok { color: var(--ok); }
|
.docker-msg-ok { color: var(--ok); }
|
||||||
.docker-msg-err { color: var(--err); }
|
.docker-msg-err { color: var(--err); }
|
||||||
.docker-confirm-note { display: flex; align-items: center; gap: 7px; color: var(--ink-3); font-size: 12px; margin-top: 10px; }
|
.docker-confirm-note { display: flex; align-items: center; gap: 7px; color: var(--ink-3); font-size: 12px; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* --- Popup config machine (SJ-7) --- */
|
||||||
|
.cfg { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.cfg-current { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.cfg-current .mono { color: var(--ink-1); font-size: 13px; }
|
||||||
|
.cfg-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.cfg-block-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.cfg-probe { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.cfg-facts { color: var(--ink-2); font-size: 12px; }
|
||||||
|
.cfg-proposal { color: var(--accent); font-size: 12px; }
|
||||||
|
.cfg-changes { margin: 0; padding-left: 16px; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.cfg-changes li { color: var(--warn); font-size: 12px; }
|
||||||
|
.cfg-nochange { color: var(--ok); font-size: 12px; }
|
||||||
|
.cfg-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.machine-check-row input { accent-color: var(--accent); }
|
.machine-check-row input { accent-color: var(--accent); }
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user