diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..267b527 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,16 @@ +// client/src/App.tsx +import { useState } from "react"; +import { HermesPanel } from "./panels/HermesPanel.js"; +import { Dashboard } from "./panels/Dashboard.js"; +import { TerminalPanel } from "./panels/TerminalPanel.js"; + +export function App() { + const [selected, setSelected] = useState(null); + return ( +
+ + + +
+ ); +} diff --git a/client/src/features/machines/AddMachineModal.tsx b/client/src/features/machines/AddMachineModal.tsx new file mode 100644 index 0000000..b570645 --- /dev/null +++ b/client/src/features/machines/AddMachineModal.tsx @@ -0,0 +1,42 @@ +// client/src/features/machines/AddMachineModal.tsx +import { useState } from "react"; + +interface Props { onClose: () => void; onCreated: () => void; } + +export function AddMachineModal({ onClose, onCreated }: Props) { + const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" }); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const set = (k: string, v: string | number) => setForm({ ...form, [k]: v }); + + 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"); + onCreated(); onClose(); + } catch (e) { setError((e as Error).message); } finally { setBusy(false); } + } + + return ( +
+
+
AJOUTER UNE MACHINE
+ {(["name", "hostname", "username"] as const).map((k) => ( + set(k, e.target.value)} /> + ))} + set("port", e.target.value)} /> + set("password", e.target.value)} /> + set("sudoPassword", e.target.value)} /> + {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/client/src/features/machines/MachineTile.tsx b/client/src/features/machines/MachineTile.tsx new file mode 100644 index 0000000..412982e --- /dev/null +++ b/client/src/features/machines/MachineTile.tsx @@ -0,0 +1,39 @@ +// client/src/features/machines/MachineTile.tsx +import type { MachineView } from "@shared/types.js"; + +interface Props { + machine: MachineView; + packageCount: number; + onSelect: (id: string) => void; + onRefresh: (id: string) => void; + onUpgrade: (id: string) => void; + onReboot: (id: string) => void; +} + +const STATUS_COLOR: Record = { + ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)", + running: "var(--info)", unknown: "var(--ink-4)", +}; + +export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) { + return ( +
onSelect(machine.id)}> +
+ + {machine.name} +
+
+ {machine.hostname}:{machine.port} · {machine.osFamily} +
+
+ UPDATES{" "} + {packageCount} +
+
e.stopPropagation()}> + + + +
+
+ ); +} diff --git a/client/src/panels/Dashboard.tsx b/client/src/panels/Dashboard.tsx new file mode 100644 index 0000000..79be700 --- /dev/null +++ b/client/src/panels/Dashboard.tsx @@ -0,0 +1,46 @@ +// client/src/panels/Dashboard.tsx +import { useEffect, 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"; + +interface Props { onSelect: (id: string) => void; } + +export function Dashboard({ onSelect }: Props) { + const [machines, setMachines] = useState([]); + const [counts, setCounts] = useState>({}); + const [adding, setAdding] = useState(false); + + 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)); + } + useEffect(() => { void load(); }, []); + + return ( +
+
+

Machines

+ +
+ {machines.length === 0 &&

Aucune machine. Clique sur « + Ajouter ».

} +
+ {machines.map((m) => ( + { onSelect(id); void api.refresh(id).then(load); }} + onUpgrade={(id) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); }} + onReboot={(id) => { onSelect(id); void api.runAction(id, "reboot"); }} + /> + ))} +
+ {adding && setAdding(false)} onCreated={load} />} +
+ ); +} diff --git a/client/src/panels/HermesPanel.tsx b/client/src/panels/HermesPanel.tsx new file mode 100644 index 0000000..477d6fe --- /dev/null +++ b/client/src/panels/HermesPanel.tsx @@ -0,0 +1,12 @@ +// client/src/panels/HermesPanel.tsx +export function HermesPanel() { + return ( + + ); +} diff --git a/client/src/panels/TerminalPanel.tsx b/client/src/panels/TerminalPanel.tsx new file mode 100644 index 0000000..7cb7aa0 --- /dev/null +++ b/client/src/panels/TerminalPanel.tsx @@ -0,0 +1,28 @@ +// client/src/panels/TerminalPanel.tsx +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; +import { connectOutput } from "../lib/ws.js"; + +export function TerminalPanel({ machineId }: { machineId: string | null }) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) return; + const term = new Terminal({ + fontFamily: "'Share Tech Mono', monospace", fontSize: 12, + theme: { background: "#1d2021", foreground: "#ebdbb2" }, + convertEol: true, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(ref.current); + fit.fit(); + term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine"); + const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {}; + return () => { disconnect(); term.dispose(); }; + }, [machineId]); + + return
; +}