docs: fondation projet (CLAUDE.md, design system, spec + plan jalon 1)
Ignore les dépôts de référence imbriqués (linux-update-dashboard, nas-ops). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git checkout *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,7 @@ data/
|
||||
.env
|
||||
reports/*
|
||||
!reports/.gitkeep
|
||||
|
||||
# Dépôts de référence (git imbriqués) — inspiration uniquement, gérés séparément
|
||||
linux-update-dashboard/
|
||||
nas-ops/
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Langue de travail
|
||||
|
||||
Répondre et documenter **en français**. C'est la langue du projet et de l'utilisateur.
|
||||
|
||||
## Nature du projet et état actuel
|
||||
|
||||
Objectif : construire une **webapp de mise à jour distante de machines Linux** (Debian, Ubuntu, Proxmox, Raspberry Pi OS) + Docker Compose, pilotée par SSH agentless, avec un copilote IA (« Hermes ») branché via un serveur MCP.
|
||||
|
||||
**Le projet est en phase de conception — l'application n'est pas encore initialisée.** Le dépôt ne contient pour l'instant que :
|
||||
|
||||
- `deep-research-report(7).md` — l'étude d'architecture de référence (stack, contrats JSON, flux, sécurité). **À lire avant toute décision d'architecture.**
|
||||
- `ajout.md` — l'ajout du volet Hermes et le périmètre du serveur MCP.
|
||||
- `design_system/` — le design system **Gruvbox seventies** à utiliser pour tout le frontend (voir section dédiée).
|
||||
- `linux-update-dashboard/` et `nas-ops/` — **dépôts de référence en lecture seule** (voir ci-dessous).
|
||||
|
||||
Avant d'écrire du code applicatif, proposer/valider une structure avec l'utilisateur : ne pas figer l'architecture finale seul.
|
||||
|
||||
## Dépôts de référence (inspiration, PAS copie)
|
||||
|
||||
Ces deux dossiers sont des **sources d'analyse**, pas du code à recopier. Ne pas réutiliser de larges portions sans réécriture adaptée et vérification de licence.
|
||||
|
||||
- **`linux-update-dashboard/`** — licence **AGPL-3.0**. Excellent modèle d'**orchestration web agentless par SSH** : front React 19 + Vite + Tailwind 4, backend Hono, Drizzle + SQLite, SSH2, WebSocket de flux live (`/api/ws/systems/:id/output` avec messages `started`/`output`/`phase`/`done`/`error` et buffer rejouable), exécution détachée `nohup` survivant aux coupures SSH, credentials chiffrés au repos, host-key approval, ProxyJump. La contrainte AGPL impose de **reconstruire notre propre code**.
|
||||
- **`nas-ops/`** — scripts Bash déterministes **JSON-friendly** (`nas-system-update`, `nas-system-upgrade`, `nas-docker-pull`, `nas-docker-up`). Modèle pour la **logique métier dans des scripts shell** qui détectent puis émettent un JSON compact (compare les image IDs avant/après pull, lit les labels compose). Licence non confirmée — traiter en référence.
|
||||
|
||||
Principe directeur issu de l'étude : **fusionner les deux** — orchestration web façon `linux-update-dashboard` + logique métier en templates shell versionnés façon `nas-ops`.
|
||||
|
||||
## Architecture cible (à construire)
|
||||
|
||||
Quatre couches, le backend **orchestre mais ne connaît pas la logique fine des mises à jour** :
|
||||
|
||||
1. **UI** — React + TypeScript + Vite. Pas de machine pré-déclarée ; ajout via bouton `+`.
|
||||
2. **API** — Hono/TypeScript. Stocke machines, credentials (chiffrés), templates, jobs, rapports.
|
||||
3. **Worker** — refresh et opérations longues en arrière-plan (file de jobs ; pg-boss sur Postgres recommandé en MVP).
|
||||
4. **SSH/script runtime** — pousse les commandes, normalise les retours en **JSON canonique**.
|
||||
|
||||
La logique « comment mettre à jour APT / lister les stacks Docker » vit dans des **templates shell versionnés sur disque** (OS profile-aware), éditables depuis le front mais sauvegardés comme ressources de projet, avec overrides par machine. Ne pas stocker les commandes critiques uniquement en base.
|
||||
|
||||
Stack recommandée par l'étude : front shadcn/ui + Lucide, **Monaco** pour éditer les templates, **xterm.js** (couleurs ANSI, addon WebSocket) pour le terminal live à droite, layout `Resizable`. Backend Hono. Stockage **PostgreSQL + Drizzle** (plutôt que SQLite pour le projet final). Ces choix restent à confirmer avec l'utilisateur (cf. « Questions ouvertes » du rapport).
|
||||
|
||||
## Règles fonctionnelles (invariants)
|
||||
|
||||
- **`update`/`check` = tâche de fond** ; **`upgrade`/`full-upgrade`/`dist-upgrade`/`docker apply`/`reboot` = action manuelle validée** dans l'UI.
|
||||
- Toujours distinguer **détection → planification → exécution**.
|
||||
- Toujours produire un **JSON canonique par machine**. Deux schémas pivots : *update availability snapshot* et *execution result* (exemples complets dans `deep-research-report(7).md`).
|
||||
- Après chaque exécution, **archiver un rapport `.md`** (et garder le log brut référencé, pas inliné).
|
||||
- **Réduction déterministe avant tout appel LLM** : ne garder que les lignes utiles (APT : `Inst`/`Conf`/`Remv`/`Err`/`E:`/`W:`/`dpkg:`/`reboot-required` ; Docker : `Pulling`/`Digest`/`Status`/`Downloaded newer image`/`Recreating`/`Started`/`Error`). Le reste reste dans le log archivé.
|
||||
- **Déduplication** des updates entre machines par empreinte fonctionnelle — système : `os_family + package + from + to + origin` ; Docker : `image + fromDigest + toDigest`.
|
||||
- Docker : détecter via labels compose des conteneurs *en cours*, mais prévoir un **fallback par scan des répertoires Compose déclarés** (stacks non démarrées).
|
||||
|
||||
## Sécurité (non négociable)
|
||||
|
||||
- **Aucun secret ne transite vers un agent/LLM ni dans un prompt** : mots de passe SSH, sudo password, tokens, clés privées restent côté backend.
|
||||
- **Aucun secret en clair** dans les logs, l'UI ou les retours MCP.
|
||||
- Credentials **chiffrés au repos**. Vérification des host keys. Bastion/ProxyJump prévu. Utilisateur dédié de maintenance, sudo réduit au strict nécessaire.
|
||||
- App pensée pour un **réseau de confiance** derrière reverse proxy/TLS/VPN, pas exposée directement à Internet.
|
||||
|
||||
## Hermes / serveur MCP
|
||||
|
||||
Hermes est un **copilote d'analyse**, la **webapp reste le chef d'orchestre**. Layout 3 zones : volet gauche = Hermes (chat, actions rapides, liens rapports), centre = dashboard machines, droite = terminal live.
|
||||
|
||||
- **Hermes n'exécute jamais de SSH directement.** Il passe uniquement par l'API interne / le serveur MCP, sur des actions explicitement autorisées, en consommant les JSON normalisés.
|
||||
- Le **MCP est une façade** de l'API métier (pas de logique SSH dedans). Il ne reçoit jamais de secret ; les actions destructives exigent une validation côté webapp.
|
||||
- Surface MCP volontairement **petite** (v1) : `list_machines`, `get_machine_snapshot`, `get_machine_execution`, `run_refresh`, `run_action`, `list_templates`, `preview_template`, `search_reports`. Contrats détaillés dans `deep-research-report(7).md` → `docs/MCP_CONTRACTS.md`.
|
||||
- Le rôle d'Hermes : lire un snapshot, regrouper les doublons, recherche web ciblée sur les inconnus, proposer un plan court, rédiger un rapport Markdown. La skill `update-ops-planner` (gabarit dans le rapport) encapsule ce mode d'emploi.
|
||||
|
||||
## Design system — Gruvbox seventies (frontend)
|
||||
|
||||
**Tout le frontend doit suivre `design_system/consigne_design_system.md` — le lire en entier avant d'écrire du JSX.** Vibe rétro-industriel / console de monitoring. Règles dures :
|
||||
|
||||
- **Variables CSS uniquement**, jamais de hex en dur (`var(--accent)`, pas `#fe8019`). Toujours un `data-theme` (`dark`/`light`) sur un parent, et vérifier les deux thèmes.
|
||||
- **Réutiliser les composants existants** de `design_system/components/ui-kit.jsx` (Button, IconButton, Toggle, StatusLed, RadialGauge, BatteryGauge, Popup, TreeNav, Sparkline, LineChart, Tooltip, Icon) avant d'en créer.
|
||||
- **Icônes** via `<Icon name="…">` (noms mappés Font Awesome) — **jamais d'emoji ni de SVG inline custom**.
|
||||
- **Pas de hover** sur boutons/tuiles (sauf jauges) — seulement la pression 3D `.interactive`. IconButton seul → `label` obligatoire (tooltip). Jamais `window.alert`/`confirm` → `<Popup>`.
|
||||
- Polices strictes : **Inter** (UI), **JetBrains Mono** (données/IDs/IPs), **Share Tech Mono** (logs/terminal). Labels en uppercase `.label`.
|
||||
- Tokens : `design_system/tokens/tokens.css` (web), `tokens.gnome.css` (GTK4/libadwaita), `tokens.json`.
|
||||
|
||||
## Commandes (dépôt de référence uniquement)
|
||||
|
||||
Le projet principal n'a pas encore de toolchain. Les commandes ci-dessous concernent **`linux-update-dashboard/`** (pnpm, Node 24, à lancer depuis ce sous-dossier) et servent de modèle pour notre futur setup :
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # serveur (tsx watch) + client (vite) en parallèle
|
||||
pnpm build # vite build && tsup
|
||||
pnpm start # node dist/server/index.js
|
||||
pnpm test # vitest run (un seul test : pnpm test -- <pattern>)
|
||||
pnpm check # tsc --noEmit
|
||||
```
|
||||
|
||||
## Fichiers de consigne à créer au démarrage
|
||||
|
||||
L'étude (`ajout.md`) liste les fichiers à mettre en place : `docs/CONSIGNE_PROJET.md`, `docs/ARCHITECTURE.md`, `docs/MCP_CONTRACTS.md`, `docs/HERMES_INTEGRATION.md`, `docs/SECURITY.md`, `docs/JSON_SCHEMAS.md`, `docs/TEMPLATES_UPDATE.md`, `hermes-skills/update-ops-planner/SKILL.md`, `mcp/README.md`, et les `templates/apt/*.sh.tpl` + `templates/docker/*.sh.tpl`. Les dépôts de référence sont à déplacer sous `references/` une fois le projet structuré.
|
||||
|
||||
## Posture attendue de l'agent
|
||||
|
||||
Pour toute proposition technique, distinguer **MVP recommandé / alternatives / risques**. Éviter les abstractions et générateurs opaques. Préférer des noms de dossiers explicites et une convention stable. En cas de doute sur l'architecture finale, **demander** plutôt que figer.
|
||||
@@ -0,0 +1,150 @@
|
||||
À prévoir dans les consignes : un volet gauche “Hermes” intégré à la webapp.
|
||||
|
||||
Architecture proposée :
|
||||
|
||||
Frontend Webapp
|
||||
├─ Centre : dashboard machines / tuiles / actions
|
||||
├─ Droite : terminal live xterm.js
|
||||
└─ Gauche : panneau Hermes
|
||||
├─ chat avec Hermes
|
||||
├─ envoi d’ordres structurés
|
||||
├─ retour d’analyse
|
||||
├─ plan de mise à jour proposé
|
||||
└─ liens vers rapports Markdown
|
||||
|
||||
Principe important : Hermes ne doit pas exécuter directement les commandes SSH. Il dialogue avec la webapp via API/MCP, analyse les snapshots JSON, propose un plan, puis l’utilisateur valide les actions dans l’interface.
|
||||
|
||||
À ajouter dans les fichiers de consigne :
|
||||
|
||||
Ajout : intégration Hermes dans la webapp
|
||||
|
||||
Prévoir un panneau latéral gauche dédié à Hermes Agent.
|
||||
|
||||
Objectif
|
||||
|
||||
Permettre à l’utilisateur de dialoguer avec Hermes depuis la webapp pour :
|
||||
|
||||
transmettre des messages ;
|
||||
demander une analyse des mises à jour disponibles ;
|
||||
demander un plan de mise à jour ;
|
||||
analyser les erreurs après exécution ;
|
||||
générer ou consulter un rapport Markdown ;
|
||||
demander une synthèse globale multi-machines.
|
||||
Règle de sécurité
|
||||
|
||||
Hermes ne doit jamais exécuter directement de commandes SSH.
|
||||
|
||||
Hermes doit uniquement passer par :
|
||||
|
||||
l’API interne de la webapp ;
|
||||
le serveur MCP du projet ;
|
||||
les actions explicitement autorisées ;
|
||||
les JSON normalisés produits par la webapp.
|
||||
|
||||
Les secrets SSH, mots de passe, sudo password, tokens et clés privées ne doivent jamais être transmis à Hermes.
|
||||
|
||||
UX attendue
|
||||
|
||||
Le layout principal doit prévoir trois zones :
|
||||
|
||||
volet gauche : assistant Hermes ;
|
||||
zone centrale : dashboard machines ;
|
||||
volet droit : terminal live.
|
||||
|
||||
Le volet Hermes doit permettre :
|
||||
|
||||
historique de conversation ;
|
||||
saisie de message ;
|
||||
boutons d’actions rapides :
|
||||
analyser les mises à jour ;
|
||||
proposer un plan ;
|
||||
analyser les erreurs ;
|
||||
générer rapport ;
|
||||
comparer plusieurs machines ;
|
||||
affichage des réponses Hermes ;
|
||||
lien vers les rapports Markdown archivés.
|
||||
Flux recommandé
|
||||
La webapp collecte les mises à jour disponibles.
|
||||
Elle produit un snapshot JSON par machine.
|
||||
Le frontend peut transmettre à Hermes une version filtrée et dédupliquée.
|
||||
Hermes analyse les mises à jour.
|
||||
Hermes propose un plan.
|
||||
L’utilisateur valide manuellement.
|
||||
La webapp exécute les actions.
|
||||
La webapp remonte un JSON de résultat.
|
||||
Hermes peut analyser le résultat et générer un rapport Markdown.
|
||||
Fichiers à prévoir dans le projet
|
||||
|
||||
Créer les fichiers suivants dès le démarrage du projet :
|
||||
|
||||
CLAUDE.md
|
||||
docs/CONSIGNE_PROJET.md
|
||||
docs/ARCHITECTURE.md
|
||||
docs/MCP_CONTRACTS.md
|
||||
docs/HERMES_INTEGRATION.md
|
||||
docs/SECURITY.md
|
||||
docs/JSON_SCHEMAS.md
|
||||
docs/TEMPLATES_UPDATE.md
|
||||
hermes-skills/update-ops-planner/SKILL.md
|
||||
mcp/README.md
|
||||
templates/apt/update.sh.tpl
|
||||
templates/apt/upgrade.sh.tpl
|
||||
templates/apt/full-upgrade.sh.tpl
|
||||
templates/apt/dist-upgrade.sh.tpl
|
||||
templates/apt/clean.sh.tpl
|
||||
templates/apt/autoremove.sh.tpl
|
||||
templates/apt/reboot-check.sh.tpl
|
||||
templates/docker/scan-compose.sh.tpl
|
||||
templates/docker/pull.sh.tpl
|
||||
templates/docker/up.sh.tpl
|
||||
templates/docker/prune-images.sh.tpl
|
||||
|
||||
Les fichiers importants doivent être modifiables par l’agent Claude Code, mais la structure finale sera décidée après échange avec l’agent.
|
||||
|
||||
Les dépôts de référence doivent être placés dans un dossier dédié :
|
||||
|
||||
references/
|
||||
├─ linux-update-dashboard/
|
||||
└─ nas-ops/
|
||||
|
||||
Ils servent d’inspiration et de comparaison technique, pas de copie directe.
|
||||
|
||||
Ajout : rôle du MCP server pour Hermes
|
||||
|
||||
Le serveur MCP doit exposer une interface stable entre la webapp et les agents.
|
||||
|
||||
Tools MCP proposés
|
||||
list_machines
|
||||
get_machine_snapshot
|
||||
get_all_snapshots
|
||||
get_machine_execution_result
|
||||
get_report
|
||||
search_reports
|
||||
run_refresh
|
||||
request_action_plan
|
||||
run_approved_action
|
||||
list_templates
|
||||
preview_template
|
||||
Règles
|
||||
Le MCP ne reçoit jamais de secret.
|
||||
Le MCP ne donne accès qu’aux actions prévues.
|
||||
Les actions dangereuses nécessitent validation côté webapp.
|
||||
Hermes peut proposer, analyser et documenter.
|
||||
La webapp reste responsable de l’exécution réelle.
|
||||
Exemple de commande agent
|
||||
|
||||
L’utilisateur peut écrire dans le volet Hermes :
|
||||
|
||||
Analyse les mises à jour disponibles sur toutes les machines Debian et Proxmox, regroupe les doublons, puis propose un ordre de mise à jour.
|
||||
|
||||
Hermes doit alors :
|
||||
|
||||
récupérer les snapshots via MCP ;
|
||||
dédupliquer les paquets et images Docker ;
|
||||
rechercher les composants importants si besoin ;
|
||||
produire un plan court ;
|
||||
générer un rapport Markdown archivable.
|
||||
|
||||
|
||||
|
||||
Je retiendrais donc : Hermes comme copilote d’exploitation, mais webapp comme chef d’orchestre sécurisé.
|
||||
@@ -0,0 +1,401 @@
|
||||
# Étude d’architecture pour une webapp de mise à jour distante Linux
|
||||
|
||||
## Ce que montrent les deux dépôts
|
||||
|
||||
Le dépôt **linux-update-dashboard** est déjà très proche de votre besoin sur la partie orchestration web : il s’agit d’une application TypeScript presque entièrement composée d’un **frontend React 19 + Vite 8 + Tailwind CSS 4** et d’un **backend Hono**, avec **Drizzle ORM**, **SQLite**, **SSH2**, **WebSocket**, **highlight.js**, **Mustache**, **OIDC**, **WebAuthn**, **MQTT** et des scripts de planification. Le README et l’arborescence indiquent aussi des dossiers `client/`, `server/`, `ssh/`, `services/`, `routes/` et `db/`, ce qui montre une séparation assez propre entre UI, API, logique métier, stockage et exécution SSH. citeturn24view1turn39view0turn6view0turn6view1turn25search4turn25search5
|
||||
|
||||
Sur le plan d’exécution, **linux-update-dashboard** expose des routes API pour les systèmes, les mises à jour, les scripts, les réglages et les credentials, ainsi qu’une route **WebSocket** `/api/ws/systems/:id/output` dédiée au flux live des commandes. Son service `output-stream` publie des messages structurés (`started`, `output`, `phase`, `done`, `error`, `warning`) et conserve un buffer rejouable pour les clients qui se reconnectent. La couche SSH encapsule les commandes via `sh -c`, force `LC_ALL=C` et un `PATH` minimal pour stabiliser les sorties, et sait lancer des scripts détachés `nohup` avec fichiers de log et d’exit code, ce qui permet aux opérations longues de survivre à une coupure SSH. citeturn7view0turn12view1turn12view3turn11view0
|
||||
|
||||
Le dépôt **nas-ops** apporte l’autre moitié de la solution : des **scripts Bash simples, ciblés et JSON-friendly**. `nas-system-update` exécute `apt-get update -qq`, simule un `full-upgrade`, extrait les paquets upgradables et retourne un JSON avec `count`, `packages` et `reboot_required` en mode non interactif. `nas-system-upgrade` applique `apt-get full-upgrade -y` avec options `dpkg` défensives, puis renvoie un JSON de résultat. Côté Docker, `nas-docker-pull` inspecte les conteneurs, compare les IDs d’image avant/après `docker pull`, lit des labels comme le dépôt source et les versions, puis émet un JSON listant uniquement les conteneurs réellement concernés ; `nas-docker-up` remonte ensuite les stacks via `docker compose up -d --remove-orphans` avec gestion des fichiers d’environnement OMV et renvoie à son tour un JSON d’exécution. citeturn2view4turn3view3turn3view4turn4view0turn4view1turn4view2turn18view0turn18view1turn18view2
|
||||
|
||||
Le point important est donc le suivant : **linux-update-dashboard** apporte un excellent modèle d’**application web agentless par SSH**, tandis que **nas-ops** apporte un excellent modèle de **scripts shell déterministes qui produisent des JSON compacts**. Pour votre cas Debian, Ubuntu, Proxmox et Raspberry Pi, je recommande clairement de **fusionner les deux approches**, plutôt que de n’en reprendre qu’une seule. citeturn24view1turn3view3turn3view4turn4view1turn4view2
|
||||
|
||||
Il faut aussi noter une contrainte juridique : **linux-update-dashboard** affiche explicitement une licence **AGPL-3.0** sur GitHub. En revanche, sur la page consultée de **nas-ops**, je n’ai pas trouvé de mention explicite de licence dans la navigation GitHub. Concrètement, cela plaide pour mettre les deux dépôts en **références de travail dans le dossier de l’app**, mais en les considérant d’abord comme **sources d’inspiration et de vérification**, pas comme du code à recopier sans revue de licence. citeturn24view1turn24view0turn24view2
|
||||
|
||||
## Principes d’architecture à retenir
|
||||
|
||||
Le meilleur pattern pour votre projet est, à mon avis, un **backend Node.js/TypeScript qui orchestre**, mais **ne connaît pas la logique métier fine des mises à jour**. Toute la logique “comment mettre à jour APT”, “comment détecter les paquets”, “comment lister les stacks Docker”, “comment appliquer un `docker compose up -d` dans un dossier précis” doit vivre dans des **templates shell versionnés**, dérivés de l’esprit `nas-ops`, tandis que le backend gère l’inventaire, les droits, les jobs, les logs live, le chiffrement des secrets, l’historique et l’API. C’est exactement la séparation de responsabilités que la combinaison des deux dépôts rend possible. citeturn24view1turn3view3turn3view4turn4view1turn4view2
|
||||
|
||||
Je vous recommande donc une architecture en quatre couches. La première est la **couche UI**, sans machine prédéfinie au démarrage, où l’utilisateur ajoute des machines via un bouton `+`. La deuxième est la **couche API**, qui stocke les machines, les credentials, les templates, les jobs et les rapports. La troisième est la **couche worker**, qui lance les refreshs et les opérations longues en arrière-plan. La quatrième est la **couche SSH/script runtime**, qui pousse des commandes vers les hôtes et normalise leurs retours en JSON. Le dépôt `linux-update-dashboard` montre que ce schéma fonctionne bien pour du pilotage SSH multi-machines ; `nas-ops` montre qu’un script shell bien écrit peut déjà servir d’API machine. citeturn24view1turn7view0turn12view1turn3view3turn4view0turn4view1turn4view2
|
||||
|
||||
Pour votre cas précis, je garderais la règle suivante : **`update/check` en tâche de fond**, **`upgrade/full-upgrade/dist-upgrade/docker apply/reboot` en déclenchement manuel**. Cette séparation est cohérente avec la documentation APT, qui distingue nettement la resynchronisation des index (`update`) des opérations qui modifient réellement l’état de la machine (`upgrade`, `dist-upgrade`, `autoremove`, `clean`). Elle colle aussi à l’approche de `linux-update-dashboard`, qui distingue déjà les checks, les upgrades, l’autoremove et le reboot, avec certaines opérations exécutées en mode SSH-safe détaché. citeturn23view0turn24view1turn11view0turn12view3
|
||||
|
||||
Je déconseille en revanche de stocker vos commandes critiques uniquement dans la base. Il vaut mieux avoir un **registre de templates versionnés sur disque**, éditables depuis le frontend mais sauvegardés comme des ressources de projet, avec éventuellement des **overrides par machine**, exactement dans l’esprit des “script customizations” et des “per-system script overrides” du dépôt de référence. Cela facilitera énormément le travail avec Claude Code, les revues Git et l’évolution future vers des scripts de post-install, de réseau ou d’installation de paquets. citeturn24view1turn8view2
|
||||
|
||||
## Stack technique recommandée
|
||||
|
||||
Pour le **frontend**, je vous recommande de rester dans le même univers que `linux-update-dashboard` : **React + TypeScript + Vite**. React reste une base solide pour un dashboard à composants, Vite apporte une boucle de dev rapide, et le dépôt étudié montre déjà qu’un tel couple tient bien la charge sur ce type d’outil. Pour le design system, je recommande **shadcn/ui** plutôt qu’une grosse librairie opaque : la documentation officielle le présente comme une plateforme de distribution de composants accessibles et ouverts, avec un composant **Resizable** basé sur `react-resizable-panels`, ce qui est très utile pour votre volet terminal à droite. Pour les icônes, **Lucide React** est un très bon choix, avec composants SVG tree-shakables et typed. citeturn39view0turn19search3turn20search13turn20search1turn26search1turn26search2
|
||||
|
||||
Pour l’éditeur de templates, je prendrais **Monaco Editor**. Pour le terminal live, en revanche, je prendrais **xterm.js**. La raison est simple : Monaco est excellent pour éditer des scripts et des snippets avec coloration, tandis que xterm.js est un **vrai émulateur de terminal web**, avec support des séquences ANSI et un addon d’attache WebSocket. Autrement dit, Monaco pour **éditer les templates**, xterm.js pour **voir l’exécution en direct**. Votre intuition du “web terminal à droite avec coloration syntaxique” est donc réalisable, mais la bonne implémentation est plutôt “**coloration terminal ANSI + thème terminal**” que “éditeur de code live”. citeturn19search6turn19search1turn19search12turn7view0turn12view1
|
||||
|
||||
Pour le **backend HTTP/API**, **Hono** est une proposition cohérente. Le projet de référence l’utilise déjà, et sa documentation officielle le présente comme un framework small/simple/ultrafast, multi-runtime. Si vous voulez rester proche des patterns déjà observés, Hono est un bon choix. Si vous cherchiez un framework plus “entreprise”, Fastify ou NestJS seraient défendables, mais au vu des deux dépôts étudiés, Hono est le choix le plus naturel pour un premier jet propre et rapide. citeturn25search0turn25search4turn7view0
|
||||
|
||||
Pour le **stockage**, je vous recommande plutôt **PostgreSQL** que SQLite pour votre projet final, même si le repo étudié utilise SQLite. SQLite est parfait pour une app solo simple ; votre besoin, lui, évoque déjà plusieurs machines, rapports archivés, templates, logs, états de jobs, déduplication, intégration agent et évolution fonctionnelle. **PostgreSQL + Drizzle ORM** me paraît donc un meilleur point d’équilibre. Si vous voulez **minimiser l’infrastructure**, utilisez **pg-boss** pour la file de jobs sur PostgreSQL ; si vous avez déjà Redis et que vous voulez davantage de fonctions de queue natives comme dedup/throttle/flows, **BullMQ** est une alternative robuste. citeturn25search5turn25search13turn27search2turn27search5turn27search0turn27search12
|
||||
|
||||
Pour l’**exécution distante**, je recommande **agentless SSH** en v1, avec un compte dédié de maintenance, chiffrement des credentials côté serveur, vérification de host key, bastion/ProxyJump optionnel, et règles sudo minimales. `linux-update-dashboard` montre déjà des credentials chiffrés au repos, du host-key approval explicite et du ProxyJump ; son README précise aussi que l’application n’est pas pensée pour être exposée directement sur Internet, mais pour un réseau de confiance protégé par reverse proxy/TLS/VPN. Je reprendrais cette discipline de sécurité presque telle quelle. citeturn24view1turn13search0turn12view3
|
||||
|
||||
## Flux fonctionnels et contrats JSON
|
||||
|
||||
Votre flux de base peut être très simple côté produit. La page d’accueil démarre vide. Un bouton `+` ouvre un formulaire d’ajout machine contenant : **nom**, **OS**, **IP/hostname**, **port SSH**, **username**, **mode d’authentification** (mot de passe, clé SSH plus tard), **sudo password** si nécessaire, **activation de l’update automatique**, **templates activables** (`update`, `upgrade`, `full-upgrade`, `dist-upgrade`, `clean`, `autoremove`, `reboot`, `docker scan`, `docker pull`, `docker up`, `docker prune`), **proxy APT / apt-cacher-ng**, et **un ou plusieurs répertoires Docker Compose à surveiller**. Le backend effectue un `test-connection`, détecte les capacités, puis crée la tuile machine avec le cache d’état initial. Les routes et le workflow d’ajout de système déjà visibles dans `linux-update-dashboard` rendent cette approche très crédible. citeturn8view2turn24view1
|
||||
|
||||
Pour APT, il faut distinguer les sens exacts des commandes. Le manpage officiel d’APT rappelle que `update` resynchronise les index, que `upgrade` n’enlève pas de paquets installés et n’en installe pas de nouveaux, que `dist-upgrade` gère intelligemment les changements de dépendances, que `clean` vide le cache local des paquets récupérés, et que `autoremove` supprime les dépendances devenues inutiles. Pour **Proxmox**, les docs officielles insistent fortement sur la qualité des dépôts configurés et montrent des upgrades CLI autour de `apt update` puis `apt dist-upgrade`. Pour **Raspberry Pi OS**, la documentation officielle confirme qu’APT est bien le gestionnaire natif. Votre moteur de templates doit donc faire de l’**OS profile-aware**, pas du simple collage de commandes. citeturn23view0turn31search1turn31search11turn31search0turn31search2
|
||||
|
||||
Pour `apt-cacher-ng`, la doc Debian le décrit comme un **proxy de cache** pour les téléchargements de paquets, et la doc APT précise que les proxys APT se configurent via `Acquire::http::Proxy` et les options apparentées. Je vous conseille donc un réglage frontend par machine ou par template avec trois modes : **direct**, **proxy temporaire à l’exécution**, ou **proxy persistant dans `/etc/apt/apt.conf.d/`**. Cela vous donne la souplesse nécessaire pour les Debian/Ubuntu/Raspberry Pi classiques et les cas Proxmox où vous voudrez parfois verrouiller davantage les comportements de dépôt. citeturn21search0turn29search0
|
||||
|
||||
Pour Docker, la sémantique officielle est claire : `docker compose pull` récupère les images des services, `docker compose up` peut être relancé en détaché avec `--remove-orphans`, et `docker image prune` / `docker system prune` suppriment certaines ressources inutilisées. Le très bon enseignement de `nas-ops` est qu’il ne faut pas seulement “tirer une commande Docker”, mais d’abord **détecter** les updates, **identifier la stack**, puis **retourner un JSON compact**. En revanche, `nas-ops` s’appuie principalement sur les labels des conteneurs déjà en cours d’exécution (`com.docker.compose.project.working_dir`) ; pour votre app, comme vous voulez déclarer les dossiers Docker depuis le frontend, j’ajouterais un **fallback par scan de répertoires configurés** pour lister les stacks même si aucun conteneur n’est encore lancé. citeturn21search2turn32search1turn32search4turn4view1turn4view2
|
||||
|
||||
Le contrat JSON doit devenir votre **langage commun** entre scripts distants, backend, frontend, serveur MCP et agent. Je vous propose deux messages canoniques : un **snapshot de disponibilité** et un **résultat d’exécution**. Ce choix est directement inspiré par le fait que `nas-ops` renvoie déjà des JSON structurés, tandis que `linux-update-dashboard` sait piloter des opérations longues et diffuser un flux live séparé. citeturn3view3turn3view4turn4view0turn4view1turn4view2turn12view1
|
||||
|
||||
```json
|
||||
{
|
||||
"machineId": "pve-01",
|
||||
"hostname": "192.168.1.20",
|
||||
"os": {
|
||||
"family": "proxmox",
|
||||
"version": "8.x"
|
||||
},
|
||||
"checkedAt": "2026-06-04T12:00:00Z",
|
||||
"status": "updates_available",
|
||||
"apt": {
|
||||
"enabled": true,
|
||||
"count": 12,
|
||||
"rebootRequired": false,
|
||||
"packages": [
|
||||
{
|
||||
"name": "pve-manager",
|
||||
"currentVersion": "8.4-1",
|
||||
"targetVersion": "8.4-3",
|
||||
"origin": "pve-no-subscription",
|
||||
"severityHint": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"count": 2,
|
||||
"stacks": [
|
||||
{
|
||||
"name": "media",
|
||||
"path": "/opt/stacks/media",
|
||||
"containers": [
|
||||
{
|
||||
"containerName": "jellyfin",
|
||||
"image": "jellyfin/jellyfin:latest",
|
||||
"currentImageId": "sha256:aaa",
|
||||
"targetImageId": "sha256:bbb",
|
||||
"currentVersion": "10.10.0",
|
||||
"targetVersion": "10.10.1",
|
||||
"sourceUrl": "https://github.com/jellyfin/jellyfin"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"rawHints": {
|
||||
"logImportantLines": [
|
||||
"Inst pve-manager [8.4-1] (8.4-3 ...)",
|
||||
"Downloaded newer image for jellyfin/jellyfin:latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"executionId": "exec_20260604_001245",
|
||||
"machineId": "pve-01",
|
||||
"startedAt": "2026-06-04T12:12:45Z",
|
||||
"finishedAt": "2026-06-04T12:19:10Z",
|
||||
"mode": "manual",
|
||||
"actions": [
|
||||
{
|
||||
"type": "apt_full_upgrade",
|
||||
"status": "ok",
|
||||
"changes": [
|
||||
{
|
||||
"name": "pve-manager",
|
||||
"from": "8.4-1",
|
||||
"to": "8.4-3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "docker_up_stack",
|
||||
"stack": "media",
|
||||
"status": "warning",
|
||||
"changes": [
|
||||
{
|
||||
"containerName": "jellyfin",
|
||||
"fromImageId": "sha256:aaa",
|
||||
"toImageId": "sha256:bbb"
|
||||
}
|
||||
],
|
||||
"errors": [
|
||||
"Found orphan containers; removed with --remove-orphans"
|
||||
]
|
||||
}
|
||||
],
|
||||
"rebootRequiredAfterRun": true,
|
||||
"importantLogLines": [
|
||||
"Setting up pve-manager (8.4-3)",
|
||||
"jellyfin Recreated",
|
||||
"jellyfin Started"
|
||||
],
|
||||
"rawLogRef": "reports/2026/06/04/pve-01/exec_20260604_001245.log",
|
||||
"reportRef": "reports/2026/06/04/pve-01/exec_20260604_001245.md"
|
||||
}
|
||||
```
|
||||
|
||||
Pour éviter les doublons côté agent, je recommande une déduplication par **empreinte fonctionnelle**. Pour les paquets système : `os_family + package_name + current_version + target_version + origin`. Pour Docker : `image_ref + current_digest + target_digest` ou, à défaut, `image + oldImageId + newImageId`. Cela permettra à l’agent de mutualiser les recherches web et de générer un seul résumé par mise à jour identique, même si elle apparaît sur plusieurs machines. Le dépôt `nas-ops` vous aide déjà en exposant des IDs d’image et, quand ils existent, des labels de version et de source. citeturn4view1turn28search2turn28search14
|
||||
|
||||
Enfin, pour **limiter les tokens transmis à l’agent**, il faut intercaler une étape de **réduction déterministe** avant tout appel LLM. Conservez seulement les lignes utiles : côté APT, les `Inst`, `Conf`, `Remv`, `Err`, `E:`, `W:`, `dpkg:`, `reboot-required`; côté Docker, les `Pulling`, `Digest`, `Status`, `Downloaded newer image`, `Recreating`, `Started`, `Error`. Le reste doit rester dans le log brut archivé, pas dans le prompt. C’est très cohérent avec l’esprit “JSON + flux live séparé” visible dans les deux dépôts. citeturn3view3turn3view4turn4view0turn4view1turn12view1
|
||||
|
||||
## Frontend et expérience opérateur
|
||||
|
||||
L’UX que vous décrivez est réaliste et même très bonne pour l’usage homelab/prod légère : une **page d’accueil vide**, un bouton **`+`** pour ajouter une machine, puis des **tuiles** avec **nom**, **IP**, **OS**, **compteurs de mises à jour**, **liste des paquets à mettre à jour**, **liste des stacks ou conteneurs Docker concernés**, et des actions rapides distinctes pour **refresh**, **upgrade**, **docker pull**, **docker apply**, **clean**, **autoremove** et **reboot**. L’exemple visuel de `linux-update-dashboard` montre déjà bien l’intérêt d’un dashboard à tuiles, complété par une page détail plus riche. citeturn24view1turn15search0
|
||||
|
||||
Je verrais trois écrans majeurs. Le premier est **Dashboard**, centré sur les tuiles machines et l’état global. Le deuxième est **Machine Detail**, avec historique d’activité, templates appliqués, overrides, variables de contexte, packages, stacks Docker et rapports. Le troisième est **Paramétrage**, où vous gérez les préférences frontend, les credentials, les templates de commandes, les profils d’OS, les politiques d’approbation et les réglages d’agent/MCP. Cette séparation reste proche du dépôt de référence tout en étant plus ciblée sur votre périmètre Debian/Ubuntu/Proxmox/RPi + Docker Compose. citeturn24view1turn8view2
|
||||
|
||||
Pour le **volet terminal à droite**, oui, c’est totalement possible, et même recommandé. Techniquement, le plus propre est un layout **Resizable** avec une liste/tuiles à gauche et un panneau **xterm.js** à droite, alimenté par un **WebSocket**. xterm.js supporte les séquences de terminal et dispose d’un addon d’attache à WebSocket ; de plus, `linux-update-dashboard` expose déjà un flux WebSocket structuré de sortie de commande. Comme l’API navigateur WebSocket ne gère pas la rétropression toute seule, je recommanderais un buffer circulaire côté serveur, la limitation de débit au niveau worker et une compression logique des chunks plutôt qu’un caractère-par-caractère. citeturn20search1turn19search1turn19search12turn7view0turn12view1turn14search8
|
||||
|
||||
Pour la coloration, il faut distinguer deux besoins. Le **terminal live** utilisera les **couleurs ANSI** et le thème xterm.js. Le **template editor** et la **vue diff avant exécution** utiliseront **Monaco** ou à la limite `highlight.js` pour afficher joliment les scripts. Ce duo vous donne à la fois une expérience terminal crédible et une expérience d’édition moderne. Le dépôt de référence inclut déjà `highlight.js`, ce qui confirme que la brique “rendu coloré” est déjà dans son ADN. citeturn39view4turn19search6turn19search1
|
||||
|
||||
## Agent IA, skill Hermes et serveur MCP
|
||||
|
||||
Pour la partie agent, je recommande de **ne pas mélanger l’orchestration de mise à jour et l’intelligence de synthèse**. L’application web doit rester le **source of truth opérationnel**. Au-dessus, vous ajoutez un **serveur MCP** qui expose proprement les machines, les snapshots JSON, les exécutions, les rapports, les templates et les actions autorisées. MCP est conçu pour relier des applications LLM à des outils externes, avec JSON-RPC et des transports standards comme **stdio** et **Streamable HTTP**. C’est exactement le bon niveau d’abstraction pour faire consommer votre plateforme par Claude Code, Hermes ou d’autres agents. citeturn16search3turn16search5turn34search1turn16search7
|
||||
|
||||
Pour **Claude Code**, je vous conseille un transport **stdio local** en priorité, car il est explicitement recommandé lorsque c’est possible, et c’est le cas d’usage le plus naturel pour un outil qui tourne au plus près du code et des commandes locales. Pour **Hermes Agent**, l’intérêt est double : sa documentation explique qu’il sait se connecter à des serveurs MCP externes, et ses références montrent aussi qu’il a tout un système de skills. Autrement dit, votre architecture peut être : **webapp/API** → **MCP server** → **Claude Code / Hermes**. citeturn34search1turn35search1turn35search20
|
||||
|
||||
Concernant **Hermes**, les docs officielles disent qu’une **Skill** est le bon format quand la capacité peut être exprimée comme un mélange d’**instructions, commandes shell et outils existants**, alors qu’un **Tool** est préférable pour les intégrations plus profondes, streaming temps réel, auth complexe ou logique très spécialisée. Dans votre cas, la **mise à jour distante elle-même** doit rester dans l’application et son MCP server, mais la **planification, l’analyse des updates, la mutualisation des recherches web, l’interprétation des erreurs et la rédaction de rapports** sont d’excellents candidats pour une **Skill Hermes**. citeturn36search2turn35search2turn35search6
|
||||
|
||||
Le rôle de l’agent doit rester borné. Je proposerais qu’il sache : lire un snapshot JSON machine, regrouper les updates identiques, rechercher brièvement la nature des paquets ou images quand ils sont inconnus, proposer un **plan de mise à jour succinct**, déclencher uniquement des actions autorisées, puis archiver un **rapport Markdown**. Il ne doit **jamais** recevoir les mots de passe, ni exécuter directement des commandes SSH brutes, ni modifier les templates sans validation opérateur. Cette séparation est cohérente avec les recommandations de Claude Code sur les fichiers `CLAUDE.md`, les patterns d’instructions persistantes et les bonnes pratiques de skills concises, chargées à la demande. citeturn33search5turn33search6turn33search4
|
||||
|
||||
Les outils MCP que je proposerais en v1 sont très peu nombreux : `list_machines`, `get_machine_snapshot`, `get_execution_report`, `run_refresh`, `run_approved_action`, `list_templates`, `render_template_preview`, `search_archived_reports`. Plus vous garderez cette surface petite, plus les agents resteront fiables. Le skill Hermes, lui, agira surtout comme un **mode d’emploi intelligent** de ce MCP, pas comme un exécuteur système autonome. citeturn35search1turn36search2
|
||||
|
||||
## Fichiers de consigne proposés
|
||||
|
||||
Les docs officielles de Claude Code indiquent que les fichiers **`CLAUDE.md`** servent d’instructions persistantes de projet, lues au démarrage de chaque session. Les docs Hermes indiquent de leur côté qu’une skill repose sur un **`SKILL.md`** avec frontmatter et sections explicites (`When to Use`, `Procedure`, `Verification`, etc.). Je vous propose donc de partir avec **un fichier principal de consignes pour Claude Code**, **une skill Hermes**, et **une note de contrat MCP**. citeturn33search5turn36search2turn36search0
|
||||
|
||||
### `CLAUDE.md`
|
||||
|
||||
```md
|
||||
# Projet webapp de mise à jour distante Linux
|
||||
|
||||
## Langue et ton
|
||||
- Répondre en français.
|
||||
- Favoriser des propositions concrètes, structurées et justifiées.
|
||||
- Ne pas imposer une architecture finale : proposer, comparer, argumenter.
|
||||
|
||||
## Rôle de l’agent
|
||||
- L’agent aide à concevoir et faire évoluer la webapp.
|
||||
- L’agent ne décide pas seul de la structure finale du projet.
|
||||
- L’agent doit expliciter les compromis techniques importants.
|
||||
- L’agent doit prioriser la sécurité, la lisibilité et l’évolutivité.
|
||||
|
||||
## Références locales
|
||||
- Les dépôts `linux-update-dashboard` et `nas-ops` sont présents comme références de travail.
|
||||
- Les considérer comme des sources d’inspiration et d’analyse.
|
||||
- Ne pas recopier de larges portions de code sans vérifier la compatibilité de licence et sans réécriture adaptée au projet.
|
||||
|
||||
## Objectif produit
|
||||
Construire une webapp qui permet :
|
||||
- d’ajouter des machines sans inventaire prédéfini ;
|
||||
- de rafraîchir les mises à jour en tâche de fond ;
|
||||
- de déclencher manuellement les upgrades système et Docker ;
|
||||
- de gérer Debian, Ubuntu, Proxmox et Raspberry Pi OS en priorité ;
|
||||
- de configurer apt-cacher-ng depuis le frontend ;
|
||||
- de gérer des templates de commandes et des overrides par machine ;
|
||||
- d’exposer des JSON propres au frontend, au MCP server et aux agents ;
|
||||
- d’archiver des rapports Markdown après chaque exécution ;
|
||||
- de préparer l’évolution vers post-install, réseau, installation de paquets, scripts custom.
|
||||
|
||||
## Principes d’architecture
|
||||
- Backend TypeScript modulaire.
|
||||
- Frontend React + TypeScript.
|
||||
- Les opérations distantes passent par SSH agentless.
|
||||
- La logique métier d’update doit vivre dans des templates shell versionnés.
|
||||
- Le backend orchestre, journalise, valide, chiffre, historise.
|
||||
- Les secrets ne vont jamais dans les prompts des agents.
|
||||
- Les logs destinés aux agents doivent être filtrés et résumés.
|
||||
|
||||
## Sécurité
|
||||
- Préférer la vérification des host keys.
|
||||
- Prévoir bastion / ProxyJump.
|
||||
- Utiliser un utilisateur dédié côté machines.
|
||||
- Réduire sudo au strict nécessaire.
|
||||
- Chiffrer les credentials au repos.
|
||||
- Ne jamais afficher de secret brut dans les logs, l’UI ou les retours MCP.
|
||||
|
||||
## Règles fonctionnelles
|
||||
- `update/check` = tâche de fond.
|
||||
- `upgrade/full-upgrade/dist-upgrade/docker apply/reboot` = action manuelle validée.
|
||||
- Toujours distinguer détection, planification et exécution.
|
||||
- Toujours produire un JSON canonique par machine.
|
||||
- Toujours archiver un rapport `.md` après exécution.
|
||||
|
||||
## UX attendue
|
||||
- Page d’accueil vide au premier lancement.
|
||||
- Bouton `+` pour ajouter une machine.
|
||||
- Tuiles machines avec nom, IP, OS, compteurs, paquets et Docker à mettre à jour.
|
||||
- Onglet Paramétrage pour templates, règles frontend, profils d’OS et options agent.
|
||||
- Volet terminal à droite, redimensionnable, avec flux live.
|
||||
|
||||
## Contraintes agent
|
||||
- Lorsqu’une proposition d’architecture est faite, toujours distinguer :
|
||||
- MVP recommandé ;
|
||||
- options alternatives ;
|
||||
- risques / limites.
|
||||
- Éviter les générateurs opaques et les abstractions inutiles.
|
||||
- Préférer des noms de dossiers explicites et une convention stable.
|
||||
|
||||
## Livrables attendus
|
||||
Quand on demande une proposition technique, fournir si pertinent :
|
||||
- structure de projet ;
|
||||
- schémas JSON ;
|
||||
- contrats d’API ;
|
||||
- templates shell ;
|
||||
- plan de roadmap ;
|
||||
- risques de sécurité ;
|
||||
- stratégie de tests ;
|
||||
- impacts UX.
|
||||
```
|
||||
|
||||
### `hermes-skills/update-ops-planner/SKILL.md`
|
||||
|
||||
```md
|
||||
---
|
||||
name: update-ops-planner
|
||||
description: Analyse les snapshots JSON de mises à jour Linux/Docker, déduplique les items, recherche brièvement leur objet, propose un plan d’exécution et génère un rapport Markdown.
|
||||
version: 1.0.0
|
||||
author: Projet Webapp Updates
|
||||
license: Proprietary
|
||||
platforms: [linux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [ops, updates, linux, docker, mcp, reporting]
|
||||
requires_toolsets: [web]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Update Ops Planner
|
||||
|
||||
## When to Use
|
||||
Charger cette skill quand l’utilisateur :
|
||||
- fournit un snapshot JSON machine de mises à jour ;
|
||||
- demande un plan de mise à jour système ou Docker ;
|
||||
- demande une synthèse d’erreurs d’upgrade ;
|
||||
- demande un rapport Markdown à archiver ;
|
||||
- demande de mutualiser l’analyse sur plusieurs machines.
|
||||
|
||||
## Quick Reference
|
||||
- Toujours lire d’abord le JSON normalisé.
|
||||
- Dédupliquer les mises à jour identiques entre machines.
|
||||
- Chercher sur le web uniquement pour :
|
||||
- paquets ou images peu explicites ;
|
||||
- composants critiques ;
|
||||
- erreurs non triviales ;
|
||||
- changements majeurs de version.
|
||||
- Produire une réponse courte, opérationnelle et non alarmiste.
|
||||
- Ne jamais demander ni afficher de secrets.
|
||||
|
||||
## Procedure
|
||||
1. Lire le snapshot JSON fourni par le MCP server.
|
||||
2. Identifier :
|
||||
- updates système ;
|
||||
- updates Docker ;
|
||||
- reboot requis ;
|
||||
- erreurs ou warnings ;
|
||||
- éléments dupliqués entre machines.
|
||||
3. Pour chaque item pertinent, dédupliquer par nom + version cible.
|
||||
4. Pour les items inconnus ou importants, faire une recherche web brève.
|
||||
5. Produire un plan de mise à jour :
|
||||
- ordre recommandé ;
|
||||
- éléments sûrs à appliquer en lot ;
|
||||
- éléments à isoler ;
|
||||
- reboot éventuel ;
|
||||
- vérifications post-run.
|
||||
6. Générer un rapport Markdown avec :
|
||||
- résumé exécutif ;
|
||||
- tableau des updates système ;
|
||||
- tableau des updates Docker ;
|
||||
- risques connus ;
|
||||
- ordre recommandé ;
|
||||
- incidents / erreurs ;
|
||||
- annexes JSON utiles.
|
||||
7. Si un résultat d’exécution est fourni, comparer avant/après et résumer :
|
||||
- succès ;
|
||||
- écarts de versions ;
|
||||
- erreurs restantes ;
|
||||
- actions de remédiation.
|
||||
|
||||
## Pitfalls
|
||||
- Ne pas inférer une criticité sans source.
|
||||
- Ne pas répéter la même recherche pour 10 machines identiques.
|
||||
- Ne pas transmettre les logs bruts complets au modèle si une version filtrée existe.
|
||||
- Ne pas confondre `refresh` et `upgrade`.
|
||||
- Ne pas supposer qu’un `docker pull` implique un redéploiement réussi.
|
||||
|
||||
## Verification
|
||||
Vérifier que la sortie contient :
|
||||
- un résumé lisible ;
|
||||
- un plan d’exécution ordonné ;
|
||||
- la liste des mises à jour regroupées ;
|
||||
- une section risques/erreurs si nécessaire ;
|
||||
- un rapport Markdown archivable.
|
||||
|
||||
## Output Format
|
||||
Toujours retourner :
|
||||
- `summary`
|
||||
- `deduplicated_updates`
|
||||
- `recommended_plan`
|
||||
- `web_notes`
|
||||
- `report_markdown`
|
||||
```
|
||||
|
||||
### `docs/MCP_CONTRACTS.md`
|
||||
|
||||
```md
|
||||
# Contrats MCP proposés
|
||||
|
||||
## Principe
|
||||
Le MCP server ne contient pas la logique SSH métier.
|
||||
Il appelle l’API interne de la webapp et expose un outillage minimal, stable et typé.
|
||||
|
||||
## Tools v1
|
||||
- `list_machines()`
|
||||
- `get_machine_snapshot(machineId)`
|
||||
- `get_machine_execution(machineId, executionId)`
|
||||
- `run_refresh(machineId)`
|
||||
- `run_action(machineId, action, options)`
|
||||
- `list_templates()`
|
||||
- `preview_template(machineId, templateName)`
|
||||
- `search_reports(query)`
|
||||
|
||||
## Resources v1
|
||||
- `machine://{id}/snapshot`
|
||||
- `machine://{id}/history`
|
||||
- `report://{executionId}`
|
||||
|
||||
## Règles
|
||||
- Les tools d’exécution ne reçoivent jamais de secret brut.
|
||||
- Les réponses d’exécution renvoient des références de rapport et des lignes importantes, pas uniquement du log brut.
|
||||
- Le MCP server doit rester une façade de l’API métier.
|
||||
- Les actions destructives doivent être explicitement approuvées côté application.
|
||||
|
||||
## JSON canoniques
|
||||
Utiliser deux schémas principaux :
|
||||
- `update availability snapshot`
|
||||
- `execution result`
|
||||
|
||||
## Déduplication
|
||||
- Système : `os_family + package + from + to + origin`
|
||||
- Docker : `image + fromDigest + toDigest`
|
||||
```
|
||||
|
||||
## Questions ouvertes et limites
|
||||
|
||||
Le point le plus important à clarifier avant toute implémentation est **le niveau de réutilisation autorisé** des deux dépôts, car `linux-update-dashboard` est bien sous **AGPL-3.0**, tandis que la licence de `nas-ops` n’était pas visible dans les pages consultées. Tant que ce point n’est pas purgé, je vous conseille de garder les deux dépôts dans le projet comme **références lues par les agents et les développeurs**, mais de reconstruire votre propre code. citeturn24view1turn24view0turn24view2
|
||||
|
||||
Il reste aussi quelques choix d’architecture à trancher au début du projet : **PostgreSQL seul avec pg-boss** ou **PostgreSQL + Redis/BullMQ**, **auth par mot de passe en production** ou **SSH key only**, **scope strictement agentless** ou **préparation d’un futur agent local**, et **politique exacte de stockage des rapports** sur filesystem local, NAS ou bucket objet. Ces questions ne bloquent pas l’étude, mais elles influencent la structure du backend et le niveau de complexité du déploiement. citeturn27search2turn27search5turn27search0turn27search12
|
||||
|
||||
En synthèse, la proposition la plus robuste pour votre besoin est : **webapp React/TypeScript avec design system shadcn/ui + icônes Lucide + terminal xterm.js**, **backend Hono/TypeScript**, **orchestration SSH agentless**, **templates shell versionnés par OS et par capacité**, **JSON canoniques pour frontend/MCP/agent**, **refresh en tâche de fond**, **upgrade manuel**, **serveur MCP en façade**, et **skill Hermes centrée sur l’analyse, la déduplication, la recherche web ciblée et le reporting**. C’est la combinaison la plus cohérente de ce que vos deux dépôts de référence font déjà bien, tout en restant propre, évolutive et compatible avec une future collaboration structurée avec Claude Code. citeturn24view1turn3view3turn4view1turn20search13turn26search1turn19search1turn25search4turn34search1turn36search2
|
||||
@@ -0,0 +1,304 @@
|
||||
# mon design system — Gruvbox seventies
|
||||
|
||||
> Design system rétro-futuriste pour applications de monitoring, ops, IoT, domotique.
|
||||
> Orange brûlé, fond brun délavé en sombre / gris clair usé en clair.
|
||||
> **Version 1.0** · deux thèmes (dark + light), 14+ composants React, palette GTK pour GNOME.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Démarrage rapide (web)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html data-theme="dark">
|
||||
<head>
|
||||
<!-- 1. Polices Google -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- 2. Icônes Font Awesome 6 -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<!-- 3. Tokens (variables CSS) -->
|
||||
<link rel="stylesheet" href="tokens/tokens.css">
|
||||
|
||||
<!-- 4. React + Babel -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- 5. Composants UI -->
|
||||
<script type="text/babel" src="components/ui-kit.jsx"></script>
|
||||
<script type="text/babel">
|
||||
// Tes composants ici
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Pour voir tout fonctionner, ouvre `examples/exemple-minimal.html`.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Contenu du package
|
||||
|
||||
```
|
||||
export/
|
||||
├── README.md ← Ce fichier
|
||||
├── consigne_design_system.md ← Brief pour agents IA (Claude, ChatGPT…)
|
||||
├── tokens/
|
||||
│ ├── tokens.css ← Variables CSS web (dark + light)
|
||||
│ ├── tokens.gnome.css ← GTK 4 / libadwaita (apps GNOME)
|
||||
│ └── tokens.json ← Format générique (Tailwind, Figma…)
|
||||
├── components/
|
||||
│ └── ui-kit.jsx ← 14 composants React (Button, IconButton, Toggle, Tooltip,
|
||||
│ StatusLed, BatteryGauge, RadialGauge, BigRadialGauge,
|
||||
│ Popup, TreeNav, Sparkline, LineChart, Icon, …)
|
||||
└── examples/
|
||||
└── exemple-minimal.html ← Démo minimale autoportante
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Ce qui est paramétrable
|
||||
|
||||
### 1. Thème global
|
||||
|
||||
```html
|
||||
<html data-theme="dark"> <!-- ou "light" -->
|
||||
```
|
||||
|
||||
Tu peux mettre `data-theme` sur **n'importe quel parent** pour basculer un sous-arbre uniquement (utile pour une preview en mode opposé dans un menu de réglages).
|
||||
|
||||
### 2. Toutes les couleurs (CSS variables)
|
||||
|
||||
Édite `tokens.css` ou surcharge dans ton propre CSS :
|
||||
|
||||
```css
|
||||
:root[data-theme="dark"] {
|
||||
--accent: #fe8019; /* Couleur principale (orange seventies) */
|
||||
--accent-soft: #d65d0e;
|
||||
--bg-1: #2a231d; /* Fond app */
|
||||
--bg-3: #3c332a; /* Cartes */
|
||||
--ink-1: #f2e5c7; /* Texte */
|
||||
--ok: #4dbb26;
|
||||
--warn: #fabd2f;
|
||||
--err: #fb4934;
|
||||
--blue: #3db0d1; /* Datavis additionnel */
|
||||
--purple: #c882c8;
|
||||
}
|
||||
```
|
||||
|
||||
**4 statuts** (ok / warn / err / info) + **2 couleurs datavis** (blue / purple) + **6 niveaux de fond** + **4 niveaux d'encre** + **3 niveaux de bordure**.
|
||||
|
||||
### 3. Polices
|
||||
|
||||
Trois familles, toutes substituables :
|
||||
|
||||
| Variable | Usage | Défaut |
|
||||
|-----------------|-------------------------------------|---------------------|
|
||||
| `--font-ui` | Interface (titres, corps, boutons) | Inter |
|
||||
| `--font-mono` | Données, code, valeurs numériques | JetBrains Mono |
|
||||
| `--font-terminal` | Logs, terminal embarqué, vibe rétro | Share Tech Mono |
|
||||
|
||||
Pour changer, remplace simplement les `@import` Google Fonts et redéfinis les variables.
|
||||
|
||||
### 4. Ombres et relief
|
||||
|
||||
```css
|
||||
--tile-3d /* Relief 3D marqué pour cartes */
|
||||
--shadow-1, -2, -3 /* Niveaux d'élévation */
|
||||
--shadow-press /* Inset pour état pressé */
|
||||
--hover-glow /* Halo accent au survol */
|
||||
```
|
||||
|
||||
### 5. Composants — props paramétrables
|
||||
|
||||
Chaque composant accepte des props pour personnaliser sans toucher au CSS. Exemples :
|
||||
|
||||
```jsx
|
||||
<Button variant="primary|ghost|danger|default" size="sm|md|lg" icon="play">Texte</Button>
|
||||
|
||||
<IconButton icon="cog" label="Tooltip obligatoire" primary danger active />
|
||||
|
||||
<Toggle on={state} onChange={setState} label="Auto" icon="refresh" />
|
||||
|
||||
<BatteryGauge
|
||||
value={64} max={100} unit="%"
|
||||
label="CPU"
|
||||
warnAt={70} errAt={85} // seuils de couleur
|
||||
compact // mode 1 ligne
|
||||
icon="cpu"
|
||||
color="var(--blue)" // couleur fixe (sinon auto selon seuils)
|
||||
/>
|
||||
|
||||
<RadialGauge value={87} label="SCORE" size={120} />
|
||||
<BigRadialGauge value={87} label="santé système" />
|
||||
|
||||
<Popup open={open} onClose={fn} title="…" footer={…}>
|
||||
Contenu
|
||||
</Popup>
|
||||
|
||||
<TreeNav groups={[
|
||||
{ id, icon: 'server', label, count, open, children: [
|
||||
{ id, label, status: 'ok|warn|err', meta }
|
||||
]}
|
||||
]} activeId={id} onSelect={fn} />
|
||||
```
|
||||
|
||||
Voir la doc complète des props : `Component Reference.html` dans le projet original.
|
||||
|
||||
---
|
||||
|
||||
## 🐧 Utilisation dans une app GNOME (GTK 4 / libadwaita)
|
||||
|
||||
Charge `tokens/tokens.gnome.css` comme provider CSS au démarrage de l'app.
|
||||
|
||||
**Python (PyGObject)** :
|
||||
```python
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_path("tokens.gnome.css")
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(),
|
||||
css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
```
|
||||
|
||||
**GJS** :
|
||||
```javascript
|
||||
const provider = new Gtk.CssProvider();
|
||||
provider.load_from_path('tokens.gnome.css');
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(),
|
||||
provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
);
|
||||
```
|
||||
|
||||
**Rust (gtk4-rs)** :
|
||||
```rust
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_path("tokens.gnome.css");
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&gdk::Display::default().unwrap(),
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
```
|
||||
|
||||
Le fichier override directement les couleurs sémantiques de libadwaita (`@window_bg_color`, `@accent_color`, etc.) ET ajoute des styles spécifiques pour les widgets courants : `button.suggested-action`, `entry`, `switch`, `scale`, `progressbar`, `notebook`, `popover`…
|
||||
|
||||
Classes CSS supplémentaires à appliquer via `add_css_class()` :
|
||||
- `.tile` / `.card` — Tuile en relief 3D
|
||||
- `.mono` — Texte monospace JetBrains Mono
|
||||
- `.terminal` — Texte terminal Share Tech Mono
|
||||
- `.status.ok` / `.status.warn` / `.status.error` / `.status.info` — Badge de statut
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Intégration dans d'autres outils
|
||||
|
||||
### Tailwind CSS
|
||||
|
||||
Convertis `tokens.json` en `tailwind.config.js` :
|
||||
|
||||
```js
|
||||
const tokens = require('./tokens/tokens.json');
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: tokens.themes.dark.accent.primary.value,
|
||||
ok: tokens.themes.dark.status.ok.value,
|
||||
// …
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [tokens.typography.fonts.ui.family, ...tokens.typography.fonts.ui.fallback],
|
||||
mono: [tokens.typography.fonts.mono.family],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Figma / outils de design
|
||||
|
||||
`tokens.json` suit un schéma compatible avec la plupart des plugins de tokens (Figma Tokens, Style Dictionary). Importe-le directement.
|
||||
|
||||
### Variables Sass / SCSS
|
||||
|
||||
```scss
|
||||
@use 'sass:map';
|
||||
$tokens: (
|
||||
accent: #fe8019,
|
||||
bg-1: #2a231d,
|
||||
ok: #4dbb26,
|
||||
);
|
||||
// …
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Personnalisation avancée
|
||||
|
||||
### Créer un thème dérivé
|
||||
|
||||
Duplique `tokens.css`, change le nom du sélecteur (`[data-theme="ocean"]` par exemple) et modifie les variables. Charge les deux fichiers — `data-theme` choisira automatiquement.
|
||||
|
||||
### Ajouter une couleur status custom
|
||||
|
||||
```css
|
||||
:root[data-theme="dark"] {
|
||||
--critical: #ff0080;
|
||||
--critical-glow: rgba(255, 0, 128, 0.45);
|
||||
}
|
||||
```
|
||||
|
||||
Utilisable ensuite partout : `<StatusLed status="critical">` nécessite une PR dans `ui-kit.jsx` (carte `map` dans `StatusLed`), mais en raw CSS tu peux utiliser la variable directement.
|
||||
|
||||
### Désactiver les effets
|
||||
|
||||
Tous les effets de `transition` / `transform` / `box-shadow` sont concentrés dans les classes `.interactive`, `.bg-hover`, `.gauge-hover`. Surcharge-les en CSS si besoin :
|
||||
|
||||
```css
|
||||
.interactive { transition: none !important; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist d'intégration
|
||||
|
||||
- [ ] Polices Google Fonts chargées (Inter, JetBrains Mono, Share Tech Mono)
|
||||
- [ ] Font Awesome 6 chargé
|
||||
- [ ] `tokens.css` (web) **ou** `tokens.gnome.css` (GTK) chargé
|
||||
- [ ] Attribut `data-theme="dark"` (ou "light") sur `<html>` ou un parent
|
||||
- [ ] React 18 + Babel chargés (uniquement pour `ui-kit.jsx`)
|
||||
- [ ] `ui-kit.jsx` chargé en `type="text/babel"`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Statuts du système
|
||||
|
||||
| Couleur | Token | Hex (dark) | Hex (light) | Usage |
|
||||
|---------|--------|------------|-------------|-----------------------------|
|
||||
| Accent | `--accent` | `#fe8019` | `#af3a03` | Primaire, focus, sélection |
|
||||
| OK | `--ok` | `#4dbb26` | `#3c911c` | Succès, état nominal |
|
||||
| Warn | `--warn` | `#fabd2f` | `#b57614` | Attention, latence élevée |
|
||||
| Err | `--err` | `#fb4934` | `#9d0006` | Erreur, alerte critique |
|
||||
| Info | `--info` | `#83a598` | `#427b58` | Information neutre |
|
||||
| Blue | `--blue` | `#3db0d1` | `#2d82a3` | Datavis catégorie 2 |
|
||||
| Purple | `--purple` | `#c882c8` | `#8c468c` | Datavis catégorie 3 |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Pour les agents IA
|
||||
|
||||
Si tu utilises ce design system avec une IA (Claude, GPT, Copilot, etc.), partage-lui le fichier **`consigne_design_system.md`**. Il y trouvera toutes les règles d'utilisation, conventions, contre-exemples à éviter.
|
||||
|
||||
---
|
||||
|
||||
**Licence** · Usage libre dans tes projets. Pas de garantie.
|
||||
@@ -0,0 +1,656 @@
|
||||
/* ============================================================
|
||||
ui-kit.jsx
|
||||
Composants haute-fid Gruvbox Seventies.
|
||||
Tout est purement décoratif/interactif côté composant.
|
||||
Effets : transparence (glass), hover glow, click 3D, tooltips.
|
||||
============================================================ */
|
||||
|
||||
const { useState, useRef, useEffect } = React;
|
||||
|
||||
/* ============================================================
|
||||
Icônes — Font Awesome 6 Free.
|
||||
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
|
||||
dans le <head>. Le composant garde la MÊME API qu'avant (name,
|
||||
size, style) pour ne rien casser ailleurs.
|
||||
============================================================ */
|
||||
const ICON_MAP = {
|
||||
cpu: 'microchip',
|
||||
memory: 'memory',
|
||||
disk: 'hard-drive',
|
||||
network: 'network-wired',
|
||||
clock: 'clock',
|
||||
grid: 'table-cells',
|
||||
list: 'list',
|
||||
cog: 'gear',
|
||||
alert: 'triangle-exclamation',
|
||||
bell: 'bell',
|
||||
server: 'server',
|
||||
chart: 'chart-line',
|
||||
bars: 'chart-simple',
|
||||
terminal: 'terminal',
|
||||
refresh: 'arrows-rotate',
|
||||
play: 'play',
|
||||
pause: 'pause',
|
||||
power: 'power-off',
|
||||
sun: 'sun',
|
||||
moon: 'moon',
|
||||
search: 'magnifying-glass',
|
||||
close: 'xmark',
|
||||
chevR: 'chevron-right',
|
||||
chevL: 'chevron-left',
|
||||
chevD: 'chevron-down',
|
||||
chevU: 'chevron-up',
|
||||
plus: 'plus',
|
||||
filter: 'filter',
|
||||
download: 'download',
|
||||
folder: 'folder',
|
||||
node: 'circle-nodes',
|
||||
user: 'user',
|
||||
};
|
||||
|
||||
const Icon = ({ name, size = 16, style }) => {
|
||||
const fa = ICON_MAP[name] || 'circle-question';
|
||||
return (
|
||||
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
|
||||
fontSize: size,
|
||||
width: size,
|
||||
height: size,
|
||||
lineHeight: `${size}px`,
|
||||
textAlign: 'center',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: '0 0 auto',
|
||||
color: 'currentColor',
|
||||
...style,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
Tooltip — apparaît au hover après 300ms, position auto.
|
||||
============================================================ */
|
||||
function Tooltip({ children, label, side = 'top' }) {
|
||||
const [show, setShow] = useState(false);
|
||||
const t = useRef();
|
||||
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
|
||||
const onLeave = () => { clearTimeout(t.current); setShow(false); };
|
||||
const sides = {
|
||||
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
|
||||
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
|
||||
};
|
||||
return (
|
||||
<span style={{ position: 'relative', display: 'inline-flex' }}
|
||||
onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
||||
{children}
|
||||
{show && (
|
||||
<span className="glass-strong" style={{
|
||||
position: 'absolute', ...sides[side],
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
color: 'var(--ink-1)',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
letterSpacing: '0.02em',
|
||||
}}>{label}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
IconButton — bouton icône seul + tooltip obligatoire.
|
||||
============================================================ */
|
||||
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
|
||||
const bg = active ? 'var(--accent-tint)'
|
||||
: primary ? 'var(--accent)'
|
||||
: 'var(--bg-3)';
|
||||
const fg = active ? 'var(--accent)'
|
||||
: primary ? 'var(--bg-1)'
|
||||
: danger ? 'var(--err)'
|
||||
: 'var(--ink-2)';
|
||||
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button onClick={onClick} className="interactive" style={{
|
||||
width: size, height: size,
|
||||
background: bg,
|
||||
color: fg,
|
||||
border: `1px solid ${bd}`,
|
||||
borderRadius: 8,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 0, cursor: 'pointer',
|
||||
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
|
||||
}}>
|
||||
<Icon name={icon} size={Math.round(size * 0.5)} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Toggle on/off — switch tactile avec glow accent quand ON
|
||||
============================================================ */
|
||||
function Toggle({ on, onChange, label, icon }) {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
|
||||
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
|
||||
<button onClick={() => onChange(!on)} className="interactive" style={{
|
||||
width: 42, height: 22, borderRadius: 12,
|
||||
background: on ? 'var(--accent)' : 'var(--bg-4)',
|
||||
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
|
||||
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
|
||||
position: 'relative', cursor: 'pointer', padding: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 1, left: on ? 21 : 1,
|
||||
width: 18, height: 18, borderRadius: '50%',
|
||||
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
|
||||
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Status LED — pastille pulsante (effet halo si critique)
|
||||
============================================================ */
|
||||
function StatusLed({ status = 'ok', size = 10, pulse }) {
|
||||
const map = {
|
||||
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
|
||||
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
|
||||
err: { c: 'var(--err)', g: 'var(--err-glow)' },
|
||||
off: { c: 'var(--ink-4)', g: 'transparent' },
|
||||
info: { c: 'var(--info)', g: 'var(--info-glow)' },
|
||||
};
|
||||
const { c, g } = map[status];
|
||||
const id = `pulse-${status}-${size}`;
|
||||
return (
|
||||
<>
|
||||
{pulse && (
|
||||
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
|
||||
)}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
background: c,
|
||||
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
|
||||
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
|
||||
flex: '0 0 auto',
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BatteryGauge — jauge horizontale style batterie
|
||||
- Pas de bandes (couleur unie + léger gloss interne)
|
||||
- Pas de graduations verticales
|
||||
- Hover : glow lumineux dans la couleur de la jauge
|
||||
- Mode compact : label [bar] valeur sur une seule ligne
|
||||
============================================================ */
|
||||
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
|
||||
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
||||
const color = colorOverride
|
||||
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
|
||||
const glowVar = pct >= errAt ? 'var(--err-glow)'
|
||||
: pct >= warnAt ? 'var(--warn-glow)'
|
||||
: 'var(--ok-glow)';
|
||||
|
||||
// Variante compacte : label [bar] valeur sur une seule ligne
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="bg-hover" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
|
||||
'--bg-glow': glowVar,
|
||||
}}>
|
||||
{(icon || label) && (
|
||||
<span style={{
|
||||
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
minWidth: 90,
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
|
||||
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
|
||||
</span>
|
||||
)}
|
||||
<div className="bg-bar" style={{
|
||||
flex: 1, height: 12, borderRadius: 3,
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden', position: 'relative',
|
||||
transition: 'border-color .2s',
|
||||
}}>
|
||||
<div className="bg-fill" style={{
|
||||
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||
background: color,
|
||||
borderRadius: 2,
|
||||
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||
}} />
|
||||
</div>
|
||||
<span className="mono" style={{
|
||||
flex: '0 0 auto', fontSize: 13,
|
||||
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
|
||||
}}>
|
||||
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-hover" style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
|
||||
'--bg-glow': glowVar,
|
||||
}}>
|
||||
{label && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span className="label">{label}</span>
|
||||
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
|
||||
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-bar" style={{
|
||||
position: 'relative',
|
||||
height, borderRadius: 4,
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border-2)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color .2s',
|
||||
}}>
|
||||
<div className="bg-fill" style={{
|
||||
position: 'absolute', top: 1, left: 1, bottom: 1,
|
||||
width: `calc((100% - 2px) * ${pct / 100})`,
|
||||
background: color,
|
||||
borderRadius: 3,
|
||||
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
|
||||
}} />
|
||||
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RadialGauge — jauge ronde, version épurée
|
||||
============================================================ */
|
||||
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
|
||||
const pct = Math.max(0, Math.min(100, value));
|
||||
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
|
||||
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
|
||||
const r = size / 2 - 10;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + 6;
|
||||
const circ = Math.PI * r;
|
||||
const offset = circ - (pct / 100) * circ;
|
||||
return (
|
||||
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
|
||||
<defs>
|
||||
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.5" />
|
||||
</filter>
|
||||
</defs>
|
||||
{/* arc background */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
|
||||
{/* arc value glow */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
filter={`url(#glow-${label})`} opacity="0.7" />
|
||||
{/* arc value crisp */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
|
||||
</svg>
|
||||
<div style={{ marginTop: -10, textAlign: 'center' }}>
|
||||
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
|
||||
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
|
||||
</div>
|
||||
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BigRadialGauge — la grande jauge cockpit "santé système"
|
||||
============================================================ */
|
||||
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
|
||||
const size = 320;
|
||||
const r = 130;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2 + 30;
|
||||
const circ = Math.PI * r;
|
||||
const offset = circ - (value / 100) * circ;
|
||||
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
|
||||
return (
|
||||
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
|
||||
<svg width={size} height={size * 0.85}>
|
||||
<defs>
|
||||
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" />
|
||||
</filter>
|
||||
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
|
||||
<stop offset="1" stopColor={color}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* tics */}
|
||||
{Array.from({ length: 21 }).map((_, i) => {
|
||||
const a = Math.PI - (i / 20) * Math.PI;
|
||||
const major = i % 5 === 0;
|
||||
const inner = major ? r + 8 : r + 11;
|
||||
const outer = major ? r + 20 : r + 15;
|
||||
return <line key={i}
|
||||
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
|
||||
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
|
||||
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
|
||||
/>;
|
||||
})}
|
||||
{[0, 50, 100].map(v => {
|
||||
const a = Math.PI - (v / 100) * Math.PI;
|
||||
const x = cx + Math.cos(a) * (r + 32);
|
||||
const y = cy - Math.sin(a) * (r + 32) + 4;
|
||||
return <text key={v} x={x} y={y} textAnchor="middle"
|
||||
fontFamily="JetBrains Mono" fontSize="11"
|
||||
fill="var(--ink-3)">{v}</text>;
|
||||
})}
|
||||
{/* arc bg */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
|
||||
{/* arc value glow */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
filter="url(#biggauge-glow)" opacity="0.55" />
|
||||
{/* arc value */}
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
|
||||
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
|
||||
{/* needle */}
|
||||
<line x1={cx} y1={cy}
|
||||
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
|
||||
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
|
||||
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
|
||||
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
|
||||
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
|
||||
<div className="mono" style={{
|
||||
fontSize: 64, fontWeight: 700, lineHeight: 1,
|
||||
color: 'var(--ink-1)',
|
||||
textShadow: `0 0 20px ${color}33`,
|
||||
}}>{value}</div>
|
||||
<div className="label" style={{ marginTop: 6 }}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Popup — modale glassmorphism centrée + bouton fermer
|
||||
============================================================ */
|
||||
function Popup({ open, onClose, title, children, footer, width = 460 }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'fadein .2s ease-out',
|
||||
}} onClick={onClose}>
|
||||
<style>{`
|
||||
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
|
||||
`}</style>
|
||||
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
|
||||
width, maxWidth: '90%',
|
||||
borderRadius: 12,
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid var(--border-1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: 'var(--bg-3)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
|
||||
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
|
||||
</div>
|
||||
<div style={{ padding: 18 }}>{children}</div>
|
||||
{footer && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid var(--border-1)',
|
||||
background: 'var(--bg-2)',
|
||||
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||
}}>{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Button — bouton classique avec variantes
|
||||
============================================================ */
|
||||
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
|
||||
const sizes = {
|
||||
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
|
||||
md: { padding: '7px 14px', fontSize: 13, h: 34 },
|
||||
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
|
||||
}[size];
|
||||
const variants = {
|
||||
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
|
||||
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
|
||||
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
|
||||
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
|
||||
}[variant];
|
||||
return (
|
||||
<button onClick={onClick} className="interactive" style={{
|
||||
height: sizes.h,
|
||||
padding: sizes.padding,
|
||||
background: variants.bg,
|
||||
color: variants.fg,
|
||||
border: `1px solid ${variants.bd}`,
|
||||
borderRadius: 8,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={14} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TreeNav — arbre dépliable avec icône en tête (style B)
|
||||
============================================================ */
|
||||
function TreeNav({ groups, activeId, onSelect }) {
|
||||
const [open, setOpen] = useState(() =>
|
||||
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{groups.map(g => (
|
||||
<div key={g.id}>
|
||||
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px', borderRadius: 6,
|
||||
color: 'var(--ink-2)',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<Icon name="chevR" size={12} style={{
|
||||
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
|
||||
transition: 'transform .15s',
|
||||
color: 'var(--ink-3)',
|
||||
}} />
|
||||
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
|
||||
{g.count != null && (
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
|
||||
{g.count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open[g.id] && (
|
||||
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
|
||||
{g.children.map(c => {
|
||||
const active = c.id === activeId;
|
||||
return (
|
||||
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
background: active ? 'var(--accent-tint)' : 'transparent',
|
||||
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
|
||||
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginLeft: active ? 0 : 2,
|
||||
fontSize: 12.5,
|
||||
}}>
|
||||
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
|
||||
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
|
||||
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Sparkline pour les KPI
|
||||
============================================================ */
|
||||
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
|
||||
const w = 100;
|
||||
const max = Math.max(...points);
|
||||
const min = Math.min(...points);
|
||||
const range = max - min || 1;
|
||||
const step = w / (points.length - 1);
|
||||
const path = points.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
|
||||
).join(' ');
|
||||
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||
<path d={area} fill={color} opacity="0.12" />
|
||||
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LineChart — grand graph multi-séries
|
||||
============================================================ */
|
||||
function LineChart({ series, h = 200, labels }) {
|
||||
const w = 600;
|
||||
const padding = { l: 36, r: 12, t: 12, b: 24 };
|
||||
const innerW = w - padding.l - padding.r;
|
||||
const innerH = h - padding.t - padding.b;
|
||||
const all = series.flatMap(s => s.points);
|
||||
const max = Math.max(...all) * 1.1;
|
||||
const min = 0;
|
||||
const range = max - min;
|
||||
const ptsCount = series[0].points.length;
|
||||
const step = innerW / (ptsCount - 1);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
|
||||
{/* grid horizontal */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(p => {
|
||||
const y = padding.t + innerH * p;
|
||||
const v = Math.round(max - range * p);
|
||||
return (
|
||||
<g key={p}>
|
||||
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
|
||||
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={padding.l - 6} y={y + 3} textAnchor="end"
|
||||
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* labels x */}
|
||||
{labels && labels.map((lb, i) => (
|
||||
i % Math.ceil(labels.length / 8) === 0 && (
|
||||
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
|
||||
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
|
||||
)
|
||||
))}
|
||||
{/* séries */}
|
||||
{series.map((s, si) => {
|
||||
const path = s.points.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
|
||||
).join(' ');
|
||||
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
|
||||
return (
|
||||
<g key={si}>
|
||||
<path d={area} fill={s.color} opacity="0.12" />
|
||||
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* Expose */
|
||||
Object.assign(window, {
|
||||
Icon, Tooltip, IconButton, Toggle, StatusLed,
|
||||
BatteryGauge, RadialGauge, BigRadialGauge,
|
||||
Popup, Button, TreeNav, Sparkline, LineChart,
|
||||
});
|
||||
|
||||
/* Effets hover sur les jauges (sans effet au clic) */
|
||||
(function injectGaugeHoverStyles() {
|
||||
if (document.getElementById('gauge-hover-styles')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'gauge-hover-styles';
|
||||
s.textContent = `
|
||||
.bg-hover:hover .bg-bar {
|
||||
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
|
||||
}
|
||||
.bg-hover:hover .bg-fill {
|
||||
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.gauge-hover { transition: filter .2s; }
|
||||
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
@@ -0,0 +1,363 @@
|
||||
# Consignes — mon design system (Gruvbox seventies)
|
||||
|
||||
> **Tu es un agent IA chargé de produire ou modifier du code utilisant ce design system.**
|
||||
> Lis ce fichier en entier avant d'écrire la moindre ligne. Suis les règles à la lettre.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Identité du système
|
||||
|
||||
- **Nom** : mon design system — Gruvbox seventies
|
||||
- **Vibe** : rétro-industriel, console de monitoring, SCADA, terminal années 70
|
||||
- **Palette** : orange brûlé Gruvbox + fond brun délavé (pas noir intense) ou gris clair usé (pas blanc pur)
|
||||
- **Cas d'usage cibles** : tableaux de bord, monitoring, IoT, domotique, ops, scanners réseau
|
||||
- **Public** : utilisateurs techniques (admin sys, devs, makers) — densité d'info élevée acceptée
|
||||
|
||||
---
|
||||
|
||||
## 📁 Fichiers à connaître
|
||||
|
||||
| Fichier | Contient |
|
||||
|---------------------------------|-------------------------------------------------------|
|
||||
| `tokens/tokens.css` | Variables CSS web (`:root[data-theme="dark|light"]`) |
|
||||
| `tokens/tokens.gnome.css` | Tokens GTK 4 / libadwaita (`@define-color`) |
|
||||
| `tokens/tokens.json` | Tokens en JSON pour outils externes |
|
||||
| `components/ui-kit.jsx` | 14 composants React (Button, Icon, Popup…) |
|
||||
| `examples/exemple-minimal.html` | Démo de référence |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Règles absolues — ne JAMAIS enfreindre
|
||||
|
||||
1. **Toujours utiliser les variables CSS**, jamais des hex en dur dans le code utilisateur.
|
||||
✅ `color: var(--accent)`
|
||||
❌ `color: #fe8019`
|
||||
|
||||
2. **Toujours déclarer `data-theme`** sur un parent (`<html>` ou un wrapper).
|
||||
Sans ça, les variables ne sont pas définies et l'UI casse silencieusement.
|
||||
|
||||
3. **Composants existants** — ne jamais en réinventer. Vérifier d'abord la liste ci-dessous.
|
||||
|
||||
4. **Icônes** — utiliser le composant `<Icon name="…">` avec les noms mappés. JAMAIS d'emoji, JAMAIS de SVG inline custom pour un cas où une icône Font Awesome existe.
|
||||
|
||||
5. **Pas d'effet hover** sur les boutons / tuiles / composants généraux (sauf jauges et tuiles Heimdall qui en ont un). Seulement **pression 3D au clic** via `.interactive`.
|
||||
|
||||
6. **Toujours des tooltips** sur les boutons icônes seuls (`<IconButton>` exige `label`).
|
||||
|
||||
7. **Pas de bordure arrondie excessive**. Tuiles : `border-radius: 10-12px`. Boutons : `8px`. Pastilles : `999px`.
|
||||
|
||||
8. **Polices** — respecter strictement les 3 familles :
|
||||
- **Inter** → UI (titres, corps, boutons, labels d'interface généraux)
|
||||
- **JetBrains Mono** → données numériques, valeurs, code, IDs, IPs
|
||||
- **Share Tech Mono** → logs, terminal embarqué, ambiance rétro
|
||||
Toute autre police = bug.
|
||||
|
||||
9. **Tonalité** : labels en `text-transform: uppercase` + `letter-spacing: 0.08em` (classe `.label` déjà fournie).
|
||||
|
||||
10. **Densité** : pas de padding inutile. Ce DS est dense par nature. Tuiles : padding 14-18px. Boutons : 6-10px vertical.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Tokens disponibles
|
||||
|
||||
### Couleurs (toutes définies en `dark` ET `light`)
|
||||
|
||||
#### Fonds (du plus profond au plus haut)
|
||||
```
|
||||
--bg-0 très rare, niveau le plus bas
|
||||
--bg-1 fond application principal
|
||||
--bg-2 panneaux (sidebar, headerbar)
|
||||
--bg-3 cartes, tuiles ← LE PLUS UTILISÉ
|
||||
--bg-4 hover, état actif
|
||||
--bg-5 press, sélection forte
|
||||
```
|
||||
|
||||
#### Texte (du plus contrasté au moins)
|
||||
```
|
||||
--ink-1 texte principal
|
||||
--ink-2 texte secondaire
|
||||
--ink-3 labels, hints
|
||||
--ink-4 désactivé
|
||||
```
|
||||
|
||||
#### Accent
|
||||
```
|
||||
--accent couleur primaire (orange Gruvbox seventies)
|
||||
--accent-soft variante foncée (bordures, hover)
|
||||
--accent-glow halo (rgba)
|
||||
--accent-tint teinte transparente (fonds discrets)
|
||||
```
|
||||
|
||||
#### Statuts
|
||||
```
|
||||
--ok #4dbb26 (vert flashy)
|
||||
--warn #fabd2f (jaune)
|
||||
--err #fb4934 (rouge)
|
||||
--info #83a598 (vert-bleu pastel)
|
||||
```
|
||||
|
||||
#### Datavis additionnel
|
||||
```
|
||||
--blue #3db0d1
|
||||
--purple #c882c8
|
||||
```
|
||||
|
||||
#### Bordures
|
||||
```
|
||||
--border-1, --border-2, --border-3 du plus subtil au plus marqué
|
||||
```
|
||||
|
||||
#### Ombres / relief
|
||||
```
|
||||
--shadow-1, -2, -3 élévations standards
|
||||
--shadow-press état pressé (inset)
|
||||
--tile-3d relief 3D marqué pour cartes ← À utiliser sur les tuiles importantes
|
||||
```
|
||||
|
||||
### Polices
|
||||
```
|
||||
--font-ui 'Inter', system-ui, sans-serif
|
||||
--font-mono 'JetBrains Mono', monospace
|
||||
--font-terminal 'Share Tech Mono', monospace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Composants — quand utiliser quoi
|
||||
|
||||
| Besoin | Composant | Exemple |
|
||||
|--------|-----------|---------|
|
||||
| Bouton texte avec ou sans icône | `<Button variant="primary|ghost|danger|default">` | Action principale, secondaire |
|
||||
| Bouton icône seul | `<IconButton icon="…" label="…">` | Toolbars, headers (le `label` devient tooltip) |
|
||||
| On/off | `<Toggle on={…} onChange={…} label icon>` | Activer/désactiver une option |
|
||||
| État système | `<StatusLed status="ok|warn|err|info|off" pulse>` | LED pulsante pour critique |
|
||||
| Jauge ronde standard | `<RadialGauge value={…} label size>` | KPI compact, cockpit |
|
||||
| Jauge ronde héro | `<BigRadialGauge value={…} label>` | Métrique principale unique |
|
||||
| Jauge barre standard | `<BatteryGauge value label>` | Stack vertical de ressources |
|
||||
| Jauge barre **inline** | `<BatteryGauge compact value label icon>` | Listes denses, label + barre + valeur sur 1 ligne |
|
||||
| Modale | `<Popup open onClose title footer>` | Confirmation, config détaillée |
|
||||
| Tree dépliable | `<TreeNav groups activeId onSelect>` | Sidebar hiérarchique (clusters/nodes) |
|
||||
| Mini graphe | `<Sparkline points color>` | Dans une tuile KPI |
|
||||
| Graphe ligne | `<LineChart series labels h>` | Évolution temporelle multi-séries |
|
||||
| Tooltip | `<Tooltip label side><…/></Tooltip>` | Toute icône isolée |
|
||||
| Icône | `<Icon name="…" size>` | JAMAIS d'emoji, JAMAIS de SVG custom |
|
||||
|
||||
### Icônes disponibles (noms logiques → Font Awesome)
|
||||
|
||||
`cpu`, `memory`, `disk`, `network`, `clock`, `grid`, `list`, `cog`, `alert`, `bell`, `server`, `chart`, `bars`, `terminal`, `refresh`, `play`, `pause`, `power`, `sun`, `moon`, `search`, `close`, `chevR`, `chevL`, `chevD`, `chevU`, `plus`, `filter`, `download`, `folder`, `node`, `user`.
|
||||
|
||||
Pour un nouveau besoin → utiliser une icône Font Awesome (préfixe `fa-solid fa-…`) en ajoutant l'alias dans `ICON_MAP` au sein de `ui-kit.jsx`.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Patterns d'agencement standards
|
||||
|
||||
### Layout dashboard 3 colonnes
|
||||
```
|
||||
┌─ Header (tabs workspace + search + actions + statut connexion) ─┐
|
||||
├──────┬────────────────────────────────┬──────────────────────────┤
|
||||
│ Tree │ Center cockpit (KPIs + jauges) │ Logs/Terminal repliable │
|
||||
│ nav │ │ │
|
||||
├──────┴────────────────────────────────┴──────────────────────────┤
|
||||
│ Status bar (mode · workspace · stats · horloge) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tuile KPI standard
|
||||
```jsx
|
||||
<div className="glass" style={{ padding: 12, borderRadius: 10, ...}}>
|
||||
<Icon name="cpu" /> <span className="label">CPU</span>
|
||||
<span className="mono">{value}<span className="label">%</span></span>
|
||||
<Sparkline points={trend} color="var(--accent)" />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Status bar inférieure
|
||||
- Première cellule = mode courant en fond accent (style tmux)
|
||||
- Cellules séparées par `border-right: 1px solid var(--border-1)`
|
||||
- Police `Share Tech Mono` 11-12px
|
||||
- Horloge à droite
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Anti-patterns à éviter
|
||||
|
||||
### NE PAS faire :
|
||||
|
||||
❌ **Mettre des emoji** pour un état :
|
||||
```jsx
|
||||
<span>✅ Système OK</span> // NON
|
||||
<><StatusLed status="ok" /> Système OK</> // OUI
|
||||
```
|
||||
|
||||
❌ **Inventer de nouvelles couleurs hors palette** :
|
||||
```jsx
|
||||
style={{ color: '#ff00aa' }} // NON — utilise les tokens
|
||||
```
|
||||
|
||||
❌ **Police arbitraire** :
|
||||
```jsx
|
||||
fontFamily: 'Roboto' // NON
|
||||
fontFamily: 'var(--font-ui)' // OUI
|
||||
```
|
||||
|
||||
❌ **Bordures arrondies à 24px+** sur des cartes (vibe trop SaaS pastel).
|
||||
|
||||
❌ **Tooltip absent sur une icône isolée** :
|
||||
```jsx
|
||||
<button><Icon name="cog" /></button> // NON
|
||||
<IconButton icon="cog" label="Configurer" onClick={fn} /> // OUI
|
||||
```
|
||||
|
||||
❌ **`window.alert` / `confirm`** — toujours utiliser `<Popup>`.
|
||||
|
||||
❌ **Texte secondaire en `--ink-1`** — choisir la bonne couche d'encre selon la hiérarchie.
|
||||
|
||||
❌ **Sur-utiliser le glow / shadow** — réservé aux accents importants.
|
||||
|
||||
❌ **Mélanger les casses de label** — labels = uppercase mono, titres = sentence case.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Patterns recommandés
|
||||
|
||||
### Hiérarchie de fond
|
||||
- App / page → `--bg-1`
|
||||
- Sidebar / headerbar → `--bg-2`
|
||||
- Tuiles / cartes principales → `--bg-3` ou `.glass`
|
||||
- Input fields / containers profonds → `--bg-1` avec inset shadow
|
||||
|
||||
### Effet glass standard
|
||||
```jsx
|
||||
className="glass" // backdrop-filter + bg semi-transparent + tile-3d shadow
|
||||
```
|
||||
ou pour plus marqué :
|
||||
```jsx
|
||||
className="glass-strong"
|
||||
```
|
||||
|
||||
### Validation visuelle d'un état critique
|
||||
```jsx
|
||||
<StatusLed status="err" pulse /> // pastille pulsante
|
||||
<Button variant="danger" icon="power">…</Button>
|
||||
// + bordure rouge sur le conteneur :
|
||||
style={{ border: '1px solid var(--err)', boxShadow: 'inset 0 1px 0 rgba(251,73,52,0.2), 0 0 18px rgba(251,73,52,0.15)' }}
|
||||
```
|
||||
|
||||
### Sticky footer d'actions (form)
|
||||
```jsx
|
||||
<div className="glass-strong" style={{
|
||||
padding: '12px 20px',
|
||||
display: 'flex', gap: 12, alignItems: 'center',
|
||||
borderTop: '1px solid var(--border-2)',
|
||||
}}>
|
||||
<StatusLed status={dirty ? 'warn' : 'ok'} pulse={dirty} />
|
||||
<span className="terminal">{dirty ? 'modifications non sauvegardées' : 'à jour'}</span>
|
||||
<span style={{ flex: 1 }}></span>
|
||||
<Button variant="ghost">Annuler</Button>
|
||||
<Button variant="primary" icon="download">Enregistrer</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌗 Gestion des deux thèmes
|
||||
|
||||
**Règle d'or** : tout ce qui s'affiche doit être lisible et cohérent dans les deux thèmes.
|
||||
|
||||
Avant de livrer un écran, **mentalement (ou réellement) bascule `data-theme`** et vérifie :
|
||||
- Les couleurs personnalisées (en dur) cassent forcément → utilise les tokens
|
||||
- Les opacités blanches (`rgba(255,255,255,…)`) en dark passent mal en light → préfère les variables `--border-*`
|
||||
- Les ombres très profondes en dark sont invisibles en light → utilise `--shadow-*` qui s'adapte
|
||||
|
||||
Pour basculer dynamiquement :
|
||||
```jsx
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🪟 Cas particulier : applications GNOME
|
||||
|
||||
Pour GTK 4 / libadwaita :
|
||||
1. Charger `tokens/tokens.gnome.css` via `GtkCssProvider`
|
||||
2. Le fichier **override les couleurs sémantiques libadwaita** (`@accent_color`, `@window_bg_color`, etc.) — les widgets standards se ré-habillent automatiquement
|
||||
3. Ajouter `add_css_class("tile")` pour le relief 3D, `("mono")` pour monospace, `("terminal")` pour Share Tech Mono
|
||||
4. Pour les boutons accent : utiliser la classe libadwaita standard `suggested-action` (déjà restylée)
|
||||
5. Pour danger : classe `destructive-action`
|
||||
|
||||
Polices : penser à installer ou bundler Inter / JetBrains Mono / Share Tech Mono dans le `.flatpak` / `.deb` (sinon GTK fallback sur Cantarell / DejaVu).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quand l'utilisateur demande quelque chose…
|
||||
|
||||
### "Ajoute un bouton de déconnexion"
|
||||
→ `<IconButton icon="power" label="Se déconnecter" danger />` ou
|
||||
`<Button variant="danger" icon="power">Déconnexion</Button>`
|
||||
|
||||
### "Affiche le statut du serveur"
|
||||
→ Combinaison `<StatusLed status="ok|warn|err" pulse />` + label texte. Le pulse uniquement si c'est critique/nouveau.
|
||||
|
||||
### "Mets une jauge CPU"
|
||||
→ `<BatteryGauge compact value={cpu} label="cpu" icon="cpu" warnAt={70} errAt={85} />` (inline)
|
||||
ou `<RadialGauge value={cpu} label="CPU" />` (visuel)
|
||||
|
||||
### "Crée une modale de confirmation"
|
||||
→ `<Popup>` avec `footer={<><Button variant="ghost">Annuler</Button><Button variant="primary">Confirmer</Button></>}`
|
||||
|
||||
### "Liste hiérarchique des serveurs"
|
||||
→ `<TreeNav>` avec `groups: [{ id, icon: 'server', label, count, open, children: [{ id, label, status, meta }] }]`
|
||||
|
||||
### "Affiche les logs"
|
||||
→ Conteneur avec `font-family: var(--font-terminal)` + lignes colorées par niveau (ERROR → var(--err), WARN → var(--warn), INFO → var(--ink-2)).
|
||||
|
||||
### "Ajoute une option dark/light dans les réglages"
|
||||
→ `<RadioGroup options={[{value:'dark', icon:'moon'}, {value:'light', icon:'sun'}, {value:'auto', icon:'clock'}]}>` + effet de bord :
|
||||
```jsx
|
||||
React.useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 Tailles standards à respecter
|
||||
|
||||
| Élément | Taille / Padding |
|
||||
|---------------|------------------------------------------|
|
||||
| Boutons sm | h: 28px · pad: 5px 10px · font: 12px |
|
||||
| Boutons md | h: 34px · pad: 7px 14px · font: 13px |
|
||||
| Boutons lg | h: 40px · pad: 10px 18px · font: 14px |
|
||||
| IconButton | 34px (default) · 26px (compact) |
|
||||
| Inputs | pad: 9px 12px · font: 13px |
|
||||
| Toggle | 42 × 22px |
|
||||
| StatusLed | 8-14px diamètre |
|
||||
| Header app | 48-56px hauteur |
|
||||
| Sidebar | 200-260px largeur |
|
||||
| Volet logs | 320-360px largeur |
|
||||
| Status bar | 24-28px hauteur |
|
||||
| Radius tuile | 10-12px |
|
||||
| Radius button | 8px |
|
||||
| Espacement | 8 / 12 / 14 / 18 / 24px (rythme bas) |
|
||||
|
||||
---
|
||||
|
||||
## 💡 Trucs pour ne pas se tromper
|
||||
|
||||
1. **Avant de créer un composant, cherche d'abord** dans `ui-kit.jsx`. 90% du temps il existe déjà.
|
||||
2. **Avant d'inventer une couleur**, regarde les tokens. Tu as 6 fonds, 4 encres, 4 statuts, 2 datavis = largement assez.
|
||||
3. **Si tu hésites sur une taille de police** : labels = 11px mono uppercase, body = 13-14px, kpi = 18-28px mono bold.
|
||||
4. **Quand tu ajoutes une tuile**, mets `className="glass"` (ou `glass-strong` pour les modales) — tout le styling est inclus.
|
||||
5. **Pour un état critique**, combine plusieurs signaux : couleur + pulse LED + icône + position visuelle. Pas juste une couleur.
|
||||
6. **Quand l'utilisateur demande "un peu d'effet"** : pas de hover (sauf jauges), oui à la pression 3D, oui aux animations d'entrée 200-400ms `cubic-bezier(.3,.7,.3,1.2)`.
|
||||
|
||||
---
|
||||
|
||||
## 🔚 En cas de doute
|
||||
|
||||
- Pas sûr d'une couleur ? → tokens
|
||||
- Pas sûr d'un composant ? → `ui-kit.jsx`
|
||||
- Pas sûr d'un layout ? → `examples/exemple-minimal.html`
|
||||
- Pas sûr d'une convention ? → ce fichier
|
||||
|
||||
Toujours préférer la cohérence avec l'existant à l'innovation.
|
||||
Quand tu doutes, **demande-moi** plutôt que de deviner.
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Exemple minimal — mon design system</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- 1. Polices -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- 2. Icônes -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
<!-- 3. Tokens du design system -->
|
||||
<link rel="stylesheet" href="../tokens/tokens.css">
|
||||
|
||||
<style>
|
||||
body { padding: 32px; }
|
||||
.row { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
h2 { font-size: 20px; margin: 32px 0 12px; color: var(--ink-1); }
|
||||
h2:first-child { margin-top: 0; }
|
||||
p { color: var(--ink-3); margin: 0 0 8px; }
|
||||
</style>
|
||||
|
||||
<!-- 4. React + composants -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="../components/ui-kit.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function App() {
|
||||
const [theme, setTheme] = React.useState('dark');
|
||||
const [popupOpen, setPopupOpen] = React.useState(false);
|
||||
const [auto, setAuto] = React.useState(true);
|
||||
|
||||
React.useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24 }}>
|
||||
<Icon name="grid" size={28} style={{ color: 'var(--accent)' }} />
|
||||
<h1 style={{ margin: 0, fontSize: 28 }}>Exemple minimal</h1>
|
||||
<span style={{ flex: 1 }}></span>
|
||||
<IconButton icon={theme === 'dark' ? 'sun' : 'moon'}
|
||||
label={theme === 'dark' ? 'Mode clair' : 'Mode sombre'}
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} />
|
||||
</div>
|
||||
|
||||
<h2>Boutons</h2>
|
||||
<div className="row">
|
||||
<Button>défaut</Button>
|
||||
<Button variant="primary" icon="play">primaire</Button>
|
||||
<Button variant="ghost" icon="filter">ghost</Button>
|
||||
<Button variant="danger" icon="power">danger</Button>
|
||||
</div>
|
||||
|
||||
<h2>Boutons icônes (avec tooltip)</h2>
|
||||
<div className="row">
|
||||
<IconButton icon="refresh" label="Rafraîchir" />
|
||||
<IconButton icon="cog" label="Configurer" primary />
|
||||
<IconButton icon="bell" label="Notifications" />
|
||||
<IconButton icon="power" label="Arrêter" danger />
|
||||
</div>
|
||||
|
||||
<h2>Statuts</h2>
|
||||
<div className="row">
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><StatusLed status="ok" /> ok</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><StatusLed status="warn" pulse /> warn</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><StatusLed status="err" pulse /> err</span>
|
||||
<Toggle on={auto} onChange={setAuto} label="Auto-refresh" icon="refresh" />
|
||||
</div>
|
||||
|
||||
<h2>Jauges</h2>
|
||||
<div className="row" style={{ alignItems: 'flex-end' }}>
|
||||
<RadialGauge value={28} label="DISQUE" />
|
||||
<RadialGauge value={64} label="CPU" warnAt={70} errAt={85} />
|
||||
<RadialGauge value={92} label="RÉSEAU" warnAt={70} errAt={85} />
|
||||
</div>
|
||||
<div style={{ maxWidth: 520, marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<BatteryGauge compact value={88} label="mémoire" icon="memory" />
|
||||
<BatteryGauge compact value={64} label="cpu" icon="cpu" warnAt={70} errAt={85} />
|
||||
<BatteryGauge compact value={28} label="disque" icon="disk" />
|
||||
<BatteryGauge compact value={92} label="réseau" icon="network" warnAt={70} errAt={85} />
|
||||
</div>
|
||||
|
||||
<h2>Popup</h2>
|
||||
<Button variant="primary" icon="cog" onClick={() => setPopupOpen(true)}>Ouvrir la popup</Button>
|
||||
<Popup open={popupOpen} onClose={() => setPopupOpen(false)}
|
||||
title="Confirmer l'action"
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setPopupOpen(false)}>Annuler</Button>
|
||||
<Button variant="primary" onClick={() => setPopupOpen(false)}>OK</Button>
|
||||
</>}>
|
||||
<div style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.5 }}>
|
||||
Une popup glassmorphism centrée. Clic à l'extérieur ou Échap pour fermer.
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
/* ============================================================
|
||||
ui-tokens.css
|
||||
Design tokens Gruvbox Seventies — dark (par défaut) + light.
|
||||
Sombre délavé (pas noir intense) / gris clair usé (pas blanc pur).
|
||||
============================================================ */
|
||||
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
/* Couches de fond — sombre délavé, brun-gris chaud */
|
||||
--bg-0: #221c17; /* niveau le plus profond (rare) */
|
||||
--bg-1: #2a231d; /* fond app */
|
||||
--bg-2: #322a23; /* panneaux */
|
||||
--bg-3: #3c332a; /* cartes */
|
||||
--bg-4: #4a4035; /* hover */
|
||||
--bg-5: #5a4f43; /* press / actif */
|
||||
|
||||
/* Surfaces translucides */
|
||||
--surf-glass: rgba(50, 42, 35, 0.72);
|
||||
--surf-glass-strong: rgba(50, 42, 35, 0.92);
|
||||
--surf-glass-soft: rgba(50, 42, 35, 0.42);
|
||||
|
||||
/* Bordures */
|
||||
--border-1: rgba(168, 153, 132, 0.18);
|
||||
--border-2: rgba(168, 153, 132, 0.32);
|
||||
--border-3: rgba(168, 153, 132, 0.55);
|
||||
|
||||
/* Texte */
|
||||
--ink-1: #f2e5c7; /* cream principal */
|
||||
--ink-2: #d5c4a1; /* secondaire */
|
||||
--ink-3: #a89984; /* labels / hints */
|
||||
--ink-4: #7c6f64; /* désactivé */
|
||||
|
||||
/* Accent orange seventies */
|
||||
--accent: #fe8019;
|
||||
--accent-soft: #d65d0e;
|
||||
--accent-glow: rgba(254, 128, 25, 0.35);
|
||||
--accent-tint: rgba(254, 128, 25, 0.12);
|
||||
|
||||
/* Statuts */
|
||||
--ok: #4dbb26;
|
||||
--ok-glow: rgba(77, 187, 38, 0.45);
|
||||
--warn: #fabd2f;
|
||||
--warn-glow: rgba(250, 189, 47, 0.45);
|
||||
--err: #fb4934;
|
||||
--err-glow: rgba(251, 73, 52, 0.4);
|
||||
--info: #83a598;
|
||||
--info-glow: rgba(131, 165, 152, 0.4);
|
||||
|
||||
/* Couleurs additionnelles (datavis, badges, catégories) */
|
||||
--blue: #3db0d1;
|
||||
--blue-glow: rgba(61, 176, 209, 0.45);
|
||||
--purple: #c882c8;
|
||||
--purple-glow: rgba(200, 130, 200, 0.45);
|
||||
|
||||
/* Ombres */
|
||||
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
|
||||
--shadow-2: 0 4px 12px rgba(0,0,0,0.45);
|
||||
--shadow-3: 0 12px 32px rgba(0,0,0,0.55);
|
||||
--shadow-press: inset 0 2px 4px rgba(0,0,0,0.5);
|
||||
|
||||
/* Relief 3D pour tuiles : highlight haut + ombre bas + ombre portée */
|
||||
--tile-3d:
|
||||
inset 0 1px 0 rgba(255, 230, 180, 0.12),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.45),
|
||||
0 1px 0 rgba(0, 0, 0, 0.35),
|
||||
0 2px 4px rgba(0, 0, 0, 0.4),
|
||||
0 8px 18px rgba(0, 0, 0, 0.5);
|
||||
--tile-3d-strong:
|
||||
inset 0 1px 0 rgba(255, 230, 180, 0.18),
|
||||
inset 0 -2px 0 rgba(0, 0, 0, 0.55),
|
||||
0 1px 0 rgba(0, 0, 0, 0.4),
|
||||
0 4px 8px rgba(0, 0, 0, 0.5),
|
||||
0 14px 28px rgba(0, 0, 0, 0.55);
|
||||
|
||||
/* Polices */
|
||||
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--font-terminal: 'Share Tech Mono', 'VT323', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* Gris clair usé, légèrement chaud (pas blanc pur) */
|
||||
--bg-0: #b8b2a3;
|
||||
--bg-1: #d5d0c5;
|
||||
--bg-2: #dcd7cc;
|
||||
--bg-3: #e3ded3;
|
||||
--bg-4: #ccc6b8;
|
||||
--bg-5: #bdb6a7;
|
||||
|
||||
--surf-glass: rgba(220, 215, 204, 0.72);
|
||||
--surf-glass-strong: rgba(220, 215, 204, 0.94);
|
||||
--surf-glass-soft: rgba(220, 215, 204, 0.42);
|
||||
|
||||
--border-1: rgba(60, 56, 54, 0.15);
|
||||
--border-2: rgba(60, 56, 54, 0.28);
|
||||
--border-3: rgba(60, 56, 54, 0.5);
|
||||
|
||||
--ink-1: #28241f;
|
||||
--ink-2: #3c3836;
|
||||
--ink-3: #5a544c;
|
||||
--ink-4: #8a8278;
|
||||
|
||||
--accent: #af3a03;
|
||||
--accent-soft: #d65d0e;
|
||||
--accent-glow: rgba(175, 58, 3, 0.28);
|
||||
--accent-tint: rgba(175, 58, 3, 0.08);
|
||||
|
||||
--ok: #3c911c;
|
||||
--ok-glow: rgba(60, 145, 28, 0.32);
|
||||
--warn: #b57614;
|
||||
--warn-glow: rgba(181, 118, 20, 0.35);
|
||||
--err: #9d0006;
|
||||
--err-glow: rgba(157, 0, 6, 0.3);
|
||||
--info: #427b58;
|
||||
--info-glow: rgba(66, 123, 88, 0.3);
|
||||
|
||||
/* Couleurs additionnelles (datavis, badges, catégories) */
|
||||
--blue: #2d82a3;
|
||||
--blue-glow: rgba(45, 130, 163, 0.32);
|
||||
--purple: #8c468c;
|
||||
--purple-glow: rgba(140, 70, 140, 0.32);
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(40,30,20,0.12);
|
||||
--shadow-2: 0 4px 12px rgba(40,30,20,0.15);
|
||||
--shadow-3: 0 12px 32px rgba(40,30,20,0.2);
|
||||
--shadow-press: inset 0 2px 4px rgba(40,30,20,0.18);
|
||||
|
||||
/* Relief light : highlight haut blanc cassé + ombre marquée */
|
||||
--tile-3d:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.55),
|
||||
inset 0 -1px 0 rgba(60, 50, 40, 0.18),
|
||||
0 1px 0 rgba(60, 50, 40, 0.1),
|
||||
0 2px 4px rgba(60, 50, 40, 0.12),
|
||||
0 8px 18px rgba(60, 50, 40, 0.18);
|
||||
--tile-3d-strong:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||||
inset 0 -2px 0 rgba(60, 50, 40, 0.22),
|
||||
0 1px 0 rgba(60, 50, 40, 0.15),
|
||||
0 4px 8px rgba(60, 50, 40, 0.18),
|
||||
0 14px 28px rgba(60, 50, 40, 0.22);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Reset minimal + base typo
|
||||
============================================================ */
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
color: var(--ink-1);
|
||||
background: var(--bg-1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.terminal { font-family: var(--font-terminal); letter-spacing: 0.02em; }
|
||||
.label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Surfaces — relief 3D marqué, AUCUN effet hover
|
||||
============================================================ */
|
||||
.glass {
|
||||
background: var(--surf-glass);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
border: 1px solid var(--border-2);
|
||||
box-shadow: var(--tile-3d);
|
||||
}
|
||||
.glass-strong {
|
||||
background: var(--surf-glass-strong);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
||||
border: 1px solid var(--border-3);
|
||||
box-shadow: var(--tile-3d-strong);
|
||||
}
|
||||
|
||||
/* Élément cliquable : pas de hover, mais réelle pression 3D au clic */
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
transition: transform .04s ease-out, box-shadow .04s, background .04s;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.interactive:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: var(--shadow-press) !important;
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
/* Scrollbar custom */
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--accent-soft); }
|
||||
@@ -0,0 +1,378 @@
|
||||
/* ============================================================
|
||||
tokens.gnome.css — Tokens pour applications GNOME (GTK 4 / libadwaita)
|
||||
Gruvbox seventies · v1.0
|
||||
============================================================
|
||||
|
||||
Usage dans une app GTK 4 / libadwaita :
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
GtkCssProvider *provider = gtk_css_provider_new();
|
||||
gtk_css_provider_load_from_path(provider, "tokens.gnome.css");
|
||||
gtk_style_context_add_provider_for_display(
|
||||
gdk_display_get_default(), GTK_STYLE_PROVIDER(provider),
|
||||
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
|
||||
Python (PyGObject) :
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_provider.load_from_path("tokens.gnome.css")
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(), css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
|
||||
GJS :
|
||||
const provider = new Gtk.CssProvider();
|
||||
provider.load_from_path('tokens.gnome.css');
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(), provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||
============================================================ */
|
||||
|
||||
/* ============================================================
|
||||
THÈME SOMBRE (défaut)
|
||||
============================================================ */
|
||||
|
||||
/* Couches de fond (du plus profond au plus haut) */
|
||||
@define-color bg_0 #221c17;
|
||||
@define-color bg_1 #2a231d;
|
||||
@define-color bg_2 #322a23;
|
||||
@define-color bg_3 #3c332a;
|
||||
@define-color bg_4 #4a4035;
|
||||
@define-color bg_5 #5a4f43;
|
||||
|
||||
/* Encres / texte */
|
||||
@define-color ink_1 #f2e5c7;
|
||||
@define-color ink_2 #d5c4a1;
|
||||
@define-color ink_3 #a89984;
|
||||
@define-color ink_4 #7c6f64;
|
||||
|
||||
/* Accent orange seventies */
|
||||
@define-color accent_color #fe8019;
|
||||
@define-color accent_soft #d65d0e;
|
||||
@define-color accent_fg_color #221c17;
|
||||
|
||||
/* Statuts */
|
||||
@define-color success_color #4dbb26;
|
||||
@define-color warning_color #fabd2f;
|
||||
@define-color error_color #fb4934;
|
||||
@define-color info_color #83a598;
|
||||
@define-color blue_color #3db0d1;
|
||||
@define-color purple_color #c882c8;
|
||||
|
||||
/* Bordures */
|
||||
@define-color border_1 alpha(#a89984, 0.18);
|
||||
@define-color border_2 alpha(#a89984, 0.32);
|
||||
@define-color border_3 alpha(#a89984, 0.55);
|
||||
|
||||
/* Couleurs sémantiques GNOME / libadwaita (overrides) */
|
||||
@define-color window_bg_color @bg_1;
|
||||
@define-color window_fg_color @ink_1;
|
||||
@define-color view_bg_color @bg_2;
|
||||
@define-color view_fg_color @ink_1;
|
||||
@define-color headerbar_bg_color @bg_2;
|
||||
@define-color headerbar_fg_color @ink_1;
|
||||
@define-color headerbar_border_color @border_2;
|
||||
@define-color headerbar_backdrop_color @bg_1;
|
||||
@define-color sidebar_bg_color @bg_2;
|
||||
@define-color sidebar_fg_color @ink_1;
|
||||
@define-color sidebar_backdrop_color @bg_1;
|
||||
@define-color popover_bg_color @bg_3;
|
||||
@define-color popover_fg_color @ink_1;
|
||||
@define-color card_bg_color @bg_3;
|
||||
@define-color card_fg_color @ink_1;
|
||||
@define-color shade_color alpha(black, 0.4);
|
||||
@define-color scrollbar_outline_color alpha(@ink_3, 0.3);
|
||||
|
||||
/* ============================================================
|
||||
COMPOSANTS GTK — habillage Gruvbox seventies
|
||||
============================================================ */
|
||||
|
||||
/* Fond global */
|
||||
window {
|
||||
background-color: @window_bg_color;
|
||||
color: @window_fg_color;
|
||||
font-family: 'Inter', 'Cantarell', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* HeaderBar (barre de titre) */
|
||||
headerbar {
|
||||
background: @bg_2;
|
||||
color: @ink_1;
|
||||
border-bottom: 1px solid @border_2;
|
||||
box-shadow: inset 0 1px 0 alpha(white, 0.04);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
headerbar .title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
headerbar .subtitle {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: @ink_3;
|
||||
}
|
||||
|
||||
/* Boutons — relief 3D et accent */
|
||||
button {
|
||||
background: @bg_3;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font-weight: 500;
|
||||
box-shadow:
|
||||
inset 0 1px 0 alpha(white, 0.06),
|
||||
inset 0 -1px 0 alpha(black, 0.3),
|
||||
0 1px 2px alpha(black, 0.4);
|
||||
transition: all 60ms ease;
|
||||
}
|
||||
button:active {
|
||||
background: @bg_4;
|
||||
box-shadow: inset 0 2px 4px alpha(black, 0.5);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
button:disabled {
|
||||
color: @ink_4;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Bouton "suggested-action" = primary (accent orange) */
|
||||
button.suggested-action {
|
||||
background: @accent_color;
|
||||
color: @accent_fg_color;
|
||||
border-color: @accent_soft;
|
||||
box-shadow:
|
||||
inset 0 1px 0 alpha(white, 0.2),
|
||||
0 2px 6px alpha(@accent_color, 0.35);
|
||||
}
|
||||
button.suggested-action:active {
|
||||
background: @accent_soft;
|
||||
}
|
||||
|
||||
/* Bouton "destructive-action" = danger */
|
||||
button.destructive-action {
|
||||
background: @bg_3;
|
||||
color: @error_color;
|
||||
border-color: @error_color;
|
||||
}
|
||||
|
||||
/* Bouton plat (toolbar) */
|
||||
button.flat {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.flat:hover {
|
||||
background: @bg_3;
|
||||
}
|
||||
|
||||
/* Champs de saisie */
|
||||
entry,
|
||||
text {
|
||||
background: @bg_1;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: inset 0 1px 2px alpha(black, 0.3);
|
||||
}
|
||||
entry:focus,
|
||||
text:focus {
|
||||
border-color: @accent_color;
|
||||
outline: 2px solid alpha(@accent_color, 0.18);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Listes / treeview */
|
||||
list,
|
||||
treeview {
|
||||
background: @bg_2;
|
||||
color: @ink_1;
|
||||
}
|
||||
list > row {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid @border_1;
|
||||
}
|
||||
list > row:selected,
|
||||
treeview:selected {
|
||||
background: alpha(@accent_color, 0.12);
|
||||
color: @ink_1;
|
||||
border-left: 3px solid @accent_color;
|
||||
}
|
||||
|
||||
/* Switch (toggle) */
|
||||
switch {
|
||||
background: @bg_4;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 12px;
|
||||
box-shadow: inset 0 1px 2px alpha(black, 0.4);
|
||||
min-height: 22px;
|
||||
min-width: 42px;
|
||||
}
|
||||
switch:checked {
|
||||
background: @accent_color;
|
||||
border-color: @accent_soft;
|
||||
box-shadow: 0 0 10px alpha(@accent_color, 0.35);
|
||||
}
|
||||
switch slider {
|
||||
background: @ink_2;
|
||||
border-radius: 50%;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
}
|
||||
switch:checked slider {
|
||||
background: @accent_fg_color;
|
||||
}
|
||||
|
||||
/* Scale (slider) */
|
||||
scale trough {
|
||||
background: @bg_1;
|
||||
border-radius: 4px;
|
||||
min-height: 6px;
|
||||
}
|
||||
scale highlight {
|
||||
background: @accent_color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
scale slider {
|
||||
background: @ink_1;
|
||||
border: 2px solid @accent_color;
|
||||
border-radius: 50%;
|
||||
min-width: 16px;
|
||||
min-height: 16px;
|
||||
box-shadow: 0 1px 4px alpha(black, 0.5);
|
||||
}
|
||||
|
||||
/* Progress bar (jauge horizontale type batterie) */
|
||||
progressbar trough {
|
||||
background: @bg_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 2px alpha(black, 0.4);
|
||||
min-height: 12px;
|
||||
}
|
||||
progressbar progress {
|
||||
background: @success_color;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 8px alpha(@success_color, 0.45);
|
||||
}
|
||||
|
||||
/* Niveaux de progression sémantiques (à appliquer via add_css_class) */
|
||||
progressbar.warning progress { background: @warning_color; }
|
||||
progressbar.error progress { background: @error_color; }
|
||||
progressbar.info progress { background: @info_color; }
|
||||
|
||||
/* Notebook / onglets */
|
||||
notebook header {
|
||||
background: @bg_2;
|
||||
border-bottom: 1px solid @border_2;
|
||||
}
|
||||
notebook tab {
|
||||
padding: 8px 16px;
|
||||
color: @ink_3;
|
||||
border-top: 2px solid transparent;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
notebook tab:checked {
|
||||
color: @ink_1;
|
||||
border-top-color: @accent_color;
|
||||
background: @bg_3;
|
||||
}
|
||||
|
||||
/* Popover */
|
||||
popover contents {
|
||||
background: @bg_3;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 12px 32px alpha(black, 0.55);
|
||||
}
|
||||
|
||||
/* Menubutton / dropdown */
|
||||
menubutton button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Status pill (badge) — à appliquer sur GtkLabel.status */
|
||||
label.status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
label.status.ok { background: alpha(@success_color, 0.18); color: @success_color; }
|
||||
label.status.warn { background: alpha(@warning_color, 0.18); color: @warning_color; }
|
||||
label.status.error { background: alpha(@error_color, 0.18); color: @error_color; }
|
||||
label.status.info { background: alpha(@info_color, 0.18); color: @info_color; }
|
||||
|
||||
/* Texte monospace / terminal */
|
||||
label.mono,
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
label.terminal,
|
||||
.terminal {
|
||||
font-family: 'Share Tech Mono', 'VT323', monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Carte tuile (à appliquer via add_css_class("tile")) */
|
||||
.tile,
|
||||
.card {
|
||||
background: @bg_3;
|
||||
color: @ink_1;
|
||||
border: 1px solid @border_2;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 alpha(white, 0.06),
|
||||
inset 0 -1px 0 alpha(black, 0.4),
|
||||
0 2px 4px alpha(black, 0.4),
|
||||
0 6px 14px alpha(black, 0.45);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
scrollbar slider {
|
||||
background: @border_2;
|
||||
border-radius: 4px;
|
||||
min-width: 6px;
|
||||
min-height: 6px;
|
||||
}
|
||||
scrollbar slider:hover {
|
||||
background: @accent_soft;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THÈME CLAIR — à charger en alternative
|
||||
Pour appliquer le thème clair, charger ce fichier puis
|
||||
`tokens.gnome.light.css` (à dupliquer en remplaçant
|
||||
les @define-color des fonds et encres) OU appliquer
|
||||
un settings GTK light :
|
||||
g_object_set(gtk_settings, "gtk-application-prefer-dark-theme",
|
||||
FALSE, NULL);
|
||||
Et fournir un fichier dérivé avec les valeurs ci-dessous :
|
||||
============================================================ */
|
||||
/*
|
||||
bg_0: #b8b2a3
|
||||
bg_1: #d5d0c5
|
||||
bg_2: #dcd7cc
|
||||
bg_3: #e3ded3
|
||||
bg_4: #ccc6b8
|
||||
bg_5: #bdb6a7
|
||||
ink_1: #28241f
|
||||
ink_2: #3c3836
|
||||
ink_3: #5a544c
|
||||
ink_4: #8a8278
|
||||
accent_color: #af3a03
|
||||
success_color: #3c911c
|
||||
warning_color: #b57614
|
||||
error_color: #9d0006
|
||||
info_color: #427b58
|
||||
blue_color: #2d82a3
|
||||
purple_color: #8c468c
|
||||
*/
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"$schema": "design-tokens-v1",
|
||||
"name": "mon design system — gruvbox seventies",
|
||||
"version": "1.0.0",
|
||||
"description": "Design system Gruvbox seventies. Orange brûlé, fond brun délavé en sombre / gris clair usé en clair. Deux thèmes dark/light parfaitement à parité.",
|
||||
"themes": {
|
||||
"dark": {
|
||||
"bg": {
|
||||
"0": { "value": "#221c17", "description": "Niveau le plus profond, rare" },
|
||||
"1": { "value": "#2a231d", "description": "Fond application principal" },
|
||||
"2": { "value": "#322a23", "description": "Panneaux (sidebar, headerbar)" },
|
||||
"3": { "value": "#3c332a", "description": "Cartes, tuiles" },
|
||||
"4": { "value": "#4a4035", "description": "Hover, état actif" },
|
||||
"5": { "value": "#5a4f43", "description": "Press, sélection forte" }
|
||||
},
|
||||
"ink": {
|
||||
"1": { "value": "#f2e5c7", "description": "Texte principal (cream)" },
|
||||
"2": { "value": "#d5c4a1", "description": "Texte secondaire" },
|
||||
"3": { "value": "#a89984", "description": "Labels, hints" },
|
||||
"4": { "value": "#7c6f64", "description": "Désactivé" }
|
||||
},
|
||||
"accent": {
|
||||
"primary": { "value": "#fe8019", "description": "Orange Gruvbox seventies" },
|
||||
"soft": { "value": "#d65d0e", "description": "Orange foncé (hover, bordures)" },
|
||||
"glow": { "value": "rgba(254, 128, 25, 0.35)" },
|
||||
"tint": { "value": "rgba(254, 128, 25, 0.12)" }
|
||||
},
|
||||
"status": {
|
||||
"ok": { "value": "#4dbb26" },
|
||||
"warn": { "value": "#fabd2f" },
|
||||
"err": { "value": "#fb4934" },
|
||||
"info": { "value": "#83a598" }
|
||||
},
|
||||
"extra": {
|
||||
"blue": { "value": "#3db0d1" },
|
||||
"purple": { "value": "#c882c8" }
|
||||
},
|
||||
"border": {
|
||||
"1": { "value": "rgba(168, 153, 132, 0.18)" },
|
||||
"2": { "value": "rgba(168, 153, 132, 0.32)" },
|
||||
"3": { "value": "rgba(168, 153, 132, 0.55)" }
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"bg": {
|
||||
"0": { "value": "#b8b2a3", "description": "Niveau le plus profond" },
|
||||
"1": { "value": "#d5d0c5", "description": "Fond application principal" },
|
||||
"2": { "value": "#dcd7cc", "description": "Panneaux" },
|
||||
"3": { "value": "#e3ded3", "description": "Cartes, tuiles" },
|
||||
"4": { "value": "#ccc6b8", "description": "Hover" },
|
||||
"5": { "value": "#bdb6a7", "description": "Press" }
|
||||
},
|
||||
"ink": {
|
||||
"1": { "value": "#28241f", "description": "Texte principal" },
|
||||
"2": { "value": "#3c3836", "description": "Texte secondaire" },
|
||||
"3": { "value": "#5a544c", "description": "Labels, hints" },
|
||||
"4": { "value": "#8a8278", "description": "Désactivé" }
|
||||
},
|
||||
"accent": {
|
||||
"primary": { "value": "#af3a03", "description": "Orange brûlé (variante contrastée)" },
|
||||
"soft": { "value": "#d65d0e" },
|
||||
"glow": { "value": "rgba(175, 58, 3, 0.28)" },
|
||||
"tint": { "value": "rgba(175, 58, 3, 0.08)" }
|
||||
},
|
||||
"status": {
|
||||
"ok": { "value": "#3c911c" },
|
||||
"warn": { "value": "#b57614" },
|
||||
"err": { "value": "#9d0006" },
|
||||
"info": { "value": "#427b58" }
|
||||
},
|
||||
"extra": {
|
||||
"blue": { "value": "#2d82a3" },
|
||||
"purple": { "value": "#8c468c" }
|
||||
},
|
||||
"border": {
|
||||
"1": { "value": "rgba(60, 56, 54, 0.15)" },
|
||||
"2": { "value": "rgba(60, 56, 54, 0.28)" },
|
||||
"3": { "value": "rgba(60, 56, 54, 0.5)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"fonts": {
|
||||
"ui": { "family": "Inter", "weights": [400, 500, 600, 700], "fallback": ["Cantarell", "system-ui", "sans-serif"] },
|
||||
"mono": { "family": "JetBrains Mono", "weights": [400, 500, 600, 700], "fallback": ["ui-monospace", "monospace"] },
|
||||
"terminal": { "family": "Share Tech Mono", "weights": [400], "fallback": ["VT323", "Courier New", "monospace"] }
|
||||
},
|
||||
"scale": {
|
||||
"label": { "size": 11, "weight": 500, "transform": "uppercase", "tracking": "0.08em", "family": "mono" },
|
||||
"caption": { "size": 12, "weight": 400, "family": "ui" },
|
||||
"body": { "size": 14, "weight": 400, "family": "ui" },
|
||||
"body-emph": { "size": 14, "weight": 600, "family": "ui" },
|
||||
"title": { "size": 18, "weight": 700, "family": "ui" },
|
||||
"h2": { "size": 22, "weight": 700, "family": "ui" },
|
||||
"h1": { "size": 28, "weight": 700, "family": "ui" },
|
||||
"display": { "size": 44, "weight": 700, "family": "ui" },
|
||||
"kpi": { "size": 28, "weight": 700, "family": "mono" }
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"xs": 3,
|
||||
"sm": 4,
|
||||
"md": 6,
|
||||
"lg": 8,
|
||||
"xl": 10,
|
||||
"2xl": 12,
|
||||
"pill": 999
|
||||
},
|
||||
"spacing": {
|
||||
"1": 4,
|
||||
"2": 6,
|
||||
"3": 8,
|
||||
"4": 10,
|
||||
"5": 12,
|
||||
"6": 14,
|
||||
"7": 16,
|
||||
"8": 18,
|
||||
"9": 20,
|
||||
"10": 24,
|
||||
"12": 32,
|
||||
"14": 40,
|
||||
"16": 56
|
||||
},
|
||||
"shadows": {
|
||||
"1": "0 1px 2px rgba(0,0,0,0.4)",
|
||||
"2": "0 4px 12px rgba(0,0,0,0.45)",
|
||||
"3": "0 12px 32px rgba(0,0,0,0.55)",
|
||||
"press": "inset 0 2px 4px rgba(0,0,0,0.5)",
|
||||
"tile3d": "inset 0 1px 0 rgba(255,230,180,0.12), inset 0 -1px 0 rgba(0,0,0,0.45), 0 1px 0 rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 8px 18px rgba(0,0,0,0.5)"
|
||||
},
|
||||
"motion": {
|
||||
"fast": "60ms ease",
|
||||
"normal": "180ms cubic-bezier(.3,.7,.3,1.2)",
|
||||
"slow": "400ms cubic-bezier(.3,.6,.3,1)"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
# Jalon 1 — Tranche verticale APT — Design
|
||||
|
||||
> Spec du premier jalon de la webapp de mise à jour distante Linux.
|
||||
> Statut : **validé** (2026-06-04). Langue de travail : français.
|
||||
> Voir aussi : `CLAUDE.md`, `deep-research-report(7).md` (étude d'architecture), `ajout.md` (volet Hermes / MCP).
|
||||
|
||||
## Objectif
|
||||
|
||||
Construire la première tranche verticale qui valide toute l'ossature de la plateforme
|
||||
(UI ↔ API ↔ worker ↔ SSH ↔ JSON canonique ↔ rapport) sur le cas le plus simple :
|
||||
|
||||
> ajouter une machine Debian/Ubuntu → tester la connexion → rafraîchir les mises à jour APT
|
||||
> en tâche de fond → afficher les paquets dans une tuile → déclencher `full-upgrade` (ou `reboot`)
|
||||
> manuellement avec terminal live → archiver un rapport Markdown + log brut.
|
||||
|
||||
## Décisions verrouillées
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Périmètre | Tranche verticale APT, Debian/Ubuntu, 1 machine à la fois |
|
||||
| Stockage | SQLite via Drizzle ORM |
|
||||
| File de jobs | In-process (croner / worker interne), pas de pg-boss/BullMQ |
|
||||
| Auth SSH | Mot de passe (login + password + sudo password), chiffré au repos |
|
||||
| Déploiement | Docker, clé maître de chiffrement via variable d'environnement |
|
||||
| Auth webapp | Aucune au MVP (réseau de confiance derrière reverse proxy / VPN) |
|
||||
| Exécution | Templates shell versionnés `.sh.tpl` (Mustache), esprit `nas-ops` |
|
||||
| Architecture | API headless agnostique du frontend (réutilisable par TUI / MCP / Hermes) |
|
||||
| Frontend | React 19 + Vite + design system Gruvbox seventies (`design_system/`) |
|
||||
| Backend | Hono + TypeScript |
|
||||
| Terminal live | WebSocket + xterm.js |
|
||||
| Packaging code | Mono-package pnpm (client + server, un seul `package.json`) |
|
||||
| Volet Hermes | Stub visuel uniquement (« à venir ») |
|
||||
| Actions | `apt_full_upgrade` **et** `reboot` |
|
||||
|
||||
### Hors périmètre de ce jalon (jalons suivants)
|
||||
|
||||
Docker Compose ; profils Proxmox / Raspberry Pi OS ; serveur MCP ; intelligence Hermes ;
|
||||
authentification de la webapp ; clés SSH ; déduplication multi-machines ; apt-cacher-ng persistant.
|
||||
|
||||
## Contrainte d'architecture transverse
|
||||
|
||||
Le **cœur métier (API) est headless et agnostique du frontend**. Le client web n'est qu'un
|
||||
consommateur parmi d'autres ; un futur TUI, un bot de messagerie, le serveur MCP et Hermes
|
||||
taperont sur la **même API interne**. Conséquence : **aucune logique métier dans le client**.
|
||||
Les secrets et l'exécution restent côté backend ; les clients (web, TUI, bot) proposent et
|
||||
déclenchent uniquement des actions autorisées.
|
||||
|
||||
## Structure du projet (mono-package pnpm, racine du dépôt)
|
||||
|
||||
```
|
||||
system_update/
|
||||
├─ shared/ # types JSON canoniques (snapshot, execution result), partagés client+server
|
||||
├─ server/ # API Hono headless — cœur métier
|
||||
│ ├─ index.ts
|
||||
│ ├─ routes/ # machines, refresh, actions, ws
|
||||
│ ├─ services/ # orchestration update, génération snapshot, rapports
|
||||
│ ├─ ssh/ # wrapper ssh2 (password, sudo -S, exécution détachée)
|
||||
│ ├─ templates/ # rendu Mustache + parsing JSON
|
||||
│ ├─ crypto/ # chiffrement AES-256-GCM des credentials
|
||||
│ ├─ jobs/ # worker in-process (croner)
|
||||
│ └─ db/ # schéma + migrations Drizzle (SQLite)
|
||||
├─ client/ # React 19 + Vite + design system
|
||||
│ └─ src/{components,panels,features,lib}
|
||||
├─ templates/apt/ # check.sh.tpl, full-upgrade.sh.tpl, reboot-check.sh.tpl
|
||||
├─ reports/ # rapports .md + logs bruts (volume Docker)
|
||||
├─ docker/ # Dockerfile + docker-compose.yml
|
||||
└─ docs/superpowers/specs/
|
||||
```
|
||||
|
||||
Le design system existant (`design_system/tokens/*`, `design_system/components/ui-kit.jsx`)
|
||||
est consommé/porté dans `client/src/components` : `tokens.css` chargé globalement,
|
||||
`data-theme` posé sur `<html>`.
|
||||
|
||||
## Modèle de données (SQLite / Drizzle)
|
||||
|
||||
- **machines** : `id, name, hostname, port, os_family, username, enc_password, enc_sudo_password,
|
||||
apt_proxy_mode, apt_proxy_url, status, last_checked_at, created_at`
|
||||
- **snapshots** : `id, machine_id, checked_at, status, payload_json`
|
||||
(le *update availability snapshot* canonique du rapport)
|
||||
- **executions** : `id, machine_id, action, mode, started_at, finished_at, status, result_json,
|
||||
report_path, raw_log_path`
|
||||
|
||||
Les jobs de fond sont suivis en mémoire + reflétés via `machine.status` ; pas de table jobs
|
||||
dédiée au MVP.
|
||||
|
||||
### Secrets
|
||||
|
||||
Chiffrement **AES-256-GCM**. Clé maître lue depuis `SU_MASTER_KEY` (env). Les credentials sont
|
||||
déchiffrés uniquement en mémoire au moment de l'exécution SSH. **Jamais loggués, jamais
|
||||
sérialisés vers l'API, l'UI ou (futur) le MCP.**
|
||||
|
||||
## Couche SSH + templates (le moteur)
|
||||
|
||||
- `ssh2` en authentification mot de passe.
|
||||
- Commandes enveloppées `sh -c`, avec `LC_ALL=C` et un `PATH` minimal pour stabiliser les sorties
|
||||
(pattern repris de `linux-update-dashboard`).
|
||||
- sudo via `sudo -S` (mot de passe sudo poussé sur stdin).
|
||||
- **Opérations longues détachées** : `nohup` + fichier de log + fichier d'exit-code, pour survivre
|
||||
à une coupure SSH.
|
||||
- Les templates `.sh.tpl` (Mustache) **produisent eux-mêmes le JSON** (esprit `nas-ops`) :
|
||||
- `check.sh.tpl` → `apt-get update -qq` puis simulation `full-upgrade`, émet
|
||||
`{ count, packages[], reboot_required }`.
|
||||
- `full-upgrade.sh.tpl` → applique `apt-get full-upgrade` (options dpkg défensives), émet le
|
||||
*execution result*.
|
||||
- `reboot-check.sh.tpl` → état `reboot_required` / reboot.
|
||||
- Le backend rend le template avec les variables par machine (proxy APT, etc.), pousse en SSH,
|
||||
parse la **dernière ligne JSON**, et **streame** la sortie lisible vers le terminal via WebSocket.
|
||||
|
||||
## API headless (contrat réutilisable par TUI / MCP / Hermes)
|
||||
|
||||
```
|
||||
GET /api/machines
|
||||
POST /api/machines # crée + test-connection
|
||||
POST /api/machines/:id/test-connection
|
||||
GET /api/machines/:id/snapshot
|
||||
POST /api/machines/:id/refresh # déclenche un job de fond
|
||||
POST /api/machines/:id/actions # { action: "apt_full_upgrade" | "reboot" }
|
||||
GET /api/machines/:id/executions
|
||||
GET /api/machines/:id/executions/:execId
|
||||
GET /api/machines/:id/executions/:execId/report
|
||||
WS /api/ws/machines/:id/output # flux live + buffer rejouable
|
||||
```
|
||||
|
||||
## Flux fonctionnels
|
||||
|
||||
1. **Ajout machine** — `POST /api/machines` : connexion SSH de test + détection OS
|
||||
(`lsb_release` / `/etc/os-release`), chiffrement et stockage des credentials, création de la
|
||||
tuile avec état initial.
|
||||
2. **Refresh (tâche de fond)** — le worker rend `check.sh.tpl`, exécute en SSH, parse le snapshot
|
||||
JSON, le stocke ; la tuile UI se met à jour (polling ou WS).
|
||||
3. **Upgrade (manuel)** — `POST /api/machines/:id/actions { action: "apt_full_upgrade" }` : job
|
||||
détaché, sortie streamée sur `WS /api/ws/machines/:id/output` ; à la fin, parsing du résultat,
|
||||
écriture du rapport `.md` + log brut, persistance de l'exécution.
|
||||
4. **Reboot (manuel)** — même mécanique, action `reboot`.
|
||||
5. **Rapport** — `GET …/report` renvoie le Markdown archivé.
|
||||
|
||||
## Frontend (design system Gruvbox, layout 3 volets)
|
||||
|
||||
Layout `Resizable` :
|
||||
- **Gauche** : panneau Hermes (stub « à venir »).
|
||||
- **Centre** : dashboard de tuiles machines + bouton `+` (Popup d'ajout).
|
||||
- **Droite** : terminal `xterm.js` branché sur le WebSocket.
|
||||
|
||||
Composants issus du design system (Button, IconButton, StatusLed, Popup, tuiles `glass`,
|
||||
BatteryGauge pour les compteurs d'updates). Respect strict des règles : pas de hover (pression
|
||||
3D), tooltips sur icônes seules, polices Inter / JetBrains Mono / Share Tech Mono, vérification
|
||||
des deux thèmes (`data-theme`).
|
||||
|
||||
## Gestion d'erreurs & réduction
|
||||
|
||||
- Échec de connexion → `machine.status = error` + message **sans secret**.
|
||||
- Template avec exit ≠ 0 → `execution.status = error`, capture des lignes stderr importantes.
|
||||
- Reconnexion WS → rejoue le buffer circulaire côté serveur.
|
||||
- **Réducteur déterministe APT** construit dès ce jalon : filtre les lignes utiles
|
||||
(`Inst`, `Conf`, `Remv`, `Err`, `E:`, `W:`, `dpkg:`, `reboot-required`) ; le log brut complet
|
||||
reste sur disque. Le JSON canonique reste propre — prépare l'intégration Hermes sans la câbler.
|
||||
|
||||
## Tests (vitest)
|
||||
|
||||
Unitaires :
|
||||
- rendu de template Mustache,
|
||||
- **parser de sortie APT → snapshot JSON** (sur fixtures de sortie capturées),
|
||||
- round-trip chiffrement / déchiffrement des credentials,
|
||||
- réducteur de lignes APT.
|
||||
|
||||
La couche SSH réelle est validée manuellement contre une vraie machine pour ce jalon (pas de
|
||||
mock SSH lourd).
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] Ajout d'une machine Debian/Ubuntu via le bouton `+`, avec test de connexion réussi.
|
||||
- [ ] Credentials stockés chiffrés ; aucun secret visible en base, API, logs ou UI.
|
||||
- [ ] Refresh de fond produit un snapshot JSON canonique avec compteur + liste de paquets.
|
||||
- [ ] La tuile affiche nom, IP, OS, compteur d'updates, paquets concernés.
|
||||
- [ ] `full-upgrade` déclenché manuellement, sortie visible en direct dans le terminal xterm.js.
|
||||
- [ ] `reboot` déclenchable manuellement.
|
||||
- [ ] Un rapport `.md` + un log brut sont archivés après exécution.
|
||||
- [ ] L'app tourne en Docker avec `SU_MASTER_KEY` en env et volumes pour SQLite + rapports.
|
||||
- [ ] Tests vitest verts (parser, crypto, réducteur, rendu template).
|
||||
Reference in New Issue
Block a user