chore: init projet — spec design + design system

Spec complète dans docs/superpowers/specs/2026-05-28-inventaire-hdd-design.md :
architecture 2 conteneurs Docker (FastAPI + nginx), script Python stdlib only,
SQLite avec serial comme clé de vérité, API ingest + dashboard + agents IA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gilles Soulier
2026-05-28 19:46:54 +02:00
commit f9f805cd8b
11 changed files with 3755 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Projet
Script bash one-shot d'inventaire disques (HDD/SSD/NVMe) pour systèmes Debian/Proxmox. Exécuté manuellement une seule fois par machine — pas de cron, pas de daemon, pas de service.
## Lancement
```bash
# Local
bash inventaire_hdd.sh
# À distance (via Gitea)
curl -fsSL https://<gitea-host>/<user>/mes_hdd/raw/branch/main/inventaire_hdd.sh | bash
```
## Architecture du script
`inventaire_hdd.sh` suit ce pipeline :
1. Détection machine (hostname, IP via `ip route`)
2. Listage des disques entiers via `lsblk -d -o NAME,TYPE`
3. Pour chaque disque : modèle/série via `lsblk` puis `smartctl -i` en fallback
4. État SMART via `smartctl -H` + `smartctl -A` (traduit en français simple)
5. Type (HDD/SSD/NVMe) via `/sys/block/<name>/queue/rotational`
6. Montage/espace via `df -h`
7. Lien `by-id` via `/dev/disk/by-id/`
8. Génération rapport Markdown → `hdd.md` dans le répertoire du script (ou `pwd` si exécuté via pipe)
## Publication MQTT (à implémenter)
Le script doit aussi publier un JSON retenu vers :
- broker : `10.0.0.3:1883` (sans auth)
- topic : `hdd/inventaire/<hostname>`
- flag retain (`mosquitto_pub -r`)
Format JSON attendu : voir `consigne.hd` section "Format de message conseillé".
## Prérequis système
- `smartmontools` (smartctl) — optionnel, géré gracieusement
- `util-linux` (lsblk)
- `mosquitto-clients` (mosquitto_pub) — pour la publication MQTT
- `coreutils` (df, hostname), `iproute2` (ip)
## Robustesse
Le script doit continuer l'inventaire même si un disque échoue, si smartctl est absent, ou si le système est une VM sans accès SMART. Chaque erreur est captée localement (pas de `set -e`).
+240
View File
@@ -0,0 +1,240 @@
# Consigne pour Claude Code — inventaire HDD/SMART vers MQTT
## Contexte
Gilles veut un **script Linux lancé une seule fois par machine** pour inventorier les disques dun système Debian ou Proxmox.
Le script doit fonctionner sur :
- une machine physique
- une VM
- une VM avec disques pass-through
- une VM avec PCI passthrough si les disques apparaissent dans lOS invité
Le but est de **collecter un snapshot à linstant T** puis de **publier le résultat sur MQTT**.
Le script doit être stocké dans le même dépôt que le reste du projet :
- dépôt local : `mes_hdd`
- chemin local du projet : `/home/gilles/projects/mes_hdd/`
Le fichier de consigne doit être copié dans ce projet sous le nom :
- `consigne.hd`
---
## Objectif principal
Créer un script qui :
1. détecte les disques visibles par lOS
2. identifie chaque disque dans le système
3. lit l’état SMART quand cest possible
4. récupère la taille du disque
5. récupère les partitions et le taux dutilisation des points de montage
6. publie un **message MQTT retenu** (`retain`) sur le broker de Gilles
7. sarrête après une exécution unique
Il ne faut **pas** prévoir de cron ni dagent permanent.
---
## Broker MQTT cible
Le script doit publier vers :
- hôte : `10.0.0.3`
- port : `1883`
- sans user ni mot de passe
Le message doit être **retenu sur le serveur MQTT**.
La publication doit donc utiliser l’équivalent de :
- `mosquitto_pub ... -r`
---
## Résultat attendu
Le script doit produire un **payload structuré**, idéalement en JSON, contenant au minimum :
- hostname
- IP principale
- date/heure de collecte
- liste des disques
- identifiant système du disque : `sda`, `sdb`, `nvme0n1`, etc.
- chemin du périphérique : `/dev/sdX`, `/dev/nvmeXn1`, etc.
- modèle
- numéro de série
- type : HDD / SSD / NVMe / inconnu
- capacité totale
- partitions détectées
- point de montage associé
- espace libre
- espace total
- taux dutilisation
- état SMART
- état SMART expliqué en français simple
- remarque / statut
---
## Exigences sur l’état SMART
L’état SMART doit être compréhensible par un novice, en français.
Exemples de libellés attendus :
- `Bon état`
- `Attention`
- `Défaillance probable`
- `SMART indisponible`
Le script peut aussi ajouter une explication courte, par exemple :
- `Bon état : aucun problème SMART détecté`
- `Attention : certains indicateurs SMART sont dégradés`
- `Défaillance probable : prévoir le remplacement du disque`
Le but est que la lecture côté MQTT soit immédiatement compréhensible.
---
## Exigences sur lidentification système
Le script doit identifier clairement les disques visibles sous Linux :
- `sda`, `sdb`, `sdc`, …
- `nvme0n1`, `nvme1n1`, …
- si possible, inclure aussi le lien `/dev/disk/by-id/...`
Lobjectif est de relier :
- le nom logique dans lOS
- le modèle du disque
- le numéro de série
- les partitions
- le point de montage
- lusage réel
---
## Exigences sur les partitions et lespace
Pour chaque disque, si des partitions ou des montages existent, récupérer :
- la liste des partitions
- le point de montage
- lespace libre
- lespace total
- le taux dutilisation
Si un disque na pas de filesystem monté, le script doit lindiquer clairement.
---
## Robustesse
Le script doit rester robuste si :
- un disque ne répond pas
- `smartctl` échoue
- un disque nest pas monté
- SMART nest pas accessible depuis lenvironnement
- le système est une VM et ne voit pas tous les disques physiques sous-jacents
Le script ne doit pas casser linventaire global si un disque est problématique.
---
## Mode dexécution
Le script doit être **exécuté une seule fois** par machine.
Il ne faut pas créer :
- de service permanent
- de daemon
- de cron
- de timer systemd
Le mode attendu est une exécution manuelle ou à distance ponctuelle.
---
## Publication MQTT
Le script doit publier le résultat :
- sur un topic dédié par machine, par exemple `hdd/inventaire/<hostname>`
- en **JSON** si possible
- avec le mode **retain** activé
Le message retenu permettra à un client MQTT de relire le dernier état plus tard.
---
## Format de message conseillé
Exemple de structure JSON :
```json
{
"hostname": "pve1",
"ip": "10.0.3.205",
"timestamp": "2026-05-28T15:30:00+02:00",
"disks": [
{
"device": "sda",
"path": "/dev/sda",
"by_id": "/dev/disk/by-id/...",
"model": "ST1000LM024",
"serial": "...",
"type": "HDD",
"capacity": "1T",
"smart": "Bon état",
"smart_detail": "Bon état : aucun problème SMART détecté",
"partitions": [
{
"name": "sda1",
"mountpoint": "/mnt/data",
"free": "500G",
"total": "1T",
"used_percent": 50
}
]
}
]
}
```
---
## Fichier de sortie local
Même si la destination principale est MQTT, le script peut aussi générer localement :
- un fichier JSON temporaire
- ou un fichier Markdown de debug si utile
Mais ce nest pas obligatoire.
---
## Lancement à distance
Prévoir une méthode simple pour exécuter le script à distance, par exemple :
- `curl ... | bash`
- ou téléchargement puis exécution
- ou script brut servi depuis le dépôt Gitea
La méthode distante doit être :
- documentée
- reproductible
- sans secret en clair
---
## Source de vérité fonctionnelle
Le script doit sappuyer sur des outils simples et standards :
- `lsblk`
- `df`
- `smartctl`
- `hostname`
- `ip`
- `mosquitto_pub`
---
## Critères de qualité
- code lisible
- exécution unique
- sortie JSON claire
- état SMART compréhensible en français
- publication MQTT retenue
- aucune dépendance inutile
- pas de cron
- pas dagent permanent
---
## Attendu de Claude Code
Claude Code doit :
1. proposer larchitecture minimale du script
2. créer le script dans `mes_hdd`
3. documenter le topic MQTT et le format JSON
4. prévoir la publication retenue
5. vérifier que le résultat est simple à exploiter sur MQTT
+304
View File
@@ -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.
+656
View File
@@ -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);
})();
+363
View File
@@ -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.
+115
View File
@@ -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
+204
View File
@@ -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); }
+378
View File
@@ -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
*/
+136
View File
@@ -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)"
}
}
@@ -0,0 +1,294 @@
# Inventaire HDD — Design Spec
**Date** : 2026-05-28
**Projet** : mes_hdd
**Statut** : approuvé
---
## Contexte
Outil de monitoring des disques physiques (HDD/SSD/NVMe) pour un parc de 1015 machines Proxmox, Debian et Ubuntu. Certaines machines sont des VMs avec disques en PCI passthrough (45 machines).
Le numéro de série est la **source de vérité unique** d'un disque : si un disque migre entre machines, si il tombe en panne ou est retiré, l'historique est conservé.
Le script est lancé **manuellement** par l'administrateur quand la configuration change (pas de cron, pas de daemon).
---
## Architecture globale
```
[Machine cliente — Proxmox / Debian / Ubuntu]
python3 inventaire.py (stdlib uniquement, pas de venv)
└── HTTP POST http://10.0.0.50:8088/api/ingest
[nginx:alpine — port 8088]
├── /api/* → proxy → [FastAPI :8000]
└── / → static (HTML + design system)
SQLite (volume Docker nommé)
```
**2 conteneurs Docker** via `docker-compose.yml` :
- `api` : FastAPI (Python), port interne 8000
- `web` : nginx:alpine, port exposé **8088**, sert les fichiers statiques et proxifie `/api/*`
SQLite dans un volume nommé (`mes_hdd_db`).
---
## Script client — `inventaire.py`
### Contraintes
- Python 3 uniquement, **stdlib exclusively** (`subprocess`, `json`, `urllib.request`)
- Aucun `pip install`, aucun venv, aucune dépendance externe
- Fonctionne sur Proxmox, Debian, Ubuntu
- **Doit être exécuté en root** (ou via `sudo`) : `smartctl` et `pvs`/`lvs` nécessitent des droits élevés
- Tolérant aux erreurs : un disque ou une commande qui échoue ne bloque pas l'inventaire des autres
- Si `smartctl` est absent → `smart.status = "unavailable"`
- Si `pvs`/`lvs` sont absents ou LVM non installé → partitions LVM ignorées, `lvm: null`
### Commandes utilisées
| Commande | Usage |
|---|---|
| `lsblk -J -o NAME,TYPE,SIZE,MODEL,SERIAL,FSTYPE,MOUNTPOINT,ROTA` | Structure des disques et partitions |
| `smartctl -H -A -i /dev/sdX` | Santé SMART + attributs |
| `df -B1` | Espace utilisé/libre par point de montage |
| `pvs --noheadings --reportformat json` | Volumes physiques LVM |
| `vgs --noheadings --reportformat json` | Groupes de volumes LVM |
| `lvs --noheadings --reportformat json -o lv_name,vg_name,lv_size,lv_path,lv_dm_path` | Volumes logiques LVM |
| `udevadm info --query=property --name=/dev/sdX` | Bus (sata/usb/nvme) |
| `find /dev/disk/by-id -type l` | Lien by-id (sans partitions) |
| `hostname`, `ip route get 1.1.1.1` | Identité de la machine |
### Payload JSON envoyé
```json
{
"hostname": "pve1",
"ip": "10.0.0.10",
"collected_at": "2026-05-28T15:30:00+02:00",
"disks": [
{
"device": "sda",
"path": "/dev/sda",
"by_id": "ata-ST1000LM024_W1234567",
"model": "ST1000LM024 HN-M101MBB",
"serial": "W1234567",
"type": "HDD",
"capacity_bytes": 1000204886016,
"capacity_human": "1.0 To",
"bus": "sata",
"smart": {
"status": "ok",
"label": "Bon état",
"detail": "2 847h d'utilisation · 38°C · aucun secteur défectueux",
"temperature_c": 38,
"power_on_hours": 2847,
"reallocated_sectors": 0,
"pending_sectors": 0,
"uncorrectable_sectors": 0
},
"partitions": [
{
"name": "sda1",
"fstype": "ext4",
"size_bytes": 536870912000,
"size_human": "500 Go",
"used_bytes": 128849018880,
"used_human": "120 Go",
"free_bytes": 408021991120,
"free_human": "380 Go",
"used_percent": 24,
"mountpoint": "/",
"lvm": null
},
{
"name": "sda2",
"fstype": "LVM2_member",
"size_bytes": 536870912000,
"size_human": "500 Go",
"mountpoint": null,
"lvm": {
"vg_name": "vg_data",
"logical_volumes": [
{
"lv_name": "lv_home",
"size_human": "300 Go",
"used_human": "50 Go",
"free_human": "250 Go",
"used_percent": 17,
"fstype": "ext4",
"mountpoint": "/home"
},
{
"lv_name": "lv_swap",
"size_human": "8 Go",
"fstype": "swap",
"mountpoint": null
}
]
}
}
]
}
]
}
```
### SMART — labels en français
| Cas | status | label | Exemple de detail |
|---|---|---|---|
| Test global PASSED, pas d'attribut dégradé | `ok` | Bon état | "2 847h · 38°C · aucun secteur défectueux" |
| PASSED mais attributs FAILING_NOW / In_the_past | `warn` | Attention | "3 secteurs réalloués · disque à surveiller" |
| Test global FAILED | `fail` | Défaillance probable | "Prévoir le remplacement du disque" |
| smartctl absent ou inaccessible | `unavailable` | SMART indisponible | "smartctl non disponible ou accès refusé" |
Les champs numériques (`temperature_c`, `power_on_hours`, `reallocated_sectors`, `pending_sectors`, `uncorrectable_sectors`) sont inclus quand disponibles, `null` sinon.
---
## Backend — FastAPI
### Routes
| Méthode | Route | Description |
|---|---|---|
| `POST` | `/api/ingest` | Reçoit le payload du script client |
| `GET` | `/api/disks` | Liste tous les disques (dernière observation par serial) |
| `GET` | `/api/disks/{serial}` | Historique complet d'un disque |
| `GET` | `/api/machines` | Liste des machines avec last_seen |
| `GET` | `/api/ai/summary` | Synthèse structurée pour agents IA |
| `GET` | `/api/ai/at-risk` | Disques avec status warn ou fail |
### Logique d'ingest
À chaque POST `/api/ingest` :
1. Upsert dans `machines` (hostname, ip, last_seen)
2. Pour chaque disque du payload :
- Upsert dans `disks` (serial comme PK) — met à jour `last_host`, `last_seen`, `status`
- Si `last_host``first_host` → le mouvement est détecté (visible via l'API)
- Insert dans `snapshots` (historique immuable)
---
## Base de données — SQLite
### Table `disks`
| Colonne | Type | Description |
|---|---|---|
| `serial` | TEXT PK | Numéro de série (source de vérité) |
| `model` | TEXT | Modèle du disque |
| `type` | TEXT | HDD / SSD / NVMe / inconnu |
| `capacity_bytes` | INTEGER | Capacité en octets |
| `capacity_human` | TEXT | Capacité lisible |
| `first_seen_host` | TEXT | Première machine observée |
| `first_seen_at` | TEXT | Date première observation |
| `last_seen_host` | TEXT | Dernière machine observée |
| `last_seen_at` | TEXT | Date dernière observation |
| `smart_status` | TEXT | Dernier status SMART |
### Table `snapshots`
| Colonne | Type | Description |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `serial` | TEXT FK → disks | |
| `hostname` | TEXT | Machine au moment de la collecte |
| `device` | TEXT | ex: sda, nvme0n1 |
| `smart_status` | TEXT | ok / warn / fail / unavailable |
| `smart_label` | TEXT | Label français |
| `smart_detail` | TEXT | Détail lisible |
| `smart_raw_json` | TEXT | Attributs bruts (JSON) |
| `partitions_json` | TEXT | Partitions + LVM (JSON) |
| `collected_at` | TEXT | ISO 8601 |
### Table `machines`
| Colonne | Type | Description |
|---|---|---|
| `hostname` | TEXT PK | |
| `ip` | TEXT | |
| `last_seen` | TEXT | ISO 8601 |
---
## Frontend (étape 2)
Design system **Gruvbox Seventies** — React 18 via CDN + Babel standalone (pas de build step).
Fichiers statiques servis par nginx, localisés dans `frontend/` :
- `index.html` — point d'entrée, charge tokens.css + ui-kit.jsx via CDN/local
- `app.jsx` — composant principal React (chargé Babel)
Structure de page prévue :
- Colonne gauche : `TreeNav` des machines, avec nombre de disques et statut global
- Zone principale : cartes disques par machine
- `StatusLed` (ok/warn/err) + modèle + serial
- `BatteryGauge` espace par partition/LV
- Détail partitions + LVM dans un `Popup`
- Indicateur visuel de disque déplacé (serial apparu sur nouvelle machine)
---
## Docker Compose
```yaml
services:
api:
build: ./api
restart: unless-stopped
volumes:
- mes_hdd_db:/data
environment:
DB_PATH: /data/mes_hdd.db
expose:
- "8000"
web:
image: nginx:alpine
restart: unless-stopped
ports:
- "8088:80"
volumes:
- ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
volumes:
mes_hdd_db:
```
nginx proxifie `/api/*` vers `api:8000`, sert `/` depuis les fichiers statiques.
---
## Lancement du script client
```bash
# Télécharger et exécuter (depuis Gitea ou autre dépôt)
curl -fsSL http://<gitea>/mes_hdd/raw/main/inventaire.py | python3
# Ou localement
python3 inventaire.py
```
Aucun argument requis. L'URL de l'API est configurable via variable d'environnement :
```bash
MES_HDD_API=http://10.0.0.50:8088 python3 inventaire.py
```
---
## Ce qui est hors scope (étape 2 ou plus tard)
- Authentification sur l'API
- Alertes automatiques (email, Telegram) en cas de SMART dégradé
- Scraping périodique automatique (cron distant)
- Interface mobile