# Design — IP réservées (hors DHCP) par subnet

**Date :** 2026-07-14
**Statut :** validé (brainstorming)

## Objectif

Permettre à l'admin de déclarer, sur chaque subnet, une liste d'IP/plages **réservées
hors DHCP** (IP fixes configurées manuellement sur des équipements). Cette liste :

1. est **purement informative / applicative** (stockée en `app_*`, jamais poussée à Kea) ;
2. **apparaît sur le bargraph** comme un 4e type de bloc (« Réservé hors DHCP ») — donc une
   IP fixe prise n'est plus affichée comme un trou libre ;
3. **bloque la création d'une réservation DHCP** dont l'IP tomberait dans la liste
   (comme le pool dynamique aujourd'hui).

## Décisions de design (validées)

1. **Stockage** : texte brut multiligne (nouvelle colonne `reserved_ips TEXT` sur
   `app_subnets`, à côté de `notes`), **validé avant enregistrement**.
2. **Format d'une ligne** : `<ip-ou-range>[#description]`. Le `#` (collé ou précédé
   d'espaces) introduit une description libre optionnelle. Formes de plage identiques au
   filtre `parseRange` de `subnet.php` (formes raccourcies `30-40`, `2.50-3.10`, complétées
   par le préfixe réseau du subnet). Lignes vides et commentaires purs (`# …`) ignorés.
3. **Blocage** : dur, comme `checkPoolConflict` du pool dynamique. Erreur de validation à
   la création/édition d'une réservation + feedback AJAX rouge au blur du champ IP.
4. **Bargraph** : 4e type de bloc `reserved`, couleur gris ardoise (`#64748b`), infobulle
   « Réservé (hors DHCP) » + plage + nb + masque + description. Clic inerte (comme les
   pools). Compté comme **occupé** dans le taux (union d'intervalles déjà en place).
5. **Validation de la liste** (avant enregistrement du subnet) : rejette format invalide,
   hors-CIDR, chevauchement avec pool dynamique **ou** short-lease. Messages par ligne.
6. **Conflit avec réservations DHCP existantes** : **bloque** l'enregistrement de la liste
   si une réservation DHCP existante tombe dans une plage réservée (liste les hostnames/IP).
7. **UI erreurs** : gouttière à gauche du textarea avec un `!` rouge par ligne fautive
   (overlay, ne modifie pas le texte) ; **détail au survol du `!`** (tooltip décalé
   bas-droite pour laisser la ligne visible) ; bord du champ rouge si ≥1 erreur ; statut
   résumé sous le champ. Fonctionne avec textarea redimensionnable + scrollable (la
   gouttière se translate avec `scrollTop`).

## Modèle de données

```sql
-- sql/add_reserved_ips.sql (migration v0.15 → v0.16)
ALTER TABLE app_subnets ADD COLUMN reserved_ips TEXT NULL AFTER notes;
```

Exemple de contenu :
```
192.168.2.5 # Imprimante compta
192.168.2.20-192.168.2.30 # Switchs
30-40 # Caméras (préfixe réseau du subnet complété)
2.50-3.10 # Plage inter-octets
192.168.2.99#Sans espace avant le dièse
```

## Architecture — logique métier

Nouvelle classe **pure** `lib/ReservedIps.php` (pas d'accès DB, testable isolément) :

```php
// Parse le texte brut → entrées normalisées. Réutilise la règle de forme raccourcie
// (octets de fin complétés par le préfixe réseau). IP seule → startInt==endInt.
ReservedIps::parse(string $raw, int $networkInt): array
// → [ ['startInt'=>int,'endInt'=>int,'desc'=>string,'line'=>int,'raw'=>string], ... ]

// Valide ligne par ligne : format, dans le CIDR, pas de chevauchement pool dyn/short.
// Retourne un tableau d'erreurs [ ['line'=>int,'message'=>string], ... ] (vide = OK).
ReservedIps::validate(string $raw, array $subnet): array

// L'entrée réservée contenant $ip (avec desc), ou null. Pour le blocage de réservation.
ReservedIps::contains(string $raw, int $networkInt, string $ip): ?array

// Plages fusionnées {startInt,endInt,desc} pour le bargraph (type 'reserved').
// Fusionne les recouvrements ; concatène les descriptions distinctes.
ReservedIps::toBlocks(string $raw, int $networkInt): array
```

`parse`/`validate` renvoient les numéros de ligne (1-indexés) pour la gouttière UI.

### Règle de parsing d'une ligne

1. Retirer la description : tout à partir du 1er `#` (`preg_replace('/#.*$/', ...)`), la
   description = ce qui suit le `#`, trimé.
2. Trimer la partie plage. Vide → ligne ignorée (retourne null pour cette ligne).
3. Matcher `IP` ou `IP-IP` avec la même regex que `parseRange`
   (`\d{1,3}(?:\.\d{1,3}){0,3}`), compléter les octets manquants à gauche avec le préfixe
   réseau, valider octets 0–255. Range inversé → trié.

### Intégration dans les validations existantes

1. **`Subnet::validate()`** :
   - appelle `ReservedIps::validate()`, fusionne les erreurs (préfixées « Ligne N : ») ;
   - **vérif croisée DB** (touche la DB, donc ici et non dans la classe pure) : charge les
     réservations existantes du subnet, si une tombe dans une plage réservée → erreur
     bloquante listant hostname + IP.
2. **`Reservation::validate()`** : nouvelle `checkReservedConflict($keaSubnetId, $ip)`
   (sœur de `checkPoolConflict`), appelée quand une IP est fournie. Erreur :
   « L'IP x.x.x.x est réservée (hors DHCP) sur ce subnet : <desc>. »
3. **`api_check_duplicate.php`** (`type=ip`) : ajoute la vérif réservée pour le feedback
   temps réel, comme le pool dynamique.

### Synchronisation JS ↔ PHP

`parseRange` (JS dans `subnet.php`) et la règle de parsing de `ReservedIps` (PHP) sont deux
implémentations de la **même règle de forme raccourcie**. Le bargraph JS a aussi sa propre
passe de parsing du `reserved_ips`. Ces trois points doivent rester synchrones ; documenté
dans le code (`[[bargraph_component]]` mémoire) et CLAUDE.md.

## UI — formulaire subnet

`subnet_add.php` / `subnet_edit.php` : nouveau champ après les pools.

- **Textarea monospace redimensionnable** (~6 lignes), pré-rempli en édition avec le brut.
- **Gouttière** à gauche (colonne ~30px) : un `!` rouge par ligne fautive, aligné, se
  translate avec `scrollTop` du textarea, resurvit au resize (hauteur de ligne fixe).
- **Tooltip au survol du `!`** : motif de l'erreur, positionné en bas-droite du curseur.
- **Bord rouge** du champ si ≥1 erreur ; **statut** sous le champ (`✓ Liste valide` /
  `✗ N ligne(s) en erreur`).
- Validation live côté client (réutilise la logique de parsing) ; la **validation serveur
  au submit reste la source de vérité** (rejette et réaffiche le champ conservé).
- `Subnet::add()`/`update()` gèrent `reserved_ips` (trim, `?: null`), comme `notes`.

## Bargraph — nouveau type `reserved`

- `subnet.php` injecte `reservedIps` (texte brut) dans l'objet `SUBNET`.
- `subnet-bargraph.js` : nouvelle fonction interne parse le brut (même règle raccourcie) →
  blocs `reserved` fusionnés, injectés dans `computeBlocks` **avant** `computeHoles` (donc
  jamais un trou). Chaque bloc porte `desc`.
- **Couleur** : `--sbg-reserved: #64748b` (gris ardoise), + variante dark. Trous inchangés.
- **Priorité de recouvrement** : une réservation DHCP réelle l'emporte visuellement (cas
  legacy). Pools et réservées ne se chevauchent jamais (validation).
- **Taux d'occupation** : les réservées comptent comme occupées (union d'intervalles déjà
  implémentée dans `occupancy`).
- **Infobulle** : titre « Réservé (hors DHCP) », plage, nb d'adresses, masque, description.
- **Clic** : inerte (infobulle seule), comme les pools dynamique/short-lease.
- Légende de la démo mise à jour avec la 4e entrée.

## Cas limites

| Cas | Comportement |
|-----|--------------|
| Ligne vide / commentaire pur (`# …`) | Ignorée |
| Format invalide | Erreur bloquante ligne N |
| Hors CIDR | Erreur bloquante ligne N |
| Chevauche pool dynamique ou short-lease | Erreur bloquante ligne N |
| Recouvre une réservation DHCP existante | Bloque l'enregistrement du subnet (liste les résa) |
| Deux lignes réservées se chevauchent | Toléré, fusionnées (descriptions concaténées) |
| Range inversé (`.80-.30`) | Normalisé (lo/hi triés) |
| Nouvelle réservation DHCP dans une IP réservée | Bloquée (`Reservation::validate` + AJAX) |
| Clic bargraph sur bloc réservé | Infobulle seule |

## Tests

- **`public/assets/js/reserved-ips.test.html`** — harnais navigateur autonome (pattern de
  `subnet-bargraph.test.html`) pour la couche pure : `parse`, `validate`, `contains`,
  `toBlocks` (formes raccourcies, hors-CIDR, chevauchement pool, descriptions, lignes
  vides/commentaires, range inversé, fusion). Note : `ReservedIps` étant en PHP, le harnais
  JS teste l'équivalent JS utilisé par le bargraph ; la classe PHP est vérifiée par un
  script `php -r` autonome documenté dans le plan.
- **`subnet-bargraph.test.html`** — cas ajoutés : type `reserved`, fusion, taux (réservées
  = occupées, pas de double comptage avec union).
- Vérification manuelle : gouttière + tooltip du formulaire, blocage de réservation
  (submit + AJAX), rendu de la barre + infobulle.

## Fichiers touchés

| Fichier | Action |
|---------|--------|
| `sql/add_reserved_ips.sql` | **nouveau** — migration colonne `reserved_ips` |
| `lib/ReservedIps.php` | **nouveau** — parse/validate/contains/toBlocks (pur) |
| `lib/Subnet.php` | `add`/`update` gèrent `reserved_ips` ; `validate` appelle `ReservedIps::validate` + vérif croisée réservations existantes |
| `lib/Reservation.php` | `checkReservedConflict` + appel dans `validate` |
| `public/api_check_duplicate.php` | vérif réservée pour le feedback AJAX (type=ip) |
| `public/subnet_add.php`, `public/subnet_edit.php` | champ textarea + gouttière + tooltip + validation live |
| `public/subnet.php` | injecte `reservedIps` dans `SUBNET` |
| `public/assets/js/subnet-bargraph.js` | type `reserved` (parse, bloc, couleur, infobulle) |
| `public/assets/js/subnet-bargraph-demo.html` | données + légende avec le type réservé |
| `public/assets/js/subnet-bargraph.test.html` | tests type `reserved` |
| `public/assets/js/reserved-ips.test.html` | **nouveau** — tests parsing/validation JS |
| `public/assets/css/app.css` | styles gouttière `.rip-*` + `--sbg-reserved` (clair/sombre) |
| `CLAUDE.md` | doc (colonne, classe, format de ligne, sync JS/PHP) |

## Limites assumées

- Les IP réservées ne sont **jamais** poussées à Kea (informatives + garde-fou applicatif
  uniquement). Un équipement en IP fixe hors DHCP n'est pas géré par Kea de toute façon.
- Le parsing raccourci est dupliqué (JS filtre + JS bargraph + PHP `ReservedIps`) : même
  règle, trois sites, à garder synchrones (documenté).
- Pas de dédup/tri automatique du texte saisi : on garde le brut tel quel (seule la
  validation refuse l'incohérent).
