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:
2026-06-05 19:50:25 +02:00
parent 0fbca06d3d
commit 08919752e3
69 changed files with 7785 additions and 102 deletions
+83 -5
View File
@@ -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) : "--";
}
+11
View File
@@ -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); }
}
+185 -18
View File
@@ -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
View File
@@ -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" }),
+52 -14
View File
@@ -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
+259
View File
@@ -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>
);
}
+23 -3
View File
@@ -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
View File
@@ -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; }
}