docs: plan d'implémentation jalon 2 (polish design system)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,894 @@
|
||||
# Jalon 2 — Polish design system — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refondre l'UI existante avec les composants du design system Gruvbox (Button, IconButton, StatusLed, Popup), brancher Font Awesome + les polices en offline, ajouter un header (titre + ajout + bascule thème) et une status bar tmux, et rendre le terminal non ambigu entre machines.
|
||||
|
||||
**Architecture:** Frontend React/Vite. Le `ui-kit.tsx` (design system) passe en exports ESM. L'état (machines, compteurs, machine sélectionnée, thème) remonte dans `App`, qui distribue en props au Header, au Dashboard (présentationnel), à la StatusBar et au TerminalPanel. Helpers purs (`theme`, `stats`) testés ; le reste est vérifié visuellement.
|
||||
|
||||
**Tech Stack:** React 19, Vite 6, @xterm/xterm, @fortawesome/fontawesome-free, @fontsource/{inter,jetbrains-mono,share-tech-mono}, vitest.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
client/src/
|
||||
├─ main.tsx # MODIF: imports CSS FA + polices
|
||||
├─ App.tsx # MODIF: remontée d'état, header+statusbar+thème
|
||||
├─ components/ui-kit.tsx # MODIF: ajout exports ESM (1 ligne)
|
||||
├─ styles/app.css # MODIF: classes header/statusbar/input/term-header
|
||||
├─ lib/
|
||||
│ ├─ theme.ts # NOUVEAU: thème (getInitial/apply/next)
|
||||
│ ├─ theme.test.ts # NOUVEAU
|
||||
│ ├─ stats.ts # NOUVEAU: sumUpdates
|
||||
│ └─ stats.test.ts # NOUVEAU
|
||||
├─ panels/
|
||||
│ ├─ Header.tsx # NOUVEAU
|
||||
│ ├─ StatusBar.tsx # NOUVEAU
|
||||
│ ├─ HermesPanel.tsx # MODIF: label + Icon
|
||||
│ ├─ Dashboard.tsx # MODIF: présentationnel (props)
|
||||
│ └─ TerminalPanel.tsx # MODIF: machine + en-tête + bannière
|
||||
└─ features/machines/
|
||||
├─ MachineTile.tsx # MODIF: StatusLed + IconButton
|
||||
└─ AddMachineModal.tsx # MODIF: Popup + Button
|
||||
vitest.config.ts # MODIF: inclure client/**/*.test.ts
|
||||
package.json # MODIF: deps FA + fontsource
|
||||
```
|
||||
|
||||
Le composant `ui-kit.tsx` ne doit JAMAIS être importé dans un test (il touche `window`/`document` au chargement → KO en environnement node). Les tests ne portent que sur `lib/theme.ts` et `lib/stats.ts` (purs, node-safe).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Brancher le design system (deps + exports + CSS)
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Modify: `client/src/components/ui-kit.tsx` (fin de fichier)
|
||||
- Modify: `client/src/main.tsx`
|
||||
|
||||
- [ ] **Step 1: Installer les dépendances**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
rtk pnpm add @fortawesome/fontawesome-free @fontsource/inter @fontsource/jetbrains-mono @fontsource/share-tech-mono
|
||||
```
|
||||
Expected: 4 paquets ajoutés dans `dependencies`, `pnpm-lock.yaml` mis à jour, install OK.
|
||||
|
||||
- [ ] **Step 2: Exporter les composants du design system**
|
||||
|
||||
Ajouter à la toute fin de `client/src/components/ui-kit.tsx` (après le dernier `})();`) :
|
||||
```ts
|
||||
|
||||
export {
|
||||
Icon, Tooltip, IconButton, Toggle, StatusLed,
|
||||
BatteryGauge, RadialGauge, BigRadialGauge,
|
||||
Popup, Button, TreeNav, Sparkline, LineChart,
|
||||
};
|
||||
```
|
||||
Ne rien supprimer (garder `// @ts-nocheck`, l'import React, le `Object.assign(window, …)`).
|
||||
|
||||
- [ ] **Step 3: Importer FA + polices dans main.tsx**
|
||||
|
||||
Remplacer le contenu de `client/src/main.tsx` par :
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@fortawesome/fontawesome-free/css/all.min.css";
|
||||
import "@fontsource/inter";
|
||||
import "@fontsource/jetbrains-mono";
|
||||
import "@fontsource/share-tech-mono";
|
||||
import "./styles/app.css";
|
||||
import { App } from "./App.js";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vérifier le build**
|
||||
|
||||
Run: `rtk pnpm check && rtk pnpm vite build`
|
||||
Expected: `pnpm check` 0 erreur, build OK (`dist/client` produit, le CSS FA/polices intégré).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add package.json pnpm-lock.yaml client/src/components/ui-kit.tsx client/src/main.tsx
|
||||
rtk git commit -m "feat(ui): brancher le design system (exports ESM, Font Awesome, polices offline)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Helper thème (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `vitest.config.ts`
|
||||
- Create: `client/src/lib/theme.ts`, `client/src/lib/theme.test.ts`
|
||||
|
||||
- [ ] **Step 1: Inclure les tests client dans vitest**
|
||||
|
||||
Dans `vitest.config.ts`, remplacer la ligne `include` par :
|
||||
```ts
|
||||
include: ["server/**/*.test.ts", "shared/**/*.test.ts", "client/**/*.test.ts"],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Écrire le test (échec attendu)**
|
||||
|
||||
`client/src/lib/theme.test.ts` :
|
||||
```ts
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { nextTheme, getInitialTheme } from "./theme.js";
|
||||
|
||||
describe("nextTheme", () => {
|
||||
it("bascule dark <-> light", () => {
|
||||
expect(nextTheme("dark")).toBe("light");
|
||||
expect(nextTheme("light")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInitialTheme", () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - environnement node sans localStorage
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
it("retombe sur dark sans localStorage", () => {
|
||||
expect(getInitialTheme()).toBe("dark");
|
||||
});
|
||||
it("lit la valeur persistée si présente", () => {
|
||||
const store: Record<string, string> = { "su-theme": "light" };
|
||||
// @ts-expect-error - stub minimal
|
||||
globalThis.localStorage = { getItem: (k: string) => store[k] ?? null };
|
||||
expect(getInitialTheme()).toBe("light");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lancer le test (échec)**
|
||||
|
||||
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
|
||||
Expected: FAIL — module `./theme.js` introuvable.
|
||||
|
||||
- [ ] **Step 4: Implémenter `client/src/lib/theme.ts`**
|
||||
|
||||
```ts
|
||||
// client/src/lib/theme.ts
|
||||
export type Theme = "dark" | "light";
|
||||
const KEY = "su-theme";
|
||||
|
||||
export function nextTheme(t: Theme): Theme {
|
||||
return t === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function getInitialTheme(): Theme {
|
||||
try {
|
||||
const v = globalThis.localStorage?.getItem(KEY);
|
||||
return v === "light" ? "light" : "dark";
|
||||
} catch {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(t: Theme): void {
|
||||
try {
|
||||
document.documentElement.dataset.theme = t;
|
||||
globalThis.localStorage?.setItem(KEY, t);
|
||||
} catch {
|
||||
/* localStorage indisponible (mode privé) : on ignore la persistance */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Lancer le test (succès)**
|
||||
|
||||
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add vitest.config.ts client/src/lib/theme.ts client/src/lib/theme.test.ts
|
||||
rtk git commit -m "feat(ui): helper de thème dark/light persisté (TDD)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Helper stats (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/lib/stats.ts`, `client/src/lib/stats.test.ts`
|
||||
|
||||
- [ ] **Step 1: Écrire le test (échec attendu)**
|
||||
|
||||
`client/src/lib/stats.test.ts` :
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sumUpdates } from "./stats.js";
|
||||
|
||||
describe("sumUpdates", () => {
|
||||
it("somme les compteurs", () => {
|
||||
expect(sumUpdates({ a: 2, b: 3, c: 0 })).toBe(5);
|
||||
});
|
||||
it("retourne 0 pour un objet vide", () => {
|
||||
expect(sumUpdates({})).toBe(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test (échec)**
|
||||
|
||||
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
|
||||
Expected: FAIL — module introuvable.
|
||||
|
||||
- [ ] **Step 3: Implémenter `client/src/lib/stats.ts`**
|
||||
|
||||
```ts
|
||||
// client/src/lib/stats.ts
|
||||
export function sumUpdates(counts: Record<string, number>): number {
|
||||
return Object.values(counts).reduce((acc, n) => acc + n, 0);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer le test (succès)**
|
||||
|
||||
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/lib/stats.ts client/src/lib/stats.test.ts
|
||||
rtk git commit -m "feat(ui): helper sumUpdates (TDD)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Classes CSS (header, status bar, inputs, en-tête terminal)
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/styles/app.css`
|
||||
|
||||
- [ ] **Step 1: Mettre à jour `client/src/styles/app.css`**
|
||||
|
||||
Remplacer tout le contenu par :
|
||||
```css
|
||||
@import "./tokens.css";
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
background: var(--bg-1);
|
||||
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; }
|
||||
|
||||
.su-header {
|
||||
height: 52px; flex: 0 0 52px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
}
|
||||
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
||||
.su-spacer { flex: 1; }
|
||||
|
||||
.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-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-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
|
||||
/* Status bar style tmux */
|
||||
.su-statusbar {
|
||||
height: 26px; flex: 0 0 26px;
|
||||
display: flex; align-items: stretch;
|
||||
background: var(--bg-2);
|
||||
border-top: 1px solid var(--border-1);
|
||||
font-family: var(--font-terminal);
|
||||
font-size: 11px;
|
||||
}
|
||||
.su-statusbar .cell { display: flex; align-items: center; padding: 0 12px; border-right: 1px solid var(--border-1); color: var(--ink-2); }
|
||||
.su-statusbar .cell.mode { background: var(--accent); color: var(--bg-1); font-weight: 700; letter-spacing: 0.08em; }
|
||||
.su-statusbar .clock { margin-left: auto; border-right: none; border-left: 1px solid var(--border-1); }
|
||||
|
||||
/* Champs de formulaire tokenisés */
|
||||
.su-field {
|
||||
padding: 9px 12px; font-size: 13px; font-family: var(--font-ui);
|
||||
background: var(--bg-1); color: var(--ink-1);
|
||||
border: 1px solid var(--border-2); border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
.su-field:focus { border-color: var(--accent-soft); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier le build**
|
||||
|
||||
Run: `rtk pnpm vite build`
|
||||
Expected: build OK.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/styles/app.css
|
||||
rtk git commit -m "feat(ui): classes layout header/statusbar/inputs/terminal
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Composant Header
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/panels/Header.tsx`
|
||||
|
||||
- [ ] **Step 1: Créer `client/src/panels/Header.tsx`**
|
||||
|
||||
```tsx
|
||||
// client/src/panels/Header.tsx
|
||||
import { Button, IconButton } from "../components/ui-kit.js";
|
||||
import type { Theme } from "../lib/theme.js";
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
onToggleTheme: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function Header({ theme, onToggleTheme, onAdd }: Props) {
|
||||
return (
|
||||
<header className="su-header">
|
||||
<h1>System Update</h1>
|
||||
<div className="su-spacer" />
|
||||
<Button variant="primary" icon="plus" onClick={onAdd}>Ajouter</Button>
|
||||
<IconButton
|
||||
icon={theme === "dark" ? "sun" : "moon"}
|
||||
label={theme === "dark" ? "Thème clair" : "Thème sombre"}
|
||||
onClick={onToggleTheme}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier la compilation**
|
||||
|
||||
Run: `rtk pnpm check`
|
||||
Expected: 0 erreur.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/panels/Header.tsx
|
||||
rtk git commit -m "feat(ui): header (titre, ajout, bascule thème)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Composant StatusBar
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/panels/StatusBar.tsx`
|
||||
|
||||
- [ ] **Step 1: Créer `client/src/panels/StatusBar.tsx`**
|
||||
|
||||
```tsx
|
||||
// client/src/panels/StatusBar.tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { sumUpdates } from "../lib/stats.js";
|
||||
|
||||
interface Props {
|
||||
machineCount: number;
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export function StatusBar({ machineCount, counts }: Props) {
|
||||
const [clock, setClock] = useState(() => new Date().toLocaleTimeString("fr-FR"));
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setClock(new Date().toLocaleTimeString("fr-FR")), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="su-statusbar">
|
||||
<div className="cell mode">SYSTEM UPDATE</div>
|
||||
<div className="cell">{machineCount} machine{machineCount > 1 ? "s" : ""}</div>
|
||||
<div className="cell">{sumUpdates(counts)} update{sumUpdates(counts) > 1 ? "s" : ""}</div>
|
||||
<div className="cell clock">{clock}</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier la compilation**
|
||||
|
||||
Run: `rtk pnpm check`
|
||||
Expected: 0 erreur.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/panels/StatusBar.tsx
|
||||
rtk git commit -m "feat(ui): status bar tmux (mode, compteurs, horloge live)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Refonte MachineTile (StatusLed + IconButton)
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/features/machines/MachineTile.tsx`
|
||||
|
||||
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/MachineTile.tsx`**
|
||||
|
||||
```tsx
|
||||
// client/src/features/machines/MachineTile.tsx
|
||||
import type { MachineView } from "@shared/types.js";
|
||||
import { StatusLed, IconButton } from "../../components/ui-kit.js";
|
||||
|
||||
interface Props {
|
||||
machine: MachineView;
|
||||
packageCount: number;
|
||||
selected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onRefresh: (id: string) => void;
|
||||
onUpgrade: (id: string) => void;
|
||||
onReboot: (id: string) => void;
|
||||
}
|
||||
|
||||
// Map statut machine -> statut StatusLed du design system
|
||||
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
|
||||
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
|
||||
};
|
||||
|
||||
export function MachineTile({ machine, packageCount, selected, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="glass interactive"
|
||||
style={{ padding: 16, borderRadius: 10, border: selected ? "1px solid var(--accent-soft)" : "1px solid transparent" }}
|
||||
onClick={() => onSelect(machine.id)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<StatusLed status={LED[machine.status] ?? "off"} pulse={machine.status === "running"} />
|
||||
<strong>{machine.name}</strong>
|
||||
</div>
|
||||
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12, marginTop: 4 }}>
|
||||
{machine.hostname}:{machine.port} · {machine.osFamily}
|
||||
</div>
|
||||
<div style={{ margin: "10px 0", fontSize: 13 }}>
|
||||
<span className="label">UPDATES</span>{" "}
|
||||
<span className="mono">{packageCount}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton icon="refresh" label="Rafraîchir" onClick={() => onRefresh(machine.id)} size={30} />
|
||||
<IconButton icon="download" label="Upgrade" onClick={() => onUpgrade(machine.id)} size={30} />
|
||||
<IconButton icon="power" label="Redémarrer" danger onClick={() => onReboot(machine.id)} size={30} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier la compilation**
|
||||
|
||||
Run: `rtk pnpm check`
|
||||
Expected: 0 erreur. (Le prop `selected` sera fourni par le Dashboard en Task 10.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/features/machines/MachineTile.tsx
|
||||
rtk git commit -m "feat(ui): tuile machine avec StatusLed + IconButton (tooltips)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Refonte AddMachineModal (Popup + Button)
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/features/machines/AddMachineModal.tsx`
|
||||
|
||||
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/AddMachineModal.tsx`**
|
||||
|
||||
```tsx
|
||||
// client/src/features/machines/AddMachineModal.tsx
|
||||
import { useState } from "react";
|
||||
import { Popup, Button, StatusLed } from "../../components/ui-kit.js";
|
||||
|
||||
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<string | null>(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 (
|
||||
<Popup
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Ajouter une machine"
|
||||
width={400}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>Annuler</Button>
|
||||
<Button variant="primary" icon="download" onClick={submit}>{busy ? "Test…" : "Ajouter"}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
<input className="su-field" placeholder="nom" value={form.name} onChange={(e) => set("name", e.target.value)} />
|
||||
<input className="su-field" placeholder="hostname / IP" value={form.hostname} onChange={(e) => set("hostname", e.target.value)} />
|
||||
<input className="su-field" placeholder="username" value={form.username} onChange={(e) => set("username", e.target.value)} />
|
||||
<input className="su-field" placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
|
||||
<input className="su-field" placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
|
||||
<input className="su-field" placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
|
||||
{error && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--err)", fontSize: 12 }}>
|
||||
<StatusLed status="err" /> {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier la compilation**
|
||||
|
||||
Run: `rtk pnpm check`
|
||||
Expected: 0 erreur.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/features/machines/AddMachineModal.tsx
|
||||
rtk git commit -m "feat(ui): modale d'ajout avec Popup + Button du design system
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Refonte TerminalPanel (machine + en-tête + bannière de séparation)
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/panels/TerminalPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/TerminalPanel.tsx`**
|
||||
|
||||
```tsx
|
||||
// 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 type { MachineView } from "@shared/types.js";
|
||||
import { connectOutput } from "../lib/ws.js";
|
||||
import { StatusLed } from "../components/ui-kit.js";
|
||||
|
||||
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
|
||||
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
|
||||
};
|
||||
|
||||
export function TerminalPanel({ machine }: { machine: MachineView | null }) {
|
||||
const ref = useRef<HTMLDivElement>(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();
|
||||
if (machine) {
|
||||
// Bannière de séparation franche entre machines (couleur accent ANSI 33).
|
||||
const bar = "─".repeat(20);
|
||||
term.writeln(`\x1b[33m${bar} ${machine.name} (${machine.hostname}) ${bar}\x1b[0m`);
|
||||
} else {
|
||||
term.writeln("# sélectionne une machine");
|
||||
}
|
||||
const disconnect = machine ? connectOutput(machine.id, (c) => term.write(c)) : () => {};
|
||||
return () => { disconnect(); term.dispose(); };
|
||||
}, [machine?.id]);
|
||||
|
||||
return (
|
||||
<section className="su-terminal-wrap">
|
||||
<div className="su-terminal-head">
|
||||
<StatusLed status={machine ? (LED[machine.status] ?? "off") : "off"} />
|
||||
<span className="label">TERMINAL</span>
|
||||
{machine && <span className="mono" style={{ fontSize: 12 }}>{machine.name}</span>}
|
||||
{machine && <span style={{ color: "var(--ink-3)", fontSize: 11 }}>{machine.hostname}</span>}
|
||||
</div>
|
||||
<div className="su-terminal" ref={ref} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier la compilation**
|
||||
|
||||
Run: `rtk pnpm check`
|
||||
Expected: 0 erreur. (Le prop `machine` sera fourni par `App` en Task 11.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/panels/TerminalPanel.tsx
|
||||
rtk git commit -m "feat(ui): terminal identifie la machine + bannière de séparation
|
||||
|
||||
Répond au retour d'usage (amelioration.md): séparation franche entre machines.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Dashboard présentationnel + HermesPanel
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/panels/Dashboard.tsx`
|
||||
- Modify: `client/src/panels/HermesPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/Dashboard.tsx`**
|
||||
|
||||
```tsx
|
||||
// client/src/panels/Dashboard.tsx
|
||||
import type { MachineView } from "@shared/types.js";
|
||||
import { MachineTile } from "../features/machines/MachineTile.js";
|
||||
|
||||
interface Props {
|
||||
machines: MachineView[];
|
||||
counts: Record<string, number>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onRefresh: (id: string) => void;
|
||||
onUpgrade: (id: string) => void;
|
||||
onReboot: (id: string) => void;
|
||||
}
|
||||
|
||||
export function Dashboard({ machines, counts, selectedId, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
|
||||
return (
|
||||
<main className="su-center">
|
||||
<h2 style={{ margin: "0 0 16px" }}>Machines</h2>
|
||||
{machines.length === 0 && (
|
||||
<p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « Ajouter » dans l'en-tête.</p>
|
||||
)}
|
||||
<div className="su-tiles">
|
||||
{machines.map((m) => (
|
||||
<MachineTile
|
||||
key={m.id}
|
||||
machine={m}
|
||||
packageCount={counts[m.id] ?? 0}
|
||||
selected={selectedId === m.id}
|
||||
onSelect={onSelect}
|
||||
onRefresh={onRefresh}
|
||||
onUpgrade={onUpgrade}
|
||||
onReboot={onReboot}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remplacer le contenu de `client/src/panels/HermesPanel.tsx`**
|
||||
|
||||
```tsx
|
||||
// client/src/panels/HermesPanel.tsx
|
||||
import { Icon } from "../components/ui-kit.js";
|
||||
|
||||
export function HermesPanel() {
|
||||
return (
|
||||
<aside className="su-hermes">
|
||||
<div className="label" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<Icon name="bell" size={13} /> HERMES
|
||||
</div>
|
||||
<p style={{ color: "var(--ink-3)", fontSize: 13 }}>
|
||||
Copilote d'exploitation — à venir. Analyse des mises à jour, plans et rapports
|
||||
seront disponibles ici dans un prochain jalon.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Vérifier la compilation**
|
||||
|
||||
Run: `rtk pnpm check`
|
||||
Expected: 0 erreur.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/panels/Dashboard.tsx client/src/panels/HermesPanel.tsx
|
||||
rtk git commit -m "feat(ui): Dashboard présentationnel (props) + HermesPanel iconé
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: App — remontée d'état, thème, header + status bar
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Remplacer le contenu de `client/src/App.tsx`**
|
||||
|
||||
```tsx
|
||||
// client/src/App.tsx
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { MachineView } from "@shared/types.js";
|
||||
import { api } from "./lib/api.js";
|
||||
import { getInitialTheme, applyTheme, nextTheme, type Theme } from "./lib/theme.js";
|
||||
import { Header } from "./panels/Header.js";
|
||||
import { StatusBar } from "./panels/StatusBar.js";
|
||||
import { HermesPanel } from "./panels/HermesPanel.js";
|
||||
import { Dashboard } from "./panels/Dashboard.js";
|
||||
import { TerminalPanel } from "./panels/TerminalPanel.js";
|
||||
import { AddMachineModal } from "./features/machines/AddMachineModal.js";
|
||||
|
||||
export function App() {
|
||||
const [machines, setMachines] = useState<MachineView[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
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(() => {
|
||||
const initial = getInitialTheme();
|
||||
setTheme(initial);
|
||||
applyTheme(initial);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const t = nextTheme(theme);
|
||||
setTheme(t);
|
||||
applyTheme(t);
|
||||
};
|
||||
|
||||
const onRefresh = (id: string) => { setSelectedId(id); void api.refresh(id).then(load); };
|
||||
const onUpgrade = (id: string) => { setSelectedId(id); void api.runAction(id, "apt_full_upgrade"); };
|
||||
const onReboot = (id: string) => { setSelectedId(id); void api.runAction(id, "reboot"); };
|
||||
|
||||
const selected = machines.find((m) => m.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="su-app">
|
||||
<Header theme={theme} onToggleTheme={toggleTheme} onAdd={() => setAdding(true)} />
|
||||
<div className="su-row">
|
||||
<HermesPanel />
|
||||
<Dashboard
|
||||
machines={machines}
|
||||
counts={counts}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onRefresh={onRefresh}
|
||||
onUpgrade={onUpgrade}
|
||||
onReboot={onReboot}
|
||||
/>
|
||||
<TerminalPanel machine={selected} />
|
||||
</div>
|
||||
<StatusBar machineCount={machines.length} counts={counts} />
|
||||
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier compilation + build complet**
|
||||
|
||||
Run: `rtk pnpm check && rtk pnpm vite build`
|
||||
Expected: 0 erreur TS, build OK.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
rtk git add client/src/App.tsx
|
||||
rtk git commit -m "feat(ui): App orchestre état+thème, header et status bar
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Vérification finale (build + tests + deux thèmes)
|
||||
|
||||
**Files:** aucun (vérification).
|
||||
|
||||
- [ ] **Step 1: Suite complète**
|
||||
|
||||
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
|
||||
Expected: `check` 0 erreur ; tests verts (serveur 19 + theme 3 + stats 2 = 24) ; build OK (`dist/index.js` + `dist/client`).
|
||||
|
||||
- [ ] **Step 2: Vérification visuelle manuelle (utilisateur, navigateur)**
|
||||
|
||||
Lancer `pnpm dev`, ouvrir `http://localhost:5173`, vérifier :
|
||||
- Header avec titre, bouton « Ajouter » (icône +), bascule thème (soleil/lune) qui change l'apparence et persiste après rechargement (F5).
|
||||
- Les **deux thèmes** (dark ET light) restent lisibles et cohérents.
|
||||
- Icônes Font Awesome affichées (pas de carré vide), polices Inter/JetBrains Mono/Share Tech Mono appliquées.
|
||||
- Tuiles : StatusLed colorée selon l'état, 3 IconButton avec **tooltips** au survol, **pas de hover** (pression 3D au clic seulement), sélection visible (bordure accent).
|
||||
- Modale d'ajout = Popup (titre, bouton fermer, footer Annuler/Ajouter).
|
||||
- Status bar en bas : « SYSTEM UPDATE » + nb machines + nb updates + **horloge qui avance**.
|
||||
- Terminal : en-tête avec nom + hostname de la machine ; en sélectionnant une autre machine, **bannière de séparation** claire (ligne accent avec nom/hostname). Plus d'UUID affiché.
|
||||
|
||||
- [ ] **Step 3: Commit éventuel de finition**
|
||||
|
||||
S'il a fallu un ajustement après vérif visuelle, le committer :
|
||||
```bash
|
||||
rtk git add -A
|
||||
rtk git commit -m "fix(ui): ajustements après vérification visuelle des deux thèmes
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture du spec)
|
||||
|
||||
- **Wiring DS (exports ESM + FA + polices)** → Task 1. ✓
|
||||
- **ui-kit jamais importé en test** → tests uniquement sur lib/theme + lib/stats (Tasks 2, 3) ; vitest include ajoute `client/**` mais les seuls tests client sont ces helpers purs. ✓
|
||||
- **Header (titre, ajout, bascule thème)** → Task 5, câblé en Task 11. ✓
|
||||
- **Status bar tmux (mode, compteurs, horloge)** → Task 6, câblée Task 11. ✓
|
||||
- **Thème dark/light persisté** → Task 2 (`lib/theme`), appliqué Task 11. ✓
|
||||
- **MachineTile : StatusLed + IconButton (tooltips), danger reboot** → Task 7. ✓
|
||||
- **AddMachineModal : Popup + Button** → Task 8. ✓
|
||||
- **Dashboard présentationnel** → Task 10. ✓
|
||||
- **TerminalPanel : machine nommée + bannière de séparation (retour amelioration.md)** → Task 9. ✓
|
||||
- **Remontée d'état dans App** → Task 11. ✓
|
||||
- **Deux thèmes vérifiés** → Task 12 step 2. ✓
|
||||
- **Tests helpers + build** → Tasks 2, 3, 12. ✓
|
||||
|
||||
Pas de placeholder. Noms cohérents entre tâches : `MachineTile` reçoit `selected` (Task 7) fourni par Dashboard `selectedId===m.id` (Task 10) depuis App `selectedId` (Task 11) ; `TerminalPanel` reçoit `machine` (Task 9) fourni par App `selected` (Task 11) ; helpers `getInitialTheme`/`applyTheme`/`nextTheme`/`sumUpdates` utilisés tels que définis.
|
||||
Reference in New Issue
Block a user