Compare commits

...

22 Commits

Author SHA1 Message Date
gilles faa654c95a feat(ui): config machine (sonde+proxy), mode Listing, défaut apt-cacher-ng
- popup Profil sur la tuile : sonde machine → propositions os_family/
  machine_kind/virtualization avec Appliquer ; proxy APT (mode + url) +
  appliquer persistant
- mode d'affichage Tuiles/Liste : toggle + bouton Ajouter déplacés dans le
  header de page ; vue Liste = liste compacte + panneau détail « Machine view »
  (sections Docker/Post-install dépliées ; pliées en mode tuile)
- Popup rendu via portail document.body (position fixed, z-index 1000) :
  passe au premier plan, échappe au backdrop-filter des tuiles
- Paramètres : onglet Proxy APT (défaut apt-cacher-ng + appliquer à toutes
  les machines) ; AddMachineModal pré-remplit le proxy par défaut
- api client : settings, updateMachine, probe ; icônes network/grid/list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:53:57 +02:00
gilles 2b684da9cd feat(api): profil machine éditable, sonde, et réglages globaux apt-cacher-ng
- machines : updateMachine (PATCH /machines/:id) + POST /machines/:id/probe
  (sonde synchrone → faits + proposition de correction) ; MachineView expose
  machineKind/virtualization ; CreateMachineInput accepte aptProxyMode persistent
- app_settings (clé/valeur, migration 0006) + service appSettings :
  défaut apt-cacher-ng (mode + url) ; applyProxyToAllMachines
- routes /settings : GET, PUT /apt-proxy, POST /apt-proxy/apply-all

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:53:47 +02:00
gilles 0ab6b1d392 3 2026-06-06 07:24:30 +02:00
gilles bafb085995 feat(os): profils Proxmox/RPi + machine_probe + proxy persistent (tâche 2 SJ-7)
- templates proxmox/ (update-analyze: dépôts PVE ; full-upgrade) et raspbian/
  (update-analyze: espace disque ; full-upgrade)
- execute résout les actions APT par profil OS (resolveTemplate) → proxmox/
  raspbian si dispo, sinon fallback apt/ (non-régression debian/ubuntu vérifiée)
- machine_probe (lecture seule) : template + parseProbe/proposeCorrections (TDD)
  → propose os_family/machine_kind/virtualization, persiste machine_hardware,
  n'applique jamais auto ; branche execute + allowlist route
- apt_proxy_persistent : ActionType + template idempotent (/etc/apt/apt.conf.d/
  01proxy, backup) + TemplateVars.aptProxyUrl + allowlist route

tsc 0 · 95 tests · build OK · résolution OS vérifiée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:14:43 +02:00
gilles b5ec14dcd8 chore: charge .env via --env-file dans dev:server et start (Node 22+)
L'app lit process.env sans dotenv ; les scripts npm ne fournissaient pas
SU_MASTER_KEY. Ajoute --env-file=.env pour que pnpm dev / pnpm start
fonctionnent directement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:09:07 +02:00
gilles c79c3e5ccb feat(ui): section Docker interactive sur la tuile machine (tâche 3)
Branche le frontend sur le backend Docker (SJ-4/5/6) :
- scan, configuration des racines Compose, liste stacks + services avec
  badges de statut (candidat/activé/maj dispo/à jour)
- activer/ignorer/désactiver un stack ; pull-check (non destructif)
- apply/down/prune via action_request + confirmation Popup (design system)
- toute action streamée auto-sélectionne la machine → flux visible dans le
  terminal de droite (outputHub rejoue le buffer)
- api client : docker settings/roots/scan/stacks/status + action-requests
- icônes trash/check, styles docker-* (variables CSS uniquement)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 07:09:07 +02:00
gilles 2c15b8c06b feat(docker): routes de gestion des stacks (settings/roots/scan/list/enable)
Rend le flux Docker déclenchable via l'API (prérequis SJ-5/SJ-6) :
- GET  /machines/:id/docker/settings — settings + racines Compose
- POST /machines/:id/docker/roots    — déclare/active les racines à scanner
- POST /machines/:id/docker/scan     — scan passif (background, WS)
- GET  /machines/:id/docker/stacks   — liste stacks + services
- PATCH /machines/:id/docker/stacks/:stackId — cycle candidate→enabled→ignored

dockerScan: getDockerSettings, listStacks, setStackStatus. Les actions
pull-check/apply/down restent réservées aux stacks enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 06:24:43 +02:00
gilles 47fe952240 feat(settings): backup/restore de la base de données (amelioration #4)
- service dbBackup : createBackup (VACUUM INTO → archive .db cohérente),
  validateSqlite (header + integrity_check + schéma), prepareRestore
  (sauvegarde de sécurité auto + dépôt <db>.incoming)
- swap hors-ligne au démarrage (db/client.ts) : aucune corruption d'une base
  ouverte ; restauration appliquée au redémarrage
- routes GET /system/db/info|backup, POST /system/db/restore
- lib api : dbInfo / dbBackup (download navigateur) / dbRestore (upload)
- SettingsModal : onglet « Base de données » (taille, télécharger, restaurer
  avec confirmation Popup), icônes database/upload, styles DS variables only

Testé end-to-end : backup 184 Ko valide, restore + safety .bak + swap au boot,
fichier invalide rejeté. tsc 0 erreur · 91 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 06:13:03 +02:00
gilles edb22a59c7 feat(docker): apply/prune/down + socle action_requests (tâche 2 SJ-6)
- migration 0005 : tables docker_image_events + action_requests
- templates apply-compose (up -d --remove-orphans), prune-images (safe/agressif),
  down-compose (sans volumes/rmi)
- dockerApply: parsers TDD (apply recreated/running/exited, prune images+bytes,
  down removed, parseHumanBytes) + orchestration applyStack/pruneImages/downStack
  réservée aux stacks enabled, insère docker_image_events
- actionRequests: create/approve/reject/list — actions destructives validées
  explicitement (Hermes propose, opérateur approuve, run en arrière-plan) ;
  hors API directe (POST /:id/actions reste passif uniquement)
- routes /machines/:id/action-requests + /action-requests/:id[/approve|/reject]
- execute: RunActionOpts.aggressive, branches apply/prune/down, helper
  archiveExecution mutualisant le boilerplate d'archivage

tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 06:05:59 +02:00
gilles b1c81ba518 feat(docker): pull-check + comparaison déterministe par stack (tâche 2 SJ-5)
- template docker/pull-check.sh.tpl (pull sans up, inspect before/after)
- dockerPull: parseDockerPullCheck + buildDockerPullResult (TDD) — compare
  image id/digest/label OCI → services up_to_date|updates_available|error,
  changes operation=pulled ; erreurs registry nettoyées (URL/token/password)
- dockerDedupKey (digests prioritaires, fallback image ids) + DockerImageChange.dedupKey
- pullCheckStack: SSH + upsert docker_stack_services, refuse stack non enabled,
  refresh Docker séparé (hors refreshMachine, pas de pull auto)
- execute: runAction(opts.stackId), branche docker_pull_check, injection stackDir
  (corrige docker_inspect_current) ; route: allowlist Docker passifs + pull_check,
  destructives toujours hors API jusqu'à action_requests (SJ-6)

Pas de migration (schéma SJ-4 suffisant). tsc 0 erreur · 85 tests · build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:02:38 +02:00
gilles 2af8e74079 feat(docker): scan/inspect passifs des stacks Compose (tâche 2 SJ-4)
- 4 tables Docker (settings/compose_roots/compose_stacks/stack_services)
  + migration 0004 (timestamps journal monotones)
- templates docker/scan-compose + inspect-compose ; renderTemplate bascule
  sur délimiteurs <% %> pour les templates docker/ afin de préserver les
  Go-templates {{.ID}} intacts
- dockerScan: parseDockerScan (TDD) + scanDockerStacks (persiste stacks
  candidats, complète la détection par labels)
- action docker_scan branchée dans execute (route dédiée, archivage report/log)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:54:52 +02:00
gilles 434a149f1f fix(execute): refresh snapshot après apt upgrade/full-upgrade (amelioration #3)
Après une action APT appliquée avec succès, relance refreshMachine pour
que la webui reflète l'état réel des paquets. Échec de refresh = event
warning non bloquant (post_action_refresh_failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:54:38 +02:00
gilles 08919752e3 feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK).
- tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/
  hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002.
- tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif,
  dual-read + backfill). Migration 0003. Fix séquence journal de migration.
- tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate),
  SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH),
  SJ-3 (reboot vérifié boot_id).
- WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics,
  scaffold app_rust, ajustements frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00
gilles 0fbca06d3d docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation
Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates
validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2
et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 , 2-8 🟡).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:50:25 +02:00
gilles f9ce991ec5 feat(ui): classes layout header/statusbar/inputs/terminal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:27:04 +02:00
gilles cebe991601 feat(ui): helper sumUpdates (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:26:31 +02:00
gilles b9699bfb8f feat(ui): helper de thème dark/light persisté (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:26:00 +02:00
gilles d3bf4a9fd2 feat(ui): brancher le design system (exports ESM, Font Awesome, polices offline)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:25:21 +02:00
gilles f8a8478749 docs: consignes tâche 2 (design moteur templates) + gate de validation
tache2.md: mission design/investigation, périmètre strict, clôture obligatoire.
validation_tache2.md: grille de validation, gate avant toute phase de dev.
amelioration.md: retour d'usage (séparation terminal entre machines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:23:35 +02:00
gilles 1310bc1637 docs: plan d'implémentation jalon 2 (polish design system)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:09:14 +02:00
gilles 8d105b63ec docs: spec jalon 2 - séparation terminal par machine + remontée d'état
Suite au test live: retour d'usage (amelioration.md) sur la séparation
des sorties entre machines distinctes dans le terminal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:05:27 +02:00
gilles 50df83fda1 docs: spec jalon 2 (polish design system)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 04:52:50 +02:00
159 changed files with 31767 additions and 142 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Clé maître de chiffrement des credentials (32 octets en hex = 64 caractères).
# Générer avec: openssl rand -hex 32
SU_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
SU_MASTER_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# Chemin du fichier SQLite
SU_DB_PATH=./data/system-update.db
# Répertoire d'archivage des rapports + logs
+3
View File
@@ -8,3 +8,6 @@ reports/*
# Dépôts de référence (git imbriqués) — inspiration uniquement, gérés séparément
linux-update-dashboard/
nas-ops/
# Clé de session dev (jamais commitée)
.dev-session-key.txt
+7
View File
@@ -0,0 +1,7 @@
- dans l onglet terminal, il n y a pas de separation franche entre 2 machines distincte ou totalement separe?
- dans le champ host on peut mettre ip ou nostname .local ou .home ?
- apres un apt upgrade, ne met pas a jours les paquet dans la webui
- dans parametre ajouter d'un bacup et restore de la bdd
- le bouton ajouter sera deplacer dans le header
- ajout de bouton dans le header (toggle entre mode tuilenet mode listing)
- dans le header ajouter bouton pour un mode update all qui permet d executer update sur chacune des machine ( mettre en la machine qui est en cours d 'update via un style --shadow-press)
+1
View File
@@ -0,0 +1 @@
target/
+728
View File
@@ -0,0 +1,728 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
[[package]]
name = "cairo-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags",
"cairo-sys-rs",
"glib",
"libc",
]
[[package]]
name = "cairo-sys-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "cfg-expr"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "gdk-pixbuf"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk4"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk4-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk4-sys"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]]
name = "gio"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"pin-project-lite",
"smallvec",
]
[[package]]
name = "gio-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys",
]
[[package]]
name = "glib"
version = "0.22.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a"
dependencies = [
"bitflags",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "glib-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "gobject-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "graphene-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1"
dependencies = [
"glib",
"graphene-sys",
"libc",
]
[[package]]
name = "graphene-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c"
dependencies = [
"glib-sys",
"libc",
"pkg-config",
"system-deps",
]
[[package]]
name = "gsk4"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62"
dependencies = [
"cairo-rs",
"gdk4",
"glib",
"graphene-rs",
"gsk4-sys",
"libc",
"pango",
]
[[package]]
name = "gsk4-sys"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a"
dependencies = [
"cairo-sys-rs",
"gdk4-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk4"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2"
dependencies = [
"cairo-rs",
"field-offset",
"futures-channel",
"gdk-pixbuf",
"gdk4",
"gio",
"glib",
"graphene-rs",
"gsk4",
"gtk4-macros",
"gtk4-sys",
"libc",
"pango",
]
[[package]]
name = "gtk4-macros"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gtk4-sys"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"gsk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libadwaita"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4"
dependencies = [
"gdk4",
"gio",
"glib",
"gtk4",
"libadwaita-sys",
"libc",
"pango",
]
[[package]]
name = "libadwaita-sys"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d"
dependencies = [
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gtk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "pango"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438"
dependencies = [
"gio",
"glib",
"libc",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"version-compare",
]
[[package]]
name = "system-update-gnome"
version = "0.1.0"
dependencies = [
"gtk4",
"libadwaita",
"serde",
"serde_json",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "system-update-gnome"
version = "0.1.0"
edition = "2024"
description = "Client local Rust/GNOME pour le backend system_update"
license = "UNLICENSED"
[dependencies]
adw = { package = "libadwaita", version = "0.9", features = ["v1_6"], optional = true }
gtk = { package = "gtk4", version = "0.11", features = ["v4_6"], optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[features]
default = []
gui = ["dep:adw", "dep:gtk"]
+56
View File
@@ -0,0 +1,56 @@
# system-update-gnome
Scaffold de l'application locale Rust/GNOME pour `system_update`.
Ce sous-dossier est volontairement dédié au développement de l'application Rust :
- backend/webapp : racine du projet, `server/`, `client/`, `shared/` ;
- application Rust/GNOME : `app_rust/system-update-gnome/` ;
- artefacts Cargo : `app_rust/system-update-gnome/target/`, ignoré par Git.
État actuel :
- client CLI minimal ;
- aucune dépendance externe pour garder le premier build vérifiable sans accès réseau ;
- test de connexion HTTP vers `GET /api/capabilities` ;
- stratégie token séparée dans `src/token_store.rs` ;
- première UI GTK/libadwaita derrière la feature Cargo `gui`.
Exemples :
```bash
cargo run -- --server http://127.0.0.1:8787 capabilities
cargo run -- --server http://127.0.0.1:8787 status
cargo run -- --server http://127.0.0.1:8787 metrics
cargo run -- --server http://127.0.0.1:8787 machines
SYSTEM_UPDATE_SERVER=http://127.0.0.1:8787 cargo run -- capabilities
```
Interface graphique :
```bash
cargo run --features gui -- gui
```
Avec un serveur précis :
```bash
cargo run --features gui -- --server http://10.0.1.137:8787 gui
```
Pré-requis pour le futur incrément GTK/libadwaita :
```bash
sudo apt install libgtk-4-dev libadwaita-1-dev
```
Règles :
- l'application ne fait pas de SSH direct ;
- les credentials machines restent côté backend ;
- les actions passent par l'API du serveur ;
- le token local sera stocké via trousseau système dans un incrément suivant.
Voir aussi :
- `docs/token-storage.md`.
@@ -0,0 +1,33 @@
# Stockage du token API
Objectif : l'application locale ne doit pas stocker le token API en clair dans un fichier de configuration.
État actuel du scaffold :
- `--token` : accepté pour test manuel ponctuel ;
- `SYSTEM_UPDATE_TOKEN` : accepté pour développement ;
- trousseau système : prévu, pas encore activé dans `Cargo.toml`.
Identité prévue dans le trousseau :
- service : `system-update` ;
- compte : `api-token`.
Choix technique à finaliser :
- la documentation actuelle de `keyring` indique que Linux dispose de plusieurs magasins possibles, dont keyutils et Secret Service ;
- pour une app GNOME, Secret Service est le choix naturel ;
- l'écosystème `keyring` 4.x sépare davantage les composants de librairie, donc l'ajout Cargo doit être fait après choix précis entre `keyring-core` + store Secret Service ou une version `keyring` 3.x encore centrée librairie.
Références :
- https://docs.rs/keyring/latest/keyring/
- https://docs.rs/crate/keyring/latest
- https://github.com/open-source-cooperative/keyring-rs/wiki/Keyring-Core
Règle de sécurité :
- token jamais loggé ;
- token jamais écrit dans les rapports ;
- token affiché uniquement sous forme de préfixe côté backend ;
- révocation possible côté backend via `api_clients.revoked_at`.
+157
View File
@@ -0,0 +1,157 @@
use std::io::{Read, Write};
use std::net::TcpStream;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpUrl {
pub host: String,
pub port: u16,
pub base_path: String,
}
impl HttpUrl {
pub fn parse(server_url: &str) -> Result<Self, String> {
let without_scheme = server_url
.strip_prefix("http://")
.ok_or_else(|| "ce premier client supporte seulement http://".to_string())?;
let (host_port, base_path) = match without_scheme.split_once('/') {
Some((host_port, path)) => (host_port, format!("/{path}")),
None => (without_scheme, String::new()),
};
let (host, port) = match host_port.rsplit_once(':') {
Some((host, port)) => {
let parsed_port = port
.parse::<u16>()
.map_err(|_| "port serveur invalide".to_string())?;
(host.to_string(), parsed_port)
}
None => (host_port.to_string(), 80),
};
if host.is_empty() {
return Err("hôte serveur manquant".to_string());
}
Ok(Self {
host,
port,
base_path,
})
}
pub fn path(&self, endpoint: &str) -> String {
let base = self.base_path.trim_end_matches('/');
let endpoint = endpoint.trim_start_matches('/');
if base.is_empty() {
format!("/{endpoint}")
} else {
format!("{base}/{endpoint}")
}
}
}
pub struct ApiClient {
server: HttpUrl,
token: Option<String>,
}
impl ApiClient {
pub fn new(server_url: &str, token: Option<String>) -> Result<Self, String> {
Ok(Self {
server: HttpUrl::parse(server_url)?,
token,
})
}
pub fn get_capabilities(&self) -> Result<String, String> {
self.get("/api/capabilities")
}
pub fn get_system_status(&self) -> Result<String, String> {
self.get("/api/system/status")
}
pub fn get_system_metrics(&self) -> Result<String, String> {
self.get("/api/system/metrics")
}
pub fn get_machines(&self) -> Result<String, String> {
self.get("/api/machines")
}
fn get(&self, endpoint: &str) -> Result<String, String> {
let path = self.server.path(endpoint);
let mut request = format!(
"GET {path} HTTP/1.1\r\nHost: {}\r\nUser-Agent: system-update-gnome/0.1\r\nAccept: application/json\r\nConnection: close\r\n",
self.server.host
);
if let Some(token) = &self.token {
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
}
request.push_str("\r\n");
let mut stream = TcpStream::connect((&*self.server.host, self.server.port))
.map_err(|err| format!("connexion serveur échouée: {err}"))?;
stream
.write_all(request.as_bytes())
.map_err(|err| format!("envoi requête échoué: {err}"))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("lecture réponse échouée: {err}"))?;
split_http_response(&response)
}
}
fn split_http_response(response: &str) -> Result<String, String> {
let (headers, body) = response
.split_once("\r\n\r\n")
.ok_or_else(|| "réponse HTTP invalide".to_string())?;
let status_line = headers.lines().next().unwrap_or_default();
if !status_line.contains(" 200 ") {
return Err(format!("réponse serveur inattendue: {status_line}"));
}
Ok(body.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_http_port() {
let url = HttpUrl::parse("http://localhost").expect("url");
assert_eq!(url.host, "localhost");
assert_eq!(url.port, 80);
assert_eq!(url.path("/api/capabilities"), "/api/capabilities");
}
#[test]
fn parses_explicit_http_port_and_base_path() {
let url = HttpUrl::parse("http://10.0.0.80:8787/system-update").expect("url");
assert_eq!(url.host, "10.0.0.80");
assert_eq!(url.port, 8787);
assert_eq!(
url.path("/api/capabilities"),
"/system-update/api/capabilities"
);
}
#[test]
fn rejects_https_until_tls_client_is_added() {
assert!(HttpUrl::parse("https://10.0.0.80:8787").is_err());
}
#[test]
fn extracts_success_body() {
let body = split_http_response(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}",
)
.expect("body");
assert_eq!(body, "{\"ok\":true}");
}
}
+122
View File
@@ -0,0 +1,122 @@
use crate::token_store::TokenSource;
use std::env;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppConfig {
pub server_url: String,
pub token: Option<String>,
}
impl AppConfig {
pub fn from_args(args: &[String]) -> Result<(Self, Command), String> {
let mut server_url = env::var("SYSTEM_UPDATE_SERVER")
.unwrap_or_else(|_| "http://127.0.0.1:8787".to_string());
let mut token = TokenSource::from_env().load();
let mut command = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--server" => {
i += 1;
server_url = args
.get(i)
.ok_or_else(|| "--server attend une URL".to_string())?
.clone();
}
"--token" => {
i += 1;
let raw_token = args
.get(i)
.ok_or_else(|| "--token attend une valeur".to_string())?
.clone();
token = TokenSource::CliArgument(raw_token).load();
}
"capabilities" => command = Some(Command::Capabilities),
"status" => command = Some(Command::Status),
"metrics" => command = Some(Command::Metrics),
"machines" => command = Some(Command::Machines),
"gui" => command = Some(Command::Gui),
"help" | "--help" | "-h" => command = Some(Command::Help),
other => return Err(format!("argument inconnu: {other}")),
}
i += 1;
}
validate_server_url(&server_url)?;
Ok((Self { server_url, token }, command.unwrap_or(Command::Help)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Capabilities,
Status,
Metrics,
Machines,
Gui,
Help,
}
pub fn validate_server_url(url: &str) -> Result<(), String> {
if url.starts_with("http://") || url.starts_with("https://") {
Ok(())
} else {
Err("l'URL serveur doit commencer par http:// ou https://".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_http_server_url() {
assert!(validate_server_url("http://127.0.0.1:8787").is_ok());
}
#[test]
fn rejects_missing_scheme() {
assert!(validate_server_url("127.0.0.1:8787").is_err());
}
#[test]
fn parses_capabilities_command() {
let args = vec![
"system-update-gnome".to_string(),
"--server".to_string(),
"http://10.0.0.80:8787".to_string(),
"capabilities".to_string(),
];
let (config, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(config.server_url, "http://10.0.0.80:8787");
assert_eq!(command, Command::Capabilities);
}
#[test]
fn parses_status_command() {
let args = vec!["system-update-gnome".to_string(), "status".to_string()];
let (_, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(command, Command::Status);
}
#[test]
fn parses_gui_command() {
let args = vec!["system-update-gnome".to_string(), "gui".to_string()];
let (_, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(command, Command::Gui);
}
#[test]
fn parses_machines_command() {
let args = vec!["system-update-gnome".to_string(), "machines".to_string()];
let (_, command) = AppConfig::from_args(&args).expect("config");
assert_eq!(command, Command::Machines);
}
}
+517
View File
@@ -0,0 +1,517 @@
use crate::api::ApiClient;
use crate::config::AppConfig;
use adw::prelude::*;
use serde::Deserialize;
const APP_CSS: &str = r#"
window { background: #28201b; color: #ead9b8; }
.su-header { background: #241b17; border-bottom: 1px solid #4a3a2f; }
.su-root { background: #28201b; }
.su-sidebar {
background: #30261f;
border-right: 1px solid #5a4738;
padding: 12px;
}
.su-terminal-pane {
background: #181b1d;
border-left: 1px solid #4a3a2f;
}
.su-terminal-head {
background: #241b17;
color: #bdae93;
border-bottom: 1px solid #3a2c24;
padding: 8px 10px;
font-family: monospace;
font-size: 11px;
}
.su-terminal-output {
background: #181b1d;
color: #f6e3b4;
padding: 8px;
font-family: monospace;
font-size: 11px;
}
.su-center { padding: 16px; }
.su-title { font-size: 20px; font-weight: 700; color: #f1d8aa; }
.su-muted { color: #bdae93; font-size: 12px; }
.su-label {
color: #bdae93;
font-family: monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
}
.su-card {
background: #30261f;
border: 1px solid #6a5544;
border-radius: 8px;
padding: 14px;
box-shadow: 0 6px 14px rgba(0,0,0,.22);
}
.su-dot-ok { background: #6ad13f; border-radius: 999px; min-width: 10px; min-height: 10px; }
.su-dot-unknown { background: #928374; border-radius: 999px; min-width: 10px; min-height: 10px; }
.su-machine-name { font-weight: 700; font-size: 15px; color: #f1e0bc; }
.su-mono { font-family: monospace; color: #bdae93; font-size: 12px; }
.su-taskbar {
background: #241b17;
border-top: 1px solid #5a4738;
min-height: 28px;
}
.su-task-cell {
padding: 5px 12px;
border-right: 1px solid #4a3a2f;
color: #bdae93;
font-family: monospace;
font-size: 11px;
}
.su-task-mode { background: #d79921; color: #28201b; font-weight: 700; }
"#;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Machine {
name: String,
hostname: String,
port: u16,
os_family: String,
status: String,
}
pub fn run(config: AppConfig) {
let app = adw::Application::builder()
.application_id("local.system-update.gnome")
.build();
app.connect_activate(move |app| {
build_window(app, config.clone());
});
app.run_with_args::<&str>(&[]);
}
fn build_window(app: &adw::Application, config: AppConfig) {
install_css();
let server_entry = gtk::Entry::builder()
.text(&config.server_url)
.hexpand(true)
.placeholder_text("http://10.0.1.137:8787")
.build();
let terminal = gtk::TextView::builder()
.editable(false)
.monospace(true)
.vexpand(true)
.hexpand(true)
.css_classes(["su-terminal-output"])
.build();
terminal.buffer().set_text(
"Terminal API prêt.\nLes retours capabilities/status/metrics/machines apparaissent ici.",
);
let machines_flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.column_spacing(12)
.row_spacing(12)
.max_children_per_line(3)
.min_children_per_line(1)
.homogeneous(false)
.build();
let task_status = gtk::Label::new(Some("server 10.0.1.137:8787"));
task_status.add_css_class("su-task-cell");
let task_metrics = gtk::Label::new(Some("metrics --"));
task_metrics.add_css_class("su-task-cell");
let add_button = gtk::Button::with_label("+ Ajouter");
let refresh_button = gtk::Button::with_label("Refresh");
let capabilities = gtk::Button::with_label("Capabilities");
let status = gtk::Button::with_label("Status");
let metrics = gtk::Button::with_label("Metrics");
let header = adw::HeaderBar::builder()
.title_widget(&gtk::Label::new(Some("System Update")))
.css_classes(["su-header"])
.build();
let left = build_hermes_panel();
let center = build_center_panel(&machines_flow, &refresh_button, &add_button);
let right = build_terminal_panel(&terminal, &server_entry, &capabilities, &status, &metrics);
let taskbar = build_taskbar(&task_status, &task_metrics);
let body = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.hexpand(true)
.vexpand(true)
.build();
body.append(&left);
body.append(&center);
body.append(&right);
let root = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.css_classes(["su-root"])
.build();
root.append(&header);
root.append(&body);
root.append(&taskbar);
let window = adw::ApplicationWindow::builder()
.application(app)
.title("System Update")
.default_width(1480)
.default_height(760)
.content(&root)
.build();
wire_machine_refresh(
&refresh_button,
&server_entry,
config.token.clone(),
&terminal,
&machines_flow,
&task_status,
);
connect_action(
&capabilities,
&server_entry,
config.token.clone(),
&terminal,
&task_status,
Action::Capabilities,
);
connect_action(
&status,
&server_entry,
config.token.clone(),
&terminal,
&task_status,
Action::Status,
);
connect_action(
&metrics,
&server_entry,
config.token,
&terminal,
&task_status,
Action::Metrics,
);
load_machines(&server_entry, None, &terminal, &machines_flow, &task_status);
window.present();
}
fn install_css() {
let provider = gtk::CssProvider::new();
provider.load_from_data(APP_CSS);
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn build_hermes_panel() -> gtk::Box {
let panel = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.width_request(225)
.css_classes(["su-sidebar"])
.spacing(12)
.build();
let title = gtk::Label::new(Some("HERMES"));
title.set_xalign(0.0);
title.add_css_class("su-label");
let text = gtk::Label::new(Some(
"Copilote d'exploitation -- à venir.\nAnalyse des mises à jour, plans et rapports seront disponibles ici.",
));
text.set_wrap(true);
text.set_xalign(0.0);
text.add_css_class("su-muted");
panel.append(&title);
panel.append(&text);
panel
}
fn build_center_panel(
machines_flow: &gtk::FlowBox,
refresh_button: &gtk::Button,
add_button: &gtk::Button,
) -> gtk::Box {
let center = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.hexpand(true)
.vexpand(true)
.spacing(14)
.css_classes(["su-center"])
.build();
let title = gtk::Label::new(Some("Machines"));
title.set_xalign(0.0);
title.add_css_class("su-title");
let tools = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.build();
tools.append(&title);
tools.append(&gtk::Box::builder().hexpand(true).build());
tools.append(refresh_button);
tools.append(add_button);
let scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(true)
.child(machines_flow)
.build();
center.append(&tools);
center.append(&scroll);
center
}
fn build_terminal_panel(
terminal: &gtk::TextView,
server_entry: &gtk::Entry,
capabilities: &gtk::Button,
status: &gtk::Button,
metrics: &gtk::Button,
) -> gtk::Box {
let right = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.width_request(365)
.css_classes(["su-terminal-pane"])
.build();
let head = gtk::Label::new(Some("TERMINAL API"));
head.set_xalign(0.0);
head.add_css_class("su-terminal-head");
let controls = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
controls.append(server_entry);
let buttons = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
buttons.append(capabilities);
buttons.append(status);
buttons.append(metrics);
controls.append(&buttons);
let scroll = gtk::ScrolledWindow::builder()
.hexpand(true)
.vexpand(true)
.child(terminal)
.build();
right.append(&head);
right.append(&controls);
right.append(&scroll);
right
}
fn build_taskbar(task_status: &gtk::Label, task_metrics: &gtk::Label) -> gtk::Box {
let taskbar = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.css_classes(["su-taskbar"])
.build();
let mode = gtk::Label::new(Some(" SYSTEM UPDATE "));
mode.add_css_class("su-task-cell");
mode.add_css_class("su-task-mode");
let scope = gtk::Label::new(Some("machines"));
scope.add_css_class("su-task-cell");
let spacer = gtk::Box::builder().hexpand(true).build();
taskbar.append(&mode);
taskbar.append(&scope);
taskbar.append(task_status);
taskbar.append(&spacer);
taskbar.append(task_metrics);
taskbar
}
fn wire_machine_refresh(
button: &gtk::Button,
server_entry: &gtk::Entry,
token: Option<String>,
terminal: &gtk::TextView,
machines_flow: &gtk::FlowBox,
task_status: &gtk::Label,
) {
let server_entry = server_entry.clone();
let terminal = terminal.clone();
let machines_flow = machines_flow.clone();
let task_status = task_status.clone();
button.connect_clicked(move |_| {
load_machines(
&server_entry,
token.clone(),
&terminal,
&machines_flow,
&task_status,
);
});
}
fn load_machines(
server_entry: &gtk::Entry,
token: Option<String>,
terminal: &gtk::TextView,
machines_flow: &gtk::FlowBox,
task_status: &gtk::Label,
) {
let server_url = server_entry.text().to_string();
let raw = ApiClient::new(&server_url, token).and_then(|client| client.get_machines());
while let Some(child) = machines_flow.first_child() {
machines_flow.remove(&child);
}
match raw {
Ok(json) => {
terminal.buffer().set_text(&json);
match serde_json::from_str::<Vec<Machine>>(&json) {
Ok(machines) => {
for machine in &machines {
machines_flow.insert(&machine_card(machine), -1);
}
task_status.set_text(&format!("{server_url} · {} machines", machines.len()));
}
Err(err) => {
task_status.set_text("machines: JSON invalide");
machines_flow
.insert(&empty_card(&format!("JSON machines invalide: {err}")), -1);
}
}
}
Err(err) => {
terminal.buffer().set_text(&format!("Erreur: {err}"));
task_status.set_text("server erreur");
machines_flow.insert(&empty_card(&err), -1);
}
}
}
fn machine_card(machine: &Machine) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.width_request(230)
.css_classes(["su-card"])
.build();
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.build();
let dot = gtk::Box::builder()
.width_request(10)
.height_request(10)
.css_classes([if machine.status == "unknown" {
"su-dot-unknown"
} else {
"su-dot-ok"
}])
.build();
let name = gtk::Label::new(Some(&machine.name));
name.set_xalign(0.0);
name.add_css_class("su-machine-name");
row.append(&dot);
row.append(&name);
let host = gtk::Label::new(Some(&format!(
"{}:{} · {}",
machine.hostname, machine.port, machine.os_family
)));
host.set_xalign(0.0);
host.add_css_class("su-mono");
let updates = gtk::Label::new(Some("UPDATES 0"));
updates.set_xalign(0.0);
updates.add_css_class("su-label");
let buttons = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
buttons.append(&gtk::Button::with_label("Refresh"));
buttons.append(&gtk::Button::with_label("Upgrade"));
buttons.append(&gtk::Button::with_label("Reboot"));
card.append(&row);
card.append(&host);
card.append(&updates);
card.append(&buttons);
card
}
fn empty_card(message: &str) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.width_request(300)
.css_classes(["su-card"])
.build();
let title = gtk::Label::new(Some("Aucune machine"));
title.set_xalign(0.0);
title.add_css_class("su-machine-name");
let text = gtk::Label::new(Some(message));
text.set_wrap(true);
text.set_xalign(0.0);
text.add_css_class("su-muted");
card.append(&title);
card.append(&text);
card
}
#[derive(Clone, Copy)]
enum Action {
Capabilities,
Status,
Metrics,
}
fn connect_action(
button: &gtk::Button,
server_entry: &gtk::Entry,
token: Option<String>,
terminal: &gtk::TextView,
task_status: &gtk::Label,
action: Action,
) {
let server_entry = server_entry.clone();
let terminal = terminal.clone();
let task_status = task_status.clone();
button.connect_clicked(move |_| {
let server_url = server_entry.text().to_string();
let body = ApiClient::new(&server_url, token.clone()).and_then(|client| match action {
Action::Capabilities => client.get_capabilities(),
Action::Status => client.get_system_status(),
Action::Metrics => client.get_system_metrics(),
});
match body {
Ok(json) => {
terminal.buffer().set_text(&json);
task_status.set_text(&format!("{server_url} · ok"));
}
Err(err) => {
terminal.buffer().set_text(&format!("Erreur: {err}"));
task_status.set_text("server erreur");
}
}
});
}
+89
View File
@@ -0,0 +1,89 @@
mod api;
mod config;
#[cfg(feature = "gui")]
mod gui;
mod token_store;
use api::ApiClient;
use config::{AppConfig, Command};
use std::env;
use std::process::ExitCode;
use token_store::keyring_identity;
fn main() -> ExitCode {
let args: Vec<String> = env::args().collect();
match run(&args) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("erreur: {err}");
ExitCode::FAILURE
}
}
}
fn run(args: &[String]) -> Result<(), String> {
let (config, command) = AppConfig::from_args(args)?;
match command {
Command::Capabilities => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_capabilities()?;
println!("{body}");
}
Command::Status => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_system_status()?;
println!("{body}");
}
Command::Metrics => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_system_metrics()?;
println!("{body}");
}
Command::Machines => {
let client = ApiClient::new(&config.server_url, config.token)?;
let body = client.get_machines()?;
println!("{body}");
}
Command::Gui => run_gui(config)?,
Command::Help => print_help(),
}
Ok(())
}
#[cfg(feature = "gui")]
fn run_gui(config: AppConfig) -> Result<(), String> {
gui::run(config);
Ok(())
}
#[cfg(not(feature = "gui"))]
fn run_gui(_config: AppConfig) -> Result<(), String> {
Err(
"l'interface graphique n'est pas compilée. Lance: cargo run --features gui -- gui"
.to_string(),
)
}
fn print_help() {
let (keyring_service, keyring_account) = keyring_identity();
println!(
"system-update-gnome\n\
\n\
Usage:\n\
system-update-gnome --server http://127.0.0.1:8787 capabilities\n\
system-update-gnome --server http://127.0.0.1:8787 status\n\
system-update-gnome --server http://127.0.0.1:8787 metrics\n\
system-update-gnome --server http://127.0.0.1:8787 machines\n\
system-update-gnome --server http://127.0.0.1:8787 gui\n\
\n\
Variables:\n\
SYSTEM_UPDATE_SERVER URL du backend, défaut http://127.0.0.1:8787\n\
SYSTEM_UPDATE_TOKEN Token API optionnel\n\
\n\
Futur trousseau:\n\
service: {keyring_service}\n\
compte: {keyring_account}\n"
);
}
@@ -0,0 +1,59 @@
use std::env;
pub const KEYRING_SERVICE: &str = "system-update";
pub const KEYRING_ACCOUNT: &str = "api-token";
pub fn keyring_identity() -> (&'static str, &'static str) {
(KEYRING_SERVICE, KEYRING_ACCOUNT)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenSource {
CliArgument(String),
Environment(Option<String>),
}
impl TokenSource {
pub fn from_env() -> Self {
Self::Environment(env::var("SYSTEM_UPDATE_TOKEN").ok())
}
pub fn load(self) -> Option<String> {
match self {
Self::CliArgument(token) => clean_token(token),
Self::Environment(token) => token.and_then(clean_token),
}
}
}
fn clean_token(token: String) -> Option<String> {
let trimmed = token.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trims_cli_token() {
assert_eq!(
TokenSource::CliArgument(" su_token ".to_string()).load(),
Some("su_token".to_string())
);
}
#[test]
fn ignores_empty_token() {
assert_eq!(TokenSource::CliArgument(" ".to_string()).load(), None);
}
#[test]
fn documents_future_keyring_identity() {
assert_eq!(keyring_identity(), ("system-update", "api-token"));
}
}
+108 -5
View File
@@ -1,16 +1,119 @@
// client/src/App.tsx
import { useState } from "react";
import { useEffect, useState } from "react";
import type { SystemMetrics } from "@shared/types.js";
import { api } from "./lib/api.js";
import type { DashboardSummary, ViewMode } from "./panels/Dashboard.js";
import { HermesPanel } from "./panels/HermesPanel.js";
import { Dashboard } from "./panels/Dashboard.js";
import { TerminalPanel } from "./panels/TerminalPanel.js";
import { SettingsModal } from "./panels/SettingsModal.js";
import { Icon } from "./components/ui-kit.js";
import { applyTheme, getInitialTheme, nextTheme, type Theme } from "./lib/theme.js";
const EMPTY_SUMMARY: DashboardSummary = { machines: 0, updates: 0, errors: 0, running: 0 };
export function App() {
const [selected, setSelected] = useState<string | null>(null);
const [summary, setSummary] = useState<DashboardSummary>(EMPTY_SUMMARY);
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
const [theme, setTheme] = useState<Theme>(() => getInitialTheme());
const [settingsOpen, setSettingsOpen] = useState(false);
const [adding, setAdding] = useState(false);
const [view, setView] = useState<ViewMode>(
() => (localStorage.getItem("su-view") as ViewMode) ?? "grid",
);
function changeView(mode: ViewMode) {
setView(mode);
localStorage.setItem("su-view", mode);
}
useEffect(() => {
applyTheme(theme);
}, [theme]);
useEffect(() => {
let cancelled = false;
async function loadMetrics() {
try {
const next = await api.systemMetrics();
if (!cancelled) setMetrics(next);
} catch {
if (!cancelled) setMetrics(null);
}
}
void loadMetrics();
const timer = window.setInterval(loadMetrics, 10_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
return (
<div className="su-layout">
<HermesPanel />
<Dashboard onSelect={setSelected} />
<TerminalPanel machineId={selected} />
<div className="su-app">
<header className="su-header">
<div className="su-brand">
<span className="su-brand-mark">SU</span>
<div>
<h1>System Update</h1>
<span className="mono">dashboard SSH agentless</span>
</div>
</div>
<div className="su-header-summary">
<span>{summary.machines} machines</span>
<span>{summary.updates} updates</span>
<span>{summary.running} jobs</span>
<span>{summary.errors} erreurs</span>
</div>
<div className="su-spacer" />
<div className="su-viewtoggle" role="group" aria-label="Mode d'affichage">
<button className={`interactive su-viewtoggle-btn ${view === "grid" ? "active" : ""}`} onClick={() => changeView("grid")}>
<Icon name="grid" size={13} style={undefined} /> Tuiles
</button>
<button className={`interactive su-viewtoggle-btn ${view === "list" ? "active" : ""}`} onClick={() => changeView("list")}>
<Icon name="list" size={13} style={undefined} /> Liste
</button>
</div>
<button className="interactive su-header-button" onClick={() => setAdding(true)}>+ Ajouter</button>
<button className="interactive su-header-button" onClick={() => setTheme(nextTheme(theme))}>
{theme === "dark" ? "Light" : "Dark"}
</button>
<button className="interactive su-header-button" onClick={() => setSettingsOpen(true)}>
Paramètres
</button>
</header>
<div className="su-row">
<HermesPanel />
<Dashboard
selectedId={selected}
onSelect={setSelected}
onSummaryChange={setSummary}
view={view}
adding={adding}
onAddingChange={setAdding}
/>
<TerminalPanel machineId={selected} />
</div>
<footer className="su-statusbar">
<span className="cell mode">SYSTEM UPDATE</span>
<span className="cell">machines {summary.machines}</span>
<span className="cell">apt {summary.updates}</span>
<span className="cell">jobs {summary.running}</span>
<span className="cell">ram {formatMb(metrics?.process.rssMb)}</span>
<span className="cell">heap {formatMb(metrics?.process.heapUsedMb)}</span>
<span className="cell">load {formatLoad(metrics?.host.loadAverage1m)}</span>
<span className="cell">terminal {selected ?? "none"}</span>
<span className="cell clock">{new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
</footer>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div>
);
}
function formatMb(value: number | undefined): string {
return typeof value === "number" ? `${Math.round(value)}M` : "--";
}
function formatLoad(value: number | undefined): string {
return typeof value === "number" ? value.toFixed(2) : "--";
}
+27 -3
View File
@@ -1,5 +1,6 @@
// @ts-nocheck
import React from "react";
import { createPortal } from "react-dom";
/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
@@ -45,7 +46,22 @@ const ICON_MAP = {
plus: 'plus',
filter: 'filter',
download: 'download',
upload: 'upload',
database: 'database',
trash: 'trash',
check: 'check',
folder: 'folder',
docker: 'boxes-stacked',
package: 'box-open',
script: 'file-code',
shield: 'shield-halved',
key: 'key',
locked: 'lock',
logs: 'file-lines',
report: 'clipboard-list',
copy: 'copy',
collapse: 'down-left-and-up-right-to-center',
upgrade: 'cloud-arrow-down',
node: 'circle-nodes',
user: 'user',
};
@@ -418,9 +434,11 @@ function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
// Portail vers <body> : échappe aux contextes d'empilement des tuiles (backdrop-filter
// glass piège même position:fixed) pour rester au premier plan global.
return createPortal((
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -457,7 +475,7 @@ function Popup({ open, onClose, title, children, footer, width = 460 }) {
)}
</div>
</div>
);
), document.body);
}
/* ============================================================
@@ -656,3 +674,9 @@ Object.assign(window, {
`;
document.head.appendChild(s);
})();
export {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
};
@@ -1,5 +1,7 @@
// client/src/features/machines/AddMachineModal.tsx
import { useState } from "react";
import { useEffect, useState } from "react";
import type { DefaultAptProxy } from "../../lib/api.js";
import { api } from "../../lib/api.js";
interface Props { onClose: () => void; onCreated: () => void; }
@@ -7,16 +9,31 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [proxyDefault, setProxyDefault] = useState<DefaultAptProxy | null>(null);
const [useProxy, setUseProxy] = useState(false);
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
useEffect(() => {
void (async () => {
try {
const s = await api.getSettings();
if (s.defaultAptProxy.url) {
setProxyDefault(s.defaultAptProxy);
setUseProxy(true);
}
} catch {
/* pas de défaut configuré */
}
})();
}, []);
async function submit() {
setBusy(true); setError(null);
try {
const res = await fetch("/api/machines", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
const proxy = useProxy && proxyDefault?.url
? { aptProxyMode: proxyDefault.mode === "direct" ? "runtime" : proxyDefault.mode, aptProxyUrl: proxyDefault.url }
: {};
await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null, ...proxy });
onCreated(); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
@@ -31,6 +48,12 @@ export function AddMachineModal({ onClose, onCreated }: Props) {
<input placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
<input placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
<input placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
{proxyDefault?.url && (
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--ink-2)" }}>
<input type="checkbox" checked={useProxy} onChange={(e) => setUseProxy(e.target.checked)} />
<span>Proxy APT par défaut <span className="mono">{proxyDefault.url}</span></span>
</label>
)}
{error && <div style={{ color: "var(--err)", fontSize: 12 }}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose}>Annuler</button>
+751 -18
View File
@@ -1,5 +1,8 @@
// client/src/features/machines/MachineTile.tsx
import type { MachineView } from "@shared/types.js";
import { useEffect, useState } from "react";
import type { ActionType, AptProxyMode, MachineStatus, MachineView } from "@shared/types.js";
import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js";
import { api, type DockerSettingsView, type DockerStackRow, type ProbeResultView, type StackStatus } from "../../lib/api.js";
interface Props {
machine: MachineView;
@@ -8,32 +11,762 @@ interface Props {
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
onChanged?: () => void;
}
const STATUS_COLOR: Record<string, string> = {
ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)",
running: "var(--info)", unknown: "var(--ink-4)",
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok",
updates_available: "warn",
error: "err",
running: "info",
unknown: "off",
};
export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
const STATUS_TEXT: Record<MachineStatus, string> = {
ok: "OK",
updates_available: "Updates",
error: "Erreur",
running: "Action en cours",
unknown: "Inconnu",
};
export function MachineTile({
machine,
packageCount,
onSelect,
onRefresh,
onUpgrade,
onReboot,
onChanged,
}: Props) {
const [dockerOpen, setDockerOpen] = useState(false);
const [postOpen, setPostOpen] = useState(false);
const [configOpen, setConfigOpen] = useState(false);
const expanded = dockerOpen || postOpen;
const isError = machine.status === "error" || machine.status === "unknown";
return (
<div className="glass" style={{ padding: 16, borderRadius: 10 }} onClick={() => onSelect(machine.id)}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 10, height: 10, borderRadius: 999, background: STATUS_COLOR[machine.status] }} />
<strong>{machine.name}</strong>
<article
className={`machine-tile glass ${expanded ? "machine-tile-expanded" : ""}`}
onClick={() => onSelect(machine.id)}
>
<header className="machine-tile-head">
<div className="machine-title-row">
<StatusLed status={STATUS_LED[machine.status]} size={10} pulse={machine.status === "running"} />
<div className="machine-title-text">
<strong>{machine.name}</strong>
<span className="mono">{machine.hostname}:{machine.port} · {machine.osFamily}</span>
</div>
</div>
<span className={`machine-status-pill ${isError ? "machine-status-danger" : ""}`}>
{STATUS_TEXT[machine.status]}
</span>
</header>
<div className="machine-summary">
<Metric label="Updates" value={packageCount.toString()} tone={packageCount > 0 ? "warn" : "ok"} />
<Metric label="Reboot" value="-" />
<Metric label="Dernier check" value={formatDate(machine.lastCheckedAt)} />
</div>
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
{machine.hostname}:{machine.port} · {machine.osFamily}
{isError && (
<div className="machine-alert">
<Icon name="alert" size={14} style={undefined} />
<span>État machine à vérifier avant toute action sensible.</span>
</div>
)}
<div className="machine-actions" onClick={(event) => event.stopPropagation()}>
<IconButton
icon="refresh"
label="Update + analyse"
active={false}
danger={false}
primary={false}
onClick={() => onRefresh(machine.id)}
/>
<IconButton
icon="upgrade"
label="Upgrade système"
active={false}
danger={false}
primary={packageCount > 0}
onClick={() => onUpgrade(machine.id)}
/>
<IconButton
icon="power"
label="Reboot"
active={false}
danger
primary={false}
onClick={() => onReboot(machine.id)}
/>
<IconButton
icon="terminal"
label="Ouvrir les logs machine"
active={false}
danger={false}
primary={false}
onClick={() => onSelect(machine.id)}
/>
<IconButton
icon="cog"
label="Profil & proxy (sonde)"
active={false}
danger={false}
primary={false}
onClick={() => setConfigOpen(true)}
/>
</div>
<div style={{ margin: "10px 0", fontSize: 13 }}>
<span className="label">UPDATES</span>{" "}
<span className="mono">{packageCount}</span>
{configOpen && (
<div onClick={(event) => event.stopPropagation()}>
<MachineConfigPopup
machine={machine}
onClose={() => setConfigOpen(false)}
onSelect={onSelect}
onChanged={onChanged}
/>
</div>
)}
<div className="machine-sections" onClick={(event) => event.stopPropagation()}>
<SectionToggle
icon="docker"
title="Docker"
open={dockerOpen}
onToggle={() => setDockerOpen((value) => !value)}
/>
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
<SectionToggle
icon="script"
title="Post-install"
open={postOpen}
onToggle={() => setPostOpen((value) => !value)}
/>
{postOpen && <PostInstallSection />}
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
<button className="interactive" onClick={() => onRefresh(machine.id)}>Refresh</button>
<button className="interactive" onClick={() => onUpgrade(machine.id)}>Upgrade</button>
<button className="interactive" onClick={() => onReboot(machine.id)}>Reboot</button>
</article>
);
}
function Metric({ label, value, tone }: { label: string; value: string; tone?: "ok" | "warn" }) {
return (
<div className="machine-metric">
<span className="label">{label}</span>
<span className={`mono ${tone === "warn" ? "machine-metric-warn" : tone === "ok" ? "machine-metric-ok" : ""}`}>
{value}
</span>
</div>
);
}
function SectionToggle({
icon,
title,
open,
onToggle,
}: {
icon: string;
title: string;
open: boolean;
onToggle: () => void;
}) {
return (
<button className="machine-section-toggle interactive" onClick={onToggle}>
<span className="machine-section-title">
<Icon name={icon} size={14} style={undefined} />
<span>{title}</span>
</span>
<Icon name={open ? "chevD" : "chevR"} size={12} style={undefined} />
</button>
);
}
function MachineConfigPopup({
machine,
onClose,
onSelect,
onChanged,
}: {
machine: MachineView;
onClose: () => void;
onSelect: (id: string) => void;
onChanged?: () => void;
}) {
const [probe, setProbe] = useState<ProbeResultView | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [proxyMode, setProxyMode] = useState<AptProxyMode>(machine.aptProxyMode);
const [proxyUrl, setProxyUrl] = useState(machine.aptProxyUrl ?? "");
async function withBusy(key: string, fn: () => Promise<void>) {
setBusy(key);
setMsg(null);
try {
await fn();
} catch (err) {
setMsg({ kind: "err", text: (err as Error).message });
} finally {
setBusy(null);
}
}
const sonder = () =>
withBusy("probe", async () => {
onSelect(machine.id);
setProbe(await api.probe(machine.id));
});
const applyCorrection = () =>
withBusy("apply", async () => {
if (!probe) return;
await api.updateMachine(machine.id, {
osFamily: probe.proposal.osFamily,
machineKind: probe.proposal.machineKind,
virtualization: probe.proposal.virtualization,
});
onChanged?.();
setMsg({ kind: "ok", text: "Correction appliquée au profil." });
});
const saveProxy = () =>
withBusy("proxy", async () => {
await api.updateMachine(machine.id, { aptProxyMode: proxyMode, aptProxyUrl: proxyUrl.trim() || null });
onChanged?.();
setMsg({ kind: "ok", text: "Proxy enregistré." });
});
const applyPersistent = () =>
withBusy("proxyapply", async () => {
onSelect(machine.id);
await api.updateMachine(machine.id, { aptProxyMode: "persistent", aptProxyUrl: proxyUrl.trim() || null });
setProxyMode("persistent");
onChanged?.();
await api.runAction(machine.id, "apt_proxy_persistent");
setMsg({ kind: "ok", text: "Proxy persistant appliqué sur la machine (voir terminal de droite)." });
});
return (
<Popup
open
onClose={onClose}
title={`Profil — ${machine.name}`}
width={460}
footer={<Button icon="close" variant="ghost" onClick={onClose}>Fermer</Button>}
>
<div className="cfg">
<div className="cfg-current">
<span className="label">Profil actuel</span>
<span className="mono">
os={machine.osFamily} · kind={machine.machineKind ?? "?"} · virt={machine.virtualization ?? "?"}
</span>
</div>
<div className="cfg-block">
<div className="cfg-block-head">
<span className="label">Sonde machine (lecture seule)</span>
<Button icon="search" size="sm" variant="primary" onClick={busy ? undefined : sonder}>
{busy === "probe" ? "Sonde…" : "Sonder"}
</Button>
</div>
{probe && (
<div className="cfg-probe">
<div className="mono cfg-facts">
os={probe.probe.osId} {probe.probe.osVersion} · arch={probe.probe.arch} · virt={probe.probe.virt}
{probe.probe.isProxmox ? " · proxmox" : ""}
{probe.probe.isRpi ? " · rpi" : ""}
</div>
<div className="cfg-proposal mono">
proposition : os_family={probe.proposal.osFamily} · machine_kind={probe.proposal.machineKind} · virt=
{probe.proposal.virtualization}
</div>
{probe.changes.length ? (
<>
<ul className="cfg-changes">
{probe.changes.map((c, i) => (
<li key={i} className="mono">{c}</li>
))}
</ul>
<Button icon="check" size="sm" variant="primary" onClick={busy ? undefined : applyCorrection}>
{busy === "apply" ? "Application…" : "Appliquer la correction"}
</Button>
</>
) : (
<span className="cfg-nochange">Aucune correction : le profil correspond déjà.</span>
)}
</div>
)}
</div>
<div className="cfg-block">
<span className="label">Proxy APT</span>
<select className="su-field" value={proxyMode} onChange={(e) => setProxyMode(e.target.value as AptProxyMode)}>
<option value="direct">Direct (aucun proxy)</option>
<option value="runtime">Runtime (le temps d'une exécution)</option>
<option value="persistent">Persistant (/etc/apt/apt.conf.d/01proxy)</option>
</select>
<input
className="su-field"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
placeholder="http://10.0.3.100:3142"
/>
<div className="cfg-actions">
<Button icon="check" size="sm" onClick={busy ? undefined : saveProxy}>
{busy === "proxy" ? "…" : "Enregistrer"}
</Button>
<Button icon="upgrade" size="sm" variant="primary" onClick={busy ? undefined : applyPersistent}>
{busy === "proxyapply" ? "Application…" : "Appliquer persistant"}
</Button>
</div>
</div>
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
</div>
</Popup>
);
}
interface ConfirmState {
action: ActionType;
stackId?: string;
aggressive?: boolean;
label: string;
detail: string;
}
function DockerSection({ machineId, onSelect }: { machineId: string; onSelect: (id: string) => void }) {
const [settings, setSettings] = useState<DockerSettingsView | null>(null);
const [stacks, setStacks] = useState<DockerStackRow[] | null>(null);
const [rootsInput, setRootsInput] = useState("");
const [showRoots, setShowRoots] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
async function load() {
try {
const [s, st] = await Promise.all([api.dockerSettings(machineId), api.dockerStacks(machineId)]);
setSettings(s);
setStacks(st);
if (s.roots.length) setRootsInput(s.roots.map((r) => r.path).join("\n"));
} catch (err) {
setMsg({ kind: "err", text: (err as Error).message });
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [machineId]);
async function withBusy(key: string, fn: () => Promise<void>) {
setBusy(key);
setMsg(null);
try {
await fn();
} catch (err) {
setMsg({ kind: "err", text: (err as Error).message });
} finally {
setBusy(null);
}
}
const saveRoots = () =>
withBusy("roots", async () => {
const paths = rootsInput.split("\n").map((s) => s.trim()).filter(Boolean);
if (!paths.length) throw new Error("Indique au moins une racine Compose.");
await api.dockerSetRoots(machineId, paths);
await load();
setShowRoots(false);
setMsg({ kind: "ok", text: "Racines enregistrées." });
});
const scan = () =>
withBusy("scan", async () => {
onSelect(machineId); // bascule le terminal de droite sur cette machine
await api.dockerScan(machineId);
setMsg({ kind: "ok", text: "Scan lancé… (voir le terminal de droite)" });
window.setTimeout(() => void load(), 6000);
});
const setStatus = (stackId: string, status: StackStatus) =>
withBusy(`stack:${stackId}`, async () => {
await api.setStackStatus(machineId, stackId, status);
await load();
});
const pullCheck = (stackId: string) =>
withBusy(`pull:${stackId}`, async () => {
onSelect(machineId); // bascule le terminal de droite sur cette machine
await api.runAction(machineId, "docker_pull_check", stackId);
setMsg({ kind: "ok", text: "Pull-check lancé… (voir le terminal de droite)" });
window.setTimeout(() => void load(), 8000);
});
const runConfirmed = () =>
withBusy("confirm", async () => {
if (!confirm) return;
onSelect(machineId); // bascule le terminal de droite sur cette machine
const req = await api.createActionRequest(machineId, {
action: confirm.action,
stackId: confirm.stackId,
aggressive: confirm.aggressive,
});
await api.approveActionRequest(req.id);
setConfirm(null);
setMsg({ kind: "ok", text: `${confirm.label} : demande approuvée et lancée.` });
window.setTimeout(() => void load(), 8000);
});
const enabledExists = (stacks ?? []).some((s) => s.status === "enabled");
const lastScan = settings?.settings?.lastScanAt;
return (
<div className="machine-section-body">
<div className="docker-toolbar">
<Button icon="refresh" size="sm" onClick={busy ? undefined : scan}>
{busy === "scan" ? "Scan…" : "Scanner"}
</Button>
<Button icon="cog" size="sm" variant="ghost" onClick={() => setShowRoots((v) => !v)}>
Racines{settings?.roots.length ? ` (${settings.roots.length})` : ""}
</Button>
{enabledExists && (
<Button
icon="trash"
size="sm"
variant="danger"
onClick={() =>
setConfirm({
action: "docker_prune_images",
label: "Prune images",
detail: "Supprime les images Docker inutilisées (mode sûr : dangling uniquement) sur cette machine.",
})
}
>
Prune
</Button>
)}
{lastScan && <span className="docker-laststamp mono">scan {formatDate(lastScan)}</span>}
</div>
{showRoots && (
<div className="docker-roots">
<span className="label">Racines Compose (une par ligne)</span>
<textarea
className="su-field settings-textarea"
value={rootsInput}
onChange={(e) => setRootsInput(e.target.value)}
placeholder={"/home/gilles/docker\n/opt/stacks"}
/>
<Button icon="check" size="sm" variant="primary" onClick={busy ? undefined : saveRoots}>
{busy === "roots" ? "Enregistrement…" : "Enregistrer & activer"}
</Button>
</div>
)}
{stacks === null ? (
<div className="machine-placeholder">Chargement</div>
) : stacks.length === 0 ? (
<div className="machine-placeholder">
Aucun stack détecté. Déclare des racines Compose puis lance un scan.
</div>
) : (
<div className="docker-stacks">
{stacks.map((stack) => (
<DockerStackCard
key={stack.id}
stack={stack}
busy={busy}
onActivate={() => setStatus(stack.id, "enabled")}
onIgnore={() => setStatus(stack.id, "ignored")}
onDisable={() => setStatus(stack.id, "candidate")}
onPullCheck={() => pullCheck(stack.id)}
onApply={() =>
setConfirm({
action: "docker_compose_apply",
stackId: stack.id,
label: `Appliquer ${stack.name}`,
detail: `Recrée les conteneurs du stack « ${stack.name} » (docker compose up -d). Bref redémarrage du service.`,
})
}
onDown={() =>
setConfirm({
action: "docker_compose_down",
stackId: stack.id,
label: `Arrêter ${stack.name}`,
detail: `Arrête le stack « ${stack.name} » (docker compose down). Les volumes sont préservés.`,
})
}
/>
))}
</div>
)}
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
<Popup
open={confirm !== null}
onClose={() => setConfirm(null)}
title={confirm?.label ?? ""}
width={420}
footer={
<>
<Button variant="ghost" icon="close" onClick={() => setConfirm(null)}>Annuler</Button>
<Button variant="danger" icon="check" onClick={busy === "confirm" ? undefined : runConfirmed}>
{busy === "confirm" ? "Lancement…" : "Confirmer"}
</Button>
</>
}
>
<p>{confirm?.detail}</p>
<p className="docker-confirm-note">
<Icon name="shield" size={13} style={undefined} /> Action tracée comme demande validée (action_request).
</p>
</Popup>
</div>
);
}
function DockerStackCard({
stack,
busy,
onActivate,
onIgnore,
onDisable,
onPullCheck,
onApply,
onDown,
}: {
stack: DockerStackRow;
busy: string | null;
onActivate: () => void;
onIgnore: () => void;
onDisable: () => void;
onPullCheck: () => void;
onApply: () => void;
onDown: () => void;
}) {
const isEnabled = stack.status === "enabled";
const stackBusy = busy === `stack:${stack.id}`;
const pullBusy = busy === `pull:${stack.id}`;
return (
<div className="docker-stack">
<div className="docker-stack-head">
<span className="docker-stack-name">{stack.name}</span>
<DockerBadge status={stack.status} />
{stack.detectedBy && <span className="docker-stack-by mono">{stack.detectedBy}</span>}
</div>
<div className="docker-stack-actions">
{stack.status === "candidate" && (
<>
<Button icon="check" size="sm" variant="primary" onClick={stackBusy ? undefined : onActivate}>Activer</Button>
<Button icon="close" size="sm" variant="ghost" onClick={stackBusy ? undefined : onIgnore}>Ignorer</Button>
</>
)}
{isEnabled && (
<>
<Button icon="download" size="sm" onClick={pullBusy ? undefined : onPullCheck}>
{pullBusy ? "Pull…" : "Pull-check"}
</Button>
<Button icon="upgrade" size="sm" variant="primary" onClick={onApply}>Appliquer</Button>
<Button icon="power" size="sm" variant="danger" onClick={onDown}>Down</Button>
<Button icon="close" size="sm" variant="ghost" onClick={stackBusy ? undefined : onDisable}>Désactiver</Button>
</>
)}
</div>
{stack.services.length > 0 && (
<div className="docker-services">
{stack.services.map((svc) => (
<div key={svc.id} className="docker-service">
<span className="docker-service-name mono">{svc.imageRef ?? svc.serviceName}</span>
<DockerBadge status={svc.status ?? "unknown"} />
{svc.status === "updates_available" && (
<span className="docker-service-diff mono">
{shortId(svc.currentImageId)} {shortId(svc.candidateImageId)}
</span>
)}
</div>
))}
</div>
)}
</div>
);
}
function DockerBadge({ status }: { status: string }) {
const tone =
status === "updates_available" ? "warn" :
status === "up_to_date" || status === "enabled" ? "ok" :
status === "error" ? "err" :
status === "candidate" ? "info" : "off";
const label =
status === "updates_available" ? "maj dispo" :
status === "up_to_date" ? "à jour" :
status === "enabled" ? "activé" :
status === "candidate" ? "candidat" :
status === "ignored" ? "ignoré" :
status === "error" ? "erreur" : status;
return <span className={`docker-badge docker-badge-${tone}`}>{label}</span>;
}
function shortId(id: string | null): string {
if (!id) return "—";
const hex = id.startsWith("sha256:") ? id.slice(7) : id;
return hex.slice(0, 10);
}
function PostInstallSection() {
return (
<div className="machine-section-body">
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil network tools</span>
</label>
<label className="machine-check-row">
<input type="checkbox" />
<span>Profil partage Samba/NFS</span>
</label>
<div className="machine-placeholder">
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
</div>
</div>
);
}
function formatDate(value: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("fr-FR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
// --- Mode Listing : ligne compacte + panneau détail "Machine view" ---
export function MachineRow({
machine,
packageCount,
selected,
onClick,
}: {
machine: MachineView;
packageCount: number;
selected: boolean;
onClick: () => void;
}) {
return (
<button className={`machine-row interactive ${selected ? "active" : ""}`} onClick={onClick}>
<StatusLed status={STATUS_LED[machine.status]} size={9} pulse={machine.status === "running"} />
<span className="machine-row-name">{machine.name}</span>
<span className="machine-row-ip mono">{machine.hostname}:{machine.port}</span>
<span className="machine-row-os">
<Icon name="package" size={12} style={undefined} />
{machine.osFamily}
</span>
<span className="machine-row-cell">
<span className="label">updates</span>
<b className={packageCount > 0 ? "machine-metric-warn" : "machine-metric-ok"}>{packageCount}</b>
</span>
<span className="machine-row-cell">
<span className="label">check</span>
<span className="mono">{formatDate(machine.lastCheckedAt)}</span>
</span>
</button>
);
}
function InfoRow({ k, v, mono, tone }: { k: string; v: string; mono?: boolean; tone?: "ok" | "warn" }) {
return (
<div className="machine-info-row">
<span className="machine-info-k">{k}</span>
<span className={`machine-info-v ${mono ? "mono" : ""} ${tone === "warn" ? "machine-metric-warn" : tone === "ok" ? "machine-metric-ok" : ""}`}>
{v}
</span>
</div>
);
}
export function MachineDetailPanel({
machine,
packageCount,
onSelect,
onRefresh,
onUpgrade,
onReboot,
onChanged,
}: {
machine: MachineView;
packageCount: number;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
onChanged?: () => void;
}) {
// Mode liste : sections dépliées par défaut (inverse du mode tuile).
const [dockerOpen, setDockerOpen] = useState(true);
const [postOpen, setPostOpen] = useState(true);
const [configOpen, setConfigOpen] = useState(false);
const isError = machine.status === "error" || machine.status === "unknown";
return (
<section className="machine-detail glass">
<header className="machine-detail-head">
<div className="machine-title-row">
<StatusLed status={STATUS_LED[machine.status]} size={11} pulse={machine.status === "running"} />
<div className="machine-title-text">
<strong>{machine.name}</strong>
<span className="mono">{machine.hostname}:{machine.port} · {machine.osFamily}</span>
</div>
</div>
<span className={`machine-status-pill ${isError ? "machine-status-danger" : ""}`}>{STATUS_TEXT[machine.status]}</span>
</header>
<div className="machine-actions">
<IconButton icon="refresh" label="Update + analyse" active={false} danger={false} primary={false} onClick={() => onRefresh(machine.id)} />
<IconButton icon="upgrade" label="Upgrade système" active={false} danger={false} primary={packageCount > 0} onClick={() => onUpgrade(machine.id)} />
<IconButton icon="power" label="Reboot" active={false} danger primary={false} onClick={() => onReboot(machine.id)} />
<IconButton icon="terminal" label="Ouvrir les logs machine" active={false} danger={false} primary={false} onClick={() => onSelect(machine.id)} />
<IconButton icon="cog" label="Profil & proxy (sonde)" active={false} danger={false} primary={false} onClick={() => setConfigOpen(true)} />
</div>
<div className="machine-detail-cards">
<div className="machine-detail-card">
<span className="label">System info</span>
<InfoRow k="Hostname" v={machine.hostname} mono />
<InfoRow k="Port SSH" v={String(machine.port)} mono />
<InfoRow k="OS" v={machine.osFamily} />
<InfoRow k="Type" v={machine.machineKind ?? "—"} />
<InfoRow k="Virtualisation" v={machine.virtualization ?? "—"} />
<InfoRow k="Utilisateur" v={machine.username} mono />
<InfoRow k="Proxy APT" v={machine.aptProxyMode} />
</div>
<div className="machine-detail-card">
<span className="label">Update status</span>
<InfoRow k="Statut" v={STATUS_TEXT[machine.status]} tone={isError ? "warn" : "ok"} />
<InfoRow k="Updates" v={String(packageCount)} tone={packageCount > 0 ? "warn" : "ok"} />
<InfoRow k="Dernier check" v={formatDate(machine.lastCheckedAt)} mono />
</div>
</div>
<div className="machine-sections">
<SectionToggle icon="docker" title="Docker" open={dockerOpen} onToggle={() => setDockerOpen((v) => !v)} />
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
<SectionToggle icon="script" title="Post-install" open={postOpen} onToggle={() => setPostOpen((v) => !v)} />
{postOpen && <PostInstallSection />}
</div>
{configOpen && (
<MachineConfigPopup machine={machine} onClose={() => setConfigOpen(false)} onSelect={onSelect} onChanged={onChanged} />
)}
</section>
);
}
+180 -5
View File
@@ -1,21 +1,196 @@
// client/src/lib/api.ts
import type { MachineView, UpdateSnapshot, ActionType } from "@shared/types.js";
import type { ActionType, AptProxyMode, MachineKind, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js";
async function readJsonBody(res: Response): Promise<unknown> {
const text = await res.text();
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return { error: text };
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { "content-type": "application/json" },
...init,
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
return res.json() as Promise<T>;
const body = await readJsonBody(res);
if (!res.ok) {
const apiUnavailable = res.status >= 500 && body === null;
const error = apiUnavailable
? "API indisponible: le serveur backend ne répond pas."
: body && typeof body === "object" && "error" in body
? String(body.error)
: res.statusText;
throw new Error(error || "Erreur API");
}
return body as T;
}
export const api = {
systemMetrics: () => req<SystemMetrics>("/system/metrics"),
listMachines: () => req<MachineView[]>("/machines"),
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
snapshot: (id: string) => req<UpdateSnapshot>(`/machines/${id}/snapshot`),
runAction: (id: string, action: ActionType) =>
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
runAction: (id: string, action: ActionType, stackId?: string) =>
req<{ ok: boolean }>(`/machines/${id}/actions`, {
method: "POST",
body: JSON.stringify(stackId ? { action, stackId } : { action }),
}),
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
// --- Réglages globaux ---
getSettings: () => req<AppSettingsView>("/settings"),
setDefaultAptProxy: (body: DefaultAptProxy) =>
req<DefaultAptProxy>("/settings/apt-proxy", { method: "PUT", body: JSON.stringify(body) }),
applyProxyToAll: () => req<{ ok: boolean; updated: number }>("/settings/apt-proxy/apply-all", { method: "POST" }),
// --- Profil machine (SJ-7) ---
updateMachine: (id: string, body: UpdateMachineBody) =>
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
// --- Docker ---
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
dockerSetRoots: (id: string, paths: string[], scanDepth = 4) =>
req<DockerSettingsView>(`/machines/${id}/docker/roots`, {
method: "POST",
body: JSON.stringify({ paths, scanDepth }),
}),
dockerScan: (id: string) => req<{ ok: boolean }>(`/machines/${id}/docker/scan`, { method: "POST" }),
dockerStacks: (id: string) => req<DockerStackRow[]>(`/machines/${id}/docker/stacks`),
setStackStatus: (id: string, stackId: string, status: StackStatus) =>
req<DockerStackRow>(`/machines/${id}/docker/stacks/${stackId}`, {
method: "PATCH",
body: JSON.stringify({ status }),
}),
// --- Demandes d'action destructive ---
createActionRequest: (id: string, body: { action: ActionType; stackId?: string; aggressive?: boolean; summary?: string }) =>
req<ActionRequestRow>(`/machines/${id}/action-requests`, { method: "POST", body: JSON.stringify(body) }),
approveActionRequest: (reqId: string, approvedBy = "ui") =>
req<ActionRequestRow>(`/action-requests/${reqId}/approve`, { method: "POST", body: JSON.stringify({ approvedBy }) }),
// --- Sauvegarde / restauration de la base ---
dbInfo: () => req<DbInfo>("/system/db/info"),
/** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */
dbBackup: async (): Promise<void> => {
const res = await fetch("/api/system/db/backup");
if (!res.ok) throw new Error("Échec de la sauvegarde");
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const filename = /filename="([^"]+)"/.exec(cd)?.[1] ?? "system-update-backup.db";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
},
/** Envoie une archive `.db` à restaurer (appliquée au redémarrage). */
dbRestore: async (file: File): Promise<DbRestoreResult> => {
const res = await fetch("/api/system/db/restore", {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: file,
});
const body = (await readJsonBody(res)) as DbRestoreResult & { error?: string };
if (!res.ok) throw new Error(body?.error ?? "Échec de la restauration");
return body;
},
};
export interface DbInfo {
sizeBytes: number;
modifiedAt: string | null;
restorePending: boolean;
}
export interface DbRestoreResult {
ok: boolean;
restartRequired: boolean;
safetyBackup: string;
message: string;
}
export interface DefaultAptProxy {
mode: AptProxyMode;
url: string | null;
}
export interface AppSettingsView {
defaultAptProxy: DefaultAptProxy;
}
export interface UpdateMachineBody {
osFamily?: OsFamily;
machineKind?: MachineKind;
virtualization?: string | null;
aptProxyMode?: AptProxyMode;
aptProxyUrl?: string | null;
}
export interface ProbeResultView {
probe: {
osId: string | null;
osVersion: string | null;
osCodename: string | null;
arch: string | null;
dpkgArch: string | null;
virt: string | null;
isProxmox: boolean;
isRpi: boolean;
gpus: string[];
net: { iface: string; addr: string }[];
};
proposal: { osFamily: OsFamily; machineKind: MachineKind; virtualization: string };
changes: string[];
}
export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
export interface DockerSettingsView {
settings: { machineId: string; enabled: number; scanDepth: number; pruneMode: string; lastScanAt: string | null; lastPullCheckAt: string | null } | null;
roots: Array<{ id: string; path: string; enabled: number }>;
}
export interface DockerServiceRow {
id: string;
stackId: string;
serviceName: string;
imageRef: string | null;
currentImageId: string | null;
currentDigest: string | null;
candidateImageId: string | null;
candidateDigest: string | null;
versionLabel: string | null;
status: string | null;
}
export interface DockerStackRow {
id: string;
machineId: string;
name: string;
workingDir: string;
status: StackStatus;
detectedBy: string | null;
lastScanAt: string | null;
lastUpdateAt: string | null;
composeFiles: string[];
services: DockerServiceRow[];
}
export interface ActionRequestRow {
id: string;
machineId: string;
action: ActionType;
risk: string | null;
status: "pending" | "approved" | "rejected" | "executed" | "expired";
summary: string | null;
executionId: string | null;
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import { sumUpdates } from "./stats.js";
describe("sumUpdates", () => {
it("somme les compteurs", () => {
expect(sumUpdates({ a: 2, b: 3, c: 0 })).toBe(5);
});
it("retourne 0 pour un objet vide", () => {
expect(sumUpdates({})).toBe(0);
});
});
+4
View File
@@ -0,0 +1,4 @@
// client/src/lib/stats.ts
export function sumUpdates(counts: Record<string, number>): number {
return Object.values(counts).reduce((acc, n) => acc + n, 0);
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach } from "vitest";
import { nextTheme, getInitialTheme } from "./theme.js";
describe("nextTheme", () => {
it("bascule dark <-> light", () => {
expect(nextTheme("dark")).toBe("light");
expect(nextTheme("light")).toBe("dark");
});
});
describe("getInitialTheme", () => {
beforeEach(() => {
// @ts-expect-error - environnement node sans localStorage
delete globalThis.localStorage;
});
it("retombe sur dark sans localStorage", () => {
expect(getInitialTheme()).toBe("dark");
});
it("lit la valeur persistée si présente", () => {
const store: Record<string, string> = { "su-theme": "light" };
// @ts-expect-error - stub minimal
globalThis.localStorage = { getItem: (k: string) => store[k] ?? null };
expect(getInitialTheme()).toBe("light");
});
});
+25
View File
@@ -0,0 +1,25 @@
// client/src/lib/theme.ts
export type Theme = "dark" | "light";
const KEY = "su-theme";
export function nextTheme(t: Theme): Theme {
return t === "dark" ? "light" : "dark";
}
export function getInitialTheme(): Theme {
try {
const v = globalThis.localStorage?.getItem(KEY);
return v === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
export function applyTheme(t: Theme): void {
try {
document.documentElement.dataset.theme = t;
globalThis.localStorage?.setItem(KEY, t);
} catch {
/* localStorage indisponible (mode privé) : on ignore la persistance */
}
}
+4
View File
@@ -1,5 +1,9 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "@fontsource/inter";
import "@fontsource/jetbrains-mono";
import "@fontsource/share-tech-mono";
import "./styles/app.css";
import { App } from "./App.js";
+95 -27
View File
@@ -1,46 +1,114 @@
// client/src/panels/Dashboard.tsx
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { MachineView } from "@shared/types.js";
import { api } from "../lib/api.js";
import { MachineTile } from "../features/machines/MachineTile.js";
import { MachineTile, MachineRow, MachineDetailPanel } from "../features/machines/MachineTile.js";
import { AddMachineModal } from "../features/machines/AddMachineModal.js";
import { sumUpdates } from "../lib/stats.js";
interface Props { onSelect: (id: string) => void; }
export interface DashboardSummary {
machines: number;
updates: number;
errors: number;
running: number;
}
export function Dashboard({ onSelect }: Props) {
export type ViewMode = "grid" | "list";
interface Props {
selectedId?: string | null;
onSelect: (id: string) => void;
onSummaryChange?: (summary: DashboardSummary) => void;
view: ViewMode;
adding: boolean;
onAddingChange: (open: boolean) => void;
}
export function Dashboard({ selectedId, onSelect, onSummaryChange, view, adding, onAddingChange }: Props) {
const [machines, setMachines] = useState<MachineView[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
async function load() {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}));
setCounts(Object.fromEntries(entries));
setError(null);
try {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}));
setCounts(Object.fromEntries(entries));
} catch (err) {
setMachines([]);
setCounts({});
setError((err as Error).message);
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, []);
const summary = useMemo<DashboardSummary>(() => ({
machines: machines.length,
updates: sumUpdates(counts),
errors: machines.filter((m) => m.status === "error").length,
running: machines.filter((m) => m.status === "running").length,
}), [machines, counts]);
useEffect(() => {
onSummaryChange?.(summary);
}, [onSummaryChange, summary]);
const onRefresh = (id: string) => { onSelect(id); void api.refresh(id).then(load); };
const onUpgrade = (id: string) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); };
const onReboot = (id: string) => { onSelect(id); void api.runAction(id, "reboot"); };
const detail = machines.find((m) => m.id === selectedId) ?? machines[0] ?? null;
return (
<main className="su-center">
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Machines</h2>
<button className="interactive" onClick={() => setAdding(true)}>+ Ajouter</button>
<div className="su-dashboard-head">
<div>
<h2>Machines</h2>
<p>{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs</p>
</div>
</div>
{machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
onRefresh={(id) => { onSelect(id); void api.refresh(id).then(load); }}
onUpgrade={(id) => { onSelect(id); void api.runAction(id, "apt_full_upgrade"); }}
onReboot={(id) => { onSelect(id); void api.runAction(id, "reboot"); }}
/>
))}
</div>
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
{error && <p style={{ color: "var(--err)" }}>{error}</p>}
{!error && loading && <p style={{ color: "var(--ink-3)" }}>Chargement des machines</p>}
{!error && !loading && machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
{view === "grid" ? (
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id} machine={m} packageCount={counts[m.id] ?? 0} onSelect={onSelect}
onRefresh={onRefresh} onUpgrade={onUpgrade} onReboot={onReboot} onChanged={load}
/>
))}
</div>
) : (
machines.length > 0 && (
<div className="machine-listing">
<div className="machine-list">
{machines.map((m) => (
<MachineRow
key={m.id} machine={m} packageCount={counts[m.id] ?? 0}
selected={detail?.id === m.id} onClick={() => onSelect(m.id)}
/>
))}
</div>
{detail && (
<MachineDetailPanel
machine={detail} packageCount={counts[detail.id] ?? 0} onSelect={onSelect}
onRefresh={onRefresh} onUpgrade={onUpgrade} onReboot={onReboot} onChanged={load}
/>
)}
</div>
)
)}
{adding && <AddMachineModal onClose={() => onAddingChange(false)} onCreated={load} />}
</main>
);
}
+466
View File
@@ -0,0 +1,466 @@
// client/src/panels/SettingsModal.tsx
import { useEffect, useRef, useState } from "react";
import type { AptProxyMode } from "@shared/types.js";
import { Icon, Popup, Button } from "../components/ui-kit.js";
import { api, type DbInfo } from "../lib/api.js";
interface Props {
open: boolean;
onClose: () => void;
}
type SettingsTab =
| "appearance"
| "tiles"
| "layout"
| "proxy"
| "docker"
| "scripts"
| "hermes"
| "terminal"
| "retention"
| "database";
const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
{ id: "appearance", label: "Apparence", icon: "cog" },
{ id: "tiles", label: "Tuiles", icon: "grid" },
{ id: "layout", label: "Volets", icon: "collapse" },
{ id: "proxy", label: "Proxy APT", icon: "network" },
{ id: "docker", label: "Docker", icon: "docker" },
{ id: "scripts", label: "Scripts", icon: "script" },
{ id: "hermes", label: "Hermes", icon: "node" },
{ id: "terminal", label: "Terminal", icon: "terminal" },
{ id: "retention", label: "Nettoyage", icon: "logs" },
{ id: "database", label: "Base de données", icon: "database" },
];
export function SettingsModal({ open, onClose }: Props) {
const [active, setActive] = useState<SettingsTab>("appearance");
if (!open) return null;
return (
<div className="settings-backdrop" onClick={onClose}>
<section className="settings-modal glass-strong" onClick={(event) => event.stopPropagation()}>
<header className="settings-head">
<div>
<span className="label">PARAMÈTRES</span>
<h2>System Update</h2>
</div>
<button className="interactive settings-close" onClick={onClose} aria-label="Fermer">
<Icon name="close" size={14} style={undefined} />
</button>
</header>
<div className="settings-body">
<nav className="settings-nav">
{TABS.map((tab) => (
<button
key={tab.id}
className={`interactive settings-nav-item ${active === tab.id ? "active" : ""}`}
onClick={() => setActive(tab.id)}
>
<Icon name={tab.icon} size={14} style={undefined} />
<span>{tab.label}</span>
</button>
))}
</nav>
<div className="settings-content">
{active === "appearance" && <AppearanceSettings />}
{active === "tiles" && <TileSettings />}
{active === "layout" && <LayoutSettings />}
{active === "proxy" && <ProxyDefaultSettings />}
{active === "docker" && <DockerSettings />}
{active === "scripts" && <ScriptsSettings />}
{active === "hermes" && <HermesSettings />}
{active === "terminal" && <TerminalSettings />}
{active === "retention" && <RetentionSettings />}
{active === "database" && <DatabaseSettings />}
</div>
</div>
<footer className="settings-footer">
<span className="mono">settings backend pending</span>
<button className="interactive settings-secondary" onClick={onClose}>Fermer</button>
<button className="interactive settings-primary" onClick={onClose}>Sauvegarder</button>
</footer>
</section>
</div>
);
}
function AppearanceSettings() {
return (
<SettingsSection title="Apparence">
<Field label="Thème">
<select className="su-field" defaultValue="system">
<option value="system">Système</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</Field>
<Field label="Zoom UI">
<input className="su-field" type="number" min="80" max="130" defaultValue="100" />
</Field>
<Field label="Densité">
<select className="su-field" defaultValue="compact">
<option value="compact">Compact</option>
<option value="comfortable">Confort</option>
</select>
</Field>
</SettingsSection>
);
}
function TileSettings() {
return (
<SettingsSection title="Tuiles machine">
<Field label="Largeur minimale">
<input className="su-field" type="number" min="240" max="420" defaultValue="280" />
</Field>
<Field label="Sections ouvertes par défaut">
<div className="settings-checks">
<Check label="Docker" />
<Check label="Post-install" />
<Check label="Hardware" />
</div>
</Field>
<Field label="Mode erreur">
<select className="su-field" defaultValue="expanded">
<option value="badge">Badge</option>
<option value="expanded">Alerte visible</option>
</select>
</Field>
</SettingsSection>
);
}
function LayoutSettings() {
return (
<SettingsSection title="Volets">
<Field label="Hermes largeur">
<input className="su-field" type="number" min="200" max="300" defaultValue="240" />
</Field>
<Field label="Terminal largeur">
<input className="su-field" type="number" min="320" max="460" defaultValue="380" />
</Field>
<Field label="Mobile">
<select className="su-field" defaultValue="tabs">
<option value="tabs">Onglets</option>
<option value="bottom">Barre basse</option>
</select>
</Field>
</SettingsSection>
);
}
function DockerSettings() {
return (
<SettingsSection title="Docker">
<Field label="Roots Compose">
<textarea className="su-field settings-textarea" defaultValue={"/home/gilles/docker\n/opt/docker"} />
</Field>
<Field label="Prune">
<select className="su-field" defaultValue="safe">
<option value="safe">Images inutilisées</option>
<option value="aggressive">Agressif avec validation</option>
</select>
</Field>
</SettingsSection>
);
}
function ScriptsSettings() {
return (
<SettingsSection title="Scripts">
<Field label="Catalogue">
<select className="su-field" defaultValue="local">
<option value="local">Scripts locaux</option>
<option value="shared">Scripts partagés</option>
</select>
</Field>
<Field label="Profils visibles">
<div className="settings-checks">
<Check label="Network" checked />
<Check label="Dev tools" checked />
<Check label="Domotique" />
</div>
</Field>
</SettingsSection>
);
}
function HermesSettings() {
return (
<SettingsSection title="Hermes">
<Field label="Endpoint">
<input className="su-field" defaultValue="http://10.0.0.80:8000" />
</Field>
<Field label="Contexte max">
<input className="su-field" type="number" min="1000" max="64000" defaultValue="12000" />
</Field>
</SettingsSection>
);
}
function TerminalSettings() {
return (
<SettingsSection title="Terminal">
<Field label="Mode">
<select className="su-field" defaultValue="logs">
<option value="logs">Logs actions</option>
<option value="ssh">SSH interactif</option>
</select>
</Field>
<Field label="Police">
<input className="su-field" type="number" min="10" max="18" defaultValue="12" />
</Field>
</SettingsSection>
);
}
function RetentionSettings() {
return (
<SettingsSection title="Nettoyage">
<Field label="Logs bruts">
<input className="su-field" type="number" min="7" max="365" defaultValue="90" />
</Field>
<Field label="Rapports">
<input className="su-field" type="number" min="30" max="730" defaultValue="365" />
</Field>
<Field label="Messages importants">
<select className="su-field" defaultValue="keep">
<option value="keep">Conserver non acquittés</option>
<option value="archive">Archiver</option>
</select>
</Field>
</SettingsSection>
);
}
function ProxyDefaultSettings() {
const [mode, setMode] = useState<AptProxyMode>("direct");
const [url, setUrl] = useState("");
const [busy, setBusy] = useState<null | "save" | "apply">(null);
const [msg, setMsg] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
useEffect(() => {
void (async () => {
try {
const s = await api.getSettings();
setMode(s.defaultAptProxy.mode);
setUrl(s.defaultAptProxy.url ?? "");
} catch {
/* défaut direct */
}
})();
}, []);
async function save() {
setBusy("save");
setMsg(null);
try {
await api.setDefaultAptProxy({ mode, url: url.trim() || null });
setMsg({ kind: "ok", text: "Proxy par défaut enregistré." });
} catch (err) {
setMsg({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
async function applyAll() {
setBusy("apply");
setMsg(null);
try {
await api.setDefaultAptProxy({ mode, url: url.trim() || null });
const res = await api.applyProxyToAll();
setMsg({ kind: "ok", text: `Appliqué à ${res.updated} machine(s).` });
} catch (err) {
setMsg({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
return (
<SettingsSection title="Proxy APT par défaut (apt-cacher-ng)">
<div className="settings-fields">
<Field label="Mode par défaut">
<select className="su-field" value={mode} onChange={(e) => setMode(e.target.value as AptProxyMode)}>
<option value="direct">Direct (aucun proxy)</option>
<option value="runtime">Runtime (le temps d'une exécution)</option>
<option value="persistent">Persistant (/etc/apt/apt.conf.d/01proxy)</option>
</select>
</Field>
<Field label="URL apt-cacher-ng">
<input className="su-field" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://10.0.3.100:3142" />
</Field>
</div>
<div className="settings-actions">
<Button icon="check" variant="primary" onClick={busy ? undefined : save}>
{busy === "save" ? "Enregistrement…" : "Enregistrer le défaut"}
</Button>
<Button icon="network" variant="default" onClick={busy ? undefined : applyAll}>
{busy === "apply" ? "Application…" : "Appliquer à toutes les machines"}
</Button>
</div>
<p className="settings-note">
Ce proxy sert de valeur par défaut à l'ajout d'une machine (apt-cacher-ng mutualise le cache des paquets). « Appliquer à toutes les machines » écrase le réglage proxy de chaque machine existante. Le mode <span className="mono">persistant</span> n'est écrit sur disque que via l'action dédiée par machine.
</p>
{msg && (
<p className={`settings-note ${msg.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>{msg.text}</p>
)}
</SettingsSection>
);
}
function DatabaseSettings() {
const [info, setInfo] = useState<DbInfo | null>(null);
const [busy, setBusy] = useState<null | "backup" | "restore">(null);
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function loadInfo() {
try {
setInfo(await api.dbInfo());
} catch {
setInfo(null);
}
}
useEffect(() => {
void loadInfo();
}, []);
async function onBackup() {
setBusy("backup");
setMessage(null);
try {
await api.dbBackup();
setMessage({ kind: "ok", text: "Archive téléchargée." });
} catch (err) {
setMessage({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
function onPickFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
if (file) setPendingFile(file);
event.target.value = "";
}
async function confirmRestore() {
if (!pendingFile) return;
setBusy("restore");
setMessage(null);
try {
const res = await api.dbRestore(pendingFile);
setMessage({ kind: "ok", text: res.message });
void loadInfo();
} catch (err) {
setMessage({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
setPendingFile(null);
}
}
return (
<SettingsSection title="Base de données">
<div className="settings-fields">
<Field label="Taille actuelle">
<span className="mono">{info ? formatBytes(info.sizeBytes) : "--"}</span>
</Field>
<Field label="Dernière modification">
<span className="mono">{info?.modifiedAt ? new Date(info.modifiedAt).toLocaleString("fr-FR") : "--"}</span>
</Field>
</div>
{info?.restorePending && (
<p className="settings-note settings-note-warn">
<Icon name="alert" size={13} style={undefined} /> Une restauration est en attente : redémarrez le serveur pour l'appliquer.
</p>
)}
<div className="settings-actions">
<Button icon="download" variant="primary" onClick={busy ? undefined : onBackup}>
{busy === "backup" ? "Sauvegarde…" : "Télécharger la sauvegarde"}
</Button>
<Button icon="upload" variant="default" onClick={busy ? undefined : () => fileRef.current?.click()}>
Restaurer une archive
</Button>
<input ref={fileRef} type="file" accept=".db,application/octet-stream" hidden onChange={onPickFile} />
</div>
<p className="settings-note">
La sauvegarde produit un instantané cohérent <span className="mono">.db</span> (machines, credentials chiffrés, exécutions, rapports). La restauration remplace toute la base au prochain démarrage ; une sauvegarde de sécurité est créée automatiquement avant.
</p>
{message && (
<p className={`settings-note ${message.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>
{message.text}
</p>
)}
<Popup
open={pendingFile !== null}
onClose={() => setPendingFile(null)}
title="Confirmer la restauration"
footer={
<>
<Button variant="ghost" icon="close" onClick={() => setPendingFile(null)}>Annuler</Button>
<Button variant="danger" icon="upload" onClick={busy === "restore" ? undefined : confirmRestore}>
{busy === "restore" ? "Restauration…" : "Remplacer la base"}
</Button>
</>
}
>
<p>
La base actuelle sera <strong>entièrement remplacée</strong> par&nbsp;
<span className="mono">{pendingFile?.name}</span> au prochain démarrage du serveur.
</p>
<p>Une sauvegarde de sécurité de la base actuelle est créée automatiquement.</p>
</Popup>
</SettingsSection>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="settings-section">
<h3>{title}</h3>
<div className="settings-fields">{children}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="settings-field">
<span className="label">{label}</span>
{children}
</label>
);
}
function Check({ label, checked }: { label: string; checked?: boolean }) {
return (
<label className="settings-check">
<input type="checkbox" defaultChecked={checked} />
<span>{label}</span>
</label>
);
}
+23 -3
View File
@@ -18,11 +18,31 @@ export function TerminalPanel({ machineId }: { machineId: string | null }) {
const fit = new FitAddon();
term.loadAddon(fit);
term.open(ref.current);
fit.fit();
const fitTerminal = () => {
try { fit.fit(); } catch { /* xterm peut être entre deux cycles de layout. */ }
};
const frame = window.requestAnimationFrame(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine");
const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {};
return () => { disconnect(); term.dispose(); };
return () => {
window.cancelAnimationFrame(frame);
resizeObserver.disconnect();
disconnect();
term.dispose();
};
}, [machineId]);
return <div className="su-terminal" ref={ref} style={{ padding: 6 }} />;
return (
<aside className="su-terminal-wrap">
<div className="su-terminal-head">
<span className="label">TERMINAL</span>
<span className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
{machineId ?? "aucune machine"}
</span>
</div>
<div className="su-terminal" ref={ref} />
</aside>
);
}
+484 -4
View File
@@ -7,8 +7,488 @@ body {
background: var(--bg-1);
color: var(--ink-1);
}
.su-layout { display: flex; height: 100vh; }
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; }
.su-center { flex: 1; overflow: auto; padding: 18px; }
.su-terminal { width: 360px; min-width: 320px; background: var(--bg-0); border-left: 1px solid var(--border-1); }
/* Ossature : rangée 3 volets */
.su-app { display: flex; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; }
.su-row { flex: 1; display: flex; width: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.su-header {
height: 52px; flex: 0 0 52px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
background: var(--bg-2);
border-bottom: 1px solid var(--border-1);
}
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
.su-spacer { flex: 1; }
.su-brand { display: flex; align-items: center; gap: 10px; min-width: 210px; }
.su-brand-mark {
width: 30px; height: 30px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent); color: var(--bg-1);
font-weight: 800; font-family: var(--font-mono); font-size: 12px;
}
.su-brand .mono { display: block; color: var(--ink-3); font-size: 11px; margin-top: 2px; }
.su-header-summary { display: flex; gap: 8px; flex-wrap: wrap; color: var(--ink-3); font-size: 12px; }
.su-header-summary span,
.su-header-button {
border: 1px solid var(--border-1);
background: var(--bg-3);
color: var(--ink-2);
border-radius: 8px;
padding: 6px 9px;
}
.su-header-button { font-family: var(--font-ui); }
.su-hermes { flex: 0 0 clamp(220px, 15vw, 280px); min-width: 0; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
.su-center { flex: 1 1 auto; min-width: 0; overflow: auto; padding: 18px; }
.su-terminal-wrap { flex: 0 0 clamp(320px, 28vw, 440px); min-width: 0; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); overflow: hidden; }
.su-terminal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-1); }
.su-terminal { flex: 1; min-width: 0; min-height: 0; padding: 6px; overflow: hidden; }
.su-terminal .xterm { height: 100%; }
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.su-dashboard-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.su-dashboard-head h2 { margin: 0; font-size: 22px; }
.su-dashboard-head p { margin: 4px 0 0; color: var(--ink-3); font-size: 12px; }
.su-add-button {
background: var(--bg-3);
color: var(--ink-1);
border: 1px solid var(--border-2);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--font-ui);
}
/* --- Toggle d'affichage Tuiles / Liste --- */
.su-head-actions { display: flex; align-items: center; gap: 10px; }
.su-viewtoggle { display: inline-flex; border: 1px solid var(--border-2); border-radius: 8px; overflow: hidden; background: var(--bg-2); }
.su-viewtoggle-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 12px; font-family: var(--font-ui);
color: var(--ink-2); background: transparent; border: none;
}
.su-viewtoggle-btn.active { background: var(--accent); color: var(--bg-1); }
/* --- Mode Listing : liste compacte + panneau détail --- */
.machine-listing { display: flex; gap: 14px; align-items: flex-start; }
.machine-list { flex: 0 0 clamp(280px, 32%, 420px); display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.machine-row {
display: flex; align-items: center; gap: 10px;
padding: 9px 11px; border-radius: 8px;
border: 1px solid var(--border-1); background: var(--bg-2);
text-align: left; width: 100%; color: var(--ink-1);
}
.machine-row.active { border-color: var(--accent); background: var(--accent-tint); }
.machine-row-name { font-weight: 600; font-size: 13px; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-row-ip { color: var(--ink-3); font-size: 11px; flex: 0 0 auto; }
.machine-row-os { display: inline-flex; align-items: center; gap: 5px; color: var(--ink-2); font-size: 12px; flex: 0 0 auto; }
.machine-row-cell { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; flex: 0 0 auto; min-width: 56px; }
.machine-row-cell b { font-size: 13px; }
.machine-row-cell .mono { font-size: 11px; color: var(--ink-3); }
.machine-detail { flex: 1 1 auto; min-width: 0; padding: 16px; border-radius: 10px; display: flex; flex-direction: column; gap: 14px; }
.machine-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.machine-detail-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.machine-detail-card {
display: flex; flex-direction: column; gap: 7px;
padding: 12px; border-radius: 8px;
border: 1px solid var(--border-1); background: var(--bg-1);
}
.machine-info-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.machine-info-k { color: var(--ink-3); font-size: 12px; }
.machine-info-v { color: var(--ink-1); font-size: 13px; text-align: right; }
.machine-tile {
min-width: 0;
padding: 14px;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 12px;
}
.machine-tile-expanded { grid-column: span 2; }
.machine-tile-head,
.machine-title-row,
.machine-actions,
.machine-section-toggle,
.machine-section-row,
.machine-check-row {
display: flex;
align-items: center;
}
.machine-tile-head { justify-content: space-between; gap: 12px; min-width: 0; }
.machine-title-row { gap: 9px; min-width: 0; }
.machine-title-text { display: flex; flex-direction: column; min-width: 0; }
.machine-title-text strong { color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-title-text .mono { color: var(--ink-3); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-status-pill {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-3);
border: 1px solid var(--border-2);
color: var(--ink-3);
font-size: 11px;
font-family: var(--font-mono);
}
.machine-status-danger { color: var(--err); border-color: var(--err); background: rgba(251, 73, 52, 0.08); }
.machine-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.machine-metric {
min-width: 0;
padding: 8px;
border-radius: 8px;
background: var(--bg-2);
border: 1px solid var(--border-1);
}
.machine-metric .mono { display: block; margin-top: 3px; font-size: 13px; color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-metric-warn { color: var(--warn) !important; }
.machine-metric-ok { color: var(--ok) !important; }
.machine-alert {
display: flex;
gap: 8px;
align-items: center;
color: var(--err);
font-size: 12px;
padding: 8px;
border: 1px solid var(--err);
border-radius: 8px;
background: rgba(251, 73, 52, 0.08);
}
.machine-actions { gap: 7px; flex-wrap: wrap; }
.machine-sections {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid var(--border-1);
padding-top: 10px;
}
.machine-section-toggle {
width: 100%;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-1);
background: var(--bg-2);
color: var(--ink-2);
font-family: var(--font-ui);
}
.machine-section-title { display: inline-flex; align-items: center; gap: 8px; }
.machine-section-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: var(--bg-1);
border: 1px solid var(--border-1);
}
.machine-section-row { justify-content: space-between; gap: 8px; }
.machine-placeholder { color: var(--ink-3); font-size: 12px; line-height: 1.35; }
.machine-check-row { gap: 8px; color: var(--ink-2); font-size: 13px; }
/* --- Docker section --- */
.docker-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.docker-laststamp { color: var(--ink-3); font-size: 11px; margin-left: auto; }
.docker-roots {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-2);
}
.docker-roots .settings-textarea { min-height: 60px; }
.docker-stacks { display: flex; flex-direction: column; gap: 8px; }
.docker-stack {
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-2);
}
.docker-stack-head { display: flex; align-items: center; gap: 8px; }
.docker-stack-name { font-weight: 600; color: var(--ink-1); font-size: 13px; }
.docker-stack-by { color: var(--ink-3); font-size: 11px; margin-left: auto; }
.docker-stack-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.docker-services {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-1);
}
.docker-service { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.docker-service-name { color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docker-service-diff { color: var(--ink-3); font-size: 11px; margin-left: auto; }
.docker-badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--border-2);
white-space: nowrap;
}
.docker-badge-ok { color: var(--ok); border-color: var(--ok); }
.docker-badge-warn { color: var(--warn); border-color: var(--warn); }
.docker-badge-err { color: var(--err); border-color: var(--err); }
.docker-badge-info { color: var(--accent); border-color: var(--accent-soft); }
.docker-badge-off { color: var(--ink-3); }
.docker-msg { font-size: 12px; margin: 4px 0 0; }
.docker-msg-ok { color: var(--ok); }
.docker-msg-err { color: var(--err); }
.docker-confirm-note { display: flex; align-items: center; gap: 7px; color: var(--ink-3); font-size: 12px; margin-top: 10px; }
/* --- Popup config machine (SJ-7) --- */
.cfg { display: flex; flex-direction: column; gap: 14px; }
.cfg-current { display: flex; flex-direction: column; gap: 4px; }
.cfg-current .mono { color: var(--ink-1); font-size: 13px; }
.cfg-block {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-2);
}
.cfg-block-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.cfg-probe { display: flex; flex-direction: column; gap: 6px; }
.cfg-facts { color: var(--ink-2); font-size: 12px; }
.cfg-proposal { color: var(--accent); font-size: 12px; }
.cfg-changes { margin: 0; padding-left: 16px; display: flex; flex-direction: column; gap: 2px; }
.cfg-changes li { color: var(--warn); font-size: 12px; }
.cfg-nochange { color: var(--ok); font-size: 12px; }
.cfg-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.machine-check-row input { accent-color: var(--accent); }
@media (max-width: 1180px) {
.machine-tile-expanded { grid-column: 1 / -1; }
}
@media (max-width: 920px) {
.su-hermes { flex-basis: 220px; }
.su-terminal-wrap { flex-basis: 320px; }
.su-header-summary { display: none; }
}
/* Status bar style tmux */
.su-statusbar {
height: 26px; flex: 0 0 26px;
display: flex; align-items: stretch;
background: var(--bg-2);
border-top: 1px solid var(--border-1);
font-family: var(--font-terminal);
font-size: 11px;
}
.su-statusbar .cell { display: flex; align-items: center; padding: 0 12px; border-right: 1px solid var(--border-1); color: var(--ink-2); }
.su-statusbar .cell.mode { background: var(--accent); color: var(--bg-1); font-weight: 700; letter-spacing: 0.08em; }
.su-statusbar .clock { margin-left: auto; border-right: none; border-left: 1px solid var(--border-1); }
/* Champs de formulaire tokenisés */
.su-field {
padding: 9px 12px; font-size: 13px; font-family: var(--font-ui);
background: var(--bg-1); color: var(--ink-1);
border: 1px solid var(--border-2); border-radius: 8px;
outline: none;
}
.su-field:focus { border-color: var(--accent-soft); }
.settings-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
}
.settings-modal {
width: min(920px, 96vw);
max-height: min(720px, 92vh);
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
}
.settings-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-1);
background: var(--bg-3);
}
.settings-head h2 { margin: 2px 0 0; font-size: 18px; }
.settings-close {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-2);
color: var(--ink-2);
}
.settings-body {
flex: 1;
min-height: 0;
display: flex;
}
.settings-nav {
flex: 0 0 210px;
padding: 12px;
border-right: 1px solid var(--border-1);
background: var(--bg-2);
overflow: auto;
}
.settings-nav-item {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
font-family: var(--font-ui);
text-align: left;
}
.settings-nav-item.active {
background: var(--accent-tint);
border-color: var(--accent-soft);
color: var(--ink-1);
}
.settings-content {
flex: 1;
min-width: 0;
padding: 18px;
overflow: auto;
}
.settings-section h3 { margin: 0 0 14px; font-size: 18px; }
.settings-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.settings-field {
display: flex;
flex-direction: column;
gap: 7px;
}
.settings-field .su-field {
width: 100%;
}
.settings-textarea {
min-height: 96px;
resize: vertical;
}
.settings-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 4px;
}
.settings-note {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--ink-2);
}
.settings-note .mono { color: var(--ink-1); }
.settings-note-ok { color: var(--ok); }
.settings-note-err { color: var(--err); }
.settings-note-warn {
color: var(--warn);
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--warn);
background: var(--bg-1);
}
.settings-checks {
display: flex;
flex-direction: column;
gap: 8px;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border-2);
background: var(--bg-1);
}
.settings-check {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-2);
font-size: 13px;
}
.settings-check input { accent-color: var(--accent); }
.settings-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border-1);
background: var(--bg-2);
}
.settings-footer .mono {
margin-right: auto;
color: var(--ink-3);
font-size: 11px;
}
.settings-primary,
.settings-secondary {
border-radius: 8px;
padding: 8px 12px;
font-family: var(--font-ui);
border: 1px solid var(--border-2);
}
.settings-primary {
background: var(--accent);
color: var(--bg-1);
border-color: var(--accent-soft);
}
.settings-secondary {
background: var(--bg-3);
color: var(--ink-1);
}
@media (max-width: 720px) {
.settings-body { flex-direction: column; }
.settings-nav {
flex: 0 0 auto;
display: flex;
gap: 6px;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border-1);
}
.settings-nav-item { flex: 0 0 auto; width: auto; }
}
+122
View File
@@ -0,0 +1,122 @@
# Revue de cohérence — tâches system_update
> **But** : vérifier que les tâches 1.9 à 8 s'enchaînent proprement vers l'objectif final : une webapp serveur `system_update` exploitable via Docker Compose, avec API extensible et clients futurs.
---
## 1. Verdict global
Les tâches sont globalement cohérentes.
Il n'y a pas de contradiction bloquante détectée entre :
- moteur templates/scripts ;
- frontend web ;
- backend/API ;
- BDD ;
- Hermes/MCP ;
- optimisation/nettoyage ;
- app locale future.
Les recouvrements repérés sont normaux : certains sujets sont transverses et doivent apparaître dans plusieurs tâches, mais avec responsabilités différentes.
---
## 2. Flux de développement recommandé
Ordre logique :
```text
1. Validation tâche 1.9 → schéma BDD cible
2. Validation tâche 2 → templates, APT, Docker, contrats JSON
3. Validation tâche 4 → scripts post-install/hardware/profils
4. Validation tâche 5 → backend/API/jobs/storage
5. Validation tâche 3 → frontend web/tuiles/paramètres/layout
6. Validation tâche 6 → Hermes/MCP/skills
7. Validation tâche 7 → optimisation/nettoyage/sécurité/découverte
8. Validation tâche 8 → app Rust/GNOME future
```
Pourquoi cet ordre :
- la BDD et les contrats JSON structurent tout ;
- les scripts/templates produisent les données ;
- le backend stocke, orchestre et expose ;
- le frontend consomme ces contrats ;
- Hermes/MCP et optimisations s'appuient sur le backend ;
- l'app Rust/GNOME reste une évolution future via API commune.
---
## 3. Recouvrements acceptés
### Métriques simples
- `tache4.md` : définit le script SSH `machine_metrics_simple`.
- `tache5.md` : définit stockage/API/snapshot.
- `tache7.md` : définit affichage footer, optimisation et usage observabilité.
- `tache1.9.md` : définit tables `machine_metrics_latest`.
Ce n'est pas un doublon : chaque tâche couvre une couche différente.
### Logs, rapports et messages importants
- `tache5.md` : stockage backend, API, rétention.
- `tache6.md` : accès Hermes/MCP.
- `tache7.md` : nettoyage/rétention.
- `tache1.9.md` : tables.
Ce recouvrement est nécessaire.
### Paramètres frontend
- `tache3.md` : UX paramètres.
- `tache1.9.md` : stockage `app_settings`, `user_preferences`, `machine_ui_state`.
- `tache5.md` : API `/api/settings`.
Ce découpage est cohérent.
### App locale Rust/GNOME
- `tache8.md` : client natif futur.
- `tache5.md` : API commune/capabilities.
- `tache1.9.md` : table `api_clients`.
Ce n'est pas un chantier immédiat ; il doit rester futur.
---
## 4. Points corrigés pendant la revue
- `tache2.md` et `validation_tache2.md` parlaient encore de 7 questions d'investigation alors que la tâche en contient 8 après ajout des profils machine. Corrigé.
---
## 5. Objectif final Docker Compose
Objectif final confirmé :
```text
Une webapp serveur system_update déployable via Docker Compose :
- backend API ;
- frontend web servi par le backend ou reverse proxy ;
- SQLite persisté en volume ;
- reports/logs persistés en volume ;
- configuration via variables d'environnement ;
- secrets master key hors image ;
- accès réseau vers machines SSH ;
- option future reverse proxy/TLS.
```
Cet objectif doit rester un critère transversal dans les validations, surtout tâches 5, 7 et le plan d'implémentation final.
---
## 6. Risques à surveiller
- Ne pas implémenter toutes les tâches en un seul jalon : trop grand.
- Garder les actions dangereuses validées UI, même si Hermes ou l'app Rust les demande.
- Ne pas exposer les credentials SSH/sudo/API dans logs, UI, Hermes, MCP ou clients locaux.
- Garder SQLite au MVP, mais écrire le schéma pour migrer vers PostgreSQL.
- Garder les scripts critiques versionnés sur disque, pas uniquement en BDD.
- Ne pas confondre terminal live d'exécution, vrai terminal SSH interactif et conversation Hermes.
+295
View File
@@ -0,0 +1,295 @@
# Consigne icônes — system_update
> **Type** : brief de création d'icônes SVG et assets applicatifs.
> **Langue** : français.
> **But** : transmettre à un agent spécialisé les contraintes de création d'icônes pour la webapp `system_update`.
---
## 1. Contexte
`system_update` est une application d'administration système agentless SSH :
- suivi de machines Debian, Ubuntu, Proxmox, Raspberry Pi OS ;
- update/analyse APT ;
- Docker Compose ;
- scripts post-install ;
- métriques simples ;
- logs/rapports ;
- discussion Hermes ;
- terminal SSH.
Le design system est **Gruvbox seventies** :
- rétro-industriel ;
- console de monitoring ;
- SCADA / terminal années 70 ;
- fond brun/gris usé ;
- accent orange brûlé ;
- UI technique, dense, lisible.
Lire aussi :
- `design_system/consigne_design_system.md`
- `design_system/tokens/tokens.css`
- `design_system/tokens/tokens.gnome.css`
- `design_system/tokens/tokens.json`
- `tache3.md`
---
## 2. Règles absolues
- Ne pas utiliser d'emoji.
- on peut utiliser des logos officiels de distributions, Docker, Proxmox, Raspberry Pi, NVIDIA, etc.
- on peut utiliser des mascotte.
- Ne pas créer d'illustration complexe.
- Icônes lisibles en `16px`, `20px`, `24px`, `32px`.
- SVG monochrome ou bichrome maximum.
- Les couleurs doivent être pilotables par CSS : `currentColor`, variables CSS ou classes.
- Pas de hex en dur dans les SVG finaux, sauf si un export bitmap final est explicitement demandé.
- Stroke régulier, formes simples, angles légèrement industriels.
- Éviter les arrondis excessifs.
- Les icônes UI courantes doivent d'abord utiliser Font Awesome via `Icon`; créer un SVG custom seulement pour les concepts spécifiques.
---
## 3. Assets applicatifs à créer
### Favicon principal
Fichier cible recommandé :
```text
client/public/favicon.svg
```
Concept :
- petit terminal ou serveur ;
- LED de statut ;
- flèche d'update ;
- grille machine ou stack discret.
Contraintes :
- lisible à `16x16` ;
- pas de texte ;
- silhouette reconnaissable ;
- version dark/light compatible.
### Fallback navigateur
```text
client/public/favicon.ico
```
Exporter depuis le SVG en tailles :
- `16x16`
- `32x32`
- `48x48`
### Icônes smartphone / PWA
```text
client/public/apple-touch-icon.png
client/public/icon-192.png
client/public/icon-512.png
client/public/maskable-512.png
```
Contraintes :
- fond plein compatible thème Gruvbox ;
- symbole centré ;
- marge de sécurité pour icône maskable ;
- lisible sur fond clair et sombre ;
- pas de détails fins.
---
## 4. Direction visuelle
Formes recommandées :
- serveur rack compact ;
- terminal carré ;
- grille 2x2 de machines ;
- LED ronde ;
- flèche circulaire d'update ;
- ligne de terminal ;
- stack de conteneurs ;
- puce CPU ;
- disque ;
- bouclier sécurité.
Palette conceptuelle :
- fond : utiliser les tokens `--bg-2`, `--bg-3` ;
- trait principal : `--ink-1` ou `currentColor` ;
- accent : `--accent` ;
- statut ok : `--ok` ;
- warning : `--warn` ;
- erreur : `--err`.
Le SVG doit rester utilisable en `currentColor`. Les variantes colorées ne doivent être utilisées que pour favicon/app icon, pas pour toutes les icônes UI.
---
## 5. Liste d'icônes nécessaires
### Navigation et layout
| Alias | Usage | Source recommandée |
|---|---|---|
| `app-logo` | logo app, favicon | SVG custom |
| `machines` | onglet machines | Font Awesome ou SVG custom |
| `hermes` | volet discussion agent | SVG custom si identité locale nécessaire |
| `settings` | paramètres | Font Awesome |
| `terminal` | volet terminal | Font Awesome |
| `logs` | logs bruts | Font Awesome |
| `report` | rapport Markdown | Font Awesome |
| `copy` | copier message/commande | Font Awesome |
| `fullscreen` | terminal plein écran | Font Awesome |
| `collapse` | réduire volet | Font Awesome |
### Actions système
| Alias | Usage | Source recommandée |
|---|---|---|
| `refresh` | analyse/refresh | Font Awesome |
| `analyze` | update + analyse | Font Awesome |
| `upgrade` | upgrade | Font Awesome |
| `full-upgrade` | full/dist-upgrade | SVG custom optionnel |
| `reboot` | reboot | Font Awesome |
| `reboot-verified` | reboot vérifié | SVG custom optionnel |
| `stop` | arrêter action | Font Awesome |
| `dry-run` | simulation | Font Awesome |
| `approve` | validation action | Font Awesome |
| `reject` | refus action | Font Awesome |
### Type de machine
| Alias | Usage | Source recommandée |
|---|---|---|
| `server` | machine générique | Font Awesome |
| `vm` | machine virtuelle | SVG custom optionnel |
| `physical-host` | machine physique | SVG custom optionnel |
| `proxmox-host` | hôte hyperviseur générique | SVG custom, sans logo Proxmox |
| `container` | LXC/container | Font Awesome ou SVG custom |
| `raspberry-pi` | Raspberry Pi générique | SVG custom sans logo officiel |
| `workstation` | poste/serveur GPU | Font Awesome |
### Hardware et métriques
| Alias | Usage | Source recommandée |
|---|---|---|
| `cpu` | CPU/load | Font Awesome existant |
| `memory` | RAM | Font Awesome existant |
| `disk` | disque/df | Font Awesome existant |
| `network` | réseau | Font Awesome existant |
| `gpu` | GPU | SVG custom optionnel |
| `temperature` | température | Font Awesome |
| `smart-disk` | SMART disk | SVG custom optionnel |
| `benchmark` | benchmark | Font Awesome |
| `machine-probe` | détection hardware | SVG custom optionnel |
### APT, Docker, scripts
| Alias | Usage | Source recommandée |
|---|---|---|
| `package` | paquet APT | Font Awesome |
| `repository` | dépôt APT | Font Awesome |
| `firmware` | firmware | SVG custom optionnel |
| `driver` | driver | SVG custom optionnel |
| `docker` | Docker installé/absent | SVG custom ou Font Awesome si disponible |
| `compose-stack` | stack Compose | SVG custom recommandé |
| `container-image` | image Docker | SVG custom optionnel |
| `prune` | nettoyage images | SVG custom optionnel |
| `script` | script install | Font Awesome |
| `profile` | profil post-install | Font Awesome |
### Sécurité et états
| Alias | Usage | Source recommandée |
|---|---|---|
| `ok` | succès | Font Awesome |
| `warning` | warning | Font Awesome |
| `error` | erreur | Font Awesome |
| `locked` | action verrouillée | Font Awesome |
| `secret` | secret masqué | Font Awesome |
| `key` | clé SSH/API | Font Awesome |
| `shield` | sécurité | Font Awesome |
| `disconnected` | machine/Hermes déconnecté | Font Awesome |
| `running` | action en cours | Font Awesome |
---
## 6. Icônes SVG custom prioritaires
Priorité haute :
1. `app-logo`
2. `compose-stack`
3. `machine-probe`
4. `reboot-verified`
Priorité moyenne :
1. `proxmox-host`
2. `physical-host`
3. `vm`
4. `gpu`
5. `firmware`
6. `driver`
Priorité basse :
1. `smart-disk`
2. `prune`
3. `container-image`
4. `raspberry-pi`
---
## 7. Format attendu des SVG
Recommandation :
```xml
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="..." stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
```
Contraintes :
- `viewBox="0 0 24 24"` pour icônes UI ;
- `viewBox="0 0 512 512"` possible pour logo/app icon source ;
- `stroke-width` entre `1.6` et `2`;
- pas de filtre SVG, pas de blur ;
- pas de texte vectorisé ;
- pas de dépendance externe ;
- fichiers nommés en kebab-case.
---
## 8. Validation visuelle
Chaque icône doit être vérifiée :
- en dark theme ;
- en light theme ;
- en `16px`, `20px`, `24px`, `32px` ;
- sur fond `--bg-2` et `--bg-3` ;
- en état normal, warning, error si applicable ;
- avec le composant `IconButton` et tooltip.
Critères d'acceptation :
- silhouette compréhensible sans label à `20px` ;
- pas de confusion entre `refresh`, `upgrade`, `reboot` ;
- pas de confusion entre `vm`, `physical-host`, `proxmox-host`, `container` ;
- pas de dépendance à une marque externe ;
- rendu cohérent avec le design system Gruvbox seventies.
+91
View File
@@ -0,0 +1,91 @@
# Tâche 2 — Moteur de templates de mise à jour : synthèse de design
> **Type** : document de design / spec. Aucune implémentation. Langue : français.
> **Périmètre** : design du moteur de templates (APT + Docker + scripts custom) et des contrats de données associés, prêt à passer en `writing-plans`.
> **Statut** : design figé proposé, à valider contre `validation_tache2.md` (gate obligatoire avant tout code).
---
## 1. Objet de la mission
Concevoir — sans coder — le **moteur de templates de mise à jour complet** et les **contrats JSON** associés, couvrant cinq axes :
- **Axe A** — Templates APT complets et OS-aware (update/analyse, upgrade, full/dist-upgrade, clean, autoremove, reboot-check, reboot vérifié), profils OS + type machine, proxy apt-cacher-ng.
- **Axe B** — Capture des mises à jour *prévues* (snapshot) et *appliquées* (diff réel avant/après), consommables par Hermes via déduplication + réduction déterministe.
- **Axe C** — Taxonomie des erreurs APT/dpkg/Docker + stratégie de remédiation.
- **Axe D** — Docker Compose : scan, inspect, pull-check, apply, prune, down, par SSH, avec racines déclarées + détection labels.
- **Axe E** — Scripts personnalisés (post-install, installation de paquets) avec garde-fous, manifestes et champs dynamiques.
La logique métier vit dans des **templates shell versionnés sur disque** (esprit `nas-ops`), rendus en Mustache et poussés en SSH (`server/ssh/client.ts`). Le backend orchestre, parse en **JSON canonique**, archive logs + rapports. Hermes analyse les JSON réduits, n'exécute jamais de SSH, ne reçoit jamais de secret.
---
## 2. Cartographie des livrables
| Fichier | Contenu | Axe / Livrable §4 |
|---|---|---|
| `00-synthese.md` (ce fichier) | Vue d'ensemble, décisions clés, couverture du gate | tous |
| `10-templates-apt.md` | Inventaire + pseudo-shell des templates APT, sémantique, marqueurs | A, §4.1, §4.2 |
| `20-docker.md` | Inventaire + pseudo-shell Docker Compose, flux, sécurité prune/down | D, §4.1, §4.2 |
| `30-scripts-custom.md` | Modèle des profils post-install, manifestes, champs dynamiques, garde-fous | E, §4.1, §4.7 |
| `40-contrats-json.md` | Schémas JSON canoniques étendus + types TS rétro-compatibles + déduplication/réduction | B, §4.3 |
| `50-erreurs.md` | Taxonomie des erreurs APT/dpkg/Docker/réseau + codes + remédiation | C, §4.4 |
| `60-profils-os-machine.md` | Modèle profils OS + type machine + overrides + proxy APT + détection | A, §4.5, §4.6 |
| `70-securite.md` | Frontière Hermes/MCP, actions destructives, validations, surface MCP | §4.8 |
| `80-sous-jalons.md` | Découpage en sous-jalons priorisé, prêt pour `writing-plans` | §4.9 |
| `90-questions-investigation.md` | Les 8 questions §3 tranchées (MVP / alternatives / risques) | §3 |
| `99-couverture-gate.md` | Auto-évaluation case par case de `validation_tache2.md` | gate |
---
## 3. Décisions structurantes (résumé)
Détail et justifications dans `90-questions-investigation.md`. Synthèse :
1. **Parsing : hybride, parsing-TS dominant (MVP).** On conserve l'approche actuelle (marqueurs `===SU:XXX===` + parsing TS dans `server/services/`), enrichie par des **données structurées en TSV/clé=valeur produites côté shell** (ex. `dpkg-query -W -f=...`, `docker ... --format json`) là où le format est déjà stable et documenté. Pas de génération de gros JSON imbriqué dans le shell. Rétro-compatible avec le jalon 1.
2. **Profils OS = fichiers de templates par profil + héritage par convention de dossier.** Arborescence `templates/<famille>/<commande>.sh.tpl` avec un profil `base` et des overrides par OS résolus par ordre de priorité. Le moteur de rendu choisit le template le plus spécifique disponible (fallback vers `base`).
3. **Type machine = choix manuel à l'ajout + action `machine_probe` de correction.** L'opérateur choisit `os_family` et `machine_kind` au formulaire ; une sonde non destructive propose des corrections (jamais appliquées automatiquement sans validation).
4. **Diff avant/après = snapshot dpkg autour de chaque action APT réelle.** `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'` avant et après ; le backend calcule le diff. L'exit code APT ne suffit jamais à déclarer un succès.
5. **Extensions de `shared/types.ts` : tout en champs optionnels.** Élargissement des unions (`OsFamily`, `ActionType`, `AptProxyMode`), ajout de blocs optionnels `apt` (détaillé), `docker`, `custom`/`postInstall`, `reboot`, `errors` sur `UpdateSnapshot` / `ExecutionResult`. Un snapshot/exécution du jalon 1 reste strictement valide.
6. **Opérations longues : `nohup` + fichier exit-code généralisé pour les actions applicatives longues, mais pas pour le refresh.** Reboot vérifié = mécanisme dédié (boot_id avant/après, reconnexion, délai adaptatif). Le refresh/analyse reste synchrone et court.
7. **Sécurité prune/down/scripts : barrière de validation côté webapp + `action_requests`.** Hermes propose, ne déclenche jamais. Secrets jamais lus ni renvoyés (registry creds, sudo, tokens). Erreurs nettoyées avant UI/MCP.
8. **Surface MCP minimale, en lecture + déclenchement d'actions déjà autorisées.** Réutilise les outils v1 du rapport (`list_machines`, `get_machine_snapshot`, `get_machine_execution`, `list_templates`, `preview_template`, `run_refresh`, `run_action`, `search_reports`) ; aucune nouvelle primitive d'exécution SSH exposée.
---
## 4. Principes invariants respectés
- **Convention templates** : tous les templates émettent des marqueurs `===SU:XXX===` ; `LC_ALL=C` ; `DEBIAN_FRONTEND=noninteractive` pour APT ; exécution sous `sudo -S` via `runScriptSudo` (base64) ; marqueur de sortie `===SU:EXIT=N===`.
- **Réduction déterministe avant LLM** : le réducteur (`aptReduce.ts`, à étendre en `reduceLines.ts`) ne garde que les lignes utiles APT et Docker ; le log brut complet est archivé séparément (`raw_artifacts` / `rawLogPath`).
- **Déduplication** : APT par `os_family + package + from + to + origin` ; Docker par `image + fromDigest + toDigest` (fallback `image + fromImageId + toImageId`).
- **Templates versionnés sur disque** : éditables depuis le front mais sauvegardés comme ressources de projet (revues Git), versionnés via `install_recipe_versions` pour les scripts custom.
- **Backend orchestre, shell porte la logique métier, JSON canonique = langage commun** frontend / MCP / Hermes.
- **Réutilisation de l'existant** : pas de nouveau mécanisme d'exécution SSH ; on s'appuie sur `runScriptSudo` / `runPlain` et sur la table `executions` + WebSocket terminal.
---
## 5. Alignement avec `tache1.9.md` (schéma BDD cible)
Les contrats JSON et tables dérivées de ce design se rangent dans les tables prévues :
- Snapshot APT/Docker → `snapshots(kind, payload_json, important_json)` + tables dérivées `apt_planned_packages`, `docker_compose_stacks`, `docker_stack_services`.
- Résultats d'exécution → `executions(result_json, important_json)` + `apt_applied_packages`, `docker_image_events`, `apt_errors`.
- Scripts custom → `install_profiles`, `install_recipes`, `install_recipe_versions`, `machine_profile_state`, `script_variables_presets`.
- Messages importants extraits → `important_messages`.
- Config Docker par machine → `docker_settings`, `docker_compose_roots`.
- Profils OS / type machine → colonnes `machines.os_family / machine_kind / virtualization / hardware_profile` + `machine_hardware` (sonde).
Aucune table nouvelle n'est requise par la tâche 2 ; le design réutilise la cible `tache1.9.md`.
---
## 6. Ce qui reste hors périmètre (suggestions, pas exécuté)
- Catalogue détaillé des scripts post-install → renvoyé à **tâche 4** (ce design pose le mécanisme moteur + les manifestes attendus).
- API/jobs/route d'action, file de jobs persistante → **tâche 5**.
- Affichage UI fin des snapshots/actions → **tâche 3**.
- Skill Hermes et analyse → **tâche 6**.
- Politique de rétention/purge des logs → **tâche 7**.
- Découverte réseau de machines → hors tâche 2.
Ces points sont mentionnés comme contexte d'emboîtement mais ne sont pas conçus en détail ici.
+202
View File
@@ -0,0 +1,202 @@
# 10 — Templates APT : inventaire, sémantique et pseudo-shell
> Axe A + livrables §4.1 et §4.2. Cohérent avec `templates/apt/check.sh.tpl`, `full-upgrade.sh.tpl`, `reboot.sh.tpl` existants, la convention `===SU:XXX===`, `LC_ALL=C`, `DEBIAN_FRONTEND=noninteractive`, exécution sous `sudo -S` (`server/ssh/client.ts`).
---
## 1. Sémantique APT clarifiée (manpage `apt-get`)
| Commande | Effet | Peut supprimer ? | Peut installer du nouveau ? |
|---|---|---|---|
| `apt-get update` | Resynchronise les index de paquets. Ne modifie aucun paquet installé. | non | non |
| `apt-get -s upgrade` | **Simulation** : installe les nouvelles versions des paquets installés **sans jamais supprimer** ni installer de nouveaux paquets. Les paquets dont l'upgrade exigerait une suppression/installation restent *held back*. | non | non |
| `apt-get -s dist-upgrade` / `full-upgrade` | **Simulation** : gère intelligemment les changements de dépendances, peut **installer de nouveaux paquets et en supprimer** pour satisfaire les dépendances. | oui | oui |
| `apt-get autoremove` | Retire les dépendances automatiquement installées et devenues inutiles. | oui | non |
| `apt-get clean` | Vide le cache local `/var/cache/apt/archives`. N'affecte pas l'état des paquets. | non | non |
> `apt full-upgrade` (commande `apt`) ≡ `apt-get dist-upgrade` (commande `apt-get`). **On utilise toujours `apt-get` en script** (non interactif, stable), jamais `apt` (UI humaine). L'UI parle d'« full-upgrade » comme alias convivial ; la commande système est `apt-get dist-upgrade`.
Lignes documentées parsées : `Inst <pkg> [<cur>] (<target> <origin> [<arch>])`, `Conf <pkg>`, `Remv <pkg>`. Le log brut complet reste archivé ; seules ces lignes + `E:`/`W:`/`dpkg:`/`reboot-required` alimentent Hermes.
Sources : `apt-get` https://manpages.debian.org/apt-get · `dpkg` https://manpages.debian.org/dpkg · `dpkg-query` https://manpages.debian.org/dpkg-query · `apt-listchanges` https://manpages.debian.org/bookworm/apt-listchanges/apt-listchanges.1.en.html · `needrestart` https://manpages.debian.org/bookworm/needrestart/needrestart.1.en.html
---
## 2. Inventaire des templates APT
| Template | Action (`ActionType`) | Rôle | Type | OS ciblés | Destructif ? | Marqueurs |
|---|---|---|---|---|---|---|
| `apt/update-analyze.sh.tpl` | `apt_update_analyze` | Refresh index + simulation `upgrade` et `dist-upgrade` + reboot-check. **Tâche de fond, non destructif.** Remplace/étend l'actuel `check.sh.tpl`. | snapshot | tous | non | `===SU:APT_UPDATE===`, `===SU:APT_SIM_UPGRADE===`, `===SU:APT_SIM_DISTUPGRADE===`, `===SU:APT_HELD===`, `===SU:REBOOT===`, `===SU:EXIT=N===` |
| `apt/upgrade.sh.tpl` | `apt_upgrade` | Applique l'upgrade simple (sans suppression volontaire). Snapshot dpkg avant/après. | action | tous | oui (modif paquets) | `===SU:DPKG_BEFORE===`, `===SU:APT_UPGRADE===`, `===SU:DPKG_AFTER===`, `===SU:REBOOT===`, `===SU:EXIT=N===` |
| `apt/full-upgrade.sh.tpl` | `apt_full_upgrade` | Applique `apt-get dist-upgrade`. **Conserve l'existant (jalon 1) en l'enrichissant du diff dpkg.** | action | tous | oui (peut supprimer) | idem upgrade + `===SU:APT_FULLUPGRADE===` |
| `apt/autoremove.sh.tpl` | `apt_autoremove` | Retire les dépendances inutiles, avec simulation préalable affichée. | action | tous | oui (supprime) | `===SU:APT_SIM_AUTOREMOVE===`, `===SU:DPKG_BEFORE===`, `===SU:APT_AUTOREMOVE===`, `===SU:DPKG_AFTER===`, `===SU:EXIT=N===` |
| `apt/clean.sh.tpl` | `apt_clean` | Vide le cache des paquets téléchargés. Action séparée, peu risquée. | action | tous | non (cache only) | `===SU:APT_CLEAN===`, `===SU:EXIT=N===` |
| `apt/reboot-check.sh.tpl` | (intégré au refresh) | Vérifie `/run/reboot-required`, `/var/run/reboot-required`, liste `reboot-required.pkgs`, état `needrestart -b`. | snapshot | tous | non | `===SU:REBOOT===` |
| `apt/reboot.sh.tpl` | `reboot_verified` | Lit `boot_id` avant, planifie le reboot, émet un marqueur parsable avant coupure SSH. Conserve l'existant en ajoutant `boot_id`. | action | tous | oui (reboot) | `===SU:BOOT_ID_BEFORE===`, `===SU:REBOOT_NOW===` |
> **Compatibilité jalon 1** : `apt_full_upgrade` et `reboot` restent des actions valides. `check.sh.tpl` n'est pas supprimé ; `update-analyze.sh.tpl` est son successeur enrichi (le refresh peut basculer dessus sans casser le parsing existant — voir §6 ci-dessous).
---
## 3. Variables de rendu (Mustache)
Étend `TemplateVars` (extension proposée, voir `40-contrats-json.md`). Variables utilisées par les templates APT :
```text
aptProxy string|null proxy apt-cacher-ng injecté à l'exécution (mode runtime)
osProfile string debian | ubuntu | proxmox | raspbian
machineKind string physical | vm | proxmox_host | lxc | raspberry_pi | workstation
confValues bool true => --force-confdef --force-confold (défaut)
inactivityTimeout int secondes; 0 = désactivé (défaut 600)
```
Les sections Mustache `{{#aptProxy}}…{{/aptProxy}}` restent identiques à l'existant.
---
## 4. Pseudo-shell des templates clés
### 4.1 `apt/update-analyze.sh.tpl` (refresh + analyse, non destructif)
```sh
#!/bin/sh
# Refresh index + simulations upgrade/dist-upgrade + reboot-check.
# Exécuté entier sous sudo par la couche SSH. Non destructif.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:APT_UPDATE==="
apt-get update -qq 2>&1
UPD=$?
echo "===SU:APT_SIM_UPGRADE==="
apt-get -s -y upgrade 2>&1
echo "===SU:APT_SIM_DISTUPGRADE==="
apt-get -s -y dist-upgrade 2>&1
echo "===SU:APT_HELD==="
# Paquets retenus (held back) : présents en dist-upgrade mais pas en upgrade.
apt-mark showhold 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
echo "REBOOT_REQUIRED=1"
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
else
echo "REBOOT_REQUIRED=0"
fi
# needrestart en mode batch/list si présent (jamais interactif).
command -v needrestart >/dev/null 2>&1 && needrestart -b 2>/dev/null | grep -E '^NEEDRESTART-(KSTA|SVC)' || true
echo "===SU:EXIT=${UPD}==="
```
Le backend parse :
- section `APT_SIM_UPGRADE` → liste `upgrade` (paquets `Inst`),
- section `APT_SIM_DISTUPGRADE` → liste `dist-upgrade` (inclut `Inst` nouveaux + `Remv` suppressions),
- `held` = `APT_HELD` (et/ou paquets présents en dist-upgrade absents d'upgrade),
- `REBOOT_REQUIRED` + `PKG=` → reboot requis + paquets concernés.
Statut snapshot APT : `ok` si rien ; `updates_available` si `Inst` non vides ; `warning` si dist-upgrade implique des `Remv` ou des `held` ; `error` si `APT_UPDATE` échoue (dépôt injoignable, clé GPG…).
### 4.2 `apt/full-upgrade.sh.tpl` (application, avec diff dpkg)
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_FULLUPGRADE==="
apt-get -y \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold \
dist-upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
Le backend compare `DPKG_BEFORE` et `DPKG_AFTER` (clé = `package+arch`) → `installed` / `upgraded` / `removed` / `unchanged`, versions finales réelles. **L'exit code seul ne suffit pas** : un paquet annoncé en simulation mais resté inchangé est signalé comme anomalie.
> Politique non interactive justifiée : `--force-confdef`+`--force-confold` conservent les fichiers de config locaux quand dpkg ne peut pas trancher, pour ne pas écraser une configuration distante. Les prompts (conffile, debconf, apt-listchanges, needrestart) sont traités comme **risques de blocage à détecter** (timeout d'inactivité), pas comme dialogues à exposer. Voir `50-erreurs.md` (`human_interaction_required`).
### 4.3 `apt/autoremove.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:APT_SIM_AUTOREMOVE==="
apt-get -s -y autoremove 2>&1 # prévisualisation des Remv
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_AUTOREMOVE==="
apt-get -y autoremove 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:EXIT=${CODE}==="
```
Confirmation UI explicite requise (action qui supprime des paquets).
### 4.4 `apt/clean.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:APT_CLEAN==="
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
apt-get clean 2>&1
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
echo "FREED_BYTES=$((BEFORE - AFTER))"
echo "===SU:EXIT=0==="
```
### 4.5 `apt/reboot.sh.tpl` (reboot vérifié — capture boot_id)
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
```
Le **backend** orchestre la vérification (hors template) : il a lu `boot_id` *avant*, attend la coupure SSH, retente la connexion (délai adaptatif), relit `boot_id` via `runPlain` (`cat /proc/sys/kernel/random/boot_id`). Reboot `ok` seulement si la machine revient ET `boot_id` a changé. Statuts d'échec : `reboot_command_failed`, `ssh_never_went_down`, `machine_did_not_return`, `boot_id_unchanged`, `timeout`. Champs résultat : `beforeBootId`, `afterBootId`, `requestedAt`, `sshWentDownAt`, `sshCameBackAt`, `waitedSeconds`, `status`, `errors`. Délai adaptatif par machine : `lastRebootDurationSeconds``nextRecommendedWaitSeconds` (avec marge). Voir `40-contrats-json.md` (`RebootResult`).
---
## 5. Spécificités par profil OS (détail dans `60-profils-os-machine.md`)
- **Debian** : avant de proposer firmware/drivers propriétaires, vérifier la présence des composants `contrib`, `non-free`, `non-free-firmware` (template `apt/check-components.sh.tpl`, lecture seule). Pas d'activation automatique.
- **Ubuntu** : `ubuntu-drivers devices` (lecture) pour proposer des drivers (NVIDIA/GPU) — proposition uniquement, jamais installé par défaut.
- **Proxmox** : profil dédié. Vérifier dépôts PVE (`pve-no-subscription` vs `enterprise`), meta-package `proxmox-ve`, kernel PVE, Ceph éventuel, puis `apt-get dist-upgrade`. Ne **jamais** traiter comme une Debian générique.
- **Raspberry Pi OS** : profil dédié. Attention firmware/kernel, **vérifier l'espace disque avant upgrade**, utiliser `full-upgrade`.
Le template le plus spécifique disponible sous `templates/<profil>/<commande>.sh.tpl` est choisi, sinon fallback `templates/apt/<commande>.sh.tpl` (profil `base`).
---
## 6. Compatibilité et migration (non-régression jalon 1)
- L'actuel `check.sh.tpl` produit `===SU:UPDATE===` / `===SU:SIMULATE===` / `===SU:REBOOT===` / `===SU:END===`. Le nouveau `update-analyze.sh.tpl` ajoute des sections **sans supprimer** la sémantique : le parsing actuel (`extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===")`) peut être conservé en parallèle pendant la migration.
- **Recommandation MVP** : introduire `update-analyze.sh.tpl` comme nouveau template et faire pointer le refresh dessus dans un sous-jalon dédié, en gardant `check.sh.tpl` jusqu'à bascule validée. Aucune rupture du flux prouvé en prod.
- `full-upgrade.sh.tpl` et `reboot.sh.tpl` existants restent fonctionnels ; on **ajoute** les sections `DPKG_BEFORE/AFTER` et `BOOT_ID_BEFORE` (extension, pas remplacement de marqueurs).
+211
View File
@@ -0,0 +1,211 @@
# 20 — Docker Compose : inventaire, flux et pseudo-shell
> Axe D + livrables §4.1/§4.2. Conçu pour cocher chaque case de `validation_tache2.md §6` (« Focus Docker Compose »). Gestion **par SSH sur la machine cible** via `server/ssh/client.ts` et templates versionnés ; pas de moteur parallèle.
---
## 1. Méthode retenue (MVP)
- **Gestion par SSH** sur la machine cible, réutilisant `runScriptSudo` / `runPlain` et la table `executions`, le WebSocket terminal, `rawLogPath`, `reportPath`, statut `ok|warning|error`. **Pas de second système de jobs Docker.**
- Variante `docker context` over SSH : **citée comme alternative opérateur**, pas le moteur MVP.
- **Découverte des stacks** depuis des **racines déclarées par machine** (`composeRoots`, ex. `/opt/stacks`, `/srv/docker`), scan limité en profondeur (`composeScanDepth`, défaut 4), puis validation UI.
- **Détection par labels Compose** (`com.docker.compose.project`, `com.docker.compose.service`, et si présent `com.docker.compose.project.working_dir`) = **complément** pour retrouver les stacks actifs, pas l'unique source de vérité.
- Cycle de vie d'un stack : `candidate` (juste détecté) → `enabled` (validé par l'utilisateur) → actions autorisées. `pull`, `up`, `down`, `prune` **uniquement sur un stack `enabled`/validé**.
Sources citées : `docker compose pull` https://docs.docker.com/reference/cli/docker/compose/pull/ · `up` https://docs.docker.com/reference/cli/docker/compose/up/ · `config` https://docs.docker.com/reference/cli/docker/compose/config/ · `ps` https://docs.docker.com/reference/cli/docker/compose/ps/ · `images` https://docs.docker.com/reference/cli/docker/compose/images/ · `down` https://docs.docker.com/reference/cli/docker/compose/down/ · `image inspect` https://docs.docker.com/reference/cli/docker/image/inspect/ · `image prune` https://docs.docker.com/reference/cli/docker/image/prune/
---
## 2. Inventaire des templates Docker
| Template | Action (`ActionType`) | Rôle | Effet disque | Destructif ? | Validation UI |
|---|---|---|---|---|---|
| `docker/scan-compose.sh.tpl` | `docker_scan` | Scanne les racines déclarées, trouve les fichiers compose, valide chaque candidat. **Passif.** | non | non | non |
| `docker/inspect-compose.sh.tpl` | `docker_inspect_current` | État actuel sans changement : config images, ps, images, inspect. **Passif.** | non | non | non |
| `docker/pull-check.sh.tpl` | `docker_pull_check` | `docker compose pull` (télécharge sans démarrer), compare ID/digest/labels avant-après. **Écrit sur le disque Docker** (pas un scan pur). | oui (cache images) | non (pas applicatif) | non (mais non passif) |
| `docker/apply-compose.sh.tpl` | `docker_compose_apply` | `docker compose up -d --remove-orphans` après validation. Recapture ps/images/inspect. | oui | oui (recrée conteneurs) | **oui, explicite** |
| `docker/prune-images.sh.tpl` | `docker_prune_images` | `docker image prune -f` (safe) ; mode agressif `-a -f --filter "until=168h"`. | oui | oui (agressif) | **oui pour agressif** |
| `docker/down-compose.sh.tpl` | `docker_compose_down` | `docker compose down`. **Action séparée et destructive**, hors chemin de mise à jour normal. | oui | oui | **oui, forte** |
> `--volumes` et `--rmi` sur `down` : **interdits au MVP** (ou protégés par une validation forte distincte). Le chemin de mise à jour normal n'utilise jamais `down` : `up -d` recrée les conteneurs quand l'image ou la config change, en préservant les volumes montés.
Tous les templates : `LC_ALL=C`, marqueurs `===SU:DOCKER_*===`, sortie parsable, log brut archivé.
---
## 3. Flux de mise à jour Docker (formalisé)
1. `docker_scan` — découverte des stacks candidats (racines déclarées + labels actifs).
2. `docker_inspect_current` — état actuel des services, conteneurs, images.
3. `docker_pull_check` — téléchargement des images candidates **sans démarrage de conteneurs**.
4. Comparaison déterministe — image ref, image ID, repo digest, labels OCI (`org.opencontainers.image.version`, `revision`, `source`, `created`) si présents.
5. Proposition UI/Hermes — liste des stacks/services avec update dispo, erreurs de pull, inconnues.
6. `docker_compose_apply` après validation utilisateur — `docker compose up -d --remove-orphans`.
7. Vérification après application — conteneurs recréés, état `running/exited`, health si dispo, erreurs.
8. `docker_prune_images` après succès ou action séparée — images supprimées, espace récupéré, erreurs.
**Points clés validés** :
- `docker compose pull` télécharge mais **ne démarre pas** les conteneurs → bon pré-check applicatif, pas un scan sans effet.
- `docker compose up -d` recrée les conteneurs quand l'image/config a changé, **en préservant les volumes**`down` inutile pour une MAJ normale.
- `docker image prune -f` = images *dangling* (sûr) ; `docker image prune -a` = **toutes** les images non référencées par un conteneur (destructif).
---
## 4. Pseudo-shell des templates
### 4.1 `docker/scan-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_SCAN==="
# {{composeRoots}} rendu en liste shell-safe par le backend (une racine par ligne).
ROOTS="{{composeRoots}}"
DEPTH="{{composeScanDepth}}"
for root in $ROOTS; do
[ -d "$root" ] || continue
find "$root" -maxdepth "$DEPTH" -type f \
\( -name 'compose.yaml' -o -name 'compose.yml' \
-o -name 'docker-compose.yaml' -o -name 'docker-compose.yml' \) \
-not -path '*/.git/*' -not -path '*/node_modules/*' \
-not -path '*/backup/*' -not -path '*/old/*' -not -path '*/archive/*' \
2>/dev/null | while IFS= read -r f; do
dir=$(dirname "$f")
# Valide le candidat ; n'applique rien.
if docker compose -f "$f" config --quiet >/dev/null 2>&1; then
echo "STACK_OK\tdir=$dir\tfile=$f"
else
echo "STACK_INVALID\tdir=$dir\tfile=$f"
fi
done
done
echo "===SU:DOCKER_LABELS==="
# Complément : stacks actifs détectés par labels.
docker ps --format '{{ "{{.ID}}" }}' 2>/dev/null | while read -r id; do
proj=$(docker inspect --format '{{ "{{index .Config.Labels \"com.docker.compose.project\"}}" }}' "$id" 2>/dev/null)
wd=$(docker inspect --format '{{ "{{index .Config.Labels \"com.docker.compose.project.working_dir\"}}" }}' "$id" 2>/dev/null)
[ -n "$proj" ] && echo "ACTIVE\tproject=$proj\tworking_dir=$wd"
done
echo "===SU:EXIT=0==="
```
> Note de rendu : les `{{ }}` Docker Go-template sont échappés ici pour ne pas être interprétés par Mustache (le moteur de rendu réel utilisera des délimiteurs Mustache personnalisés ou un échappement, à fixer en implémentation). Seules `composeRoots`/`composeScanDepth` sont des variables Mustache.
### 4.2 `docker/inspect-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "===SU:DOCKER_ERR===\ncompose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_CONFIG_IMAGES==="
docker compose config --images 2>&1
echo "===SU:DOCKER_PS==="
docker compose ps --format json 2>&1
echo "===SU:DOCKER_IMAGES==="
docker compose images --format json 2>&1
echo "===SU:DOCKER_INSPECT==="
# Pour chaque image utilisée : Id, RepoDigests, labels OCI.
docker compose config --images 2>/dev/null | while IFS= read -r img; do
docker image inspect "$img" \
--format '{{ "IMG\t{{.Id}}\t{{join .RepoDigests \",\"}}\t{{index .Config.Labels \"org.opencontainers.image.version\"}}\t{{index .Config.Labels \"org.opencontainers.image.source\"}}" }}' 2>/dev/null \
|| echo "IMG_MISSING\t$img"
done
echo "===SU:EXIT=0==="
```
### 4.3 `docker/pull-check.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_INSPECT_BEFORE==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
id=$(docker image inspect "$img" --format '{{ "{{.Id}}" }}' 2>/dev/null || echo "")
dg=$(docker image inspect "$img" --format '{{ "{{join .RepoDigests \",\"}}" }}' 2>/dev/null || echo "")
echo "BEFORE\t$img\t$id\t$dg"
done
echo "===SU:DOCKER_PULL==="
# Télécharge les images candidates SANS démarrer de conteneurs.
docker compose pull --policy always --ignore-buildable 2>&1
CODE=$?
echo "===SU:DOCKER_INSPECT_AFTER==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
id=$(docker image inspect "$img" --format '{{ "{{.Id}}" }}' 2>/dev/null || echo "")
dg=$(docker image inspect "$img" --format '{{ "{{join .RepoDigests \",\"}}" }}' 2>/dev/null || echo "")
ver=$(docker image inspect "$img" --format '{{ "{{index .Config.Labels \"org.opencontainers.image.version\"}}" }}' 2>/dev/null || echo "")
echo "AFTER\t$img\t$id\t$dg\t$ver"
done
echo "===SU:EXIT=${CODE}==="
```
Backend : compare `BEFORE`/`AFTER` par `image ref`. Si `id`/`digest` change → `updates_available`. Erreurs `pull_failed`/`registry_auth_failed` nettoyées (jamais d'URL/token sensible vers UI/MCP).
### 4.4 `docker/apply-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_APPLY==="
docker compose up -d --remove-orphans 2>&1
CODE=$?
echo "===SU:DOCKER_PS_AFTER==="
docker compose ps --format json 2>&1
echo "===SU:DOCKER_INSPECT_AFTER==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
docker image inspect "$img" --format '{{ "IMG\t{{.Id}}\t{{join .RepoDigests \",\"}}" }}' 2>/dev/null || echo "IMG_MISSING\t$img"
done
echo "===SU:EXIT=${CODE}==="
```
### 4.5 `docker/prune-images.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_PRUNE==="
{{#aggressive}}
# Mode agressif : nécessite validation UI explicite distincte.
docker image prune -a -f --filter "until=168h" 2>&1
{{/aggressive}}
{{^aggressive}}
# Mode sûr par défaut : dangling images uniquement.
docker image prune -f 2>&1
{{/aggressive}}
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
Le backend parse `Total reclaimed space` et `deleted` pour `bytesReclaimed` et la liste d'images supprimées.
### 4.6 `docker/down-compose.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
cd "{{stackDir}}" || { echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_DOWN==="
# --volumes et --rmi INTERDITS au MVP. down simple uniquement.
docker compose down 2>&1
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
---
## 5. Réduction Hermes (Docker)
Seules ces lignes (+ le JSON canonique) sont transmises : `Pulling`, `Digest`, `Status`, `Downloaded newer image`, `Recreating`, `Started`, `Error`, `deleted`, `Total reclaimed space`. Le log brut complet reste archivé (`raw_artifacts` / `rawLogPath`).
---
## 6. Insertion dans la webapp existante
- **Config machine** : nouveaux champs `dockerEnabled`, `composeRoots[]`, `composeScanDepth`, `composeStacks[]`**optionnels** ou dans un endpoint dédié ; `MachineView` n'est pas cassé (les champs sont ajoutés en option). Stockés dans `docker_settings` / `docker_compose_roots` / `docker_compose_stacks` (`tache1.9.md`).
- **Refresh/snapshot** : le refresh machine peut produire un snapshot combiné `apt` + `docker`, **ou** un refresh Docker séparé pour éviter de lancer `docker_pull_check` automatiquement (recommandé : pull-check séparé car non-passif).
- **Actions** : extension progressive de `ActionType` (`docker_scan`, `docker_pull_check`, `docker_compose_apply`, `docker_prune_images`, `docker_compose_down`) avec filtrage d'autorisation conservé sur `POST /api/machines/:id/actions`.
- **Executions/rapports** : réutilisation de la table `executions`, du WebSocket terminal, de `rawLogPath`/`reportPath`/statut. Pas de second système.
- **UI machine** : compteur Docker séparé du compteur APT (ex. stacks avec updates) ; vue détail par stack/service ; boutons d'action validés (`Pull/check`, `Appliquer`, `Prune`, `Down`).
- **Validation utilisateur** : `docker_compose_apply`, `docker_prune_images` agressif et `docker_compose_down` passent par une confirmation UI explicite (via `action_requests`). Hermes propose, ne déclenche jamais.
- **Secrets** : credentials registry (`~/.docker/config.json`, helpers, tokens) **jamais lus ni renvoyés** ; erreurs nettoyées si elles exposent une URL sensible (voir `70-securite.md`).
+171
View File
@@ -0,0 +1,171 @@
# 30 — Scripts personnalisés / post-install : modèle moteur
> Axe E + livrables §4.1/§4.7. Conçu pour cocher `validation_tache2.md §8` (« scripts post-install et profils personnalisés »). **Le catalogue détaillé est renvoyé à la tâche 4** ; ce document pose le **mécanisme moteur**, les **manifestes**, les **champs dynamiques** et les **garde-fous**.
---
## 1. Principe UX et absence d'interactivité SSH
- **Interdiction stricte des questions interactives au milieu d'un script SSH.** Toute question nécessaire devient un **champ de formulaire** dans la webapp avant exécution.
- Les profils post-install sont **cochables** ; cocher un profil déplie ses champs obligatoires.
- Chaque profil fournit un **manifeste** : `id`, `label`, `description`, `fields`, valeurs par défaut, validations, prévisualisation, `risk`, `requiresConfirmation`.
- Le bouton d'exécution reste **désactivé** tant que les champs requis des profils cochés ne sont pas valides.
- La webapp propose une **preview du template rendu** avant exécution (`preview_template`), avec **masquage des secrets** et signalement des changements réseau/reboot.
- Les scripts s'exécutent en **mode non interactif** ; s'ils détectent une décision non fournie, ils **échouent avec une erreur structurée** au lieu de bloquer.
Stockage : `install_profiles` (catalogue + `manifest_json`), `install_recipes`/`install_recipe_versions` (scripts versionnés + sha256), `machine_profile_state` (état par machine, variables **non sensibles**), `script_variables_presets` (préréglages réutilisables). Cf. `tache1.9.md §9`.
---
## 2. Profils post-install attendus (composables)
| Profil | Rôle | Risque | Confirmation |
|---|---|---|---|
| `bootstrap_root` | Première prépa après DHCP/`su -` : installe `sudo`, `resolvconf`, `ca-certificates`, `curl` ; ajoute l'opérateur au groupe `sudo` ; vérifie `sudo`. | low | non |
| `identity_network` | Hostname, domaine/search `.home`, `/etc/hosts`, IP statique dans `/etc/network/interfaces`. | network_change | **oui** |
| `base_tools` | Outils de base **sans vim** : `nano`, `less`, `bash-completion`, `tmux`, `screen`, `htop`, `iotop`, `ncdu`, `tree`, `rsync`, `unzip`, `zip`, `tar`. | low | non |
| `network_tools` | `iproute2`, `iputils-ping`, `dnsutils`, `traceroute`, `net-tools`(opt), `tcpdump`, `nmap`, `mtr-tiny`, `lsof`, `netcat-openbsd`. | low | non |
| `dev_git` | `git`, `curl`, `wget`, `jq`, `yq`, `gnupg`, `lsb-release` ; `build-essential` optionnel. | low | non |
| `sharing` | `samba`, `nfs-kernel-server`, `avahi-daemon`, `libnss-mdns`, configurables séparément. | medium | oui |
| `docker_official` | Docker Engine depuis le dépôt officiel Debian : `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin` ; ajout user au groupe `docker` ; dossier Compose dans le home ; reboot/reconnexion si nécessaire. | medium | oui |
| `vm_guest_tools` | `qemu-guest-agent` ou `open-vm-tools` selon hyperviseur choisi. | low | non |
| Optionnels (non installés par défaut) | `security_basic`, `backup_tools`, `monitoring`, `mail_notify`, `time_sync`, `storage_tools`. | variable | selon profil |
> Les scripts hardware/drivers/benchmark ne sont **jamais installés par défaut** et exigent validation (voir `60-profils-os-machine.md`).
---
## 3. Champs dynamiques générés
- **`identity_network`** : `newHostname`, `domain`/`search`, `interfaceName`, `staticAddress` (CIDR, ex. `10.0.x.y/22`), `gateway` (défaut `10.0.0.1`), `dnsNameservers` (défaut `10.0.0.1`, `10.0.0.10`), `reconnectHost`.
- **`docker_official`** : `dockerUser` (ex. `gilles`), `dockerHomeDir`/`composeRoot` (ex. `/home/gilles/docker`), `installComposePlugin`, `rebootAfterInstall`.
- **`sharing`** : choix séparé Samba/NFS/mDNS, noms de partages, chemins autorisés, utilisateurs/groupes si nécessaire.
- **`vm_guest_tools`** : type d'hyperviseur / paquet cible.
Les champs peuvent être **préremplis** depuis la machine (`machine.name`, IP DHCP, interface primaire détectée par `machine_probe`, utilisateur SSH), mais restent **modifiables** avant validation.
### Exemple de manifeste (attendu dans la spec)
```json
{
"id": "identity_network",
"label": "Hostname + IP statique",
"requiresConfirmation": true,
"risk": "network_change",
"fields": [
{ "name": "newHostname", "type": "hostname", "required": true },
{ "name": "domain", "type": "string", "required": true, "default": "home" },
{ "name": "interfaceName", "type": "select", "required": true, "defaultFrom": "detected.primaryInterface" },
{ "name": "staticAddress", "type": "ipv4_cidr", "required": true },
{ "name": "gateway", "type": "ipv4", "required": true, "default": "10.0.0.1" },
{ "name": "dnsNameservers", "type": "ipv4_list", "required": true, "default": ["10.0.0.1", "10.0.0.10"] },
{ "name": "reconnectHost", "type": "ipv4", "required": true, "defaultFrom": "staticAddress.ip" }
]
}
```
Types de champ proposés : `string`, `hostname`, `ipv4`, `ipv4_cidr`, `ipv4_list`, `select`, `bool`, `int`, `path`, `secret` (jamais sérialisé en clair, jamais envoyé à Hermes/MCP). `defaultFrom` référence une valeur détectée par `machine_probe`.
---
## 4. Templates custom attendus (pseudo-shell)
Tous : `LC_ALL=C`, `DEBIAN_FRONTEND=noninteractive`, marqueurs `===SU:CUSTOM_*===`, `===SU:EXIT=N===`, sortie parsable, log brut archivé. Échec contrôlé si décision manquante.
### 4.1 `custom/bootstrap-root.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_BOOTSTRAP==="
apt-get update -qq 2>&1
apt-get install -y sudo resolvconf ca-certificates curl 2>&1
CODE=$?
# Ajoute l'opérateur au groupe sudo (variable de formulaire, non secret).
usermod -aG sudo "{{operatorUser}}" 2>&1 || echo "WARN usermod"
# Vérifie sudo
su - "{{operatorUser}}" -c 'sudo -n true' 2>&1 && echo "SUDO_OK" || echo "SUDO_CHECK_PENDING"
echo "===SU:EXIT=${CODE}==="
```
### 4.2 `custom/identity-network.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:CUSTOM_IDENTITY==="
# Sauvegardes avant modification.
cp -a /etc/hosts "/etc/hosts.su.bak.$(date +%s)" 2>/dev/null
cp -a /etc/network/interfaces "/etc/network/interfaces.su.bak.$(date +%s)" 2>/dev/null
OLD_IP="{{dhcpEndpoint}}"
echo "OLD_ENDPOINT=${OLD_IP}"
hostnamectl set-hostname "{{newHostname}}" 2>&1 || echo "hostname_failed"
# Réécrit /etc/network/interfaces pour {{interfaceName}} en statique {{staticAddress}}.
# (rendu détaillé en tâche 4 ; échoue proprement si interface absente)
echo "NEW_ENDPOINT={{reconnectHost}}"
echo "RECONNECT_REQUIRED=1"
echo "===SU:EXIT=0==="
```
Le script **ne coupe jamais la connexion sans stratégie de reconnexion planifiée par la webapp**. Si reboot requis → mécanisme `reboot_verified`. Après application, la webapp vérifie la reconnexion sur la nouvelle IP/hostname et met à jour la machine si le retour est confirmé. Erreurs distinguées : `network_config_invalid`, `interface_not_found`, `dns_config_failed`, `reconnect_failed`, `hostname_failed`, `sudo_setup_failed`.
### 4.3 `custom/install-package-groups.sh.tpl`
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_PKGGROUPS==="
# {{packages}} rendu comme liste shell-safe par le backend (jamais de vim par défaut).
apt-get update -qq 2>&1
apt-get install -y {{packages}} 2>&1
CODE=$?
echo "===SU:EXIT=${CODE}==="
```
### 4.4 `custom/docker-official-debian.sh.tpl`
Suit la doc officielle Docker Debian (https://docs.docker.com/engine/install/debian/, https://docs.docker.com/compose/install/linux/) : clé GPG dans `/etc/apt/keyrings`, `docker.sources`, puis paquets.
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:CUSTOM_DOCKER==="
apt-get update -qq 2>&1
apt-get install -y ca-certificates curl 2>&1
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc 2>&1
chmod a+r /etc/apt/keyrings/docker.asc
# docker.sources écrit selon codename détecté (non secret).
apt-get update -qq 2>&1
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>&1
CODE=$?
usermod -aG docker "{{dockerUser}}" 2>&1 || echo "WARN docker group"
mkdir -p "{{composeRoot}}" 2>&1
echo "DOCKER_GROUP_RELOGIN_REQUIRED=1"
{{#rebootAfterInstall}}echo "REBOOT_REQUESTED=1"{{/rebootAfterInstall}}
echo "===SU:EXIT=${CODE}==="
```
### 4.5 `custom/sharing.sh.tpl` et `4.6 custom/vm-guest-tools.sh.tpl`
Installent Samba/NFS/mDNS selon les choix (sans config dangereuse par défaut) / l'agent invité choisi. Mêmes conventions de marqueurs et d'échec contrôlé. Détail des configs renvoyé à la tâche 4.
Sources citées : Docker Debian https://docs.docker.com/engine/install/debian/ · Compose plugin https://docs.docker.com/compose/install/linux/ · Debian network https://wiki.debian.org/NetworkConfiguration · Debian Handbook https://www.debian.org/doc/manuals/debian-handbook/sect.network-config · resolvconf https://packages.debian.org/stable/net/resolvconf
---
## 5. JSON canonique post-install
`ExecutionResult` reçoit un bloc optionnel `postInstall` (voir `40-contrats-json.md`) listant : profils exécutés, variables **non sensibles** utilisées, fichiers modifiés, paquets installés, services activés/démarrés, reboots demandés, erreurs. Secrets/tokens **jamais** inclus (variables sérialisées, logs UI, rapports, MCP). Les changements réseau/Docker sont marqués dans le rapport Markdown avec les prochaines actions attendues (reconnexion, logout/login groupe Docker, reboot).
---
## 6. Insertion dans la webapp existante
- Même mécanique que les autres actions : templates versionnés, preview, exécution SSH (`runScriptSudo`), WebSocket terminal, `executions`, rapport Markdown, log brut.
- Valeurs réutilisables conservées dans `script_variables_presets` (scope `global`/`machine`/`profile`) ; état par machine dans `machine_profile_state`. Le provisioning peut être un assistant ponctuel ou stocké par machine.
- Hermes peut proposer des profils ou expliquer un échec, mais ne reçoit que le JSON réduit et **ne déclenche jamais** les actions à risque sans validation webapp.
- Découpage en sous-jalons indépendants : bootstrap/sudo, identité+réseau, paquets de base, Docker officiel, partage réseau, outils VM/monitoring (voir `80-sous-jalons.md`).
+311
View File
@@ -0,0 +1,311 @@
# 40 — Contrats JSON canoniques étendus + types TypeScript
> Axe B + livrable §4.3. Tranche la question §3.5 (extensions de `shared/types.ts`). **Tous les ajouts sont rétro-compatibles** : champs optionnels, unions élargies. Un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide.
---
## 1. Principe de rétro-compatibilité
État actuel (`shared/types.ts`) :
```ts
export type OsFamily = "debian" | "ubuntu" | "unknown";
export type AptProxyMode = "direct" | "runtime";
export type ActionType = "apt_full_upgrade" | "reboot";
// UpdateSnapshot.apt: { enabled, count, rebootRequired, packages: AptPackage[] }
// ExecutionResult: { ... action: ActionType, status, rebootRequiredAfterRun, importantLogLines, rawLogRef, reportRef }
```
Règles d'extension :
1. **Élargir les unions** (`OsFamily`, `AptProxyMode`, `ActionType`) — additif, aucun retrait.
2. **Ajouter des blocs optionnels** (`docker?`, `errors?`, `apt` détaillé optionnel, `reboot?`, `postInstall?`) — un payload sans ces blocs reste valide.
3. **Ne jamais retirer ni renommer** un champ existant. Le jalon 1 émet `apt: { enabled, count, rebootRequired, packages }` ; on **ajoute** des champs optionnels à côté.
4. Versionner via `schemaVersion?: number` (aligné `snapshots.schema_version` / `executions.schema_version` de `tache1.9.md`). Absence ⇒ version 1.
---
## 2. Extensions des unions
```ts
// Élargissement additif. Le jalon 1 ("debian"|"ubuntu"|"unknown") reste valide.
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
export type MachineKind =
| "physical" | "vm" | "proxmox_host" | "lxc"
| "raspberry_pi" | "workstation" | "unknown";
// "persistent" ajouté (écriture dans /etc/apt/apt.conf.d/).
export type AptProxyMode = "direct" | "runtime" | "persistent";
export type ActionType =
// jalon 1 (conservés tels quels)
| "apt_full_upgrade" | "reboot"
// APT
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
| "apt_autoremove" | "apt_clean" | "reboot_verified"
// Docker
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
// probe + custom
| "machine_probe" | "post_install";
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
export type ExecutionStatus = "ok" | "warning" | "error"; // inchangé
```
> `reboot` (jalon 1) et `reboot_verified` coexistent : `reboot_verified` ajoute la vérification boot_id ; le code jalon 1 continue d'émettre `reboot`.
---
## 3. Snapshot canonique étendu (`UpdateSnapshot`)
```ts
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
// Ajouts optionnels (rétro-compatibles) :
arch?: string;
operation?: "upgrade" | "install" | "remove" | "hold";
severityHint?: "normal" | "security";
}
export interface AptSnapshotDetail {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
// Ajouts optionnels :
status?: SnapshotStatus; // ok | updates_available | warning | error
upgradeCount?: number; // simulation `upgrade`
distUpgradeCount?: number; // simulation `dist-upgrade`
installed?: AptPackage[]; // nouveaux paquets (dist-upgrade)
removed?: AptPackage[]; // suppressions prévues (=> status warning)
held?: string[]; // paquets retenus (=> status warning)
rebootPkgs?: string[]; // depuis reboot-required.pkgs
}
export interface DockerSnapshotService {
serviceName: string;
image: string; // image ref (ex. jellyfin/jellyfin:latest)
currentImageId?: string | null;
currentDigest?: string | null;
candidateImageId?: string | null; // après pull-check
candidateDigest?: string | null;
currentVersion?: string | null; // label OCI org.opencontainers.image.version
candidateVersion?: string | null;
sourceUrl?: string | null; // label OCI source
status?: "up_to_date" | "updates_available" | "warning" | "error";
}
export interface DockerSnapshotStack {
name: string;
workingDir: string;
composeFiles: string[];
projectName?: string | null;
status: "candidate" | "enabled" | "ignored" | "error";
detectedBy?: "root_scan" | "label" | "manual";
services: DockerSnapshotService[];
}
export interface DockerSnapshot {
enabled: boolean;
installed: boolean;
count: number; // services avec update dispo
declaredRoots?: string[];
stacks: DockerSnapshotStack[];
status?: SnapshotStatus;
}
export interface SnapshotError {
source: "apt" | "docker" | "post_install" | "ssh" | "system";
kind: string; // voir 50-erreurs.md (taxonomie)
severity: "info" | "warning" | "error";
message: string; // nettoyé, jamais de secret
remediation?: string;
importantLines?: string[];
}
export interface UpdateSnapshot {
machineId: string;
hostname: string;
os: { family: OsFamily; version: string };
checkedAt: string; // ISO 8601
status: MachineStatus;
apt: AptSnapshotDetail; // bloc jalon 1 conservé, champs additifs optionnels
// Ajouts optionnels (rétro-compatibles) :
schemaVersion?: number;
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
machineKind?: MachineKind;
docker?: DockerSnapshot;
errors?: SnapshotError[];
rawHints?: { logImportantLines: string[] };
}
```
> Le bloc `apt` reste **requis** (présent au jalon 1) ; seuls ses champs *additifs* sont optionnels. `docker`, `errors`, `machineKind`, `kind`, `schemaVersion` sont **optionnels** → un snapshot jalon 1 (sans eux) reste valide.
### Bloc Docker minimal exigé par la validation (couverture §6)
Le bloc snapshot Docker contient au minimum : stacks déclarés (`declaredRoots`), stacks candidats (`stacks[].status="candidate"`), services (`stacks[].services[]`), image ref actuelle (`image`), image ID actuelle (`currentImageId`), digest actuel si dispo (`currentDigest`), labels de version si dispo (`currentVersion`), image ID/digest candidat après pull (`candidateImageId`/`candidateDigest`), statut `up_to_date|updates_available|warning|error`. ✔
---
## 4. Résultat d'exécution étendu (`ExecutionResult`)
```ts
export interface AptChange {
name: string;
arch?: string;
fromVersion: string | null;
toVersion: string | null;
operation: "upgraded" | "installed" | "removed" | "unchanged";
origin?: string | null;
}
export interface AptExecutionResult {
planned: AptPackage[]; // ce qui était prévu (simulation pré-action)
applied: AptChange[]; // diff dpkg réel before/after
installed: AptChange[];
removed: AptChange[];
held: string[];
errors?: SnapshotError[];
rebootRequiredAfterRun: boolean;
}
export interface DockerImageChange {
stack: string;
serviceName?: string;
imageRef?: string;
fromImageId?: string | null;
toImageId?: string | null;
fromDigest?: string | null;
toDigest?: string | null;
operation: "pulled" | "recreated" | "pruned";
}
export interface DockerExecutionResult {
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
errors?: SnapshotError[];
}
export interface RebootResult {
beforeBootId: string | null;
afterBootId: string | null;
requestedAt: string;
sshWentDownAt: string | null;
sshCameBackAt: string | null;
waitedSeconds: number;
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
lastRebootDurationSeconds?: number;
nextRecommendedWaitSeconds?: number;
errors?: SnapshotError[];
}
export interface PostInstallResult {
profilesRun: string[];
variablesUsed: Record<string, string | number | boolean>; // NON sensible uniquement
filesModified: string[];
packagesInstalled: string[];
servicesEnabled: string[];
rebootsRequested: boolean;
networkChange?: {
oldEndpoint: string | null;
newEndpoint: string | null;
reconnectHost: string | null;
};
errors?: SnapshotError[];
}
export interface ExecutionResult {
executionId: string;
machineId: string;
startedAt: string;
finishedAt: string;
mode: "manual" | "scheduled" | "hermes_requested"; // élargi (jalon 1 = "manual")
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
// Ajouts optionnels (rétro-compatibles) :
schemaVersion?: number;
apt?: AptExecutionResult;
docker?: DockerExecutionResult;
reboot?: RebootResult;
postInstall?: PostInstallResult;
errors?: SnapshotError[];
}
```
> `mode` était `"manual"` (littéral) au jalon 1. L'élargir en union `"manual" | "scheduled" | "hermes_requested"` reste compatible (le jalon 1 émet toujours `"manual"`). Tous les nouveaux blocs (`apt`, `docker`, `reboot`, `postInstall`, `errors`) sont optionnels → une exécution jalon 1 reste valide.
---
## 5. Extension de `TemplateVars` (rendu Mustache)
```ts
export interface TemplateVars {
aptProxy?: string | null; // existant
// Ajouts (tous optionnels) :
osProfile?: OsFamily;
machineKind?: MachineKind;
confValues?: boolean;
inactivityTimeout?: number;
// Docker :
composeRoots?: string; // liste rendue shell-safe par le backend
composeScanDepth?: number;
stackDir?: string;
aggressive?: boolean; // prune agressif
// Custom :
operatorUser?: string;
packages?: string; // liste shell-safe
newHostname?: string;
interfaceName?: string;
staticAddress?: string;
reconnectHost?: string;
dockerUser?: string;
composeRoot?: string;
rebootAfterInstall?: boolean;
// ... champs de profil custom (typés au cas par cas en tâche 4)
}
```
---
## 6. Déduplication (empreinte fonctionnelle)
- **APT** : `dedupKey = os_family + "|" + package + "|" + from + "|" + to + "|" + origin`. Permet à Hermes de mutualiser une même mise à jour vue sur plusieurs machines (une seule recherche web, un seul résumé). Stocké dans `apt_planned_packages.dedup_key` / `apt_applied_packages.dedup_key`.
- **Docker** : `dedupKey = image + "|" + fromDigest + "|" + toDigest` ; fallback `image + "|" + fromImageId + "|" + toImageId` quand le digest manque.
Le calcul de `dedupKey` se fait **côté backend TS** (déterministe), pas dans le shell.
---
## 7. Réduction déterministe avant Hermes/MCP
Le réducteur actuel (`server/templates/aptReduce.ts`) garde les lignes : `Inst `, `Conf `, `Remv `, `Err `, `E:`, `W:`, `dpkg:`, `reboot-required`/`REBOOT_REQUIRED`. **Extension proposée** (renommage suggéré `reduceLines.ts`, additif, sans casser `reduceAptLines`) ajoutant les préfixes Docker : `Pulling`, `Digest`, `Status`, `Downloaded newer image`, `Recreating`, `Started`, `Error`, `deleted`, `Total reclaimed space`.
Ce que Hermes reçoit : **JSON canonique réduit** (`important_json`) + lignes importantes (`importantLogLines`). **Jamais** le log brut complet (archivé dans `raw_artifacts`/`rawLogPath`), jamais de secret.
---
## 8. Mapping vers `tache1.9.md` (tables dérivées)
| Bloc JSON | Table dérivée | Colonnes clés |
|---|---|---|
| `apt.packages` / `installed` / `removed` / `held` (simulation) | `apt_planned_packages` | `mode`, `operation`, `current_version`, `target_version`, `origin`, `dedup_key` |
| `apt.applied` (diff dpkg) | `apt_applied_packages` | `from_version`, `to_version`, `operation`, `dedup_key` |
| `errors[]` source apt | `apt_errors` | `kind`, `severity`, `message`, `important_lines_json`, `remediation` |
| `docker.stacks[]` | `docker_compose_stacks` + `docker_stack_services` | `status`, `detected_by`, `current_image_id`, `candidate_digest`, … |
| `docker.pull/up/prune changes` | `docker_image_events` | `from_image_id`, `to_image_id`, `operation`, `bytes_reclaimed` |
| lignes importantes/notices | `important_messages` | `source`, `category`, `package_name`, `message` |
| payload complet snapshot | `snapshots.payload_json` + `important_json` | `kind`, `schema_version`, `status` |
| payload complet exécution | `executions.result_json` + `important_json` | `error_kind`, `error_message`, `exit_code` |
Le JSON complet reste la vérité canonique (archivé) ; les tables dérivées servent recherche/filtres/dédup/badges (conforme à la règle structurante `tache1.9.md §2`).
+77
View File
@@ -0,0 +1,77 @@
# 50 — Taxonomie des erreurs + stratégie de remédiation
> Axe C + livrable §4.4. Codes d'erreur normalisés alignés sur `SnapshotError.kind` (`40-contrats-json.md`) et `apt_errors.kind` (`tache1.9.md`).
---
## 1. Principes
- **L'exit code ne suffit jamais** à déclarer un succès (APT comme Docker). On corrèle exit code + sections parsées + diff réel + lignes importantes.
- **Statut normalisé** : `ok` | `warning` | `error`. `warning` = succès partiel ou effet à surveiller (suppressions de paquets, held back, orphans removed, conteneur unhealthy).
- **Capture des lignes pertinentes** : seules les lignes d'erreur utiles (`E:`, `W:`, `dpkg:`, `Error`, etc.) sont remontées ; le log brut complet reste archivé.
- **Pas d'auto-réparation dangereuse non validée** : on **propose** une remédiation, on ne l'exécute pas automatiquement (`dpkg --configure -a`, `rm /var/lib/dpkg/lock`, etc. exigent validation explicite).
- **Nettoyage des secrets** : toute ligne d'erreur exposant une URL d'auth, un token ou un chemin de credential est nettoyée avant UI/MCP (voir `70-securite.md`).
---
## 2. Taxonomie APT / dpkg
| `kind` | Détection (lignes/exit) | Sévérité | Remédiation proposée (non auto) |
|---|---|---|---|
| `apt_lock_busy` | `Could not get lock /var/lib/dpkg/lock`, `E: Unable to acquire the dpkg frontend lock` | error | Attendre la fin d'un apt/unattended-upgrades concurrent ; relancer. |
| `dpkg_interrupted` | `dpkg was interrupted`, `dpkg --configure -a` | error | Proposer `dpkg --configure -a` **avec validation explicite**. |
| `repo_unreachable` | `Failed to fetch`, `Could not resolve`, `Connection timed out`, `E: Some index files failed to download` | error | Vérifier réseau/proxy apt-cacher-ng/dépôt ; retenter. |
| `gpg_key_error` | `NO_PUBKEY`, `EXPKEYSIG`, `signatures couldn't be verified` | error | Vérifier/mettre à jour la clé du dépôt ; ne pas désactiver la vérif. |
| `package_conflict` | `Depends:`, `but it is not going to be installed`, `held broken packages` | warning/error | Examiner le conflit ; éventuel `dist-upgrade` ; validation. |
| `disk_space_low` | `E: You don't have enough free space`, `No space left on device` | error | Libérer de l'espace (`apt-get clean`), vérifier `/`, `/var`, `/boot`. |
| `packages_held` | `apt-mark showhold` non vide / présents en dist-upgrade absents d'upgrade | warning | Information : paquets retenus ; nécessite dist-upgrade ou hold explicite. |
| `packages_removed` | `Remv ` en simulation/diff | warning | Suppression de paquets ⇒ confirmation UI obligatoire. |
| `human_interaction_required` | timeout d'inactivité atteint, prompt conffile/debconf/needrestart/apt-listchanges détecté | error | Reprise manuelle ; ne pas forcer ; lignes importantes fournies. |
| `kernel_partial_config` | `needrestart` signale services à redémarrer, `reboot-required` | warning | Planifier `reboot_verified`. |
| `apt_unknown_error` | exit ≠ 0 sans motif identifié | error | Consulter le log brut archivé. |
### Gestion des interactions humaines (couverture §7)
- Upgrades réels : `DEBIAN_FRONTEND=noninteractive`, `apt-get -y`, `-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold`.
- Justification politique par défaut : **conserver les fichiers de config locaux** quand dpkg ne peut pas trancher (ne pas écraser une config distante).
- Prompts potentiels (conffile, debconf, apt-listchanges, needrestart, service restart, maintainer script) = **risques de blocage à détecter**, jamais des dialogues exposés dans le terminal.
- **Timeout d'inactivité** (`inactivityTimeout`, défaut 600 s) et/ou **timeout global** → classer l'exécution en erreur contrôlée `human_interaction_required` si une action reste bloquée. Le backend (couche SSH) détecte l'absence de nouvelles données et coupe proprement, en marquant l'exécution.
---
## 3. Taxonomie Docker (alignée §6 de la validation)
| `kind` | Détection | Sévérité | Remédiation |
|---|---|---|---|
| `docker_not_installed` | `docker: command not found`, exit 127 | error | Proposer profil `docker_official`. |
| `compose_not_found` | dossier/fichier compose absent, `no configuration file provided` | error | Vérifier `composeRoots`/chemin du stack. |
| `compose_config_invalid` | `docker compose config --quiet` exit ≠ 0 | error | Corriger le fichier compose ; stack reste `candidate`. |
| `registry_auth_failed` | `unauthorized`, `denied`, `pull access denied` | error | Vérifier l'auth registry **sur la machine** (jamais lire les creds) ; message nettoyé. |
| `pull_failed` | `Error response from daemon`, `manifest unknown`, timeout | warning/error | Vérifier réseau/tag/registry ; retenter. |
| `image_inspect_failed` | `No such image`, exit ≠ 0 sur inspect | warning | Image absente localement ; relancer pull-check. |
| `up_failed` | `docker compose up` exit ≠ 0 | error | Examiner logs du service ; conserver l'ancien conteneur. |
| `container_unhealthy` | health `unhealthy` après up | warning | Surveiller ; proposer rollback manuel. |
| `prune_failed` | `docker image prune` exit ≠ 0 | warning | Vérifier l'état du daemon. |
| `disk_space_low` | `no space left on device` pendant pull/up | error | Prune sûr, libérer de l'espace. |
---
## 4. Taxonomie réseau / post-install (couverture §8)
| `kind` | Sévérité | Remédiation |
|---|---|---|
| `network_config_invalid` | error | Restaurer la sauvegarde `/etc/network/interfaces.su.bak.*`. |
| `interface_not_found` | error | Vérifier `interfaceName` (sonde `machine_probe`). |
| `dns_config_failed` | warning | Vérifier `resolvconf`/`dnsNameservers`. |
| `reconnect_failed` | error | La webapp retente sur `reconnectHost` ; rollback si échec. |
| `hostname_failed` | error | Vérifier droits / `hostnamectl`. |
| `sudo_setup_failed` | error | Reprendre `bootstrap_root` depuis un contexte root. |
---
## 5. Robustesse, idempotence, reprise
- **Idempotence** : les templates de détection (`update-analyze`, `docker_scan`, `inspect`, `pull-check`) sont rejouables sans effet de bord applicatif (pull-check écrit dans le cache images mais ne démarre rien — rejouable).
- **Opérations longues** : voir `90-questions-investigation.md` Q6. MVP : `nohup` + fichier exit-code pour les actions applicatives longues (full-upgrade, docker apply), reboot vérifié via boot_id ; refresh reste synchrone court.
- **Reprise** : une exécution coupée laisse un état lisible (sections déjà émises + exit-code sur disque côté machine). Le backend peut relire l'état au lieu de tout relancer.
- **Verrous** : `machine_locks` (`tache1.9.md`) empêche deux actions concurrentes destructives sur une même machine (`apt`, `docker`, `reboot`, `exclusive`).
+150
View File
@@ -0,0 +1,150 @@
# 60 — Profils OS, type machine, overrides et proxy APT
> Axe A + livrables §4.5/§4.6. Tranche §3.2 (structure profils OS) et §3.3 (profils machine). Couvre la grille §7 (« Profils OS et type de machine »).
---
## 1. Deux dimensions distinctes
- **`os_family`** : `debian` | `ubuntu` | `proxmox` | `raspbian` | `unknown` (quelle distro / quel jeu de dépôts et de commandes).
- **`machine_kind`** : `physical` | `vm` | `proxmox_host` | `lxc` | `raspberry_pi` | `workstation` | `unknown` (quel matériel / quels scripts pertinents : firmware, drivers, guest tools…).
Les deux sont **orthogonaux** : une Debian peut être physique, VM ou LXC ; un Raspberry Pi OS implique presque toujours `raspberry_pi`. Choisis **manuellement à l'ajout**, corrigeables par `machine_probe`. Stockés dans `machines.os_family` / `machine_kind` / `virtualization` / `hardware_profile` (`tache1.9.md §5`).
---
## 2. Arborescence des templates et héritage (décision §3.2)
**Convention de dossier + fallback `base`.** Le moteur de rendu choisit le template **le plus spécifique disponible**, sinon retombe sur le profil générique.
```text
templates/
├── apt/ # profil "base" (Debian/Ubuntu générique — jalon 1)
│ ├── update-analyze.sh.tpl
│ ├── upgrade.sh.tpl
│ ├── full-upgrade.sh.tpl
│ ├── autoremove.sh.tpl
│ ├── clean.sh.tpl
│ ├── reboot-check.sh.tpl
│ └── reboot.sh.tpl
├── proxmox/ # overrides Proxmox (dépôts PVE, kernel, Ceph)
│ ├── update-analyze.sh.tpl
│ └── full-upgrade.sh.tpl
├── raspbian/ # overrides Raspberry Pi OS (firmware, espace disque)
│ ├── update-analyze.sh.tpl
│ └── full-upgrade.sh.tpl
├── docker/
│ └── *.sh.tpl
└── custom/
└── *.sh.tpl
```
Résolution (pseudo-code backend) :
```text
resolveTemplate(action, osFamily):
candidate = templates/<osFamily>/<action>.sh.tpl
if exists(candidate): return candidate
return templates/apt/<action>.sh.tpl # fallback base
```
**Pourquoi pas un héritage par fragments/includes Mustache ?** Plus simple à auditer en Git (un fichier = un script complet, lisible et testable). Inconvénient : duplication partielle entre profils — accepté au MVP (peu de profils). Alternative notée en §3.2 de `90-questions-investigation.md`.
> **Non-régression jalon 1** : Debian/Ubuntu n'ont pas de dossier dédié ⇒ ils tombent sur `templates/apt/*` (comportement actuel inchangé). Le mécanisme de résolution est **additif**.
---
## 3. Overrides par machine
Au-delà du profil OS, chaque machine peut surcharger :
- `aptProxyMode` / `aptProxyUrl` (déjà présent au jalon 1) ;
- des variables de contexte (`composeRoots`, `inactivityTimeout`, etc.) ;
- l'activation de templates (`templates activables` côté formulaire) ;
- des presets de variables custom (`script_variables_presets` scope `machine`).
Priorité de résolution des variables : **override machine** > **défaut profil OS** > **défaut global**. Aucun override ne peut introduire un secret dans un template (les secrets restent côté `machine_credentials`, injectés uniquement via `runScriptSudo` stdin).
---
## 4. Spécificités par profil OS
### Debian
- `apt-get update` + `dist-upgrade` standard.
- Avant firmware/drivers propriétaires : vérifier `contrib`, `non-free`, `non-free-firmware` dans les sources (lecture seule, template `check-components`). Proposition uniquement.
- Source : https://www.debian.org/releases/bookworm/amd64/ch02s02.en.html
### Ubuntu
- Idem Debian + `ubuntu-drivers devices` (lecture) pour proposer des drivers (NVIDIA/GPU), surtout sur `machine_kind` physique/workstation/gpu. Jamais installé par défaut.
### Proxmox (profil dédié, jamais Debian générique)
- Contrôler les dépôts PVE : `pve-no-subscription` vs `enterprise` (sinon `apt update` échoue sur le dépôt entreprise sans abonnement).
- Meta-package `proxmox-ve`, kernel PVE, Ceph éventuel.
- `apt-get update` puis `apt-get dist-upgrade`.
- Source : https://pve.proxmox.com/wiki/System_Software_Updates
### Raspberry Pi OS (profil dédié)
- Attention firmware/kernel (`rpi-update` **non** utilisé par défaut — risqué).
- **Vérifier l'espace disque avant upgrade** (carte SD souvent petite).
- Utiliser `full-upgrade`.
- Source : https://www.raspberrypi.com/documentation/usage/terminal/
---
## 5. Influence du type machine sur les scripts proposés
| `machine_kind` | Scripts pertinents | À éviter |
|---|---|---|
| `physical` | détection hardware, firmware (`fwupd`), SMART/disques, sensors, drivers GPU, benchmark | guest tools |
| `vm` | guest tools (`qemu-guest-agent` ou `open-vm-tools`) | drivers GPU/firmware (sauf passthrough) |
| `proxmox_host` | profil Proxmox dédié (dépôts PVE, kernel, Ceph) | traitement Debian générique |
| `lxc` | minimal (pas de kernel/firmware propre au conteneur) | firmware, drivers, reboot kernel |
| `raspberry_pi` | profil RPi (firmware/kernel prudent, espace disque) | drivers GPU desktop |
| `workstation` / GPU server | drivers GPU (`ubuntu-drivers`/`nvidia`), benchmark | — |
> Les scripts hardware/drivers/benchmark ne sont **jamais installés par défaut** et **exigent validation explicite** (couverture §7).
---
## 6. Détection / correction : `machine_probe` (décision §3.3)
Action non destructive (lecture seule), proposée à l'ajout et relançable. Sources lues :
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:PROBE_OS==="
cat /etc/os-release 2>/dev/null
echo "===SU:PROBE_ARCH==="
uname -m
dpkg --print-architecture 2>/dev/null
echo "===SU:PROBE_VIRT==="
systemd-detect-virt 2>/dev/null || echo "none"
echo "===SU:PROBE_PROXMOX==="
[ -d /etc/pve ] && echo "PROXMOX=1" || echo "PROXMOX=0"
echo "===SU:PROBE_RPI==="
grep -qi raspberry /proc/cpuinfo 2>/dev/null && echo "RPI=1" || echo "RPI=0"
echo "===SU:PROBE_GPU==="
command -v lspci >/dev/null 2>&1 && lspci 2>/dev/null | grep -Ei 'vga|3d|display' || echo "no-lspci"
echo "===SU:PROBE_NET==="
ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}'
echo "===SU:EXIT=0==="
```
Le backend propose une **correction** de `os_family`/`machine_kind`/`virtualization`/interface primaire, **jamais appliquée automatiquement** sans validation utilisateur (règle de correction : l'opérateur garde le dernier mot). Résultats persistés dans `machine_hardware` + colonnes `machines`.
**MVP retenu** : choix manuel à l'ajout (Debian/Ubuntu/Proxmox VE/Raspberry Pi OS/autre Linux ; VM/physique/Proxmox/LXC/RPi/GPU-workstation) + `machine_probe` pour proposer des corrections. Alternative (détection 100 % automatique) jugée trop fragile (cas limites : conteneurs imbriqués, distros dérivées) — voir `90-questions-investigation.md` Q3.
---
## 7. Proxy APT (apt-cacher-ng) — trois modes
`AptProxyMode = "direct" | "runtime" | "persistent"` (le `persistent` est l'ajout tâche 2).
| Mode | Mécanisme | Quand |
|---|---|---|
| `direct` | aucun proxy | défaut |
| `runtime` | `export http_proxy/https_proxy` dans le script (sections Mustache `{{#aptProxy}}`) — **comportement jalon 1, conservé** | proxy temporaire pour une exécution |
| `persistent` | écrire `Acquire::http::Proxy "<url>";` dans `/etc/apt/apt.conf.d/01proxy` (action dédiée, idempotente, sauvegarde de l'existant) | proxy permanent de la machine |
Le mode `persistent` est une **action explicite** (écriture sur disque) avec preview ; il n'est pas appliqué silencieusement. `runtime` reste injecté au rendu comme aujourd'hui.
+70
View File
@@ -0,0 +1,70 @@
# 70 — Note de sécurité : frontière Hermes/MCP et actions destructives
> Livrable §4.8. Tranche §3.7 (sécurité prune/scripts) et §3.8 (surface MCP). Aligné `CLAUDE.md` (sécurité non négociable) et `validation_tache2.md §3`/§6/§7/§8.
---
## 1. Ce qui ne doit JAMAIS atteindre Hermes / MCP / un prompt LLM
- Mots de passe SSH, **sudo password**, passphrases de clés, clés privées.
- Tokens / credentials de registry Docker (`~/.docker/config.json`, credential helpers, tokens d'auth).
- Toute variable de champ de type `secret` d'un profil post-install.
- URLs contenant des identifiants (`https://user:pass@…`), en-têtes d'auth, chaînes de connexion.
- Le **log brut complet** (archivé séparément, jamais inliné dans un prompt).
Mécanismes :
- Les secrets vivent uniquement dans `machine_credentials` (chiffrés au repos), injectés **uniquement** via `runScriptSudo` sur **stdin** (`sudo -S -p ''`) — jamais dans le corps du script, jamais en argument, jamais loggés.
- Le JSON canonique métier ne contient **aucun** champ secret (cf. `PostInstallResult.variablesUsed` = non sensible uniquement).
- **Nettoyage des erreurs** avant UI/MCP : un filtre déterministe masque les motifs sensibles (`https?://[^/@\s]+:[^/@\s]+@`, `Authorization:`, chemins `*/config.json`, `token=…`) dans les lignes d'erreur Docker/APT avant affichage et avant réduction Hermes.
---
## 2. Actions destructives → validation explicite côté webapp
Toute action modifiant l'état de la machine de façon non triviale passe par une **confirmation UI explicite** et est tracée comme `action_request` (`tache1.9.md §10`).
| Action | Risque | Validation |
|---|---|---|
| `apt_dist_upgrade` / `apt_full_upgrade` | peut supprimer des paquets | confirmation explicite si `removed`/`held` |
| `apt_autoremove` | supprime des paquets | confirmation explicite |
| `reboot` / `reboot_verified` | redémarrage | confirmation explicite |
| `docker_compose_apply` | recrée les conteneurs | confirmation explicite |
| `docker_prune_images` (agressif `-a`) | supprime des images non dangling | confirmation explicite distincte |
| `docker_compose_down` | arrête le stack | confirmation forte ; `--volumes`/`--rmi` interdits au MVP |
| `post_install:identity_network` | change le réseau / coupe la connexion | preview obligatoire + sauvegarde fichiers + stratégie de reconnexion planifiée |
| `apt_proxy persistent` | écrit dans `/etc/apt/apt.conf.d` | preview |
Règle d'or : **Hermes peut recommander/proposer, mais ne déclenche jamais directement** une action à risque. Le déclenchement passe par l'opérateur via l'UI (ou un `action_request` approuvé).
Actions sûres sans validation : `apt_update_analyze`, `docker_scan`, `docker_inspect_current`, `machine_probe`, `apt_clean`. `docker_pull_check` est **non passif** (écrit dans le cache images) mais non applicatif : pas de validation destructive, mais isolé du chemin de scan pur.
---
## 3. Surface MCP minimale (décision §3.8)
On **réutilise la surface v1** du rapport / `CLAUDE.md`, sans nouvelle primitive d'exécution SSH :
| Outil MCP | Rôle | Renvoie un secret ? |
|---|---|---|
| `list_machines` | liste les machines (vue publique, sans secret) | non |
| `get_machine_snapshot` | dernier snapshot réduit (APT + Docker) | non |
| `get_machine_execution` | résultat d'exécution réduit + réfs logs/rapport | non |
| `list_templates` | liste des templates disponibles | non |
| `preview_template` | rendu d'un template avec **masquage des secrets** | non |
| `run_refresh` | déclenche un refresh/analyse (action sûre) | non |
| `run_action` | déclenche une action **déjà autorisée** ; les actions destructives exigent une validation webapp préalable | non |
| `search_reports` | recherche dans les rapports archivés | non |
Principes :
- Le **MCP est une façade** de l'API métier : aucune logique SSH dedans, aucun secret.
- Aucun nouvel outil pour Docker/post-install : les nouvelles capacités passent par `run_action(actionType, params)` avec le **filtrage d'autorisation** de la route `POST /api/machines/:id/actions`. Surface stable et petite = agents fiables.
- `run_action` sur une action destructive non encore validée → renvoie un `action_request` en attente, **pas** une exécution.
- Audit : tout appel MCP est journalisé (`mcp_audit_log`, `tache1.9.md §11`) avec `request_json` réduit (sans secret).
---
## 4. Traçabilité
- Chaque exécution : log brut archivé (`raw_artifacts`/`rawLogPath`, `redacted=1`), rapport Markdown (`reports`), `important_json` réduit.
- Les changements réseau/Docker sont explicitement marqués dans le rapport avec les prochaines actions attendues (reconnexion, relogin groupe docker, reboot).
- Les secrets ne figurent ni dans les rapports, ni dans les logs UI, ni dans le MCP.
+89
View File
@@ -0,0 +1,89 @@
# 80 — Découpage en sous-jalons implémentables
> Livrable §4.9. Chaque sous-jalon = un cycle spec → plan → implémentation, indépendamment testable, sans casser le jalon 1. Priorisé. Prêt pour `writing-plans`.
---
## Ordre recommandé et dépendances
```text
SJ-0 (socle types/réduction) ──► SJ-1 (APT update/analyse) ──► SJ-2 (APT upgrade + diff)
SJ-3 (reboot vérifié)
SJ-4 (Docker scan/inspect) ──► SJ-5 (Docker pull-check) ──► SJ-6 (Docker apply/prune/down)
SJ-7 (profils OS Proxmox/RPi) [transversal, après SJ-1]
SJ-8 (post-install bootstrap/identité) [après SJ-0]
SJ-9 (post-install Docker officiel / partages / VM tools) [après SJ-8]
```
---
## SJ-0 — Socle : types étendus + réduction + résolution de profil
- **Contenu** : étendre `shared/types.ts` (unions + blocs optionnels, cf. `40-contrats-json.md`), étendre le réducteur (`reduceLines.ts` ajoutant les préfixes Docker), ajouter le mécanisme `resolveTemplate(action, osFamily)` avec fallback `base`, ajouter `schemaVersion`.
- **Testable** : tests unitaires de réduction (APT + Docker), tests de résolution de template, validation qu'un snapshot/exécution jalon 1 reste typé valide.
- **Risque** : faible (additif). **Priorité : 1 (prérequis de tout le reste).**
## SJ-1 — APT update/analyse (snapshot enrichi)
- **Contenu** : `apt/update-analyze.sh.tpl` (update + simulations upgrade/dist-upgrade + held + reboot-check), parsing des sections, `AptSnapshotDetail` enrichi, statut `ok|updates_available|warning|error`. Bascule du refresh dessus (en gardant `check.sh.tpl` jusqu'à validation).
- **Testable** : fixtures de sortie APT → snapshot ; non-régression du refresh jalon 1.
- **Risque** : faible-moyen (toucher le refresh). **Priorité : 2.**
## SJ-2 — APT upgrade / full-upgrade / autoremove / clean + diff dpkg réel
- **Contenu** : templates `upgrade`, `full-upgrade` (enrichi diff), `autoremove`, `clean` ; capture `DPKG_BEFORE/AFTER` ; calcul du diff réel (`AptExecutionResult`) ; timeout d'inactivité + `human_interaction_required` ; confirmations UI pour suppressions.
- **Testable** : fixtures dpkg before/after → diff ; détection des suppressions/held.
- **Risque** : moyen (actions destructives). **Priorité : 3.**
## SJ-3 — Reboot vérifié (boot_id + délai adaptatif)
- **Contenu** : `apt/reboot.sh.tpl` (capture boot_id) + orchestration backend (attente coupure, reconnexion, relecture boot_id), `RebootResult`, délai adaptatif par machine. Conserve l'action `reboot` jalon 1.
- **Testable** : simulation des états (`boot_id_unchanged`, `machine_did_not_return`, `timeout`).
- **Risque** : moyen. **Priorité : 4.**
## SJ-4 — Docker scan + inspect (passifs)
- **Contenu** : `docker/scan-compose.sh.tpl`, `docker/inspect-compose.sh.tpl` ; config machine `dockerEnabled`/`composeRoots`/`composeScanDepth` ; cycle `candidate`/`enabled` ; tables `docker_*`. Détection labels en complément.
- **Testable** : fixtures de scan → liste de stacks ; validation `compose config --quiet`.
- **Risque** : faible (passif). **Priorité : 5.**
## SJ-5 — Docker pull-check + comparaison
- **Contenu** : `docker/pull-check.sh.tpl` ; comparaison déterministe ID/digest/labels OCI ; `DockerSnapshot`/services ; dédup Docker ; refresh Docker séparé (non auto).
- **Testable** : fixtures before/after pull → updates détectées ; nettoyage secrets registry.
- **Risque** : faible-moyen. **Priorité : 6.**
## SJ-6 — Docker apply + prune + down
- **Contenu** : `apply-compose`, `prune-images` (safe/agressif), `down-compose` ; `DockerExecutionResult` ; validations UI explicites ; `docker_image_events`.
- **Testable** : fixtures up/prune → conteneurs recréés / bytes reclaimed.
- **Risque** : moyen-élevé (destructif). **Priorité : 7.**
## SJ-7 — Profils OS Proxmox + Raspberry Pi (+ proxy persistent)
- **Contenu** : dossiers `templates/proxmox/`, `templates/raspbian/` (update-analyze, full-upgrade) ; mode `AptProxyMode="persistent"` ; `machine_probe`.
- **Testable** : résolution de template par OS ; sonde → propositions de correction.
- **Risque** : faible (additif, fallback base préservé). **Priorité : 8 (transversal).**
## SJ-8 — Post-install : bootstrap + identité/réseau
- **Contenu** : moteur de profils (manifestes, champs dynamiques, preview, validations), `custom/bootstrap-root.sh.tpl`, `custom/identity-network.sh.tpl` ; `install_profiles`/`install_recipes` ; stratégie reconnexion ; `PostInstallResult`.
- **Testable** : rendu de manifeste → formulaire ; preview masquant les secrets ; échec contrôlé si champ manquant.
- **Risque** : moyen (réseau). **Priorité : 9.**
## SJ-9 — Post-install : paquets de base + Docker officiel + partages + VM tools
- **Contenu** : `install-package-groups`, `docker-official-debian`, `sharing`, `vm-guest-tools` ; presets de variables ; renvoi du catalogue détaillé à la tâche 4.
- **Testable** : installation de groupes ; idempotence.
- **Risque** : faible-moyen. **Priorité : 10.**
---
## Notes de séquencement
- **SJ-0 est bloquant** pour tous les autres (types + réduction + résolution).
- APT (SJ-1→3) et Docker (SJ-4→6) sont **indépendants** : peuvent être menés en parallèle après SJ-0.
- Chaque sous-jalon livre un logiciel testable et ne casse pas les flux jalon 1 (`refresh`, `apt_full_upgrade`, `reboot`) grâce aux extensions additives.
- Les actions destructives n'arrivent qu'après le socle de validation UI (`action_requests`), conformément à `70-securite.md`.
@@ -0,0 +1,72 @@
# 90 — Les 8 questions d'investigation (§3) tranchées
> Chaque question : **MVP recommandé / alternatives / risques**. Décisions autonomes argumentées, cohérentes avec l'existant et `tache1.9.md`.
---
## Q1 — JSON-in-shell vs parsing-in-TS
**MVP recommandé : hybride à dominante parsing-TS.** On conserve la convention actuelle (marqueurs `===SU:XXX===` + parsing dans `server/services/`). On enrichit avec des **données semi-structurées produites par le shell uniquement quand le format est déjà stable et documenté** : `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'` (TSV), `docker compose ps/images --format json`, `docker image inspect --format '...'`. Pas de construction de gros JSON imbriqué à la main dans le shell.
- **Pourquoi** : (a) cohérence avec le jalon 1 (déjà en prod, parsing TS testé) ; (b) testabilité — les fixtures de sortie shell + tests TS sont faciles à maintenir ; (c) robustesse multi-OS — éviter le JSON bricolé en Bash (échappement fragile, comme on le voit dans `nas-ops` avec la concaténation manuelle de chaînes JSON) ; (d) on tire parti des formats JSON **natifs et documentés** de Docker sans les réinventer.
- **Alternatives** : (1) tout-JSON-in-shell façon `nas-ops` — rejeté (échappement fragile, dur à tester, risque de casser sur des noms/versions exotiques) ; (2) tout-parsing-TS sur sortie brute uniquement — rejeté pour Docker où `--format json` est plus sûr que parser du texte libre.
- **Risques** : double convention (TSV/clé=valeur + sections) à documenter clairement ; mitigé par un parseur central par section. Format `docker ... --format json` varie selon version de Compose — pin de la commande + fallback texte.
## Q2 — Structure des profils OS
**MVP recommandé : un fichier de template complet par profil, dans un dossier par OS, avec fallback `base`** (`templates/<osFamily>/<action>.sh.tpl` → sinon `templates/apt/<action>.sh.tpl`). Résolution par convention de chemin (cf. `60-profils-os-machine.md §2`).
- **Pourquoi** : lisibilité et audit Git (un script = un fichier complet, testable isolément) ; n'invalide pas Debian/Ubuntu (pas de dossier dédié ⇒ fallback `apt/`, comportement jalon 1 intact).
- **Alternatives** : (1) héritage par fragments/partials Mustache (DRY) — plus complexe à auditer, reporté ; (2) une matrice de variables dans un seul template géant avec `{{#proxmox}}…` — rejeté (templates illisibles, logique métier noyée dans le rendu).
- **Risques** : duplication partielle entre profils. Accepté au MVP (peu de profils) ; refactor en partials possible plus tard si la duplication devient coûteuse.
## Q3 — Structure des profils machine
**MVP recommandé : choix manuel à l'ajout (`os_family` + `machine_kind`) + action `machine_probe` non destructive proposant des corrections** (jamais appliquées sans validation). Sources : `/etc/os-release`, `uname -m`/`dpkg --print-architecture`, `systemd-detect-virt`, présence `/etc/pve`, `/proc/cpuinfo` (RPi), `lspci` (GPU), `ip addr` (interface).
- **Pourquoi** : la détection auto seule est fragile (conteneurs imbriqués, distros dérivées, VM mal taguées). Le couple « défaut manuel + sonde de correction » est robuste et garde l'opérateur maître.
- **Alternatives** : (1) détection 100 % auto — rejetée (cas limites) ; (2) manuel sans sonde — perte de confort et risque d'erreur de profil.
- **Risques** : utilisateur choisit mal le profil ⇒ `machine_probe` le signale ; correction nécessite validation. Persisté dans `machines` + `machine_hardware`.
## Q4 — Capture avant/après (diff réel)
**MVP recommandé : snapshot dpkg complet avant ET après chaque action APT réelle** via `dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n'`, diff calculé côté backend (clé `package+arch`). L'exit code APT ne suffit jamais.
- **Pourquoi** : `dpkg-query` reflète l'état **réel** installé (pas l'intention APT). Détecte les écarts entre simulation et réalité (paquet annoncé non installé, held back effectif).
- **Alternatives** : (1) parser uniquement la sortie `apt-get` (`Setting up …`) — moins fiable, dépend du locale et du verbeux ; (2) historique `/var/log/dpkg.log` — parsing daté fragile. Les deux gardés comme signaux secondaires, pas comme source de vérité.
- **Risques** : sur de très gros parcs de paquets, deux `dpkg-query` ajoutent quelques secondes — négligeable. Diff côté TS = testable par fixtures.
## Q5 — Contrats JSON (extensions exactes)
**MVP recommandé : extensions additives détaillées dans `40-contrats-json.md`** — unions élargies (`OsFamily`, `AptProxyMode`, `ActionType`, `MachineKind`), blocs optionnels `docker`/`errors`/`reboot`/`postInstall` + champs additifs sur `apt`, `schemaVersion?`. Types TS fournis. Un payload jalon 1 reste valide.
- **Pourquoi** : rétro-compatibilité stricte exigée par le gate (§3 de la validation). Champs optionnels + unions additives = zéro rupture.
- **Alternatives** : versionner par type séparé (`UpdateSnapshotV2`) — rejeté (duplication, migration lourde) au profit de `schemaVersion` sur un type unique.
- **Risques** : un type unique grossit ; mitigé par découpe en sous-interfaces (`AptSnapshotDetail`, `DockerSnapshot`, etc.).
## Q6 — Idempotence & opérations longues
**MVP recommandé : différencier selon la durée et le risque.**
- **Refresh/analyse, scan, inspect** : synchrones et courts (comme le jalon 1).
- **Actions applicatives longues** (`full-upgrade`, `docker apply`) : généraliser l'exécution détachée `nohup` + **fichier exit-code sur la machine** (survit à une coupure SSH), comme prévu au jalon 1 et inspiré de `linux-update-dashboard`. Le backend peut relire l'état/exit-code à la reconnexion plutôt que tout relancer.
- **Reboot** : mécanisme dédié `reboot_verified` (boot_id avant/après + reconnexion + délai adaptatif).
- **Idempotence** : les détections sont rejouables sans effet de bord applicatif ; `pull-check` écrit dans le cache images mais ne démarre rien (rejouable). `machine_locks` évite la concurrence destructive.
- **Alternatives** : (1) tout synchrone — rejeté (un upgrade long meurt avec la session SSH) ; (2) file de jobs persistante dès le MVP — utile mais relève de la **tâche 5** ; ici on pose le mécanisme `nohup`+exit-code et on renvoie l'orchestration job à la tâche 5.
- **Risques** : suivi de progression d'une opération détachée = tailing du fichier de sortie distant + WebSocket ; à câbler proprement en implémentation (réutilise `outputHub`).
## Q7 — Sécurité Docker `prune` / scripts custom
**MVP recommandé : barrière de validation côté webapp (`action_requests`) pour toute action destructive + nettoyage déterministe des secrets dans les erreurs.** `docker_prune_images -a`, `docker_compose_down`, `docker_compose_apply`, suppressions APT, `reboot`, `identity_network` ⇒ confirmation explicite. Hermes propose, ne déclenche jamais. Credentials registry/sudo/tokens jamais lus ni renvoyés (cf. `70-securite.md`).
- **Pourquoi** : respecte `CLAUDE.md` (actions destructives validées, aucun secret vers LLM). La barrière unique (`action_requests`) centralise l'autorisation côté API.
- **Alternatives** : validation par template (flag `requiresConfirmation` dans le manifeste) — complémentaire, pas suffisant seul ; la décision d'autorisation reste côté API/route.
- **Risques** : un script custom pourrait logger un secret ⇒ règle « pas de secret dans le corps du script » + filtre de nettoyage des lignes avant UI/MCP + revue Git des templates.
## Q8 — Surface MCP
**MVP recommandé : conserver la surface v1 (8 outils), sans nouvelle primitive d'exécution SSH.** Les nouvelles capacités (Docker, post-install, APT détaillé) passent par `run_action(actionType, params)` filtré côté route, pas par de nouveaux outils. `preview_template` masque les secrets. `run_action` sur action destructive non validée renvoie un `action_request` en attente. Audit via `mcp_audit_log`.
- **Pourquoi** : surface petite = agents fiables et auditables ; le MCP reste une **façade** sans logique SSH ni secret.
- **Alternatives** : exposer un outil par action (`docker_apply`, `apt_upgrade`…) — rejeté (explosion de surface, duplication d'autorisation).
- **Risques** : `run_action` générique doit valider strictement `actionType`/`params` côté API (liste blanche) ; sinon risque d'action non prévue. Mitigé par la table d'autorisation et `action_requests`.
+182
View File
@@ -0,0 +1,182 @@
# 99 — Auto-évaluation de couverture du gate `validation_tache2.md`
> Relecture case par case. ✅ = couvert ; ⚠️ = couvert avec réserve / hors périmètre design (à confirmer en implémentation). Légende des renvois : fichiers de `docs/design/tache2/`.
---
## §1 Discipline & périmètre
| Case | État | Renvoi |
|---|---|---|
| Aucun code de production modifié (server/client/shared/templates/configs) | ✅ | Seuls `docs/design/tache2/**` + section clôture `tache2.md` créés. À vérifier par `git status`. |
| Jalon 1 et jalon 2 intacts | ✅ | Aucun fichier de jalon touché. |
| Aucun autre chantier hors périmètre | ✅ | Hors-scope listés comme suggestions (`00-synthese.md §6`). |
| Dépôts de référence non copiés | ✅ | `nas-ops`/`linux-update-dashboard` cités en inspiration, pseudo-shell réécrit. |
## §2 Complétude — Axes
| Axe | État | Renvoi |
|---|---|---|
| A — Templates APT + sémantique + profils OS + proxy | ✅ | `10-templates-apt.md`, `60-profils-os-machine.md` |
| B — Capture prévu/appliqué consommable Hermes | ✅ | `40-contrats-json.md` (snapshot/diff/dédup/réduction) |
| C — Taxonomie erreurs + remédiation | ✅ | `50-erreurs.md` |
| D — Docker scan/pull/up/down/prune + détection + JSON | ✅ | `20-docker.md` |
| E — Scripts personnalisés + overrides + garde-fous | ✅ | `30-scripts-custom.md` |
## §2 Complétude — Livrables §4
| Livrable | État | Renvoi |
|---|---|---|
| Inventaire des templates | ✅ | `10` §2, `20` §2, `30` §2/§4 |
| Contenu proposé (pseudo-shell, `===SU:XXX===`) | ✅ | `10` §4, `20` §4, `30` §4 |
| Schémas JSON canoniques étendus | ✅ | `40` |
| Taxonomie des erreurs | ✅ | `50` |
| Modèle profils OS + overrides | ✅ | `60` |
| Modèle profils machine | ✅ | `60` §5 |
| Modèle scripts personnalisés | ✅ | `30` |
| Note de sécurité | ✅ | `70` |
| Découpage en sous-jalons priorisé | ✅ | `80` |
## §2 — 8 questions d'investigation
| | État | Renvoi |
|---|---|---|
| Q1Q8 tranchées (MVP/alternatives/risques) | ✅ | `90-questions-investigation.md` |
## §3 Cohérence & intégration
| Case | État | Renvoi |
|---|---|---|
| Types JSON compatibles `shared/types.ts`, rétro-compatibles | ✅ | `40` §1–§4 (champs optionnels, payload jalon 1 valide) |
| Convention templates (`===SU:`, `LC_ALL=C`, `sudo -S`, parsable) | ✅ | `10`/`20`/`30` |
| Parsing (JSON-in-shell vs TS) explicite et justifié | ✅ | `90` Q1, `40` §7 |
| Couche SSH réutilisée (`server/ssh/client.ts`) | ✅ | `00` §4, `90` Q6 |
| Frontière Hermes/MCP, réduction déterministe | ✅ | `70`, `40` §7 |
| Sécurité actions destructives + pas de secret | ✅ | `70` §1/§2 |
| Profils OS n'invalident pas Debian/Ubuntu prod | ✅ | `60` §2 (fallback `base`) |
| Sous-jalons indépendamment implémentables | ✅ | `80` |
## §4 Non-régression
| Case | État | Note |
|---|---|---|
| `pnpm check`/`test`/`build` verts | ⚠️ | Hors périmètre design (aucun code touché) ; à exécuter par l'orchestrateur. Aucune modification de code n'a été faite. |
| Flux jalon 1 inchangés | ✅ | Extensions additives uniquement ; templates jalon 1 non modifiés. |
## §6 Focus Docker Compose
| Case | État | Renvoi |
|---|---|---|
| Gestion par SSH, réutilise couche existante, `docker context` = alternative | ✅ | `20` §1 |
| Stacks depuis racines déclarées `composeRoots`, scan limité, validation UI | ✅ | `20` §1/§4.1 |
| Détection labels en complément | ✅ | `20` §1/§4.1 |
| Stack détecté = `candidate`, actions sur `enabled` seulement | ✅ | `20` §1 |
| `scan-compose.sh.tpl` (fichiers compose, ignore .git/node_modules/backup/old/archive, `config --quiet`) | ✅ | `20` §4.1 |
| `inspect-compose.sh.tpl` (`config --images`, `ps --format json`, `images --format json`, `image inspect`) | ✅ | `20` §4.2 |
| `pull-check.sh.tpl` (`pull --policy always --ignore-buildable`, compare ID/digest/labels, non passif) | ✅ | `20` §4.3, §1 tableau |
| `apply-compose.sh.tpl` (`up -d --remove-orphans`, recapture) | ✅ | `20` §4.4 |
| `prune-images.sh.tpl` (safe `-f`, agressif `-a -f --filter until=168h` validé) | ✅ | `20` §4.5 |
| `down-compose.sh.tpl` (séparé/destructif, `--volumes`/`--rmi` interdits) | ✅ | `20` §4.6 |
| Flux 1→8 formalisé | ✅ | `20` §3 |
| `pull` télécharge sans démarrer | ✅ | `20` §3 |
| `up -d` recrée si changement, préserve volumes, `down` inutile | ✅ | `20` §3 |
| `prune -f` vs `-a` (destructif) | ✅ | `20` §2/§3 |
| Sources Docker citées | ✅ | `20` §1 |
| Snapshot Docker rétrocompatible (bloc optionnel) | ✅ | `40` §3 |
| Bloc snapshot Docker (stacks/services/ID/digest/labels/candidat/statut) | ✅ | `40` §3 (« bloc Docker minimal ») |
| `ExecutionResult.docker` (pull/up/prune/erreurs/recréés/supprimés/octets) | ✅ | `40` §4 |
| Erreurs Docker structurées (10 codes) | ✅ | `50` §3 |
| Réduction Hermes (lignes Docker) + log brut archivé | ✅ | `20` §5, `40` §7 |
| Config machine (`dockerEnabled`/`composeRoots`/`composeScanDepth`/`composeStacks[]`) sans casser `MachineView` | ✅ | `20` §6, `40` §5 |
| Refresh combiné apt+docker ou Docker séparé | ✅ | `20` §6 |
| `ActionType` étendu (docker_*) + filtrage autorisation | ✅ | `40` §2, `20` §6 |
| Réutilise `executions`/WS/`rawLogPath`/`reportPath`/statut | ✅ | `20` §6 |
| UI compteur Docker séparé + détail + boutons validés | ✅ | `20` §6 |
| Validation UI apply/prune agressif/down ; Hermes ne déclenche pas | ✅ | `20` §6, `70` §2 |
| Secrets registry jamais lus ; erreurs nettoyées | ✅ | `20` §6, `70` §1 |
## §7 Focus APT/reboot
| Case | État | Renvoi |
|---|---|---|
| `apt_update_analyze` distinct des upgrades destructifs | ✅ | `10` §2, `40` §2 |
| update + `-s upgrade` + `-s dist-upgrade` | ✅ | `10` §4.1 |
| Snapshot liste paquets prévus (nom/cur/cible/origine/arch) | ✅ | `10` §4.1, `40` §3 |
| Distingue upgrade vs full/dist (maj/install/remove/held) | ✅ | `10` §4.1, `40` §3 |
| Simulations parsées via `Inst`/`Conf`/`Remv`, log brut archivé | ✅ | `10` §1/§4.1 |
| Statut `ok/updates_available/warning/error`, warning si remove/held | ✅ | `10` §4.1, `40` §3 |
| Sources APT citées | ✅ | `10` §1 |
| Distingue `os_family` et `machine_kind` à l'ajout | ✅ | `60` §1/§6 |
| Choix manuel OS (Debian/Ubuntu/Proxmox/RPi/autre) | ✅ | `60` §6 |
| Choix manuel type (VM/physique/Proxmox/LXC/RPi/GPU-workstation) | ✅ | `60` §6 |
| `machine_probe` détecte/corrige | ✅ | `60` §6 |
| Scripts dépendent du couple OS/type | ✅ | `60` §5 |
| Debian firmware vérifie contrib/non-free/non-free-firmware | ✅ | `60` §4 |
| Proxmox = profil dédié | ✅ | `60` §2/§4 |
| Scripts hardware/drivers/benchmark jamais par défaut, validation | ✅ | `60` §5 |
| Templates APT attendus (update-analyze/upgrade/full-upgrade/autoremove/clean/reboot-check/reboot) | ✅ | `10` §2/§4 |
| Politique non interactive (`noninteractive`, `-y`, confdef/confold) | ✅ | `10` §4.2, `50` §2 |
| Justification confdef/confold | ✅ | `10` §4.2, `50` §2 |
| Prompts traités comme risques de blocage | ✅ | `50` §2 |
| Timeout inactivité/global → erreur contrôlée | ✅ | `50` §2 |
| `human_interaction_required` prévu | ✅ | `50` §2 |
| Pas seulement exit code | ✅ | `50` §1, `10` §4.2 |
| dpkg-query before/after | ✅ | `10` §4.2 |
| Diff backend (maj/install/remove/inchangé/versions/anomalies) | ✅ | `40` §4, `90` Q4 |
| `ExecutionResult.apt` (planned/applied/installed/removed/held/errors/reboot) | ✅ | `40` §4 |
| Rapport MD résume diff + réf log | ✅ | `70` §4, `40` §8 |
| Reboot vérifié (boot_id avant/après, attente, reconnexion) | ✅ | `10` §4.5, `40` §4 |
| Reboot ok si revient ET boot_id changé | ✅ | `10` §4.5 |
| `RebootResult` (beforeBootId…status/errors) | ✅ | `40` §4 |
| Délai adaptatif par machine | ✅ | `10` §4.5, `40` §4 |
| Statuts d'échec reboot distingués | ✅ | `40` §4 (`RebootResult.status`) |
| Reboot = action validée ; Hermes ne déclenche pas | ✅ | `70` §2 |
| `apt_update_analyze` alimente snapshot + tuile | ✅ | `10` §6, `20` §6 |
| Actions via même route + table `executions` | ✅ | `20` §6, `10` §6 |
| UI avant exécution (paquets/suppressions/held/reboot/risque) | ✅ | `70` §2, `40` §3 (renvoi tâche 3 pour le rendu) |
| UI après exécution (réussite/diff/reboot/rapport/log) | ✅ | `70` §4 (renvoi tâche 3) |
| Confirmation UI pour dist/full/autoremove/reboot | ✅ | `70` §2 |
| Nouveaux champs/actions rétrocompatibles | ✅ | `40` §1/§2 |
## §8 Focus post-install
| Case | État | Renvoi |
|---|---|---|
| Interdit questions interactives SSH → champs formulaire | ✅ | `30` §1 |
| Profils cochables dépliant leurs champs | ✅ | `30` §1/§3 |
| Manifeste (`id`/`label`/`description`/`fields`/défauts/validations/preview/risk/confirmations) | ✅ | `30` §1/§3 |
| Bouton désactivé si champs invalides | ✅ | `30` §1 |
| Preview avec masquage secrets + signalement réseau/reboot | ✅ | `30` §1, `70` §1 |
| Échec structuré si décision manquante | ✅ | `30` §1/§4 |
| Profils attendus (bootstrap_root/identity_network/base_tools/network_tools/dev_git/sharing/docker_official/vm_guest_tools + optionnels) | ✅ | `30` §2 |
| Champs `identity_network` | ✅ | `30` §3 |
| Champs `docker_official` | ✅ | `30` §3 |
| Champs `sharing` | ✅ | `30` §3 |
| Champs `vm_guest_tools` | ✅ | `30` §3 |
| Champs préremplis modifiables | ✅ | `30` §3 |
| Exemple de manifeste | ✅ | `30` §3 |
| Templates custom attendus (bootstrap/identity/install-package-groups/docker-official/sharing/vm-guest-tools) | ✅ | `30` §4 |
| Sources citées | ✅ | `30` §4 |
| identity_network à risque (confirmation/preview/sauvegardes) | ✅ | `30` §4.2, `70` §2 |
| Résultat JSON ancien/nouveau endpoint + reconnectHost | ✅ | `40` §4 (`PostInstallResult.networkChange`), `30` §4.2 |
| Pas de coupure sans stratégie reconnexion ; reboot via reboot_verified | ✅ | `30` §4.2 |
| Webapp vérifie reconnexion + met à jour machine | ✅ | `30` §4.2 |
| Erreurs réseau distinguées (6 codes) | ✅ | `50` §4 |
| `ExecutionResult.postInstall` rétrocompatible | ✅ | `40` §4 |
| Résultat liste profils/variables non sensibles/fichiers/paquets/services/reboots/erreurs | ✅ | `40` §4 |
| Secrets jamais inclus | ✅ | `30` §5, `70` §1 |
| Changements réseau/Docker marqués dans rapport MD | ✅ | `30` §5, `70` §4 |
| Même mécanique (templates/preview/SSH/WS/executions/rapport/log) | ✅ | `30` §6 |
| Valeurs réutilisables stockées (où) | ✅ | `30` §6 (`script_variables_presets`/`machine_profile_state`) |
| Hermes propose/explique, JSON réduit, pas de déclenchement risqué | ✅ | `30` §6, `70` §2/§3 |
| Profils découpés en sous-jalons indépendants | ✅ | `80` SJ-8/SJ-9 |
---
## Réserves résiduelles (⚠️)
1. **Non-régression build/tests (§4)** : non exécutée dans cette mission de design (aucun code touché, par consigne). L'orchestrateur doit lancer `pnpm check/test/build` pour confirmer 0 régression — attendu vert puisque aucune modification de code.
2. **Rendu UI fin (§7/§8 « UI avant/après »)** : le design pose les données et les exigences ; le rendu visuel exact relève de la **tâche 3**. Couvert au niveau contrat/exigence, pas au niveau JSX.
3. **Détails Mustache vs Go-templates Docker** : les `{{ }}` de `docker inspect --format` entrent en conflit avec Mustache ; le pseudo-shell le signale (échappement) — choix de délimiteurs à figer en implémentation (SJ-4).
Aucune réserve bloquante identifiée. Verdict visé : **✅ Accepté**.
@@ -0,0 +1,896 @@
# Jalon 2 — Polish design system — Implementation Plan
> **⚠️ STATUT (2026-06-05) : ABSORBÉ PAR LA TÂCHE 3.** La roadmap `liste_taches.md` / `coherence_taches.md` regroupe tout le frontend (layout, tuiles, volet Hermes, terminal, paramètres, thème, status bar, icônes) dans la **tâche 3 (design frontend)**, gate `validation_tache3.md`. Ce plan jalon-2 reste valide comme **matériau d'implémentation du polish** : le wiring DS (exports ESM + Font Awesome + polices, Tasks 1-4) est **déjà commité** et acquis ; les Tasks 5-12 (Header, StatusBar, refonte MachineTile/AddMachineModal/TerminalPanel/Dashboard/App) seront **implémentées plus tard dans le cadre de la tâche 3**, après validation de son design. Ne pas exécuter ce plan isolément.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refondre l'UI existante avec les composants du design system Gruvbox (Button, IconButton, StatusLed, Popup), brancher Font Awesome + les polices en offline, ajouter un header (titre + ajout + bascule thème) et une status bar tmux, et rendre le terminal non ambigu entre machines.
**Architecture:** Frontend React/Vite. Le `ui-kit.tsx` (design system) passe en exports ESM. L'état (machines, compteurs, machine sélectionnée, thème) remonte dans `App`, qui distribue en props au Header, au Dashboard (présentationnel), à la StatusBar et au TerminalPanel. Helpers purs (`theme`, `stats`) testés ; le reste est vérifié visuellement.
**Tech Stack:** React 19, Vite 6, @xterm/xterm, @fortawesome/fontawesome-free, @fontsource/{inter,jetbrains-mono,share-tech-mono}, vitest.
---
## File Structure
```
client/src/
├─ main.tsx # MODIF: imports CSS FA + polices
├─ App.tsx # MODIF: remontée d'état, header+statusbar+thème
├─ components/ui-kit.tsx # MODIF: ajout exports ESM (1 ligne)
├─ styles/app.css # MODIF: classes header/statusbar/input/term-header
├─ lib/
│ ├─ theme.ts # NOUVEAU: thème (getInitial/apply/next)
│ ├─ theme.test.ts # NOUVEAU
│ ├─ stats.ts # NOUVEAU: sumUpdates
│ └─ stats.test.ts # NOUVEAU
├─ panels/
│ ├─ Header.tsx # NOUVEAU
│ ├─ StatusBar.tsx # NOUVEAU
│ ├─ HermesPanel.tsx # MODIF: label + Icon
│ ├─ Dashboard.tsx # MODIF: présentationnel (props)
│ └─ TerminalPanel.tsx # MODIF: machine + en-tête + bannière
└─ features/machines/
├─ MachineTile.tsx # MODIF: StatusLed + IconButton
└─ AddMachineModal.tsx # MODIF: Popup + Button
vitest.config.ts # MODIF: inclure client/**/*.test.ts
package.json # MODIF: deps FA + fontsource
```
Le composant `ui-kit.tsx` ne doit JAMAIS être importé dans un test (il touche `window`/`document` au chargement → KO en environnement node). Les tests ne portent que sur `lib/theme.ts` et `lib/stats.ts` (purs, node-safe).
---
## Task 1: Brancher le design system (deps + exports + CSS)
**Files:**
- Modify: `package.json`
- Modify: `client/src/components/ui-kit.tsx` (fin de fichier)
- Modify: `client/src/main.tsx`
- [ ] **Step 1: Installer les dépendances**
Run:
```bash
rtk pnpm add @fortawesome/fontawesome-free @fontsource/inter @fontsource/jetbrains-mono @fontsource/share-tech-mono
```
Expected: 4 paquets ajoutés dans `dependencies`, `pnpm-lock.yaml` mis à jour, install OK.
- [ ] **Step 2: Exporter les composants du design system**
Ajouter à la toute fin de `client/src/components/ui-kit.tsx` (après le dernier `})();`) :
```ts
export {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
};
```
Ne rien supprimer (garder `// @ts-nocheck`, l'import React, le `Object.assign(window, …)`).
- [ ] **Step 3: Importer FA + polices dans main.tsx**
Remplacer le contenu de `client/src/main.tsx` par :
```tsx
import React from "react";
import { createRoot } from "react-dom/client";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "@fontsource/inter";
import "@fontsource/jetbrains-mono";
import "@fontsource/share-tech-mono";
import "./styles/app.css";
import { App } from "./App.js";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
```
- [ ] **Step 4: Vérifier le build**
Run: `rtk pnpm check && rtk pnpm vite build`
Expected: `pnpm check` 0 erreur, build OK (`dist/client` produit, le CSS FA/polices intégré).
- [ ] **Step 5: Commit**
```bash
rtk git add package.json pnpm-lock.yaml client/src/components/ui-kit.tsx client/src/main.tsx
rtk git commit -m "feat(ui): brancher le design system (exports ESM, Font Awesome, polices offline)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: Helper thème (TDD)
**Files:**
- Modify: `vitest.config.ts`
- Create: `client/src/lib/theme.ts`, `client/src/lib/theme.test.ts`
- [ ] **Step 1: Inclure les tests client dans vitest**
Dans `vitest.config.ts`, remplacer la ligne `include` par :
```ts
include: ["server/**/*.test.ts", "shared/**/*.test.ts", "client/**/*.test.ts"],
```
- [ ] **Step 2: Écrire le test (échec attendu)**
`client/src/lib/theme.test.ts` :
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { nextTheme, getInitialTheme } from "./theme.js";
describe("nextTheme", () => {
it("bascule dark <-> light", () => {
expect(nextTheme("dark")).toBe("light");
expect(nextTheme("light")).toBe("dark");
});
});
describe("getInitialTheme", () => {
beforeEach(() => {
// @ts-expect-error - environnement node sans localStorage
delete globalThis.localStorage;
});
it("retombe sur dark sans localStorage", () => {
expect(getInitialTheme()).toBe("dark");
});
it("lit la valeur persistée si présente", () => {
const store: Record<string, string> = { "su-theme": "light" };
// @ts-expect-error - stub minimal
globalThis.localStorage = { getItem: (k: string) => store[k] ?? null };
expect(getInitialTheme()).toBe("light");
});
});
```
- [ ] **Step 3: Lancer le test (échec)**
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
Expected: FAIL — module `./theme.js` introuvable.
- [ ] **Step 4: Implémenter `client/src/lib/theme.ts`**
```ts
// client/src/lib/theme.ts
export type Theme = "dark" | "light";
const KEY = "su-theme";
export function nextTheme(t: Theme): Theme {
return t === "dark" ? "light" : "dark";
}
export function getInitialTheme(): Theme {
try {
const v = globalThis.localStorage?.getItem(KEY);
return v === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
export function applyTheme(t: Theme): void {
try {
document.documentElement.dataset.theme = t;
globalThis.localStorage?.setItem(KEY, t);
} catch {
/* localStorage indisponible (mode privé) : on ignore la persistance */
}
}
```
- [ ] **Step 5: Lancer le test (succès)**
Run: `rtk pnpm vitest run client/src/lib/theme.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 6: Commit**
```bash
rtk git add vitest.config.ts client/src/lib/theme.ts client/src/lib/theme.test.ts
rtk git commit -m "feat(ui): helper de thème dark/light persisté (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: Helper stats (TDD)
**Files:**
- Create: `client/src/lib/stats.ts`, `client/src/lib/stats.test.ts`
- [ ] **Step 1: Écrire le test (échec attendu)**
`client/src/lib/stats.test.ts` :
```ts
import { describe, it, expect } from "vitest";
import { sumUpdates } from "./stats.js";
describe("sumUpdates", () => {
it("somme les compteurs", () => {
expect(sumUpdates({ a: 2, b: 3, c: 0 })).toBe(5);
});
it("retourne 0 pour un objet vide", () => {
expect(sumUpdates({})).toBe(0);
});
});
```
- [ ] **Step 2: Lancer le test (échec)**
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3: Implémenter `client/src/lib/stats.ts`**
```ts
// client/src/lib/stats.ts
export function sumUpdates(counts: Record<string, number>): number {
return Object.values(counts).reduce((acc, n) => acc + n, 0);
}
```
- [ ] **Step 4: Lancer le test (succès)**
Run: `rtk pnpm vitest run client/src/lib/stats.test.ts`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
rtk git add client/src/lib/stats.ts client/src/lib/stats.test.ts
rtk git commit -m "feat(ui): helper sumUpdates (TDD)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4: Classes CSS (header, status bar, inputs, en-tête terminal)
**Files:**
- Modify: `client/src/styles/app.css`
- [ ] **Step 1: Mettre à jour `client/src/styles/app.css`**
Remplacer tout le contenu par :
```css
@import "./tokens.css";
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font-ui);
background: var(--bg-1);
color: var(--ink-1);
}
/* Ossature : header / rangée 3 volets / status bar */
.su-app { display: flex; flex-direction: column; height: 100vh; }
.su-row { flex: 1; display: flex; min-height: 0; }
.su-header {
height: 52px; flex: 0 0 52px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
background: var(--bg-2);
border-bottom: 1px solid var(--border-1);
}
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
.su-spacer { flex: 1; }
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
.su-center { flex: 1; overflow: auto; padding: 18px; }
.su-terminal-wrap { width: 360px; min-width: 320px; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); }
.su-terminal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-1); }
.su-terminal { flex: 1; min-height: 0; padding: 6px; }
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
/* Status bar style tmux */
.su-statusbar {
height: 26px; flex: 0 0 26px;
display: flex; align-items: stretch;
background: var(--bg-2);
border-top: 1px solid var(--border-1);
font-family: var(--font-terminal);
font-size: 11px;
}
.su-statusbar .cell { display: flex; align-items: center; padding: 0 12px; border-right: 1px solid var(--border-1); color: var(--ink-2); }
.su-statusbar .cell.mode { background: var(--accent); color: var(--bg-1); font-weight: 700; letter-spacing: 0.08em; }
.su-statusbar .clock { margin-left: auto; border-right: none; border-left: 1px solid var(--border-1); }
/* Champs de formulaire tokenisés */
.su-field {
padding: 9px 12px; font-size: 13px; font-family: var(--font-ui);
background: var(--bg-1); color: var(--ink-1);
border: 1px solid var(--border-2); border-radius: 8px;
outline: none;
}
.su-field:focus { border-color: var(--accent-soft); }
```
- [ ] **Step 2: Vérifier le build**
Run: `rtk pnpm vite build`
Expected: build OK.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/styles/app.css
rtk git commit -m "feat(ui): classes layout header/statusbar/inputs/terminal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 5: Composant Header
**Files:**
- Create: `client/src/panels/Header.tsx`
- [ ] **Step 1: Créer `client/src/panels/Header.tsx`**
```tsx
// client/src/panels/Header.tsx
import { Button, IconButton } from "../components/ui-kit.js";
import type { Theme } from "../lib/theme.js";
interface Props {
theme: Theme;
onToggleTheme: () => void;
onAdd: () => void;
}
export function Header({ theme, onToggleTheme, onAdd }: Props) {
return (
<header className="su-header">
<h1>System Update</h1>
<div className="su-spacer" />
<Button variant="primary" icon="plus" onClick={onAdd}>Ajouter</Button>
<IconButton
icon={theme === "dark" ? "sun" : "moon"}
label={theme === "dark" ? "Thème clair" : "Thème sombre"}
onClick={onToggleTheme}
/>
</header>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/Header.tsx
rtk git commit -m "feat(ui): header (titre, ajout, bascule thème)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 6: Composant StatusBar
**Files:**
- Create: `client/src/panels/StatusBar.tsx`
- [ ] **Step 1: Créer `client/src/panels/StatusBar.tsx`**
```tsx
// client/src/panels/StatusBar.tsx
import { useEffect, useState } from "react";
import { sumUpdates } from "../lib/stats.js";
interface Props {
machineCount: number;
counts: Record<string, number>;
}
export function StatusBar({ machineCount, counts }: Props) {
const [clock, setClock] = useState(() => new Date().toLocaleTimeString("fr-FR"));
useEffect(() => {
const id = setInterval(() => setClock(new Date().toLocaleTimeString("fr-FR")), 1000);
return () => clearInterval(id);
}, []);
return (
<footer className="su-statusbar">
<div className="cell mode">SYSTEM UPDATE</div>
<div className="cell">{machineCount} machine{machineCount > 1 ? "s" : ""}</div>
<div className="cell">{sumUpdates(counts)} update{sumUpdates(counts) > 1 ? "s" : ""}</div>
<div className="cell clock">{clock}</div>
</footer>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/StatusBar.tsx
rtk git commit -m "feat(ui): status bar tmux (mode, compteurs, horloge live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 7: Refonte MachineTile (StatusLed + IconButton)
**Files:**
- Modify: `client/src/features/machines/MachineTile.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/MachineTile.tsx`**
```tsx
// client/src/features/machines/MachineTile.tsx
import type { MachineView } from "@shared/types.js";
import { StatusLed, IconButton } from "../../components/ui-kit.js";
interface Props {
machine: MachineView;
packageCount: number;
selected: boolean;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
}
// Map statut machine -> statut StatusLed du design system
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
};
export function MachineTile({ machine, packageCount, selected, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
return (
<div
className="glass interactive"
style={{ padding: 16, borderRadius: 10, border: selected ? "1px solid var(--accent-soft)" : "1px solid transparent" }}
onClick={() => onSelect(machine.id)}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StatusLed status={LED[machine.status] ?? "off"} pulse={machine.status === "running"} />
<strong>{machine.name}</strong>
</div>
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12, marginTop: 4 }}>
{machine.hostname}:{machine.port} · {machine.osFamily}
</div>
<div style={{ margin: "10px 0", fontSize: 13 }}>
<span className="label">UPDATES</span>{" "}
<span className="mono">{packageCount}</span>
</div>
<div style={{ display: "flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
<IconButton icon="refresh" label="Rafraîchir" onClick={() => onRefresh(machine.id)} size={30} />
<IconButton icon="download" label="Upgrade" onClick={() => onUpgrade(machine.id)} size={30} />
<IconButton icon="power" label="Redémarrer" danger onClick={() => onReboot(machine.id)} size={30} />
</div>
</div>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur. (Le prop `selected` sera fourni par le Dashboard en Task 10.)
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/features/machines/MachineTile.tsx
rtk git commit -m "feat(ui): tuile machine avec StatusLed + IconButton (tooltips)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 8: Refonte AddMachineModal (Popup + Button)
**Files:**
- Modify: `client/src/features/machines/AddMachineModal.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/features/machines/AddMachineModal.tsx`**
```tsx
// client/src/features/machines/AddMachineModal.tsx
import { useState } from "react";
import { Popup, Button, StatusLed } from "../../components/ui-kit.js";
interface Props { onClose: () => void; onCreated: () => void; }
export function AddMachineModal({ onClose, onCreated }: Props) {
const [form, setForm] = useState({ name: "", hostname: "", port: 22, username: "", password: "", sudoPassword: "" });
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const set = (k: string, v: string | number) => setForm({ ...form, [k]: v });
async function submit() {
setBusy(true); setError(null);
try {
const res = await fetch("/api/machines", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }),
});
if (!res.ok) throw new Error((await res.json()).error ?? "Échec");
onCreated(); onClose();
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
return (
<Popup
open
onClose={onClose}
title="Ajouter une machine"
width={400}
footer={
<>
<Button variant="ghost" onClick={onClose}>Annuler</Button>
<Button variant="primary" icon="download" onClick={submit}>{busy ? "Test…" : "Ajouter"}</Button>
</>
}
>
<div style={{ display: "grid", gap: 10 }}>
<input className="su-field" placeholder="nom" value={form.name} onChange={(e) => set("name", e.target.value)} />
<input className="su-field" placeholder="hostname / IP" value={form.hostname} onChange={(e) => set("hostname", e.target.value)} />
<input className="su-field" placeholder="username" value={form.username} onChange={(e) => set("username", e.target.value)} />
<input className="su-field" placeholder="port" type="number" value={form.port} onChange={(e) => set("port", e.target.value)} />
<input className="su-field" placeholder="password" type="password" value={form.password} onChange={(e) => set("password", e.target.value)} />
<input className="su-field" placeholder="sudo password (optionnel)" type="password" value={form.sudoPassword} onChange={(e) => set("sudoPassword", e.target.value)} />
{error && (
<div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--err)", fontSize: 12 }}>
<StatusLed status="err" /> {error}
</div>
)}
</div>
</Popup>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/features/machines/AddMachineModal.tsx
rtk git commit -m "feat(ui): modale d'ajout avec Popup + Button du design system
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 9: Refonte TerminalPanel (machine + en-tête + bannière de séparation)
**Files:**
- Modify: `client/src/panels/TerminalPanel.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/TerminalPanel.tsx`**
```tsx
// client/src/panels/TerminalPanel.tsx
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import type { MachineView } from "@shared/types.js";
import { connectOutput } from "../lib/ws.js";
import { StatusLed } from "../components/ui-kit.js";
const LED: Record<string, "ok" | "warn" | "err" | "info" | "off"> = {
ok: "ok", updates_available: "warn", error: "err", running: "info", unknown: "off",
};
export function TerminalPanel({ machine }: { machine: MachineView | null }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const term = new Terminal({
fontFamily: "'Share Tech Mono', monospace", fontSize: 12,
theme: { background: "#1d2021", foreground: "#ebdbb2" },
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(ref.current);
fit.fit();
if (machine) {
// Bannière de séparation franche entre machines (couleur accent ANSI 33).
const bar = "─".repeat(20);
term.writeln(`\x1b[33m${bar} ${machine.name} (${machine.hostname}) ${bar}\x1b[0m`);
} else {
term.writeln("# sélectionne une machine");
}
const disconnect = machine ? connectOutput(machine.id, (c) => term.write(c)) : () => {};
return () => { disconnect(); term.dispose(); };
}, [machine?.id]);
return (
<section className="su-terminal-wrap">
<div className="su-terminal-head">
<StatusLed status={machine ? (LED[machine.status] ?? "off") : "off"} />
<span className="label">TERMINAL</span>
{machine && <span className="mono" style={{ fontSize: 12 }}>{machine.name}</span>}
{machine && <span style={{ color: "var(--ink-3)", fontSize: 11 }}>{machine.hostname}</span>}
</div>
<div className="su-terminal" ref={ref} />
</section>
);
}
```
- [ ] **Step 2: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur. (Le prop `machine` sera fourni par `App` en Task 11.)
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/panels/TerminalPanel.tsx
rtk git commit -m "feat(ui): terminal identifie la machine + bannière de séparation
Répond au retour d'usage (amelioration.md): séparation franche entre machines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 10: Dashboard présentationnel + HermesPanel
**Files:**
- Modify: `client/src/panels/Dashboard.tsx`
- Modify: `client/src/panels/HermesPanel.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/panels/Dashboard.tsx`**
```tsx
// client/src/panels/Dashboard.tsx
import type { MachineView } from "@shared/types.js";
import { MachineTile } from "../features/machines/MachineTile.js";
interface Props {
machines: MachineView[];
counts: Record<string, number>;
selectedId: string | null;
onSelect: (id: string) => void;
onRefresh: (id: string) => void;
onUpgrade: (id: string) => void;
onReboot: (id: string) => void;
}
export function Dashboard({ machines, counts, selectedId, onSelect, onRefresh, onUpgrade, onReboot }: Props) {
return (
<main className="su-center">
<h2 style={{ margin: "0 0 16px" }}>Machines</h2>
{machines.length === 0 && (
<p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « Ajouter » dans l'en-tête.</p>
)}
<div className="su-tiles">
{machines.map((m) => (
<MachineTile
key={m.id}
machine={m}
packageCount={counts[m.id] ?? 0}
selected={selectedId === m.id}
onSelect={onSelect}
onRefresh={onRefresh}
onUpgrade={onUpgrade}
onReboot={onReboot}
/>
))}
</div>
</main>
);
}
```
- [ ] **Step 2: Remplacer le contenu de `client/src/panels/HermesPanel.tsx`**
```tsx
// client/src/panels/HermesPanel.tsx
import { Icon } from "../components/ui-kit.js";
export function HermesPanel() {
return (
<aside className="su-hermes">
<div className="label" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Icon name="bell" size={13} /> HERMES
</div>
<p style={{ color: "var(--ink-3)", fontSize: 13 }}>
Copilote d'exploitation à venir. Analyse des mises à jour, plans et rapports
seront disponibles ici dans un prochain jalon.
</p>
</aside>
);
}
```
- [ ] **Step 3: Vérifier la compilation**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 4: Commit**
```bash
rtk git add client/src/panels/Dashboard.tsx client/src/panels/HermesPanel.tsx
rtk git commit -m "feat(ui): Dashboard présentationnel (props) + HermesPanel iconé
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 11: App — remontée d'état, thème, header + status bar
**Files:**
- Modify: `client/src/App.tsx`
- [ ] **Step 1: Remplacer le contenu de `client/src/App.tsx`**
```tsx
// client/src/App.tsx
import { useCallback, useEffect, useState } from "react";
import type { MachineView } from "@shared/types.js";
import { api } from "./lib/api.js";
import { getInitialTheme, applyTheme, nextTheme, type Theme } from "./lib/theme.js";
import { Header } from "./panels/Header.js";
import { StatusBar } from "./panels/StatusBar.js";
import { HermesPanel } from "./panels/HermesPanel.js";
import { Dashboard } from "./panels/Dashboard.js";
import { TerminalPanel } from "./panels/TerminalPanel.js";
import { AddMachineModal } from "./features/machines/AddMachineModal.js";
export function App() {
const [machines, setMachines] = useState<MachineView[]>([]);
const [counts, setCounts] = useState<Record<string, number>>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [theme, setTheme] = useState<Theme>("dark");
const load = useCallback(async () => {
const ms = await api.listMachines();
setMachines(ms);
const entries = await Promise.all(
ms.map(async (m) => {
try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; }
catch { return [m.id, 0] as const; }
}),
);
setCounts(Object.fromEntries(entries));
}, []);
useEffect(() => {
const initial = getInitialTheme();
setTheme(initial);
applyTheme(initial);
void load();
}, [load]);
const toggleTheme = () => {
const t = nextTheme(theme);
setTheme(t);
applyTheme(t);
};
const onRefresh = (id: string) => { setSelectedId(id); void api.refresh(id).then(load); };
const onUpgrade = (id: string) => { setSelectedId(id); void api.runAction(id, "apt_full_upgrade"); };
const onReboot = (id: string) => { setSelectedId(id); void api.runAction(id, "reboot"); };
const selected = machines.find((m) => m.id === selectedId) ?? null;
return (
<div className="su-app">
<Header theme={theme} onToggleTheme={toggleTheme} onAdd={() => setAdding(true)} />
<div className="su-row">
<HermesPanel />
<Dashboard
machines={machines}
counts={counts}
selectedId={selectedId}
onSelect={setSelectedId}
onRefresh={onRefresh}
onUpgrade={onUpgrade}
onReboot={onReboot}
/>
<TerminalPanel machine={selected} />
</div>
<StatusBar machineCount={machines.length} counts={counts} />
{adding && <AddMachineModal onClose={() => setAdding(false)} onCreated={load} />}
</div>
);
}
```
- [ ] **Step 2: Vérifier compilation + build complet**
Run: `rtk pnpm check && rtk pnpm vite build`
Expected: 0 erreur TS, build OK.
- [ ] **Step 3: Commit**
```bash
rtk git add client/src/App.tsx
rtk git commit -m "feat(ui): App orchestre état+thème, header et status bar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 12: Vérification finale (build + tests + deux thèmes)
**Files:** aucun (vérification).
- [ ] **Step 1: Suite complète**
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: `check` 0 erreur ; tests verts (serveur 19 + theme 3 + stats 2 = 24) ; build OK (`dist/index.js` + `dist/client`).
- [ ] **Step 2: Vérification visuelle manuelle (utilisateur, navigateur)**
Lancer `pnpm dev`, ouvrir `http://localhost:5173`, vérifier :
- Header avec titre, bouton « Ajouter » (icône +), bascule thème (soleil/lune) qui change l'apparence et persiste après rechargement (F5).
- Les **deux thèmes** (dark ET light) restent lisibles et cohérents.
- Icônes Font Awesome affichées (pas de carré vide), polices Inter/JetBrains Mono/Share Tech Mono appliquées.
- Tuiles : StatusLed colorée selon l'état, 3 IconButton avec **tooltips** au survol, **pas de hover** (pression 3D au clic seulement), sélection visible (bordure accent).
- Modale d'ajout = Popup (titre, bouton fermer, footer Annuler/Ajouter).
- Status bar en bas : « SYSTEM UPDATE » + nb machines + nb updates + **horloge qui avance**.
- Terminal : en-tête avec nom + hostname de la machine ; en sélectionnant une autre machine, **bannière de séparation** claire (ligne accent avec nom/hostname). Plus d'UUID affiché.
- [ ] **Step 3: Commit éventuel de finition**
S'il a fallu un ajustement après vérif visuelle, le committer :
```bash
rtk git add -A
rtk git commit -m "fix(ui): ajustements après vérification visuelle des deux thèmes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review (couverture du spec)
- **Wiring DS (exports ESM + FA + polices)** → Task 1. ✓
- **ui-kit jamais importé en test** → tests uniquement sur lib/theme + lib/stats (Tasks 2, 3) ; vitest include ajoute `client/**` mais les seuls tests client sont ces helpers purs. ✓
- **Header (titre, ajout, bascule thème)** → Task 5, câblé en Task 11. ✓
- **Status bar tmux (mode, compteurs, horloge)** → Task 6, câblée Task 11. ✓
- **Thème dark/light persisté** → Task 2 (`lib/theme`), appliqué Task 11. ✓
- **MachineTile : StatusLed + IconButton (tooltips), danger reboot** → Task 7. ✓
- **AddMachineModal : Popup + Button** → Task 8. ✓
- **Dashboard présentationnel** → Task 10. ✓
- **TerminalPanel : machine nommée + bannière de séparation (retour amelioration.md)** → Task 9. ✓
- **Remontée d'état dans App** → Task 11. ✓
- **Deux thèmes vérifiés** → Task 12 step 2. ✓
- **Tests helpers + build** → Tasks 2, 3, 12. ✓
Pas de placeholder. Noms cohérents entre tâches : `MachineTile` reçoit `selected` (Task 7) fourni par Dashboard `selectedId===m.id` (Task 10) depuis App `selectedId` (Task 11) ; `TerminalPanel` reçoit `machine` (Task 9) fourni par App `selected` (Task 11) ; helpers `getInitialTheme`/`applyTheme`/`nextTheme`/`sumUpdates` utilisés tels que définis.
@@ -0,0 +1,659 @@
# Tâche 1.9 — Phase 1 (schéma BDD socle) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implémenter la Phase 1 du schéma BDD cible (`tache1.9.md §14`) : étendre `machines/snapshots/executions`, créer les tables socle (`machine_state`, `machine_hardware`, `machine_metrics_latest`, `machine_events`, `important_messages`, `reports`, `raw_artifacts`), et alimenter l'état dérivé + la timeline lors des refresh/exécutions existants.
**Architecture:** Extension additive (rétro-compatible) du schéma Drizzle/SQLite. Migration générée par drizzle-kit. Un service `machineState` dérive l'état courant d'une machine depuis un snapshot/exécution et l'« upsert » dans `machine_state` ; `refreshMachine` et `runAction` (existants) sont enrichis pour peupler `machine_state`, `machine_events`, et (pour les exécutions) `reports` + `raw_artifacts`, ainsi que les nouveaux champs `kind/schema_version/important_json` des snapshots/exécutions. **Aucune modification de l'API ni du frontend** (réservé tâches 3/5).
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
---
## Contexte & invariants
- État actuel : `server/db/schema.ts` contient `machines`, `snapshots`, `executions` (jalon 1, en prod). Voir le fichier.
- **Rétro-compatibilité stricte** : on AJOUTE des colonnes/tables ; on ne renomme ni ne supprime rien. En particulier on conserve `snapshots.checked_at` (le design tache1.9 le nomme `created_at`, mais le code jalon 1 `refresh.ts` utilise `checkedAt` — on ne casse pas).
- Les nouveaux champs sont nullable ou ont une valeur par défaut, pour que les lignes du jalon 1 restent valides.
- `payload_json` / `result_json` restent la vérité canonique ; `machine_state` n'est qu'un cache dérivé pour l'UI (jamais source de vérité métier).
- Pas de FK vers des tables non encore créées (jobs/action_requests/schedules = phases ultérieures) : `running_job_id`, `request_id`, `job_id` sont de simples colonnes `text` nullable.
- Ne pas committer (l'utilisateur gère les commits en fin de parcours). Les étapes « Commit » du template sont **remplacées par une vérification** ; ne PAS exécuter `git commit`.
> **Note exécution** : ce plan se construit sur l'état courant du working tree (qui contient du WIP non commité : feature `capabilities`, scaffold Rust). Ne pas annuler ce WIP. Les fichiers touchés ici (`server/db/*`, `server/services/*`) ne chevauchent pas le WIP `capabilities`/frontend.
---
## File Structure
```
server/db/
├─ schema.ts # MODIF : +colonnes machines/snapshots/executions, +7 tables
├─ migrations/ # +1 migration générée (drizzle-kit)
└─ schema.test.ts # NOUVEAU : test que la migration applique le schéma cible
server/services/
├─ machineState.ts # NOUVEAU : dériver + upsert machine_state, insert events
├─ machineState.test.ts # NOUVEAU : tests purs de dérivation
├─ refresh.ts # MODIF : peupler snapshot.kind/schema_version/important_json + machine_state + event
└─ execute.ts # MODIF : champs executions + machine_state + event + reports + raw_artifacts
```
---
## Task 1 : Étendre le schéma Drizzle + migration
**Files:**
- Modify: `server/db/schema.ts`
- Create: migration sous `server/db/migrations/` (générée)
- Create: `server/db/schema.test.ts`
- [ ] **Step 1 : Remplacer le contenu de `server/db/schema.ts`**
> **⚠️ Tree déplacé** : `schema.ts` contient déjà la table `apiClients` (WIP api_clients, migration `0001_api_clients.sql`). Le contenu ci-dessous **préserve `apiClients`** (et l'import `uniqueIndex`). NE PAS supprimer `apiClients`. La migration générée à l'étape suivante sera donc `0002_*` (et non `0001`).
```ts
// server/db/schema.ts
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
export const machines = sqliteTable("machines", {
id: text("id").primaryKey(),
name: text("name").notNull(),
hostname: text("hostname").notNull(),
port: integer("port").notNull().default(22),
osFamily: text("os_family").notNull().default("unknown"),
osVersion: text("os_version"),
osCodename: text("os_codename"),
arch: text("arch"),
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
username: text("username").notNull(),
encPassword: text("enc_password").notNull(),
encSudoPassword: text("enc_sudo_password"),
aptProxyMode: text("apt_proxy_mode").notNull().default("direct"),
aptProxyUrl: text("apt_proxy_url"),
status: text("status").notNull().default("unknown"),
lastCheckedAt: text("last_checked_at"),
lastSeenAt: text("last_seen_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
deletedAt: text("deleted_at"),
});
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull().default("apt_update_analyze"),
schemaVersion: integer("schema_version").notNull().default(1),
checkedAt: text("checked_at").notNull(),
status: text("status").notNull(),
payloadJson: text("payload_json").notNull(),
importantJson: text("important_json"),
rawLogPath: text("raw_log_path"),
rawArtifactId: text("raw_artifact_id"),
sourceJobId: text("source_job_id"),
});
export const executions = sqliteTable("executions", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
action: text("action").notNull(),
mode: text("mode").notNull().default("manual"),
schemaVersion: integer("schema_version").notNull().default(1),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
status: text("status").notNull(),
requestId: text("request_id"),
jobId: text("job_id"),
resultJson: text("result_json"),
importantJson: text("important_json"),
reportPath: text("report_path"),
rawLogPath: text("raw_log_path"),
reportId: text("report_id"),
exitCode: integer("exit_code"),
errorKind: text("error_kind"),
errorMessage: text("error_message"),
});
export const machineState = sqliteTable("machine_state", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
status: text("status").notNull(),
aptStatus: text("apt_status"),
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
aptLastAnalyzeAt: text("apt_last_analyze_at"),
dockerStatus: text("docker_status"),
dockerInstalled: integer("docker_installed").notNull().default(0),
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
postInstallStatus: text("post_install_status"),
metricsLastCollectedAt: text("metrics_last_collected_at"),
cpuLoad1: real("cpu_load1"),
memoryUsedPercent: real("memory_used_percent"),
rootUsedPercent: real("root_used_percent"),
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
runningJobId: text("running_job_id"),
lastErrorKind: text("last_error_kind"),
lastErrorMessage: text("last_error_message"),
updatedAt: text("updated_at").notNull(),
});
export const machineHardware = sqliteTable("machine_hardware", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
probeSnapshotId: text("probe_snapshot_id"),
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
memoryBytes: integer("memory_bytes"),
gpusJson: text("gpus_json"),
disksJson: text("disks_json"),
networkJson: text("network_json"),
firmwareJson: text("firmware_json"),
driverJson: text("driver_json"),
warningsJson: text("warnings_json"),
updatedAt: text("updated_at").notNull(),
});
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
snapshotId: text("snapshot_id"),
collectedAt: text("collected_at").notNull(),
cpuLoad1: real("cpu_load1"),
cpuLoad5: real("cpu_load5"),
cpuCores: integer("cpu_cores"),
memoryTotalBytes: integer("memory_total_bytes"),
memoryUsedBytes: integer("memory_used_bytes"),
memoryAvailableBytes: integer("memory_available_bytes"),
memoryUsedPercent: real("memory_used_percent"),
filesystemsJson: text("filesystems_json"),
rootUsedPercent: real("root_used_percent"),
warningsJson: text("warnings_json"),
});
export const machineEvents = sqliteTable("machine_events", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
severity: text("severity").notNull(), // info | warning | error
createdAt: text("created_at").notNull(),
actorType: text("actor_type"), // user | system | schedule | hermes
actorId: text("actor_id"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
jobId: text("job_id"),
message: text("message"),
payloadJson: text("payload_json"),
});
export const importantMessages = sqliteTable("important_messages", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
source: text("source").notNull(), // apt | docker | post_install | ssh | system
category: text("category").notNull(), // error | warning | future_major_change | ...
severity: text("severity").notNull(),
packageName: text("package_name"),
component: text("component"),
message: text("message").notNull(),
rawLineRef: text("raw_line_ref"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
acknowledged: integer("acknowledged").notNull().default(0),
acknowledgedAt: text("acknowledged_at"),
acknowledgedBy: text("acknowledged_by"),
payloadJson: text("payload_json"),
});
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
executionId: text("execution_id"),
kind: text("kind").notNull(), // machine | global | cleanup | hermes
title: text("title").notNull(),
path: text("path").notNull(),
createdAt: text("created_at").notNull(),
pinned: integer("pinned").notNull().default(0),
summaryJson: text("summary_json"),
});
export const rawArtifacts = sqliteTable("raw_artifacts", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
path: text("path").notNull(),
bytes: integer("bytes"),
sha256: text("sha256"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at"),
pinned: integer("pinned").notNull().default(0),
redacted: integer("redacted").notNull().default(1),
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
deletedAt: text("deleted_at"),
deleteReason: text("delete_reason"),
metadataJson: text("metadata_json"),
});
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
export const apiClients = sqliteTable(
"api_clients",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
tokenPrefix: text("token_prefix").notNull(),
tokenHash: text("token_hash").notNull(),
scopesJson: text("scopes_json").notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
revokedAt: text("revoked_at"),
},
(table) => ({
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
}),
);
```
> Avant d'écrire : relire l'état RÉEL de `server/db/schema.ts` (`rtk read server/db/schema.ts`). Si `apiClients` y a évolué (colonnes différentes), reprendre sa définition à l'identique plutôt que celle ci-dessus, pour ne pas régresser le WIP. N'ajouter que les colonnes étendues + les 7 nouvelles tables.
- [ ] **Step 2 : Générer la migration**
Run: `rtk pnpm db:generate`
Expected: un nouveau fichier `server/db/migrations/0002_*.sql` est créé (ALTER TABLE machines/snapshots/executions + CREATE TABLE des 7 nouvelles tables). La migration `0001_api_clients.sql` reste intacte. Aucune erreur drizzle-kit, aucun DROP de `api_clients`.
- [ ] **Step 3 : Écrire le test de migration `server/db/schema.test.ts`**
```ts
// server/db/schema.test.ts
import { describe, it, expect } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
function freshMigratedDb() {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./server/db/migrations" });
return sqlite;
}
function tableNames(sqlite: Database.Database): string[] {
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
}
function columnNames(sqlite: Database.Database, table: string): string[] {
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
}
describe("schéma Phase 1", () => {
it("crée les tables socle", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
for (const t of [
"machines", "snapshots", "executions",
"machine_state", "machine_hardware", "machine_metrics_latest",
"machine_events", "important_messages", "reports", "raw_artifacts",
]) {
expect(tables, `table ${t}`).toContain(t);
}
});
it("ajoute les colonnes étendues sans casser l'existant", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "machines")).toEqual(
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
);
expect(columnNames(sqlite, "snapshots")).toEqual(
expect.arrayContaining(["kind", "schema_version", "important_json"]),
);
expect(columnNames(sqlite, "executions")).toEqual(
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
);
// colonnes jalon 1 conservées
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
```
- [ ] **Step 4 : Lancer le test**
Run: `rtk pnpm vitest run server/db/schema.test.ts`
Expected: PASS (2 tests). Si une colonne attendue manque, corriger `schema.ts` et régénérer la migration (`rtk pnpm db:generate`) — ne pas modifier le test.
- [ ] **Step 5 : Vérifier la compilation + non-régression**
Run: `rtk pnpm check && rtk pnpm test`
Expected: 0 erreur TS ; toute la suite verte (les tests existants ne doivent pas casser).
- [ ] **Step 6 : (pas de commit — vérification seulement)**
Vérifier `git status` : seuls `server/db/schema.ts`, `server/db/migrations/0001_*.sql`, `server/db/schema.test.ts` ajoutés à la liste des modifs. Ne PAS committer.
---
## Task 2 : Service `machineState` (dérivation + upsert + events)
**Files:**
- Create: `server/services/machineState.ts`, `server/services/machineState.test.ts`
- [ ] **Step 1 : Écrire le test (échec attendu)**
```ts
// server/services/machineState.test.ts
import { describe, it, expect } from "vitest";
import { deriveAptState } from "./machineState.js";
import type { UpdateSnapshot } from "@shared/types.js";
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
};
describe("deriveAptState", () => {
it("dérive le bloc APT de machine_state depuis un snapshot", () => {
expect(deriveAptState(snap)).toEqual({
status: "updates_available",
aptStatus: "updates_available",
aptUpdatesCount: 3,
aptRebootRequired: 1,
aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
});
});
it("met rebootRequired à 0 quand absent", () => {
const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
});
});
```
- [ ] **Step 2 : Lancer (échec)**
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
Expected: FAIL — module introuvable.
- [ ] **Step 3 : Implémenter `server/services/machineState.ts`**
```ts
// server/services/machineState.ts
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import type { UpdateSnapshot } from "@shared/types.js";
export interface AptDerivedState {
status: string;
aptStatus: string;
aptUpdatesCount: number;
aptRebootRequired: number;
aptLastAnalyzeAt: string;
}
/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
return {
status: snapshot.status,
aptStatus: snapshot.status,
aptUpdatesCount: snapshot.apt.count,
aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
aptLastAnalyzeAt: snapshot.checkedAt,
};
}
type MachineStateInsert = typeof schema.machineState.$inferInsert;
/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
export function upsertMachineState(
machineId: string,
fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
): void {
const now = new Date().toISOString();
db.insert(schema.machineState)
.values({ machineId, updatedAt: now, ...fields })
.onConflictDoUpdate({
target: schema.machineState.machineId,
set: { ...fields, updatedAt: now },
})
.run();
}
/** Ajoute une ligne à la timeline machine_events. */
export function recordEvent(input: {
machineId: string;
eventType: string;
severity: "info" | "warning" | "error";
actorType?: string;
snapshotId?: string;
executionId?: string;
message?: string;
}): void {
db.insert(schema.machineEvents).values({
id: randomUUID(),
machineId: input.machineId,
eventType: input.eventType,
severity: input.severity,
createdAt: new Date().toISOString(),
actorType: input.actorType ?? "system",
snapshotId: input.snapshotId,
executionId: input.executionId,
message: input.message,
}).run();
}
/** Utilitaire interne réservé aux migrations/tests éventuels. */
export const _internal = { sql };
```
> Note : `_internal` exporte `sql` pour rester explicite sur l'import drizzle ; supprime-le si lint le signale inutilisé et retire l'import `sql` correspondant.
- [ ] **Step 4 : Lancer (succès)**
Run: `rtk pnpm vitest run server/services/machineState.test.ts`
Expected: PASS (2 tests). `deriveAptState` est pure et n'importe pas de DB au moment du test — mais le module importe `../db/client.js`. Si l'import de `db/client` fait échouer le test en environnement node (chargement better-sqlite3), refactorer en isolant la fonction pure dans le même fichier sans exécuter de requête à l'import (c'est déjà le cas : aucune requête n'est lancée à l'import). Le test n'appelle que `deriveAptState`.
- [ ] **Step 5 : Vérifier**
Run: `rtk pnpm check`
Expected: 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 3 : Peupler l'état + la timeline dans `refreshMachine`
**Files:**
- Modify: `server/services/refresh.ts`
- [ ] **Step 1 : Lire l'état actuel de `refresh.ts`**
Run: `rtk read server/services/refresh.ts`
Repère : la construction de `snapshot`, l'`insert` dans `schema.snapshots`, et l'`update` de `machines.status`.
- [ ] **Step 2 : Enrichir l'insertion du snapshot (kind/schema_version/important_json)**
Dans `refreshMachine`, remplacer l'insertion actuelle du snapshot par :
```ts
const snapshotId = randomUUID();
db.insert(schema.snapshots).values({
id: snapshotId,
machineId,
kind: "apt_update_analyze",
schemaVersion: 1,
checkedAt,
status,
payloadJson: JSON.stringify(snapshot),
importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
}).run();
```
(Le `randomUUID` est déjà importé dans `refresh.ts`.)
- [ ] **Step 3 : Mettre à jour `machine_state` + event après le snapshot**
Ajouter, juste après l'`update` de `machines` (status/lastCheckedAt), et après avoir importé en tête de fichier
`import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";` :
```ts
upsertMachineState(machineId, deriveAptState(snapshot));
recordEvent({
machineId,
eventType: "apt_refresh",
severity: status === "error" ? "error" : "info",
snapshotId,
message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
});
```
- [ ] **Step 4 : Vérifier compilation + tests**
Run: `rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts`
Expected: 0 erreur TS ; le test existant `extractSection` reste vert (il n'importe pas la DB grâce au mock en place).
- [ ] **Step 5 : (pas de commit)**
---
## Task 4 : Enrichir `runAction` (champs executions + state + event + reports + raw_artifacts)
**Files:**
- Modify: `server/services/execute.ts`
- [ ] **Step 1 : Lire l'état actuel de `execute.ts`**
Run: `rtk read server/services/execute.ts`
Repère : l'`insert` initial dans `executions`, le bloc d'archivage (`writeFileSync` du log + rapport), l'`update` final de `executions` et de `machines`.
- [ ] **Step 2 : Importer les helpers**
En tête de `execute.ts`, ajouter :
```ts
import { randomUUID } from "node:crypto"; // déjà présent — ne pas dupliquer
import { statSync } from "node:fs";
import { upsertMachineState, recordEvent } from "./machineState.js";
```
(Si `randomUUID` est déjà importé, n'ajouter que `statSync` et la ligne `machineState`.)
- [ ] **Step 3 : Mettre `running_job_id`/status dans machine_state au démarrage**
Juste après l'`update` initial de `machines` en `status: "running"` et l'`insert` de l'exécution (status `running`), ajouter :
```ts
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
```
- [ ] **Step 4 : Enrichir l'`update` final de l'exécution**
Remplacer l'`update` final de `schema.executions` par (ajout `schemaVersion`, `importantJson`, `exitCode`, `errorKind`, `errorMessage`, `reportId`) :
```ts
const reportId = randomUUID();
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
db.update(schema.executions).set({
finishedAt,
status,
schemaVersion: 1,
resultJson: JSON.stringify(result),
importantJson: JSON.stringify(result.importantLogLines),
reportPath,
rawLogPath,
reportId,
exitCode: exitMatch ? Number(exitMatch[1]) : null,
errorKind: status === "error" ? "execution_failed" : null,
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
}).where(eq(schema.executions.id, executionId)).run();
```
- [ ] **Step 5 : Insérer `reports` + `raw_artifacts` + state + event**
Juste après l'`update` final de `machines`, ajouter :
```ts
db.insert(schema.reports).values({
id: reportId,
machineId,
executionId,
kind: "machine",
title: `${m.name}${action}`,
path: reportPath,
createdAt: finishedAt,
}).run();
db.insert(schema.rawArtifacts).values({
id: randomUUID(),
machineId,
kind: "raw_log",
path: rawLogPath,
bytes: statSync(rawLogPath).size,
createdAt: finishedAt,
retentionPolicy: status === "error" ? "failed" : "default",
}).run();
upsertMachineState(machineId, {
status: status === "error" ? "error" : "unknown",
runningJobId: null,
lastErrorKind: status === "error" ? "execution_failed" : null,
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
});
recordEvent({
machineId,
eventType: `action_${action}`,
severity: status === "error" ? "error" : status === "warning" ? "warning" : "info",
executionId,
message: `Action ${action} : ${status}`,
});
```
- [ ] **Step 6 : Vérifier compilation + tests**
Run: `rtk pnpm check && rtk pnpm test`
Expected: 0 erreur TS ; suite complète verte.
- [ ] **Step 7 : (pas de commit)**
---
## Task 5 : Vérification finale Phase 1
**Files:** aucun (vérification).
- [ ] **Step 1 : Suite + build**
Run: `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: 0 erreur TS ; tests verts (jalon 1 + schema migration + machineState + helpers existants) ; `dist/index.js` + `dist/client` produits.
- [ ] **Step 2 : Démarrage runtime + migration appliquée**
Run (clé jetable, DB jetable) :
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/phase1-check.db SU_REPORTS_DIR=./data/phase1-reports
node dist/index.js > ./data/phase1.log 2>&1 &
sleep 3
curl -s localhost:8787/health
sqlite3 ./data/phase1-check.db ".tables" 2>/dev/null || echo "(sqlite3 absent — vérifier via le test de migration)"
kill %1 2>/dev/null
rm -rf ./data/phase1-check.db* ./data/phase1-reports ./data/phase1.log
```
Expected: `{"ok":true}` et la liste des tables inclut `machine_state`, `machine_events`, `reports`, `raw_artifacts`, etc. (migration appliquée au boot via `runMigrations()`).
- [ ] **Step 3 : Synthèse à l'utilisateur**
Reporter : tables/colonnes ajoutées, `machine_state`/`machine_events`/`reports`/`raw_artifacts` peuplés lors des refresh/exécutions, non-régression confirmée. **Ne pas committer** (l'utilisateur gère les commits en fin de parcours).
---
## Self-Review (couverture tache1.9 §14 Phase 1)
- machine_state → Task 1 (table) + Task 2/3/4 (peuplement). ✓
- machine_kind/virtualization/hardware_profile dans machines → Task 1. ✓
- machine_hardware → Task 1 (table ; producteur = tâche 4, hors Phase 1). ✓
- machine_metrics_latest → Task 1 (table ; producteur = tâche 4). ✓
- machine_events → Task 1 + Task 3/4 (peuplement). ✓
- important_messages → Task 1 (table ; peuplement fin = tâche 5/7, l'`important_json` du snapshot/exécution est déjà capturé). ✓
- reports → Task 1 + Task 4 (peuplement depuis le rapport déjà écrit). ✓
- raw_artifacts → Task 1 + Task 4 (peuplement depuis le log déjà écrit). ✓
- snapshots.kind/schema_version/important_json → Task 1 + Task 3. ✓
- executions.schema_version/important_json/error_kind/error_message → Task 1 + Task 4. ✓
Décision assumée (rétro-compat) : `snapshots.checked_at` conservé (non renommé en `created_at`) pour ne pas casser `refresh.ts`. Tables sans producteur en Phase 1 (`machine_hardware`, `machine_metrics_latest`, `important_messages`) créées vides, alimentées aux tâches 4/5/7 — conforme au principe « migration progressive ».
Pas de placeholder. Noms cohérents : `deriveAptState`/`upsertMachineState`/`recordEvent` définis Task 2 et utilisés Tasks 3-4 ; `reportId` défini Task 4 Step 4 et réutilisé Step 5.
@@ -0,0 +1,269 @@
# Tâche 1.9 — Phase 2 (sécurité credentials) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en checkbox.
**Goal:** Isoler les secrets SSH dans une table dédiée `machine_credentials` (+ table `machine_host_keys`), de façon **non destructive** : nouvelle table, écriture dédiée, lecture prioritaire avec fallback sur `machines.enc_password`, et backfill des machines existantes.
**Architecture:** Ajout additif (Drizzle/SQLite, migration `0003`). `machines.enc_password`/`enc_sudo_password` sont CONSERVÉS (non droppés) comme fallback/legacy. Un service `credentials` écrit/lit `machine_credentials` ; `createMachine` y insère, `getCreds` lit `machine_credentials` puis retombe sur les colonnes `machines` si absent ; un backfill (idempotent) crée les lignes manquantes au démarrage. `machine_host_keys` est créée (schéma) pour la future vérification host key (pas de logique de vérif en Phase 2).
**Tech Stack:** Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
---
## Invariants
- **Non destructif** : ne pas dropper `machines.enc_password`/`enc_sudo_password` (NOT NULL conservé). Pas de perte des machines réelles existantes.
- Secrets uniquement chiffrés (AES-256-GCM existant, `server/crypto/secrets.ts`). `machine_credentials` n'est JAMAIS exposée via l'API publique (la `MachineView` reste sans secret).
- Rétro-compatibilité : une machine sans ligne `machine_credentials` reste utilisable (fallback). Le backfill comble le manque.
- **Ne pas committer** (l'utilisateur gère les commits). Étapes « commit » remplacées par vérification.
- Tree partagé avec du WIP concurrent : ne toucher QUE `server/db/schema.ts`, migrations, `server/services/credentials.ts` (+test), `server/services/machines.ts`, et le point de backfill (`server/db/migrate.ts` ou `server/index.ts`). Relire chaque fichier avant édition (drift possible).
## File Structure
```
server/db/schema.ts # MODIF : +machine_credentials, +machine_host_keys
server/db/migrations/0003_*.sql # généré
server/services/credentials.ts # NOUVEAU : writeCredentials/readCreds/backfill
server/services/credentials.test.ts # NOUVEAU
server/services/machines.ts # MODIF : createMachine écrit credentials ; getCreds lit credentials+fallback
server/db/migrate.ts # MODIF : appeler backfill après migrate
```
---
## Task 1 : Tables `machine_credentials` + `machine_host_keys`
**Files:** Modify `server/db/schema.ts` ; generate migration ; extend `server/db/schema.test.ts`.
- [ ] **Step 1 : Relire le schéma réel**
Run: `rtk read server/db/schema.ts` (capter l'état courant, préserver tout l'existant : machines/snapshots/executions/apiClients + les 7 tables Phase 1).
- [ ] **Step 2 : Ajouter les deux tables** (à la fin de `schema.ts`, avant ou après `apiClients`, sans rien supprimer)
```ts
export const machineCredentials = sqliteTable("machine_credentials", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
authMethod: text("auth_method").notNull(), // password | ssh_key
encPassword: text("enc_password"),
encSudoPassword: text("enc_sudo_password"),
encPrivateKey: text("enc_private_key"),
encKeyPassphrase: text("enc_key_passphrase"),
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastTestAt: text("last_test_at"),
status: text("status"), // ok | error | unknown
});
export const machineHostKeys = sqliteTable("machine_host_keys", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
port: integer("port").notNull(),
keyType: text("key_type"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
publicKey: text("public_key"),
status: text("status").notNull(), // approved | changed | rejected | unknown
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
});
```
- [ ] **Step 3 : Générer la migration**
Run: `rtk pnpm db:generate`
Expected: `server/db/migrations/0003_*.sql` (CREATE TABLE machine_credentials + machine_host_keys uniquement). Vérifier qu'aucun DROP ni recréation de table existante n'apparaît (sinon corriger le schéma et régénérer).
- [ ] **Step 4 : Étendre `server/db/schema.test.ts`** — ajouter un test
```ts
it("crée les tables de credentials Phase 2", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
// machines conserve ses colonnes secrets legacy (fallback)
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
```
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/db/schema.test.ts` → PASS. Puis `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 2 : Service `credentials` (write / read / backfill) — TDD
**Files:** Create `server/services/credentials.ts`, `server/services/credentials.test.ts`.
- [ ] **Step 1 : Test (échec attendu)**`server/services/credentials.test.ts`
```ts
import { describe, it, expect } from "vitest";
import { resolveCreds } from "./credentials.js";
describe("resolveCreds", () => {
it("préfère la ligne machine_credentials", () => {
const out = resolveCreds(
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
);
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
});
it("retombe sur machines si pas de credentials", () => {
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
});
});
```
- [ ] **Step 2 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → FAIL (module manquant).
- [ ] **Step 3 : Implémenter `server/services/credentials.ts`**
```ts
// server/services/credentials.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
}
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
export function writeCredentials(input: {
machineId: string;
encPassword: string | null;
encSudoPassword: string | null;
}): void {
const now = new Date().toISOString();
db.insert(schema.machineCredentials)
.values({
machineId: input.machineId,
authMethod: "password",
encPassword: input.encPassword,
encSudoPassword: input.encSudoPassword,
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
createdAt: now,
updatedAt: now,
status: "unknown",
})
.onConflictDoUpdate({
target: schema.machineCredentials.machineId,
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
})
.run();
}
/** Lit la ligne machine_credentials (ou null). */
export function readCredentials(machineId: string): EncPair | null {
const row = db.select().from(schema.machineCredentials)
.where(eq(schema.machineCredentials.machineId, machineId)).get();
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
}
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
export function backfillCredentials(): number {
const machines = db.select().from(schema.machines).all();
let created = 0;
for (const m of machines) {
if (readCredentials(m.id)) continue;
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
created++;
}
return created;
}
```
- [ ] **Step 4 :** Run `rtk pnpm vitest run server/services/credentials.test.ts` → PASS (2). (Le test n'appelle que `resolveCreds`, pur ; pas de DB.)
- [ ] **Step 5 : (pas de commit)**
---
## Task 3 : Brancher dans `machines.ts` + backfill au démarrage
**Files:** Modify `server/services/machines.ts`, `server/db/migrate.ts`.
- [ ] **Step 1 : Relire `server/services/machines.ts`** (état réel : `getCreds`, `createMachine`).
- [ ] **Step 2 : `createMachine` écrit aussi machine_credentials**
Importer en tête : `import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";`
Après l'`insert` de la ligne `machines` (avant `return toView(row)`), ajouter :
```ts
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
```
(On conserve aussi l'écriture dans `machines.enc_password` — non destructif.)
- [ ] **Step 3 : `getCreds` lit machine_credentials en priorité**
Remplacer le corps de `getCreds` par :
```ts
export function getCreds(m: MachineRow): SshCreds {
const key = env.requireMasterKey();
const { encPassword, encSudoPassword } = resolveCreds(
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
readCredentials(m.id),
);
if (!encPassword) throw new Error("Aucun secret pour cette machine");
return {
hostname: m.hostname,
port: m.port,
username: m.username,
password: decryptSecret(encPassword, key),
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
};
}
```
- [ ] **Step 4 : Backfill au démarrage** — dans `server/db/migrate.ts`, après `runMigrations()`, exposer et appeler le backfill. Modifier `runMigrations` pour enchaîner :
```ts
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
import { backfillCredentials } from "../services/credentials.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
const n = backfillCredentials();
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
}
```
- [ ] **Step 5 :** Run `rtk pnpm check && rtk pnpm test` → 0 erreur TS ; tests verts (48 attendus : +2 credentials). Si un test hors périmètre (WIP concurrent) casse, le signaler sans corriger.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale Phase 2
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert + `dist` produit.
- [ ] **Step 2 : Boot + backfill + tables**
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/p2.db SU_REPORTS_DIR=./data/p2-reports
node dist/index.js > ./data/p2.log 2>&1 &
sleep 3
curl -s localhost:8787/health
node -e "const D=require('better-sqlite3');const db=new D('./data/p2.db');console.log(db.prepare(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'machine_%'\").all().map(r=>r.name).join(', '));"
kill %1 2>/dev/null
rm -rf ./data/p2.db* ./data/p2-reports ./data/p2.log
```
Expected: `{"ok":true}` ; tables incluent `machine_credentials`, `machine_host_keys`. (Backfill = 0 sur DB neuve, normal.)
- [ ] **Step 3 :** Reporter à l'utilisateur (tables ajoutées, dual-read/backfill, non-régression). **Ne pas committer.**
---
## Self-Review (couverture tache1.9 §14 Phase 2)
- créer `machine_credentials` → Task 1. ✓
- migrer `enc_password`/`enc_sudo_password` → approche non destructive : dual-write + backfill + lecture prioritaire (Tasks 2-3). Les colonnes legacy restent comme fallback (drop = phase ultérieure de nettoyage). ✓
- créer `machine_host_keys` → Task 1 (schéma ; vérification host key = logique ultérieure). ✓
- audit événements secrets → léger : non inclus en Phase 2 (le `recordEvent` Phase 1 existe ; l'audit systématique des déchiffrements relève de tâche 7 sécurité). Noté comme suite.
Décision assumée : non destructif (pas de DROP des colonnes secrets de `machines`) pour protéger les machines réelles existantes. Noms cohérents : `resolveCreds`/`writeCredentials`/`readCredentials`/`backfillCredentials` définis Task 2, utilisés Task 3.
@@ -0,0 +1,408 @@
# Tâche 2 — SJ-0 (socle : types + réduction + résolution de profil) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Poser le socle de la tâche 2, **purement additif** : étendre `shared/types.ts` (unions élargies + blocs optionnels, rétro-compatibles), enrichir le réducteur de lignes (préfixes Docker), et ajouter `resolveTemplate(action, osFamily)` avec fallback `base`. Aucun changement de wiring (refresh/execute inchangés).
**Architecture:** Extensions additives. Référence design : `docs/design/tache2/40-contrats-json.md` (types), `60-profils-os-machine.md` (résolution), `99-couverture-gate.md`. Tous les ajouts sont optionnels/élargis ⇒ un `UpdateSnapshot`/`ExecutionResult` du jalon 1 reste strictement valide (vérifié par `tsc`).
**Tech Stack:** TypeScript, vitest.
---
## Invariants
- **Rétro-compat stricte** : ne rien retirer/renommer. Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` (WIP) et tout autre contenu actuel de `shared/types.ts`.
- **Aucun changement de comportement** : on n'altère PAS `refresh.ts`/`execute.ts` en SJ-0 (la bascule du refresh sur les nouveaux templates = SJ-1).
- Réducteur : **garder `reduceAptLines`** (imports existants dans refresh/execute) ; ajouter les préfixes Docker et un alias `reduceLines`. **Ne PAS renommer le fichier** `aptReduce.ts` (éviter de toucher les imports de refresh/execute — churn/concurrence).
- Tree partagé / WIP concurrent : ne toucher QUE `shared/types.ts`, `server/templates/aptReduce.ts` (+test), `server/templates/render.ts` (+ test resolveTemplate), et les fichiers de test. **Ne pas committer.**
## File Structure
```
shared/types.ts # MODIF : unions élargies + interfaces + champs optionnels
shared/types.test.ts # NOUVEAU : verrouille la rétro-compat (compile + runtime léger)
server/templates/aptReduce.ts # MODIF : préfixes Docker + alias reduceLines
server/templates/aptReduce.test.ts # MODIF : +cas Docker
server/templates/render.ts # MODIF : +resolveTemplate
server/templates/resolveTemplate.test.ts # NOUVEAU
```
---
## Task 1 : Étendre `shared/types.ts`
**Files:** Modify `shared/types.ts` ; Create `shared/types.test.ts`.
- [ ] **Step 1 : Relire le fichier réel** (`rtk read shared/types.ts`) pour repérer le contenu à préserver (`MachineStatus`, `MachineView`, `ServerCapabilities`, etc.).
- [ ] **Step 2 : Appliquer les extensions** (élargir les unions existantes, remplacer `AptPackage`/`UpdateSnapshot`/`ExecutionResult` par les versions étendues, AJOUTER les nouvelles interfaces). Ne pas supprimer l'existant. Contenu cible (depuis `docs/design/tache2/40-contrats-json.md`) :
```ts
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
export type MachineKind =
| "physical" | "vm" | "proxmox_host" | "lxc"
| "raspberry_pi" | "workstation" | "unknown";
export type AptProxyMode = "direct" | "runtime" | "persistent";
export type ActionType =
| "apt_full_upgrade" | "reboot"
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
| "apt_autoremove" | "apt_clean" | "reboot_verified"
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
| "machine_probe" | "post_install";
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
// ExecutionStatus, MachineStatus : INCHANGÉS (préserver l'existant)
export interface AptPackage {
name: string;
currentVersion: string | null;
targetVersion: string;
origin: string | null;
arch?: string;
operation?: "upgrade" | "install" | "remove" | "hold";
severityHint?: "normal" | "security";
}
export interface AptSnapshotDetail {
enabled: boolean;
count: number;
rebootRequired: boolean;
packages: AptPackage[];
status?: SnapshotStatus;
upgradeCount?: number;
distUpgradeCount?: number;
installed?: AptPackage[];
removed?: AptPackage[];
held?: string[];
rebootPkgs?: string[];
}
export interface DockerSnapshotService {
serviceName: string;
image: string;
currentImageId?: string | null;
currentDigest?: string | null;
candidateImageId?: string | null;
candidateDigest?: string | null;
currentVersion?: string | null;
candidateVersion?: string | null;
sourceUrl?: string | null;
status?: "up_to_date" | "updates_available" | "warning" | "error";
}
export interface DockerSnapshotStack {
name: string;
workingDir: string;
composeFiles: string[];
projectName?: string | null;
status: "candidate" | "enabled" | "ignored" | "error";
detectedBy?: "root_scan" | "label" | "manual";
services: DockerSnapshotService[];
}
export interface DockerSnapshot {
enabled: boolean;
installed: boolean;
count: number;
declaredRoots?: string[];
stacks: DockerSnapshotStack[];
status?: SnapshotStatus;
}
export interface SnapshotError {
source: "apt" | "docker" | "post_install" | "ssh" | "system";
kind: string;
severity: "info" | "warning" | "error";
message: string;
remediation?: string;
importantLines?: string[];
}
export interface UpdateSnapshot {
machineId: string;
hostname: string;
os: { family: OsFamily; version: string };
checkedAt: string;
status: MachineStatus;
apt: AptSnapshotDetail;
schemaVersion?: number;
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
machineKind?: MachineKind;
docker?: DockerSnapshot;
errors?: SnapshotError[];
rawHints?: { logImportantLines: string[] };
}
export interface AptChange {
name: string;
arch?: string;
fromVersion: string | null;
toVersion: string | null;
operation: "upgraded" | "installed" | "removed" | "unchanged";
origin?: string | null;
}
export interface AptExecutionResult {
planned: AptPackage[];
applied: AptChange[];
installed: AptChange[];
removed: AptChange[];
held: string[];
errors?: SnapshotError[];
rebootRequiredAfterRun: boolean;
}
export interface DockerImageChange {
stack: string;
serviceName?: string;
imageRef?: string;
fromImageId?: string | null;
toImageId?: string | null;
fromDigest?: string | null;
toDigest?: string | null;
operation: "pulled" | "recreated" | "pruned";
}
export interface DockerExecutionResult {
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
errors?: SnapshotError[];
}
export interface RebootResult {
beforeBootId: string | null;
afterBootId: string | null;
requestedAt: string;
sshWentDownAt: string | null;
sshCameBackAt: string | null;
waitedSeconds: number;
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
lastRebootDurationSeconds?: number;
nextRecommendedWaitSeconds?: number;
errors?: SnapshotError[];
}
export interface PostInstallResult {
profilesRun: string[];
variablesUsed: Record<string, string | number | boolean>;
filesModified: string[];
packagesInstalled: string[];
servicesEnabled: string[];
rebootsRequested: boolean;
networkChange?: { oldEndpoint: string | null; newEndpoint: string | null; reconnectHost: string | null };
errors?: SnapshotError[];
}
export interface ExecutionResult {
executionId: string;
machineId: string;
startedAt: string;
finishedAt: string;
mode: "manual" | "scheduled" | "hermes_requested";
action: ActionType;
status: ExecutionStatus;
rebootRequiredAfterRun: boolean;
importantLogLines: string[];
rawLogRef: string;
reportRef: string;
schemaVersion?: number;
apt?: AptExecutionResult;
docker?: DockerExecutionResult;
reboot?: RebootResult;
postInstall?: PostInstallResult;
errors?: SnapshotError[];
}
```
> Préserver `MachineStatus`, `MachineView`, `ServerCapabilities` et tout autre contenu présent. Le bloc `apt` de `UpdateSnapshot` reste **requis** (forme jalon 1) ; `mode` de `ExecutionResult` était le littéral `"manual"` → l'union l'inclut.
- [ ] **Step 3 : Test de rétro-compat `shared/types.test.ts`**
```ts
import { describe, it, expect } from "vitest";
import type { UpdateSnapshot, ExecutionResult } from "./types.js";
describe("rétro-compatibilité des contrats", () => {
it("un snapshot jalon 1 (sans blocs optionnels) reste valide", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
checkedAt: "2026-06-05T10:00:00Z", status: "ok",
apt: { enabled: true, count: 0, rebootRequired: false, packages: [] },
};
expect(snap.apt.count).toBe(0);
});
it("une exécution jalon 1 (mode manual, sans blocs) reste valide", () => {
const exec: ExecutionResult = {
executionId: "e1", machineId: "m1", startedAt: "a", finishedAt: "b",
mode: "manual", action: "apt_full_upgrade", status: "ok",
rebootRequiredAfterRun: false, importantLogLines: [], rawLogRef: "r", reportRef: "rr",
};
expect(exec.action).toBe("apt_full_upgrade");
});
it("accepte les nouveaux blocs optionnels", () => {
const snap: UpdateSnapshot = {
machineId: "m1", hostname: "h", os: { family: "proxmox", version: "8" },
checkedAt: "t", status: "updates_available",
apt: { enabled: true, count: 1, rebootRequired: false, packages: [], status: "updates_available" },
schemaVersion: 1, kind: "apt_update_analyze", machineKind: "proxmox_host",
docker: { enabled: false, installed: false, count: 0, stacks: [] },
errors: [],
};
expect(snap.docker?.installed).toBe(false);
});
});
```
- [ ] **Step 4 :** Run `rtk pnpm vitest run shared/types.test.ts` → PASS (3). Puis `rtk pnpm check`**0 erreur** (c'est le vrai test de rétro-compat : si un consommateur existant casse à cause d'un retrait/renommage, tsc le révèle). Si `check` signale une erreur dans un fichier consommateur (`refresh.ts`/`execute.ts`/`machines.ts`/WIP) causée par TON changement de types, corrige le type (rends additif) — ne casse pas les consommateurs.
- [ ] **Step 5 : (pas de commit)**
---
## Task 2 : Réducteur enrichi (préfixes Docker)
**Files:** Modify `server/templates/aptReduce.ts`, `server/templates/aptReduce.test.ts`.
- [ ] **Step 1 : Relire `server/templates/aptReduce.ts`** (état réel).
- [ ] **Step 2 : Ajouter un cas Docker au test `aptReduce.test.ts`**
```ts
it("garde aussi les lignes Docker utiles", () => {
const raw = [
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"blabla inutile",
"Total reclaimed space: 1.2GB",
].join("\n");
expect(reduceLines(raw)).toEqual([
"Pulling jellyfin ...",
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
"Recreating jellyfin ...",
"Started jellyfin",
"Total reclaimed space: 1.2GB",
]);
});
```
Ajouter `reduceLines` à l'import existant : `import { reduceAptLines, reduceLines } from "./aptReduce.js";`
- [ ] **Step 3 : Lancer (échec attendu)**`rtk pnpm vitest run server/templates/aptReduce.test.ts` → FAIL (`reduceLines` introuvable / lignes Docker non gardées).
- [ ] **Step 4 : Étendre `server/templates/aptReduce.ts`**
```ts
// server/templates/aptReduce.ts
const PREFIXES = [
// APT / dpkg (jalon 1)
"Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:",
// Docker (SJ-0)
"Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error",
];
const CONTAINS = [
"reboot-required", "REBOOT_REQUIRED",
"deleted", "Total reclaimed space",
];
/** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */
export function reduceLines(raw: string): string[] {
return raw
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
}
/** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */
export const reduceAptLines = reduceLines;
```
> Garder l'export `reduceAptLines` (utilisé par `refresh.ts`/`execute.ts`). `reduceLines` est le nouveau nom canonique.
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/aptReduce.test.ts` → PASS (cas APT existants + nouveau cas Docker). `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 3 : `resolveTemplate(action, osFamily)`
**Files:** Modify `server/templates/render.ts` ; Create `server/templates/resolveTemplate.test.ts`.
- [ ] **Step 1 : Relire `server/templates/render.ts`** (état réel : `TEMPLATES_ROOT`, `renderTemplate`, `TemplateVars`).
- [ ] **Step 2 : Test `server/templates/resolveTemplate.test.ts`**
```ts
import { describe, it, expect } from "vitest";
import { resolveTemplate } from "./render.js";
describe("resolveTemplate", () => {
it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => {
const noneExist = () => false;
expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl");
expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl");
});
it("choisit le template OS spécifique quand il existe", () => {
const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl";
expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl");
});
it("unknown retombe toujours sur apt/", () => {
const all = () => true;
expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl");
});
});
```
- [ ] **Step 3 : Lancer (échec)**`rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → FAIL.
- [ ] **Step 4 : Ajouter `resolveTemplate` à `server/templates/render.ts`** (sans toucher `renderTemplate`/`TemplateVars` existants ; ajouter l'import `existsSync`) :
```ts
import { existsSync } from "node:fs";
// ... (TEMPLATES_ROOT, renderTemplate existants inchangés) ...
/** Existence par défaut d'un template relatif à templates/. */
function defaultExists(rel: string): boolean {
return existsSync(resolve(TEMPLATES_ROOT, rel));
}
/**
* Résout le chemin de template le plus spécifique pour (action, OS) :
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.sh.tpl`.
* `exists` est injectable pour les tests.
*/
export function resolveTemplate(
action: string,
osFamily: string,
exists: (rel: string) => boolean = defaultExists,
): string {
const specific = `${osFamily}/${action}.sh.tpl`;
if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific;
return `apt/${action}.sh.tpl`;
}
```
> Note : `renderTemplate` accepte déjà un `relPath` (ex. `apt/full-upgrade.sh.tpl`), donc `renderTemplate(resolveTemplate(action, osFamily), vars)` fonctionnera en SJ-1 sans modifier `renderTemplate`.
- [ ] **Step 5 :** Run `rtk pnpm vitest run server/templates/resolveTemplate.test.ts` → PASS (3). `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-0
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build`
Expected: 0 erreur TS ; tous tests verts (49 Phase 2 + 3 types + 1 Docker reduce + 3 resolveTemplate ≈ 56) ; build OK.
- [ ] **Step 2 :** Reporter : types étendus rétro-compatibles (tsc vert = preuve), réducteur Docker, `resolveTemplate` prêt pour SJ-1. **Ne pas committer.**
---
## Self-Review (couverture SJ-0)
- Types étendus (unions + blocs optionnels) → Task 1, rétro-compat verrouillée par `tsc` + test. ✓
- Réducteur + préfixes Docker → Task 2 (`reduceLines` + alias `reduceAptLines` conservé). ✓
- `resolveTemplate(action, osFamily)` + fallback base → Task 3. ✓
- `schemaVersion` → présent dans `UpdateSnapshot`/`ExecutionResult` (optionnel). ✓
- Aucun wiring modifié (refresh/execute intacts) ⇒ non-régression jalon 1. ✓
Décisions assumées : fichier `aptReduce.ts` NON renommé (alias `reduceLines` ajouté) pour éviter de toucher les imports de refresh/execute (churn/concurrence) — le nom canonique `reduceLines` est exporté ; renommage physique reporté à un nettoyage ultérieur. `resolveTemplate` avec `exists` injectable pour testabilité des deux branches.
@@ -0,0 +1,349 @@
# Tâche 2 — SJ-1 (APT update/analyse enrichi) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Introduire `apt/update-analyze.sh.tpl` (refresh index + simulations `upgrade` et `dist-upgrade` + held + reboot-check, non destructif), son parsing enrichi (`AptSnapshotDetail` : upgrade/dist-upgrade/installed/removed/held/rebootPkgs + statut `ok|updates_available|warning|error`), et **basculer `refreshMachine` dessus** via `resolveTemplate`, en conservant `check.sh.tpl`.
**Architecture:** Additif. Référence design : `docs/design/tache2/10-templates-apt.md §4.1` (template) et `40-contrats-json.md §3` (`AptSnapshotDetail`). Le parsing est en TS (réutilise `parseAptSimulate` SJ-0/jalon 1) ; `buildAptSnapshotDetail` est une fonction pure testée sur fixtures. Le refresh bascule sur le nouveau template via `resolveTemplate("update-analyze", osFamily)` (fallback `apt/`). `check.sh.tpl` reste en place (non supprimé). Aucune rupture : `snapshot.apt` garde ses champs jalon 1 (enabled/count/rebootRequired/packages) + champs additifs.
**Tech Stack:** TypeScript, Mustache, vitest.
---
## Invariants
- `snapshot.apt` reste de forme jalon 1 (champs requis présents) ; on l'enrichit via les champs optionnels de `AptSnapshotDetail` (SJ-0).
- `MachineStatus` (union jalon 1, sans "warning") **inchangée** : le statut `warning` vit dans `snapshot.apt.status` ; `snapshot.status` (MachineStatus) mappe warning→`updates_available`.
- `check.sh.tpl` conservé. Wiring : seul `refreshMachine` bascule sur `update-analyze`.
- Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/update-analyze.sh.tpl`, `server/services/refresh.ts`, `server/templates/render.test.ts` éventuel. **Ne pas committer.**
## File Structure
```
server/services/aptParse.ts # MODIF : +parseAptRemovals/parseHeld/parseRebootDetail/buildAptSnapshotDetail
server/services/aptParse.test.ts # MODIF : +tests build detail
server/services/__fixtures__/apt-update-analyze.txt # NOUVEAU : sortie complète du template
templates/apt/update-analyze.sh.tpl # NOUVEAU
server/services/refresh.ts # MODIF : bascule sur update-analyze + detail
```
---
## Task 1 : Parsing enrichi APT (TDD)
**Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts` ; Create `server/services/__fixtures__/apt-update-analyze.txt`.
- [ ] **Step 1 : Créer la fixture `server/services/__fixtures__/apt-update-analyze.txt`**
```
===SU:APT_UPDATE===
Hit:1 http://deb.debian.org/debian bookworm InRelease
Reading package lists...
===SU:APT_SIM_UPGRADE===
Reading package lists...
Building dependency tree...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_SIM_DISTUPGRADE===
Reading package lists...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Inst newdep (1.0.0 Debian:11.6/stable [all])
Remv oldpkg [3.2-1]
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_HELD===
frozenpkg
===SU:REBOOT===
REBOOT_REQUIRED=1
PKG=linux-image-amd64
===SU:EXIT=0===
```
- [ ] **Step 2 : Écrire le test (échec attendu)** — ajouter à `server/services/aptParse.test.ts`
```ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail } from "./aptParse.js";
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
function section(raw: string, start: string, end: string): string {
const s = raw.indexOf(start); if (s === -1) return "";
const from = s + start.length; const e = raw.indexOf(end, from);
return raw.slice(from, e === -1 ? undefined : e).trim();
}
describe("parseAptRemovals", () => {
it("extrait les suppressions Remv", () => {
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
});
});
describe("parseHeld", () => {
it("liste les paquets retenus non vides", () => {
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
});
});
describe("parseRebootDetail", () => {
it("lit le flag et les paquets reboot", () => {
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
});
});
describe("buildAptSnapshotDetail", () => {
it("construit le détail enrichi depuis les sections", () => {
const detail = buildAptSnapshotDetail({
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: false,
});
expect(detail.enabled).toBe(true);
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
expect(detail.distUpgradeCount).toBe(2);
expect(detail.rebootRequired).toBe(true);
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
expect(detail.held).toEqual(["frozenpkg"]);
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
expect(detail.status).toBe("warning"); // car removed + held non vides
});
it("status=updates_available sans removed/held, error si update échoue", () => {
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
expect(ok.status).toBe("updates_available");
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
expect(err.status).toBe("error");
});
});
```
- [ ] **Step 3 : Lancer (échec)**`rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL.
- [ ] **Step 4 : Étendre `server/services/aptParse.ts`** (garder `parseAptSimulate`/`parseRebootRequired` existants ; ajouter) :
```ts
import type { AptPackage, AptSnapshotDetail, SnapshotStatus } from "@shared/types.js";
// ... (parseAptSimulate, parseRebootRequired existants conservés) ...
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
const out: { name: string; currentVersion: string | null }[] = [];
for (const line of raw.split("\n")) {
const m = REMV_RE.exec(line.trimEnd());
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
}
return out;
}
export function parseHeld(raw: string): string[] {
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
}
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
const pkgs: string[] = [];
for (const line of raw.split("\n")) {
const m = /^PKG=(.+)$/.exec(line.trim());
if (m) pkgs.push(m[1]!.trim());
}
return { rebootRequired, pkgs };
}
export interface AptSections {
upgradeSim: string;
distUpgradeSim: string;
heldRaw: string;
rebootRaw: string;
updateFailed: boolean;
}
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
const upgradePkgs = parseAptSimulate(s.upgradeSim);
const distPkgs = parseAptSimulate(s.distUpgradeSim);
const installed: AptPackage[] = distPkgs
.filter((p) => p.currentVersion === null)
.map((p) => ({ ...p, operation: "install" }));
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove",
}));
const held = parseHeld(s.heldRaw);
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
let status: SnapshotStatus = "ok";
if (s.updateFailed) status = "error";
else if (removed.length > 0 || held.length > 0) status = "warning";
else if (distPkgs.length > 0) status = "updates_available";
return {
enabled: true,
count: distPkgs.length,
rebootRequired,
packages: distPkgs,
status,
upgradeCount: upgradePkgs.length,
distUpgradeCount: distPkgs.length,
installed,
removed,
held,
rebootPkgs,
};
}
```
- [ ] **Step 5 : Lancer (succès)**`rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur.
- [ ] **Step 6 : (pas de commit)**
---
## Task 2 : Template `apt/update-analyze.sh.tpl`
**Files:** Create `templates/apt/update-analyze.sh.tpl`.
- [ ] **Step 1 : Créer le template** (depuis `10-templates-apt.md §4.1`)
```sh
#!/bin/sh
# Refresh index + simulations upgrade/dist-upgrade + held + reboot-check.
# Exécuté entier sous sudo par la couche SSH. Non destructif.
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:APT_UPDATE==="
apt-get update -qq 2>&1
UPD=$?
echo "===SU:APT_SIM_UPGRADE==="
apt-get -s -y upgrade 2>&1
echo "===SU:APT_SIM_DISTUPGRADE==="
apt-get -s -y dist-upgrade 2>&1
echo "===SU:APT_HELD==="
apt-mark showhold 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
echo "REBOOT_REQUIRED=1"
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
else
echo "REBOOT_REQUIRED=0"
fi
echo "===SU:EXIT=${UPD}==="
```
- [ ] **Step 2 : Vérifier le rendu**`rtk pnpm vitest run server/templates/render.test.ts` reste vert (le test existant porte sur `check.sh.tpl` ; pas de régression). Optionnellement ajouter un cas :
```ts
it("rend update-analyze.sh.tpl avec les sections attendues", () => {
const out = renderTemplate("apt/update-analyze.sh.tpl", { aptProxy: null });
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
expect(out).toContain("apt-mark showhold");
});
```
- [ ] **Step 3 : (pas de commit)**
---
## Task 3 : Basculer `refreshMachine` sur update-analyze
**Files:** Modify `server/services/refresh.ts`.
- [ ] **Step 1 : Relire `server/services/refresh.ts`** (état réel, incl. wiring Phase 1 machine_state/event).
- [ ] **Step 2 : Adapter les imports**
```ts
import { renderTemplate, resolveTemplate } from "../templates/render.js";
import {
parseAptSimulate, parseRebootRequired, // existants (peuvent rester importés)
buildAptSnapshotDetail,
} from "./aptParse.js";
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
```
- [ ] **Step 3 : Remplacer la construction du snapshot** dans `refreshMachine`. Remplacer le rendu + le parsing actuels (`check.sh.tpl`, `extractSection(...SIMULATE...)`) par :
```ts
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
const script = renderTemplate(resolveTemplate("update-analyze", m.osFamily), { aptProxy: proxy });
let raw = "";
try {
const res = await runScriptSudo(getCreds(m), script, (c) => {
raw += c;
outputHub.publish(machineId, c);
});
raw = res.stdout;
} catch (err) {
db.update(schema.machines).set({ status: "error" }).where(eq(schema.machines.id, machineId)).run();
throw err;
}
const updateExit = /===SU:EXIT=(\d+)===/.exec(raw);
const detail: AptSnapshotDetail = buildAptSnapshotDetail({
upgradeSim: extractSection(raw, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: extractSection(raw, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: extractSection(raw, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: updateExit ? Number(updateExit[1]) !== 0 : false,
});
// MachineStatus n'a pas "warning" : warning => updates_available côté machine.
const status: MachineStatus =
detail.status === "error" ? "error" : detail.count > 0 || detail.status === "warning" ? "updates_available" : "ok";
const checkedAt = new Date().toISOString();
const snapshot: UpdateSnapshot = {
machineId,
hostname: m.hostname,
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: m.osVersion ?? "" },
checkedAt,
status,
apt: detail,
schemaVersion: 1,
kind: "apt_update_analyze",
rawHints: { logImportantLines: reduceAptLines(raw) },
};
```
> Conserver ensuite TOUT le bloc Phase 1 inchangé : insertion du snapshot (`kind`/`schemaVersion`/`importantJson`), update `machines`, `upsertMachineState(machineId, deriveAptState(snapshot))`, `recordEvent(...)`, `return snapshot;`. `deriveAptState` lit `snapshot.status`/`apt.count`/`apt.rebootRequired`/`checkedAt` — inchangé.
- [ ] **Step 4 : Vérifier**`rtk pnpm check && rtk pnpm vitest run server/services/refresh.test.ts server/services/aptParse.test.ts` → 0 erreur, tests verts (`extractSection` + parsing). Note : `check.sh.tpl` n'est plus référencé par le refresh mais reste sur disque (non supprimé), comme prévu.
- [ ] **Step 5 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-1
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → 0 erreur, tous tests verts, build OK.
- [ ] **Step 2 : Boot smoke** (DB jetable) — confirmer que le serveur démarre (`/health`) avec le refresh branché sur le nouveau template (pas d'exécution SSH réelle ici) :
```bash
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/sj1.db SU_REPORTS_DIR=./data/sj1-reports
node dist/index.js > ./data/sj1.log 2>&1 &
sleep 3; curl -s localhost:8787/health; kill %1 2>/dev/null
rm -rf ./data/sj1.db* ./data/sj1-reports ./data/sj1.log
```
Expected: `{"ok":true}`.
- [ ] **Step 3 :** Reporter. Note pour l'utilisateur : la **vérif live** (refresh réel sur une machine Debian) confirmera le parsing des vraies sorties `apt-get -s`. **Ne pas committer.**
---
## Self-Review (couverture SJ-1)
- `apt/update-analyze.sh.tpl` (update + sim upgrade + sim dist-upgrade + held + reboot-check) → Task 2. ✓
- parsing des sections + `AptSnapshotDetail` enrichi (upgrade/dist/installed/removed/held/rebootPkgs + status) → Task 1 (TDD fixtures). ✓
- statut `ok|updates_available|warning|error``buildAptSnapshotDetail`. ✓
- bascule du refresh sur update-analyze (via `resolveTemplate`), `check.sh.tpl` conservé → Task 3. ✓
- non-régression : `snapshot.apt` garde la forme jalon 1 ; `MachineStatus` inchangée (warning→updates_available) ; machine_state/events Phase 1 préservés. ✓
Décision : `count = distUpgradeCount` (toutes les mises à jour disponibles, cohérent avec le jalon 1 qui comptait la simulation full-upgrade). `warning` (removed/held) exposé dans `apt.status`, mappé `updates_available` pour `machine.status`. Noms cohérents : `parseAptRemovals`/`parseHeld`/`parseRebootDetail`/`buildAptSnapshotDetail` définis Task 1, utilisés Task 3.
@@ -0,0 +1,325 @@
# Tâche 2 — SJ-2 (APT apply + diff dpkg réel) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Enrichir `apt/full-upgrade.sh.tpl` du snapshot dpkg avant/après, ajouter `apt/upgrade.sh.tpl`, `apt/autoremove.sh.tpl`, `apt/clean.sh.tpl`, calculer le **diff dpkg réel** (`AptExecutionResult` : applied/installed/removed), brancher les actions `apt_upgrade`/`apt_autoremove`/`apt_clean` (+ `apt_full_upgrade` enrichi) dans `runAction`, et ajouter un **timeout d'inactivité** optionnel à la couche SSH.
**Architecture:** Additif. Référence : `docs/design/tache2/10-templates-apt.md §4.2-4.4`, `40-contrats-json.md §4` (`AptExecutionResult`/`AptChange`), `50-erreurs.md` (`human_interaction_required`). Le diff dpkg est calculé en TS (`buildAptExecutionResult`, pure, TDD). `runScriptSudo` reçoit une option `inactivityTimeoutMs` (défaut 0 = désactivé ⇒ comportement jalon 1 inchangé) ; `runAction` la passe (600000) pour les actions APT. Les confirmations UI des suppressions relèvent de la tâche 3 ; SJ-2 expose `removed[]` dans le résultat.
**Tech Stack:** TypeScript, Mustache, ssh2, vitest.
---
## Invariants
- `apt_full_upgrade` et `reboot` (jalon 1) restent fonctionnels ; on **enrichit** sans casser le parsing exit/reboot existant de `execute.ts`.
- `runScriptSudo` : nouveau paramètre **optionnel** `inactivityTimeoutMs` (défaut 0 = pas de timeout) ⇒ `refreshMachine` et tout appelant existant **inchangés** de comportement.
- `ExecutionResult.apt` est optionnel (SJ-0) ⇒ une exécution sans diff reste valide.
- Tree partagé / WIP concurrent : ne toucher QUE `server/services/aptParse.ts` (+test/fixtures), `templates/apt/{full-upgrade,upgrade,autoremove,clean}.sh.tpl`, `server/ssh/client.ts`, `server/services/execute.ts`. **Ne pas committer.**
## File Structure
```
server/services/aptParse.ts # MODIF : +parseDpkgList/diffDpkg/buildAptExecutionResult
server/services/aptParse.test.ts # MODIF : +tests diff dpkg
templates/apt/full-upgrade.sh.tpl # MODIF : +DPKG_BEFORE/AFTER
templates/apt/upgrade.sh.tpl # NOUVEAU
templates/apt/autoremove.sh.tpl # NOUVEAU
templates/apt/clean.sh.tpl # NOUVEAU
server/ssh/client.ts # MODIF : +inactivityTimeoutMs (additif)
server/services/execute.ts # MODIF : actions APT + buildAptExecutionResult + timeout
```
---
## Task 1 : Diff dpkg (TDD)
**Files:** Modify `server/services/aptParse.ts`, `server/services/aptParse.test.ts`.
- [ ] **Step 1 : Test (échec attendu)** — ajouter à `aptParse.test.ts`
```ts
import { parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
describe("parseDpkgList", () => {
it("indexe par package:arch", () => {
const m = parseDpkgList("libc6\t2.31-13\tamd64");
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
});
});
describe("buildAptExecutionResult", () => {
it("calcule le diff réel before/after", () => {
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
expect(r.rebootRequiredAfterRun).toBe(true);
});
});
```
- [ ] **Step 2 : Lancer (échec)**`rtk pnpm vitest run server/services/aptParse.test.ts` → FAIL.
- [ ] **Step 3 : Étendre `server/services/aptParse.ts`**
```ts
import type { AptChange, AptExecutionResult } from "@shared/types.js";
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
const out: Record<string, { version: string; arch: string }> = {};
for (const line of raw.split("\n")) {
const parts = line.split("\t");
if (parts.length < 3) continue;
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
if (!name) continue;
out[`${name}:${arch}`] = { version, arch };
}
return out;
}
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
const before = parseDpkgList(beforeRaw);
const after = parseDpkgList(afterRaw);
const applied: AptChange[] = [];
const installed: AptChange[] = [];
const removed: AptChange[] = [];
for (const key of Object.keys(after)) {
const [name] = key.split(":");
const a = after[key]!;
const b = before[key];
if (!b) {
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
installed.push(change); applied.push(change);
} else if (b.version !== a.version) {
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
}
}
for (const key of Object.keys(before)) {
if (!after[key]) {
const [name] = key.split(":");
const b = before[key]!;
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
removed.push(change); applied.push(change);
}
}
return {
planned: [],
applied,
installed,
removed,
held: [],
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
};
}
```
- [ ] **Step 4 : Lancer (succès)**`rtk pnpm vitest run server/services/aptParse.test.ts` → PASS. `rtk pnpm check` → 0 erreur.
- [ ] **Step 5 : (pas de commit)**
---
## Task 2 : Templates APT (full-upgrade enrichi + upgrade/autoremove/clean)
**Files:** Modify `templates/apt/full-upgrade.sh.tpl` ; Create `upgrade.sh.tpl`, `autoremove.sh.tpl`, `clean.sh.tpl`.
- [ ] **Step 1 : Remplacer `templates/apt/full-upgrade.sh.tpl`** (ajoute DPKG_BEFORE/AFTER ; conserve REBOOT + EXIT que `execute.ts` parse déjà)
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_FULLUPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
- [ ] **Step 2 : Créer `templates/apt/upgrade.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
{{/aptProxy}}
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_UPGRADE==="
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
- [ ] **Step 3 : Créer `templates/apt/autoremove.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
export DEBIAN_FRONTEND=noninteractive
echo "===SU:APT_SIM_AUTOREMOVE==="
apt-get -s -y autoremove 2>&1
echo "===SU:DPKG_BEFORE==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:APT_AUTOREMOVE==="
apt-get -y autoremove 2>&1
CODE=$?
echo "===SU:DPKG_AFTER==="
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
echo "===SU:REBOOT==="
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
echo "===SU:EXIT=${CODE}==="
```
- [ ] **Step 4 : Créer `templates/apt/clean.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:APT_CLEAN==="
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
apt-get clean 2>&1
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
echo "FREED_BYTES=$((BEFORE - AFTER))"
echo "===SU:EXIT=0==="
```
- [ ] **Step 5 :** `rtk pnpm vitest run server/templates/render.test.ts` reste vert. (pas de commit)
---
## Task 3 : Timeout d'inactivité SSH (additif)
**Files:** Modify `server/ssh/client.ts`.
- [ ] **Step 1 : Relire `server/ssh/client.ts`** (signatures `runScriptSudo`, `execStream`).
- [ ] **Step 2 : Ajouter un paramètre optionnel `inactivityTimeoutMs`** (défaut 0 = désactivé) à `runScriptSudo` et `execStream`. Dans `execStream`, armer un timer réinitialisé à chaque `data`/`stderr data` ; à expiration, `stream.close()`/`conn.end()` et `reject(new Error("human_interaction_required: aucune sortie depuis " + (ms/1000) + "s"))`.
```ts
export async function runScriptSudo(
creds: SshCreds,
script: string,
onData: (chunk: string) => void,
inactivityTimeoutMs = 0,
): Promise<RunResult> {
const conn = await connect(creds);
try {
const b64 = Buffer.from(script, "utf8").toString("base64");
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
} finally {
conn.end();
}
}
```
Dans `execStream(conn, command, stdinData, onData, inactivityTimeoutMs = 0)` : après obtention du `stream`,
```ts
let timer: NodeJS.Timeout | undefined;
const arm = () => {
if (!inactivityTimeoutMs) return;
clearTimeout(timer);
timer = setTimeout(() => {
stream.close();
reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
}, inactivityTimeoutMs);
};
arm();
```
Réinitialiser `arm()` dans les handlers `data` et `stderr data` ; `clearTimeout(timer)` dans `close`. (Garder le `runPlain` existant inchangé : il appelle `execStream` sans le 5e arg ⇒ timeout 0.)
- [ ] **Step 3 :** `rtk pnpm check` → 0 erreur. (Pas de test unitaire SSH ; vérif manuelle en live ultérieure.) (pas de commit)
---
## Task 4 : Brancher les actions APT dans `runAction`
**Files:** Modify `server/services/execute.ts`.
- [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, update executions, blocs Phase 1 reports/artifacts/state/event).
- [ ] **Step 2 : Étendre `TEMPLATE_FOR`**
```ts
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
apt_upgrade: "apt/upgrade.sh.tpl",
apt_autoremove: "apt/autoremove.sh.tpl",
apt_clean: "apt/clean.sh.tpl",
reboot: "apt/reboot.sh.tpl",
};
```
(Adapter l'accès : `const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action);`)
- [ ] **Step 3 : Passer le timeout d'inactivité** pour les actions APT (pas pour reboot) :
```ts
const inactivity = action === "reboot" ? 0 : 600000;
const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }, inactivity);
```
- [ ] **Step 4 : Construire `result.apt` (diff dpkg) pour les actions APT applicatives.** Après calcul de `raw` et avant l'écriture du rapport, ajouter :
```ts
let aptResult: AptExecutionResult | undefined;
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
aptResult = buildAptExecutionResult(
extractSection(raw, "===SU:DPKG_BEFORE===", "==="), // jusqu'au prochain marqueur
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
);
}
```
> ⚠️ `extractSection(raw, "===SU:DPKG_BEFORE===", "===")` : le 2ᵉ marqueur générique `"==="` capture jusqu'au prochain `===SU:...===`. Vérifier que `extractSection` (dans `refresh.ts`) coupe bien au 1ᵉʳ `"==="` rencontré ; sinon, utiliser le marqueur réel suivant (`"===SU:APT_FULLUPGRADE==="` / `"===SU:APT_UPGRADE==="` / `"===SU:APT_AUTOREMOVE==="`). **Préférer** le marqueur explicite : détecter lequel est présent. Implémentation robuste :
```ts
const afterBeforeMarker =
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
"===SU:APT_AUTOREMOVE===";
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
aptResult = buildAptExecutionResult(
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
);
}
```
- [ ] **Step 5 : Attacher `aptResult` au `ExecutionResult`** : dans la construction de `result`, ajouter `...(aptResult ? { apt: aptResult } : {})`. Importer en tête : `import { parseRebootRequired, extractSection } ...` (extractSection vient de `./refresh.js` — déjà importé) et `import { buildAptExecutionResult } from "./aptParse.js";` ainsi que `import type { AptExecutionResult } from "@shared/types.js";`.
- [ ] **Step 6 : Vérifier**`rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. (pas de commit)
---
## Task 5 : Vérification finale SJ-2
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK. Nettoyer.
- [ ] **Step 3 :** Reporter. Vérif live ultérieure : `apt_full_upgrade` réel sur Debian → vérifier `result.apt.applied` (diff dpkg réel) + détection removed/held + comportement du timeout. **Ne pas committer.**
---
## Self-Review (couverture SJ-2)
- Templates `upgrade`/`full-upgrade` enrichi/`autoremove`/`clean` → Task 2. ✓
- Capture `DPKG_BEFORE/AFTER` + diff réel (`AptExecutionResult`) → Task 1 + Task 4. ✓
- Timeout d'inactivité + `human_interaction_required` → Task 3 (additif, off par défaut) + Task 4 (600s pour APT). ✓
- Confirmations UI suppressions → hors périmètre (tâche 3) ; la donnée `removed[]` est exposée dans `result.apt`. ✓ (noté)
- Non-régression : `apt_full_upgrade`/`reboot` jalon 1 conservés ; `runScriptSudo` rétro-compatible (timeout 0 par défaut) ; `ExecutionResult.apt` optionnel ; blocs Phase 1 préservés. ✓
Décision : `planned`/`held` laissés vides dans `AptExecutionResult` (portés par le snapshot SJ-1, pas re-simulés à l'exécution). `extractSection` utilisé avec marqueur explicite pour `DPKG_BEFORE`. Noms cohérents : `parseDpkgList`/`buildAptExecutionResult` (Task 1) utilisés Task 4 ; `inactivityTimeoutMs` (Task 3) passé Task 4.
@@ -0,0 +1,252 @@
# Tâche 2 — SJ-3 (reboot vérifié) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Ajouter l'action `reboot_verified` : capture du `boot_id` avant reboot, orchestration backend (attente de la coupure SSH, reconnexion avec délai adaptatif, relecture du `boot_id`), production d'un `RebootResult` (`ok` seulement si la machine revient ET le `boot_id` a changé). L'action `reboot` (jalon 1) reste inchangée.
**Architecture:** Référence `docs/design/tache2/10-templates-apt.md §4.5` + `40-contrats-json.md §4` (`RebootResult`). Le template `apt/reboot.sh.tpl` est enrichi pour émettre `===SU:BOOT_ID_BEFORE===`. Un module `server/services/rebootVerify.ts` contient : `classifyReboot(...)` (fonction **pure**, TDD) + `verifyReboot(machineId)` (orchestration réseau : poll `runPlain` jusqu'à coupure puis retour). `execute.ts` route l'action `reboot_verified` vers cette orchestration. Délai adaptatif stocké dans `machine_state` (réutilise la table Phase 1).
**Tech Stack:** TypeScript, ssh2, vitest.
---
## Invariants
- `reboot` (jalon 1) **inchangé** (toujours via `apt/reboot.sh.tpl`, fire-and-forget). `reboot_verified` est une **nouvelle** action.
- `ExecutionResult.reboot` est optionnel (SJ-0) → rétro-compatible.
- Pas de blocage indéfini : timeouts bornés (détection coupure ≤ 60 s ; retour machine ≤ 600 s par défaut).
- Tree partagé / WIP concurrent : ne toucher QUE `templates/apt/reboot.sh.tpl`, `server/services/rebootVerify.ts` (+test), `server/services/execute.ts`. **Ne pas committer.**
## File Structure
```
templates/apt/reboot.sh.tpl # MODIF : +===SU:BOOT_ID_BEFORE===
server/services/rebootVerify.ts # NOUVEAU : classifyReboot (pure) + verifyReboot (orchestration)
server/services/rebootVerify.test.ts # NOUVEAU : tests classifyReboot
server/services/execute.ts # MODIF : route action reboot_verified
```
---
## Task 1 : Template `reboot.sh.tpl` (capture boot_id)
**Files:** Modify `templates/apt/reboot.sh.tpl`.
- [ ] **Step 1 : Remplacer `templates/apt/reboot.sh.tpl`** (ajoute BOOT_ID_BEFORE ; conserve REBOOT_NOW)
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:BOOT_ID_BEFORE==="
cat /proc/sys/kernel/random/boot_id 2>/dev/null
echo "===SU:REBOOT_NOW==="
# Reboot différé pour laisser le canal SSH se fermer proprement.
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
echo "reboot planifié"
```
- [ ] **Step 2 : (pas de commit)**`templates/apt/reboot.sh.tpl` reste utilisé par l'action `reboot` (jalon 1) ET `reboot_verified`.
---
## Task 2 : `classifyReboot` (pure, TDD)
**Files:** Create `server/services/rebootVerify.ts`, `server/services/rebootVerify.test.ts`.
- [ ] **Step 1 : Test (échec attendu)**`server/services/rebootVerify.test.ts`
```ts
import { describe, it, expect } from "vitest";
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";
describe("parseBootIdBefore", () => {
it("extrait le boot_id de la sortie du template", () => {
const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
expect(parseBootIdBefore(raw)).toBe("abcd-1234");
});
it("retourne null si absent", () => {
expect(parseBootIdBefore("rien")).toBeNull();
});
});
describe("classifyReboot", () => {
it("ok si la machine revient avec un boot_id différent", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
});
it("boot_id_unchanged si même boot_id", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
});
it("ssh_never_went_down si la coupure n'a pas été observée", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
});
it("machine_did_not_return si coupure mais pas de retour", () => {
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
});
});
```
- [ ] **Step 2 : Lancer (échec)**`rtk pnpm vitest run server/services/rebootVerify.test.ts` → FAIL.
- [ ] **Step 3 : Implémenter le socle pur dans `server/services/rebootVerify.ts`**
```ts
// server/services/rebootVerify.ts
import { runPlain, type SshCreds } from "../ssh/client.js";
import type { RebootResult } from "@shared/types.js";
export function parseBootIdBefore(raw: string): string | null {
const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
if (s === -1) return null;
const from = s + "===SU:BOOT_ID_BEFORE===".length;
const e = raw.indexOf("===SU:REBOOT_NOW===", from);
const id = raw.slice(from, e === -1 ? undefined : e).trim();
return id || null;
}
export interface RebootSignals {
beforeBootId: string | null;
afterBootId: string | null;
wentDown: boolean;
cameBack: boolean;
}
/** Détermine le statut d'un reboot vérifié (fonction pure). */
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
if (!s.wentDown) return { status: "ssh_never_went_down" };
if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
return { status: "ok" };
}
async function readBootId(creds: SshCreds): Promise<string | null> {
try {
const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
const id = res.stdout.trim();
return id || null;
} catch {
return null; // connexion impossible (machine down)
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export interface VerifyOptions {
beforeBootId: string | null;
requestedAt: string;
downTimeoutMs?: number; // détection de la coupure
upTimeoutMs?: number; // attente du retour
pollMs?: number;
}
/**
* Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
* relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
*/
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
const downTimeoutMs = opt.downTimeoutMs ?? 60000;
const upTimeoutMs = opt.upTimeoutMs ?? 600000;
const pollMs = opt.pollMs ?? 5000;
const t0 = Date.now();
// Phase A : attendre que la machine devienne injoignable.
let wentDown = false;
let sshWentDownAt: string | null = null;
while (Date.now() - t0 < downTimeoutMs) {
const id = await readBootId(creds);
if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
await sleep(pollMs);
}
// Phase B : attendre le retour (seulement si on a vu la coupure).
let cameBack = false;
let sshCameBackAt: string | null = null;
let afterBootId: string | null = null;
if (wentDown) {
const tB = Date.now();
while (Date.now() - tB < upTimeoutMs) {
const id = await readBootId(creds);
if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
await sleep(pollMs);
}
}
const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
const waitedSeconds = Math.round((Date.now() - t0) / 1000);
return {
beforeBootId: opt.beforeBootId,
afterBootId,
requestedAt: opt.requestedAt,
sshWentDownAt,
sshCameBackAt,
waitedSeconds,
status,
lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
};
}
```
- [ ] **Step 4 : Lancer (succès)**`rtk pnpm vitest run server/services/rebootVerify.test.ts` → PASS (6). `rtk pnpm check` → 0 erreur.
- [ ] **Step 5 : (pas de commit)**
---
## Task 3 : Router l'action `reboot_verified` dans `execute.ts`
**Files:** Modify `server/services/execute.ts`.
- [ ] **Step 1 : Relire `server/services/execute.ts`** (TEMPLATE_FOR, flux, blocs Phase 1).
- [ ] **Step 2 : Ajouter `reboot_verified` à `TEMPLATE_FOR`** (réutilise le même template que `reboot`)
```ts
reboot_verified: "apt/reboot.sh.tpl",
```
- [ ] **Step 3 : Après l'exécution du script (raw obtenu), lancer la vérification pour `reboot_verified`** et attacher `result.reboot`. Importer en tête :
```ts
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
import type { RebootResult } from "@shared/types.js";
```
Puis, après le bloc qui calcule `status`/`raw` et avant la construction de `result` (ou juste après, en enrichissant `result`), ajouter une branche :
```ts
let rebootResult: RebootResult | undefined;
if (action === "reboot_verified") {
const beforeBootId = parseBootIdBefore(raw);
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
// Le statut de l'exécution suit la vérif : ok si reboot ok, sinon error.
if (rebootResult.status !== "ok") status = "error";
}
```
Puis dans la construction de `result`, ajouter `...(rebootResult ? { reboot: rebootResult } : {})` ; et conserver `rebootRequiredAfterRun` existant.
> ⚠️ `verifyReboot` est **long** (jusqu'à plusieurs minutes). C'est acceptable : `runAction` est déjà lancé en arrière-plan (la route POST renvoie 202). La sortie live reste streamée pendant l'attente n'est pas nécessaire ; on peut publier un message d'attente : `outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n")` avant `verifyReboot`.
- [ ] **Step 4 : Persister le délai adaptatif** (optionnel, simple) : après `verifyReboot`, si `rebootResult.lastRebootDurationSeconds`, l'écrire dans un event :
```ts
if (rebootResult.status === "ok") {
recordEvent({ machineId, eventType: "reboot_verified", severity: "info", executionId,
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)` });
}
```
(Le stockage en colonne dédiée `machine_state` peut venir plus tard ; l'event suffit au MVP.)
- [ ] **Step 5 : Vérifier**`rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. Les blocs Phase 1 (executions/reports/rawArtifacts/state/event) restent intacts ; `reboot` (jalon 1) inchangé.
- [ ] **Step 6 : (pas de commit)**
---
## Task 4 : Vérification finale SJ-3
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK. Nettoyer.
- [ ] **Step 3 :** Reporter. **Vérif live indispensable** : `reboot_verified` réel sur une machine de test (la boucle réseau attente-coupure/retour + comparaison `boot_id` ne peut être validée qu'en conditions réelles). **Ne pas committer.**
---
## Self-Review (couverture SJ-3)
- `apt/reboot.sh.tpl` capture `boot_id` → Task 1. ✓
- Orchestration backend (attente coupure → reconnexion délai adaptatif → relecture boot_id) → Task 2 (`verifyReboot`). ✓
- `RebootResult` + statuts (`ok`/`ssh_never_went_down`/`machine_did_not_return`/`boot_id_unchanged`/`timeout`) → `classifyReboot` (TDD) + `verifyReboot`. ✓
- Délai adaptatif `lastRebootDurationSeconds``nextRecommendedWaitSeconds``verifyReboot`. ✓
- Conserve l'action `reboot` jalon 1 → Task 3 (nouvelle action distincte). ✓
Décision : la boucle réseau utilise des timeouts bornés (down ≤ 60 s, up ≤ 600 s, poll 5 s) ; seule `classifyReboot` (+`parseBootIdBefore`) est testée unitairement, l'orchestration est validée en live. `timeout` (statut) est couvert par `machine_did_not_return` quand le retour n'arrive pas dans `upTimeoutMs` (mêmes conséquences ; un raffinement `timeout` explicite est notable mais non bloquant au MVP).
@@ -0,0 +1,387 @@
# Tâche 2 — SJ-4 (Docker scan + inspect, passifs) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
**Goal:** Ouvrir le volet Docker (passif) : tables Docker (`docker_settings`, `docker_compose_roots`, `docker_compose_stacks`, `docker_stack_services`), templates `docker/scan-compose.sh.tpl` + `docker/inspect-compose.sh.tpl` (avec délimiteurs Mustache custom pour cohabiter avec les Go-templates Docker), parsing du scan, service de configuration + scan qui persiste les stacks candidats, et branchement des actions `docker_scan` / `docker_inspect_current`.
**Architecture:** Référence `docs/design/tache2/20-docker.md §1-4` + `40-contrats-json.md §3` (`DockerSnapshot*`). **Découverte par racines déclarées** (`composeRoots`) scannées en profondeur bornée, validées par `docker compose config --quiet` ; labels Compose en complément. Cycle stack `candidate``enabled`. **Conflit de délimiteurs résolu** : `renderTemplate` accepte des tags Mustache custom ; les templates Docker utilisent `<% %>` pour les variables, laissant les Go-templates `{{...}}` intacts. Réutilise `runScriptSudo`/`executions`/terminal/`rawLogPath` (pas de moteur parallèle). Passif : aucun `pull`/`up`/`prune` ici (SJ-5/6).
**Tech Stack:** Drizzle/SQLite, Mustache, ssh2, vitest.
---
## Invariants
- **Passif** : SJ-4 ne télécharge/recrée/supprime rien (scan + inspect lecture seule).
- Additif : `MachineView` inchangé ; nouvelles tables ; actions `docker_scan`/`docker_inspect_current` déjà dans l'union `ActionType` (SJ-0).
- Délimiteurs : `renderTemplate` reste rétro-compatible (`{{ }}` par défaut) ; seuls les templates `docker/*` passent `tags: ['<%','%>']`.
- Tree partagé / WIP concurrent : ne toucher QUE `server/db/schema.ts` (+migration), `server/templates/render.ts` (+test), `templates/docker/{scan-compose,inspect-compose}.sh.tpl`, `server/services/dockerScan.ts` (+test), `server/services/execute.ts`. **Ne pas committer.**
## File Structure
```
server/db/schema.ts # MODIF : +docker_settings/compose_roots/compose_stacks/stack_services
server/db/migrations/0004_*.sql # généré
server/db/schema.test.ts # MODIF : +assert tables docker
server/templates/render.ts # MODIF : tags Mustache custom (optionnels)
server/templates/render.test.ts # MODIF : +cas délimiteurs custom
templates/docker/scan-compose.sh.tpl # NOUVEAU (délimiteurs <% %>)
templates/docker/inspect-compose.sh.tpl # NOUVEAU
server/services/dockerScan.ts # NOUVEAU : config + parseDockerScan + scanDockerStacks
server/services/dockerScan.test.ts # NOUVEAU : parseDockerScan (TDD)
server/services/execute.ts # MODIF : actions docker_scan / docker_inspect_current
```
---
## Task 1 : Tables Docker (migration)
**Files:** Modify `server/db/schema.ts` ; generate migration ; extend `server/db/schema.test.ts`.
- [ ] **Step 1 : Relire `schema.ts`** (préserver tout l'existant).
- [ ] **Step 2 : Ajouter les tables** (fin de fichier)
```ts
export const dockerSettings = sqliteTable("docker_settings", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
enabled: integer("enabled").notNull().default(0),
scanDepth: integer("scan_depth").notNull().default(4),
pruneMode: text("prune_mode").notNull().default("safe"),
lastScanAt: text("last_scan_at"),
lastPullCheckAt: text("last_pull_check_at"),
updatedAt: text("updated_at").notNull(),
});
export const dockerComposeRoots = sqliteTable("docker_compose_roots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
path: text("path").notNull(),
enabled: integer("enabled").notNull().default(1),
scanDepth: integer("scan_depth"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
export const dockerComposeStacks = sqliteTable("docker_compose_stacks", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workingDir: text("working_dir").notNull(),
composeFilesJson: text("compose_files_json").notNull(),
projectName: text("project_name"),
envFile: text("env_file"),
status: text("status").notNull(), // candidate | enabled | ignored | error
detectedBy: text("detected_by"), // root_scan | label | manual
lastScanAt: text("last_scan_at"),
lastUpdateAt: text("last_update_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
export const dockerStackServices = sqliteTable("docker_stack_services", {
id: text("id").primaryKey(),
stackId: text("stack_id").notNull().references(() => dockerComposeStacks.id, { onDelete: "cascade" }),
serviceName: text("service_name").notNull(),
imageRef: text("image_ref"),
currentImageId: text("current_image_id"),
currentDigest: text("current_digest"),
candidateImageId: text("candidate_image_id"),
candidateDigest: text("candidate_digest"),
versionLabel: text("version_label"),
status: text("status"), // up_to_date | updates_available | error
updatedAt: text("updated_at").notNull(),
});
```
- [ ] **Step 3 : Générer la migration**`rtk pnpm db:generate``server/db/migrations/0004_*.sql` (4 CREATE TABLE, aucun DROP des tables existantes). Vérifier le SQL.
- [ ] **Step 4 : Étendre `schema.test.ts`** — ajouter un test asserttant la présence de `docker_settings`, `docker_compose_roots`, `docker_compose_stacks`, `docker_stack_services`.
- [ ] **Step 5 :** `rtk pnpm vitest run server/db/schema.test.ts` → PASS ; `rtk pnpm check` → 0 erreur. (pas de commit)
---
## Task 2 : Délimiteurs Mustache custom + templates Docker
**Files:** Modify `server/templates/render.ts`, `server/templates/render.test.ts` ; Create `templates/docker/scan-compose.sh.tpl`, `templates/docker/inspect-compose.sh.tpl`.
- [ ] **Step 1 : Étendre `renderTemplate`** (tags optionnels, rétro-compatible)
```ts
export function renderTemplate(
relPath: string,
vars: TemplateVars,
opts?: { tags?: [string, string] },
): string {
const tpl = readFileSync(resolve(TEMPLATES_ROOT, relPath), "utf8");
// Les templates Docker contiennent des Go-templates {{...}} : on bascule les
// délimiteurs Mustache sur <% %> pour ne pas les interpréter.
const tags = opts?.tags ?? (relPath.startsWith("docker/") ? (["<%", "%>"] as [string, string]) : undefined);
return Mustache.render(tpl, vars, {}, { escape: (s) => s, ...(tags ? { tags } : {}) });
}
```
- [ ] **Step 2 : Test délimiteurs** — ajouter à `render.test.ts`
```ts
it("rend les variables Docker en <% %> sans toucher aux Go-templates {{...}}", () => {
const out = renderTemplate("docker/scan-compose.sh.tpl", { composeRoots: "/opt/stacks", composeScanDepth: 3 });
expect(out).toContain("/opt/stacks");
expect(out).toContain("{{.ID}}"); // Go-template Docker resté littéral
expect(out).not.toContain("<%composeRoots%>");
});
```
- [ ] **Step 3 : Créer `templates/docker/scan-compose.sh.tpl`** (variables en `<% %>`, Go-templates en `{{ }}` littéraux)
```sh
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_SCAN==="
ROOTS="<%composeRoots%>"
DEPTH="<%composeScanDepth%>"
for root in $ROOTS; do
[ -d "$root" ] || continue
find "$root" -maxdepth "$DEPTH" -type f \
\( -name 'compose.yaml' -o -name 'compose.yml' \
-o -name 'docker-compose.yaml' -o -name 'docker-compose.yml' \) \
-not -path '*/.git/*' -not -path '*/node_modules/*' \
-not -path '*/backup/*' -not -path '*/old/*' -not -path '*/archive/*' \
2>/dev/null | while IFS= read -r f; do
dir=$(dirname "$f")
if docker compose -f "$f" config --quiet >/dev/null 2>&1; then
echo "STACK_OK\tdir=$dir\tfile=$f"
else
echo "STACK_INVALID\tdir=$dir\tfile=$f"
fi
done
done
echo "===SU:DOCKER_LABELS==="
docker ps --format '{{.ID}}' 2>/dev/null | while read -r id; do
proj=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$id" 2>/dev/null)
wd=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null)
[ -n "$proj" ] && echo "ACTIVE\tproject=$proj\tworking_dir=$wd"
done
echo "===SU:EXIT=0==="
```
- [ ] **Step 4 : Créer `templates/docker/inspect-compose.sh.tpl`**
```sh
#!/bin/sh
export LC_ALL=C
cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_CONFIG_IMAGES==="
docker compose config --images 2>&1
echo "===SU:DOCKER_PS==="
docker compose ps --format json 2>&1
echo "===SU:DOCKER_IMAGES==="
docker compose images --format json 2>&1
echo "===SU:DOCKER_INSPECT==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
docker image inspect "$img" \
--format 'IMG\t{{.Id}}\t{{join .RepoDigests ","}}\t{{index .Config.Labels "org.opencontainers.image.version"}}\t{{index .Config.Labels "org.opencontainers.image.source"}}' 2>/dev/null \
|| echo "IMG_MISSING\t$img"
done
echo "===SU:EXIT=0==="
```
- [ ] **Step 5 :** `rtk pnpm vitest run server/templates/render.test.ts` → PASS. `rtk pnpm check` → 0 erreur. (pas de commit)
---
## Task 3 : Parsing du scan + service (TDD)
**Files:** Create `server/services/dockerScan.ts`, `server/services/dockerScan.test.ts`.
- [ ] **Step 1 : Test (échec attendu)**`server/services/dockerScan.test.ts`
```ts
import { describe, it, expect } from "vitest";
import { parseDockerScan } from "./dockerScan.js";
const raw = [
"===SU:DOCKER_SCAN===",
"STACK_OK\tdir=/opt/stacks/media\tfile=/opt/stacks/media/compose.yaml",
"STACK_INVALID\tdir=/opt/stacks/broken\tfile=/opt/stacks/broken/compose.yml",
"===SU:DOCKER_LABELS===",
"ACTIVE\tproject=media\tworking_dir=/opt/stacks/media",
"===SU:EXIT=0===",
].join("\n");
describe("parseDockerScan", () => {
it("extrait stacks valides/invalides et actifs", () => {
const r = parseDockerScan(raw);
expect(r.stacks).toEqual([
{ workingDir: "/opt/stacks/media", composeFile: "/opt/stacks/media/compose.yaml", valid: true },
{ workingDir: "/opt/stacks/broken", composeFile: "/opt/stacks/broken/compose.yml", valid: false },
]);
expect(r.active).toEqual([{ project: "media", workingDir: "/opt/stacks/media" }]);
});
});
```
- [ ] **Step 2 : Lancer (échec)**`rtk pnpm vitest run server/services/dockerScan.test.ts` → FAIL.
- [ ] **Step 3 : Implémenter `server/services/dockerScan.ts`**
```ts
// server/services/dockerScan.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { basename } from "node:path";
import { db, schema } from "../db/client.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { runScriptSudo } from "../ssh/client.js";
import { outputHub } from "../ws/outputHub.js";
export interface DockerScanResult {
stacks: { workingDir: string; composeFile: string; valid: boolean }[];
active: { project: string; workingDir: string }[];
}
function fields(line: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of line.split("\t")) {
const i = part.indexOf("=");
if (i > 0) out[part.slice(0, i)] = part.slice(i + 1);
}
return out;
}
export function parseDockerScan(raw: string): DockerScanResult {
const stacks: DockerScanResult["stacks"] = [];
const active: DockerScanResult["active"] = [];
for (const line of raw.split("\n")) {
const l = line.trimEnd();
if (l.startsWith("STACK_OK\t") || l.startsWith("STACK_INVALID\t")) {
const f = fields(l);
stacks.push({ workingDir: f.dir ?? "", composeFile: f.file ?? "", valid: l.startsWith("STACK_OK") });
} else if (l.startsWith("ACTIVE\t")) {
const f = fields(l);
active.push({ project: f.project ?? "", workingDir: f.working_dir ?? "" });
}
}
return { stacks, active };
}
/** Racines Compose déclarées (enabled) d'une machine. */
export function getComposeRoots(machineId: string): string[] {
return db.select().from(schema.dockerComposeRoots)
.where(eq(schema.dockerComposeRoots.machineId, machineId)).all()
.filter((r) => r.enabled).map((r) => r.path);
}
/** Déclare/active Docker pour une machine + ses racines Compose (idempotent). */
export function setDockerRoots(machineId: string, paths: string[], scanDepth = 4): void {
const now = new Date().toISOString();
db.insert(schema.dockerSettings)
.values({ machineId, enabled: 1, scanDepth, pruneMode: "safe", updatedAt: now })
.onConflictDoUpdate({ target: schema.dockerSettings.machineId, set: { enabled: 1, scanDepth, updatedAt: now } })
.run();
db.delete(schema.dockerComposeRoots).where(eq(schema.dockerComposeRoots.machineId, machineId)).run();
for (const path of paths) {
db.insert(schema.dockerComposeRoots).values({
id: randomUUID(), machineId, path, enabled: 1, createdAt: now, updatedAt: now,
}).run();
}
}
/** Scanne les racines déclarées et upsert les stacks candidats. Renvoie le résultat parsé. */
export async function scanDockerStacks(machineId: string): Promise<DockerScanResult> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const roots = getComposeRoots(machineId);
const settings = db.select().from(schema.dockerSettings)
.where(eq(schema.dockerSettings.machineId, machineId)).get();
const depth = settings?.scanDepth ?? 4;
if (roots.length === 0) return { stacks: [], active: [] };
const script = renderTemplate("docker/scan-compose.sh.tpl", {
composeRoots: roots.join(" "),
composeScanDepth: depth,
});
let raw = "";
const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); });
raw = res.stdout;
const parsed = parseDockerScan(raw);
const now = new Date().toISOString();
const activeDirs = new Set(parsed.active.map((a) => a.workingDir));
for (const s of parsed.stacks) {
if (!s.valid) continue;
const name = basename(s.workingDir);
const existing = db.select().from(schema.dockerComposeStacks)
.where(eq(schema.dockerComposeStacks.workingDir, s.workingDir)).get();
const detectedBy = activeDirs.has(s.workingDir) ? "label" : "root_scan";
if (existing) {
db.update(schema.dockerComposeStacks).set({ lastScanAt: now, detectedBy, updatedAt: now })
.where(eq(schema.dockerComposeStacks.id, existing.id)).run();
} else {
db.insert(schema.dockerComposeStacks).values({
id: randomUUID(), machineId, name, workingDir: s.workingDir,
composeFilesJson: JSON.stringify([s.composeFile]), status: "candidate",
detectedBy, lastScanAt: now, createdAt: now, updatedAt: now,
}).run();
}
}
db.update(schema.dockerSettings).set({ lastScanAt: now, updatedAt: now })
.where(eq(schema.dockerSettings.machineId, machineId)).run();
return parsed;
}
```
- [ ] **Step 4 :** `rtk pnpm vitest run server/services/dockerScan.test.ts` → PASS. `rtk pnpm check` → 0 erreur. (pas de commit)
---
## Task 4 : Brancher `docker_scan` / `docker_inspect_current`
**Files:** Modify `server/services/execute.ts`.
- [ ] **Step 1 : Relire `execute.ts`**.
- [ ] **Step 2 : `TEMPLATE_FOR`** — ajouter
```ts
docker_scan: "docker/scan-compose.sh.tpl",
docker_inspect_current: "docker/inspect-compose.sh.tpl",
```
> `docker_inspect_current` requiert un `stackDir` (variable de rendu). Au MVP, `runAction` ne porte pas de paramètre de stack ; `docker_inspect_current` reste donc déclaré mais **son orchestration par stack viendra avec SJ-5** (qui itère les stacks `enabled`). Pour SJ-4, **seul `docker_scan` est réellement exécutable** via `runAction`.
- [ ] **Step 3 : Spécialiser `docker_scan` dans `runAction`** — après obtention de `raw` (le script de scan a tourné via le flux normal), persister les stacks via le parseur. Le plus simple : router `docker_scan` vers le service dédié plutôt que le flux générique. Ajouter en début de `runAction`, juste après le `getMachineRow` et la création de l'`executionId`/insert execution :
```ts
if (action === "docker_scan") {
// Le rendu Docker nécessite les délimiteurs custom + les racines déclarées :
// on délègue au service de scan qui rend le template et persiste les stacks.
const { scanDockerStacks } = await import("./dockerScan.js");
try {
const parsed = await scanDockerStacks(machineId);
outputHub.publish(machineId, `\n===SU:DONE status=ok stacks=${parsed.stacks.length}===\n`);
} catch (err) {
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
}
}
```
> ⚠️ Implémentation propre attendue : plutôt que de laisser le flux générique re-rendre `docker/scan-compose.sh.tpl` SANS racines (ce qui produirait un scan vide), faire en sorte que pour `action === "docker_scan"` le flux générique NE rende PAS le template lui-même. Option recommandée : extraire la logique commune, ou faire un `early return` après le scan pour `docker_scan` en construisant un `ExecutionResult` minimal (status ok, importantLogLines = lignes réduites, report/log archivés comme les autres actions). **Préférer** : router `docker_scan` AVANT le rendu générique et construire son propre `ExecutionResult` (réutiliser les helpers d'archivage). Le sous-agent doit choisir l'implémentation la plus propre qui évite un double rendu.
- [ ] **Step 4 : Vérifier**`rtk pnpm check && rtk pnpm test` → 0 erreur, tests verts. Les blocs Phase 1 et les actions APT restent intacts.
- [ ] **Step 5 : (pas de commit)**
---
## Task 5 : Vérification finale SJ-4
- [ ] **Step 1 :** `rtk pnpm check && rtk pnpm test && rtk pnpm build` → tout vert.
- [ ] **Step 2 : Boot smoke** (DB jetable) → `/health` OK + tables `docker_*` créées. Nettoyer.
- [ ] **Step 3 :** Reporter. Vérif live : `setDockerRoots(machineId, ["/opt/stacks"])` puis action `docker_scan` réelle sur une machine avec Docker → vérifier la détection des stacks. **Ne pas committer.**
---
## Self-Review (couverture SJ-4)
- `docker/scan-compose.sh.tpl` + `inspect-compose.sh.tpl` (passifs) → Task 2. ✓
- Conflit délimiteurs Mustache/Go-template résolu (`<% %>` pour Docker) → Task 2. ✓
- Config machine `composeRoots`/`scanDepth` + tables `docker_*` → Task 1 + Task 3 (`setDockerRoots`/`getComposeRoots`). ✓
- Cycle `candidate` (détecté) + détection labels en complément → `scanDockerStacks`. ✓
- Action `docker_scan` exécutable → Task 4. ✓
- Validation `docker compose config --quiet` (valid/invalid) → template + parser. ✓
Décisions : `docker_inspect_current` déclaré mais orchestré par stack en SJ-5 (nécessite `stackDir`). Pas d'API/UI de configuration des roots en SJ-4 (tâche 3/5) ; `setDockerRoots` est le point d'entrée backend. Aucun pull/up/prune (passif). Noms cohérents : `parseDockerScan`/`getComposeRoots`/`setDockerRoots`/`scanDockerStacks`.
```
@@ -0,0 +1,57 @@
# Tâche 2 — SJ-5 : Docker pull-check + comparaison déterministe
> Statut : **implémenté** (2026-06-05). tsc 0 erreur · 85 tests · build OK.
> Réf. design : `docs/design/tache2/20-docker.md §4.3`, `40-contrats-json.md §3/§6`, `80-sous-jalons.md` SJ-5.
## Périmètre livré
Télécharger les images candidates d'un stack Compose **sans démarrer de conteneur**
(`docker compose pull`), comparer avant/après par image ID + repo digest + label OCI,
et persister l'état des services — **sans toucher au flux jalon 1** et sans déclencher
de pull automatique (action manuelle par stack, non incluse dans `refreshMachine`).
## Composants
- **Template** `templates/docker/pull-check.sh.tpl` — délimiteurs Mustache `<% %>`
(`<%stackDir%>`), Go-templates `{{.Id}}` / `{{join .RepoDigests ","}}` préservés.
Sections `===SU:DOCKER_INSPECT_BEFORE/PULL/INSPECT_AFTER===` + `===SU:EXIT=N===`.
- **`server/services/dockerPull.ts`** :
- `parseDockerPullCheck(raw)` — lit BEFORE/AFTER (id, digest, version), code de sortie,
et extrait les erreurs de pull **nettoyées de tout secret** (URLs, token/bearer/password).
- `buildDockerPullResult(stackName, raw)` — comparaison déterministe → `services`
(`up_to_date | updates_available | error` par image) + `changes` (`operation:"pulled"`
uniquement pour les images modifiées) + `status` global + `errors`.
- `dockerDedupKey(image, fromDigest, toDigest, fromId?, toId?)` — empreinte fonctionnelle
(digests prioritaires, fallback image IDs), conforme `40 §6`.
- `pullCheckStack(machineId, stackId, onData?)` — orchestration SSH + upsert des services
dans `docker_stack_services` (par `stackId + serviceName`), maj `lastUpdateAt` du stack
et `lastPullCheckAt` des settings. **Refuse un stack non `enabled`.**
- **`server/services/dockerPull.test.ts`** — 7 cas (parse, nettoyage secret registry,
classement up_to_date/updates_available, change unique, status global, dédup).
- **Wiring** :
- `runAction(machineId, action, opts?: { stackId })` — branche dédiée `docker_pull_check`
(archivage report/log, `ExecutionResult.docker.pull.changes` + `dedupKey`, event).
- Chemin générique : injection `stackDir` quand `stackId` fourni → **corrige aussi
`docker_inspect_current`** (SJ-4 le déclarait sans orchestration par stack).
- `POST /:id/actions` — allowlist élargie aux actions Docker passives/non-applicatives
(`docker_scan`, `docker_inspect_current`, `docker_pull_check`) ; `stackId` requis pour
les actions par-stack. **Destructives (apply/down/prune agressif) toujours hors API**
jusqu'au socle `action_requests` (SJ-6).
- **`shared/types.ts`** : `DockerImageChange.dedupKey?` (additif, pour mutualisation Hermes).
## Pas de migration
Le schéma SJ-4 (`docker_stack_services` avec `current/candidate_image_id|digest`,
`version_label`, `status` ; `docker_settings.last_pull_check_at`) couvrait déjà SJ-5.
## Sécurité
- `docker compose pull` sans `up` → aucun conteneur recréé (pré-check pur applicatif).
- Erreurs registry (`registry_auth_failed` / `pull_failed`) **nettoyées** : ni URL, ni token,
ni mot de passe ne remontent vers UI/MCP (test dédié).
- Credentials registry (`~/.docker/config.json`) jamais lus ni renvoyés.
## Reste pour SJ-6
`docker_compose_apply` (up -d --remove-orphans), `docker_prune_images`, `docker_compose_down`,
table `docker_image_events`, et validation UI explicite via `action_requests`.
@@ -0,0 +1,45 @@
# Tâche 2 — SJ-6 : Docker apply / prune / down + socle action_requests
> Statut : **implémenté** (2026-06-06). tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005).
> Réf. design : `docs/design/tache2/20-docker.md §4.4-4.6`, `40-contrats-json.md §4`, `70-securite.md §2`, `80-sous-jalons.md` SJ-6.
## Périmètre livré
Actions Docker **destructives** (recrée/supprime) protégées par un socle de
**validation explicite** (`action_requests`) : Hermes/UI proposent, l'opérateur approuve,
l'exécution part en arrière-plan. Aucune de ces actions n'est accessible directement
via `POST /:id/actions` (allowlist passive uniquement).
## Composants
- **Migration 0005** (`0005_silent_drax.sql`, timestamp monotone) : tables
`docker_image_events` (historique pulled/recreated/pruned + bytes) et
`action_requests` (pending|approved|rejected|executed|expired).
- **Templates** `docker/apply-compose.sh.tpl` (`up -d --remove-orphans`),
`docker/prune-images.sh.tpl` (safe par défaut / `<%#aggressive%>` = `-a --filter until=168h`),
`docker/down-compose.sh.tpl` (down simple, **`--volumes`/`--rmi` interdits**).
- **`server/services/dockerApply.ts`** :
- parsers purs (TDD) : `parseDockerApply` (recreated/running/exited via ps json),
`parseDockerPrune` (`imagesDeleted` + `Total reclaimed space` → octets),
`parseDockerDown` (removed), `parseHumanBytes` (unités décimales Docker).
- orchestration : `applyStack` / `pruneImages` / `downStack` — réservées aux stacks
`enabled`, insèrent les `docker_image_events`. Erreurs nettoyées (réutilise `cleanDockerError`).
- **`server/services/actionRequests.ts`** : `createActionRequest` (refuse une action non
destructive, exige `stackId` pour apply/down), `approve` (→ `runAction` en tâche de fond,
pose `executionId`/`executed`), `reject`, `get`, `list`.
- **Routes** `server/routes/actionRequests.ts` (montées à la racine `/api`) :
`POST /machines/:id/action-requests`, `GET …`, `GET/POST /action-requests/:id[/approve|/reject]`.
- **`execute.ts`** : `RunActionOpts.aggressive`, branches `docker_compose_apply` /
`docker_prune_images` / `docker_compose_down`, helper `archiveExecution` mutualisant
le boilerplate (log/rapport/DB/état/event) + `ExecutionResult.docker.up|prune`.
## Sécurité
- Destructives **hors API directe** : passent obligatoirement par un `action_request` approuvé.
- `down` sans volumes ni rmi (volumes préservés). Prune agressif = risque distinct (champ `aggressive`).
- Erreurs Docker nettoyées (URL/token/password) avant UI/MCP.
## Reste tâche 2
SJ-7 (profils Proxmox/RPi + proxy persistent), SJ-8/9 (post-install). UI des boutons
validés (Appliquer/Prune/Down) = tâche 3 (frontend, design system).
@@ -0,0 +1,39 @@
# Tâche 2 — SJ-7 : Profils OS Proxmox/RPi + machine_probe + proxy persistent
> Statut : **implémenté** (2026-06-06). tsc 0 · 95 tests · build OK. Résolution OS vérifiée.
> Réf. design : `docs/design/tache2/60-profils-os-machine.md`, `80-sous-jalons.md` SJ-7.
## Périmètre livré (additif, fallback base préservé)
- **Templates OS-spécifiques** :
- `templates/proxmox/update-analyze.sh.tpl` (détection dépôts PVE enterprise/no-subscription)
+ `full-upgrade.sh.tpl` (dist-upgrade kernel/proxmox-ve/Ceph).
- `templates/raspbian/update-analyze.sh.tpl` (contrôle espace disque carte SD)
+ `full-upgrade.sh.tpl` (`apt full-upgrade`). `rpi-update` volontairement non utilisé.
- **Résolution par profil OS dans `execute.ts`** : les actions APT passent par
`resolveTemplate(file, osFamily)``proxmox/`/`raspbian/` si dispo, sinon `apt/`.
Vérifié : proxmox/raspbian pris ; debian/ubuntu → fallback `apt/` (non-régression jalon 1).
`refresh.ts` résolvait déjà `update-analyze`.
- **`machine_probe`** (action lecture seule) :
- `templates/apt/machine-probe.sh.tpl` (os-release, arch, systemd-detect-virt, /etc/pve,
/proc/cpuinfo RPi, lspci GPU, ip addr).
- `machineProbe.ts` : `parseProbe` + `proposeCorrections` (TDD, 4 cas : Proxmox/RPi/VM KVM)
→ propose `os_family`/`machine_kind`/`virtualization`. `runProbe` persiste les faits
matériels (`machine_hardware` gpus/network) et renvoie un diff **jamais appliqué auto**.
- Branche `execute` (archiveExecution) + allowlist route.
- **Proxy APT persistant** (`apt_proxy_persistent`) :
- ActionType ajouté ; `templates/apt/apt-proxy-persistent.sh.tpl` écrit
`/etc/apt/apt.conf.d/01proxy` (idempotent, sauvegarde horodatée de l'existant).
- `TemplateVars.aptProxyUrl` ; rendu avec `m.aptProxyUrl` ; allowlist route.
## Sécurité / invariants
- `machine_probe` ne modifie rien ; les corrections OS/kind sont **proposées**, l'opérateur
garde le dernier mot (pas d'application auto).
- Proxy persistant = action explicite idempotente avec backup ; l'URL n'est pas un secret.
- Aucun secret dans les templates ; fallback `base` garantit la non-régression Debian/Ubuntu.
## Reste tâche 2
SJ-8 / SJ-9 (post-install : bootstrap/identité, paquets de base/Docker officiel/partages/VM tools).
UI : bouton « Sonder » + affichage des propositions, sélecteur de proxy persistant = tâche 3.
@@ -0,0 +1,110 @@
# Jalon 2 — Polish design system — Design
> Spec du deuxième jalon : refonte de l'UI avec le design system Gruvbox seventies.
> Statut : **validé** (2026-06-05). Langue de travail : français.
> Voir aussi : `CLAUDE.md`, `design_system/consigne_design_system.md`, jalon 1 (`docs/superpowers/specs/2026-06-04-jalon1-tranche-verticale-apt-design.md`).
## Objectif
Le jalon 1 a livré une UI fonctionnelle mais "brute" : des `<button className="interactive">` et des styles inline, sans utiliser les composants du design system. Le `ui-kit.tsx` porté n'est même pas consommable (il expose ses composants via `Object.assign(window, …)` et dépend de Font Awesome non chargé).
Ce jalon **branche correctement le design system** et **refond les écrans existants** avec ses composants, en respectant la consigne (`design_system/consigne_design_system.md`). Aucune nouvelle capacité métier : c'est un jalon qualité.
## Périmètre
**Dedans** : wiring du DS (exports ESM, Font Awesome, polices, tous bundlés offline), refonte des écrans existants avec les composants DS, ajout d'un header (titre + actions + bascule thème), ajout d'une status bar style tmux, vérification des deux thèmes (dark + light).
**Dehors** : aucune logique backend, aucune nouvelle route, pas de panneaux redimensionnables (split panes — reporté), pas de nouveaux écrans.
## Décisions verrouillées
| Sujet | Décision |
|-------|----------|
| Font Awesome | Bundlé via npm `@fortawesome/fontawesome-free` (solid), import CSS dans `main.tsx`. Offline, pas de CDN. |
| Polices | Bundlées via `@fontsource/inter`, `@fontsource/jetbrains-mono`, `@fontsource/share-tech-mono`, importées dans `main.tsx`. Offline. |
| Consommation `ui-kit` | Ajout d'exports ESM nommés. On garde `@ts-nocheck` (pas de réécriture typée) et le `Object.assign(window, …)` existant. |
| Layout | Ajout d'un header et d'une status bar. Les 3 volets restent. |
| Thème | Bascule dark/light via `lib/theme.ts` (persistance localStorage), IconButton sun/moon dans le header. |
| Tests | Helpers purs testés (`theme`, `sumUpdates`). Le reste = vérif visuelle. `ui-kit` jamais importé dans un test (touche `window`/`document` au chargement). |
## Contrainte transverse
Le design system impose (consigne) : variables CSS uniquement, composants existants réutilisés, `<Icon name=>` (jamais d'emoji/SVG custom), **pas de hover** sauf jauges (pression 3D `.interactive`), tooltips obligatoires sur IconButton isolé, polices Inter/JetBrains Mono/Share Tech Mono, labels uppercase. Tout écran doit être lisible et cohérent en **dark ET light**.
## API du design system (vérifiée dans `ui-kit.tsx`)
- `Icon({ name, size, style })``name` mappé via `ICON_MAP` vers `fa-solid fa-…`. Icônes dispo : cpu, memory, disk, network, clock, grid, list, cog, alert, bell, server, chart, bars, terminal, refresh, play, pause, power, sun, moon, search, close, chevR/L/D/U, plus, filter, download, folder, node, user.
- `Button({ children, icon, onClick, variant, size })` — variant: default/primary/ghost/danger ; size: sm/md/lg.
- `IconButton({ icon, label, onClick, active, danger, size, primary })``label` = tooltip (obligatoire).
- `StatusLed({ status, size, pulse })` — status: ok/warn/err/info/off.
- `Popup({ open, onClose, title, children, footer, width })`.
- `Toggle`, `Tooltip`, `BatteryGauge`, `RadialGauge`, `BigRadialGauge`, `TreeNav`, `Sparkline`, `LineChart` (non utilisés ici mais exportés).
## Wiring du design system
1. **`client/src/components/ui-kit.tsx`** : ajouter en fin de fichier
`export { Icon, Tooltip, IconButton, Toggle, StatusLed, BatteryGauge, RadialGauge, BigRadialGauge, Popup, Button, TreeNav, Sparkline, LineChart };`
Conserver `@ts-nocheck`, l'import React et le `Object.assign(window, …)`.
2. **`client/src/main.tsx`** : ajouter les imports CSS
`import "@fortawesome/fontawesome-free/css/all.min.css";`
`import "@fontsource/inter";` `import "@fontsource/jetbrains-mono";` `import "@fontsource/share-tech-mono";`
3. **`package.json`** : ajouter les deps `@fortawesome/fontawesome-free`, `@fontsource/inter`, `@fontsource/jetbrains-mono`, `@fontsource/share-tech-mono`.
## Layout cible
```
┌─ Header : « System Update » ............ [+ Ajouter] [☀/☾] ┐
├──────────┬─────────────────────────────┬────────────────────┤
│ Hermes │ Dashboard (tuiles machines) │ Terminal │
├──────────┴─────────────────────────────┴────────────────────┤
│ SYSTEM UPDATE · N machines · M updates · ⏱ 14:22:07 │
└──────────────────────────────────────────────────────────────┘
```
`App.tsx` orchestre : `<Header>` en haut, la rangée 3 volets au milieu (flex:1), `<StatusBar>` en bas.
**Remontée d'état dans `App`** : la liste des machines, les compteurs d'updates, la machine sélectionnée et le thème vivent désormais dans `App` (le Dashboard les reçoit en props, plus de fetch local autonome). Cela alimente le Header (action Ajouter), la StatusBar (N machines, M updates) et le TerminalPanel (machine sélectionnée). Le chargement des machines + snapshots se fait dans `App` via une fonction `load()` passée au Dashboard pour rafraîchir après action.
## Composants
### Nouveaux
- **`Header.tsx`** : titre « System Update », bouton `<Button variant="primary" icon="plus">Ajouter</Button>` (ouvre la modale via callback remonté), et `<IconButton icon={theme==="dark"?"sun":"moon"} label="Basculer le thème">`. Hauteur 48-56px, fond `--bg-2`.
- **`StatusBar.tsx`** : 1re cellule mode « SYSTEM UPDATE » fond `--accent` ; cellules suivantes séparées par `border-right: 1px solid var(--border-1)` ; nb machines, total updates ; horloge live (Share Tech Mono, tick 1s via `setInterval`, nettoyé au démontage) à droite. Hauteur 24-28px.
- **`lib/theme.ts`** :
- `type Theme = "dark" | "light"`
- `getInitialTheme(): Theme` — lit `localStorage["su-theme"]`, défaut `"dark"`, robuste si localStorage indisponible.
- `applyTheme(t: Theme): void``document.documentElement.dataset.theme = t` + persiste.
- `nextTheme(t: Theme): Theme` — bascule dark↔light (fonction pure, testable).
- **`lib/stats.ts`** : `sumUpdates(counts: Record<string, number>): number` (fonction pure, testable).
### Refondus
- **`MachineTile.tsx`** : point d'état → `<StatusLed status={machine.status} pulse={machine.status==="running"}>` ; compteur → `<span className="label">UPDATES</span> <span className="mono">{count}</span>` ; actions → `<IconButton icon="refresh" label="Rafraîchir">`, `<IconButton icon="download" label="Upgrade">`, `<IconButton icon="power" label="Redémarrer" danger>`. Conserver `className="glass"`, `onClick` sélection (les IconButton stoppent la propagation).
- **`AddMachineModal.tsx`** : enveloppé dans `<Popup open onClose title="Ajouter une machine" footer={…}>` ; footer = `<Button variant="ghost" onClick={onClose}>Annuler</Button>` + `<Button variant="primary" icon="download" onClick={submit}>Ajouter</Button>` ; champs en inputs tokenisés ; erreur affichée avec `<StatusLed status="err">` + texte `--err`. Logique de soumission inchangée (POST /api/machines).
- **`Dashboard.tsx`** : retirer le bouton « + Ajouter » local (déplacé dans le Header) ; le Dashboard expose l'ouverture de la modale via prop/état remonté à `App`. Grille de tuiles inchangée. État vide : texte `--ink-3`.
- **`HermesPanel.tsx`** : en-tête `.label` + `<Icon name="bell">` (ou autre), texte stub inchangé.
- **`TerminalPanel.tsx`** : recevoir la **machine sélectionnée** (objet `MachineView`, pas juste l'id) depuis `App`. En-tête clair au-dessus du xterm : `<StatusLed status>` + `.label` « TERMINAL » + nom de la machine (`.mono`) + hostname (`--ink-3`). **Séparation franche entre machines** (retour d'usage, `amelioration.md`) : à chaque changement de machine, écrire une bannière de séparation dans le terminal, p. ex. `\n──────── <nom> (<hostname>) ────────\n` (couleur accent), avant de rejouer le flux. Le terminal est déjà recréé par machine (`useEffect` deps) ; l'en-tête nommé + la bannière rendent le passage d'une machine à l'autre non ambigu (fini l'UUID). xterm inchangé sinon.
## Flux thème
Au montage de `App` : `applyTheme(getInitialTheme())`. État `theme` dans `App` ; le toggle du Header appelle `setTheme(nextTheme(theme))` puis `applyTheme`. `data-theme` initial dans `index.html` reste `dark` (cohérent avec le défaut).
## Gestion d'erreurs / cas limites
- `localStorage` indisponible (mode privé) → `getInitialTheme` retombe sur `"dark"`, `applyTheme` ignore l'échec de persistance (try/catch) sans casser l'UI.
- Icône inconnue → le composant `Icon` retombe déjà sur `circle-question`.
- Horloge : l'intervalle est nettoyé dans le cleanup du `useEffect`.
## Tests
- **`lib/theme.test.ts`** : `nextTheme("dark")==="light"` et inverse ; `getInitialTheme()` retombe sur `"dark"` quand localStorage vide. (localStorage mocké, pas d'import de `ui-kit`.)
- **`lib/stats.test.ts`** : `sumUpdates({a:2,b:3})===5` ; `sumUpdates({})===0`.
- Vérif build : `pnpm check` + `pnpm build` verts.
- **Vérif visuelle manuelle** (utilisateur) : dark ET light lisibles ; icônes FA affichées ; polices Inter/JetBrains Mono/Share Tech Mono appliquées ; tooltips sur les IconButton ; modale Popup OK ; status bar + horloge ; aucun hover sur boutons/tuiles (pression 3D seulement).
## Critères d'acceptation
- [ ] `ui-kit` exporte ses composants en ESM ; les écrans les importent (plus aucun `<button className="interactive">` brut dans les features).
- [ ] Font Awesome et les 3 polices sont bundlés (offline) et appliqués.
- [ ] Header avec titre, bouton Ajouter, bascule thème fonctionnelle et persistée.
- [ ] Status bar tmux avec mode, compteurs et horloge live.
- [ ] MachineTile utilise StatusLed + IconButton (tooltips) ; AddMachineModal utilise Popup + Button.
- [ ] Le terminal identifie clairement la machine courante (nom + hostname, plus d'UUID) et marque une séparation franche au passage d'une machine à l'autre.
- [ ] Les deux thèmes sont cohérents et lisibles.
- [ ] `pnpm check`, `pnpm build`, et les tests des helpers passent.
+148
View File
@@ -0,0 +1,148 @@
# Liste des tâches projet — system_update
> **But** : garder une vue claire de la numérotation des tâches et de leur périmètre.
---
## Tâches existantes
### Tâche 1.9 — Architecture BDD cible
Fichier : `tache1.9.md`
Validation : `validation_tache1.9.md`
Périmètre :
- schéma SQLite/Drizzle cible ;
- migration future PostgreSQL ;
- machines, snapshots, exécutions ;
- logs/rapports/messages importants ;
- Docker, post-install, jobs ;
- Hermes/MCP ;
- métriques, nettoyage, découverte ;
- préférences frontend ;
- app locale future.
### Tâche 2 — Moteur de templates et contrats JSON
Fichier : `tache2.md`
Périmètre :
- APT update/analyse/upgrade/reboot ;
- Docker Compose ;
- scripts custom/post-install ;
- profils OS et type de machine ;
- JSON canoniques ;
- intégration Hermes/MCP.
Validation : `validation_tache2.md`
### Tâche 3 — Frontend web, tuiles, layout, paramètres
Fichier : `tache3.md`
Validation : `validation_tache3.md`
Périmètre :
- tuiles machine extensibles ;
- layout web global header/Hermes/centre/terminal/footer ;
- volet Hermes ;
- terminal droit ;
- mode smartphone ;
- paramètres app ;
- favicon, icônes smartphone, icônes SVG spécifiques ;
- brief icônes : `consigne_icon.md`.
### Tâche 4 — Scripts post-install et installateurs
Fichier : `tache4.md`
Validation : `validation_tache4.md`
Périmètre :
- profils post-install ;
- Docker officiel ;
- partage Samba/NFS/wsdd2 ;
- dev-tools, domotique, ESP/PlatformIO ;
- détection hardware ;
- drivers/firmware ;
- métriques simples ;
- benchmark.
### Tâche 5 — Backend, historique JSON et automatisations
Fichier : `tache5.md`
Validation : `validation_tache5.md`
Périmètre :
- stockage JSON ;
- état courant machine ;
- logs/rapports/messages ;
- schedules/jobs ;
- API webapp/Hermes/app locale ;
- rétention.
### Tâche 6 — Hermes, MCP, skills et messagerie
Fichier : `tache6.md`
Validation : `validation_tache6.md`
Périmètre :
- volet Hermes web ;
- API Hermes ;
- MCP HTTP ;
- skill `system-update-ops` ;
- messagerie/TUI ;
- accès rapports/logs réduits.
### Tâche 7 — Optimisation, métriques, nettoyage, sécurité
Fichier : `tache7.md`
Validation : `validation_tache7.md`
Périmètre :
- footer métriques ;
- métriques simples par machine ;
- optimisation tokens Hermes ;
- nettoyage DB/logs ;
- découverte SSH ;
- sécurité mots de passe/secrets ;
- smartphone à brainstormer.
### Tâche 8 — App locale Rust/GNOME
Fichier : `tache8.md`
Validation : `validation_tache8.md`
Périmètre :
- application native Rust ;
- GTK4/libadwaita ;
- API commune avec backend ;
- thème Gruvbox GNOME ;
- mode sans navigateur ;
- sécurité token locale ;
- cache lecture seule ;
- notifications desktop.
---
## Fichiers transverses
- `validation_tache2.md` : gate de validation tâche 2.
- `validation_tache1.9.md`, `validation_tache3.md` à `validation_tache8.md` : gates des autres tâches.
- `coherence_taches.md` : revue de cohérence globale et ordre de développement recommandé.
- `consigne_icon.md` : brief de création icônes SVG/favicon/smartphone.
- `design_system/consigne_design_system.md` : règles design system web/Gruvbox.
- `design_system/tokens/tokens.gnome.css` : base thème pour future app GNOME.
+17 -5
View File
@@ -3,18 +3,25 @@
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
"engines": { "node": ">=22" },
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "pnpm run dev:server & pnpm run dev:client",
"dev:server": "tsx watch server/index.ts",
"dev:server": "tsx watch --env-file=.env server/index.ts",
"dev:client": "vite",
"build": "vite build && tsup",
"start": "node dist/index.js",
"start": "node --env-file=.env dist/index.js",
"test": "vitest run",
"check": "tsc --noEmit",
"db:generate": "drizzle-kit generate"
"db:generate": "drizzle-kit generate",
"api-client:create": "tsx server/cli/createApiClient.ts"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/share-tech-mono": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@hono/node-server": "^1.13.0",
"better-sqlite3": "^11.8.0",
"croner": "^9.0.0",
@@ -25,7 +32,12 @@
"ws": "^8.18.0"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "ssh2", "cpu-features", "esbuild"]
"onlyBuiltDependencies": [
"better-sqlite3",
"ssh2",
"cpu-features",
"esbuild"
]
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
+164
View File
@@ -0,0 +1,164 @@
# Plan de développement — Tâche 3
> Suivi vivant du développement lié à `tache3.md`.
> Objectif : faire évoluer la webapp vers les tuiles machine extensibles, paramètres frontend et layout dashboard cible.
---
## 0. Position actuelle
- Date de démarrage dev : 2026-06-05.
- État : démarrage après validation tâche 3.
- Validation disponible : `validation_tache3.md`, verdict **accepté avec réserves**.
- Périmètre immédiat : webapp React, design system, tuiles machine.
---
## 1. Réserves à traiter
- [ ] Clarifier logos officiels : favicon/app icon original, logos officiels uniquement pour types/outils si autorisés.
- [ ] Ajouter ou décider le composant `Checkbox`/sélection profil post-install.
- [ ] Prévoir spec mobile dédiée.
- [ ] Aligner largeurs min/max Hermes/terminal avec design system.
- [ ] Ajouter état machine erreur/hors ligne dans les maquettes et l'UI.
- [ ] Trancher sauvegarde paramètres : auto-save ou bouton save.
- [ ] Prévoir composants `Select`/`Dropdown` design system.
---
## 2. Jalons
### 3.0 — Reprise et cadrage
- [x] Vérifier que `plan_8.md` est à jour.
- [x] Mettre la tâche 8 en pause.
- [x] Relire `tache3.md` et `validation_tache3.md`.
- [x] Inspecter `MachineTile`, `Dashboard`, `App`, `ui-kit`, CSS.
### 3.1 — Tuile machine compacte/extensible
- [x] Remplacer les boutons texte système par `IconButton`.
- [x] Utiliser `StatusLed` du ui-kit.
- [x] Afficher OS/type/status/dernier check de façon compacte.
- [x] Ajouter sections repliables Docker et Post-install.
- [x] Ajouter état erreur/hors ligne lisible dans la tuile.
- [x] Ajouter CSS dédié sans inline styles excessifs.
- [x] Vérifier TypeScript/build.
### 3.2 — Layout global webapp
- [x] Ajouter header webapp.
- [x] Ajouter footer/barre de tâche avec métriques minimales.
- [x] Vérifier que Hermes/centre/terminal ne se chevauchent pas.
- [x] Préparer largeurs bornées et future redimension.
### 3.3 — Paramètres frontend
- [x] Créer vue Paramètres.
- [x] Apparence/thème/zoom/tuiles.
- [x] Layout volets Hermes/terminal.
- [x] Scripts custom, Docker roots, nettoyage logs.
- [x] Persistance backend à préparer, pas localStorage seul.
### 3.4 — Mode smartphone
- [ ] Brainstorm UX dédiée.
- [ ] Décider onglets/bottom nav.
- [ ] Définir vues prioritaires mobile.
---
## 3. Avancement du tour en cours
- [x] Contexte tâche 8 vérifié.
- [x] Tâche 3 relue.
- [x] Réserves de validation listées.
- [x] Refonte `MachineTile` premier incrément terminée.
---
## 4. Premier incrément — Tuile machine
Fichiers modifiés :
- `client/src/features/machines/MachineTile.tsx`
- `client/src/styles/app.css`
- `client/src/components/ui-kit.tsx`
Ce qui est en place :
- tuile compacte plus dense ;
- actions système en icônes avec tooltips ;
- statut via `StatusLed` ;
- résumé updates/reboot/dernier check ;
- alerte visible pour état `error` ou `unknown` ;
- sections repliables Docker et Post-install ;
- placeholders UI tant que les données Docker/Post-install ne sont pas exposées par le backend ;
- nouveaux alias icônes utiles dans `ICON_MAP`.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
Note :
- Vite signale un warning de chunk JS > 500 kB. Non bloquant pour ce jalon, à traiter plus tard par découpage dynamique si nécessaire.
---
## 5. Deuxième incrément — Layout global
Fichiers modifiés :
- `client/src/App.tsx`
- `client/src/panels/Dashboard.tsx`
- `client/src/lib/api.ts`
- `client/src/styles/app.css`
Ce qui est en place :
- header webapp avec identité, résumé machines/updates/jobs/erreurs et toggle thème ;
- footer/barre de tâche style terminal avec machines, apt, jobs, métriques process et load ;
- appel frontend vers `/api/system/metrics` ;
- largeurs Hermes et terminal bornées avec `clamp(...)` ;
- Dashboard remonte un résumé à `App`.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
Note :
- warning Vite chunk > 500 kB toujours présent, non bloquant.
---
## 6. Troisième incrément — Paramètres frontend
Fichiers modifiés :
- `client/src/App.tsx`
- `client/src/panels/SettingsModal.tsx`
- `client/src/styles/app.css`
Ce qui est en place :
- bouton Paramètres dans le header ;
- modale Paramètres avec navigation latérale ;
- catégories Apparence, Tuiles, Volets, Docker, Scripts, Hermes, Terminal, Nettoyage ;
- contrôles prêts à brancher à une future API `/api/settings` ;
- mention explicite côté UI que la persistance backend reste à venir.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
Note :
- warning Vite chunk > 500 kB toujours présent, non bloquant.
+298
View File
@@ -0,0 +1,298 @@
# Plan de développement — Tâche 8
> Suivi vivant du développement lié à `tache8.md`.
> Objectif : préparer puis développer l'app locale Rust/GNOME sans casser la webapp serveur.
---
## 0. Position actuelle
- Date de démarrage : 2026-06-05.
- État : développement validé par l'utilisateur.
- Décision : le gate qui bloquait le code Rust est levé par validation utilisateur du 2026-06-05. Le scaffold Rust peut démarrer, avec une approche progressive centrée sur le client API avant l'UI GTK/libadwaita.
- Dossier dédié Rust : `app_rust/system-update-gnome/`.
---
## 1. Vision suffisante pour démarrer ?
Oui pour un premier incrément.
La direction est claire :
- l'app locale Rust/GNOME est un client du backend `system_update` ;
- elle ne fait pas de SSH direct au MVP ;
- elle consomme les mêmes JSON que la webapp ;
- elle doit découvrir les capacités du serveur avant d'afficher ses actions ;
- les secrets machines restent côté backend ;
- le token client local sera stocké côté app via trousseau système, quand le scaffold Rust sera autorisé.
Point d'environnement :
- Rust est installé.
- GTK4/libadwaita ne sont pas encore visibles via `pkg-config`, donc l'UI GNOME complète attendra les paquets système de développement.
---
## 2. Jalons
### 8.0 — API commune minimale
- [x] Relire `tache8.md` et `validation_tache8.md`.
- [x] Identifier le premier endpoint utile pour app locale.
- [x] Ajouter le type partagé `ServerCapabilities`.
- [x] Exposer `GET /api/capabilities`.
- [x] Ajouter un test du contrat capabilities.
- [x] Vérifier TypeScript/tests ciblés.
### 8.1 — Préparation sécurité client local
- [x] Définir le modèle `api_clients` côté backend.
- [x] Prévoir scopes : lecture seule, opérateur, admin, debug.
- [x] Prévoir révocation de token.
- [x] Documenter stockage token app locale via keyring.
- [x] Préparer un middleware d'auth API après validation du mode d'amorçage admin.
- [x] Ajouter une commande locale de création de token.
- [ ] Activer le middleware sur les routes après choix du mode bootstrap admin.
### 8.2 — Contrat API app locale
- [ ] Stabiliser endpoints machines/state/metrics/hardware.
- [ ] Stabiliser snapshots/executions/reports/messages.
- [ ] Clarifier pagination et erreurs structurées.
- [ ] Clarifier WebSocket/SSE pour sortie live.
- [x] Ajouter `/api/system/status`.
- [x] Ajouter `/api/system/metrics`.
### 8.3 — Scaffold Rust/GNOME
- [x] Créer workspace Rust après validation.
- [x] Utiliser un sous-dossier dédié : `app_rust/system-update-gnome/`.
- [ ] Choisir GTK4/libadwaita direct ou Relm4.
- [x] Implémenter configuration URL serveur.
- [x] Implémenter test de connexion via `/api/capabilities`.
- [ ] Stocker token via keyring.
- [x] Isoler la stratégie token dans `src/token_store.rs`.
### 8.4 — UI native MVP
- [x] Première fenêtre GTK/libadwaita derrière feature `gui`.
- [x] Champ URL serveur.
- [x] Boutons `Capabilities`, `Status`, `Metrics`.
- [x] Zone résultat JSON.
- [ ] HeaderBar + Sidebar complète.
- [ ] Liste machines.
- [ ] Tuile machine compacte.
- [ ] Détail machine.
- [ ] Lancement `apt_update_analyze`.
- [ ] Lecture rapports/logs réduits.
- [ ] Notifications desktop simples.
---
## 3. Avancement du tour en cours
- [x] Le repo a été inspecté.
- [x] Le manque prioritaire est identifié : endpoint capabilities absent.
- [x] Patch API capabilities appliqué.
- [x] Vérification TypeScript passée.
- [x] Vérification Vitest ciblée passée.
- [x] Vérification Vitest complète passée.
---
## 4. Résultat du premier incrément
Fichiers ajoutés/modifiés pour le démarrage tâche 8 :
- `shared/types.ts` : ajout du contrat partagé `ServerCapabilities`.
- `server/services/capabilities.ts` : génération du JSON de capabilities.
- `server/services/capabilities.test.ts` : test du contrat capabilities.
- `server/routes/index.ts` : exposition de `GET /api/capabilities`.
- `plan_8.md` : suivi d'avancement.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run server/services/capabilities.test.ts` : OK.
- `vitest run` : OK, 11 fichiers de test, 25 tests.
Décision de suite :
- Continuer par `8.1` et `8.2` : sécurité client local, scopes de token, erreurs structurées et endpoints stables.
- Débuter le scaffold Rust dans `app_rust/system-update-gnome`.
- Garder l'UI GTK/libadwaita pour un incrément suivant, car les bibliothèques système ne sont pas encore installées.
---
## 5. Validation utilisateur du démarrage dev
- [x] Demande reçue : "ok je valide pour que tu commences le dev".
- [x] `tache8.md` mis à jour : la tâche passe de design futur à développement progressif.
- [x] `validation_tache8.md` mis à jour : le code Rust est autorisé dans `app_rust/system-update-gnome`.
- [x] Scaffold Rust minimal créé dans `app_rust/system-update-gnome`.
- [x] `.gitignore` local ajouté pour ignorer `target/`.
- [x] `cargo fmt` passé.
- [x] `cargo test` passé : 7 tests.
---
## 6. Jalon 8.1 — Sécurité client local
Fichiers ajoutés/modifiés :
- `shared/types.ts` : scopes API et vues client API sans secret.
- `server/db/schema.ts` : table `api_clients`.
- `server/db/migrations/0001_api_clients.sql` : migration SQLite.
- `server/crypto/apiTokens.ts` : génération, préfixe, hash HMAC, vérification.
- `server/services/apiClients.ts` : création/liste/révocation côté service.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run server/crypto/apiTokens.test.ts server/services/apiClients.test.ts` : OK.
- `vitest run` : OK, 13 fichiers de test, 32 tests.
- migration SQLite temporaire : OK.
Décision :
- Ne pas exposer encore une route publique de création de token sans mécanisme admin.
- Garder `authTokens: false` dans `/api/capabilities` tant que l'auth n'est pas réellement activée.
Complément :
- `server/auth/apiAuth.ts` : middleware `requireApiScope` prêt à brancher.
- `server/auth/apiAuth.test.ts` : tests extraction Bearer.
- `app_rust/system-update-gnome/src/token_store.rs` : séparation token CLI/env/futur trousseau.
- `app_rust/system-update-gnome/docs/token-storage.md` : choix et règles du stockage token.
- `server/cli/createApiClient.ts` : création locale d'un token API sans route publique.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `cargo test` : OK, 11 tests.
---
## 9. Passe compilation/test
Dernière passe lancée après validation du dossier projet :
- `tsc --noEmit` : OK.
- `vitest run` : OK, 16 fichiers de test, 42 tests.
- `vite build && tsup` : OK.
- `cargo fmt` : OK.
- `cargo test` : OK, 11 tests.
- `cargo build` : OK, sans warning après utilisation de l'identité keyring dans l'aide CLI.
---
## 10. Test réel client Rust ↔ backend
Backend temporaire lancé avec :
- `SU_DB_PATH=/tmp/system-update-rust-client-test.db`.
- `SU_REPORTS_DIR=/tmp/system-update-rust-client-reports`.
- `SU_PORT=8787`.
Commandes Rust testées :
- `cargo run -- capabilities` : OK, JSON capabilities reçu.
- `cargo run -- status` : OK, JSON status reçu.
- `cargo run -- metrics` : OK, JSON metrics reçu.
Nettoyage :
- backend temporaire arrêté après test ;
- vérification `/health` après arrêt : connexion refusée, donc port libéré.
---
## 11. Début interface graphique Rust
Décision :
- UI GTK4/libadwaita ajoutée derrière la feature Cargo `gui`.
- Le client CLI reste compilable sans GTK.
- Lancement prévu : `cargo run --features gui -- gui`.
Pré-requis système manquants sur la machine au moment du test :
- `pkg-config --modversion gtk4` : paquet absent.
- `pkg-config --modversion libadwaita-1` : paquet absent.
Installation à faire dans un terminal utilisateur :
- `sudo apt install libgtk-4-dev libadwaita-1-dev`.
État :
- Code GUI ajouté.
- Crates GTK/libadwaita résolues via Cargo.
- `cargo test` sans feature GUI : OK, 12 tests.
- `cargo build` sans feature GUI : OK.
- `cargo check --features gui` : bloqué par paquets système manquants (`gtk4`, `pango`, `cairo`, `glib-2.0`, `gio-2.0`, `gdk-pixbuf-2.0`, `graphene-gobject-1.0`).
Commande utilisateur à lancer dans un terminal interactif :
```bash
sudo apt install libgtk-4-dev libadwaita-1-dev
```
Puis retester :
```bash
cd /home/gilles/Documents/projet/system_update/app_rust/system-update-gnome
cargo check --features gui
cargo run --features gui -- --server http://10.0.1.137:8787 gui
```
Correction suivante :
- Bug observé : GTK recevait `--server` et affichait `Option inconnue --server`.
- Cause : `adw::Application::run()` relisait les arguments du processus.
- Fix : lancement GUI via `run_with_args::<&str>(&[])`.
- Warning supprimé : import GTK inutilisé.
- Vérification : `cargo check --features gui` OK, `cargo test` OK.
- Layout natif aligné webapp : Hermes gauche, Machines centre, terminal API droit, barre de tâche basse.
- `/api/machines` consommé par la GUI pour remplir la zone centrale.
- Commande CLI `machines` ajoutée pour tester le même endpoint hors GUI.
---
## 12. Pause tâche 8
- Date : 2026-06-05.
- Décision utilisateur : terminer la tâche 8 plus tard.
- État au moment de la pause :
- client Rust CLI fonctionnel ;
- commandes `capabilities`, `status`, `metrics`, `machines` ;
- première GUI GTK/libadwaita disponible derrière `--features gui` ;
- layout GUI rapproché de la webapp : Hermes gauche, Machines centre, terminal droit, barre basse ;
- compilation/test GUI OK après installation des paquets système ;
- prochaine reprise : améliorer UX native, keyring, vrai modèle machines/actions.
---
## 7. Notes techniques
- Le backend actuel expose déjà `/api/machines`, actions APT/reboot et WebSocket de sortie machine.
- L'app locale a besoin de savoir quelles fonctions sont réellement disponibles pour masquer les fonctions futures : Hermes, Docker, post-install, SSH interactif, settings, etc.
- `GET /api/capabilities` doit retourner un JSON stable, sans secret, exploitable par webapp, Hermes et future app Rust.
---
## 8. Jalon 8.2 — Endpoints système app locale
Fichiers ajoutés/modifiés :
- `shared/types.ts` : types `SystemStatus` et `SystemMetrics`.
- `server/services/system.ts` : status et métriques process/hôte.
- `server/routes/index.ts` : routes `GET /api/system/status` et `GET /api/system/metrics`.
- `server/services/capabilities.ts` : capabilities enrichies avec les endpoints système.
- `app_rust/system-update-gnome` : commandes CLI `status` et `metrics`.
Vérifications :
- `tsc --noEmit` : OK.
- `vitest run server/services/system.test.ts server/services/capabilities.test.ts` : OK.
- `vitest run` : OK, 14 fichiers de test, 35 tests.
- `cargo fmt` : OK.
- `cargo test` : OK, 8 tests.
+33
View File
@@ -8,6 +8,18 @@ importers:
.:
dependencies:
'@fontsource/inter':
specifier: ^5.2.8
version: 5.2.8
'@fontsource/jetbrains-mono':
specifier: ^5.2.8
version: 5.2.8
'@fontsource/share-tech-mono':
specifier: ^5.2.7
version: 5.2.7
'@fortawesome/fontawesome-free':
specifier: ^7.2.0
version: 7.2.0
'@hono/node-server':
specifier: ^1.13.0
version: 1.19.14(hono@4.12.23)
@@ -1060,6 +1072,19 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource/inter@5.2.8':
resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==}
'@fontsource/jetbrains-mono@5.2.8':
resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==}
'@fontsource/share-tech-mono@5.2.7':
resolution: {integrity: sha512-1JBJ6CU9u5av8aFEUOGOJkq60/IEVVOZDCmiU8X3i0skk0Pp69GngDwlBUHaTZa4G6pbF1UDrC+Fm7XSckW6TQ==}
'@fortawesome/fontawesome-free@7.2.0':
resolution: {integrity: sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==}
engines: {node: '>=6'}
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -2687,6 +2712,14 @@ snapshots:
'@esbuild/win32-x64@0.28.0':
optional: true
'@fontsource/inter@5.2.8': {}
'@fontsource/jetbrains-mono@5.2.8': {}
'@fontsource/share-tech-mono@5.2.7': {}
'@fortawesome/fontawesome-free@7.2.0': {}
'@hono/node-server@1.19.14(hono@4.12.23)':
dependencies:
hono: 4.12.23
+18
View File
@@ -0,0 +1,18 @@
// server/auth/apiAuth.test.ts
import { describe, expect, it } from "vitest";
import { apiAuthInternals } from "./apiAuth.js";
describe("apiAuthInternals", () => {
it("extrait un token bearer", () => {
expect(apiAuthInternals.extractBearerToken("Bearer su_token")).toBe("su_token");
});
it("accepte bearer sans sensibilité à la casse", () => {
expect(apiAuthInternals.extractBearerToken("bearer su_token")).toBe("su_token");
});
it("rejette un header absent ou mal formé", () => {
expect(apiAuthInternals.extractBearerToken(null)).toBeNull();
expect(apiAuthInternals.extractBearerToken("Basic abc")).toBeNull();
});
});
+34
View File
@@ -0,0 +1,34 @@
// server/auth/apiAuth.ts
import type { MiddlewareHandler } from "hono";
import type { ApiClientScope, ApiClientView } from "@shared/types.js";
import { authenticateApiToken, hasApiScope } from "../services/apiClients.js";
export interface ApiAuthVariables {
apiClient: ApiClientView;
}
export function extractBearerToken(authorization: string | null | undefined): string | null {
if (!authorization) return null;
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
return match?.[1]?.trim() || null;
}
export function requireApiScope(required: ApiClientScope): MiddlewareHandler<{
Variables: ApiAuthVariables;
}> {
return async (c, next) => {
const token = extractBearerToken(c.req.header("Authorization"));
if (!token) return c.json({ error: "Token API manquant" }, 401);
const client = authenticateApiToken(token);
if (!client) return c.json({ error: "Token API invalide ou révoqué" }, 401);
if (!hasApiScope(client.scopes, required)) {
return c.json({ error: "Scope API insuffisant" }, 403);
}
c.set("apiClient", client);
await next();
};
}
export const apiAuthInternals = { extractBearerToken };
+28
View File
@@ -0,0 +1,28 @@
// server/cli/createApiClient.test.ts
import { describe, expect, it } from "vitest";
import {
createApiClientCliInternals,
parseCreateApiClientArgs,
} from "./createApiClient.js";
describe("createApiClient CLI", () => {
it("parse un nom et des scopes", () => {
expect(
parseCreateApiClientArgs(["--name", "App Rust", "--scopes", "read,operate,read"]),
).toEqual({
name: "App Rust",
scopes: ["read", "operate"],
});
});
it("utilise read par défaut", () => {
expect(parseCreateApiClientArgs(["--name", "Hermes"])).toEqual({
name: "Hermes",
scopes: ["read"],
});
});
it("rejette un scope invalide", () => {
expect(() => createApiClientCliInternals.parseScopes("read,root")).toThrow("Scope invalide");
});
});
+78
View File
@@ -0,0 +1,78 @@
// server/cli/createApiClient.ts
import { pathToFileURL } from "node:url";
import type { ApiClientScope } from "@shared/types.js";
import { runMigrations } from "../db/migrate.js";
import { createApiClient } from "../services/apiClients.js";
export interface CreateApiClientCliOptions {
name: string;
scopes: ApiClientScope[];
}
const ALLOWED_SCOPES: ApiClientScope[] = ["read", "operate", "admin", "debug"];
export function parseCreateApiClientArgs(args: string[]): CreateApiClientCliOptions {
let name = "";
let scopes: ApiClientScope[] = ["read"];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--name") {
i += 1;
name = args[i] ?? "";
} else if (arg === "--scopes") {
i += 1;
scopes = parseScopes(args[i] ?? "");
} else if (arg === "--help" || arg === "-h") {
throw new Error(helpText());
} else {
throw new Error(`Argument inconnu: ${arg}\n\n${helpText()}`);
}
}
if (!name.trim()) throw new Error(`--name est obligatoire\n\n${helpText()}`);
return { name: name.trim(), scopes };
}
function parseScopes(raw: string): ApiClientScope[] {
const scopes = raw
.split(",")
.map((scope) => scope.trim())
.filter(Boolean) as ApiClientScope[];
if (scopes.length === 0) return ["read"];
for (const scope of scopes) {
if (!ALLOWED_SCOPES.includes(scope)) {
throw new Error(`Scope invalide: ${scope}. Scopes valides: ${ALLOWED_SCOPES.join(", ")}`);
}
}
return [...new Set(scopes)];
}
function helpText(): string {
return [
"Usage:",
" pnpm api-client:create -- --name \"App Rust\" --scopes read,operate",
"",
"Variables requises:",
" SU_MASTER_KEY clé hex 64 caractères",
" SU_DB_PATH chemin SQLite optionnel",
].join("\n");
}
async function main(): Promise<void> {
const options = parseCreateApiClientArgs(process.argv.slice(2));
runMigrations();
const created = createApiClient(options);
console.log(JSON.stringify(created, null, 2));
}
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
if (import.meta.url === entrypoint) {
main().catch((err) => {
console.error((err as Error).message);
process.exitCode = 1;
});
}
export const createApiClientCliInternals = { parseScopes, helpText };
+25
View File
@@ -0,0 +1,25 @@
// server/crypto/apiTokens.test.ts
import { describe, expect, it } from "vitest";
import { generateApiToken, hashApiToken, tokenPrefix, verifyApiToken } from "./apiTokens.js";
const PEPPER = "b".repeat(64);
describe("apiTokens", () => {
it("génère un token préfixé non trivial", () => {
const token = generateApiToken();
expect(token).toMatch(/^su_[A-Za-z0-9_-]{40,}$/);
});
it("calcule un préfixe court affichable", () => {
expect(tokenPrefix("su_abcdefghijklmnopqrstuvwxyz")).toBe("su_abcdefghi");
});
it("vérifie un token par HMAC sans stocker le token brut", () => {
const token = "su_test_token";
const hash = hashApiToken(token, PEPPER);
expect(hash).not.toContain(token);
expect(verifyApiToken(token, hash, PEPPER)).toBe(true);
expect(verifyApiToken("su_other_token", hash, PEPPER)).toBe(false);
});
});
+24
View File
@@ -0,0 +1,24 @@
// server/crypto/apiTokens.ts
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
const TOKEN_BYTES = 32;
const TOKEN_PREFIX_LENGTH = 12;
export function generateApiToken(): string {
return `su_${randomBytes(TOKEN_BYTES).toString("base64url")}`;
}
export function tokenPrefix(token: string): string {
return token.slice(0, TOKEN_PREFIX_LENGTH);
}
export function hashApiToken(token: string, pepperHex: string): string {
const pepper = Buffer.from(pepperHex, "hex");
return createHmac("sha256", pepper).update(token).digest("base64url");
}
export function verifyApiToken(token: string, expectedHash: string, pepperHex: string): boolean {
const actual = Buffer.from(hashApiToken(token, pepperHex));
const expected = Buffer.from(expectedHash);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+14 -2
View File
@@ -1,15 +1,27 @@
// server/db/client.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { mkdirSync } from "node:fs";
import { mkdirSync, existsSync, rmSync, renameSync } from "node:fs";
import { dirname } from "node:path";
import { env } from "../env.js";
import * as schema from "./schema.js";
mkdirSync(dirname(env.dbPath), { recursive: true });
// Restauration en attente : un fichier `<db>.incoming` déposé par /system/db/restore
// est appliqué au démarrage (swap hors-ligne = aucune corruption d'une base ouverte).
const incoming = `${env.dbPath}.incoming`;
if (existsSync(incoming)) {
for (const ext of ["", "-wal", "-shm"]) {
const p = `${env.dbPath}${ext}`;
if (existsSync(p)) rmSync(p, { force: true });
}
renameSync(incoming, env.dbPath);
}
const sqlite = new Database(env.dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export { schema };
export { schema, sqlite };
+3
View File
@@ -1,7 +1,10 @@
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
import { backfillCredentials } from "../services/credentials.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
const n = backfillCredentials();
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
}
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE `api_clients` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`token_prefix` text NOT NULL,
`token_hash` text NOT NULL,
`scopes_json` text NOT NULL,
`created_at` text NOT NULL,
`last_used_at` text,
`revoked_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_clients_token_hash_unique` ON `api_clients` (`token_hash`);
@@ -0,0 +1,150 @@
CREATE TABLE `important_messages` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`source` text NOT NULL,
`category` text NOT NULL,
`severity` text NOT NULL,
`package_name` text,
`component` text,
`message` text NOT NULL,
`raw_line_ref` text,
`snapshot_id` text,
`execution_id` text,
`first_seen_at` text NOT NULL,
`last_seen_at` text NOT NULL,
`acknowledged` integer DEFAULT 0 NOT NULL,
`acknowledged_at` text,
`acknowledged_by` text,
`payload_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_events` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`event_type` text NOT NULL,
`severity` text NOT NULL,
`created_at` text NOT NULL,
`actor_type` text,
`actor_id` text,
`snapshot_id` text,
`execution_id` text,
`job_id` text,
`message` text,
`payload_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_hardware` (
`machine_id` text PRIMARY KEY NOT NULL,
`probe_snapshot_id` text,
`cpu_model` text,
`cpu_cores` integer,
`memory_bytes` integer,
`gpus_json` text,
`disks_json` text,
`network_json` text,
`firmware_json` text,
`driver_json` text,
`warnings_json` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_metrics_latest` (
`machine_id` text PRIMARY KEY NOT NULL,
`snapshot_id` text,
`collected_at` text NOT NULL,
`cpu_load1` real,
`cpu_load5` real,
`cpu_cores` integer,
`memory_total_bytes` integer,
`memory_used_bytes` integer,
`memory_available_bytes` integer,
`memory_used_percent` real,
`filesystems_json` text,
`root_used_percent` real,
`warnings_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_state` (
`machine_id` text PRIMARY KEY NOT NULL,
`status` text NOT NULL,
`apt_status` text,
`apt_updates_count` integer DEFAULT 0 NOT NULL,
`apt_reboot_required` integer DEFAULT 0 NOT NULL,
`apt_last_analyze_at` text,
`docker_status` text,
`docker_installed` integer DEFAULT 0 NOT NULL,
`docker_stacks_count` integer DEFAULT 0 NOT NULL,
`docker_updates_count` integer DEFAULT 0 NOT NULL,
`docker_prune_available` integer DEFAULT 0 NOT NULL,
`post_install_status` text,
`metrics_last_collected_at` text,
`cpu_load1` real,
`memory_used_percent` real,
`root_used_percent` real,
`disk_warnings_count` integer DEFAULT 0 NOT NULL,
`hardware_warnings_count` integer DEFAULT 0 NOT NULL,
`running_job_id` text,
`last_error_kind` text,
`last_error_message` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `raw_artifacts` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`kind` text NOT NULL,
`path` text NOT NULL,
`bytes` integer,
`sha256` text,
`created_at` text NOT NULL,
`expires_at` text,
`pinned` integer DEFAULT 0 NOT NULL,
`redacted` integer DEFAULT 1 NOT NULL,
`retention_policy` text,
`deleted_at` text,
`delete_reason` text,
`metadata_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `reports` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`execution_id` text,
`kind` text NOT NULL,
`title` text NOT NULL,
`path` text NOT NULL,
`created_at` text NOT NULL,
`pinned` integer DEFAULT 0 NOT NULL,
`summary_json` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `executions` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `executions` ADD `request_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `job_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `important_json` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `report_id` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `exit_code` integer;--> statement-breakpoint
ALTER TABLE `executions` ADD `error_kind` text;--> statement-breakpoint
ALTER TABLE `executions` ADD `error_message` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `os_version` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `os_codename` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `arch` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `machine_kind` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `virtualization` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `hardware_profile` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `last_seen_at` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `updated_at` text;--> statement-breakpoint
ALTER TABLE `machines` ADD `deleted_at` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `kind` text DEFAULT 'apt_update_analyze' NOT NULL;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `important_json` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `raw_log_path` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `raw_artifact_id` text;--> statement-breakpoint
ALTER TABLE `snapshots` ADD `source_job_id` text;
@@ -0,0 +1,28 @@
CREATE TABLE `machine_credentials` (
`machine_id` text PRIMARY KEY NOT NULL,
`auth_method` text NOT NULL,
`enc_password` text,
`enc_sudo_password` text,
`enc_private_key` text,
`enc_key_passphrase` text,
`sudo_mode` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`last_test_at` text,
`status` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `machine_host_keys` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`hostname` text NOT NULL,
`port` integer NOT NULL,
`key_type` text,
`fingerprint_sha256` text NOT NULL,
`public_key` text,
`status` text NOT NULL,
`first_seen_at` text NOT NULL,
`last_seen_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,53 @@
CREATE TABLE `docker_compose_roots` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text NOT NULL,
`path` text NOT NULL,
`enabled` integer DEFAULT 1 NOT NULL,
`scan_depth` integer,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `docker_compose_stacks` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text NOT NULL,
`name` text NOT NULL,
`working_dir` text NOT NULL,
`compose_files_json` text NOT NULL,
`project_name` text,
`env_file` text,
`status` text NOT NULL,
`detected_by` text,
`last_scan_at` text,
`last_update_at` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `docker_settings` (
`machine_id` text PRIMARY KEY NOT NULL,
`enabled` integer DEFAULT 0 NOT NULL,
`scan_depth` integer DEFAULT 4 NOT NULL,
`prune_mode` text DEFAULT 'safe' NOT NULL,
`last_scan_at` text,
`last_pull_check_at` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `docker_stack_services` (
`id` text PRIMARY KEY NOT NULL,
`stack_id` text NOT NULL,
`service_name` text NOT NULL,
`image_ref` text,
`current_image_id` text,
`current_digest` text,
`candidate_image_id` text,
`candidate_digest` text,
`version_label` text,
`status` text,
`updated_at` text NOT NULL,
FOREIGN KEY (`stack_id`) REFERENCES `docker_compose_stacks`(`id`) ON UPDATE no action ON DELETE cascade
);
+34
View File
@@ -0,0 +1,34 @@
CREATE TABLE `action_requests` (
`id` text PRIMARY KEY NOT NULL,
`machine_id` text,
`requested_by_type` text NOT NULL,
`requested_by_id` text,
`action` text NOT NULL,
`risk` text,
`status` text NOT NULL,
`summary` text,
`payload_json` text,
`created_at` text NOT NULL,
`approved_at` text,
`approved_by` text,
`execution_id` text,
`expires_at` text,
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `docker_image_events` (
`id` text PRIMARY KEY NOT NULL,
`execution_id` text,
`machine_id` text NOT NULL,
`stack_id` text,
`service_name` text,
`image_ref` text,
`from_image_id` text,
`to_image_id` text,
`from_digest` text,
`to_digest` text,
`operation` text,
`bytes_reclaimed` integer,
`created_at` text NOT NULL,
FOREIGN KEY (`execution_id`) REFERENCES `executions`(`id`) ON UPDATE no action ON DELETE set null
);
@@ -0,0 +1,5 @@
CREATE TABLE `app_settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text,
`updated_at` text NOT NULL
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42
View File
@@ -8,6 +8,48 @@
"when": 1780599514478,
"tag": "0000_brainy_dakota_north",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1780669000000,
"tag": "0001_api_clients",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1780669100000,
"tag": "0002_reflective_lifeguard",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1780669200000,
"tag": "0003_magical_psylocke",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1780684150263,
"tag": "0004_thin_ted_forrester",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1780718324238,
"tag": "0005_silent_drax",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1780724800966,
"tag": "0006_many_northstar",
"breakpoints": true
}
]
}
+95
View File
@@ -0,0 +1,95 @@
// server/db/schema.test.ts
import { describe, it, expect } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
function freshMigratedDb() {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: "./server/db/migrations" });
return sqlite;
}
function tableNames(sqlite: Database.Database): string[] {
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
}
function columnNames(sqlite: Database.Database, table: string): string[] {
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
}
describe("schéma Phase 1", () => {
it("crée les tables socle", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
for (const t of [
"machines", "snapshots", "executions",
"machine_state", "machine_hardware", "machine_metrics_latest",
"machine_events", "important_messages", "reports", "raw_artifacts",
]) {
expect(tables, `table ${t}`).toContain(t);
}
});
it("ajoute les colonnes étendues sans casser l'existant", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "machines")).toEqual(
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
);
expect(columnNames(sqlite, "snapshots")).toEqual(
expect.arrayContaining(["kind", "schema_version", "important_json"]),
);
expect(columnNames(sqlite, "executions")).toEqual(
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
);
// colonnes jalon 1 conservées
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
describe("schéma Phase 2", () => {
it("crée les tables de credentials Phase 2", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
// machines conserve ses colonnes secrets legacy (fallback)
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
});
describe("schéma SJ-4 Docker", () => {
it("crée les tables docker_*", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
for (const t of [
"docker_settings",
"docker_compose_roots",
"docker_compose_stacks",
"docker_stack_services",
]) {
expect(tables, `table ${t}`).toContain(t);
}
});
it("docker_settings a les colonnes attendues", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "docker_settings")).toEqual(
expect.arrayContaining(["machine_id", "enabled", "scan_depth", "prune_mode", "last_scan_at", "updated_at"]),
);
});
it("docker_compose_stacks a les colonnes attendues", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "docker_compose_stacks")).toEqual(
expect.arrayContaining(["id", "machine_id", "name", "working_dir", "compose_files_json", "status", "detected_by"]),
);
});
it("docker_stack_services a les colonnes attendues", () => {
const sqlite = freshMigratedDb();
expect(columnNames(sqlite, "docker_stack_services")).toEqual(
expect.arrayContaining(["id", "stack_id", "service_name", "image_ref", "current_image_id", "current_digest"]),
);
});
});
+283 -1
View File
@@ -1,5 +1,5 @@
// server/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
export const machines = sqliteTable("machines", {
id: text("id").primaryKey(),
@@ -7,6 +7,12 @@ export const machines = sqliteTable("machines", {
hostname: text("hostname").notNull(),
port: integer("port").notNull().default(22),
osFamily: text("os_family").notNull().default("unknown"),
osVersion: text("os_version"),
osCodename: text("os_codename"),
arch: text("arch"),
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
username: text("username").notNull(),
encPassword: text("enc_password").notNull(),
encSudoPassword: text("enc_sudo_password"),
@@ -14,15 +20,24 @@ export const machines = sqliteTable("machines", {
aptProxyUrl: text("apt_proxy_url"),
status: text("status").notNull().default("unknown"),
lastCheckedAt: text("last_checked_at"),
lastSeenAt: text("last_seen_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
deletedAt: text("deleted_at"),
});
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull().default("apt_update_analyze"),
schemaVersion: integer("schema_version").notNull().default(1),
checkedAt: text("checked_at").notNull(),
status: text("status").notNull(),
payloadJson: text("payload_json").notNull(),
importantJson: text("important_json"),
rawLogPath: text("raw_log_path"),
rawArtifactId: text("raw_artifact_id"),
sourceJobId: text("source_job_id"),
});
export const executions = sqliteTable("executions", {
@@ -30,10 +45,277 @@ export const executions = sqliteTable("executions", {
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
action: text("action").notNull(),
mode: text("mode").notNull().default("manual"),
schemaVersion: integer("schema_version").notNull().default(1),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
status: text("status").notNull(),
requestId: text("request_id"),
jobId: text("job_id"),
resultJson: text("result_json"),
importantJson: text("important_json"),
reportPath: text("report_path"),
rawLogPath: text("raw_log_path"),
reportId: text("report_id"),
exitCode: integer("exit_code"),
errorKind: text("error_kind"),
errorMessage: text("error_message"),
});
export const machineState = sqliteTable("machine_state", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
status: text("status").notNull(),
aptStatus: text("apt_status"),
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
aptLastAnalyzeAt: text("apt_last_analyze_at"),
dockerStatus: text("docker_status"),
dockerInstalled: integer("docker_installed").notNull().default(0),
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
postInstallStatus: text("post_install_status"),
metricsLastCollectedAt: text("metrics_last_collected_at"),
cpuLoad1: real("cpu_load1"),
memoryUsedPercent: real("memory_used_percent"),
rootUsedPercent: real("root_used_percent"),
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
runningJobId: text("running_job_id"),
lastErrorKind: text("last_error_kind"),
lastErrorMessage: text("last_error_message"),
updatedAt: text("updated_at").notNull(),
});
export const machineHardware = sqliteTable("machine_hardware", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
probeSnapshotId: text("probe_snapshot_id"),
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
memoryBytes: integer("memory_bytes"),
gpusJson: text("gpus_json"),
disksJson: text("disks_json"),
networkJson: text("network_json"),
firmwareJson: text("firmware_json"),
driverJson: text("driver_json"),
warningsJson: text("warnings_json"),
updatedAt: text("updated_at").notNull(),
});
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
snapshotId: text("snapshot_id"),
collectedAt: text("collected_at").notNull(),
cpuLoad1: real("cpu_load1"),
cpuLoad5: real("cpu_load5"),
cpuCores: integer("cpu_cores"),
memoryTotalBytes: integer("memory_total_bytes"),
memoryUsedBytes: integer("memory_used_bytes"),
memoryAvailableBytes: integer("memory_available_bytes"),
memoryUsedPercent: real("memory_used_percent"),
filesystemsJson: text("filesystems_json"),
rootUsedPercent: real("root_used_percent"),
warningsJson: text("warnings_json"),
});
export const machineEvents = sqliteTable("machine_events", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
eventType: text("event_type").notNull(),
severity: text("severity").notNull(), // info | warning | error
createdAt: text("created_at").notNull(),
actorType: text("actor_type"), // user | system | schedule | hermes
actorId: text("actor_id"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
jobId: text("job_id"),
message: text("message"),
payloadJson: text("payload_json"),
});
export const importantMessages = sqliteTable("important_messages", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
source: text("source").notNull(), // apt | docker | post_install | ssh | system
category: text("category").notNull(), // error | warning | future_major_change | ...
severity: text("severity").notNull(),
packageName: text("package_name"),
component: text("component"),
message: text("message").notNull(),
rawLineRef: text("raw_line_ref"),
snapshotId: text("snapshot_id"),
executionId: text("execution_id"),
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
acknowledged: integer("acknowledged").notNull().default(0),
acknowledgedAt: text("acknowledged_at"),
acknowledgedBy: text("acknowledged_by"),
payloadJson: text("payload_json"),
});
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
executionId: text("execution_id"),
kind: text("kind").notNull(), // machine | global | cleanup | hermes
title: text("title").notNull(),
path: text("path").notNull(),
createdAt: text("created_at").notNull(),
pinned: integer("pinned").notNull().default(0),
summaryJson: text("summary_json"),
});
export const rawArtifacts = sqliteTable("raw_artifacts", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
path: text("path").notNull(),
bytes: integer("bytes"),
sha256: text("sha256"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at"),
pinned: integer("pinned").notNull().default(0),
redacted: integer("redacted").notNull().default(1),
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
deletedAt: text("deleted_at"),
deleteReason: text("delete_reason"),
metadataJson: text("metadata_json"),
});
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
export const apiClients = sqliteTable(
"api_clients",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
tokenPrefix: text("token_prefix").notNull(),
tokenHash: text("token_hash").notNull(),
scopesJson: text("scopes_json").notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
revokedAt: text("revoked_at"),
},
(table) => ({
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
}),
);
// --- Phase 2 : credentials isolés (non destructif) ---
export const machineCredentials = sqliteTable("machine_credentials", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
authMethod: text("auth_method").notNull(), // password | ssh_key
encPassword: text("enc_password"),
encSudoPassword: text("enc_sudo_password"),
encPrivateKey: text("enc_private_key"),
encKeyPassphrase: text("enc_key_passphrase"),
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastTestAt: text("last_test_at"),
status: text("status"), // ok | error | unknown
});
export const machineHostKeys = sqliteTable("machine_host_keys", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
port: integer("port").notNull(),
keyType: text("key_type"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
publicKey: text("public_key"),
status: text("status").notNull(), // approved | changed | rejected | unknown
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
});
// --- SJ-4 : Docker (passif) ---
export const dockerSettings = sqliteTable("docker_settings", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
enabled: integer("enabled").notNull().default(0),
scanDepth: integer("scan_depth").notNull().default(4),
pruneMode: text("prune_mode").notNull().default("safe"),
lastScanAt: text("last_scan_at"),
lastPullCheckAt: text("last_pull_check_at"),
updatedAt: text("updated_at").notNull(),
});
export const dockerComposeRoots = sqliteTable("docker_compose_roots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
path: text("path").notNull(),
enabled: integer("enabled").notNull().default(1),
scanDepth: integer("scan_depth"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
export const dockerComposeStacks = sqliteTable("docker_compose_stacks", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workingDir: text("working_dir").notNull(),
composeFilesJson: text("compose_files_json").notNull(),
projectName: text("project_name"),
envFile: text("env_file"),
status: text("status").notNull(), // candidate | enabled | ignored | error
detectedBy: text("detected_by"), // root_scan | label | manual
lastScanAt: text("last_scan_at"),
lastUpdateAt: text("last_update_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
export const dockerStackServices = sqliteTable("docker_stack_services", {
id: text("id").primaryKey(),
stackId: text("stack_id").notNull().references(() => dockerComposeStacks.id, { onDelete: "cascade" }),
serviceName: text("service_name").notNull(),
imageRef: text("image_ref"),
currentImageId: text("current_image_id"),
currentDigest: text("current_digest"),
candidateImageId: text("candidate_image_id"),
candidateDigest: text("candidate_digest"),
versionLabel: text("version_label"),
status: text("status"), // up_to_date | updates_available | error
updatedAt: text("updated_at").notNull(),
});
// SJ-6 : historique pull/apply/prune (tache1.9.md §8).
export const dockerImageEvents = sqliteTable("docker_image_events", {
id: text("id").primaryKey(),
executionId: text("execution_id").references(() => executions.id, { onDelete: "set null" }),
machineId: text("machine_id").notNull(),
stackId: text("stack_id"),
serviceName: text("service_name"),
imageRef: text("image_ref"),
fromImageId: text("from_image_id"),
toImageId: text("to_image_id"),
fromDigest: text("from_digest"),
toDigest: text("to_digest"),
operation: text("operation"), // pulled | recreated | pruned
bytesReclaimed: integer("bytes_reclaimed"),
createdAt: text("created_at").notNull(),
});
// SJ-6 : demandes d'actions destructives à valider (UI/Hermes) (tache1.9.md §10).
export const actionRequests = sqliteTable("action_requests", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
requestedByType: text("requested_by_type").notNull(), // user | hermes | schedule
requestedById: text("requested_by_id"),
action: text("action").notNull(),
risk: text("risk"),
status: text("status").notNull(), // pending | approved | rejected | executed | expired
summary: text("summary"),
payloadJson: text("payload_json"),
createdAt: text("created_at").notNull(),
approvedAt: text("approved_at"),
approvedBy: text("approved_by"),
executionId: text("execution_id"),
expiresAt: text("expires_at"),
});
// Réglages globaux de l'application (clé/valeur). Ex. proxy APT par défaut.
export const appSettings = sqliteTable("app_settings", {
key: text("key").primaryKey(),
value: text("value"),
updatedAt: text("updated_at").notNull(),
});
+4
View File
@@ -14,6 +14,10 @@ env.requireMasterKey();
runMigrations();
const app = new Hono();
app.onError((err, c) => {
console.error("[api]", err.message);
return c.json({ error: err.message || "Erreur serveur" }, 500);
});
app.route("/api", api);
app.get("/health", (c) => c.json({ ok: true }));
+63
View File
@@ -0,0 +1,63 @@
// server/routes/actionRequests.ts
import { Hono } from "hono";
import {
createActionRequest,
getActionRequest,
listActionRequests,
approveActionRequest,
rejectActionRequest,
} from "../services/actionRequests.js";
import type { ActionType } from "@shared/types.js";
export const actionRequestsRoutes = new Hono();
// Crée une demande d'action destructive (pending). Hermes/UI proposent ; aucune exécution ici.
actionRequestsRoutes.post("/machines/:id/action-requests", async (c) => {
const body = (await c.req.json()) as {
action: ActionType;
stackId?: string;
aggressive?: boolean;
summary?: string;
requestedByType?: "user" | "hermes" | "schedule";
};
try {
const req = createActionRequest({
machineId: c.req.param("id"),
action: body.action,
requestedByType: body.requestedByType,
summary: body.summary,
payload: { stackId: body.stackId, aggressive: body.aggressive },
});
return c.json(req, 201);
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
actionRequestsRoutes.get("/machines/:id/action-requests", (c) =>
c.json(listActionRequests(c.req.param("id"))),
);
actionRequestsRoutes.get("/action-requests/:reqId", (c) => {
const req = getActionRequest(c.req.param("reqId"));
return req ? c.json(req) : c.json({ error: "Demande introuvable" }, 404);
});
// Validation opérateur → déclenche l'exécution en arrière-plan.
actionRequestsRoutes.post("/action-requests/:reqId/approve", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { approvedBy?: string };
try {
return c.json(approveActionRequest(c.req.param("reqId"), body.approvedBy), 202);
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
actionRequestsRoutes.post("/action-requests/:reqId/reject", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { by?: string };
try {
return c.json(rejectActionRequest(c.req.param("reqId"), body.by));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
+23 -3
View File
@@ -6,13 +6,33 @@ import type { ActionType } from "@shared/types.js";
export const actionsRoutes = new Hono();
// Actions autorisées par l'API. Les actions destructives Docker
// (docker_compose_apply/down, docker_prune_images agressif) restent hors API
// jusqu'au socle de validation (action_requests, SJ-6).
const ALLOWED_ACTIONS: ActionType[] = [
"apt_full_upgrade",
"reboot",
// Docker passifs / non-applicatifs (SJ-4/SJ-5).
"docker_scan",
"docker_inspect_current",
"docker_pull_check",
// SJ-7 : sonde (lecture seule) + proxy APT persistant (action explicite idempotente).
"machine_probe",
"apt_proxy_persistent",
];
// Actions Docker ciblant un stack précis : stackId obligatoire.
const NEED_STACK: ActionType[] = ["docker_inspect_current", "docker_pull_check"];
actionsRoutes.post("/:id/actions", async (c) => {
const { action } = (await c.req.json()) as { action: ActionType };
if (action !== "apt_full_upgrade" && action !== "reboot") {
const { action, stackId } = (await c.req.json()) as { action: ActionType; stackId?: string };
if (!ALLOWED_ACTIONS.includes(action)) {
return c.json({ error: "Action non autorisée" }, 400);
}
if (NEED_STACK.includes(action) && !stackId) {
return c.json({ error: "stackId requis pour cette action" }, 400);
}
// Exécution lancée en arrière-plan; le suivi se fait via WebSocket.
runAction(c.req.param("id"), action).catch((err) =>
runAction(c.req.param("id"), action, stackId ? { stackId } : undefined).catch((err) =>
console.error("[action]", (err as Error).message),
);
return c.json({ ok: true, action }, 202);
+37
View File
@@ -0,0 +1,37 @@
// server/routes/db.ts
import { Hono } from "hono";
import { createBackup, prepareRestore, dbInfo } from "../services/dbBackup.js";
export const dbRoutes = new Hono();
// Métadonnées de la base (taille, date, restauration en attente).
dbRoutes.get("/info", (c) => c.json(dbInfo()));
// Télécharge une archive cohérente de la base courante.
dbRoutes.get("/backup", () => {
const { buffer, filename } = createBackup();
return new Response(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(buffer.length),
},
});
});
// Restaure depuis une archive uploadée (corps brut). Appliquée au prochain démarrage.
dbRoutes.post("/restore", async (c) => {
try {
const ab = await c.req.arrayBuffer();
if (!ab.byteLength) return c.json({ error: "Archive vide" }, 400);
const { safetyBackup } = prepareRestore(Buffer.from(ab));
return c.json({
ok: true,
restartRequired: true,
safetyBackup,
message: "Restauration préparée. Redémarrez le serveur pour l'appliquer.",
});
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
+45
View File
@@ -0,0 +1,45 @@
// server/routes/docker.ts
import { Hono } from "hono";
import { runAction } from "../services/execute.js";
import {
getDockerSettings,
setDockerRoots,
listStacks,
setStackStatus,
type StackStatus,
} from "../services/dockerScan.js";
export const dockerRoutes = new Hono();
// Paramètres Docker (settings + racines Compose déclarées).
dockerRoutes.get("/:id/docker/settings", (c) => c.json(getDockerSettings(c.req.param("id"))));
// Déclare/active les racines Compose à scanner.
dockerRoutes.post("/:id/docker/roots", async (c) => {
const body = (await c.req.json()) as { paths?: string[]; scanDepth?: number };
if (!Array.isArray(body.paths)) return c.json({ error: "paths[] requis" }, 400);
setDockerRoots(c.req.param("id"), body.paths, body.scanDepth ?? 4);
return c.json(getDockerSettings(c.req.param("id")), 201);
});
// Déclenche un scan (passif) en arrière-plan ; suivi via WebSocket.
dockerRoutes.post("/:id/docker/scan", (c) => {
runAction(c.req.param("id"), "docker_scan").catch((err) =>
console.error("[docker_scan]", (err as Error).message),
);
return c.json({ ok: true, action: "docker_scan" }, 202);
});
// Liste les stacks détectés (+ services).
dockerRoutes.get("/:id/docker/stacks", (c) => c.json(listStacks(c.req.param("id"))));
// Cycle de vie d'un stack : candidate → enabled (validé) → ignored…
dockerRoutes.patch("/:id/docker/stacks/:stackId", async (c) => {
const body = (await c.req.json()) as { status?: StackStatus };
if (!body.status) return c.json({ error: "status requis" }, 400);
try {
return c.json(setStackStatus(c.req.param("id"), c.req.param("stackId"), body.status));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
+13
View File
@@ -2,7 +2,20 @@
import { Hono } from "hono";
import { machinesRoutes } from "./machines.js";
import { actionsRoutes } from "./actions.js";
import { actionRequestsRoutes } from "./actionRequests.js";
import { dockerRoutes } from "./docker.js";
import { dbRoutes } from "./db.js";
import { settingsRoutes } from "./settings.js";
import { getServerCapabilities } from "../services/capabilities.js";
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
export const api = new Hono();
api.get("/capabilities", (c) => c.json(getServerCapabilities()));
api.get("/system/status", (c) => c.json(getSystemStatus()));
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
api.route("/system/db", dbRoutes);
api.route("/settings", settingsRoutes);
api.route("/machines", machinesRoutes);
api.route("/machines", actionsRoutes);
api.route("/machines", dockerRoutes);
api.route("/", actionRequestsRoutes);
+22 -2
View File
@@ -1,10 +1,11 @@
// server/routes/machines.ts
import { Hono } from "hono";
import {
listMachines, createMachine, deleteMachine, getMachineRow, getCreds, testConnection,
type CreateMachineInput,
listMachines, createMachine, deleteMachine, updateMachine, getMachineRow, getCreds, testConnection,
type CreateMachineInput, type UpdateMachineInput,
} from "../services/machines.js";
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
import { runProbe } from "../services/machineProbe.js";
export const machinesRoutes = new Hono();
@@ -43,6 +44,25 @@ machinesRoutes.post("/:id/refresh", async (c) => {
}
});
machinesRoutes.patch("/:id", async (c) => {
const body = (await c.req.json()) as UpdateMachineInput;
try {
return c.json(updateMachine(c.req.param("id"), body));
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
// Sonde synchrone (lecture seule) : renvoie faits + proposition de correction.
machinesRoutes.post("/:id/probe", async (c) => {
try {
const o = await runProbe(c.req.param("id"));
return c.json({ probe: o.probe, proposal: o.proposal, changes: o.changes });
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});
machinesRoutes.delete("/:id", (c) => {
deleteMachine(c.req.param("id"));
return c.json({ ok: true });
+24
View File
@@ -0,0 +1,24 @@
// server/routes/settings.ts
import { Hono } from "hono";
import { getDefaultAptProxy, setDefaultAptProxy, type DefaultAptProxy } from "../services/appSettings.js";
import { applyProxyToAllMachines } from "../services/machines.js";
export const settingsRoutes = new Hono();
// Réglages globaux exposés à l'UI.
settingsRoutes.get("/", (c) => c.json({ defaultAptProxy: getDefaultAptProxy() }));
// Définit le proxy APT par défaut (apt-cacher-ng).
settingsRoutes.put("/apt-proxy", async (c) => {
const body = (await c.req.json()) as DefaultAptProxy;
const mode = body.mode ?? "direct";
const url = (body.url ?? "").trim() || null;
return c.json(setDefaultAptProxy({ mode, url }));
});
// Applique le proxy par défaut à toutes les machines existantes.
settingsRoutes.post("/apt-proxy/apply-all", (c) => {
const { mode, url } = getDefaultAptProxy();
const updated = applyProxyToAllMachines(mode, url);
return c.json({ ok: true, updated });
});
@@ -0,0 +1,20 @@
===SU:APT_UPDATE===
Hit:1 http://deb.debian.org/debian bookworm InRelease
Reading package lists...
===SU:APT_SIM_UPGRADE===
Reading package lists...
Building dependency tree...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_SIM_DISTUPGRADE===
Reading package lists...
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
Inst newdep (1.0.0 Debian:11.6/stable [all])
Remv oldpkg [3.2-1]
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
===SU:APT_HELD===
frozenpkg
===SU:REBOOT===
REBOOT_REQUIRED=1
PKG=linux-image-amd64
===SU:EXIT=0===
+118
View File
@@ -0,0 +1,118 @@
// server/services/actionRequests.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
import { runAction, type RunActionOpts } from "./execute.js";
import { recordEvent } from "./machineState.js";
import type { ActionType } from "@shared/types.js";
// Actions destructives nécessitant une validation explicite (70-securite.md §2).
export const DESTRUCTIVE_ACTIONS: Partial<Record<ActionType, "medium" | "high">> = {
docker_compose_apply: "medium",
docker_prune_images: "medium",
docker_compose_down: "high",
apt_full_upgrade: "medium",
apt_dist_upgrade: "medium",
apt_autoremove: "medium",
reboot: "high",
reboot_verified: "high",
};
const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"];
export interface CreateRequestInput {
machineId: string;
action: ActionType;
requestedByType?: "user" | "hermes" | "schedule";
requestedById?: string | null;
summary?: string | null;
payload?: { stackId?: string; aggressive?: boolean } | null;
}
export function createActionRequest(input: CreateRequestInput) {
const risk = DESTRUCTIVE_ACTIONS[input.action];
if (!risk) throw new Error(`Action non destructive ou inconnue : ${input.action}`);
if (NEED_STACK.includes(input.action) && !input.payload?.stackId) {
throw new Error("stackId requis pour cette action");
}
const id = randomUUID();
const now = new Date().toISOString();
db.insert(schema.actionRequests).values({
id,
machineId: input.machineId,
requestedByType: input.requestedByType ?? "user",
requestedById: input.requestedById ?? null,
action: input.action,
risk,
status: "pending",
summary: input.summary ?? `Demande ${input.action}`,
payloadJson: input.payload ? JSON.stringify(input.payload) : null,
createdAt: now,
}).run();
recordEvent({
machineId: input.machineId,
eventType: "action_request_created",
severity: "info",
message: `Demande ${input.action} (risque ${risk}) en attente de validation`,
});
return getActionRequest(id);
}
export function getActionRequest(id: string) {
return db.select().from(schema.actionRequests).where(eq(schema.actionRequests.id, id)).get();
}
export function listActionRequests(machineId?: string) {
const q = db.select().from(schema.actionRequests);
const rows = machineId
? q.where(eq(schema.actionRequests.machineId, machineId)).all()
: q.all();
return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export function rejectActionRequest(id: string, by?: string) {
const req = getActionRequest(id);
if (!req) throw new Error("Demande introuvable");
if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`);
db.update(schema.actionRequests)
.set({ status: "rejected", approvedAt: new Date().toISOString(), approvedBy: by ?? null })
.where(eq(schema.actionRequests.id, id))
.run();
return getActionRequest(id);
}
/**
* Approuve une demande et déclenche l'action en arrière-plan. Renvoie immédiatement
* la demande passée à `approved` ; `executionId`/`executed` sont posés à la fin du run.
*/
export function approveActionRequest(id: string, approvedBy?: string) {
const req = getActionRequest(id);
if (!req) throw new Error("Demande introuvable");
if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`);
if (!req.machineId) throw new Error("Demande sans machine");
const now = new Date().toISOString();
db.update(schema.actionRequests)
.set({ status: "approved", approvedAt: now, approvedBy: approvedBy ?? null })
.where(eq(schema.actionRequests.id, id))
.run();
const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean }) : {};
const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive };
const machineId = req.machineId;
runAction(machineId, req.action as ActionType, opts)
.then((result) => {
db.update(schema.actionRequests)
.set({ status: "executed", executionId: result.executionId })
.where(eq(schema.actionRequests.id, id))
.run();
})
.catch((err) => {
recordEvent({
machineId,
eventType: "action_request_failed",
severity: "error",
message: `Demande ${req.action} échouée : ${(err as Error).message}`,
});
});
return getActionRequest(id);
}
+64
View File
@@ -0,0 +1,64 @@
// server/services/apiClients.test.ts
import { describe, expect, it, vi } from "vitest";
vi.mock("../db/client.js", () => ({
db: {},
schema: { apiClients: {} },
}));
vi.mock("../env.js", () => ({ env: { requireMasterKey: vi.fn() } }));
import { apiClientInternals } from "./apiClients.js";
describe("apiClientInternals", () => {
it("retombe sur read quand aucun scope n'est fourni", () => {
expect(apiClientInternals.normalizeScopes([])).toEqual(["read"]);
});
it("déduplique les scopes en gardant l'ordre", () => {
expect(apiClientInternals.normalizeScopes(["read", "operate", "read"])).toEqual([
"read",
"operate",
]);
});
it("rejette un scope inconnu", () => {
expect(() => apiClientInternals.normalizeScopes(["root" as never])).toThrow(
"Scope API inconnu: root",
);
});
it("convertit une ligne DB en vue sans token hash", () => {
const view = apiClientInternals.toView({
id: "client_1",
name: "App locale",
tokenPrefix: "su_abcdefghi",
tokenHash: "hash-secret",
scopesJson: '["read","operate"]',
createdAt: "2026-06-05T08:00:00.000Z",
lastUsedAt: null,
revokedAt: null,
});
expect(view).toEqual({
id: "client_1",
name: "App locale",
tokenPrefix: "su_abcdefghi",
scopes: ["read", "operate"],
createdAt: "2026-06-05T08:00:00.000Z",
lastUsedAt: null,
revokedAt: null,
});
expect(JSON.stringify(view)).not.toContain("hash-secret");
});
it("applique les scopes par capacité", () => {
expect(apiClientInternals.hasApiScope(["read"], "read")).toBe(true);
expect(apiClientInternals.hasApiScope(["read"], "operate")).toBe(false);
expect(apiClientInternals.hasApiScope(["operate"], "read")).toBe(true);
expect(apiClientInternals.hasApiScope(["operate"], "operate")).toBe(true);
expect(apiClientInternals.hasApiScope(["debug"], "debug")).toBe(true);
expect(apiClientInternals.hasApiScope(["admin"], "debug")).toBe(true);
expect(apiClientInternals.hasApiScope(["admin"], "admin")).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
// server/services/apiClients.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { ApiClientScope, ApiClientView, CreatedApiClient } from "@shared/types.js";
import { db, schema } from "../db/client.js";
import { env } from "../env.js";
import {
generateApiToken,
hashApiToken,
tokenPrefix,
verifyApiToken,
} from "../crypto/apiTokens.js";
type ApiClientRow = typeof schema.apiClients.$inferSelect;
const ALLOWED_SCOPES = new Set<ApiClientScope>(["read", "operate", "admin", "debug"]);
export interface CreateApiClientInput {
name: string;
scopes: ApiClientScope[];
}
function normalizeScopes(scopes: ApiClientScope[]): ApiClientScope[] {
const unique = [...new Set(scopes)];
if (unique.length === 0) return ["read"];
for (const scope of unique) {
if (!ALLOWED_SCOPES.has(scope)) throw new Error(`Scope API inconnu: ${scope}`);
}
return unique;
}
function scopesFromJson(json: string): ApiClientScope[] {
const parsed = JSON.parse(json) as ApiClientScope[];
return normalizeScopes(parsed);
}
function toView(row: ApiClientRow): ApiClientView {
return {
id: row.id,
name: row.name,
tokenPrefix: row.tokenPrefix,
scopes: scopesFromJson(row.scopesJson),
createdAt: row.createdAt,
lastUsedAt: row.lastUsedAt,
revokedAt: row.revokedAt,
};
}
export function createApiClient(input: CreateApiClientInput, now = new Date()): CreatedApiClient {
const name = input.name.trim();
if (!name) throw new Error("Le nom du client API est obligatoire");
const scopes = normalizeScopes(input.scopes);
const token = generateApiToken();
const pepper = env.requireMasterKey();
const row: ApiClientRow = {
id: randomUUID(),
name,
tokenPrefix: tokenPrefix(token),
tokenHash: hashApiToken(token, pepper),
scopesJson: JSON.stringify(scopes),
createdAt: now.toISOString(),
lastUsedAt: null,
revokedAt: null,
};
db.insert(schema.apiClients).values(row).run();
return { client: toView(row), token };
}
export function listApiClients(): ApiClientView[] {
return db.select().from(schema.apiClients).all().map(toView);
}
export function revokeApiClient(id: string, now = new Date()): ApiClientView | null {
const existing = db.select().from(schema.apiClients).where(eq(schema.apiClients.id, id)).get();
if (!existing) return null;
const revokedAt = now.toISOString();
db.update(schema.apiClients).set({ revokedAt }).where(eq(schema.apiClients.id, id)).run();
return toView({ ...existing, revokedAt });
}
export function authenticateApiToken(token: string, now = new Date()): ApiClientView | null {
const pepper = env.requireMasterKey();
const tokenHash = hashApiToken(token, pepper);
const row = db
.select()
.from(schema.apiClients)
.where(eq(schema.apiClients.tokenHash, tokenHash))
.get();
if (!row || row.revokedAt) return null;
if (!verifyApiToken(token, row.tokenHash, pepper)) return null;
const lastUsedAt = now.toISOString();
db.update(schema.apiClients)
.set({ lastUsedAt })
.where(eq(schema.apiClients.id, row.id))
.run();
return toView({ ...row, lastUsedAt });
}
export function hasApiScope(scopes: ApiClientScope[], required: ApiClientScope): boolean {
if (scopes.includes("admin")) return true;
if (required === "read") return scopes.length > 0;
if (required === "operate") return scopes.includes("operate");
if (required === "debug") return scopes.includes("debug");
return false;
}
export const apiClientInternals = {
normalizeScopes,
scopesFromJson,
toView,
hasApiScope,
};
+43
View File
@@ -0,0 +1,43 @@
// server/services/appSettings.ts
import { db, schema } from "../db/client.js";
import type { AptProxyMode } from "@shared/types.js";
export const SETTING_KEYS = {
defaultAptProxyUrl: "default_apt_proxy_url",
defaultAptProxyMode: "default_apt_proxy_mode",
} as const;
export function getAllSettings(): Record<string, string> {
return Object.fromEntries(
db.select().from(schema.appSettings).all().map((r) => [r.key, r.value ?? ""]),
);
}
export function setSettings(patch: Record<string, string | null>): void {
const now = new Date().toISOString();
for (const [key, value] of Object.entries(patch)) {
db.insert(schema.appSettings)
.values({ key, value, updatedAt: now })
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value, updatedAt: now } })
.run();
}
}
export interface DefaultAptProxy {
mode: AptProxyMode;
url: string | null;
}
export function getDefaultAptProxy(): DefaultAptProxy {
const s = getAllSettings();
const mode = (s[SETTING_KEYS.defaultAptProxyMode] as AptProxyMode) || "direct";
return { mode, url: s[SETTING_KEYS.defaultAptProxyUrl] || null };
}
export function setDefaultAptProxy(input: DefaultAptProxy): DefaultAptProxy {
setSettings({
[SETTING_KEYS.defaultAptProxyMode]: input.mode,
[SETTING_KEYS.defaultAptProxyUrl]: input.url ?? "",
});
return getDefaultAptProxy();
}
+79 -1
View File
@@ -2,10 +2,17 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
import { parseAptSimulate, parseRebootRequired, parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail, parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
const raw = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-simulate.txt", import.meta.url)), "utf8");
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
function section(rawInput: string, start: string, end: string): string {
const s = rawInput.indexOf(start); if (s === -1) return "";
const from = s + start.length; const e = rawInput.indexOf(end, from);
return rawInput.slice(from, e === -1 ? undefined : e).trim();
}
describe("parseAptSimulate", () => {
it("extrait les paquets upgradables avec versions et origine", () => {
const pkgs = parseAptSimulate(raw);
@@ -28,3 +35,74 @@ describe("parseRebootRequired", () => {
expect(parseRebootRequired("rien")).toBe(false);
});
});
describe("parseAptRemovals", () => {
it("extrait les suppressions Remv", () => {
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
});
});
describe("parseHeld", () => {
it("liste les paquets retenus non vides", () => {
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
});
});
describe("parseRebootDetail", () => {
it("lit le flag et les paquets reboot", () => {
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
});
});
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
describe("parseDpkgList", () => {
it("indexe par package:arch", () => {
const m = parseDpkgList("libc6\t2.31-13\tamd64");
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
});
});
describe("buildAptExecutionResult", () => {
it("calcule le diff réel before/after", () => {
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
expect(r.rebootRequiredAfterRun).toBe(true);
});
});
describe("buildAptSnapshotDetail", () => {
it("construit le détail enrichi depuis les sections", () => {
const detail = buildAptSnapshotDetail({
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
updateFailed: false,
});
expect(detail.enabled).toBe(true);
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
expect(detail.distUpgradeCount).toBe(2);
expect(detail.rebootRequired).toBe(true);
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
expect(detail.held).toEqual(["frozenpkg"]);
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
expect(detail.status).toBe("warning"); // car removed + held non vides
});
it("status=updates_available sans removed/held, error si update échoue", () => {
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
expect(ok.status).toBe("updates_available");
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
expect(err.status).toBe("error");
});
});
+115 -1
View File
@@ -1,5 +1,5 @@
// server/services/aptParse.ts
import type { AptPackage } from "@shared/types.js";
import type { AptPackage, AptSnapshotDetail, SnapshotStatus, AptChange, AptExecutionResult } from "@shared/types.js";
// Exemple de ligne:
// Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
@@ -24,3 +24,117 @@ export function parseAptSimulate(raw: string): AptPackage[] {
export function parseRebootRequired(raw: string): boolean {
return /REBOOT_REQUIRED=1/.test(raw);
}
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
const out: { name: string; currentVersion: string | null }[] = [];
for (const line of raw.split("\n")) {
const m = REMV_RE.exec(line.trimEnd());
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
}
return out;
}
export function parseHeld(raw: string): string[] {
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
}
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
const pkgs: string[] = [];
for (const line of raw.split("\n")) {
const m = /^PKG=(.+)$/.exec(line.trim());
if (m) pkgs.push(m[1]!.trim());
}
return { rebootRequired, pkgs };
}
export interface AptSections {
upgradeSim: string;
distUpgradeSim: string;
heldRaw: string;
rebootRaw: string;
updateFailed: boolean;
}
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
const out: Record<string, { version: string; arch: string }> = {};
for (const line of raw.split("\n")) {
const parts = line.split("\t");
if (parts.length < 3) continue;
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
if (!name) continue;
out[`${name}:${arch}`] = { version, arch };
}
return out;
}
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
const before = parseDpkgList(beforeRaw);
const after = parseDpkgList(afterRaw);
const applied: AptChange[] = [];
const installed: AptChange[] = [];
const removed: AptChange[] = [];
for (const key of Object.keys(after)) {
const [name] = key.split(":");
const a = after[key]!;
const b = before[key];
if (!b) {
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
installed.push(change); applied.push(change);
} else if (b.version !== a.version) {
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
}
}
for (const key of Object.keys(before)) {
if (!after[key]) {
const [name] = key.split(":");
const b = before[key]!;
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
removed.push(change); applied.push(change);
}
}
return {
planned: [],
applied,
installed,
removed,
held: [],
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
};
}
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
const upgradePkgs = parseAptSimulate(s.upgradeSim);
const distPkgs = parseAptSimulate(s.distUpgradeSim);
const installed: AptPackage[] = distPkgs
.filter((p) => p.currentVersion === null)
.map((p) => ({ ...p, operation: "install" as const }));
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove" as const,
}));
const held = parseHeld(s.heldRaw);
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
let status: SnapshotStatus = "ok";
if (s.updateFailed) status = "error";
else if (removed.length > 0 || held.length > 0) status = "warning";
else if (distPkgs.length > 0) status = "updates_available";
return {
enabled: true,
count: distPkgs.length,
rebootRequired,
packages: distPkgs,
status,
upgradeCount: upgradePkgs.length,
distUpgradeCount: distPkgs.length,
installed,
removed,
held,
rebootPkgs,
};
}
+31
View File
@@ -0,0 +1,31 @@
// server/services/capabilities.test.ts
import { describe, expect, it } from "vitest";
import { getServerCapabilities } from "./capabilities.js";
describe("getServerCapabilities", () => {
it("publie un contrat stable sans annoncer les fonctions futures non implémentées", () => {
const caps = getServerCapabilities(new Date("2026-06-05T08:00:00.000Z"));
expect(caps).toMatchObject({
app: "system_update",
apiVersion: "1",
generatedAt: "2026-06-05T08:00:00.000Z",
features: {
machines: true,
actions: true,
terminalOutput: true,
docker: false,
hermes: false,
interactiveSsh: false,
authTokens: false,
},
endpoints: {
capabilities: "GET /api/capabilities",
systemStatus: "GET /api/system/status",
systemMetrics: "GET /api/system/metrics",
machines: "GET /api/machines",
terminalOutputWs: "WS /api/ws/machines/:id/output",
},
});
});
});
+38
View File
@@ -0,0 +1,38 @@
// server/services/capabilities.ts
import type { ServerCapabilities } from "@shared/types.js";
export function getServerCapabilities(now = new Date()): ServerCapabilities {
return {
app: "system_update",
apiVersion: "1",
generatedAt: now.toISOString(),
features: {
machines: true,
machineSnapshots: true,
actions: true,
aptFullUpgrade: true,
reboot: true,
reports: true,
terminalOutput: true,
interactiveSsh: false,
docker: false,
postInstall: false,
hermes: false,
settings: false,
scheduledJobs: false,
authTokens: false,
},
endpoints: {
capabilities: "GET /api/capabilities",
systemStatus: "GET /api/system/status",
systemMetrics: "GET /api/system/metrics",
machines: "GET /api/machines",
machineSnapshot: "GET /api/machines/:id/snapshot",
machineRefresh: "POST /api/machines/:id/refresh",
machineActions: "POST /api/machines/:id/actions",
machineExecutions: "GET /api/machines/:id/executions",
executionReport: "GET /api/machines/:id/executions/:execId/report",
terminalOutputWs: "WS /api/ws/machines/:id/output",
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { resolveCreds } from "./credentials.js";
describe("resolveCreds", () => {
it("préfère la ligne machine_credentials", () => {
const out = resolveCreds(
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
);
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
});
it("retombe sur machines si pas de credentials", () => {
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
});
});
+55
View File
@@ -0,0 +1,55 @@
// server/services/credentials.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
}
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
export function writeCredentials(input: {
machineId: string;
encPassword: string | null;
encSudoPassword: string | null;
}): void {
const now = new Date().toISOString();
db.insert(schema.machineCredentials)
.values({
machineId: input.machineId,
authMethod: "password",
encPassword: input.encPassword,
encSudoPassword: input.encSudoPassword,
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
createdAt: now,
updatedAt: now,
status: "unknown",
})
.onConflictDoUpdate({
target: schema.machineCredentials.machineId,
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
})
.run();
}
/** Lit la ligne machine_credentials (ou null). */
export function readCredentials(machineId: string): EncPair | null {
const row = db.select().from(schema.machineCredentials)
.where(eq(schema.machineCredentials.machineId, machineId)).get();
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
}
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
export function backfillCredentials(): number {
const machines = db.select().from(schema.machines).all();
let created = 0;
for (const m of machines) {
if (readCredentials(m.id)) continue;
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
created++;
}
return created;
}
+81
View File
@@ -0,0 +1,81 @@
// server/services/dbBackup.ts
import Database from "better-sqlite3";
import { readFileSync, writeFileSync, rmSync, existsSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { sqlite } from "../db/client.js";
import { env } from "../env.js";
// En-tête SQLite : 15 octets ASCII + un octet nul terminal.
const SQLITE_HEADER = "SQLite format 3";
function isSqliteHeader(buffer: Buffer): boolean {
return (
buffer.length >= 16 &&
buffer.subarray(0, 15).toString("latin1") === SQLITE_HEADER &&
buffer[15] === 0
);
}
function stamp(): string {
return new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "");
}
/** Snapshot cohérent de la base courante (VACUUM INTO → fichier unique, sans WAL). */
export function createBackup(): { buffer: Buffer; filename: string } {
const tmp = join(dirname(env.dbPath), `.backup-${Date.now()}.db`);
rmSync(tmp, { force: true });
sqlite.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`);
try {
return { buffer: readFileSync(tmp), filename: `system-update-${stamp()}.db` };
} finally {
rmSync(tmp, { force: true });
}
}
/** Vérifie qu'un buffer est une base SQLite intègre au schéma attendu. */
export function validateSqlite(buffer: Buffer): void {
if (!isSqliteHeader(buffer)) {
throw new Error("Fichier invalide : ce n'est pas une base SQLite.");
}
const tmp = join(dirname(env.dbPath), `.verify-${Date.now()}.db`);
writeFileSync(tmp, buffer);
try {
const test = new Database(tmp, { readonly: true });
try {
const integrity = test.pragma("integrity_check", { simple: true });
if (integrity !== "ok") throw new Error("Base corrompue (integrity_check).");
const hasMachines = test
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='machines'")
.get();
if (!hasMachines) throw new Error("Archive non reconnue : table 'machines' absente.");
} finally {
test.close();
}
} finally {
rmSync(tmp, { force: true });
}
}
/**
* Prépare une restauration : sauvegarde la base courante puis dépose la nouvelle base
* en `<db>.incoming`. Le swap réel a lieu au prochain démarrage (db/client.ts) pour
* ne jamais écraser une base ouverte. Renvoie le chemin de la sauvegarde de sécurité.
*/
export function prepareRestore(buffer: Buffer): { safetyBackup: string } {
validateSqlite(buffer);
const safety = `${env.dbPath}.pre-restore-${stamp()}.bak`;
writeFileSync(safety, createBackup().buffer);
writeFileSync(`${env.dbPath}.incoming`, buffer);
return { safetyBackup: safety };
}
/** Métadonnées de la base courante (pour l'UI). */
export function dbInfo(): { sizeBytes: number; modifiedAt: string | null; restorePending: boolean } {
const exists = existsSync(env.dbPath);
const st = exists ? statSync(env.dbPath) : null;
return {
sizeBytes: st?.size ?? 0,
modifiedAt: st ? st.mtime.toISOString() : null,
restorePending: existsSync(`${env.dbPath}.incoming`),
};
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import {
parseDockerApply,
parseDockerPrune,
parseDockerDown,
parseHumanBytes,
} from "./dockerApply.js";
describe("parseDockerApply", () => {
const RAW = [
"===SU:DOCKER_APPLY===",
" Container media-app-1 Recreate",
" Container media-app-1 Recreated",
" Container media-worker-1 Created",
" Container media-db-1 Running",
" Container media-app-1 Started",
"===SU:DOCKER_PS_AFTER===",
'{"Name":"media-app-1","Service":"app","State":"running","Health":""}',
'{"Name":"media-db-1","Service":"db","State":"running","Health":"healthy"}',
'{"Name":"media-worker-1","Service":"worker","State":"exited","Health":""}',
"===SU:DOCKER_INSPECT_AFTER===",
"IMG\tsha256:newapp\tapp@sha256:dapp",
"IMG\tsha256:db\tdb@sha256:ddb",
"===SU:EXIT=0===",
].join("\n");
it("liste les conteneurs recréés/créés et l'état running/exited", () => {
const r = parseDockerApply(RAW);
expect(r.recreated.sort()).toEqual(["media-app-1", "media-worker-1"]);
expect(r.running.sort()).toEqual(["media-app-1", "media-db-1"]);
expect(r.exited).toEqual(["media-worker-1"]);
expect(r.errors).toHaveLength(0);
expect(r.exitCode).toBe(0);
});
it("remonte une erreur d'application nettoyée", () => {
const bad = [
"===SU:DOCKER_APPLY===",
' Container app-1 Error pulling image from https://reg.example/v2 token=SECRET123',
"===SU:DOCKER_PS_AFTER===",
"===SU:DOCKER_INSPECT_AFTER===",
"===SU:EXIT=1===",
].join("\n");
const r = parseDockerApply(bad);
expect(r.errors.length).toBeGreaterThan(0);
expect(r.errors[0]!.message).not.toContain("reg.example");
expect(r.errors[0]!.message).not.toContain("SECRET123");
expect(r.exitCode).toBe(1);
});
});
describe("parseHumanBytes", () => {
it("convertit les unités décimales Docker", () => {
expect(parseHumanBytes("0B")).toBe(0);
expect(parseHumanBytes("512MB")).toBe(512_000_000);
expect(parseHumanBytes("1.234GB")).toBe(Math.round(1.234 * 1e9));
expect(parseHumanBytes("1.5kB")).toBe(1500);
});
it("renvoie 0 pour une entrée illisible", () => {
expect(parseHumanBytes("n/a")).toBe(0);
});
});
describe("parseDockerPrune", () => {
const RAW = [
"===SU:DOCKER_PRUNE===",
"Deleted Images:",
"untagged: redis:6",
"deleted: sha256:aaa",
"deleted: sha256:bbb",
"",
"Total reclaimed space: 1.234GB",
"===SU:EXIT=0===",
].join("\n");
it("liste les images supprimées et l'espace récupéré", () => {
const r = parseDockerPrune(RAW);
expect(r.imagesDeleted).toEqual(["sha256:aaa", "sha256:bbb"]);
expect(r.bytesReclaimed).toBe(Math.round(1.234 * 1e9));
expect(r.errors).toHaveLength(0);
});
});
describe("parseDockerDown", () => {
it("liste les conteneurs retirés", () => {
const RAW = [
"===SU:DOCKER_DOWN===",
" Container media-app-1 Stopping",
" Container media-app-1 Stopped",
" Container media-app-1 Removing",
" Container media-app-1 Removed",
" Network media_default Removed",
"===SU:EXIT=0===",
].join("\n");
const r = parseDockerDown(RAW);
expect(r.removed).toEqual(["media-app-1"]);
expect(r.errors).toHaveLength(0);
});
});

Some files were not shown because too many files have changed in this diff Show More