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