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:
@@ -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
@@ -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 d’un 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 l’OS invité
|
||||||
|
|
||||||
|
Le but est de **collecter un snapshot à l’instant 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 l’OS
|
||||||
|
2. identifie chaque disque dans le système
|
||||||
|
3. lit l’état SMART quand c’est possible
|
||||||
|
4. récupère la taille du disque
|
||||||
|
5. récupère les partitions et le taux d’utilisation des points de montage
|
||||||
|
6. publie un **message MQTT retenu** (`retain`) sur le broker de Gilles
|
||||||
|
7. s’arrête après une exécution unique
|
||||||
|
|
||||||
|
Il ne faut **pas** prévoir de cron ni d’agent 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 d’utilisation
|
||||||
|
- é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 l’identification 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/...`
|
||||||
|
|
||||||
|
L’objectif est de relier :
|
||||||
|
- le nom logique dans l’OS
|
||||||
|
- le modèle du disque
|
||||||
|
- le numéro de série
|
||||||
|
- les partitions
|
||||||
|
- le point de montage
|
||||||
|
- l’usage réel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exigences sur les partitions et l’espace
|
||||||
|
Pour chaque disque, si des partitions ou des montages existent, récupérer :
|
||||||
|
- la liste des partitions
|
||||||
|
- le point de montage
|
||||||
|
- l’espace libre
|
||||||
|
- l’espace total
|
||||||
|
- le taux d’utilisation
|
||||||
|
|
||||||
|
Si un disque n’a pas de filesystem monté, le script doit l’indiquer clairement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Robustesse
|
||||||
|
Le script doit rester robuste si :
|
||||||
|
- un disque ne répond pas
|
||||||
|
- `smartctl` échoue
|
||||||
|
- un disque n’est pas monté
|
||||||
|
- SMART n’est pas accessible depuis l’environnement
|
||||||
|
- le système est une VM et ne voit pas tous les disques physiques sous-jacents
|
||||||
|
|
||||||
|
Le script ne doit pas casser l’inventaire global si un disque est problématique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mode d’exé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 n’est 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 s’appuyer 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 d’agent permanent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attendu de Claude Code
|
||||||
|
Claude Code doit :
|
||||||
|
1. proposer l’architecture 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
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 10–15 machines Proxmox, Debian et Ubuntu. Certaines machines sont des VMs avec disques en PCI passthrough (4–5 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
|
||||||
Reference in New Issue
Block a user