feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK). - tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/ hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002. - tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif, dual-read + backfill). Migration 0003. Fix séquence journal de migration. - tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate), SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH), SJ-3 (reboot vérifié boot_id). - WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics, scaffold app_rust, ajustements frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+83
-5
@@ -1,16 +1,94 @@
|
||||
// client/src/App.tsx
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SystemMetrics } from "@shared/types.js";
|
||||
import { api } from "./lib/api.js";
|
||||
import type { DashboardSummary } from "./panels/Dashboard.js";
|
||||
import { HermesPanel } from "./panels/HermesPanel.js";
|
||||
import { Dashboard } from "./panels/Dashboard.js";
|
||||
import { TerminalPanel } from "./panels/TerminalPanel.js";
|
||||
import { SettingsModal } from "./panels/SettingsModal.js";
|
||||
import { applyTheme, getInitialTheme, nextTheme, type Theme } from "./lib/theme.js";
|
||||
|
||||
const EMPTY_SUMMARY: DashboardSummary = { machines: 0, updates: 0, errors: 0, running: 0 };
|
||||
|
||||
export function App() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [summary, setSummary] = useState<DashboardSummary>(EMPTY_SUMMARY);
|
||||
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>(() => getInitialTheme());
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
const next = await api.systemMetrics();
|
||||
if (!cancelled) setMetrics(next);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
}
|
||||
}
|
||||
void loadMetrics();
|
||||
const timer = window.setInterval(loadMetrics, 10_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="su-layout">
|
||||
<HermesPanel />
|
||||
<Dashboard onSelect={setSelected} />
|
||||
<TerminalPanel machineId={selected} />
|
||||
<div className="su-app">
|
||||
<header className="su-header">
|
||||
<div className="su-brand">
|
||||
<span className="su-brand-mark">SU</span>
|
||||
<div>
|
||||
<h1>System Update</h1>
|
||||
<span className="mono">dashboard SSH agentless</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="su-header-summary">
|
||||
<span>{summary.machines} machines</span>
|
||||
<span>{summary.updates} updates</span>
|
||||
<span>{summary.running} jobs</span>
|
||||
<span>{summary.errors} erreurs</span>
|
||||
</div>
|
||||
<div className="su-spacer" />
|
||||
<button className="interactive su-header-button" onClick={() => setTheme(nextTheme(theme))}>
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</button>
|
||||
<button className="interactive su-header-button" onClick={() => setSettingsOpen(true)}>
|
||||
Paramètres
|
||||
</button>
|
||||
</header>
|
||||
<div className="su-row">
|
||||
<HermesPanel />
|
||||
<Dashboard onSelect={setSelected} onSummaryChange={setSummary} />
|
||||
<TerminalPanel machineId={selected} />
|
||||
</div>
|
||||
<footer className="su-statusbar">
|
||||
<span className="cell mode">SYSTEM UPDATE</span>
|
||||
<span className="cell">machines {summary.machines}</span>
|
||||
<span className="cell">apt {summary.updates}</span>
|
||||
<span className="cell">jobs {summary.running}</span>
|
||||
<span className="cell">ram {formatMb(metrics?.process.rssMb)}</span>
|
||||
<span className="cell">heap {formatMb(metrics?.process.heapUsedMb)}</span>
|
||||
<span className="cell">load {formatLoad(metrics?.host.loadAverage1m)}</span>
|
||||
<span className="cell">terminal {selected ?? "none"}</span>
|
||||
<span className="cell clock">{new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</footer>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMb(value: number | undefined): string {
|
||||
return typeof value === "number" ? `${Math.round(value)}M` : "--";
|
||||
}
|
||||
|
||||
function formatLoad(value: number | undefined): string {
|
||||
return typeof value === "number" ? value.toFixed(2) : "--";
|
||||
}
|
||||
|
||||
@@ -46,6 +46,17 @@ const ICON_MAP = {
|
||||
filter: 'filter',
|
||||
download: 'download',
|
||||
folder: 'folder',
|
||||
docker: 'boxes-stacked',
|
||||
package: 'box-open',
|
||||
script: 'file-code',
|
||||
shield: 'shield-halved',
|
||||
key: 'key',
|
||||
locked: 'lock',
|
||||
logs: 'file-lines',
|
||||
report: 'clipboard-list',
|
||||
copy: 'copy',
|
||||
collapse: 'down-left-and-up-right-to-center',
|
||||
upgrade: 'cloud-arrow-down',
|
||||
node: 'circle-nodes',
|
||||
user: 'user',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// client/src/features/machines/AddMachineModal.tsx
|
||||
import { useState } from "react";
|
||||
import { api } from "../../lib/api.js";
|
||||
|
||||
interface Props { onClose: () => void; onCreated: () => void; }
|
||||
|
||||
@@ -12,11 +13,7 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
|
||||
async function submit() {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/machines", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
|
||||
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null });
|
||||
onCreated(); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// client/src/features/machines/MachineTile.tsx
|
||||
import type { MachineView } from "@shared/types.js";
|
||||
import { useState } from "react";
|
||||
import type { MachineStatus, MachineView } from "@shared/types.js";
|
||||
import { Button, Icon, IconButton, StatusLed } from "../../components/ui-kit.js";
|
||||
|
||||
interface Props {
|
||||
machine: MachineView;
|
||||
@@ -10,30 +12,195 @@ interface Props {
|
||||
onReboot: (id: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)",
|
||||
running: "var(--info)", unknown: "var(--ink-4)",
|
||||
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
|
||||
ok: "ok",
|
||||
updates_available: "warn",
|
||||
error: "err",
|
||||
running: "info",
|
||||
unknown: "off",
|
||||
};
|
||||
|
||||
export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
|
||||
const STATUS_TEXT: Record<MachineStatus, string> = {
|
||||
ok: "OK",
|
||||
updates_available: "Updates",
|
||||
error: "Erreur",
|
||||
running: "Action en cours",
|
||||
unknown: "Inconnu",
|
||||
};
|
||||
|
||||
export function MachineTile({
|
||||
machine,
|
||||
packageCount,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
onUpgrade,
|
||||
onReboot,
|
||||
}: Props) {
|
||||
const [dockerOpen, setDockerOpen] = useState(false);
|
||||
const [postOpen, setPostOpen] = useState(false);
|
||||
const expanded = dockerOpen || postOpen;
|
||||
const isError = machine.status === "error" || machine.status === "unknown";
|
||||
|
||||
return (
|
||||
<div className="glass" style={{ padding: 16, borderRadius: 10 }} onClick={() => onSelect(machine.id)}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 999, background: STATUS_COLOR[machine.status] }} />
|
||||
<strong>{machine.name}</strong>
|
||||
<article
|
||||
className={`machine-tile glass ${expanded ? "machine-tile-expanded" : ""}`}
|
||||
onClick={() => onSelect(machine.id)}
|
||||
>
|
||||
<header className="machine-tile-head">
|
||||
<div className="machine-title-row">
|
||||
<StatusLed status={STATUS_LED[machine.status]} size={10} 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-summary">
|
||||
<Metric label="Updates" value={packageCount.toString()} tone={packageCount > 0 ? "warn" : "ok"} />
|
||||
<Metric label="Reboot" value="-" />
|
||||
<Metric label="Dernier check" value={formatDate(machine.lastCheckedAt)} />
|
||||
</div>
|
||||
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
|
||||
{machine.hostname}:{machine.port} · {machine.osFamily}
|
||||
|
||||
{isError && (
|
||||
<div className="machine-alert">
|
||||
<Icon name="alert" size={14} style={undefined} />
|
||||
<span>État machine à vérifier avant toute action sensible.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="machine-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ margin: "10px 0", fontSize: 13 }}>
|
||||
<span className="label">UPDATES</span>{" "}
|
||||
<span className="mono">{packageCount}</span>
|
||||
|
||||
<div className="machine-sections" onClick={(event) => event.stopPropagation()}>
|
||||
<SectionToggle
|
||||
icon="docker"
|
||||
title="Docker"
|
||||
open={dockerOpen}
|
||||
onToggle={() => setDockerOpen((value) => !value)}
|
||||
/>
|
||||
{dockerOpen && <DockerSection />}
|
||||
|
||||
<SectionToggle
|
||||
icon="script"
|
||||
title="Post-install"
|
||||
open={postOpen}
|
||||
onToggle={() => setPostOpen((value) => !value)}
|
||||
/>
|
||||
{postOpen && <PostInstallSection />}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
|
||||
<button className="interactive" onClick={() => onRefresh(machine.id)}>Refresh</button>
|
||||
<button className="interactive" onClick={() => onUpgrade(machine.id)}>Upgrade</button>
|
||||
<button className="interactive" onClick={() => onReboot(machine.id)}>Reboot</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }: { label: string; value: string; tone?: "ok" | "warn" }) {
|
||||
return (
|
||||
<div className="machine-metric">
|
||||
<span className="label">{label}</span>
|
||||
<span className={`mono ${tone === "warn" ? "machine-metric-warn" : tone === "ok" ? "machine-metric-ok" : ""}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionToggle({
|
||||
icon,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="machine-section-toggle interactive" onClick={onToggle}>
|
||||
<span className="machine-section-title">
|
||||
<Icon name={icon} size={14} style={undefined} />
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<Icon name={open ? "chevD" : "chevR"} size={12} style={undefined} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerSection() {
|
||||
return (
|
||||
<div className="machine-section-body">
|
||||
<div className="machine-section-row">
|
||||
<span className="mono">Docker non scanné</span>
|
||||
<Button icon="cog" size="sm" onClick={() => undefined}>Paramètres</Button>
|
||||
</div>
|
||||
<div className="machine-placeholder">
|
||||
Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostInstallSection() {
|
||||
return (
|
||||
<div className="machine-section-body">
|
||||
<label className="machine-check-row">
|
||||
<input type="checkbox" />
|
||||
<span>Profil network tools</span>
|
||||
</label>
|
||||
<label className="machine-check-row">
|
||||
<input type="checkbox" />
|
||||
<span>Profil partage Samba/NFS</span>
|
||||
</label>
|
||||
<div className="machine-placeholder">
|
||||
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
+23
-3
@@ -1,16 +1,36 @@
|
||||
// client/src/lib/api.ts
|
||||
import type { MachineView, UpdateSnapshot, ActionType } from "@shared/types.js";
|
||||
import type { ActionType, MachineView, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
async function readJsonBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { error: text };
|
||||
}
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
|
||||
return res.json() as Promise<T>;
|
||||
const body = await readJsonBody(res);
|
||||
if (!res.ok) {
|
||||
const apiUnavailable = res.status >= 500 && body === null;
|
||||
const error = apiUnavailable
|
||||
? "API indisponible: le serveur backend ne répond pas."
|
||||
: body && typeof body === "object" && "error" in body
|
||||
? String(body.error)
|
||||
: res.statusText;
|
||||
throw new Error(error || "Erreur API");
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
systemMetrics: () => req<SystemMetrics>("/system/metrics"),
|
||||
listMachines: () => req<MachineView[]>("/machines"),
|
||||
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
|
||||
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
|
||||
|
||||
@@ -1,35 +1,73 @@
|
||||
// client/src/panels/Dashboard.tsx
|
||||
import { useEffect, useState } from "react";
|
||||
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 { AddMachineModal } from "../features/machines/AddMachineModal.js";
|
||||
import { sumUpdates } from "../lib/stats.js";
|
||||
|
||||
interface Props { onSelect: (id: string) => void; }
|
||||
export interface DashboardSummary {
|
||||
machines: number;
|
||||
updates: number;
|
||||
errors: number;
|
||||
running: number;
|
||||
}
|
||||
|
||||
export function Dashboard({ onSelect }: Props) {
|
||||
interface Props {
|
||||
onSelect: (id: string) => void;
|
||||
onSummaryChange?: (summary: DashboardSummary) => void;
|
||||
}
|
||||
|
||||
export function Dashboard({ onSelect, onSummaryChange }: 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);
|
||||
|
||||
async function load() {
|
||||
const ms = await api.listMachines();
|
||||
setMachines(ms);
|
||||
const entries = await Promise.all(ms.map(async (m) => {
|
||||
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
|
||||
catch { return [m.id, 0] as const; }
|
||||
}));
|
||||
setCounts(Object.fromEntries(entries));
|
||||
setError(null);
|
||||
try {
|
||||
const ms = await api.listMachines();
|
||||
setMachines(ms);
|
||||
const entries = await Promise.all(ms.map(async (m) => {
|
||||
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
|
||||
catch { return [m.id, 0] as const; }
|
||||
}));
|
||||
setCounts(Object.fromEntries(entries));
|
||||
} catch (err) {
|
||||
setMachines([]);
|
||||
setCounts({});
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const summary = useMemo<DashboardSummary>(() => ({
|
||||
machines: machines.length,
|
||||
updates: sumUpdates(counts),
|
||||
errors: machines.filter((m) => m.status === "error").length,
|
||||
running: machines.filter((m) => m.status === "running").length,
|
||||
}), [machines, counts]);
|
||||
|
||||
useEffect(() => {
|
||||
onSummaryChange?.(summary);
|
||||
}, [onSummaryChange, summary]);
|
||||
|
||||
return (
|
||||
<main className="su-center">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>Machines</h2>
|
||||
<button className="interactive" onClick={() => setAdding(true)}>+ Ajouter</button>
|
||||
<div className="su-dashboard-head">
|
||||
<div>
|
||||
<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>
|
||||
{machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
|
||||
{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
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// client/src/panels/SettingsModal.tsx
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../components/ui-kit.js";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SettingsTab =
|
||||
| "appearance"
|
||||
| "tiles"
|
||||
| "layout"
|
||||
| "docker"
|
||||
| "scripts"
|
||||
| "hermes"
|
||||
| "terminal"
|
||||
| "retention";
|
||||
|
||||
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: "docker", label: "Docker", icon: "docker" },
|
||||
{ id: "scripts", label: "Scripts", icon: "script" },
|
||||
{ id: "hermes", label: "Hermes", icon: "node" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
||||
{ id: "retention", label: "Nettoyage", icon: "logs" },
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Props) {
|
||||
const [active, setActive] = useState<SettingsTab>("appearance");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-backdrop" onClick={onClose}>
|
||||
<section className="settings-modal glass-strong" onClick={(event) => event.stopPropagation()}>
|
||||
<header className="settings-head">
|
||||
<div>
|
||||
<span className="label">PARAMÈTRES</span>
|
||||
<h2>System Update</h2>
|
||||
</div>
|
||||
<button className="interactive settings-close" onClick={onClose} aria-label="Fermer">
|
||||
<Icon name="close" size={14} style={undefined} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="settings-body">
|
||||
<nav className="settings-nav">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`interactive settings-nav-item ${active === tab.id ? "active" : ""}`}
|
||||
onClick={() => setActive(tab.id)}
|
||||
>
|
||||
<Icon name={tab.icon} size={14} style={undefined} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="settings-content">
|
||||
{active === "appearance" && <AppearanceSettings />}
|
||||
{active === "tiles" && <TileSettings />}
|
||||
{active === "layout" && <LayoutSettings />}
|
||||
{active === "docker" && <DockerSettings />}
|
||||
{active === "scripts" && <ScriptsSettings />}
|
||||
{active === "hermes" && <HermesSettings />}
|
||||
{active === "terminal" && <TerminalSettings />}
|
||||
{active === "retention" && <RetentionSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="settings-footer">
|
||||
<span className="mono">settings backend pending</span>
|
||||
<button className="interactive settings-secondary" onClick={onClose}>Fermer</button>
|
||||
<button className="interactive settings-primary" onClick={onClose}>Sauvegarder</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSettings() {
|
||||
return (
|
||||
<SettingsSection title="Apparence">
|
||||
<Field label="Thème">
|
||||
<select className="su-field" defaultValue="system">
|
||||
<option value="system">Système</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Zoom UI">
|
||||
<input className="su-field" type="number" min="80" max="130" defaultValue="100" />
|
||||
</Field>
|
||||
<Field label="Densité">
|
||||
<select className="su-field" defaultValue="compact">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="comfortable">Confort</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function TileSettings() {
|
||||
return (
|
||||
<SettingsSection title="Tuiles machine">
|
||||
<Field label="Largeur minimale">
|
||||
<input className="su-field" type="number" min="240" max="420" defaultValue="280" />
|
||||
</Field>
|
||||
<Field label="Sections ouvertes par défaut">
|
||||
<div className="settings-checks">
|
||||
<Check label="Docker" />
|
||||
<Check label="Post-install" />
|
||||
<Check label="Hardware" />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Mode erreur">
|
||||
<select className="su-field" defaultValue="expanded">
|
||||
<option value="badge">Badge</option>
|
||||
<option value="expanded">Alerte visible</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutSettings() {
|
||||
return (
|
||||
<SettingsSection title="Volets">
|
||||
<Field label="Hermes largeur">
|
||||
<input className="su-field" type="number" min="200" max="300" defaultValue="240" />
|
||||
</Field>
|
||||
<Field label="Terminal largeur">
|
||||
<input className="su-field" type="number" min="320" max="460" defaultValue="380" />
|
||||
</Field>
|
||||
<Field label="Mobile">
|
||||
<select className="su-field" defaultValue="tabs">
|
||||
<option value="tabs">Onglets</option>
|
||||
<option value="bottom">Barre basse</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerSettings() {
|
||||
return (
|
||||
<SettingsSection title="Docker">
|
||||
<Field label="Roots Compose">
|
||||
<textarea className="su-field settings-textarea" defaultValue={"/home/gilles/docker\n/opt/docker"} />
|
||||
</Field>
|
||||
<Field label="Prune">
|
||||
<select className="su-field" defaultValue="safe">
|
||||
<option value="safe">Images inutilisées</option>
|
||||
<option value="aggressive">Agressif avec validation</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptsSettings() {
|
||||
return (
|
||||
<SettingsSection title="Scripts">
|
||||
<Field label="Catalogue">
|
||||
<select className="su-field" defaultValue="local">
|
||||
<option value="local">Scripts locaux</option>
|
||||
<option value="shared">Scripts partagés</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Profils visibles">
|
||||
<div className="settings-checks">
|
||||
<Check label="Network" checked />
|
||||
<Check label="Dev tools" checked />
|
||||
<Check label="Domotique" />
|
||||
</div>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function HermesSettings() {
|
||||
return (
|
||||
<SettingsSection title="Hermes">
|
||||
<Field label="Endpoint">
|
||||
<input className="su-field" defaultValue="http://10.0.0.80:8000" />
|
||||
</Field>
|
||||
<Field label="Contexte max">
|
||||
<input className="su-field" type="number" min="1000" max="64000" defaultValue="12000" />
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalSettings() {
|
||||
return (
|
||||
<SettingsSection title="Terminal">
|
||||
<Field label="Mode">
|
||||
<select className="su-field" defaultValue="logs">
|
||||
<option value="logs">Logs actions</option>
|
||||
<option value="ssh">SSH interactif</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Police">
|
||||
<input className="su-field" type="number" min="10" max="18" defaultValue="12" />
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionSettings() {
|
||||
return (
|
||||
<SettingsSection title="Nettoyage">
|
||||
<Field label="Logs bruts">
|
||||
<input className="su-field" type="number" min="7" max="365" defaultValue="90" />
|
||||
</Field>
|
||||
<Field label="Rapports">
|
||||
<input className="su-field" type="number" min="30" max="730" defaultValue="365" />
|
||||
</Field>
|
||||
<Field label="Messages importants">
|
||||
<select className="su-field" defaultValue="keep">
|
||||
<option value="keep">Conserver non acquittés</option>
|
||||
<option value="archive">Archiver</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>{title}</h3>
|
||||
<div className="settings-fields">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="settings-field">
|
||||
<span className="label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ label, checked }: { label: string; checked?: boolean }) {
|
||||
return (
|
||||
<label className="settings-check">
|
||||
<input type="checkbox" defaultChecked={checked} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -18,11 +18,31 @@ export function TerminalPanel({ machineId }: { machineId: string | null }) {
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(ref.current);
|
||||
fit.fit();
|
||||
const fitTerminal = () => {
|
||||
try { fit.fit(); } catch { /* xterm peut être entre deux cycles de layout. */ }
|
||||
};
|
||||
const frame = window.requestAnimationFrame(fitTerminal);
|
||||
const resizeObserver = new ResizeObserver(fitTerminal);
|
||||
resizeObserver.observe(ref.current);
|
||||
term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine");
|
||||
const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {};
|
||||
return () => { disconnect(); term.dispose(); };
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
disconnect();
|
||||
term.dispose();
|
||||
};
|
||||
}, [machineId]);
|
||||
|
||||
return <div className="su-terminal" ref={ref} style={{ padding: 6 }} />;
|
||||
return (
|
||||
<aside className="su-terminal-wrap">
|
||||
<div className="su-terminal-head">
|
||||
<span className="label">TERMINAL</span>
|
||||
<span className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
|
||||
{machineId ?? "aucune machine"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="su-terminal" ref={ref} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
+301
-7
@@ -8,9 +8,9 @@ body {
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
/* Ossature : header / rangée 3 volets / status bar */
|
||||
.su-app { display: flex; flex-direction: column; height: 100vh; }
|
||||
.su-row { flex: 1; display: flex; min-height: 0; }
|
||||
/* Ossature : rangée 3 volets */
|
||||
.su-app { display: flex; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; }
|
||||
.su-row { flex: 1; display: flex; width: 100%; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
|
||||
.su-header {
|
||||
height: 52px; flex: 0 0 52px;
|
||||
@@ -21,14 +21,154 @@ body {
|
||||
}
|
||||
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
||||
.su-spacer { flex: 1; }
|
||||
.su-brand { display: flex; align-items: center; gap: 10px; min-width: 210px; }
|
||||
.su-brand-mark {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); color: var(--bg-1);
|
||||
font-weight: 800; font-family: var(--font-mono); font-size: 12px;
|
||||
}
|
||||
.su-brand .mono { display: block; color: var(--ink-3); font-size: 11px; margin-top: 2px; }
|
||||
.su-header-summary { display: flex; gap: 8px; flex-wrap: wrap; color: var(--ink-3); font-size: 12px; }
|
||||
.su-header-summary span,
|
||||
.su-header-button {
|
||||
border: 1px solid var(--border-1);
|
||||
background: var(--bg-3);
|
||||
color: var(--ink-2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 9px;
|
||||
}
|
||||
.su-header-button { font-family: var(--font-ui); }
|
||||
|
||||
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
|
||||
.su-center { flex: 1; overflow: auto; padding: 18px; }
|
||||
.su-terminal-wrap { width: 360px; min-width: 320px; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); }
|
||||
.su-hermes { flex: 0 0 clamp(220px, 15vw, 280px); min-width: 0; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
|
||||
.su-center { flex: 1 1 auto; min-width: 0; overflow: auto; padding: 18px; }
|
||||
.su-terminal-wrap { flex: 0 0 clamp(320px, 28vw, 440px); min-width: 0; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); overflow: hidden; }
|
||||
.su-terminal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-1); }
|
||||
.su-terminal { flex: 1; min-height: 0; padding: 6px; }
|
||||
.su-terminal { flex: 1; min-width: 0; min-height: 0; padding: 6px; overflow: hidden; }
|
||||
.su-terminal .xterm { height: 100%; }
|
||||
|
||||
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
.su-dashboard-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.su-dashboard-head h2 { margin: 0; font-size: 22px; }
|
||||
.su-dashboard-head p { margin: 4px 0 0; color: var(--ink-3); font-size: 12px; }
|
||||
.su-add-button {
|
||||
background: var(--bg-3);
|
||||
color: var(--ink-1);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.machine-tile {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.machine-tile-expanded { grid-column: span 2; }
|
||||
.machine-tile-head,
|
||||
.machine-title-row,
|
||||
.machine-actions,
|
||||
.machine-section-toggle,
|
||||
.machine-section-row,
|
||||
.machine-check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.machine-tile-head { justify-content: space-between; gap: 12px; min-width: 0; }
|
||||
.machine-title-row { gap: 9px; min-width: 0; }
|
||||
.machine-title-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.machine-title-text strong { color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-title-text .mono { color: var(--ink-3); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-status-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border-2);
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.machine-status-danger { color: var(--err); border-color: var(--err); background: rgba(251, 73, 52, 0.08); }
|
||||
.machine-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.machine-metric {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border-1);
|
||||
}
|
||||
.machine-metric .mono { display: block; margin-top: 3px; font-size: 13px; color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-metric-warn { color: var(--warn) !important; }
|
||||
.machine-metric-ok { color: var(--ok) !important; }
|
||||
.machine-alert {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--err);
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--err);
|
||||
border-radius: 8px;
|
||||
background: rgba(251, 73, 52, 0.08);
|
||||
}
|
||||
.machine-actions { gap: 7px; flex-wrap: wrap; }
|
||||
.machine-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border-1);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.machine-section-toggle {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-1);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.machine-section-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.machine-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border-1);
|
||||
}
|
||||
.machine-section-row { justify-content: space-between; gap: 8px; }
|
||||
.machine-placeholder { color: var(--ink-3); font-size: 12px; line-height: 1.35; }
|
||||
.machine-check-row { gap: 8px; color: var(--ink-2); font-size: 13px; }
|
||||
.machine-check-row input { accent-color: var(--accent); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.machine-tile-expanded { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.su-hermes { flex-basis: 220px; }
|
||||
.su-terminal-wrap { flex-basis: 320px; }
|
||||
.su-header-summary { display: none; }
|
||||
}
|
||||
|
||||
/* Status bar style tmux */
|
||||
.su-statusbar {
|
||||
@@ -51,3 +191,157 @@ body {
|
||||
outline: none;
|
||||
}
|
||||
.su-field:focus { border-color: var(--accent-soft); }
|
||||
|
||||
.settings-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.settings-modal {
|
||||
width: min(920px, 96vw);
|
||||
max-height: min(720px, 92vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.settings-head h2 { margin: 2px 0 0; font-size: 18px; }
|
||||
.settings-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.settings-nav {
|
||||
flex: 0 0 210px;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--border-1);
|
||||
background: var(--bg-2);
|
||||
overflow: auto;
|
||||
}
|
||||
.settings-nav-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-ui);
|
||||
text-align: left;
|
||||
}
|
||||
.settings-nav-item.active {
|
||||
background: var(--accent-tint);
|
||||
border-color: var(--accent-soft);
|
||||
color: var(--ink-1);
|
||||
}
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
.settings-section h3 { margin: 0 0 14px; font-size: 18px; }
|
||||
.settings-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.settings-field .su-field {
|
||||
width: 100%;
|
||||
}
|
||||
.settings-textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
}
|
||||
.settings-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.settings-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-check input { accent-color: var(--accent); }
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-1);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.settings-footer .mono {
|
||||
margin-right: auto;
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.settings-primary,
|
||||
.settings-secondary {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-ui);
|
||||
border: 1px solid var(--border-2);
|
||||
}
|
||||
.settings-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-1);
|
||||
border-color: var(--accent-soft);
|
||||
}
|
||||
.settings-secondary {
|
||||
background: var(--bg-3);
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-body { flex-direction: column; }
|
||||
.settings-nav {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
}
|
||||
.settings-nav-item { flex: 0 0 auto; width: auto; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user