docs(merlin): transfers viz spec + routines note

Merlin Studio v2: in-progress + completed transfer journal (duration, throughput, retry failed) alongside the routines tab.
This commit is contained in:
L'électron rare
2026-06-21 08:48:59 +02:00
parent 3c6302bf0e
commit baea6ccdd1
2 changed files with 127 additions and 0 deletions
@@ -3,6 +3,9 @@
- **Date** : 2026-06-21
- **Repo** : `electron-rare/lisael-box` (firmware) + Tower `lisael-content` + Merlin Studio (`merlin.saillant.cc`)
- **Statut** : design validé, prêt pour le plan d'implémentation
- **Livraison** : 2 lots — **Firmware Mode Routine** + chantier **Merlin Studio v2** (onglet
Routines ici, et la [visu des transferts](2026-06-21-merlin-transfers-viz-design.md) livrée
dans le même chantier web/Tower).
## 1. But
@@ -0,0 +1,124 @@
# Merlin Studio — Visualisation des transferts (design)
- **Date** : 2026-06-21
- **Composants** : Tower `lisael-content` (`lisael_content.py`, `lisael_server.py`) + UI web `merlin_ui.html`
- **Statut** : design validé (variante « plus complète »), prêt pour le plan
- **Lié à** : [2026-06-21-lisael-box-routines-design.md](2026-06-21-lisael-box-routines-design.md) —
livré dans le même chantier **Merlin Studio v2** (onglet Routines + onglet Transferts).
## 1. But
Sur `merlin.saillant.cc`, voir **les transferts en cours et ceux effectués** : quand
Tower pousse du contenu vers la box (au boot/`register` ou via « Pousser maintenant »),
on veut un retour visuel — quel fichier part maintenant, combien restent, et l'historique
des push passés (réussis/échoués) avec **durée** et **débit moyen**, plus un bouton pour
**relancer les transferts échoués**.
## 2. Contexte (état actuel)
- Le push tourne dans un thread de fond : `try_push(ip, delta)` → `push_batch` → `push_file`
(POST `urllib` bloquant vers `http://<box>:8080/put?name=podcasts/<f>`, timeout 300 s).
- Aucun journal n'est exposé : l'onglet **Box** montre l'état (`box-state.json`) et le *delta*
restant, mais pas le déroulé des transferts ni l'historique.
- Contrainte : `push_file` est un POST bloquant → **pas de % intra-fichier** réaliste. On
expose donc le **statut par fichier** + l'**avancement du lot** (X/N).
## 3. Décisions (verrouillées)
| Sujet | Décision |
|---|---|
| Contenu | Transferts **en cours** + **effectués** |
| Granularité | **Par fichier**, groupés par **lot de push** (un `register` ou un `/api/push`) |
| Métriques | taille, **durée** par fichier, **débit moyen** (taille/durée), statut, horodatage |
| Échecs | raison courte + **bouton « relancer les échecs »** (re-push des fichiers `failed`) |
| Historique | **persistant**, ~200 derniers fichiers (`transfers.json`, écriture atomique) |
| Emplacement | **onglet dédié « 📤 Transferts »** dans Merlin |
| Rafraîchissement | **auto-refresh ~2 s** tant qu'un transfert est actif, sinon au clic |
| Pas de | % intra-fichier (limite POST bloquant) |
## 4. Architecture
```
push_batch/push_file ──maj──▶ journal (mémoire: lot actif) + transfers.json (historique)
(lisael_content.py) │
GET /api/transfers ──▶ onglet "Transferts"
POST /api/transfers/retry (auto-refresh 2s)
(lisael_server.py) (merlin_ui.html)
```
## 5. Modèle de données
Un **enregistrement de transfert** (par fichier) :
```json
{ "name":"matin_xx_a48.mp3", "dir":"routines", "size":428111,
"status":"done", "started":"2026-06-21T09:12:03", "ended":"2026-06-21T09:12:07",
"duration_s":4.1, "rate_bps":104417, "error":null, "box_ip":"192.168.0.250" }
```
- `status` ∈ `pending | sending | done | failed`.
- **Lot actif** (en mémoire, un seul à la fois — `try_push` est déjà sérialisé par IP via `_busy`) :
```json
{ "box_ip":"192.168.0.250", "started":"...", "total":15, "current":3,
"files":[ {name,dir,size,status,...}, ... ] }
```
- **Historique** : `transfers.json` = liste des enregistrements `done|failed` terminés,
tronquée aux ~200 derniers, écrite via `_atomic_write_json` (pattern existant).
## 6. Tower — instrumentation (`lisael_content.py`)
- Un module d'état `transfers` : `transfers_begin(box_ip, files)` (initialise le lot actif
avec tous les fichiers en `pending` + leur taille via `os.path.getsize`),
`transfers_mark(name, status, error=None)` (passe `sending` puis `done`/`failed`,
calcule `duration_s` et `rate_bps` à la fin), `transfers_end()` (vide le lot actif,
*flush* les enregistrements terminés dans `transfers.json` borné à 200),
`transfers_active()` / `transfers_history(limit)` (lecture pour l'API).
- `push_batch` appelle `transfers_begin` au début, `transfers_mark(name,"sending")` avant
chaque `push_file`, `transfers_mark(name,"done"/"failed",err)` après, `transfers_end` à la fin.
- `push_file` mesure le temps (monotone) et lève/retourne l'info de succès pour le mark.
- **Retry** : `retry_failed(box_ip)` = collecter les `failed` du dernier lot (ou de l'historique
récent pour cette box) → `try_push(ip, names)`.
## 7. API (`lisael_server.py`)
- `GET /api/transfers` → `{ "active": <lot actif|null>, "history": [<~200 derniers>] }`
(sous la Basic Auth existante).
- `POST /api/transfers/retry` → relance les fichiers `failed` connus pour la box
enregistrée (`box-state.json` → ip), `202` + `{started, count}` ; `409` si pas d'IP /
rien à relancer.
## 8. UI (`merlin_ui.html`) — onglet « 📤 Transferts »
- 4ᵉ onglet (bouton + panneau + entrée dans `TABS` + branche `showTab`), suivant le style
exact des onglets existants (helper `api()`, rendu de liste, bannière d'erreur).
- **En cours** : si `active` → carte « Vers <box_ip> — fichier <current>/<total> » + barre de
progression (X/N) + nom du fichier `sending` ; sinon « Aucun transfert en cours ».
- **Effectués** : table (fichier · dossier · taille · durée · débit · statut ✓/✗ · heure),
ligne `failed` en rouge avec la raison ; bouton **« 🔁 Relancer les échecs »**
(`POST /api/transfers/retry`) actif s'il existe ≥1 `failed`.
- **Auto-refresh** : `setInterval` ~2 s qui appelle `loadTransfers()` tant que `active != null` ;
arrêt quand plus de lot actif (et un dernier refresh pour figer l'historique).
## 9. Gestion d'erreurs
- `transfers.json` absent/corrompu → historique vide, on repart proprement (try/except).
- Restart du service en plein push → le lot actif (mémoire) est perdu ; l'historique persistant
garde les fichiers déjà terminés ; le prochain `register` de la box re-déclenche le delta.
- Retry sans box enregistrée → `409` + message UI clair.
- Double-push évité : `try_push` garde déjà l'anti-concurrence par IP (`_busy`).
## 10. Tests
- **Hôte** (Python, pattern `test_delta.py`, asserts purs) : `transfers_begin/mark/end`
(statuts, calcul `duration_s`/`rate_bps`, troncature à 200), `retry_failed` (sélection des
`failed`). Pas de réseau réel : injecter une fonction de push factice / utiliser un fichier
`transfers.json` temporaire.
## 11. Critères d'acceptation
1. Lancer un push (bouton « Pousser maintenant » ou boot box) → l'onglet Transferts montre le
lot **en cours** (box, X/N, fichier courant) qui progresse, puis bascule en **historique**.
2. L'historique affiche fichier/taille/**durée**/**débit**/statut/heure et **survit à un
restart** du service `lisael-content`.
3. Un fichier en échec apparaît en rouge avec sa raison ; **« Relancer les échecs »** le
re-pousse et il repasse ✓.
4. L'onglet s'**auto-rafraîchit** pendant un transfert et se calme une fois fini.
5. Test hôte vert (journal + retry).