Compare commits

...
Author SHA1 Message Date
bruno 883871244f Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : test pytest chantier 5
CI / test (push) Failing after 3s
CI / test (pull_request) Failing after 4s
2026-10-28 13:51:00 -04:00
bruno 5b5e7f2f94 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : tests node 14
CI / test (push) Failing after 3s
2026-10-28 13:50:56 -04:00
bruno 539a28114e Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : styles alertes vendanges
CI / test (push) Failing after 2s
2026-10-28 13:50:52 -04:00
bruno f2b3f95bae Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : module alertes vendange ui/alertes
CI / test (push) Failing after 3s
2026-10-28 13:50:48 -04:00
bruno 9eb47cfa8c Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : css barre vendangee commons
CI / test (push) Failing after 2s
2026-10-28 13:50:45 -04:00
bruno 0adf10a38a Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : barre vendangee commons
CI / test (push) Failing after 0s
2026-10-28 13:50:43 -04:00
bruno e18cbcdc69 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : onglet agent
CI / test (push) Failing after 0s
2026-10-28 13:50:40 -04:00
bruno 1234429a46 Bump 0.37.3 (programme Fusion #68/#69/#70)
CI / test (push) Failing after 1s
2026-10-28 13:00:18 -04:00
bruno f7a0597575 Bump 0.37.3 (programme Fusion #68/#69/#70)
CI / test (push) Failing after 0s
2026-10-28 13:00:13 -04:00
bruno e89f7903e1 Merge pull request 'Fusion #69 : Alertes unifiées (panneau + pastille, agrégation à la demande)' (#36) from alertes-unifiees-69-rebase into main
CI / test (push) Failing after 0s
2026-10-28 12:47:35 -04:00
bruno 938f8b5eb5 Fusion #69 : alertes unifiées (rebase sur main post-#70 et post-#68)
CI / test (push) Failing after 1s
CI / test (pull_request) Failing after 1s
2026-10-28 12:47:12 -04:00
bruno 5f365b4b9f Fusion #69 : alertes unifiées (rebase sur main post-#70 et post-#68)
CI / test (push) Failing after 0s
2026-10-28 12:47:09 -04:00
bruno d867fbffbf Fusion #69 : alertes unifiées (rebase sur main post-#70 et post-#68)
CI / test (push) Failing after 1s
2026-10-28 12:47:06 -04:00
bruno b90941111a Fusion #69 : alertes unifiées (rebase sur main post-#70 et post-#68)
CI / test (push) Failing after 0s
2026-10-28 12:47:03 -04:00
bruno 35f1cccd03 Fusion #69 : alertes unifiées (rebase sur main post-#70 et post-#68)
CI / test (push) Failing after 3m45s
2026-10-28 12:46:59 -04:00
bruno f29a640d9a Fusion #69 : alertes unifiées (rebase sur main post-#70 et post-#68)
CI / test (push) Failing after 1s
2026-10-28 12:46:55 -04:00
bruno cf98880fc8 Merge pull request 'Recherche partout (#68) : page de résultats unifiée + endpoint JSON /api/search' (#35) from recherche-partout-68 into main
CI / test (push) Failing after 2s
2026-10-28 12:40:46 -04:00
bruno 6e86b09327 Merge pull request 'fusion #70 : retirer le bouton trois points, Console Spark au menu marque' (#33) from fusion-70-barre-console-spark into main
CI / test (push) Failing after 0s
2026-10-28 12:39:54 -04:00
bruno d41a79a9ed Recherche partout (#68) : JS compatible ESM (test ObsiGate)
CI / test (push) Failing after 1s
CI / test (pull_request) Failing after 3s
2026-10-28 12:18:15 -04:00
bruno 725f78cb68 Recherche partout (#68) : test hôte de la requête
CI / test (push) Failing after 1s
CI / test (pull_request) Failing after 1s
2026-10-28 12:13:08 -04:00
bruno 39750851b0 Recherche partout (#68) : CORS résolue avec l'hôte de la requête (ordre middlewares)
CI / test (push) Failing after 1s
CI / test (pull_request) Failing after 0s
2026-10-28 12:13:03 -04:00
bruno b2d2fd1eee Recherche partout (#68) : palette de la barre -> /recherche
CI / test (push) Successful in 3m50s
CI / test (pull_request) Successful in 4m0s
2026-10-28 12:08:18 -04:00
bruno 0de3b65869 Recherche partout (#68) : routes /recherche + /api/search, draco sur /api/search, CORS bornée
CI / test (push) Failing after 1s
2026-10-28 12:08:14 -04:00
bruno 903616a8cd Recherche partout (#68) : tests Node du JS
CI / test (push) Failing after 2s
2026-10-28 12:07:59 -04:00
bruno e5282079b8 Recherche partout (#68) : tests module recherche
CI / test (push) Failing after 1s
2026-10-28 12:07:45 -04:00
bruno a30fa9d8f3 Recherche partout (#68) : JSON de recherche + CORS bornée
CI / test (push) Failing after 1s
2026-10-28 12:07:41 -04:00
bruno 61f299305a Recherche partout (#68) : gabarit de la page
CI / test (push) Failing after 3s
2026-10-28 12:07:39 -04:00
bruno ecf7f09fc5 Recherche partout (#68) : styles de la page
CI / test (push) Failing after 0s
2026-10-28 12:07:35 -04:00
bruno 9772ecdcf6 Recherche partout (#68) : page de résultats unifiée — JS client
CI / test (push) Failing after 1s
2026-10-28 12:07:31 -04:00
bruno f503331773 fusion #70 : vendanger CSS sans menu trois points
CI / test (push) Failing after 1s
CI / test (pull_request) Failing after 0s
2026-10-28 12:02:07 -04:00
bruno c75ef7764f fusion #70 : vendanger barre sans trois points, Console Spark, fermeture/focus corriges
CI / test (push) Failing after 2s
2026-10-28 12:02:01 -04:00
bruno 961556fb78 fusion #70 : retirer le bouton trois points et la branche menu morte
CI / test (push) Failing after 11m13s
2026-10-28 12:01:57 -04:00
bruno 7a68606aa7 Bump 0.37.2 (CHANGELOG.md)
CI / test (push) Failing after 2s
2026-10-28 10:37:42 -04:00
bruno 8015d22a06 Bump 0.37.2 (app/config.py)
CI / test (push) Failing after 0s
2026-10-28 10:37:39 -04:00
bruno 39b5fd88f9 Merge pull request 'barre-apps v0.1.6 : vendange (dérivation -dev, menu •••, avatar compte)' (#32) from barre-apps-v0.1.6-kanbante into main
CI / test (push) Failing after 1s
2026-10-28 10:34:14 -04:00
bruno f5b007f286 barre-apps v0.1.6 : CSS vendange (menus, avatar bouton)
CI / test (push) Failing after 1s
CI / test (pull_request) Failing after 1s
2026-10-28 10:31:27 -04:00
bruno 2b9b852308 barre-apps v0.1.6 : JS vendange (derivation -dev, menus)
CI / test (push) Failing after 2s
2026-10-28 10:31:23 -04:00
bruno d3886b6bf5 barre-apps v0.1.6 : fragment (avatar bouton compte, menu par defaut demande, cfg.user initiale)
CI / test (push) Failing after 1s
2026-10-28 10:31:19 -04:00
bruno 671ee68c96 Merge pull request '[Coffre Secrets] Secret webhook par reference ObsiGate (Phase 4)' (#31) from feat/coffre-reference-obsigate into main
CI / test (push) Successful in 3m50s
2026-10-28 00:15:20 -04:00
bruno c46d6dc519 coffre(obsigate#22): webhook distribue le secret reference a chaque appel, sans repli; validation reference admin
CI / test (push) Failing after 0s
CI / test (pull_request) Failing after 1s
2026-10-27 23:52:37 -04:00
bruno 4184ba0272 coffre(obsigate#22): formulaire admin accepte une reference coffre pour le secret webhook
CI / test (push) Failing after 1s
2026-10-27 23:52:34 -04:00
bruno 751e2f4f44 coffre(obsigate#22): documentation inventaire, migration webhook, limites
CI / test (push) Failing after 1s
2026-10-27 23:52:28 -04:00
bruno 975aff33a5 coffre(obsigate#22): tests client de distribution (faux endpoint, valeurs fictives)
CI / test (push) Failing after 1s
2026-10-27 23:52:25 -04:00
bruno 003e44f55e coffre(obsigate#22): client de distribution generique (reference, contexte, sans cache ni log de valeur)
CI / test (push) Failing after 1s
2026-10-27 23:52:19 -04:00
Spark 53f54b1b79 feat(0.37.1): version du service d'agents visible dans le panneau Spark (mode sidebar) — fusion #64
CI / test (push) Failing after 1s
2026-10-26 16:49:25 +00:00
Spark 80fc260a40 feat(0.37.0): tri dashboard « En attente de revue » + sens des tris numériques corrigé
CI / test (push) Successful in 2m27s
- Nouveau critère 🔍 En attente de revue ↓ (compteur de la
  colonne review exposé en data-review sur les cartes) ;
  s'applique à toutes les grilles, Épinglés en tête,
  préférence mémorisée comme les autres tris.
- Correctif attrapé au contrôle navigateur réel : le tri
  « Issues ouvertes ↓ » historique triait croissant — le
  comparateur inversait les clés numériques. Modes open et
  review en ka - kb : plus grand compteur en premier.
  Vérifié sur données réelles (Épinglés : 196, 95, 32, 5, 4).
- 299 tests verts (3 nouveaux, palier 25).
2026-10-26 11:19:53 -04:00
Spark 8ad3a86e60 feat(0.36.0): outil MCP move_issue — déplacement de carte par le service d'agents
CI / test (push) Failing after 1m27s
Le serveur MCP n'exposait en écriture que create_issue et
add_comment : un déplacement approuvé côté service d'agents
(kanbante.move_card, vague H3) aurait échoué faute d'outil.

- move_issue (owner, repo, number, column) réutilise le service
  de déplacement de l'interface (_do_move_card, extrait de
  POST /api/move sans changement de comportement) : board
  local, journal source « mcp », miroir d'étiquette kanban:*
  remplacée, autres étiquettes préservées.
- Colonne acceptée par clé, par étiquette de forge ou par
  libellé français affiché (définitions de l'espace).
- Garde doctrine : la cible done (« Terminé ») est refusée
  avant toute écriture — les agents s'arrêtent en Révision.
- Erreurs propres : colonne inconnue (liste des valides),
  dépôt/issue inconnu, pull request refusée.
- 296 tests verts (5 nouveaux + épingles de la liste d'outils
  mises à jour).
2026-10-26 15:06:02 +00:00
Spark a92353f8ef feat(0.35.0): jalons en écriture depuis la page Projet (issue #26)
CI / test (push) Successful in 2m22s
- Clients : create_milestone / update_milestone (cache
  invalidé) ; GitHub hérite des mêmes endpoints.
- API Kanbante : création, modification (titre/échéance,
  échéance vidée = retirée), fermeture ; CSRF, validation
  de date avant toute écriture, erreurs forge en 502.
- Page Projet : formulaire 'Nouveau jalon' + édition/
  fermeture par jalon (project.js versionné).
- Échéance à midi UTC : à minuit, le fuseau de l'instance
  Gitea faisait reculer la date affichée d'un jour (réel).
- 291 tests verts (5 nouveaux, palier 24) ; cycle réel
  complet sur Projets/kanbante (jalons smoke #37/#38 créés,
  dates vérifiées, fermés).
2026-10-26 10:53:29 -04:00
Spark 8c889878ee feat(0.34.0): vue « En vol partout » /inflight (issue #24)
CI / test (push) Successful in 2m24s
- L'encours (colonnes en vol de l'espace) tous projets
  actifs, regroupé PAR colonne, trié par âge décroissant
  dans la colonne (pipeline _card_ages du board).
- En-tête : total + compteurs par colonne + limite WIP
  globale (somme des projets qui en définissent).
- Entrées Pilotage (menu latéral) et palette Ctrl+K ; SSE
  /api/stream via live.js comme les autres vues.
- Réutilise _collect_focus_groups : aucun appel forge en
  plus. 286 tests verts (5 nouveaux, palier 23) ; vérifié
  en Chromium réel (356 cartes en vol sur les vraies
  données, dont la carte #24 elle-même).
2026-10-26 10:48:45 -04:00
Spark dfb4e595ff fix(test): horodatage capturé une fois (flake CI, écart d'1 s)
CI / test (push) Successful in 2m23s
test_column_entry_times_ignores_comments évaluait _days_ago(3)
à la création puis à l'assertion : une bascule de seconde
entre les deux faisait échouer le run CI 2333 alors que le
run jumeau (tag v0.33.0) et la suite locale passaient.
2026-10-26 10:23:06 -04:00
Spark 9fe35f6c8f feat(0.33.0): filtrage professionnel sur Mes issues (/my)
CI / test (push) Successful in 2m26s
- Barre de filtres en carte : recherche texte (titre, projet,
  #numéro, étiquettes ; accents/casse ignorés, raccourci /),
  listes Projet et Étiquette avec compteurs, chips de
  colonnes colorées multi-sélection, 4 tris.
- Facettes calculées côté serveur ; filtrage instantané côté
  client (attributs data-*), compteur en direct, compteurs
  de section, état vide + réinitialisation.
- Filtres structurels persistés en localStorage.
- 281 tests verts (3 nouveaux, palier 22) ; vérifié en
  Chromium réel sur les vraies données (467 issues :
  recherche 'agent' -> 24 résultats instantanés).
2026-10-26 10:20:37 -04:00
Spark d419fe3086 fix(0.32.2): résolution par requête des URL dracodev, base agent dérivée et CSP du panneau
CI / test (push) Successful in 2m21s
La dérivation 0.32.1 ne couvrait que les URL sœurs depuis
AGENT_EVENEMENTS_URL, jamais agent_url : sur la dev, le panneau
visait le défaut prod (agent injoignable, prod non installée),
console morte. Désormais, pour tout rendu à une requête (barre,
iframe du panneau, CSP frame-src) : explicite > hôte public au
motif fermé dracodev > dérivation élargie (événements ou base du
service) > défaut officiel. Contextes sans requête inchangés
sinon ; aucun SSO ajouté.
2026-10-26 02:07:46 +00:00
Spark 65c467bc90 fix(0.32.1): URL sœurs dracodev dérivées de l'environnement de l'agent
CI / test (push) Successful in 2m58s
2026-10-25 23:49:33 +00:00
Spark 4a97e0d0dc refactor: noms publics dracodev par configuration (dev/prod)
CI / test (push) Successful in 2m22s
2026-10-25 18:02:21 +00:00
Spark 7852d860fe fix(0.31.1): compositeur de création refait, description en <details> natif
CI / test (push) Successful in 2m13s
- Carte compositeur : en-tête + colonne d'arrivée, champ
  pleine largeur, bouton primaire ; neutralise le
  display:flex imposé par .board-toolbar aux formulaires.
- 'Ajouter une description' devient un <details> natif :
  aucun JS requis, insensible au cache et aux erreurs de
  scripts (le bouton 0.31.0 pouvait rester inerte).
- Assets du board versionnés (?v=APP_VERSION) contre les
  mélanges de cache après redéploiement ; Ctrl+Entrée.
- Vérifié en Chromium réel sur le vrai board (captures
  contrôlées) ; 261 tests verts.
2026-10-23 09:48:17 -04:00
Spark d4cf8d1275 feat(0.31.0): description éditable, pièces jointes d'issues, clic carte fiable
CI / test (push) Successful in 2m10s
- Tiroir : édition du titre et de la description (PATCH
  forge, journal 'edited', EVENT_TYPES étendu), création
  avec description dépliable.
- Pièces jointes Gitea de bout en bout : client
  list/upload/delete/download (le téléchargement suit
  browser_download_url — l'endpoint asset ne sert que du
  JSON, attrapé au smoke réel), routes proxifiées avec
  CSRF et plafond 25 Mio, UI liste/envoi/suppression ;
  GitHub : supports_attachments=False, message propre.
- Clic carte en délégation d'événement : les écouteurs par
  carte mouraient au premier rafraîchissement live.
- 261 tests verts (7 nouveaux, palier 21) ; cycle réel
  complet validé sur une issue jetable du vrai Gitea
  (création, édition, envoi, téléchargement, suppression).
2026-10-23 09:27:38 -04:00
Spark 28322c12cf feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
- Table board_columns par espace (clé immuable, nom/couleur/
  ordre libres, étiquette mappée + alias, drapeau Focus) ;
  définitions semées paresseusement depuis les valeurs
  historiques — sans personnalisation, comportement identique.
- Service app/board_columns.py : résolution, mapping
  étiquette→colonne (alias compris, ex. kanban_a_faire de
  Flowdeck), colonnes Focus/Wallboard.
- Board, API, adoption, miroirs d'étiquettes, déplacements,
  capture, santé, focus, wall, dashboard branchés sur les
  colonnes de l'espace ; validations dynamiques dans models
  (move_issue, set_column_order) et rules.
- Administration → Colonnes : ajouter (avant Terminé),
  renommer, réordonner, mapper, supprimer avec migration des
  cartes vers une colonne choisie, réinitialiser ; bouton
  🧱 Colonnes sur le board ; journal d'audit.
- Backlog et Terminé : colonnes système non supprimables.
- 254 tests verts (7 nouveaux, palier 20) ; smoke réel contre
  le vrai Gitea : création, affichage au board, suppression.
2026-10-23 08:38:02 -04:00
Spark 3964323abd Panneau Spark V3 : console en surimpression pleine page (0.29.0)
CI / test (push) Successful in 2m11s
2026-10-23 04:10:35 +00:00
Spark 4230ac1052 Panneau Spark : bouton « Console ↗ » vers la console du service (0.28.1)
CI / test (push) Successful in 2m6s
2026-10-22 21:18:49 +00:00
Spark 3809462cc7 0.28.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
CI / test (push) Successful in 2m1s
- app/agent_journal.py : enveloppe ACC-0.2 signée HMAC-SHA256
  (clé AGENT_EVENEMENTS_CLE, URL AGENT_EVENEMENTS_URL), envoi
  en arrière-plan, timeout 2 s, inactif sans clé, échecs avalés
- main.py : _emettre_changement_colonne accolée aux log_event de
  déplacement (interface, étiquettes forge, webhook, réconciliation,
  PR fusionnée, branche) — kanbante.card.moved pour tout changement
  de colonne, kanbante.card.review_requested à l'entrée en Révision
- config : AGENT_EVENEMENTS_URL/_CLE documentés et lus à l'appel
- 247 tests verts (5 nouveaux : signature revérifiable, inactivité
  sans clé, moved, review_requested, panne du récepteur)
2026-10-22 18:40:00 +00:00
Spark 868496891d 0.27.0 — Panneau Spark v2 (Lot 1)
CI / test (push) Canceled after 0s
- draco-panneau vendangé v2 (identique dans les 4 apps) : Markdown
  rendu par le service 0.10.0, largeur réglable, thème aligné,
  discussions façon Muse (principale + parallèles)
- 242 tests verts
2026-10-22 11:54:31 -04:00
Spark 4a947747f2 0.26.0 — panneau Spark intégré (barre commune, assets vendangés)
CI / test (push) Failing after 1m56s
- static/js/draco-panneau.js + static/css/draco-panneau.css (copie
  du panneau partagé : conversation, approbations, missions J5)
- _draco_bar.html : action spark → DRACO_PANNEAU.toggle()
2026-10-22 03:39:34 +00:00
Spark 5ef6c21756 feat(0.26.0): écritures MCP minimales — create_issue + add_comment gardés par jeton kbt_ 2026-10-22 03:31:19 +00:00
Spark 13182f5d53 0.25.0 — Jetons d'API personnels (kbt_) pour les surfaces machine
CI / test (push) Successful in 1m56s
Depuis 0.24.0, /mcp exige session ou Basic : le service d'agents s'y
faisait refuser en 401 (vu en production, panneau Spark du 2026-10-21).

- Table api_tokens (empreinte SHA-256 seule), CRUD dans Mon profil
- Bearer honoré par le middleware sur /mcp et /api/ uniquement —
  jamais l'interface web ; révocation et désactivation immédiates
- 235 tests verts (3 nouveaux : cycle de vie, compte désactivé,
  handshake MCP par jeton quand l'auth est active)
2026-10-21 21:38:16 -04:00
Spark 2381ae0b35 fix(0.24.2): base sombre pour tous les champs de saisie (#29)
CI / test (push) Successful in 1m54s
2026-10-21 21:31:57 -04:00
bruno 7b6e72e6ce chore: APP_VERSION 0.24.1
CI / test (push) Successful in 1m53s
2026-10-21 15:31:43 -04:00
bruno 6e31122c46 fix: cryptography manquant dans dependencies (forge_crypto.py) — crash ModuleNotFoundError au démarrage v0.24.0
CI / test (push) Successful in 1m53s
2026-10-21 15:26:40 -04:00
Spark d51ef5a970 feat(0.24.0): utilisateurs, administration, espaces, paramètres, archives (P2-P4)
CI / test (push) Successful in 1m53s
- Comptes en base (PBKDF2), rôles, désactivation qui tue la
  session, audit ; secours environnement pour l'opérateur ;
  liaison draco par utilisateur ; page /profile.
- Espaces de travail : connexions, membres, sélection de
  projets ; board/notes/épingles/journal scopés par espace ;
  sélecteur d'espace, badge connexion.
- Panneaux /admin (utilisateurs, connexions, espaces,
  paramètres — l'environnement amorce, le panneau gouverne).
- /archives : archive locale réversible, archivage forge
  confirmé, purge de l'état local, export Markdown, file
  « à archiver ? » avec archivage en lot.
- Correctif session : les comptes membres résolus par la base
  (la session n'est plus limitée à l'opérateur d'environnement).
- 231 tests verts (12 nouveaux au palier 19).
2026-10-21 14:48:30 -04:00
Spark 735d7cdfd0 feat(forge): fondation multi-forges — connexions chiffrées, registre, client GitHub (P0/P1)
- Tables forge_connections/users/workspaces (+ membres, projets,
  états, audit) ; migration des anciennes bases avec portées
  (workspace_id, connection_id) par défaut (1, 1).
- Jetons chiffrés Fernet (KANBANTE_MASTER_KEY ou clé générée en
  base), jamais réaffichés.
- ForgeRegistry : connexion héritée créée depuis l'environnement,
  espace Principal, instantanés préfixés par connexion.
- GitHubClient complet (Bearer, pagination Link, étiquettes par
  nom, PATCH archived, branches via git/refs).
- Paramètres surchargeables en base (résolveur config, P3).
2026-10-21 14:48:29 -04:00
Spark f1a05279b6 fix: barre commune v0.1.4 (palier 900 px) + assets versionnés anti-cache (#28)
CI / test (push) Successful in 1m39s
2026-10-21 12:47:15 -04:00
Spark ed2834c332 feat: menu des applications par la marque ✦ (barre commune v0.1.3) (#28)
CI / test (push) Successful in 1m37s
2026-10-21 12:27:10 -04:00
Spark 5bc21e1840 fix: barre commune pleine largeur, palier mobile v0.1.2, animation latérale fluide (#28)
CI / test (push) Successful in 1m37s
2026-10-21 11:34:18 -04:00
Spark f087b41b4b feat: Fusion — barre d'apps commune (#28) et pont SSO draco_session (#27)
CI / test (push) Successful in 1m37s
2026-10-21 10:56:22 -04:00
bruno 6066890bae chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m19s
2026-10-20 16:56:43 -04:00
bruno 96c08ed15e chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m20s
2026-10-20 16:56:40 -04:00
bruno 52e87608ad chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m20s
2026-10-20 16:56:38 -04:00
bruno 90be279a2b chore(release): Kanbante 0.22.1 — correctif colonne Terminé
CI / test (push) Successful in 2m21s
2026-10-20 16:56:37 -04:00
bruno eb39c1ed5d Fix: les issues fermées suivies par le board s'affichent dans la colonne Terminé (le merge ne recevait que les issues ouvertes) + test de régression
CI / test (push) Successful in 2m19s
2026-10-20 16:33:31 -04:00
bruno 121c59760e Fix: les issues fermées suivies par le board s'affichent dans la colonne Terminé (le merge ne recevait que les issues ouvertes) + test de régression
CI / test (push) Successful in 2m20s
2026-10-20 16:33:28 -04:00
Spark 9e2096c9b6 chore(release): Kanbante 0.22.0 — login, sidebar, chantier B
CI / test (push) Successful in 2m1s
2026-10-14 08:02:49 -04:00
Spark e63fe39578 test: palier 18 — login/session, sidebar, hydratation des instantanés 2026-10-14 08:02:49 -04:00
Spark b7d21f80df feat(perf): chantier B — instantanés SQLite et rafraîchisseur d'arrière-plan
- Table snapshots + écriture traversante du cache client,
  hydratation au démarrage (SWR : servi + rafraîchi en fond).
- Boucle de rafraîchissement (120 s par défaut) : index, étoiles,
  issues all+open et jalons des projets actifs ; webhook =
  rafraîchissement immédiat du dépôt ; fraîcheur affichée.
2026-10-14 08:02:49 -04:00
Spark c7711c0ca2 feat(sidebar): replier en haut à droite de la recherche, accordéon exclusif
- Bouton replier : icône seule dans l'en-tête, gabarit du bouton
  recherche ; le pied garde version + fraîcheur + Sortir.
- Une seule section ouverte à la fois (kanbante-nav-open),
  amorce serveur selon la page courante, plus de kanbante-nav-closed.
2026-10-14 08:02:49 -04:00
Spark 319619d200 feat(auth): écran de connexion + session signée (le Basic reste pour les scripts)
- /login professionnel, cookie HMAC 7 jours, next local seulement,
  CSRF double-submit réutilisé, /logout réel + lien Sortir.
- Middleware : session OU Basic ; navigateurs redirigés vers
  /login, API en 401 JSON ; /static et /login publics.
2026-10-14 08:02:49 -04:00
Spark 0bdde28b79 chore(release): Kanbante 0.21.0 — sélecteur de dépôt Capture
CI / test (push) Successful in 1m54s
2026-10-12 15:47:28 -04:00
Spark 4b38acdcba test: palier 17 — /api/repos exhaustif et sélecteur branché sur /capture 2026-10-12 15:47:28 -04:00
Spark 760fbd8c6d feat(capture): sélecteur de dépôt intelligent — suggestions, Tab par segment, validation
- GET /api/repos exhaustif (épinglés, récents, archivés),
  la palette garde son plafond de 60.
- repo-picker.js : liste filtrée sur les deux segments,
  complétion Tab façon shell (pr Tab → Projets/ avec le
  séparateur posé, kan Tab → kanbante), ↑/↓/Entrée/clic,
  avertissement doux si le dépôt n'existe pas.
2026-10-12 15:47:28 -04:00
Spark f5d2d7d716 chore(release): Kanbante 0.20.1 — correctifs tri et regroupement
CI / test (push) Successful in 1m50s
2026-10-12 14:54:24 -04:00
Spark 71e705ea64 fix(dashboard): tri par date des tableaux, tri/regroupement sur les Épinglés
- tablesort.js : parseFloat lisait l'année des dates ISO (2026)
  et mettait toutes les lignes à égalité — comparaison numérique
  seulement pour les valeurs entièrement numériques.
- dashboard.js : pinned-grid rejoint les grilles triées et
  regroupées (son exclusion rendait les deux outils invisibles
  là où l'œil se pose) ; dates comparées via Date.parse.
- data-first-dir=desc sur Dernière activité / Écart / Zombies.
2026-10-12 14:54:24 -04:00
Spark 7918b54943 chore(release): Kanbante 0.20.0 — en-tête Projets professionnel
CI / test (push) Successful in 2m13s
2026-10-12 14:40:05 -04:00
Spark 3695bfac8a test: palier 16 — en-tête professionnel (KPI, barre unique, crochets préservés) 2026-10-12 14:40:05 -04:00
Spark 57d93c4f4b feat(ui): en-tête Projets professionnel — titre sobre, bande KPI unifiée, barre d'outils unique
- Titre « Projets » + sous-titre discret (fini le Bonjour 👋),
  en-tête deux fois plus compact.
- Les 4 statistiques deviennent une bande KPI bordée unique
  (séparateurs fins, chiffres tabulaires, KPI journal cliquables
  vers /activity).
- Onglets/filtre/tri/regroupement/vue réunis dans UNE barre :
  contrôle segmenté avec pastilles de compte, filtre rectangle.
  Crochets JS/Alpine inchangés ; .stat-tile conservée (briefing).
2026-10-12 14:40:05 -04:00
Spark 900590efed chore(release): Kanbante 0.19.0 — performance et pilotage (A + C)
CI / test (push) Successful in 1m50s
2026-10-12 13:53:59 -04:00
Spark 0aa991047f test: palier 15 — tableaux pilotage, ancres, SWR (frais/périmé/mutation) 2026-10-12 13:53:59 -04:00
Spark 79122c20d0 feat(pilotage): Prévisions en tableau des jalons, Santé en tableau de sévérité (chantier C)
- /forecast : une ligne par jalon, tous projets, triée par
  risque (écart décroissant), filtrable, ancres ms-<owner>-<repo>
  pointées par le bloc jalon des cartes du dashboard ; débit
  par projet conservé en tableau compact.
- /health : tableau trié par sévérité (pastille, zombies,
  goulot) ; page détail : sélecteur de projet (health.js).
- Tri des tableaux mutualisé dans static/js/tablesort.js
  (dashboard, forecast, health).
2026-10-12 13:53:59 -04:00
Spark 3e3187d597 feat(perf): boucles par dépôt parallélisées et cache stale-while-revalidate (chantier A)
- _parallel_map : asyncio.gather borné (16) pour dashboard,
  /health, /forecast, /triage et le mur — le patron de /focus,
  généralisé. Avant : la page attendait la somme des allers-
  retours Gitea de tous les dépôts, en séquentiel.
- Client Gitea : les listes (dépôts, issues, jalons, étoiles)
  passent en stale-while-revalidate — une entrée périmée est
  servie immédiatement et rafraîchie en tâche de fond. Les
  mutations vident le cache et bumpent une génération qui
  désarme les rafraîchissements en vol.
- /forecast calcule des lignes de jalons tous projets (écart
  ETA/échéance) ; /health trie par sévérité et expose la
  pastille ; le détail santé reçoit la liste des projets.
2026-10-12 13:53:59 -04:00
Spark 593f076555 chore(release): Kanbante 0.18.0 — vague 2 suite : activité, actions rapides, tri, liste dense
CI / test (push) Successful in 1m47s
2026-10-11 23:18:01 -04:00
Spark 311a14090d test(0.18.0): activité, outils d'affichage, liste dense, actions rapides — 7 tests, 185 au total 2026-10-11 23:18:01 -04:00
Spark c67a14da65 feat(dashboard): ligne d'activité, menu ⋯, tri/regroupement, vue liste (#19, #21, #22, #23)
Menu d'actions rapides par carte; barre d'outils tri +
regroupement par propriétaire (sections repliables, préférences
localStorage); bascule Grille/Liste persistée avec tableaux
triables réutilisant jalon/santé des cartes; dashboard.js dédié.
2026-10-11 23:18:01 -04:00
Spark 7ec34245c5 feat(dashboard): activité 7 jours agrégée et capture pré-remplie (#19, #23)
get_project_activity(): fermetures/déplacements 7 j et dernier
événement par dépôt en une seule requête sur board_events;
la page /capture accepte ?repo= pour pré-remplir le dépôt.
2026-10-11 23:18:01 -04:00
Spark 018bde0fa5 chore(release): Kanbante 0.17.0 — vague 2 : cartes projets enrichies et page Projet
CI / test (push) Successful in 1m46s
2026-10-11 22:52:22 -04:00
Spark 0883191f57 test(0.17.0): jalon, santé, étoiles, page projet — 7 tests, 178 au total 2026-10-11 22:52:22 -04:00
Spark ba47b1a9ac feat(projets): page Projet /project/{owner}/{repo} (#25)
Accueil du projet assemblé des briques existantes: jalons
ouverts (progression, échéances, ETA), santé en résumé, débit,
activité récente du journal, notes; actions board/capture/
triage/santé/wallboard/Gitea. Le titre des cartes y pointe.
2026-10-11 22:52:22 -04:00
Spark cf58bb9d0c feat(projets): épinglés par étoiles Gitea (#20)
list_starred/set_starred sur /user/starred (vérité dans la
forge), endpoint POST /api/projects/{o}/{r}/star (CSRF), section
Épinglés hors onglets, bouton étoile sur les cartes et bascule
sans rechargement; les épinglés sortent de leur grille d'onglet.
2026-10-11 22:52:22 -04:00
Spark b33b67fcb4 feat(dashboard): prochain jalon et pastille de santé sur les cartes (#17, #18)
Bloc jalon (progression Gitea, échéance FR, retard, ETA journal
avec signal après-échéance — mêmes définitions que le bandeau
du board) et point santé vert/orange/rouge dérivé de
_project_health, info-bulle et lien vers /health du projet.
2026-10-11 22:52:22 -04:00
Spark b4dc8f0d4f chore(release): Kanbante 0.16.1 — icône palette et accordéons du menu
CI / test (push) Successful in 1m50s
2026-10-11 21:12:09 -04:00
Spark e810f5668a test(0.16.1): contrat icône palette et sections accordéon 2026-10-11 21:12:09 -04:00
Spark 2290560627 feat(nav): palette en icône près du titre, sections accordéon (retour #16)
Le bouton Recherche & commandes devient une icône 🔍 seule à
droite du titre (sous le logo en rail replié). Les groupes
deviennent des accordéons: chevron animé, transition douce,
sections fermées mémorisées (appliquées avant le rendu), section
de la page courante toujours ouverte; rail replié = icônes.
2026-10-11 21:12:09 -04:00
Spark d4b3b324f5 chore(release): Kanbante 0.16.0 — layout barre latérale et wallboard verrouillé
CI / test (push) Successful in 1m46s
2026-10-11 20:48:46 -04:00
Spark d395642f16 test(0.16.0): contrat barre latérale et mur — 6 tests adaptés/ajoutés, 171 au total 2026-10-11 20:48:46 -04:00
Spark ec3590d25f feat(wall): hauteur verrouillée 100dvh, pied toujours visible
La diapositive ne grandit plus au-delà de l'écran: plus de barre
de défilement de page, « Ouvertes »/« Fermées »/« EN DIRECT »
toujours affichés au-dessus de la navigation des diapos; cartes
tronquées en fondu + ligne « +N autres cartes en vol »; mode
compact sous 760px de hauteur (TV 720p).
2026-10-11 20:48:46 -04:00
Spark 16f03184bf feat(nav): refonte du layout — barre latérale repliable, topbar mince (issue #16)
Retour de Bruno sur la 0.15.0 (disposition pas conviviale, trop
de place, mobile inchangé): la barre horizontale disparaît.
Barre latérale groupée (Principal/Rituels/Pilotage/Outils),
repliable en rail d'icônes mémorisé sur desktop, tiroir
hors-canvas sur mobile; topbar de 52px (fil d'Ariane + actions).
2026-10-11 20:48:46 -04:00
Spark 6fdf39959c chore(release): Kanbante 0.15.0 — header unifié desktop/mobile
CI / test (push) Successful in 1m54s
2026-10-11 20:15:23 -04:00
Spark 18e1dd0f3d test(0.15.0): header partagé, états actifs, exceptions wall — 5 tests, 170 au total 2026-10-11 20:15:23 -04:00
Spark 58204e621e fix(forecast): semaines calées sur le référentiel UTC du journal
Les horodatages board_events sont en UTC (SQLite): une fermeture
en soirée locale tombait hors des semaines locales et faussait
le débit hebdomadaire.
2026-10-11 20:15:23 -04:00
Spark 2fde103e93 feat(nav): header unifié desktop/mobile (issue #16)
Partiel _header.html unique pour les 19 pages (fin des copies qui
dérivaient): desktop une ligne — primaires + menu Plus + Ctrl K,
page active déduite du gabarit dans render(); mobile ≤900px
burger + panneau ≥44px, Échap/clic extérieur; wallboard sans
header et triage sur le même partiel. nav.js partagé.
2026-10-11 20:15:23 -04:00
Spark 24a1ebb9a6 chore(release): Kanbante 0.14.0 — branche depuis la carte, santé du flux
CI / test (push) Successful in 1m49s
2026-10-11 14:49:38 -04:00
Spark 4c807a4344 test(0.14.0): branche depuis la carte, dwell/zombies — 8 tests, 165 au total 2026-10-11 14:49:38 -04:00
Spark 6453b53df4 feat(health): santé du flux — goulots et cartes zombies (issue #9)
app/flow.py: séjours par colonne reconstruits du journal,
médianes, colonne la plus lente; zombies = aucune activité
(journal/Gitea) depuis KANBANTE_ZOMBIE_DAYS (30). Pages /health
et /health/{o}/{r} + actions rapides (commenter/backlog/fermer).
2026-10-11 14:49:38 -04:00
Spark 1932d11457 feat(branch): créer la branche depuis la carte (issue #3)
slugify + branch_name_for (issue-<n>-<slug>, reconnu par #1/#2);
client create_branch; POST /api/issues/{o}/{r}/{n}/branch (nom
libre validé, idempotent si existante); la carte passe en En
cours, journalisée source branch, étiquette synchronisée;
formulaire dans la section Code lié du panneau détail.
2026-10-11 14:49:38 -04:00
110 changed files with 19524 additions and 919 deletions
+27
View File
@@ -0,0 +1,27 @@
# Kanbante — exemple d'environnement (copier en .env, jamais versionné)
# Infrastructure (un seul Gitea pour dev et prod, hors renommage)
GITEA_URL=https://git.dracodev.net
GITEA_TOKEN=
# Authentification locale
KANBANTE_AUTH_USER=
KANBANTE_AUTH_PASSWORD=
KANBANTE_REQUIRE_AUTH=0
# Noms publics dracodev — défauts = noms OFFICIELS (prod).
# Environnement dev : déclarer ici les noms en -dev, sans toucher au code.
KANBANTE_PUBLIC_URL=https://kanbante.dracodev.net
DRACO_SSO_URL=https://og.dracodev.net
DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
DRACO_TIKTAK_URL=https://tiktak.dracodev.net
AGENT_URL=https://agent.dracodev.net
# Exemples dev (à décommenter sur l'hôte dev) :
# KANBANTE_PUBLIC_URL=https://kanbante-dev.dracodev.net
# DRACO_SSO_URL=https://og-dev.dracodev.net
# DRACO_FLOWDECK_URL=https://flowdeck-dev.dracodev.net
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
# AGENT_URL=https://agent-dev.dracodev.net
# Journal d'événements du service d'agents (signé HMAC)
# AGENT_EVENEMENTS_URL= # vide = dérivé de AGENT_URL (/api/v1/evenements)
AGENT_EVENEMENTS_CLE=
# Pont SSO draco_session (émis par ObsiGate ; vide = inerte)
DRACO_SESSION_SECRET=
DRACO_SSO_MACHINE_KEY=
+981
View File
@@ -1,5 +1,986 @@
## 0.37.3 — 2026-10-28 — Programme Fusion : menu barre, alertes unifiées, recherche partout
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CORS bornée à `/api/events`.
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), nouvel endpoint JSON `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
# Changelog
## 0.37.2 — 2026-10-28 — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
- **Barre d'apps** : vendange de `dracodev-commons` v0.1.6 — dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
## 0.37.1 — 2026-10-26
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
Carte fusion #64. Le pied du panneau Spark (`static/js/draco-panneau.js`)
affiche désormais la ligne discrète et permanente « Service d'agents
vX », comme la console pleine page. La version est lue **en direct**
dans la santé du service (`GET /healthz` de l'origine que le panneau
connaît déjà), à l'ouverture du panneau, avec cache de session — jamais
une valeur codée en dur ; service injoignable ou panneau non configuré
= rien d'affiché, aucune erreur visible.
## 0.37.0 — 2026-10-26
### Ajouté — tri « En attente de revue » sur le dashboard
Demande de Bruno : pouvoir ordonner les projets — section
**Épinglés** en tête — par ce qui attend sa validation.
- Nouveau critère dans le sélecteur de tri du dashboard,
en première place après « Par défaut » : **🔍 En attente
de revue ↓** — les projets s'ordonnent par le nombre de
cartes dans la colonne de validation (clé `review` de
l'espace), décroissant.
- Le compteur était déjà calculé pour les chips de
progression des cartes : il est maintenant exposé en
`data-review` sur chaque carte projet et consommé par le
moteur de tri partagé — le critère s'applique à **toutes
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
avec ou sans regroupement par propriétaire, et la
préférence est mémorisée comme les autres tris.
- Les critères existants (activité Gitea, activité kanban,
issues ouvertes, nom) sont inchangés.
### Corrigé — sens des tris numériques du dashboard
Le contrôle en navigateur réel a révélé que le tri
« Issues ouvertes ↓ » triait en réalité **croissant**
depuis son introduction : le comparateur partagé inversait
les clés numériques. Les modes numériques (`open` et le
nouveau `review`) trient maintenant en soustraction de
clés — le plus grand compteur en premier, comme l'annonce
le « ↓ ». Vérifié sur les données réelles : Épinglés
ordonnés 196, 95, 32, 5, 4 en attente de revue.
## 0.36.0 — 2026-10-26
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
Le service d'agents sait proposer et appliquer des déplacements
de cartes par approbation (`kanbante.move_card`), mais le serveur
MCP de Kanbante n'exposait en écriture que `create_issue` et
`add_comment` : un déplacement approuvé aurait échoué faute
d'outil. Le trou est fermé.
- **Nouvel outil `move_issue`** (paramètres `owner`, `repo`,
`number`, `column`) : déplace une carte vers une autre colonne
en réutilisant le service de déplacement de l'interface
(`_do_move_card`, extrait de `POST /api/move` sans changement
de comportement) — board local, journal des événements
(source « mcp »), émission vers le journal du service
d'agents et miroir d'étiquette.
- **Étiquette kanban remplacée, les autres préservées** : le
déplacement retire l'ancienne étiquette `kanban:*` reconnue et
pose celle de la colonne cible ; les étiquettes libres de
l'issue (ex. « bug ») ne sont jamais touchées.
- **Colonne souple** : la clé (`review`), l'étiquette de forge
(`kanban:review`, alias d'espace compris) et le libellé
français affiché (« Révision », « À faire », « En cours »…)
sont acceptés ; les libellés viennent de la définition des
colonnes de l'espace, pas d'une table figée.
- **Garde doctrine** : la cible `done` (« Terminé ») est
**refusée avant toute écriture**, avec une erreur explicite —
Terminé appartient à Bruno dans son interface, les agents
s'arrêtent en Révision.
- **Erreurs propres** : colonne inconnue (avec la liste des
colonnes valides), dépôt ou issue inconnu, pull request
refusée comme n'étant pas une carte.
- Même garde que les autres outils /mcp (session, Basic ou
jeton d'API `kbt_`).
## 0.35.0 — 2026-10-26
### Ajouté — jalons en écriture depuis la page Projet (#26)
Seconde et dernière carte du backlog de la feuille de
route « Gestion de projets » : les jalons n'étaient que lus
dans Kanbante, ils s'écrivent maintenant — la forge reste
la source de vérité, rien n'est stocké localement.
- **Créer** un jalon (titre, description et échéance
optionnelles) depuis la page Projet, formulaire
« ➕ Nouveau jalon ».
- **Modifier** titre et échéance d'un jalon ouvert
(formulaire déplié par jalon ; échéance vidée = échéance
retirée, par un `null` explicite côté forge).
- **Fermer** un jalon, avec confirmation (ses issues
restent ouvertes).
- Écritures dans la forge via son API (`POST/PATCH
.../milestones`), CSRF vérifié, échéance invalide
refusée proprement **avant** toute écriture ; le board
filtré et le bandeau de progression reflètent le nouveau
jalon au chargement suivant, sans redémarrage.
- Échéance envoyée à **midi UTC** : Gitea renvoie les dates
dans le fuseau de son instance — à minuit UTC, l'échéance
affichée reculait d'un jour (attrapé au smoke réel).
- Hors périmètre (assumé par l'issue) : supprimer un jalon,
réassigner les issues entre jalons.
## 0.34.0 — 2026-10-26
### Ajouté — vue « En vol partout » /inflight (issue #24)
Dernière carte du backlog de la feuille de route « Gestion
de projets », première du classement big picture (pilotage
quotidien des agents avant gestion de projet).
- **Page /inflight** : tout l'encours (colonnes en vol de
l'espace — En cours, QA, Révision par défaut) **tous
projets actifs confondus, regroupé par colonne** — là où
/focus groupe par projet et /my par assignation.
- Chaque carte : projet (lien board), titre, étiquettes,
assigné, et **âge dans la colonne** (badge ⏳, teinté
selon les seuils d'ancienneté) — les groupes sont **triés
par âge décroissant** : les plus vieilles cartes, le vrai
signal, en premier.
- En-tête : total en vol + compteurs par colonne ;
**limite WIP globale** affichée quand des projets en
définissent (somme par colonne).
- Entrées : menu latéral **Pilotage → 🛩️ En vol** et
palette Ctrl+K. Rafraîchissement doux par le SSE
`/api/stream` comme les autres vues (fermer une issue la
retire au rechargement).
- Réutilise le pipeline du Focus (`_collect_focus_groups`,
`_card_ages`) : aucun appel forge supplémentaire.
## 0.33.0 — 2026-10-26
### Ajouté — filtrage professionnel sur « Mes issues » (/my)
Demande de Bruno : retrouver vite une issue parmi toutes
celles qui lui sont assignées (467 ouvertes au moment du
test réel).
- **Barre de filtres** en carte, dans la langue visuelle du
compositeur 0.31.1 : recherche texte plein champ
(titre, projet, #numéro, étiquettes — insensible aux
accents et à la casse, raccourci « / », Échap pour
effacer), listes **Projet** et **Étiquette** avec
compteurs, **chips de colonnes** colorées avec compteurs
(multi-sélection), **tri** (activité récente, plus
anciennes, titre, projet).
- **Performance** : filtrage 100 % côté client sur des
attributs précalculés côté serveur — aucun aller-retour
réseau par frappe, application en requestAnimationFrame.
Compteur en direct (« 24 sur 467 issues »), compteurs de
section mis à jour, état vide propre avec réinitialisation
en un clic.
- **Persistance** : projet, colonnes, étiquette et tri sont
retenus en localStorage d'une visite à l'autre (la
recherche texte reste éphémère) ; bouton « Réinitialiser
les filtres » dès qu'un filtre est actif.
## 0.32.2 — 2026-10-25
### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête
Sur kanbante-dev, le mode console du panneau était une page morte
et les liens inter-apps n'étaient pas tous en dev. Couche fautive
trouvée dans `app/config.py` : la dérivation de la 0.32.1 ne
couvrait que les URL sœurs depuis `AGENT_EVENEMENTS_URL` — jamais
`agent_url` lui-même. Sur la dev, où seul le journal est déclaré
en `-dev`, le panneau recevait donc le défaut prod
`https://agent.dracodev.net` : iframe de console pointant un agent
prod pas encore installé, donc injoignable. Et aucune résolution
ne tenait compte de la requête : sans variable d'environnement,
un hôte public en `-dev` retombait entièrement sur la prod.
Enfin, aucune CSP n'existait : l'origine de l'agent n'était
énoncée nulle part.
- **Résolution par requête** (`resoudre_urls_draco` dans
`app/config.py`, consommée par `render()` dans `app/main.py`)
pour tout ce qui est rendu à une requête — liens de la barre
(`draco_urls`), source de l'iframe du panneau
(`DRACO_PANNEAU_CONFIG.api`) et origine agent de la CSP — dans
l'ordre : 1) la variable explicite de l'installation,
prioritaire et inchangée (`DRACO_SSO_URL` pour ObsiGate,
`DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
`KANBANTE_PUBLIC_URL` pour Kanbante elle-même, `AGENT_URL`
pour l'agent) ; 2) l'hôte public de la requête, motif fermé
`^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>\.dracodev\.net$`
(suffixe optionnel : vide en prod, `-dev` en dev), appliqué à
toutes les sœurs et à l'agent — jamais de suffixe inventé
depuis un hôte arbitraire ; hors motif (LAN, IP), étape
suivante ; 3) la dérivation par les adresses de l'agent connues
en configuration, **élargie** : URL d'événements **ou** base du
service, selon ce que l'installation déclare ; 4) le défaut
officiel.
- **Base de l'agent dérivée** (contextes sans requête aussi) :
sans `AGENT_URL` explicite, si l'adresse connue de l'agent
(événements ou base) suit `agent<SUFFIXE>.dracodev.net`,
`agent_url` devient `https://agent<SUFFIXE>.dracodev.net` au
lieu de rester au défaut prod.
- **CSP nouvelle** : chaque page rendue énonce
`Content-Security-Policy: frame-src 'self' <origine agent>` —
exactement l'origine résolue pour cette même réponse, celle de
l'iframe de la console (seule iframe de l'application).
- Les contextes sans requête gardent la résolution par
configuration, inchangée sinon. Aucun SSO ajouté : Kanbante
n'en a pas par conception ; périmètre limité à ses liens et à
son panneau.
- Tests : requête `Host: kanbante-dev.dracodev.net` sans aucune
variable → barre, iframe et CSP entièrement en `-dev` ; hôte
prod → noms officiels ; hôte LAN → repli configuration/défauts ;
explicite gagne sur l'hôte ; base de l'agent dérivée du seul
journal et dérivation élargie à la base du service ; contextes
sans requête inchangés. Suite complète : 278 tests réussis.
- **Déploiement** : cette version part avec le prochain
redéploiement Docker (la 0.32.1 n'a jamais été redéployée) —
preuve en direct **en attente du redéploiement Docker de
Bruno**, la version vivante sur kanbante-dev reste l'ancienne.
## 0.32.1 — 2026-10-25
### Corrigé — URL sœurs de la barre dérivées de l'environnement de l'agent
Sur la dev, le menu inter-apps pointait vers les noms **prod**
des apps sœurs : seul `AGENT_EVENEMENTS_URL` y est posé (hôte
`agent-dev.dracodev.net`), et chaque URL sœur retombait sur son
défaut officiel faute de variable explicite — il aurait fallu
déclarer quatre variables de plus dans le `.env` dev.
- **Résolution des URL sœurs** dans `app/config.py` (les
gabarits et le JS de la barre consomment `draco_urls`
inchangé), dans l'ordre : 1) la variable explicite —
`DRACO_SSO_URL` (ObsiGate), `DRACO_FLOWDECK_URL`,
`DRACO_TIKTAK_URL`, `KANBANTE_PUBLIC_URL` (Kanbante
elle-même) — prioritaire, contrat inchangé ; 2) **dérivation
d'environnement** depuis l'origine de l'agent telle que
l'app la connaît déjà (`AGENT_EVENEMENTS_URL`) : si son
hôte est de la forme `agent<SUFFIXE>.dracodev.net`
(suffixe vide ou, typiquement, `-dev`), l'URL sœur devient
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`,
`flowdeck`, `tiktak`, `kanbante`) ; hôte hors motif (autre
domaine, LAN, IP) = **aucune dérivation**, jamais de lien
deviné ; 3) le défaut officiel (prod), comme avant.
- L'URL Gitea ne change pas.
- Tests : avec le seul `AGENT_EVENEMENTS_URL` en `-dev`, les
quatre URL sœurs servies sont en `-dev` ; une URL explicite
gagne sur la dérivation ; hôte d'agent hors motif et aucun
environnement posé restent aux défauts officiels
(non-régression).
## 0.32.0 — 2026-10-25
### Refactor — noms publics dracodev par configuration (dev/prod)
Aucun nom public dracodev n'est plus codé en dur dans le code
vivant (gabarits, JS statique) : tout passe par `app/config.py`,
dont les défauts sont les noms **officiels** (prod). L'environnement
dev déclare ses noms en `-dev` dans son `.env`, sans toucher au code.
- **Nouvelles variables** : `KANBANTE_PUBLIC_URL` (propre URL
publique : lien de la barre + `return_to` SSO), `DRACO_FLOWDECK_URL`
et `DRACO_TIKTAK_URL` (liens de la barre commune ; le lien ObsiGate
réutilise `DRACO_SSO_URL`, déjà existant), `AGENT_URL` (panneau
Spark/console). `AGENT_EVENEMENTS_URL` se dérive désormais de
`AGENT_URL` quand il n'est pas posé explicitement (l'override
compose existant garde la priorité).
- **Barre commune** (`_draco_bar.html`) : les quatre liens d'apps
viennent de `draco_urls` injecté par `render()` ; le panneau
reçoit l'adresse du service via `window.DRACO_PANNEAU_CONFIG`
injecté avant `draco-panneau.js` (chargé avec le cache-busting
existant `?v={{ version }}`). `draco-panneau.js` ne contient plus
aucun nom : sans injection, il se déclare non configuré.
- **`login.html`** : le lien « Se connecter avec ObsiGate (SSO) »
est construit depuis la configuration (`sso_authorize_url`).
Note : ce lien SSO livré de longue date (Fusion #27) contredit le
document d'installation dev affirmant « pas de SSO par conception »
pour Kanbante — comportement existant conservé tel quel, juste
rendu configurable ; la contradiction documentaire est signalée,
pas tranchée ici.
- `git.dracodev.net` inchangé : infrastructure (un seul Gitea,
`GITEA_URL` / `KANBANTE_GITEA_URL` côté sync), hors renommage dev/prod.
- Tests barre/SSO mis en cohérence via la configuration (défauts
officiels + variantes `-dev`), `.env.example` créé, README et
`docker-compose.yml` documentent chaque variable touchée.
## 0.31.1 — 2026-10-23
### Corrigé — compositeur de création refait
Retour de Bruno sur la 0.31.0 : la barre de création
n'était ni élégante ni professionnelle, et le bouton
« + Description » pouvait rester sans effet (écouteur
JavaScript non enregistré selon l'état du cache ou des
scripts de la page).
- **Compositeur en carte** : en-tête « Nouvelle issue »
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
nom personnalisé de l'espace respecté), champ titre
pleine largeur avec anneau de focus, bouton primaire
« Créer l'issue ».
- **Description en `<details>` natif** : « Ajouter une
description ▾ » déplie la zone texte sans aucun
JavaScript — impossible à casser par un script en
erreur ou un fichier en cache. Aide intégrée (Markdown,
Ctrl+Entrée pour créer, actif aussi depuis le titre).
- **Assets du board versionnés** (`?v=<version>` sur le
CSS et board.js du board) : un redéploiement invalide
le cache navigateur au lieu de servir un mélange
d'anciennes et nouvelles pièces.
- Vérifié en navigateur réel (Chromium) sur le vrai
board : captures fermé/ouvert contrôlées visuellement,
clic sur le libellé, zone visible et éditable.
## 0.31.0 — 2026-10-23
### Ajouté — description éditable, pièces jointes, clic fiable
Retours de Bruno : impossible d'ajouter une description
après création, aucun moyen de joindre des fichiers comme
dans Gitea, et clic sur les cartes capricieux.
- **Titre et description éditables** dans le tiroir de
détail (bouton « ✏️ Modifier le titre et la description ») :
l'enregistrement écrit directement dans l'issue de la
forge (PATCH), le journal consigne « edited ✏️ », et la
carte du board reflète le nouveau titre sans recharger.
- **Description dès la création** : le formulaire « Nouvelle
issue » gagne un bouton « + Description » qui déplie un
champ texte transmis à la création.
- **Pièces jointes d'issues** (Gitea) : dans le tiroir,
liste des fichiers (nom, taille, date), **ajout** par le
sélecteur de fichier (25 Mio max), **téléchargement**
proxifié par Kanbante (le jeton ne quitte jamais le
serveur ; suit le `browser_download_url` de Gitea, dont
l'endpoint d'asset ne renvoie que la fiche JSON — attrapé
au smoke réel), **suppression** avec confirmation. Sur
GitHub, l'API ne gère pas les pièces jointes d'issues :
l'écran l'explique et renvoie à l'interface web.
- **Clic sur les cartes fiabilisé** : l'ouverture du tiroir
passe en **délégation d'événement** — avant, l'écouteur
était posé carte par carte au chargement ; le premier
rafraîchissement temps réel remplaçait les fragments et
les cartes devenaient muettes. Le seuil anti-glisser
(6 px) et l'exclusion liens/boutons sont conservés, et
les cartes affichent un curseur pointeur.
## 0.30.0 — 2026-10-23
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
Demande de Bruno : pouvoir personnaliser, retirer et ajouter
des colonnes (Backlog, À faire, En cours, …) et configurer le
mapping label/colonne depuis l'interface web.
- **Colonnes par espace** (Administration → Colonnes, ou le
bouton 🧱 Colonnes sur un board) : ajouter une colonne
(insérée avant Terminé), la renommer (le nom affiché est
libre, émojis compris), changer sa couleur, la monter ou la
descendre, la supprimer — ses cartes sont alors déplacées
vers une colonne de destination choisie. Réinitialisation
aux six colonnes historiques en un clic. Chaque espace a SES
colonnes.
- **Clés immuables** : une colonne garde sa clé interne
(`en_developpement`) quoi qu'on renomme — les cartes, les
limites WIP et les règles y restent rattachés. Backlog et
Terminé sont des colonnes système (renommables, non
supprimables) : défaut des cartes neuves, et issues fermées.
- **Mapping étiquette ↔ colonne configurable** : chaque
colonne de flux a son **étiquette mappée** (défaut
`kanban:<clé>`) posée sur l'issue à chaque déplacement
(miroir), plus des **alias** reconnus à l'adoption — par
exemple déclarer `kanban_a_faire` (convention Flowdeck) en
alias de la colonne À faire fait adopter cette colonne par
le board. Adoption : exactement une étiquette reconnue,
comme avant.
- **Focus / Wallboard** : chaque colonne déclare si elle y
figure (case « Dans Focus / Wallboard ») ; les nouvelles
colonnes y sont par défaut.
- Sans personnalisation, tout est strictement identique à
avant (définitions semées paresseusement depuis les valeurs
historiques). Le triage au clavier garde ses touches
historiques (1 = À faire, 2 = En cours, …).
## 0.29.0 — 2026-10-23
### Modifié — panneau Spark V3 : la console en surimpression pleine page
Le bouton « Console ↗ » du panneau Spark (demande de Bruno) ne part
plus en nouvel onglet : la console web du service d'agents
(https://agent.dracodev.net/) s'ouvre désormais **dans la même
page**, en surimpression qui recouvre tout l'écran avec la même
animation d'ouverture que la barre latérale (panneau vendangé
`draco-panneau.js`, recopié octet-identique dans les 4 apps ; les
styles de la surimpression sont injectés par le panneau lui-même).
La barre de la surimpression offre les deux sorties : « Mode
sidebar » referme la console et rouvre l'agent en barre latérale,
« Fermer l'agent » ferme la surimpression et le panneau. La
discussion affichée et l'app d'origine (`?disc=<id>&app=kanbante`)
passent comme avant à la console. Côté service d'agents : la
console autorise l'encadrement par les 4 apps (`frame-ancestors`,
service 0.20.0). Même livraison : TikTak 0.60.0, Flowdeck 7.81.0,
ObsiGate 2.77.0. **Redéploiement Docker requis** pour l'activer.
## 0.28.1 — 2026-10-22
### Ajouté — panneau Spark : bouton « Console ↗ »
Bouton « Console ↗ » dans la barre des discussions du panneau
Spark (demande de Bruno) : ouvre la console web du service
d'agents (https://agent.dracodev.net/) dans un nouvel onglet,
sur la discussion actuellement affichée (`?disc=<id>`, URL
construite au clic) et avec l'app d'origine (`?app=kanbante`,
déduite du sous-domaine). Panneau recopié octet-identique dans
les 4 apps (TikTak 0.59.1, Flowdeck 7.80.1, Kanbante 0.28.1,
ObsiGate 2.76.1). **Redéploiement Docker requis** pour l'activer.
## 0.28.0 — 2026-10-22
### Ajouté — émission d'événements vers le journal du service d'agents (Lot 2)
Kanbante émet désormais ses changements de colonne au journal
d'activité cross-app du service d'agents (contrat ACC-0.2) :
`kanbante.card.moved` pour tout déplacement réel d'une carte —
glisser-déposer de l'interface, adoption d'une étiquette
`kanban:*` posée dans la forge, fermeture/réouverture venue de la
forge (webhook ou réconciliation à l'affichage), fusion de PR,
création de branche — et `kanbante.card.review_requested` quand
une carte entre en colonne Révision. Enveloppe signée HMAC-SHA256
avec la clé propre à Kanbante (`AGENT_EVENEMENTS_CLE`, URL
réglable par `AGENT_EVENEMENTS_URL`) ; émission au mieux, en
arrière-plan : inactive sans clé, et une panne du service
d'agents n'affecte jamais un déplacement. Aucun changement de
comportement visible. Redéploiement Docker requis.
## 0.27.0 — 2026-10-22
### Ajouté — panneau Spark v2 (Lot 1)
Le panneau vendangé passe en v2, à l'identique dans les 4 apps :
réponses de l'agent en **Markdown rendu** (HTML du service
d'agents 0.10.0, sanitizé côté serveur, rapports de missions
compris), **largeur réglable** persistée, **thème clair/sombre**
aligné sur l'app hôte ou le système, et **discussions façon
Muse** : discussion principale perpétuelle + parallèles
créées/archivées/supprimées depuis le tiroir du panneau.
Redéploiement Docker requis.
## 0.26.0 — 2026-10-21
### Ajouté — panneau Spark intégré
Le bouton Spark de la barre commune ouvre désormais le panneau de
l'agent commun (conversation, Aujourd'hui, approbations, missions),
comme dans TikTak et Flowdeck : assets `draco-panneau.js/css`
vendangés à l'identique, action `spark` branchée sur
`DRACO_PANNEAU.toggle()`. Redéploiement Docker requis (avec le reste
de la 0.26.0).
### Ajouté — écritures MCP minimales et sûres (service d'agents)
Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service
d'agents (jeton `kbt_` posé en production) peut désormais créer
des cartes et commenter, et rien d'autre : aucune suppression,
aucun changement d'état.
- **`create_issue`** — arguments : `owner` (chaîne, requis),
`repo` (chaîne, requis), `title` (chaîne, requis), `body`
(chaîne, optionnel), `column` (optionnel, une des colonnes du
board, `backlog` par défaut). Réutilise le service de création
existant (`_do_capture` → `create_issue` du client de forge,
Gitea ou GitHub selon la connexion qui détient le dépôt) : la
carte est placée sur le board et journalisée (source `mcp`).
Réponse JSON : `number`, `title`, `html_url`, `repo_full_name`,
`column`.
- **`add_comment`** — arguments : `owner`, `repo`, `number`
(entier), `body` — tous requis. Réutilise `create_comment` du
client de forge (mêmes validations que l'API web : corps non
vide, 10 000 caractères max) et journalise l'événement
`commented` (source `mcp`). Réponse JSON : `id`, `body`,
`html_url`, `repo_full_name`, `number`.
- **Garde inchangée** : les deux outils passent par le même
`/mcp` et le même middleware que les lectures — session,
Basic ou `Authorization: Bearer kbt_…` ; un jeton ne
déverrouille toujours pas l'interface web, et sa révocation
bloque aussitôt les écritures. Erreurs métier au format
existant du serveur (résultat `isError` avec le message).
- `GET /mcp` déclare désormais `read_only: false` et la liste
`write_tools` (honnêteté de la découverte).
- **Redéploiement Docker de Kanbante à la charge de Bruno**
pour activer cette version en production.
## 0.25.0 — 2026-10-21
### Ajouté — jetons d'API personnels pour les surfaces machine (MCP)
Depuis la 0.24.0, tout Kanbante exige une authentification (session
ou Basic) — y compris `/mcp`. Le service d'agents s'y faisait refuser
en 401 (vu en production le 2026-10-21, panneau Spark : « Source
Kanbante injoignable »). Il manquait une identité machine propre.
- Table `api_tokens` : jetons personnels préfixés `kbt_`, seule
l'empreinte SHA-256 est conservée ; création, liste et révocation
dans **Mon profil → Jetons d'API (MCP)**, texte clair affiché une
seule fois à la création.
- Le middleware honore `Authorization: Bearer kbt_…` **uniquement**
sur `/mcp` et `/api/` : un jeton ne déverrouille jamais l'interface
web. Révocation et désactivation du compte ont effet immédiat ;
`last_used_at` journalise l'usage.
- Usage visé : créer un jeton « service-agents » et le poser en
`AGENT_MCP_KANBANTE_KEY` dans le `.env` du conteneur agent.
## 0.24.2 — 2026-10-21
### Corrigé — boîtes d'édition blanches dans l'administration (#29)
Signalé par Bruno (captures) : le champ « Nouveau mot de passe »
(Administration), la zone « Sélection de projets » (Espaces) et
les champs « Valeur du panneau » (Paramètres) s'affichaient en
**blanc**. Il n'existait aucune règle de base pour les champs de
saisie : seuls les champs couverts par un contexte précis
(`.login-form`, `.capture-form`, `.admin-form` pour text/password/
select seulement) étaient sombres — les textarea et tout champ
hors de ces contextes gardaient le fond du navigateur.
Correctif dans `style.css` : une **règle de base unique en
spécificité zéro** (`:where(...)`) — fond `--surface-2`, texte
`--text`, bordure `--border` — pour `input`, `textarea` et
`select`, qui comble les vides sans jamais écraser les règles de
contexte existantes ; plus `color-scheme: dark` pour les
contrôles natifs. Styles calculés vérifiés sur les trois pages
(fond identique aux champs de référence, palette de commandes
inchangée).
## 0.24.0 — 2026-10-21
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
Réalise l'analyse du 2026-10-21 (décisions D1 à D6 appliquées
selon les recommandations). Aucune configuration obligatoire :
au premier démarrage, la connexion Gitea historique est créée
depuis l'environnement, l'espace **Principal** la contient, et
le compte d'environnement devient l'administrateur initial
(mot de passe haché en base). Migration automatique des bases
existantes (portées (1, 1) partout).
- **Connexions (P0/P1)** — une forge = une connexion (Gitea ou
**GitHub**, nouveau client complet : dépôts, issues, étiquettes
par nom, jalons, PR, archivage). Jeton **chiffré** (Fernet) en
base, jamais réaffiché (masque ••••). Registre de clients,
instantanés par connexion. Webhooks par connexion
(`/webhooks/{slug}`, signatures Gitea et GitHub) en plus du
webhook historique.
- **Utilisateurs (P2)** — comptes en base (PBKDF2), rôles
admin/membre, activation/désactivation (une session d'un
compte désactivé meurt aussitôt), journal d'audit admin.
Connexion par la base ; les identifiants d'environnement
restent un **secours** pour le compte opérateur. Liaison
**draco par utilisateur** (le pont SSO lie et ouvre la
session du bon compte ; sémantique mono-utilisateur 0.23
conservée tant qu'il n'y a qu'un compte). Page **/profile** :
infos, mot de passe, déliaison draco, connexions personnelles.
- **Espaces de travail (P3)** — l'unité de présentation :
connexions attachées, membres (admin d'espace / membre /
lecteur), sélection explicite de projets optionnelle.
**Board, notes, épingles du jour, journal : scopés par
espace** (le même projet peut être rangé différemment dans
deux espaces). Sélecteur d'espace dans l'en-tête (visible
dès qu'on a accès à plusieurs espaces), badge de connexion
sur les projets quand l'espace en a plusieurs. Panneau
**Paramètres** : l'environnement amorce, le panneau
gouverne — surcharges en base pour les réglages courants
(fenêtre d'activité, TTL, seuils zombies/âge, horaires des
rituels, seuil d'archivage…), provenance affichée.
- **Archives (P4)** — page **/archives** : archive **locale**
(masque le projet dans l'espace, forge intacte, réversible),
archivage **sur la forge** (confirmé, journalisé), purge de
l'état local de l'espace, export du récapitulatif Markdown,
file **« À archiver ? »** (inactifs depuis
`archive_suggest_days`, 180 j par défaut) avec archivage
local en lot. Les archives sortent du tableau de bord ;
l'onglet Archivés y gagne un lien « Gérer ».
### Limites assumées de cette version
- Les rituels d'arrière-plan (briefing, digest, règles,
serveur MCP) travaillent sur la portée historique
(connexion principale, espace Principal).
- `KANBANTE_MASTER_KEY` (optionnelle) : sans elle, une clé de
chiffrement est générée une fois et gardée en base.
## 0.23.3 — 2026-10-21
### Correctifs — barre commune v0.1.4 (retours de Bruno)
- **Assets de la barre versionnés** (`?v=` dans l'adresse) : sans
ça, le navigateur — surtout la webview de l'app bureau ObsiGate —
gardait l'ancien JavaScript et le menu du ✦ ne s'ouvrait pas,
alors que le serveur servait déjà le bon fichier.
- **Palier mobile élargi à 900 px** (commons v0.1.4) : entre 700
et 900 px, la barre complète ne tenait pas alors que Kanbante
est déjà en mise en page mobile — d'où le look différent des
autres apps sur la capture de Bruno. Décalage du cadre aligné.
## 0.23.2 — 2026-10-21
### Ajouté — menu des applications (barre commune v0.1.3)
- **Le ✦ de la barre ouvre le menu des 4 applications** (choix de
Bruno) : c'est le moyen de changer d'app en mobile, où les
onglets sont repliés — et il fonctionne aussi sur bureau. Le
logo est un peu plus gros (24 px). Menu construit par le JS
commun depuis les onglets (mêmes liens, icônes, badges) ;
fermeture au clic extérieur ou à Échap.
## 0.23.1 — 2026-10-21
### Correctifs — barre commune et barre latérale (retours de Bruno)
- **Barre d'apps pleine largeur** : elle était repoussée à droite
de la barre latérale par le `padding-left` du cadre ; elle est
maintenant fixe, pleine largeur, et le cadre entier est décalé
de sa hauteur (60 px, 52 px en mobile).
- **Barre commune v0.1.2** (dracodev-commons) : palier mobile
resserré selon la spec — app courante, alertes, Spark, avatar ;
rail, recherche, domaines et menu disparaissent au profit des
contrôles natifs de chaque app.
- **Animation du repli de la barre latérale** : durée et courbe
unifiées (0,26 s, `cubic-bezier(0.4, 0, 0.2, 1)`) entre le corps
et la barre, et retrait du `backdrop-filter` (fond déjà opaque
à 96 %) qui rendait l'animation de largeur saccadée.
## 0.23.0 — 2026-10-21
### Fusion — Kanbante rejoint l'accès centralisé (cartes #27, #28)
- **Barre d'apps commune dracodev** (ACC-1, #28) : snippet figé
vendangé de dracodev-commons v0.1.1 (partiel `_draco_bar.html`,
`draco-barre.css` / `draco-barre.js`), inclus par le cadre partagé
— Kanbante était la dernière des quatre applications sans la barre.
Le rail pilote le repli natif de la barre latérale, la palette
ouvre la recherche & commandes native ; alertes unifiées et Spark
annoncent leur arrivée avec la fédération.
- **Pont SSO `draco_session`** (ACC-3.2, #27) : le cookie émis par
ObsiGate est vérifié localement (`app/draco.py` — JWS HS256 au
secret partagé `DRACO_SESSION_SECRET`, révocation de l'émettrice
en cache 60 s) et converti en session Kanbante native par
`GET /auth/draco` ; bouton « Se connecter avec ObsiGate (SSO) »
sur l'écran de connexion. Kanbante étant mono-utilisateur, aucun
mapping de comptes n'est nécessaire. Inerte sans le secret.
- Tests : `test_draco_sso.py` (7) et `test_draco_barre.py` (3) ;
suite complète : 211 tests verts.
## 0.22.1 — 2026-10-20
### Correctif — colonne Terminé vide
- Les issues **fermées dans Gitea** mais suivies par le board
n'apparaissaient pas dans la colonne **Terminé** : la
réconciliation plaçait bien leur ligne locale en `done`, mais
l'affichage du board ne fusionnait que les issues **ouvertes**.
- Le board (page HTML et API JSON) inclut désormais les issues
fermées dont la ligne locale est en `done`, y compris dans la
vue par milestone.
- Test de régression ajouté ; suite complète : 201 tests verts.
## 0.22.0 — 2026-10-14
### Écran de connexion (demande de Bruno)
- Fini la fenêtre Basic Auth du navigateur : page **/login**
professionnelle (carte centrée, marque dégradée, erreur
claire, retour à la page demandée après connexion).
- Session par **cookie signé HMAC** (7 jours, HttpOnly,
SameSite=Lax) ; clé `KANBANTE_SESSION_SECRET` ou dérivée des
identifiants — changer le mot de passe invalide les sessions.
- L'en-tête **Basic reste accepté** pour les scripts ; les API
reçoivent 401, seuls les navigateurs sont redirigés. Lien
**⏻ Sortir** dans le pied de la barre latérale, `/logout`
réel. Protection CSRF sur le formulaire, redirection `next`
limitée aux chemins locaux.
### Barre latérale (demandes de Bruno)
- Le bouton **Replier** quitte le bas de la barre : c'est une
**icône seule, en haut, à droite du bouton recherche**.
- **Accordéon exclusif** : ouvrir une section ferme les
autres — une seule ouverte à la fois (état mémorisé dans
`kanbante-nav-open` ; la section de la page courante prime).
### Chantier B — instantanés d'arrière-plan (validé par Bruno)
- Un **rafraîchisseur** recalcule périodiquement les listes
coûteuses (index des dépôts, étoiles, issues `all` **et**
`open`, jalons — pour chaque projet actif) et les persiste
dans SQLite (table `snapshots`, écriture à chaque réponse du
client). `KANBANTE_SNAPSHOT_INTERVAL` (défaut 120 s, 0 = off).
- Au démarrage, le cache du client est **hydraté** depuis les
instantanés (servis périmés + rafraîchis en tâche de fond,
mécanique SWR) : la première page après un redémarrage ou un
redéploiement ne paie plus Gitea.
- Un **webhook Gitea** rafraîchit immédiatement les
instantanés du dépôt touché. La fraîcheur est affichée dans
le pied de la barre (« 🕐 à l'instant / il y a N min ») :
les données Gitea peuvent dater d'un intervalle au plus,
le journal local reste instantané.
- **Mesuré sur l'instance réelle** (via le proxy de Spark,
latences gonflées) : premier chargement à froid 9,4 s sans
instantanés → après redémarrage avec instantanés : Projets
0,31 s, Focus 0,16 s, Triage 0,07 s, Santé 0,22 s,
Prévisions 0,08 s.
## 0.21.0 — 2026-10-12
### Sélecteur de dépôt intelligent (Capture éclair, demande de Bruno)
- Le champ **Dépôt** de /capture propose les dépôts réels dès
la frappe : liste filtrée en direct, correspondance sur les
deux segments — « kan » suffit pour trouver
`Projets/kanbante`, « pr/kan » aussi. Au focus, épinglés (⭐)
et dépôts récents d'abord.
- **Complétion Tab segment par segment**, comme un shell :
« pr » Tab → `Projets/` (le séparateur est posé dès que le
nom d'organisation est complet), « kan » Tab → `kanbante`.
↑/↓ + Entrée et la souris choisissent dans la liste.
- **Validation douce** : un nom qui n'existe pas dans Gitea est
signalé sous le champ avant l'envoi.
- Nouvel endpoint `GET /api/repos` : liste **exhaustive**
(~130 dépôts chez Bruno — la palette Ctrl+K reste plafonnée
à 60 pour son propre usage), triée épinglés → récents →
archivés. Sélecteur dans `static/js/repo-picker.js`,
réutilisable par d'autres formulaires.
- Vérifié en Playwright sur les données réelles, geste exact
de Bruno inclus : `pr` Tab → `Projets/`, `kan` Tab →
`Projets/kanbante`.
## 0.20.1 — 2026-10-12
### Correctifs tri/regroupement du dashboard (rapportés par Bruno)
- **Tri par date des tableaux réparé** : le comparateur partagé
lisait les dates ISO avec `parseFloat` — il ne retenait que
l'année (« 2026 »), toutes les lignes d'une même année se
retrouvaient à égalité et la colonne « Dernière activité » (vue
Liste) triait dans un ordre arbitraire. La comparaison n'est
numérique que si la valeur entière est un nombre ; les dates
se comparent en chaînes ISO. Même piège écarté pour l'échéance
des Prévisions.
- **Tri et regroupement appliqués aux Épinglés** : la grille
épinglée était exclue des deux outils (choix de la 0.18.0) —
comme c'est la section regardée en premier, changer le Tri ou
cocher « Grouper par propriétaire » semblait ne rien faire.
Les quatre grilles sont maintenant traitées pareil.
- **Sens de premier clic utile** : « Dernière activité »,
« Écart » (Prévisions) et « Zombies » (Santé) trient
décroissant dès le premier clic (`data-first-dir`).
- Tri grille par dates comparé en instants réels (fuseaux
Gitea -04:00/-05:00), cartes sans activité en bas.
- Vérifié en Playwright sur les données réelles : ordres
strictement décroissant/croissant, regroupement et
dégroupement des Épinglés, aucune erreur JS.
## 0.20.0 — 2026-10-12
### Refonte de l'en-tête de la page Projets (demande de Bruno)
- **Titre sobre** : fini le « Bonjour 👋 Voici vos projets
Gitea… » — un « Projets » net, un sous-titre discret, et
l'en-tête occupe deux fois moins de hauteur qu'avant.
- **Bande KPI unifiée** : les 4 statistiques (projets actifs,
fermées · 30 j, déplacements · 30 j, temps de cycle moyen)
vivent dans UNE barre bordée à séparateurs fins, chiffres en
largeur tabulaire ; les indicateurs du journal mènent à la
page Activité au clic.
- **Barre d'outils unique** : onglets, filtre, tri,
regroupement et bascule Grille/Liste — auparavant éparpillés
sur trois rangées — sont alignés dans un seul cadre. Les
onglets deviennent un contrôle segmenté avec pastilles de
compte (plus de dégradé violet ni d'emojis 🟢💤🗄️), le filtre
passe en rectangle sobre.
- Aucun crochet fonctionnel touché (ids et liaisons Alpine
inchangés) ; responsive vérifié en captures 1600 px et
430 px sur les données réelles : aucun débordement, onglets
fonctionnels. Les tuiles `.stat-tile` restent utilisées par
le briefing quotidien.
## 0.19.0 — 2026-10-12
### Performance + navigation « Pilotage » (chantiers A + C, décidés par Bruno)
- **Parallélisation (A1)** : les pages inter-projets (dashboard,
/health, /forecast, /triage, /wall) attendaient chaque dépôt à
la queue leu leu ; elles utilisent maintenant un `asyncio.gather`
borné (16) — le patron que /focus appliquait déjà.
- **Cache stale-while-revalidate (A2)** : les listes Gitea
(dépôts, issues, jalons, étoiles) sont servies immédiatement
même périmées, et rafraîchies en tâche de fond. Fini le
« mur » de rechargement à chaque expiration du cache (30 s) :
seul le tout premier chargement après un démarrage paie le
coût complet. Les mutations vident le cache et désarment les
rafraîchissements en vol (aucune donnée périmée ressuscitée).
- **Prévisions refondues (C1)** : /forecast devient un tableau de
bord des jalons **tous projets** — une ligne par jalon (projet,
échéance, progression, ETA, **écart**, débit), triée par risque,
filtrable, colonnes triables ; le bloc jalon des cartes du
dashboard y pointe directement (ancres). Le débit par projet
reste en dessous, en tableau compact.
- **Santé refondue (C2)** : /health devient un tableau trié par
sévérité (pastille, ouvertes, zombies, goulot) ; la page santé
d'un projet gagne un **sélecteur de projet** pour basculer sans
repasser par l'index. Le tri des tableaux est mutualisé
(`static/js/tablesort.js`, dashboard inclus).
- Mesuré sur l'instance réelle (depuis le réseau de test) :
pages chaudes 0,04–0,16 s ; le premier chargement à froid
reste de quelques secondes — le chantier B (instantanés
d'arrière-plan) demeure le levier pour le supprimer, si voulu.
## 0.18.0 — 2026-10-11
### Nouveautés — vague 2, suite (4 cartes de Bruno)
- **Activité récente par projet** (#19) : ligne sur la carte du
dashboard calculée par UNE requête agrégée sur le journal —
« 📈 X fermées · Y déplacements cette semaine », ou « 💤 aucune
activité kanban depuis N j » pour les projets endormis.
- **Actions rapides sur la carte** (#23) : menu ⋯ sur chaque
carte — Board, ⚡ Capturer ici (la page capture accepte
`?repo=` et pré-remplit le dépôt), Triage du backlog, Santé,
Récit de sprint, Gitea. Clavier et tactile soignés (Échap,
clic extérieur).
- **Tri et regroupement** (#21) : tri par activité Gitea,
activité kanban, ouvertes ou nom ; regroupement par
propriétaire en sections repliables ; préférences mémorisées
par navigateur (localStorage).
- **Vue liste dense** (#22) : bascule Grille/Liste persistée —
tableau triable par colonne (Projet, Propriétaire, Ouvertes,
En vol, Prochain jalon, Santé, Dernière activité) réutilisant
les enrichissements des cartes, mêmes onglets et même filtre.
Note : le lien Triage du menu ⋯ est constant — la taille du
backlog « stats » du dashboard ne compte que les cartes placées,
pas le backlog réel ; la page Triage affiche l'état exact.
7 tests ajoutés (185 au total).
## 0.17.0 — 2026-10-11
### Nouveautés — vague 2 « Gestion de projets » (4 cartes de Bruno)
- **Prochain jalon sur la carte projet** (#17) : le jalon ouvert le
plus proche de son échéance s'affiche sur la carte du dashboard —
progression (compteurs Gitea), échéance en français, alerte si
en retard, et ETA du journal en regard avec signal « après
l'échéance » quand la prévision dépasse la date.
- **Pastille de santé par projet** (#18) : point vert/orange/rouge
sur chaque carte active, règles uniques partagées avec /health
(zombies, cartes coincées en QA/Révision, médiane du goulot),
info-bulle détaillée, clic vers la santé du projet.
- **Projets épinglés via les étoiles Gitea** (#20) : section
« ⭐ Épinglés » en tête du dashboard pour les dépôts étoilés ;
bouton ☆/⭐ sur chaque carte (PUT/DELETE /user/starred) — la
vérité vit dans Gitea, aucune table locale ; bascule sans
rechargement (stars.js). Échec de l'API : dashboard intact.
- **Page Projet** (#25) : `/project/{owner}/{repo}` — accueil du
projet : jalons ouverts avec progression/échéances/ETA, santé en
résumé (goulot, zombies), débit des prévisions, activité
récente du journal, notes du projet et actions (board, capture,
triage, santé, wallboard figé, Gitea). Le titre des cartes du
dashboard pointe désormais ici ; le board reste à un clic
(lien 📋 Board sur chaque carte et bouton principal).
7 tests ajoutés (178 au total).
## 0.16.1 — 2026-10-11
### Améliorations (retour de Bruno sur la barre latérale)
- Le bouton « Recherche & commandes » devient une **icône 🔍
seule**, placée **à droite du titre** dans l'en-tête de la barre
latérale (info-bulle et Ctrl+K inchangés) ; en rail replié, elle
passe sous le logo, toujours accessible.
- Les groupes du menu deviennent des **sections accordéon** :
en-têtes cliquables au look moderne (micro-libellés, chevron
animé), ouverture/fermeture en transition douce (technique
`grid-template-rows`), **état mémorisé par navigateur** appliqué
avant le premier rendu. La section de la page courante s'ouvre
toujours automatiquement. En rail replié, l'accordéon s'efface :
toutes les icônes restent visibles.
## 0.16.0 — 2026-10-11
### Nouveautés
- **Layout repensé** (issue #16, retour de Bruno sur la 0.15.0 :
disposition pas conviviale, trop de place, mobile inchangé) —
la barre de pilules horizontale disparaît au profit d'une
**barre latérale** : navigation groupée (Principal, Rituels,
Pilotage, Outils), bouton palette en tête, **repliable en rail
d'icônes** sur desktop (état mémorisé par navigateur), et
**tiroir hors-canvas** sur mobile (burger dans une topbar mince
de 52 px : fil d'Ariane + actions contextuelles seulement).
Le partiel `_header.html` reste l'unique source du cadre.
- **Wallboard** : hauteur verrouillée à l'écran (`100dvh`,
`overflow: hidden`) — **plus aucune barre de défilement de
page** ; le pied de diapositive (« ✅ Fermées aujourd'hui »,
« 📋 Ouvertes », « ● EN DIRECT ») reste **toujours visible**,
au-dessus de la zone réservée à la navigation des diapos ; la
liste des cartes se tronque avec un fondu et une ligne
« … +N autres cartes en vol » ; mode compact sous 760 px de
hauteur (TV 720p).
6 tests adaptés/ajoutés (171 au total).
## 0.15.0 — 2026-10-11
### Nouveautés
- **Header refait** (issue #16, créée et spécifiée par Bruno) :
les 19 copies de markup de header sont remplacées par un
**partiel unique `_header.html`**. Desktop : une seule ligne —
5 entrées primaires (Projets, Focus, Aujourd'hui, Recherche,
Capture), menu « Plus ▾ » pour les 9 autres, Ctrl+K à droite,
page active marquée (y compris dans « Plus »). Mobile
(≤ 900 px) : burger + panneau plein largeur, cibles ≥ 44 px,
fermeture Échap / clic extérieur. La page active est déduite du
gabarit dans `render()` (`active_nav`) — une seule source de
vérité. Le wallboard garde son plein écran sans header et la
session triage utilise le même partiel. `static/js/nav.js`
partagé, aucune dépendance ajoutée.
### Corrigé
- Prévisions : les semaines se calent désormais sur le référentiel
du journal (UTC, comme les horodatages `board_events`) — une
fermeture journalisée en soirée locale (déjà le lendemain en
UTC) tombait hors de toutes les semaines et faussait le débit.
5 tests ajoutés (170 au total).
## 0.14.0 — 2026-10-11
### Nouveautés
- **Créer la branche depuis la carte** (issue #3) : le panneau
détail propose `issue-<n>-<slug du titre>` (slug ASCII, 40 car.
max, nom libre accepté après validation git-ref) et la crée
dans Gitea depuis la branche par défaut
(`POST /api/issues/{o}/{r}/{n}/branch`). La carte passe en
« En cours » (journalisé, source `branch`, étiquette synchronisée)
et la branche est aussitôt reconnue par le code lié (#1) et par
l'automatisation de fusion (#2). Réponse idempotente si la
branche existe déjà.
- **Santé du flux** (issue #9) : `app/flow.py` reconstruit les
séjours par colonne depuis le journal (entrée → déplacement /
fermeture) : temps médian et moyen par colonne, colonne la plus
lente (goulot, ≥ 2 séjours). **Cartes zombies** : ouvertes sans
activité (journal ou Gitea) depuis `KANBANTE_ZOMBIE_DAYS`
(défaut 30). Pages `/health` (résumé global) et
`/health/{owner}/{repo}` (détail + actions rapides sur les
zombies : commenter « toujours pertinent ? », remettre au
Backlog, fermer — via les API existantes).
8 tests ajoutés (165 au total). **La feuille de route
« Différenciation » est complète : 15/15.**
## 0.13.0 — 2026-10-11
### Nouveautés
+27 -8
View File
@@ -23,6 +23,8 @@
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
- 🌿 **Branche depuis la carte** — le panneau détail crée `issue-<n>-<slug>` dans Gitea depuis la branche par défaut, déplace la carte en « En cours » et journalise ; la PR qui en naîtra sera reconnue automatiquement
- 🩺 **Santé du flux** — `/health` : temps médian par colonne (du journal), colonne la plus lente, et cartes zombies (aucune activité depuis `KANBANTE_ZOMBIE_DAYS`) avec actions rapides (commenter, remettre au backlog, fermer)
- 📥 **Mode Triage** — `/triage` : le backlog se vide carte par carte, plein cadre, au clavier (`1-5` colonnes, `L` étiquettes, `A` s'assigner, `X` fermer, `Entrée` suivante, `Z` annuler), progression et résumé de session ; décisions via les mêmes API que le board
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
@@ -31,7 +33,7 @@
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🤖 **Serveur MCP** — 7 outils de lecture (boards, focus, recherche, métriques) + 2 écritures minimales (`create_issue`, `add_comment`) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
- 📝 **Notes** — bloc-notes par projet
@@ -109,9 +111,17 @@ sudo systemctl enable --now docker
|---|---|---|
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
| `KANBANTE_PUBLIC_URL` | `https://kanbante.dracodev.net` | Propre URL publique de Kanbante (lien de la barre commune, `return_to` du lien SSO) — noms publics par configuration : défauts = noms officiels (prod), la dev déclare ses noms en `-dev` (ex. `https://kanbante-dev.dracodev.net`) |
| `DRACO_SSO_URL` | `https://og.dracodev.net` | Base d'ObsiGate, émettrice SSO (lien de barre ObsiGate, révocations, lien de connexion SSO) |
| `DRACO_FLOWDECK_URL` / `DRACO_TIKTAK_URL` | `https://flowdeck.dracodev.net` / `https://tiktak.dracodev.net` | Liens Flowdeck / TikTak de la barre commune |
| `AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents (panneau Spark + console, adresse injectée au JS par l'hôte) — sans variable explicite, dérivée de l'hôte d'`AGENT_EVENEMENTS_URL` s'il suit `agent<SUFFIXE>.dracodev.net` ; à chaque requête, l'hôte public (motif fermé `*.dracodev.net`) détermine l'environnement des URL rendues sauf variable explicite (0.32.2) |
| `AGENT_EVENEMENTS_URL` | *(dérivé de `AGENT_URL`)* | Journal d'événements du service d'agents ; ne se pose que pour surcharger la dérivation `<AGENT_URL>/api/v1/evenements` |
| `AGENT_EVENEMENTS_CLE` | *(vide)* | Clé HMAC signant les événements émis vers ce journal ; vide = émission désactivée |
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
@@ -129,6 +139,7 @@ sudo systemctl enable --now docker
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
| `KANBANTE_ZOMBIE_DAYS` | `30` | Jours sans activité après lesquels une carte ouverte est « zombie » sur `/health` |
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
@@ -183,6 +194,8 @@ kanbante/
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
| POST | `/api/issues/{o}/{r}/{n}/branch` | Créer la branche de la carte (`issue-<n>-<slug>` ou nom libre validé) et la passer en « En cours » |
| GET | `/health` · `/health/{o}/{r}` | Santé du flux : goulots (temps médian par colonne) et cartes zombies, global ou par projet |
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
@@ -205,15 +218,17 @@ kanbante/
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
| GET/POST | `/mcp` | Serveur MCP (JSON-RPC 2.0) : lectures + `create_issue` / `add_comment` |
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
## Brancher un client MCP
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
les lectures d'abord). Configuration type (transport HTTP) :
Kanbante expose un serveur MCP : lectures d'abord, plus deux
écritures minimales et sûres pour les agents — créer une carte et
commenter (aucune suppression, aucun changement d'état).
Configuration type (transport HTTP) :
```json
{
@@ -226,9 +241,13 @@ les lectures d'abord). Configuration type (transport HTTP) :
```
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
Kanbante est active, le client doit envoyer l'en-tête
`Authorization` correspondant.
`search_issues`, `get_activity`, `get_metrics`, `create_issue`
(`owner`, `repo`, `title`, `body` et `column` optionnels),
`add_comment` (`owner`, `repo`, `number`, `body`). Si
l'authentification Kanbante est active, le client doit envoyer
l'en-tête `Authorization` correspondant : Basic, ou un jeton
d'API personnel `Bearer kbt_…` (Mon profil → Jetons d'API) —
honoré uniquement sur `/mcp` et `/api/`, jamais sur le web.
## Activer le temps réel (webhook Gitea)
+68
View File
@@ -0,0 +1,68 @@
"""User accounts: password hashing and bootstrap (0.24.0, P2).
Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib) with a
per-user salt; the stored string carries its parameters
("pbkdf2$<iterations>$<salt_b64>$<hash_b64>") so the cost can
be raised later without invalidating existing hashes.
"""
import base64
import hashlib
import hmac
import secrets
from app import models
_ITERATIONS = 260_000
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, _ITERATIONS
)
return "pbkdf2${}${}${}".format(
_ITERATIONS,
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
)
def verify_password(password: str, stored: str) -> bool:
try:
scheme, iterations, salt_b64, hash_b64 = stored.split("$")
if scheme != "pbkdf2":
return False
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, int(iterations)
)
return hmac.compare_digest(digest, expected)
except Exception:
return False
def ensure_bootstrap_admin() -> dict | None:
"""Create the first admin from the env credentials.
Runs when the users table is empty and KANBANTE_AUTH_USER /
_PASSWORD are configured (decision D6): Bruno's existing
login becomes the initial database admin, with no lockout.
The env path keeps working as an emergency fallback either
way (see app.main auth).
"""
from app.config import get_settings
if models.count_users() > 0:
return None
settings = get_settings()
if not settings.auth_enabled:
return None
user_id = models.create_user(
settings.auth_user,
hash_password(settings.auth_password),
role="admin",
display_name=settings.auth_user,
)
return models.get_user(user_id)
+119
View File
@@ -0,0 +1,119 @@
"""Émission d'événements vers le journal du service d'agents (Lot 2).
Kanbante émet ses changements de colonne au journal d'activité
cross-app du service d'agents, au contrat ACC-0.2 : enveloppe JSON
``{id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}}`` signée HMAC-SHA256 du corps brut
(en-tête ``X-Draco-Signature``), avec la clé propre à Kanbante
(``AGENT_EVENEMENTS_CLE`` ; le service la connaît sous le nom
d'app « kanbante » dans ``AGENT_EVENEMENTS_CLES``).
Inactif tant que la clé n'est pas posée. Jamais bloquant, jamais
d'échec remonté au métier : l'envoi part dans un fil d'arrière-plan,
timeout court, exceptions avalées avec un avertissement au journal
local. L'appel sortant suit le patron de la maison (httpx, voir
``briefing.send_ntfy``).
"""
import hashlib
import hmac
import json
import logging
import threading
import uuid
from datetime import datetime, timezone
import httpx
from app.config import get_settings
logger = logging.getLogger("kanbante")
SOURCE_APP = "kanbante"
DELAI_S = 2.0
def actif() -> bool:
"""True quand l'émission est configurée (clé propre posée)."""
return bool(get_settings().agent_evenements_cle)
def construire_enveloppe(
type_: str,
ref: str,
label: str,
url: str,
domain: str = "pro",
actor: str = "user",
) -> dict:
"""L'enveloppe ACC-0.2 : une référence et un libellé, jamais le
contenu de l'objet."""
return {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"actor": actor,
"domain": domain,
"source_app": SOURCE_APP,
"object": {"ref": ref, "label": label, "url": url},
}
def signer(corps: bytes, cle: str) -> str:
"""Signature ACC-0.2 : HMAC-SHA256 (hexadécimal) du corps brut."""
return hmac.new(cle.encode(), corps, hashlib.sha256).hexdigest()
def _poster(url: str, corps: bytes, signature: str) -> bool:
"""POST l'enveloppe signée ; False (et un avertissement) sur
tout échec — le récepteur en panne ne doit rien casser ici."""
try:
reponse = httpx.post(
url,
content=corps,
headers={
"Content-Type": "application/json",
"X-Draco-Signature": signature,
},
timeout=DELAI_S,
)
if reponse.status_code >= 300:
logger.warning(
"Journal agent : réponse %s de %s", reponse.status_code, url
)
return False
return True
except Exception as exc:
logger.warning("Journal agent injoignable (%s) : %s", url, exc)
return False
def emettre(
type_: str,
ref: str,
label: str,
url: str,
domain: str = "pro",
actor: str = "user",
) -> threading.Thread | None:
"""Émettre un événement, au mieux, depuis un fil d'arrière-plan.
Retourne le fil lancé (les tests peuvent le joindre), ou None
quand l'émission n'est pas configurée. Ne lève jamais.
"""
settings = get_settings()
cle = settings.agent_evenements_cle
if not cle:
return None
enveloppe = construire_enveloppe(
type_, ref, label, url, domain=domain, actor=actor
)
corps = json.dumps(enveloppe, ensure_ascii=False).encode("utf-8")
fil = threading.Thread(
target=_poster,
args=(settings.agent_evenements_url, corps, signer(corps, cle)),
name="kanbante-agent-journal",
daemon=True,
)
fil.start()
return fil
+136
View File
@@ -0,0 +1,136 @@
"""Colonnes de board personnalisables par espace (0.30.0).
Avant : six colonnes codées en dur (backlog, todo, in_progress,
qa, review, done) et un mapping fixe colonne → étiquette
``kanban:<colonne>``. Maintenant, chaque espace de travail
définit ses colonnes : nom d'affichage, couleur, ordre,
étiquette de forge mappée et alias reconnus. Les clés sont
immuables (les données board_state les référencent) ; backlog
et done restent des colonnes système (renommables, non
supprimables) parce qu'elles portent une sémantique : défaut
des cartes neuves, et état fermé des issues.
Sans définition propre, un espace retombe sur les défauts
historiques — comportement 0.24 strictement conservé.
"""
from __future__ import annotations
from app import models
DEFAULT_COLUMNS: list[dict] = [
{"key": "backlog", "name": "📥 Backlog", "color": "#6b7280",
"label": "", "aliases": "", "kind": "backlog", "in_focus": 0},
{"key": "todo", "name": "📋 À faire", "color": "#3b82f6",
"label": "kanban:todo", "aliases": "", "kind": "flow",
"in_focus": 0},
{"key": "in_progress", "name": "🚧 En cours", "color": "#f59e0b",
"label": "kanban:in_progress", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "qa", "name": "🧪 QA", "color": "#06b6d4",
"label": "kanban:qa", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "review", "name": "🔍 Révision", "color": "#8b5cf6",
"label": "kanban:review", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "done", "name": "✅ Terminé", "color": "#10b981",
"label": "", "aliases": "", "kind": "done", "in_focus": 0},
]
_cache: dict[int, list[dict]] = {}
def invalidate(workspace_id: int | None = None) -> None:
if workspace_id is None:
_cache.clear()
else:
_cache.pop(workspace_id, None)
def ensure_columns(workspace_id: int) -> list[dict]:
"""Seed the historical defaults if the workspace has none."""
existing = models.list_board_columns(workspace_id)
if existing:
return existing
for position, column in enumerate(DEFAULT_COLUMNS):
models.upsert_board_column(
workspace_id, {**column, "position": position}
)
invalidate(workspace_id)
return models.list_board_columns(workspace_id)
def get_columns(workspace_id: int = 1) -> list[dict]:
"""Effective columns of a workspace (cached, seeded lazily)."""
if workspace_id not in _cache:
_cache[workspace_id] = ensure_columns(workspace_id)
return _cache[workspace_id]
def keys(workspace_id: int = 1) -> list[str]:
return [c["key"] for c in get_columns(workspace_id)]
def names(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["name"] for c in get_columns(workspace_id)}
def colors(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["color"] for c in get_columns(workspace_id)}
def flow_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c["kind"] == "flow"
]
def focus_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c.get("in_focus")
]
def wall_keys(workspace_id: int = 1) -> list[str]:
"""Wallboard cards: focus columns + the first flow column."""
columns = get_columns(workspace_id)
wanted = {c["key"] for c in columns if c.get("in_focus")}
flow = [c["key"] for c in columns if c["kind"] == "flow"]
if flow:
wanted.add(flow[0])
return [c["key"] for c in columns if c["key"] in wanted]
def label_for(workspace_id: int, key: str) -> str | None:
"""The forge label mirrored when a card enters this column."""
for column in get_columns(workspace_id):
if column["key"] == key and column["kind"] == "flow":
return column.get("label") or None
return None
def label_lookup(workspace_id: int = 1) -> dict[str, str]:
"""label name (lowercased) → column key, aliases included.
This is the configurable label/column mapping: the canonical
label of each flow column plus every alias declared for it
(e.g. Flowdeck's ``kanban_a_faire`` on the todo column).
"""
lookup: dict[str, str] = {}
for column in get_columns(workspace_id):
if column["kind"] != "flow":
continue
names = [column.get("label") or ""]
names += [
a.strip() for a in (column.get("aliases") or "").split(",")
]
for name in names:
name = name.strip().lower()
if name:
lookup[name] = column["key"]
return lookup
def recognized_labels(workspace_id: int = 1) -> set[str]:
"""Every label name (lowercased) owned by the mapping."""
return set(label_lookup(workspace_id))
+21
View File
@@ -7,6 +7,7 @@ a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`).
"""
import re
import unicodedata
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
@@ -27,6 +28,26 @@ def branch_issue_number(branch_name: str | None) -> int | None:
return int(m.group(1)) if m else None
def slugify_title(title: str, max_len: int = 40) -> str:
"""ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'."""
normalized = unicodedata.normalize("NFKD", title or "")
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if len(slug) > max_len:
slug = slug[:max_len].rstrip("-")
return slug or "issue"
def branch_name_for(number: int, title: str) -> str:
"""The branch a card would create (issue #3): issue-<n>-<slug>.
The name is recognized back by branch_issue_number, which is
what links the future PR to this card (issue #2).
"""
return f"issue-{number}-{slugify_title(title)}"
def find_code_links(
branches: list[dict],
pulls: list[dict],
+341
View File
@@ -0,0 +1,341 @@
"""Client de distribution du coffre de secrets ObsiGate (Phase 4, issue ObsiGate #22).
Patron imposé par la spec « Coffre de secrets ObsiGate » §12, appliqué ici
sans variante :
- Kanbante ne stocke QUE des références ``coffre:<slug>/<nom>``, jamais une
valeur distribuée ;
- à chaque usage, le SERVEUR Kanbante appelle
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
Kanbante (distinct du jeton MCP de notes et de la session SSO) et un
contexte obligatoire, utilise la valeur en mémoire, puis l'oublie ;
- aucun cache de valeur, aucune persistance, aucun log de la valeur ;
- octroi révoqué -> échec explicite orientant vers ObsiGate, sans repli
sur une ancienne valeur ;
- ObsiGate indisponible -> échec propre, sans continuer avec une valeur
en cache (il ne doit pas y en avoir).
Configuration (variables d'environnement, jamais versionnées) :
- ``KANBANTE_OBSIGATE_URL`` : base d'ObsiGate, ex. ``https://og.dracodev.net``
- ``KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`` : jeton du consommateur
« kanbante » enregistré dans ObsiGate (jeton révocable, hash + jti
côté ObsiGate ; il n'est jamais renvoyé au client ni journalisé).
- ``KANBANTE_COFFRE_TIMEOUT`` : délai en secondes (défaut 10).
Ce module ne dépend que de ``httpx`` (déjà dépendance du projet) et de
la bibliothèque standard, pour rester testable avec un faux endpoint
(``httpx.MockTransport``) sans instance ObsiGate réelle.
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass
import httpx
logger = logging.getLogger("kanbante.coffre")
PREFIXE_REFERENCE = "coffre:"
CHEMIN_DISTRIBUER = "/api/coffres/distribuer"
TIMEOUT_DEFAUT = 10.0
# Messages explicites, orientant vers ObsiGate. Ils ne contiennent jamais
# la valeur distribuée, ni le jeton consommateur.
_MESSAGES_DETAIL = {
"consommateur_invalide": (
"Consommateur Kanbante invalide dans ObsiGate : "
"vérifier le jeton consommateur Kanbante dans ObsiGate."
),
"consommateur_revoque": (
"Consommateur Kanbante révoqué dans ObsiGate : "
"ré-accorder l'accès du consommateur Kanbante dans ObsiGate."
),
"octroi_absent": (
"Aucun octroi actif pour cette référence dans ObsiGate : "
"créer l'octroi pour le consommateur Kanbante dans ObsiGate."
),
"octroi_revoque": (
"Accès révoqué dans ObsiGate pour cette référence : "
"ré-accorder l'octroi dans ObsiGate, puis réessayer."
),
"octroi_expire": (
"Octroi expiré dans ObsiGate pour cette référence : "
"renouveler l'octroi dans ObsiGate, puis réessayer."
),
"secret_expire": (
"Secret expiré dans ObsiGate : "
"renouveler le secret dans ObsiGate, puis réessayer."
),
"reference_invalide": (
"Référence invalide (format attendu coffre:<slug>/<nom>)."
),
"contexte_manquant": (
"Contexte de distribution manquant : "
"un contexte (type + identifiant) est obligatoire côté ObsiGate."
),
"kek_absente": "ObsiGate indisponible : clé de chiffrement absente côté ObsiGate.",
"crypto_indisponible": "ObsiGate indisponible : chiffrement indisponible côté ObsiGate.",
}
class CoffreErreur(Exception):
"""Erreur de distribution, sans jamais exposer une valeur secrète."""
class CoffreReferenceInvalide(CoffreErreur):
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
class CoffreContexteManquant(CoffreErreur):
"""Le contexte de distribution (type + identifiant) est obligatoire."""
class CoffreNonConfigure(CoffreErreur):
"""ObsiGate ou le jeton consommateur Kanbante n'est pas configuré."""
class CoffreAccesRefuse(CoffreErreur):
"""ObsiGate a refusé la distribution (octroi/consommateur/secret).
Aucun repli sur une ancienne valeur n'est autorisé : l'appelant doit
échouer proprement avec ce message.
"""
class CoffreIndisponible(CoffreErreur):
"""ObsiGate est injoignable ou a répondu sans valeur utilisable."""
@dataclass(frozen=True)
class CoffreConfig:
"""Configuration du consommateur Kanbante côté ObsiGate."""
base_url: str
jeton_consommateur: str
timeout: float = TIMEOUT_DEFAUT
@property
def url_distribuer(self) -> str:
return self.base_url.rstrip("/") + CHEMIN_DISTRIBUER
def config_depuis_env() -> CoffreConfig:
"""Construit la configuration depuis l'environnement.
Lève : CoffreNonConfigure si la base ou le jeton est absent.
Le jeton n'est jamais journalisé, même en cas d'erreur.
"""
base_url = os.getenv("KANBANTE_OBSIGATE_URL", "").strip()
jeton = os.getenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", "").strip()
try:
timeout = float(os.getenv("KANBANTE_COFFRE_TIMEOUT", "") or TIMEOUT_DEFAUT)
except ValueError:
timeout = TIMEOUT_DEFAUT
if not base_url or not jeton:
raise CoffreNonConfigure(
"Consommateur coffre Kanbante non configuré : définir "
"KANBANTE_OBSIGATE_URL et KANBANTE_COFFRE_CONSOMMATEUR_TOKEN."
)
return CoffreConfig(base_url=base_url, jeton_consommateur=jeton, timeout=timeout)
def est_reference(valeur: str | None) -> bool:
"""Vrai si ``valeur`` est une référence coffre (et non une valeur brute)."""
if not valeur:
return False
try:
parse_reference(valeur)
except CoffreReferenceInvalide:
return False
return True
def parse_reference(reference: str) -> tuple[str, str]:
"""Valide ``coffre:<slug>/<nom>`` et retourne ``(slug, nom)``.
Lève : CoffreReferenceInvalide si le format est incorrect.
La validation reste volontairement permissive sur les caractères
(ObsiGate reste l'autorité de validation) : préfixe, un « / »,
slug et nom non vides, aucun espace.
"""
reference = (reference or "").strip()
if not reference.startswith(PREFIXE_REFERENCE):
raise CoffreReferenceInvalide(
"Référence invalide (format attendu coffre:<slug>/<nom>)."
)
reste = reference[len(PREFIXE_REFERENCE):]
if "/" not in reste or any(c.isspace() for c in reference):
raise CoffreReferenceInvalide(
"Référence invalide (format attendu coffre:<slug>/<nom>)."
)
slug, nom = reste.split("/", 1)
if not slug or not nom or "/" in nom:
raise CoffreReferenceInvalide(
"Référence invalide (format attendu coffre:<slug>/<nom>)."
)
return slug, nom
def _valider_contexte(contexte_type: str, contexte_id: str) -> None:
if not (contexte_type or "").strip() or not (contexte_id or "").strip():
raise CoffreContexteManquant(
"Contexte de distribution manquant : un contexte "
"(type + identifiant) est obligatoire."
)
def _message_erreur(statut: int, detail: str) -> CoffreErreur:
"""Mappe une réponse d'erreur ObsiGate vers une erreur typée.
Les statuts 401/403/410 signalent un refus d'accès (octroi,
consommateur ou secret) : jamais de repli. Les autres erreurs
(400 de format, 503, réponse sans valeur) sont des indisponibilités
ou des erreurs de configuration, également sans repli.
"""
message = _MESSAGES_DETAIL.get(detail)
if statut in (401, 403, 410) or detail in (
"octroi_absent",
"octroi_revoque",
"octroi_expire",
"consommateur_invalide",
"consommateur_revoque",
"secret_expire",
):
return CoffreAccesRefuse(message or f"Distribution refusée par ObsiGate ({detail}).")
if detail == "reference_invalide":
return CoffreReferenceInvalide(message or "Référence invalide.")
if detail == "contexte_manquant":
return CoffreContexteManquant(message or "Contexte manquant.")
return CoffreIndisponible(
message or f"ObsiGate indisponible (statut {statut})."
)
def _traiter_reponse(reponse: httpx.Response, reference: str) -> str:
"""Extrait la valeur d'une réponse de distribution, ou lève.
La valeur extraite n'est jamais journalisée ; seuls la référence,
le contexte et le statut le sont.
"""
if reponse.status_code == 200:
try:
valeur = reponse.json().get("valeur")
except ValueError:
valeur = None
if isinstance(valeur, str) and valeur:
logger.info("distribution coffre ok (reference=%s)", reference)
return valeur
raise CoffreIndisponible(
"ObsiGate a répondu sans valeur distribuable."
)
try:
detail = str(reponse.json().get("detail") or "")
except ValueError:
detail = ""
logger.warning(
"distribution coffre refusee (reference=%s, statut=%d, detail=%s)",
reference,
reponse.status_code,
detail,
)
raise _message_erreur(reponse.status_code, detail)
def _corps_requete(reference: str, contexte_type: str, contexte_id: str) -> dict:
return {
"reference": reference,
"contexte_type": contexte_type,
"contexte_id": contexte_id,
}
def distribuer(
reference: str,
contexte_type: str,
contexte_id: str,
config: CoffreConfig | None = None,
transport: httpx.BaseTransport | None = None,
) -> str:
"""Distribue une valeur ObsiGate, en mémoire seulement.
L'appelant doit utiliser la valeur immédiatement (appel sortant,
vérification HMAC) puis la laisser sortir de portée : ce module ne
la persiste ni ne la met en cache, et ne la journalise jamais.
"""
parse_reference(reference)
_valider_contexte(contexte_type, contexte_id)
cfg = config or config_depuis_env()
try:
with httpx.Client(timeout=cfg.timeout, transport=transport) as client:
reponse = client.post(
cfg.url_distribuer,
json=_corps_requete(reference, contexte_type, contexte_id),
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
)
except httpx.HTTPError as exc:
raise CoffreIndisponible(
"ObsiGate injoignable pour la distribution : "
"vérifier qu'ObsiGate est disponible, puis réessayer."
) from exc
return _traiter_reponse(reponse, reference)
async def distribuer_async(
reference: str,
contexte_type: str,
contexte_id: str,
config: CoffreConfig | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> str:
"""Variante asynchrone de :func:`distribuer` (mêmes garanties)."""
parse_reference(reference)
_valider_contexte(contexte_type, contexte_id)
cfg = config or config_depuis_env()
try:
async with httpx.AsyncClient(timeout=cfg.timeout, transport=transport) as client:
reponse = await client.post(
cfg.url_distribuer,
json=_corps_requete(reference, contexte_type, contexte_id),
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
)
except httpx.HTTPError as exc:
raise CoffreIndisponible(
"ObsiGate injoignable pour la distribution : "
"vérifier qu'ObsiGate est disponible, puis réessayer."
) from exc
return _traiter_reponse(reponse, reference)
@contextmanager
def usage_distribue(reference: str, contexte_type: str, contexte_id: str,
config: CoffreConfig | None = None,
transport: httpx.BaseTransport | None = None):
"""Distribue une valeur pour un seul usage, dans le bloc ``with``.
La valeur ne doit pas sortir du bloc (ni être stockée, ni journalisée,
ni retournée) : à la sortie, l'appelant ne conserve que le résultat
de l'usage, jamais la valeur elle-même.
"""
valeur = distribuer(reference, contexte_type, contexte_id,
config=config, transport=transport)
try:
yield valeur
finally:
valeur = ""
@asynccontextmanager
async def usage_distribue_async(reference: str, contexte_type: str,
contexte_id: str,
config: CoffreConfig | None = None,
transport: httpx.AsyncBaseTransport | None = None):
"""Variante asynchrone de :func:`usage_distribue`."""
valeur = await distribuer_async(reference, contexte_type, contexte_id,
config=config, transport=transport)
try:
yield valeur
finally:
valeur = ""
+368 -4
View File
@@ -8,9 +8,24 @@ Environment variables:
GITEA_URL Base URL of the Gitea instance.
GITEA_TOKEN Gitea API token (read/write issues).
KANBANTE_DB_PATH Path of the local SQLite database.
KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth).
KANBANTE_AUTH_PASSWORD Basic-auth password.
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
KANBANTE_AUTH_PASSWORD Login password.
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
(default: derived from the credentials —
rotating the password invalidates sessions).
DRACO_SESSION_SECRET Shared secret of the dracodev SSO cookie
(``draco_session``, emitted by ObsiGate —
Fusion ACC-3). Empty (default) = SSO bridge
disabled. ``DRACO_SESSION_SECRET_PREV``
accepts the previous secret while rotating,
``DRACO_SSO_MACHINE_KEY`` authenticates the
revocation-list fetch and ``DRACO_SSO_URL``
points at the emitter (default
https://og.dracodev.net).
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
refreshes (chantier B, default 120;
0 disables the refresher).
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
days counts as "active" (default 365).
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
@@ -78,15 +93,91 @@ Environment variables:
column, min_age_days, label, wip_over.
Actions: add_label, notify, move_top.
A rule can never close an issue.
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
AGENT_URL Base URL of the agent service
(panneau Spark + console). Default
(official/prod name)
https://agent.dracodev.net — the dev
environment declares its ``-dev`` name
here (e.g. https://agent-dev.dracodev.net).
Sans variable explicite, la base est
aussi dérivée de l'hôte de
AGENT_EVENEMENTS_URL quand il suit le
motif ``agent<SUFFIXE>.dracodev.net``
(0.32.2 : sinon le panneau pointait le
défaut prod alors que le journal était
en dev — console morte sur la dev).
AGENT_EVENEMENTS_URL Journal endpoint of the agent
service (Lot 2, contract ACC-0.2)
receiving this app's signed events.
Default: derived from AGENT_URL
(``<AGENT_URL>/api/v1/evenements``).
Son hôte sert aussi de source à la
dérivation d'environnement des URL
sœurs ci-dessous (voir
``_url_soeur_draco``), au même titre
que la base du service (AGENT_URL) —
dérivation élargie en 0.32.2.
KANBANTE_PUBLIC_URL This app's own public base URL
(barre commune self-link, SSO
``return_to``). Default (official)
https://kanbante.dracodev.net.
DRACO_FLOWDECK_URL Public base URL of Flowdeck for the
common app bar. Default (official)
https://flowdeck.dracodev.net.
DRACO_TIKTAK_URL Public base URL of TikTak for the
common app bar. Default (official)
https://tiktak.dracodev.net.
ObsiGate's bar link reuses DRACO_SSO_URL
(the SSO emitter IS ObsiGate).
Résolution d'une URL rendue à une
requête (barre, panneau, CSP), dans
l'ordre (voir ``resoudre_urls_draco``,
0.32.2) : 1) la variable explicite
ci-dessus (prioritaire, contrat
inchangé) ; 2) l'hôte public de la
requête, motif fermé
``(og|flowdeck|tiktak|kanbante|agent)
<SUFFIXE>.dracodev.net`` (suffixe vide
en prod, ``-dev`` en dev) appliqué à
toutes les sœurs et à l'agent — jamais
de suffixe inventé depuis un hôte
arbitraire ; 3) dérivation depuis les
adresses de l'agent connues en
configuration (AGENT_EVENEMENTS_URL
ou AGENT_URL) — si cet hôte est
``agent<SUFFIXE>.dracodev.net``,
l'URL sœur devient
``https://<slug><SUFFIXE>.
dracodev.net`` (slugs : og, flowdeck,
tiktak, kanbante, agent) ; hôte hors
motif (autre domaine, LAN, IP) =
aucune dérivation, jamais de lien
deviné ; 4) le défaut officiel (prod).
Les contextes sans requête gardent la
résolution par configuration (étapes
1, 3 et 4), inchangée sinon.
No public dracodev name is hard-coded
outside these defaults: dev/prod differ
only by environment, never by code.
AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
events it emits to that journal (the
service knows it as the "kanbante"
entry of AGENT_EVENEMENTS_CLES).
Empty (default) = emission disabled.
"""
import os
import re
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.13.0"
APP_VERSION = "0.37.3"
def _env_bool(name: str, default: bool) -> bool:
@@ -122,6 +213,144 @@ def _env_wip_limits(name: str) -> dict[str, int]:
return limits
# ── Résolution d'environnement des URL publiques dracodev ──
# 0.32.1 : sur la dev, seul AGENT_EVENEMENTS_URL est posé (hôte
# agent-dev.dracodev.net) : les URL sœurs retombaient donc sur leurs
# défauts prod dans la barre commune. La dérivation depuis l'hôte de
# l'agent évite de devoir déclarer quatre variables de plus.
# 0.32.2 — couche fautive corrigée : cette dérivation ne couvrait
# que les URL sœurs, jamais ``agent_url`` lui-même — le panneau
# recevait donc le défaut prod (agent injoignable, la prod n'étant
# pas installée) et la console était une page morte sur la dev.
# Et aucune résolution ne tenait compte de la requête : un hôte
# public en ``-dev`` sans aucune variable retombait sur la prod.
# Désormais, pour tout ce qui est rendu à une requête (liens de la
# barre / DRACO_URLS, source de l'iframe du panneau, origine agent
# de la CSP) : 1) variable explicite ; 2) hôte public de la
# requête (motif fermé ci-dessous) ; 3) dérivation par les adresses
# de l'agent connues en configuration — URL d'événements OU base
# du service (élargie en 0.32.2) ; 4) défaut officiel.
_MOTIF_HOTE_AGENT = re.compile(r"agent([a-z0-9-]*)\.dracodev\.net")
_MOTIF_HOTE_PUBLIC = re.compile(
r"(?:og|flowdeck|tiktak|kanbante|agent)([a-z0-9-]*)\.dracodev\.net"
)
def _suffixe_depuis_hote(hote: str, motif: re.Pattern) -> str | None:
"""Suffixe d'environnement porté par un hôte nu, ou ``None``.
Retourne le suffixe (``""`` pour le nom officiel, ``"-dev"``
pour la dev) si l'hôte suit le motif fermé ; ``None`` hors
motif (autre domaine, LAN, IP…) — aucune dérivation alors,
jamais de suffixe inventé depuis un hôte arbitraire.
"""
correspondance = motif.fullmatch(hote.strip().lower())
return correspondance.group(1) if correspondance else None
def _suffixe_environnement_agent(url_agent: str) -> str | None:
"""Suffixe lu sur l'hôte d'une adresse connue de l'agent.
Vaut pour l'URL d'événements comme pour la base du service :
si son hôte est ``agent<SUFFIXE>.dracodev.net``, le suffixe
est retourné ; ``None`` hors motif.
"""
try:
hote = urlparse(url_agent).hostname or ""
except ValueError:
return None
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_AGENT)
def suffixe_environnement_requete(hote_requete: str | None) -> str | None:
"""Suffixe lu sur l'hôte public de la requête entrante.
Motif fermé ``^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>
\\.dracodev\\.net$`` (suffixe optionnel : vide en prod,
``-dev`` en dev) ; un port éventuel est toléré et retiré.
Hors motif (LAN, IP, autre domaine) : ``None`` — l'appelant
retombe alors sur la configuration, jamais sur un suffixe
deviné.
"""
if not hote_requete:
return None
hote = hote_requete.strip().lower()
if hote.startswith("["): # littéral IPv6 : jamais dans le motif
return None
hote = hote.split(":", 1)[0]
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_PUBLIC)
def _url_soeur_draco(
nom_env: str, slug: str, defaut: str, suffixe: str | None
) -> str:
"""Résout l'URL publique d'une app sœur de la barre commune.
1) ``nom_env`` explicite — prioritaire, contrat inchangé ;
2) dérivation d'environnement : ``https://<slug><suffixe>.
dracodev.net`` quand l'hôte de l'agent suit le motif ;
3) défaut officiel (prod), comme avant.
"""
explicite = os.getenv(nom_env)
if explicite:
return explicite.rstrip("/")
if suffixe is not None:
return f"https://{slug}{suffixe}.dracodev.net"
return defaut
@dataclass(frozen=True)
class UrlsDraco:
"""URL publiques dracodev résolues pour un rendu ou un contexte."""
sso_url: str # ObsiGate (lien de barre ; explicite = DRACO_SSO_URL)
flowdeck_url: str
tiktak_url: str
public_url: str # Kanbante elle-même (KANBANTE_PUBLIC_URL)
agent_url: str # base du service d'agents (panneau + CSP)
def resoudre_urls_draco(
hote_requete: str | None = None, base: "Settings | None" = None
) -> UrlsDraco:
"""Résout les URL publiques pour un rendu à une requête.
Ordre, par URL : 1) la variable explicite de l'installation —
prioritaire, inchangée (``DRACO_SSO_URL`` pour ObsiGate,
``KANBANTE_PUBLIC_URL`` pour Kanbante elle-même, ``AGENT_URL``
pour l'agent) ; 2) l'hôte public de la requête, s'il suit le
motif fermé dracodev : son suffixe d'environnement (``-dev``,
vide en prod) s'applique à toutes les sœurs et à l'agent ;
3) à défaut, la valeur de configuration ``base`` (défaut :
``_env_settings()``), qui porte déjà la dérivation par les
adresses de l'agent connues (événements ou base du service)
puis le défaut officiel — c'est aussi la résolution des
contextes sans requête, inchangée.
"""
if base is None:
base = _env_settings()
suffixe_hote = suffixe_environnement_requete(hote_requete)
def resoudre(nom_env: str, slug: str, valeur_base: str) -> str:
if os.getenv(nom_env):
return valeur_base # explicite : déjà résolue dans base
if suffixe_hote is not None:
return f"https://{slug}{suffixe_hote}.dracodev.net"
return valeur_base
return UrlsDraco(
sso_url=resoudre("DRACO_SSO_URL", "og", base.sso_url),
flowdeck_url=resoudre(
"DRACO_FLOWDECK_URL", "flowdeck", base.flowdeck_url
),
tiktak_url=resoudre("DRACO_TIKTAK_URL", "tiktak", base.tiktak_url),
public_url=resoudre(
"KANBANTE_PUBLIC_URL", "kanbante", base.public_url
),
agent_url=resoudre("AGENT_URL", "agent", base.agent_url),
)
@dataclass(frozen=True)
class Settings:
gitea_url: str = "https://git.dracodev.net"
@@ -154,6 +383,20 @@ class Settings:
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
briefing_include_standup: bool = False
rules_raw: str = ""
zombie_days: int = 30
session_secret: str = ""
snapshot_interval: int = 120
draco_secret: str = ""
draco_secret_prev: str = ""
sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net"
public_url: str = "https://kanbante.dracodev.net"
flowdeck_url: str = "https://flowdeck.dracodev.net"
tiktak_url: str = "https://tiktak.dracodev.net"
agent_url: str = "https://agent.dracodev.net"
archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
agent_evenements_cle: str = ""
@property
def auth_enabled(self) -> bool:
@@ -161,7 +404,36 @@ class Settings:
def get_settings() -> Settings:
"""Build settings from the current environment."""
"""Effective settings: environment, overlaid by the admin
panel's database values (0.24.0 — see OVERRIDABLE_SETTINGS)."""
return apply_db_overrides(_env_settings())
def _env_settings() -> Settings:
"""Build settings from the current environment only."""
agent_url_explicite = os.getenv("AGENT_URL")
agent_url = (
agent_url_explicite or "https://agent.dracodev.net"
).rstrip("/")
agent_evenements_url = (
os.getenv("AGENT_EVENEMENTS_URL")
or f"{agent_url}/api/v1/evenements"
).rstrip("/")
# Origines de l'agent telles que l'app les connaît déjà : elles
# portent le suffixe d'environnement (-dev…) repris par les URL
# sœurs dépourvues de variable explicite. Dérivation élargie
# (0.32.2) : l'URL d'événements d'abord, puis la base du service
# elle-même — selon ce que l'installation déclare.
suffixe_agent = _suffixe_environnement_agent(agent_evenements_url)
if suffixe_agent is None:
suffixe_agent = _suffixe_environnement_agent(agent_url)
# Base du service dérivée à son tour (0.32.2) : sans AGENT_URL
# explicite, si l'adresse connue de l'agent est en ``-dev``, le
# panneau et la console doivent viser cette même origine —
# auparavant agent_url restait au défaut prod, d'où la console
# morte sur la dev alors que le journal était bien en dev.
if not agent_url_explicite and suffixe_agent is not None:
agent_url = f"https://agent{suffixe_agent}.dracodev.net"
return Settings(
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
gitea_token=os.getenv("GITEA_TOKEN", ""),
@@ -198,4 +470,96 @@ def get_settings() -> Settings:
),
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
rules_raw=os.getenv("KANBANTE_RULES", ""),
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
draco_secret=os.getenv("DRACO_SESSION_SECRET", ""),
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=_url_soeur_draco(
"DRACO_SSO_URL", "og", "https://og.dracodev.net", suffixe_agent
),
public_url=_url_soeur_draco(
"KANBANTE_PUBLIC_URL",
"kanbante",
"https://kanbante.dracodev.net",
suffixe_agent,
),
flowdeck_url=_url_soeur_draco(
"DRACO_FLOWDECK_URL",
"flowdeck",
"https://flowdeck.dracodev.net",
suffixe_agent,
),
tiktak_url=_url_soeur_draco(
"DRACO_TIKTAK_URL",
"tiktak",
"https://tiktak.dracodev.net",
suffixe_agent,
),
agent_url=agent_url,
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
agent_evenements_url=agent_evenements_url,
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
)
# ── Surcharge par le panneau d'administration (0.24.0, P3) ──
# L'environnement amorce, le panneau gouverne : les valeurs
# ci-dessous peuvent être remplacées en base (kv_store, clé
# "settings_overrides", JSON) depuis /admin/settings. La
# provenance reste affichée dans le panneau. Les secrets et les
# réglages d'amorçage (DB, clé maître, session, draco) ne sont
# volontairement PAS surchargeables.
OVERRIDABLE_SETTINGS: dict[str, type] = {
"active_window_days": int,
"cache_ttl": float,
"snapshot_interval": int,
"aging_days": int,
"zombie_days": int,
"archive_suggest_days": int,
"max_note_chars": int,
"briefing_time": str,
"briefing_include_standup": bool,
"digest_time": str,
"digest_weekday": int,
"capture_repo": str,
"sync_labels": bool,
"sync_gitea": bool,
}
def settings_overrides() -> dict:
"""The raw overrides stored by the admin panel ({} if none)."""
try:
import json as _json
from app import models
raw = models.get_kv("settings_overrides")
data = _json.loads(raw) if raw else {}
return data if isinstance(data, dict) else {}
except Exception:
return {}
def apply_db_overrides(settings: Settings) -> Settings:
"""Overlay the admin-panel values on the env-built settings."""
overrides = settings_overrides()
if not overrides:
return settings
import dataclasses
valid: dict = {}
for key, value in overrides.items():
converter = OVERRIDABLE_SETTINGS.get(key)
if converter is None or value is None:
continue
try:
if converter is bool:
valid[key] = str(value).lower() in ("1", "true", "on", "oui")
else:
valid[key] = converter(value)
except (TypeError, ValueError):
continue
return dataclasses.replace(settings, **valid) if valid else settings
+161
View File
@@ -0,0 +1,161 @@
"""Forge connection registry (0.24.0, P0/P1).
A *connection* is one configured forge access (Gitea or GitHub):
base URL + token (Fernet-encrypted at rest, app/forge_crypto).
The registry owns one live client per connection and hands them
out to pages, the snapshot refresher and the webhook routes.
Bootstrap rules (zero-reconfiguration upgrades):
- the environment configuration (GITEA_URL / GITEA_TOKEN /
KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea",
labelled "Gitea (hérité)", the first time the table is empty;
- an "Principal" workspace containing that connection is
created the same way;
- existing snapshot rows (unprefixed keys, written when only
one client existed) are re-keyed under the "c1:" prefix once.
"""
import logging
from app import forge_crypto, models
from app.config import get_settings
from app.gitea_client import GiteaClient
logger = logging.getLogger("kanbante.connections")
def ensure_legacy_connection() -> dict:
"""Create connection #1 from the env config if none exists."""
existing = models.list_connections()
if existing:
return existing[0]
settings = get_settings()
hook_secret = getattr(settings, "webhook_" + "secret") or ""
conn_id = models.create_connection(
"gitea",
"gitea",
"Gitea (hérité)",
settings.gitea_url,
forge_crypto.encrypt_token(settings.gitea_token)
if settings.gitea_token
else "",
"",
hook_secret,
)
logger.info("created legacy forge connection #%d (gitea)", conn_id)
return models.get_connection(conn_id)
def ensure_principal_workspace() -> dict:
"""Create the 'Principal' workspace holding connection #1."""
workspaces = models.list_workspaces()
if workspaces:
return workspaces[0]
legacy = ensure_legacy_connection()
ws_id = models.create_workspace("principal", "Principal", "#7c5cfc")
models.set_workspace_connections(ws_id, [legacy["id"]])
logger.info("created Principal workspace #%d", ws_id)
return models.get_workspace(ws_id)
def prefix_legacy_snapshots(connection_id: int) -> None:
"""One-time re-keying of pre-0.24 snapshot rows (see module doc)."""
flag = f"snapshots_prefixed_c{connection_id}"
if models.get_kv(flag):
return
prefix = f"c{connection_id}:"
with models.get_db() as conn:
conn.execute(
"UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?",
(prefix, "c%:%"),
)
models.set_kv(flag, "1")
logger.info("legacy snapshots re-keyed under %s", prefix)
def build_client(row: dict) -> GiteaClient:
"""Instantiate the right client class for a connection row."""
token = forge_crypto.decrypt_token(row.get("token_encrypted") or "")
if row["kind"] == "github":
from app.github_client import GitHubClient
client: GiteaClient = GitHubClient(
base_url=row["base_url"], token=token
)
else:
client = GiteaClient(base_url=row["base_url"], token=token)
client.connection_id = row["id"]
client.connection_slug = row["slug"]
client.key_prefix = f"c{row['id']}:"
return client
class ForgeRegistry:
"""Live clients by connection id (global connections eagerly,
personal connections lazily)."""
def __init__(self) -> None:
self._clients: dict[int, GiteaClient] = {}
self.default_id: int = 1
def build(self, adopted_client: GiteaClient) -> None:
"""Adopt the app's historical client as the default
connection's client (it already matches the env config —
and the test-suite's fake), then build every other
enabled global connection."""
legacy = ensure_legacy_connection()
self.default_id = legacy["id"]
adopted_client.connection_id = legacy["id"]
adopted_client.connection_slug = legacy["slug"]
adopted_client.key_prefix = f"c{legacy['id']}:"
self._clients[legacy["id"]] = adopted_client
for row in models.list_connections(include_disabled=False):
if row["id"] == legacy["id"] or row.get("owner_user_id"):
continue
try:
self._clients[row["id"]] = build_client(row)
except Exception as exc:
logger.error(
"connection #%d (%s) failed to build: %s",
row["id"], row["slug"], exc,
)
def client_for(self, connection_id: int) -> GiteaClient | None:
client = self._clients.get(connection_id)
if client is not None:
return client
row = models.get_connection(connection_id)
if row is None or not row["enabled"]:
return None
try:
client = build_client(row)
except Exception as exc:
logger.error("connection #%d build failed: %s", connection_id, exc)
return None
self._clients[connection_id] = client
return client
def default_client(self) -> GiteaClient:
return self._clients[self.default_id]
def global_clients(self) -> list[GiteaClient]:
"""Clients the background refresher keeps warm."""
out = []
for row in models.list_connections(include_disabled=False):
if row.get("owner_user_id"):
continue
client = self.client_for(row["id"])
if client is not None:
out.append(client)
return out
def invalidate(self, connection_id: int) -> None:
"""Drop a cached client (token/config changed in admin)."""
client = self._clients.pop(connection_id, None)
if client is not None and connection_id != self.default_id:
import asyncio
try:
asyncio.get_event_loop().create_task(client.aclose())
except RuntimeError:
pass
+3 -1
View File
@@ -17,6 +17,8 @@ import logging
import httpx
from app.config import APP_VERSION
logger = logging.getLogger("kanbante.digest")
MCP_PROTOCOL_VERSION = "2024-11-05"
@@ -143,7 +145,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.13.0"},
"clientInfo": {"name": "kanbante", "version": APP_VERSION},
},
req_id=1,
)
+124
View File
@@ -0,0 +1,124 @@
"""Validation locale du cookie SSO ``draco_session`` — Fusion ACC-3.2.
Kanbante valide localement le jeton émis par ObsiGate (l'émettrice,
ACC-3.1) : JWS HS256 vérifié avec le secret partagé
(``DRACO_SESSION_SECRET``, ``_PREV`` accepté en rotation),
algorithme épinglé, expiration, et liste de révocation de
l'émettrice (cache 60 s, interrogée seulement si un cookie draco
est présenté). Vérification hmac/hashlib — mêmes sémantiques que
les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0).
Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide
en session Kanbante native. Kanbante est mono-utilisateur
(identifiants configurés par l'environnement) : un jeton valide
désigne l'opérateur configuré — pas de table de liens. Sans secret
configuré, module inerte.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from app.config import get_settings
logger = logging.getLogger(__name__)
COOKIE_NAME = "draco_session"
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
_revoked_cache: dict[str, int] = {}
_revoked_fetched_at: float = 0.0
def enabled() -> bool:
return bool(get_settings().draco_secret)
def _b64decode(segment: str) -> bytes:
return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
def verify_token(token: str) -> dict | None:
"""Vérifie un JWS HS256 (signature, alg épinglé, expiration)."""
settings = get_settings()
if not token or not settings.draco_secret:
return None
parts = token.split(".")
if len(parts) != 3:
return None
try:
header = json.loads(_b64decode(parts[0]))
if not isinstance(header, dict) or header.get("alg") != "HS256":
return None
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
signature = _b64decode(parts[2])
claims = json.loads(_b64decode(parts[1]))
except Exception:
return None
if not isinstance(claims, dict):
return None
for secret in (settings.draco_secret, settings.draco_secret_prev):
if not secret:
continue
attendu = hmac.new(secret.encode("utf-8"), signing_input,
hashlib.sha256).digest()
if hmac.compare_digest(attendu, signature):
break
else:
return None
try:
if int(claims.get("exp", 0)) <= int(time.time()):
return None
except (TypeError, ValueError):
return None
return claims
# ── Révocations (cache 60 s alimenté par l'émettrice) ───────────────────
def _refresh_revocations() -> None:
global _revoked_fetched_at
now = time.monotonic()
if now - _revoked_fetched_at < _REVOCATIONS_TTL:
return
_revoked_fetched_at = now
settings = get_settings()
try:
import httpx
headers = {}
if settings.sso_machine_key:
headers["X-Draco-Machine-Key"] = settings.sso_machine_key
r = httpx.get(f"{settings.sso_url}/api/sso/revocations",
headers=headers, timeout=2.0)
if r.status_code == 200:
_revoked_cache.clear()
for item in r.json().get("revoked", []):
_revoked_cache[str(item["jti"])] = int(item["exp"])
except Exception as exc: # l'émettrice injoignable ne bloque pas le SSO
logger.debug("Révocations draco indisponibles: %s", exc)
def is_revoked(jti: str) -> bool:
_refresh_revocations()
return jti in _revoked_cache
def claims_for(request) -> dict | None:
"""Claims valides du cookie draco de la requête (révocation incluse)."""
if not enabled():
return None
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
jti = str(claims.get("jti", ""))
if jti and is_revoked(jti):
return None
if not claims.get("sub"):
return None
return claims
+123
View File
@@ -0,0 +1,123 @@
"""Flow health (roadmap #9): bottlenecks and zombie cards.
Boards show state; this module reads the event journal for health:
how long cards actually stay in each column (dwell samples are the
intervals between an entry and the next move/close), and which
open cards have had no activity for a long time (zombies).
"""
import statistics
from datetime import datetime
from app.models import KANBAN_COLUMNS
def parse_ts(raw) -> datetime | None:
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
text = str(raw or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
try:
return datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return None
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
"""Dwell samples (in days) per column, rebuilt from the journal.
A stay starts when a card enters a column (created / moved /
reopened) and ends at its next move or close. The first stay of
a card whose journal starts mid-flight is unknowable and is
simply not sampled.
"""
by_issue: dict[tuple[str, int], list[dict]] = {}
for event in events:
by_issue.setdefault(
(event["repo_full_name"], event["issue_number"]), []
).append(event)
dwell: dict[str, list[float]] = {}
for issue_events in by_issue.values():
ordered = sorted(
issue_events, key=lambda e: str(e.get("created_at") or "")
)
current: str | None = None
since: datetime | None = None
for event in ordered:
at = parse_ts(event.get("created_at"))
if at is None:
continue
kind = event["event_type"]
if kind == "created":
if current is None:
current = event.get("to_column") or "backlog"
since = at
elif kind == "moved":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
elif current is None and event.get("from_column"):
# The stay in from_column began before the journal
# window: only the new stay can be timed.
pass
current = event.get("to_column") or current
since = at
elif kind == "closed":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
current = "done"
since = at
elif kind == "reopened":
current = event.get("to_column") or "backlog"
since = at
return dwell
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
"""Median dwell per column, in board order."""
summary = []
for column in KANBAN_COLUMNS:
samples = dwell.get(column) or []
if not samples:
continue
summary.append(
{
"column": column,
"count": len(samples),
"median_days": round(statistics.median(samples), 1),
"avg_days": round(sum(samples) / len(samples), 1),
}
)
return summary
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
"""The bottleneck: slowest column (done excluded) with data."""
candidates = [
row for row in summary
if row["column"] != "done" and row["count"] >= min_samples
]
if not candidates:
return None
return max(candidates, key=lambda r: r["median_days"])
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
"""Most recent journal timestamp per (repo, issue)."""
latest: dict[tuple[str, int], datetime] = {}
for event in events:
at = parse_ts(event.get("created_at"))
if at is None:
continue
key = (event["repo_full_name"], event["issue_number"])
if key not in latest or at > latest[key]:
latest[key] = at
return latest
+11 -3
View File
@@ -7,7 +7,15 @@ date, and are withheld entirely when the journal has no closures
to learn from — "not enough history" is an answer, too.
"""
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
def _journal_today() -> date:
"""'Today' in the journal's frame: board_events stamps are UTC
(SQLite datetime('now')), so week buckets anchor to UTC —
otherwise closures logged in the local evening (already
tomorrow in UTC) fall outside every bucket."""
return datetime.now(timezone.utc).date()
MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
@@ -35,7 +43,7 @@ def _event_date(event: dict) -> date | None:
def weekly_closures(events: list[dict], weeks: int = 8,
today: date | None = None) -> list[dict]:
"""Closure counts per ISO week, oldest first, current week last."""
today = today or date.today()
today = today or _journal_today()
current_monday = today - timedelta(days=today.weekday())
buckets: list[dict] = []
for i in range(weeks - 1, -1, -1):
@@ -83,7 +91,7 @@ def estimate(remaining: int, weekly: list[dict],
no throughput to project from, else dates: central (avg rate),
earliest (best week), latest (worst nonzero week, if any).
"""
today = today or date.today()
today = today or _journal_today()
if remaining <= 0:
return {"done": True}
stats = throughput_stats(weekly)
+70
View File
@@ -0,0 +1,70 @@
"""Encryption at rest for forge tokens (0.24.0, P0/P2).
Forge access tokens are the keys to the kingdom: they are
stored Fernet-encrypted (AES-128-CBC + HMAC) in
forge_connections.token_encrypted and never shown again after
entry (the admin UI only ever displays a mask).
Master key resolution (decision D5):
1. KANBANTE_MASTER_KEY (urlsafe base64, 32 bytes) — recommended;
2. otherwise a random key generated ONCE and kept in kv_store
("master_key_generated"): as safe as the database file
itself, zero configuration, but it travels with DB backups —
set the env var for anything stricter.
"""
import logging
import os
import secrets as _secrets
from cryptography.fernet import Fernet
from app import models
logger = logging.getLogger("kanbante.crypto")
_fernet: Fernet | None = None
def _master_key() -> bytes:
env_key = os.getenv("KANBANTE_MASTER_KEY", "").strip()
if env_key:
return env_key.encode("utf-8")
stored = models.get_kv("master_key_generated")
if stored:
return stored.encode("utf-8")
generated = Fernet.generate_key()
models.set_kv("master_key_generated", generated.decode("utf-8"))
logger.warning(
"KANBANTE_MASTER_KEY not set: generated a master key and "
"stored it in the database (kv_store). Set the env var to "
"control it explicitly."
)
return generated
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
_fernet = Fernet(_master_key())
return _fernet
def encrypt_token(plain: str) -> str:
if not plain:
return ""
return _get_fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
def decrypt_token(stored: str) -> str:
if not stored:
return ""
try:
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
except Exception as exc:
logger.error("forge token decryption failed: %s", exc)
return ""
def mask_token(stored: str) -> str:
return "••••••••" if stored else "—"
+369 -35
View File
@@ -17,8 +17,10 @@ Changes from the 0.1.0 client:
- .env loading targets the project root only (no hardcoded home path).
"""
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -36,7 +38,19 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list
class GiteaClient:
"""Lightweight async Gitea API client for Kanbante."""
"""Lightweight async Gitea API client for Kanbante.
Since 0.24.0 this class is the reference implementation of
the forge protocol: GitHubClient subclasses it and overrides
the endpoints whose shape differs. ``api_suffix`` and
``auth_scheme`` are the two seams; ``key_prefix`` namespaces
the persisted snapshots per connection ("c<id>:").
"""
kind = "gitea"
supports_attachments = True
api_suffix = "/api/v1"
auth_scheme = "token"
def __init__(
self,
@@ -49,17 +63,30 @@ class GiteaClient:
self.base_url = (base_url or settings.gitea_url).rstrip("/")
self.token = settings.gitea_token if token is None else token
self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl
self.key_prefix = ""
self.connection_id = 1
self.connection_slug = "gitea"
headers = {"Accept": "application/json"}
headers.update(self._extra_headers())
if self.token:
headers["Authorization"] = f"token {self.token}"
headers["Authorization"] = f"{self.auth_scheme} {self.token}"
self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1",
base_url=f"{self.base_url}{self.api_suffix}",
headers=headers,
timeout=15.0,
transport=transport,
)
self._user: dict | None = None
self._cache: dict[str, tuple[float, Any]] = {}
self._refreshing: set[str] = set()
self._cache_gen = 0
# Snapshot bookkeeping (chantier B): cache key -> UTC time
# the payload was fetched from Gitea, for the freshness
# label and the persisted `snapshots` table.
self._snapshot_times: dict[str, str] = {}
def _extra_headers(self) -> dict:
return {}
async def aclose(self) -> None:
await self.client.aclose()
@@ -105,24 +132,157 @@ class GiteaClient:
page += 1
return items
def _cache_get(self, key: str) -> Any | None:
if self.cache_ttl <= 0:
return None
hit = self._cache.get(key)
if hit and (time.monotonic() - hit[0]) < self.cache_ttl:
return hit[1]
return None
def _cache_put(self, key: str, value: Any) -> None:
if self.cache_ttl > 0:
self._cache[key] = (time.monotonic(), value)
self._persist_snapshot(key, value)
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
def _prefixed(self, key: str) -> str:
return f"{self.key_prefix}{key}"
def _persist_snapshot(self, key: str, value: Any) -> None:
"""Write-through to the SQLite snapshots table.
Persistence must never break a read: any failure (DB
locked, unserializable payload) is logged and swallowed.
"""
try:
from app import models
models.save_snapshot(self._prefixed(key), value)
import datetime
self._snapshot_times[key] = datetime.datetime.now(
datetime.timezone.utc
).strftime("%Y-%m-%d %H:%M:%S")
except Exception as exc:
logger.debug("snapshot persist failed for %s: %s", key, exc)
def hydrate_from_snapshots(self) -> int:
"""Preload the cache from persisted snapshots (startup).
Entries are inserted with an already-stale timestamp: the
SWR layer serves them instantly AND schedules a background
refresh, so the first page after a restart renders from
local data (tens of ms) while Gitea data catches up.
Returns the number of entries hydrated.
"""
if self.cache_ttl <= 0:
return 0
try:
from app import models
snapshots = models.load_snapshots()
except Exception as exc:
logger.warning("snapshot hydration failed: %s", exc)
return 0
stale_at = time.monotonic() - self.cache_ttl - 1.0
count = 0
for stored_key, (payload, updated_at) in snapshots.items():
if self.key_prefix:
if not stored_key.startswith(self.key_prefix):
continue
key = stored_key[len(self.key_prefix):]
else:
key = stored_key
self._cache[key] = (stale_at, payload)
self._snapshot_times[key] = updated_at
count += 1
if count:
logger.info("hydrated %d snapshot(s) from SQLite", count)
return count
def latest_snapshot_at(self) -> str | None:
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
if not self._snapshot_times:
return None
return max(self._snapshot_times.values())
async def _force(self, key: str, fetcher: Callable[[], Awaitable[Any]]) -> Any:
"""Fetch now, bypassing freshness (background refresher)."""
value = await fetcher()
self._cache_put(key, value)
return value
async def refresh_starred(self) -> list[dict]:
return await self._force(
"user:starred", lambda: self._get_all_pages("/user/starred")
)
async def refresh_repo_issues(
self, owner: str, repo: str, state: str = "all"
) -> list[dict]:
return await self._force(
f"issues:{owner}/{repo}:{state}",
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
),
)
async def refresh_milestones(
self, owner: str, repo: str
) -> list[dict]:
return await self._force(
f"milestones:{owner}/{repo}:open",
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state="open"
),
)
def _cache_clear(self) -> None:
self._cache.clear()
# Invalidate in-flight background refreshes: a refresh that
# started before a mutation must not resurrect stale data.
self._cache_gen += 1
async def _cached_list(
self, key: str, fetcher: Callable[[], Awaitable[Any]]
) -> Any:
"""Stale-while-revalidate cache for list endpoints (0.19.0).
Fresh (< TTL): serve the cache. Stale: serve the cached
value IMMEDIATELY and refresh it in the background (once
per key) — the user never waits on Gitea for a page when
any earlier answer exists. Absent: fetch and wait.
Mutations clear the cache (see _cache_clear), so writes are
never hidden by a stale entry.
"""
if self.cache_ttl <= 0:
return await fetcher()
hit = self._cache.get(key)
if hit is not None:
if (time.monotonic() - hit[0]) < self.cache_ttl:
return hit[1]
if key not in self._refreshing:
self._refreshing.add(key)
asyncio.create_task(
self._refresh_entry(key, fetcher, self._cache_gen)
)
return hit[1]
value = await fetcher()
self._cache_put(key, value)
return value
async def _refresh_entry(
self,
key: str,
fetcher: Callable[[], Awaitable[Any]],
generation: int,
) -> None:
try:
value = await fetcher()
if generation == self._cache_gen:
self._cache_put(key, value)
except Exception as exc:
logger.debug("background refresh failed for %s: %s", key, exc)
finally:
self._refreshing.discard(key)
def invalidate_cache(self) -> None:
"""Drop every cached list response (called on webhook events)."""
self._cache.clear()
self._cache_clear()
# ── API ────────────────────────────────────────────────
@@ -140,21 +300,25 @@ class GiteaClient:
self._user = await self._get("/user")
return self._user
async def search_repos(self) -> list[dict]:
"""All repos visible to the token owner (paginated, cached)."""
cached = self._cache_get("search_repos")
if cached is not None:
return cached
async def _fetch_search_repos(self) -> list[dict]:
params: dict[str, Any] = {}
try:
user = await self.get_user()
if user and user.get("id"):
params["uid"] = user["id"]
except Exception as exc: # no/invalid token → public search
logger.debug("get_user failed, falling back to public search: %s", exc)
repos = await self._get_all_pages("/repos/search", **params)
self._cache_put("search_repos", repos)
return repos
logger.debug(
"get_user failed, falling back to public search: %s", exc
)
return await self._get_all_pages("/repos/search", **params)
async def search_repos(self) -> list[dict]:
"""All repos visible to the token owner (paginated, SWR-cached)."""
return await self._cached_list("search_repos", self._fetch_search_repos)
async def refresh_search_repos(self) -> list[dict]:
"""Force-fetch the repo index (background refresher)."""
return await self._force("search_repos", self._fetch_search_repos)
async def get_repo(self, owner: str, repo: str) -> dict:
return await self._get(f"/repos/{owner}/{repo}")
@@ -178,14 +342,12 @@ class GiteaClient:
(see app.main._is_issue).
"""
key = f"issues:{owner}/{repo}:{state}"
cached = self._cache_get(key)
if cached is not None:
return cached
issues = await self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
return await self._cached_list(
key,
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
),
)
self._cache_put(key, issues)
return issues
async def get_all_issues(
self, repos: list[dict], state: str = "open"
@@ -308,14 +470,81 @@ class GiteaClient:
self, owner: str, repo: str, state: str = "open"
) -> list[dict]:
key = f"milestones:{owner}/{repo}:{state}"
cached = self._cache_get(key)
if cached is not None:
return cached
milestones = await self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state=state
return await self._cached_list(
key,
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state=state
),
)
self._cache_put(key, milestones)
return milestones
async def create_milestone(
self,
owner: str,
repo: str,
title: str,
description: str = "",
due_on: str | None = None,
) -> dict:
"""Create a milestone on the forge (issue #26)."""
payload: dict = {"title": title, "description": description}
if due_on:
payload["due_on"] = due_on
r = await self.client.post(
f"/repos/{owner}/{repo}/milestones", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def update_milestone(
self,
owner: str,
repo: str,
milestone_id: int,
title: str | None = None,
description: str | None = None,
due_on: str | None = None,
clear_due: bool = False,
state: str | None = None,
) -> dict:
"""Patch a milestone (title, due date, state) — #26.
due_on=None + clear_due=False leaves the date alone;
clear_due=True sends an explicit null (Gitea clears it).
"""
payload: dict = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if clear_due:
payload["due_on"] = None
elif due_on:
payload["due_on"] = due_on
if state is not None:
payload["state"] = state
r = await self.client.patch(
f"/repos/{owner}/{repo}/milestones/{milestone_id}", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def list_starred(self) -> list[dict]:
"""Repos starred by the token user (issue #20: pinning rides
on Gitea stars — the forge stays the source of truth)."""
return await self._cached_list(
"user:starred", lambda: self._get_all_pages("/user/starred")
)
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
"""Star / unstar a repo for the token user."""
if starred:
r = await self.client.put(f"/user/starred/{owner}/{repo}")
else:
r = await self.client.delete(f"/user/starred/{owner}/{repo}")
r.raise_for_status()
self._cache_clear()
async def create_issue(
self, owner: str, repo: str, title: str, body: str = ""
@@ -328,6 +557,87 @@ class GiteaClient:
self._cache_clear()
return r.json()
async def update_issue(
self,
owner: str,
repo: str,
number: int,
title: str | None = None,
body: str | None = None,
) -> dict:
"""Edit an issue's title and/or body (0.31.0)."""
payload: dict = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if not payload:
return await self.get_issue(owner, repo, number)
r = await self.client.patch(
f"/repos/{owner}/{repo}/issues/{number}", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
# ── Pièces jointes des issues (0.31.0) ────────────────
async def list_issue_assets(
self, owner: str, repo: str, number: int
) -> list[dict]:
"""Attachments of an issue (Gitea assets)."""
return await self._get(
f"/repos/{owner}/{repo}/issues/{number}/assets"
)
async def upload_issue_asset(
self,
owner: str,
repo: str,
number: int,
filename: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> dict:
"""Attach a file to an issue; returns the asset record."""
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/assets",
files={"attachment": (filename, content, content_type)},
)
r.raise_for_status()
return r.json()
async def delete_issue_asset(
self, owner: str, repo: str, number: int, asset_id: int
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
)
r.raise_for_status()
async def download_issue_asset(
self, owner: str, repo: str, number: int, asset_id: int
) -> tuple[bytes, str]:
"""(content, content_type) of one attachment.
Gitea nuance (vérifiée en réel sur 1.27.3): GET on the
asset endpoint returns its JSON record, NOT the bytes —
the file itself lives at `browser_download_url`
(/attachments/<uuid>), fetched here with the connection's
token so private repos work too.
"""
record = await self._get(
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
)
url = record.get("browser_download_url") if isinstance(record, dict) else None
if not url:
raise ValueError("La forge ne fournit pas d'URL de téléchargement")
r = await self.client.get(url)
r.raise_for_status()
return r.content, r.headers.get(
"content-type", "application/octet-stream"
)
async def set_issue_state(
self, owner: str, repo: str, issue_number: int, state: str
) -> dict:
@@ -341,6 +651,30 @@ class GiteaClient:
self._cache_clear()
return r.json()
async def set_archived(
self, owner: str, repo: str, archived: bool
) -> dict:
"""Archive / unarchive the repo ON THE FORGE (0.24.0, P4)."""
r = await self.client.put(
f"/repos/{owner}/{repo}", json={"archived": archived}
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
"""Create a branch from another one (issue #3)."""
return await self._request(
"POST",
f"/repos/{owner}/{repo}/branches",
json={
"new_branch_name": new_branch,
"old_branch_name": old_branch,
},
)
async def list_branches(self, owner: str, repo: str) -> list[dict]:
"""All branches of a repo (code links, issue #1)."""
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
+155
View File
@@ -0,0 +1,155 @@
"""GitHub forge client (0.24.0, P1).
Subclasses GiteaClient and overrides only what differs on
GitHub's REST API:
- base URL without suffix (api.github.com; GitHub Enterprise
serves under /api/v3 — pass a base_url ending accordingly is
NOT needed: set api_suffix on the instance if required);
- Bearer auth + GitHub Accept / API-version headers;
- list pagination via Link headers (per_page=100);
- repo index via /user/repos (no uid search);
- labels are addressed BY NAME (the label "id" exposed to the
rest of Kanbante is the name — add/remove/delete endpoints
all accept names on GitHub);
- issue search via /search/issues with qualifiers, results
normalized to carry repository.full_name like Gitea's;
- archiving via PATCH (Gitea uses PUT).
"""
import re
from typing import Any
from app.gitea_client import GiteaClient
_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
class GitHubClient(GiteaClient):
kind = "github"
# L'API GitHub ne sait pas joindre de fichiers aux issues
# (réservé à son interface web) : les routes Kanbante
# répondent proprement « non pris en charge » (0.31.0).
supports_attachments = False
api_suffix = ""
auth_scheme = "Bearer"
def _extra_headers(self) -> dict:
return {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
# ── Pagination (Link headers) ──────────────────────────
async def _get_all_pages(self, path: str, **params: Any) -> list[dict]:
items: list[dict] = []
url: str | None = path
query: dict[str, Any] = {**params, "per_page": 100}
pages = 0
while url and pages < 50:
pages += 1
if url.startswith("http"):
r = await self.client.get(url)
else:
r = await self.client.get(url, params=query)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "items" in data:
batch = data["items"]
elif isinstance(data, list):
batch = data
else:
batch = []
items.extend(batch)
match = _LINK_NEXT.search(r.headers.get("Link", ""))
url = match.group(1) if match else None
query = {}
return items
# ── Repo index ─────────────────────────────────────────
async def _fetch_search_repos(self) -> list[dict]:
return await self._get_all_pages(
"/user/repos", affiliation="owner,collaborator,organization_member"
)
# ── Labels: the exposed id IS the name ─────────────────
async def get_labels(self, owner: str, repo: str) -> list[dict]:
labels = await super().get_labels(owner, repo)
for label in labels:
label["id"] = label["name"]
return labels
async def create_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
label = await super().create_label(owner, repo, name, color)
label["id"] = label["name"]
return label
async def add_issue_label(
self, owner: str, repo: str, number: int, label_id
) -> None:
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/labels",
json={"labels": [str(label_id)]},
)
r.raise_for_status()
async def remove_issue_label(
self, owner: str, repo: str, number: int, label_id
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
)
r.raise_for_status()
# ── Issue search ───────────────────────────────────────
async def search_issues(
self,
q: str = "",
state: str = "open",
assigned_by: str | None = None,
) -> list[dict]:
qualifiers = [q.strip(), "is:issue", f"state:{state}"]
if assigned_by:
qualifiers.append(f"assignee:{assigned_by}")
query = " ".join(part for part in qualifiers if part)
raw = await self._get_all_pages("/search/issues", q=query)
# Normalize to the Gitea shape: issue["repository"]["full_name"].
for item in raw:
url = item.get("repository_url", "")
full_name = url.rsplit("/repos/", 1)[-1] if "/repos/" in url else ""
item["repository"] = {"full_name": full_name}
return raw
# ── Archiving (PATCH on GitHub) ────────────────────────
async def set_archived(
self, owner: str, repo: str, archived: bool
) -> dict:
r = await self.client.patch(
f"/repos/{owner}/{repo}", json={"archived": archived}
)
r.raise_for_status()
self._cache_clear()
return r.json()
# ── Branches (git/refs on GitHub) ──────────────────────
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
ref = await self._get(
f"/repos/{owner}/{repo}/git/ref/heads/{old_branch}"
)
sha = (ref.get("object") or {}).get("sha", "")
r = await self.client.post(
f"/repos/{owner}/{repo}/git/refs",
json={"ref": f"refs/heads/{new_branch}", "sha": sha},
)
r.raise_for_status()
return r.json()
+4386 -369
View File
File diff suppressed because it is too large Load Diff
+984 -64
View File
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
"""Recherche fédérée (fusion-trois-apps #68) : JSON de recherche,
CORS bornée et conversion des groupes d'issues.
Ce module ne dépend d'aucun autre module applicatif à l'import (les
résolveurs sont appelés paresseusement) : les fonctions pures sont
testables sans base ni serveur.
- ``GET /api/search`` (route dans ``app/main.py``) réutilise la
recherche Gitea de la page ``/search`` (titres + corps, tous états,
tous projets du workspace) et la regroupe par dépôt.
- La CORS bornée n'autorise que ``GET /api/search``, et seulement
pour les origines sœurs résolues par ``app.config`` (explicite >
hôte de la requête > dérivation > défaut officiel — donc ``-dev``
reste ``-dev``), jamais ``*``. Le middleware est un ASGI pur :
il répond lui-même aux preflights OPTIONS (qui ne portent jamais
de cookies, donc qu'``AuthMiddleware`` refuserait sinon) et ajoute
les en-têtes aux réponses de recherche, sans toucher aux autres
routes.
"""
from __future__ import annotations
from urllib.parse import urlparse
SEARCH_PATH = "/api/search"
_SNIPPET_LEN = 160
PROD_ORIGINS = frozenset({
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
})
DEV_ORIGINS = frozenset({
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
})
# Clés du dictionnaire rendu par render() (app/main.py).
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
def is_dev_host(hostname: str | None) -> bool:
"""Même règle que la barre v0.1.6 (voir static/js/recherche.js)."""
if not hostname:
return False
h = hostname.lower()
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 4 and first.endswith("-dev")
def origin_of(url: str | None) -> str | None:
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
if not url:
return None
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return None
host = parsed.hostname.lower()
port = f":{parsed.port}" if parsed.port else ""
return f"{parsed.scheme}://{host}{port}"
def sister_origins(
resolved: dict | None = None, request_host: str | None = None
) -> frozenset:
"""Origines autorisées pour la lecture cross-origin de /api/search.
``resolved`` : dictionnaire ``draco_urls`` rendu par ``render()``.
Sans argument, le résolveur est appelé (import paresseux) avec
l'hôte de la requête quand il est connu — ce middleware s'exécute
avant tout poseur de contexte d'hôte, et sans lui la résolution
retomberait sur la configuration seule et raterait l'environnement
``-dev`` ; en cas d'échec, repli sur prod + dev officiels
(premier parti uniquement).
"""
if resolved is None:
try:
from app.config import get_settings, resoudre_urls_draco
urls = resoudre_urls_draco(hote_requete=request_host, base=get_settings())
resolved = {
"obsigate": urls.sso_url,
"flowdeck": urls.flowdeck_url,
"tiktak": urls.tiktak_url,
"kanbante": urls.public_url,
}
except Exception:
return PROD_ORIGINS | DEV_ORIGINS
origins = set()
for key in _SOEUR_KEYS:
origin = origin_of((resolved or {}).get(key))
if origin:
origins.add(origin)
return frozenset(origins)
def cors_headers_for(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
if not origin:
return None
if origin.strip() not in sister_origins(resolved, request_host):
return None
return {
"Access-Control-Allow-Origin": origin.strip(),
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
def preflight_headers(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
headers = cors_headers_for(origin, resolved, request_host)
if headers is None:
return None
headers.update({
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
"Access-Control-Max-Age": "600",
})
return headers
def groupes_vers_json(groups, limit: int = 20):
"""Convertit la sortie de ``_group_issue_results`` en JSON public.
Seuls les champs utiles sont exposés (numéro, titre, état, corps
tronqué à 160 caractères, labels, colonne). Retourne
``(groupes, total)`` où ``total`` est le nombre de lignes
retournées (plafonné à ``limit``).
"""
out: list[dict] = []
total = 0
restant = max(1, min(int(limit), 50))
for group in groups or []:
rows: list[dict] = []
for row in (group or {}).get("rows", []) or []:
if restant <= 0:
break
issue = (row or {}).get("issue", {}) or {}
labels = []
for label in issue.get("labels") or []:
if isinstance(label, dict):
labels.append({"name": label.get("name"), "color": label.get("color")})
rows.append({
"issue": {
"number": issue.get("number"),
"title": issue.get("title"),
"state": issue.get("state"),
"body": (issue.get("body") or "")[:_SNIPPET_LEN],
"labels": labels,
},
"column": (row or {}).get("column"),
})
restant -= 1
total += 1
if rows:
out.append({"full_name": (group or {}).get("full_name"), "rows": rows})
if restant <= 0:
break
return out, total
class RechercheCorsMiddleware:
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
headers_in = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope.get("headers", [])
}
origin = headers_in.get("origin", "")
request_host = (
headers_in.get("host", "") or headers_in.get("x-forwarded-host", "")
).split(",")[0].strip()
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
preflight = preflight_headers(origin, request_host=request_host)
if preflight is not None:
body = b""
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in preflight.items()]
headers_out.append((b"content-length", b"0"))
await send({"type": "http.response.start", "status": 204,
"headers": headers_out})
await send({"type": "http.response.body", "body": body})
return
if path != SEARCH_PATH:
await self.app(scope, receive, send)
return
async def send_with_cors(message):
if message["type"] == "http.response.start":
cors = cors_headers_for(origin, request_host=request_host)
if cors is not None:
message["headers"] = list(message.get("headers", [])) + [
(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in cors.items()
]
await send(message)
await self.app(scope, receive, send_with_cors)
+4 -1
View File
@@ -20,6 +20,7 @@ rule can never close an issue or move a card between columns.
import json
from app import models
from app.models import KANBAN_COLUMNS
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
@@ -60,7 +61,9 @@ def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
"(fermer/déplacer une carte par règle est interdit en v1)"
)
continue
if "column" in when and when["column"] not in KANBAN_COLUMNS:
if "column" in when and when["column"] not in (
set(KANBAN_COLUMNS) | set(models.column_keys(1))
):
errors.append(f"{name} : colonne inconnue « {when['column']} »")
continue
if "min_age_days" in when and (
+121
View File
@@ -0,0 +1,121 @@
"""Background snapshot refresher — chantier B (0.22.0).
The expensive part of Kanbante is the fan-out of Gitea list
calls behind the portfolio pages (repo index, then issues and
milestones for every active project). Since 0.19.0 those calls
are parallel and stale-while-revalidate, which fixed repeat
loads, but the FIRST page after a restart still paid seconds.
The refresher pays that cost instead of the user: every
KANBANTE_SNAPSHOT_INTERVAL seconds it force-refreshes the hot
keys (repo index, starred, issues + milestones of active
repos). Every fetch write-throughs to the SQLite `snapshots`
table, and the client cache is hydrated from that table at
startup — so pages read local data from the very first request
and only ever wait on Gitea when no snapshot exists yet.
Freshness contract, shown in the UI: Gitea-side data may lag
by up to one interval; a Gitea webhook refreshes the touched
repo immediately (see refresh_repo_snapshots); anything the
user does locally (board journal) is always instant.
"""
import asyncio
import datetime
import logging
from app import models
from app.config import get_settings
logger = logging.getLogger("kanbante.snapshots")
_REFRESH_CONCURRENCY = 8
async def refresh_repo_snapshots(gitea, owner: str, repo: str) -> None:
"""Refresh one repo's hot keys (webhooks call this).
Both issue states are refreshed: most pages read "all", but
Focus/Triage/Mes issues read "open" — a snapshot set missing
either leaves a slow first hit on the table.
"""
try:
await gitea.refresh_repo_issues(owner, repo, "all")
await gitea.refresh_repo_issues(owner, repo, "open")
await gitea.refresh_milestones(owner, repo)
except Exception as exc:
logger.debug("repo snapshot refresh failed for %s/%s: %s", owner, repo, exc)
async def refresh_snapshots(gitea, classify_repos) -> int:
"""One full refresh cycle. Returns the active-repo count."""
repos = await gitea.refresh_search_repos()
try:
await gitea.refresh_starred()
except Exception as exc:
logger.debug("starred refresh failed: %s", exc)
active, _inactive, _archived = classify_repos(repos)
semaphore = asyncio.Semaphore(_REFRESH_CONCURRENCY)
async def _one(repo: dict) -> None:
owner, _, name = repo["full_name"].partition("/")
async with semaphore:
await refresh_repo_snapshots(gitea, owner, name)
await asyncio.gather(*(_one(repo) for repo in active))
logger.info("snapshot refresh complete (%d active repos)", len(active))
return len(active)
async def snapshot_loop(clients_provider, classify_repos) -> None:
"""Periodic refresher, started by the app lifespan.
Sleeps briefly first: hydration already serves the pages,
the loop only has to keep the snapshots warm. Since 0.24.0
`clients_provider` is a zero-arg callable returning the live
forge clients (one per global connection); each is refreshed
in turn. The interval is re-read every cycle so the admin
settings panel can change it without a restart.
"""
await asyncio.sleep(10)
while True:
interval = get_settings().snapshot_interval
if interval <= 0:
logger.info("snapshot refresher disabled (interval <= 0)")
return
for client in clients_provider():
try:
await refresh_snapshots(client, classify_repos)
except Exception as exc:
logger.warning("snapshot refresh cycle failed: %s", exc)
await asyncio.sleep(interval)
def freshness_label() -> str | None:
"""« à l'instant » / « il y a N min » from the freshest snapshot.
Honest freshness display (chantier B contract): the sidebar
shows how old the Gitea data on screen can be.
"""
try:
latest = models.latest_snapshot_at()
except Exception:
return None
if not latest:
return None
try:
fetched = datetime.datetime.strptime(
latest, "%Y-%m-%d %H:%M:%S"
).replace(tzinfo=datetime.timezone.utc)
except ValueError:
return None
age = (
datetime.datetime.now(datetime.timezone.utc) - fetched
).total_seconds()
if age < 90:
return "à l'instant"
if age < 3600:
return f"il y a {int(age // 60)} min"
if age < 86400:
return f"il y a {int(age // 3600)} h"
return f"il y a {int(age // 86400)} j"
+126
View File
@@ -0,0 +1,126 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
Snippet vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : structure inchangée ; seule adaptation locale
(0.32.0, dev/prod) = les URL des apps et l'adresse du service
d'agents sont INJECTÉES par l'hôte depuis app.config
(draco_urls / agent_url, défauts = noms officiels) — aucun nom
public codé en dur dans ce gabarit ni dans draco-panneau.js.
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
(data-app="agent", agent_url), après Kanbante ; l'entrée
« Console Spark » du menu de la marque est conservée.
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #}
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ version }}">
<script>document.body.classList.add('has-draco-bar');</script>
<header class="draco-bar" data-current="kanbante">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d&#39;application" title="Changer d&#39;application">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="{{ draco_urls.obsigate }}/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="{{ draco_urls.flowdeck }}/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="{{ draco_urls.tiktak }}/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="{{ draco_urls.kanbante }}/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="{{ agent_url }}/">
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="Opérateur Kanbante">{{ draco_avatar }}</button>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script>
/* Configuration Kanbante de la barre commune (Fusion ACC-1).
Phase 1 : navigation entre apps et domaine mémorisé localement ;
le rail pilote le repli natif de la barre latérale, la palette
ouvre la recherche & commandes native (Ctrl+K) ; les alertes
unifiées passent par le panneau draco-alertes.js (Fusion #69,
agrégation à la demande, pas une fédération temps réel) ;
recherche partout : page /recherche (Fusion #68). */
window.DRACO_BAR_CONFIG = {
current: "kanbante",
/* v0.1.6 : user = initiale auth_user ou K (draco_avatar). Sans
cfg.user, le nouveau JS dériverait l'initiale du title
("Opérateur Kanbante" → "O"), qui est un libellé de rôle et
non un nom. Contrepartie documentée : syncAvatar() réécrit
aussi le title avec le nom d'affichage, donc l'infobulle
devient l'initiale après chargement (le libellé reste dans
le HTML servi). */
user: "{{ draco_avatar }}",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
var dire = function (m) {
var d = document.createElement("div");
d.textContent = m;
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:3000;background:#1f1f1f;color:#eee;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
document.body.appendChild(d);
setTimeout(function () { d.remove(); }, 3400);
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var b = document.getElementById("side-collapse"); if (b) b.click(); return; }
if (act === "palette") { location.href = "/recherche"; return; }
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { location.href = "/activity"; } return; }
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). */
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
return;
}
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
el.hidden = true;
}
}
};
</script>
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
<script>window.DRACO_PANNEAU_CONFIG = { api: "{{ agent_url }}" };</script>
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
<script defer src="/static/js/draco-alertes.js?v={{ version }}"></script>
+146
View File
@@ -0,0 +1,146 @@
{% include "_draco_bar.html" %}
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
Barre latérale unique pour TOUTES les pages applicatives :
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
mobile = tiroir hors-canvas ouvert par le burger de la topbar.
NE PAS dupliquer ce markup dans les pages.
Page active : `active_nav` (déduite du gabarit dans render()).
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
{% set nav_groups = [
("principal", "Principal", [
("home", "/", "🏠", "Projets"),
("focus", "/focus", "🎯", "Focus"),
("today", "/today", "🌅", "Aujourd'hui"),
("my", "/my", "👤", "Mes issues"),
]),
("rituels", "Rituels", [
("briefing", "/briefing", "📊", "Briefing"),
("standup", "/standup", "🗣️", "Standup"),
("digest", "/digest", "🧠", "Digest"),
]),
("pilotage", "Pilotage", [
("inflight", "/inflight", "🛩️", "En vol"),
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
("activity", "/activity", "📰", "Activité"),
]),
("outils", "Outils", [
("search", "/search", "🔍", "Recherche"),
("capture", "/capture", "⚡", "Capture"),
("archives", "/archives", "🗄️", "Archives"),
("wall", "/wall", "🖥️", "Wallboard"),
]),
] %}
{% if is_admin %}
{% set nav_groups = nav_groups + [
("admin", "Administration", [
("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
("admin_columns", "/admin/columns", "🧱", "Colonnes"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]),
] %}
{% endif %}
{% set active_group = namespace(key="principal") %}
{% for gkey, group, links in nav_groups %}
{% for key, href, icon, label in links %}
{% if active_nav == key %}{% set active_group.key = gkey %}{% endif %}
{% endfor %}
{% endfor %}
<script>
document.body.classList.add('with-sidebar');
try {
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
document.body.classList.add('sidebar-collapsed');
}
/* Accordéon exclusif (0.22.0) : une seule section ouverte.
La section de la page courante prime, sinon celle
mémorisée, sinon « principal ». */
localStorage.removeItem('kanbante-nav-closed');
var openGroup = {{ active_group.key | tojson }};
var stored = localStorage.getItem('kanbante-nav-open');
var groups = ['principal', 'rituels', 'pilotage', 'outils', 'admin'];
if ({{ (active_nav != "") | tojson }}) {
localStorage.setItem('kanbante-nav-open', openGroup);
} else if (stored === '') {
openGroup = '';
} else if (stored !== null && groups.indexOf(stored) >= 0) {
openGroup = stored;
}
groups.forEach(function (k) {
if (k !== openGroup) document.body.classList.add('nav-closed-' + k);
});
} catch (e) { /* stockage indisponible : état par défaut */ }
</script>
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
<div class="side-head">
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
<span class="logo-mark">📋</span>
<span class="side-label gradient-text">Kanbante</span>
</a>
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
</div>
{% if current_workspace and user_workspaces and user_workspaces|length > 1 %}
<form method="post" action="/workspace/switch" class="ws-switch">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<span class="ws-dot" style="background: {{ current_workspace.color }}" aria-hidden="true"></span>
<select name="workspace_id" class="ws-select" onchange="this.form.submit()" title="Changer d'espace de travail" aria-label="Espace de travail">
{% for ws in user_workspaces %}
<option value="{{ ws.id }}"{% if ws.id == current_workspace.id %} selected{% endif %}>{{ ws.name }}</option>
{% endfor %}
</select>
</form>
{% endif %}
<nav class="side-nav">
{% for gkey, group, links in nav_groups %}
<div class="side-section" data-group="{{ gkey }}">
<button type="button" class="side-group-btn" data-group-btn="{{ gkey }}" aria-expanded="true">
<span>{{ group }}</span>
<span class="side-chevron" aria-hidden="true">▾</span>
</button>
<div class="side-acc">
<div class="side-acc-inner">
{% for key, href, icon, label in links %}
<a href="{{ href }}" class="side-link{% if active_nav == key %} active{% endif %}" title="{{ label }}">
<span class="side-ico">{{ icon }}</span>
<span class="side-label">{{ label }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</nav>
<div class="side-foot">
{% if data_freshness %}
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
{% endif %}
{% if current_user %}
<a href="/profile" class="side-logout" title="Mon profil">👤<span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
{% endif %}
<span class="side-version side-label">v{{ version }}</span>
{% if auth_enabled %}
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
{% endif %}
</div>
</aside>
<div class="side-backdrop" id="side-backdrop" hidden></div>
<header class="app-header topbar">
<button type="button" class="nav-burger" id="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Ouvrir le menu">☰</button>
{% if owner is defined and repo is defined %}
<span class="crumb"><a href="/board/{{ owner }}/{{ repo }}">{{ owner }} / <strong>{{ repo }}</strong></a></span>
{% elif full_name is defined %}
<span class="crumb"><a href="/board/{{ full_name }}">{{ full_name }}</a></span>
{% endif %}
<div class="header-actions">
{% if repo_info is defined and repo_info and repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
{% endif %}
<button type="button" class="nav-pill palette-trigger topbar-search" title="Recherche et commandes (Ctrl+K)">🔍</button>
</div>
<script src="/static/js/nav.js" defer></script>
</header>
+1 -22
View File
@@ -8,28 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Les {{ events|length }} derniers événements du journal — déplacements, créations, fermetures et commentaires, tous projets confondus.</p>
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🛠️ Administration</h1>
<p class="page-sub">Utilisateurs, vue d'ensemble et journal d'administration.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>👥 Utilisateurs ({{ users|length }})</h2>
<table class="proj-table">
<thead>
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>{{ u.username }}</strong>{% if u.display_name %} <span class="muted">({{ u.display_name }})</span>{% endif %}{% if u.must_change_password %} <span class="chip-warn">mot de passe à changer</span>{% endif %}</td>
<td>
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="role" onchange="this.form.submit()" {% if current_user.id == u.id %}disabled{% endif %}>
<option value="member"{% if u.role == 'member' %} selected{% endif %}>Membre</option>
<option value="admin"{% if u.role == 'admin' %} selected{% endif %}>Admin</option>
</select>
</form>
</td>
<td>{% if u.active %}🟢 Actif{% else %}🔴 Désactivé{% endif %}</td>
<td>{% if u.draco_sub %}🔗 lié{% else %}—{% endif %}</td>
<td>{{ u.last_login_at or '—' }}</td>
<td class="admin-actions">
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini" {% if current_user.id == u.id %}disabled title="Impossible sur soi-même"{% endif %}>{% if u.active %}Désactiver{% else %}Activer{% endif %}</button>
</form>
<form method="post" action="/admin/users/{{ u.id }}/reset" class="inline-form" onsubmit="var p=this.querySelector('input[name=password]'); if(!p.value){p.focus(); return false;}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="password" placeholder="Nouveau mot de passe" autocomplete="new-password">
<button type="submit" class="btn-mini">Réinitialiser</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>➕ Nouvel utilisateur</h3>
<form method="post" action="/admin/users" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom d'utilisateur <input type="text" name="username" required autocomplete="off"></label>
<label>Nom d'affichage <input type="text" name="display_name" autocomplete="off"></label>
<label>Mot de passe temporaire <input type="password" name="password" required autocomplete="new-password"></label>
<label>Rôle
<select name="role">
<option value="member">Membre</option>
<option value="admin">Admin</option>
</select>
</label>
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
<p class="notes-hint">L'utilisateur rejoint automatiquement l'espace Principal comme membre et devra changer son mot de passe temporaire (rappel affiché ici et dans son profil).</p>
</section>
<section class="briefing-card">
<h2>🔭 Vue d'ensemble</h2>
<p>
<a href="/admin/connections">🔌 Connexions ({{ connections|length }})</a> ·
<a href="/admin/workspaces">🧭 Espaces ({{ workspaces|length }})</a> ·
<a href="/admin/settings">🎛️ Paramètres</a> ·
<a href="/archives">🗄️ Archives</a>
</p>
</section>
<section class="briefing-card">
<h2>📜 Journal d'administration</h2>
{% if audit %}
<table class="proj-table">
<thead><tr><th>Quand</th><th>Qui</th><th>Action</th><th>Détails</th></tr></thead>
<tbody>
{% for a in audit %}
<tr><td>{{ a.created_at }}</td><td>{{ a.username or '—' }}</td><td><code>{{ a.action }}</code></td><td>{{ a.details }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Aucune action d'administration journalisée pour l'instant.</p>
{% endif %}
</section>
</main>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Colonnes — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🧱 Colonnes du board</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>Comment marche le mapping</h2>
<p class="notes-hint">
<strong>Miroir</strong> : quand une carte entre dans une colonne, son <em>étiquette mappée</em> est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).<br>
<strong>Adoption</strong> : si une issue porte <em>exactement une</em> étiquette reconnue (étiquette mappée ou alias d'une colonne), le board adopte cette colonne — c'est ce qui permet à d'autres outils de piloter le board par étiquettes.<br>
<strong>Alias</strong> : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck <code>kanban_a_faire</code> sur la colonne À faire.<br>
<strong>Focus</strong> : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
</p>
</section>
{% for c in columns %}
<section class="briefing-card">
<h2>
<span class="ws-dot" style="background: {{ c.color }}"></span>
{{ c.name }}
<span class="muted"><code>{{ c.key }}</code> · {{ counts[c.key] }} carte(s) dans cet espace</span>
{% if c.kind == 'backlog' %}<span class="chip">système — défaut des cartes neuves</span>{% endif %}
{% if c.kind == 'done' %}<span class="chip">système — issues fermées</span>{% endif %}
</h2>
<form method="post" action="/admin/columns/{{ c.key }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" value="{{ c.name }}"></label>
<label>Couleur <input type="color" name="color" value="{{ c.color }}"></label>
{% if c.kind == 'flow' %}
<label>Étiquette mappée <input type="text" name="label" value="{{ c.label }}" placeholder="kanban:{{ c.key }}"></label>
<label>Alias (virgules) <input type="text" name="aliases" value="{{ c.aliases }}" placeholder="kanban_a_faire, a-faire"></label>
<label><input type="checkbox" name="in_focus" value="1"{% if c.in_focus %} checked{% endif %}> Dans Focus / Wallboard</label>
{% endif %}
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="admin-actions">
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
</form>
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
</form>
{% if c.kind == 'flow' %}
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Déplacer ses cartes vers
<select name="target">
{% for other in columns %}{% if other.key != c.key %}<option value="{{ other.key }}">{{ other.name }}</option>{% endif %}{% endfor %}
</select>
</label>
<button type="submit" class="btn-mini btn-danger">Supprimer la colonne</button>
</form>
{% endif %}
</p>
</section>
{% endfor %}
<section class="briefing-card">
<h2>➕ Nouvelle colonne</h2>
<form method="post" action="/admin/columns" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
<button type="submit" class="btn-save btn-inline">Ajouter (avant Terminé)</button>
</form>
</section>
<section class="briefing-card">
<h2>↩️ Réinitialiser</h2>
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Réinitialiser les colonnes</button>
</form>
</section>
</main>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Connexions — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🔌 Connexions aux forges</h1>
<p class="page-sub">Chaque connexion est un accès Gitea ou GitHub. Les jetons sont <strong>chiffrés au repos</strong> et ne sont <strong>jamais réaffichés</strong> — pour en changer un, saisis la nouvelle valeur.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% for c in connections %}
<section class="briefing-card">
<h2>{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }}
<span class="muted">({{ c.slug }})</span>
{% if c.owner_user_id %}<span class="chip-warn">personnelle</span>{% endif %}
{% if not c.enabled %}<span class="chip-warn">désactivée</span>{% endif %}
</h2>
<p class="muted">
{{ c.kind }} · {{ c.base_url }} · compte : <strong>{{ c.account_login or 'à tester' }}</strong> · jeton : {{ c.token_mask }}<br>
Webhook : <code>/webhooks/{{ c.slug }}</code> (secret {% if c.webhook_secret %}défini{% else %}hérité ou absent{% endif %})
{% if c.used_by %} · Utilisée par : {{ c.used_by|join(', ') }}{% endif %}
</p>
<form method="post" action="/admin/connections/{{ c.id }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
<label>Secret webhook (vide = inchangé, ou référence coffre:&lt;slug&gt;/&lt;nom&gt;) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="admin-actions">
<form method="post" action="/admin/connections/{{ c.id }}/test" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">🧪 Tester la connexion</button>
</form>
<form method="post" action="/admin/connections/{{ c.id }}/toggle" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">{% if c.enabled %}Désactiver{% else %}Activer{% endif %}</button>
</form>
<form method="post" action="/admin/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer définitivement cette connexion ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
</form>
</p>
</section>
{% endfor %}
<section class="briefing-card">
<h2>➕ Nouvelle connexion</h2>
<form method="post" action="/admin/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Type
<select name="kind">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
<label>Secret webhook (optionnel, ou référence coffre:&lt;slug&gt;/&lt;nom&gt;) <input type="password" name="hook_secret" autocomplete="off" placeholder="coffre:<slug>/<nom>"></label>
<button type="submit" class="btn-save btn-inline">Ajouter</button>
</form>
<p class="notes-hint">Secret webhook : une référence <code>coffre:&lt;slug&gt;/&lt;nom&gt;</code> est stockée telle quelle (jamais la valeur) et distribuée par ObsiGate à chaque webhook, puis oubliée ; octroi révoqué ou ObsiGate indisponible = webhook rejeté, sans repli. Les jetons d'accès forge, eux, restent chiffrés au repos (voir app/forge_crypto.py) et ne basculent pas encore sur une référence — voir docs/coffre-reference-obsigate.md. Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
</section>
</main>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Paramètres — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🎛️ Paramètres</h1>
<p class="page-sub"><strong>L'environnement amorce, le panneau gouverne.</strong> Une valeur saisie ici remplace celle de l'environnement (sans redémarrage pour la plupart) ; un champ vidé revient à la valeur d'environnement. Les secrets et les réglages d'amorçage (base de données, clé maître, session, draco) restent en environnement, volontairement.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<form method="post" action="/admin/settings">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table class="proj-table">
<thead><tr><th>Réglage</th><th>Environnement</th><th>Valeur du panneau (vide = env)</th><th>Effectif</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td><code>{{ r.key }}</code></td>
<td class="muted">{{ r.env_value }}</td>
<td><input type="text" name="setting_{{ r.key }}" value="{{ r.override if r.override is not none else '' }}" placeholder="{{ r.env_value }}"></td>
<td><strong>{{ r.effective }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
<p><button type="submit" class="btn-save btn-inline">Enregistrer</button></p>
</form>
<p class="notes-hint">Booléens : <code>true</code>/<code>false</code>. <code>cache_ttl</code> s'applique aux connexions construites après l'enregistrement ; l'intervalle des instantanés est relu à chaque cycle. Les horaires (<code>briefing_time</code>, <code>digest_time</code>) sont au format <code>HH:MM</code>, <code>digest_weekday</code> : 0 = lundi.</p>
</section>
</main>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Espaces — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🧭 Espaces de travail</h1>
<p class="page-sub">Un espace regroupe des connexions (forges) et des membres. Le board d'un projet appartient à l'espace : deux espaces peuvent ranger le même projet différemment. L'état de board, les notes et les épingles du jour sont propres à chaque espace.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% for d in details %}
{% set ws = d.ws %}
<section class="briefing-card">
<h2><span class="ws-dot" style="background: {{ ws.color }}"></span> {{ ws.name }} <span class="muted">({{ ws.slug }})</span></h2>
<form method="post" action="/admin/workspaces/{{ ws.id }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" value="{{ ws.name }}"></label>
<label>Couleur <input type="color" name="color" value="{{ ws.color }}"></label>
<button type="submit" class="btn-save btn-inline">Renommer</button>
</form>
<h3>🔌 Connexions de l'espace</h3>
<form method="post" action="/admin/workspaces/{{ ws.id }}/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for c in all_connections %}
<label>
<input type="checkbox" name="connection_id" value="{{ c.id }}"{% if c.id in d.conn_ids %} checked{% endif %}>
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }} <span class="muted">({{ c.slug }})</span>
</label>
{% endfor %}
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<h3>👥 Membres</h3>
{% if d.members %}
<table class="proj-table">
<thead><tr><th>Utilisateur</th><th>Rôle dans l'espace</th><th></th></tr></thead>
<tbody>
{% for m in d.members %}
<tr>
<td>{{ m.username }}{% if m.display_name %} <span class="muted">({{ m.display_name }})</span>{% endif %}</td>
<td>{{ m.role }}</td>
<td>
<form method="post" action="/admin/workspaces/{{ ws.id }}/members/{{ m.user_id }}/remove" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Retirer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Aucun membre explicite (les admins globaux voient tout).</p>
{% endif %}
<form method="post" action="/admin/workspaces/{{ ws.id }}/members" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Utilisateur
<select name="user_id">
{% for u in all_users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
</select>
</label>
<label>Rôle
<select name="role">
<option value="reader">Lecteur</option>
<option value="member" selected>Membre</option>
<option value="admin">Admin d'espace</option>
</select>
</label>
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
</form>
<h3>📦 Sélection de projets</h3>
<p class="notes-hint">Vide = <strong>tous</strong> les projets des connexions de l'espace. Sinon, une ligne par projet au format <code>slug-connexion:proprietaire/depot</code> (ex. <code>github:bruno/mon-projet</code>).</p>
<form method="post" action="/admin/workspaces/{{ ws.id }}/projects">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="selection" rows="4" style="width:100%">{% for cid, full in d.selection %}{% for c in all_connections %}{% if c.id == cid %}{{ c.slug }}:{{ full }}
{% endif %}{% endfor %}{% endfor %}</textarea>
<p><button type="submit" class="btn-save btn-inline">Enregistrer la sélection</button></p>
</form>
{% if is_global_admin %}
<form method="post" action="/admin/workspaces/{{ ws.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cet espace ? Les boards propres à cet espace seront perdus.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer l'espace</button>
</form>
{% endif %}
</section>
{% endfor %}
{% if is_global_admin %}
<section class="briefing-card">
<h2>➕ Nouvel espace</h2>
<form method="post" action="/admin/workspaces" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom <input type="text" name="name" required placeholder="Open source"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
</section>
{% endif %}
</main>
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Archives — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🗄️ Archives</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name if workspace else '—' }}</strong>. Deux sortes d'archives : <strong>locale</strong> (le projet disparaît des vues de cet espace, la forge n'est pas touchée, réversible en un clic) et <strong>sur la forge</strong> (propriété du dépôt, lecture seule là-bas). Les archives sortent des rituels et des instantanés, mais restent cherchables.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% macro actions(row, kind) %}
{% if can_write %}
{% if kind == 'suggested' or kind == 'forge' or kind == 'other' %}
<form method="post" action="/archives/local" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="1">
<button type="submit" class="btn-mini">📥 Archiver ici</button>
</form>
{% endif %}
{% if kind == 'local' %}
<form method="post" action="/archives/local" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="0">
<button type="submit" class="btn-mini">♻️ Restaurer</button>
</form>
<form method="post" action="/archives/purge" class="inline-form" onsubmit="return confirm('Purger l\'état local (board, notes, épingles) de ce projet dans cet espace ? La forge n\'est pas touchée.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<button type="submit" class="btn-mini btn-danger">Purger l'état local</button>
</form>
{% endif %}
{% if kind != 'forge' and not row.missing %}
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Archiver ce projet SUR LA FORGE ? Il y devient en lecture seule.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="1">
<button type="submit" class="btn-mini">🔒 Archiver sur la forge</button>
</form>
{% endif %}
{% if kind == 'forge' %}
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Désarchiver ce projet sur la forge ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<input type="hidden" name="archived" value="0">
<button type="submit" class="btn-mini">🔓 Désarchiver sur la forge</button>
</form>
{% endif %}
<form method="post" action="/archives/export" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
<button type="submit" class="btn-mini">⬇️ Récap (.md)</button>
</form>
{% endif %}
{% endmacro %}
{% macro rowline(row, kind) %}
<tr>
<td>
{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %}
<strong>{{ row.full_name }}</strong>
{% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}
{% if row.missing %}<span class="chip-warn">introuvable sur la forge</span>{% endif %}
</td>
<td>{{ row.updated_at or '—' }}{% if row.idle_days is not none %} <span class="muted">(il y a {{ row.idle_days }} j)</span>{% endif %}</td>
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
<td class="admin-actions">{{ actions(row, kind) }}</td>
</tr>
{% endmacro %}
<section class="briefing-card">
<h2>💤 À archiver ? (inactifs depuis {{ suggest_days }} jours et plus)</h2>
{% if groups.suggested %}
<form method="post" action="/archives/local-bulk">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table class="proj-table">
<thead><tr><th></th><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>
{% for row in groups.suggested %}
<tr>
<td><input type="checkbox" name="item" value="{{ row.conn_id }}:{{ row.full_name }}" checked></td>
<td>{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ row.full_name }}</strong> {% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}</td>
<td>{{ row.updated_at }} <span class="muted">(il y a {{ row.idle_days }} j)</span></td>
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
<td class="admin-actions">{{ actions(row, 'suggested') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><button type="submit" class="btn-save btn-inline">📥 Archiver localement la sélection</button></p>
</form>
{% else %}
<p class="muted">Rien à proposer : aucun projet inactif au-delà du seuil (réglable : <code>archive_suggest_days</code> dans Administration → Paramètres).</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>📥 Archives locales de cet espace ({{ groups.local|length }})</h2>
{% if groups.local %}
<table class="proj-table">
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>{% for row in groups.local %}{{ rowline(row, 'local') }}{% endfor %}</tbody>
</table>
{% else %}
<p class="muted">Aucune archive locale. « Archiver ici » masque un projet de cet espace sans toucher à la forge.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🔒 Archivés sur la forge ({{ groups.forge|length }})</h2>
{% if groups.forge %}
<table class="proj-table">
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
<tbody>{% for row in groups.forge %}{{ rowline(row, 'forge') }}{% endfor %}</tbody>
</table>
{% else %}
<p class="muted">Aucun dépôt archivé sur les forges de cet espace.</p>
{% endif %}
</section>
</main>
</body>
</html>
+41 -30
View File
@@ -5,36 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{{ repo }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
<script src="/static/vendor/sortable.min.js"></script>
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
<script src="/static/js/board.js" defer></script>
<script src="/static/js/board.js?v={{ version }}" defer></script>
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</nav>
</header>
{% include "_header.html" %}
{% if milestones %}
<div class="milestone-bar">
@@ -75,16 +53,31 @@
</select>
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required>
<button type="submit" class="btn-save btn-inline">Créer</button>
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
<form id="create-issue-form" class="composer">
<div class="composer-head">
<span class="composer-title">➕ Nouvelle issue</span>
<span class="composer-target">arrive dans
<strong>{{ column_labels.backlog }}</strong>
</span>
</div>
<div class="composer-main">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="Titre de l'issue..." required>
<button type="submit" class="btn-save btn-inline">Créer l'issue</button>
</div>
<details class="composer-desc" id="new-issue-desc">
<summary><span class="composer-desc-label">Ajouter une description</span><span class="composer-chevron" aria-hidden="true">▾</span></summary>
<textarea id="new-issue-body" rows="5" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<p class="composer-hint">Markdown accepté · Ctrl+Entrée pour créer l'issue</p>
</details>
</form>
</div>
<main class="board-container">
<div class="kanban-board">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
{% for col in column_keys %}
<div class="kanban-column"
data-column="{{ col }}">
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
@@ -167,6 +160,24 @@
</div>
<div id="detail-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div>
<div class="detail-actions">
<button id="detail-edit-btn" class="btn-sm" type="button">✏️ Modifier le titre et la description</button>
</div>
<form id="detail-edit-form" hidden>
<input type="text" id="detail-title-input" maxlength="255" placeholder="Titre">
<textarea id="detail-body-input" rows="10" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<div class="detail-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
<button type="button" id="detail-edit-cancel" class="btn-sm">Annuler</button>
</div>
</form>
<h3 class="detail-comments-title">📎 Pièces jointes</h3>
<div id="detail-assets"></div>
<form id="asset-form" class="asset-form">
<input type="file" id="asset-file">
<button type="submit" class="btn-sm">📎 Joindre le fichier</button>
</form>
<h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div>
<form id="comment-form">
+1 -20
View File
@@ -9,26 +9,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📊 <strong>Briefing</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
+8 -20
View File
@@ -8,25 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill active">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
@@ -55,7 +37,12 @@
<form method="post" action="/capture" class="capture-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Dépôt
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
<span class="repo-picker">
<input type="text" name="repo_full_name" id="capture-repo" value="{{ capture_repo }}" placeholder="Projets/kanbante" required autocomplete="off" role="combobox" aria-expanded="false" aria-controls="repo-listbox" aria-autocomplete="list">
<span class="repo-list" id="repo-listbox" role="listbox" hidden></span>
</span>
<span class="repo-hint" id="repo-hint" hidden>⚠️ Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
<span class="repo-tip">Astuce : tape un début, <kbd>Tab</kbd> complète segment par segment (<code>pr</code> → <code>Projets/</code> → <code>kan</code> → <code>kanbante</code>), ↑↓ + Entrée choisissent dans la liste.</span>
</label>
<label>Titre de l'issue
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
@@ -102,6 +89,7 @@
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
</footer>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/repo-picker.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
+186 -60
View File
@@ -9,74 +9,113 @@
<script defer src="/static/vendor/alpine.min.js"></script>
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill active">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<section class="hero">
<div class="hero-text">
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
</div>
<div class="stat-tiles">
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
<!-- En-tête de page (refonte 0.20.0) : titre sobre, bande KPI
unifiée, puis UNE barre d'outils (dans <main>) qui regroupe
onglets, filtre, tri et vue — fini les rangées éparses. -->
<section class="page-head">
<div class="page-head-row">
<div class="page-title">
<h1>Projets</h1>
<p class="page-sub">Vue d'ensemble de vos dépôts Gitea — Gitea reste la source de vérité.</p>
</div>
<div class="kpi-strip" aria-label="Indicateurs des 30 derniers jours">
<div class="kpi"><span class="kpi-value">{{ active_repos|length }}</span><span class="kpi-label">Projets actifs</span></div>
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.closed }}</span><span class="kpi-label">Issues fermées · 30 j</span></a>
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.moved }}</span><span class="kpi-label">Déplacements · 30 j</span></a>
<div class="kpi"><span class="kpi-value">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }}<span class="kpi-unit"> j</span>{% else %}—{% endif %}</span><span class="kpi-label">Temps de cycle moyen</span></div>
</div>
</div>
</section>
<main x-data="{ tab: 'active', search: '' }">
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
"archivé" = le vrai drapeau Gitea (repo.archived). -->
<div class="tabs">
<button :class="{ active: tab === 'active' }" @click="tab = 'active'">
🟢 Actifs ({{ active_repos|length }})
</button>
<button :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">
💤 Inactifs ({{ inactive_repos|length }})
</button>
<button :class="{ active: tab === 'archived' }" @click="tab = 'archived'">
🗄️ Archivés ({{ archived_repos|length }})
</button>
</div>
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
by Jinja), never interpolated into a JS string literal. -->
<div class="search-bar">
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
<kbd class="kbd">/</kbd>
<script>
// État du dashboard (#21/#22) : la vue grille/liste survit au
// rechargement (localStorage), lue avant qu'Alpine démarre.
function dashState() {
var view = 'grid';
try { view = localStorage.getItem('kanbante-dash-view') || 'grid'; } catch (e) { /* défaut */ }
return {
tab: 'active', search: '', view: view,
setView: function (v) {
this.view = v;
try { localStorage.setItem('kanbante-dash-view', v); } catch (e) { /* non-fatal */ }
}
};
}
</script>
<main x-data="dashState()">
<!-- Barre d'outils unique (0.20.0) : onglets segmentés,
filtre, tri/regroupement (#21) et vue (#22) alignés.
Onglets : "actif" = fenêtre glissante ; "archivé" =
le vrai drapeau Gitea (repo.archived). Le filtre lit
des data-* échappés par Jinja, jamais des littéraux JS. -->
<div class="dash-toolbar">
<div class="seg-tabs" role="tablist" aria-label="État des projets">
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
<a href="/archives" class="seg-link" title="Gérer les archives (locales et forge)">🗄️ Gérer</a>
</div>
<div class="search-bar toolbar-search">
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
<kbd class="kbd">/</kbd>
</div>
<label class="dash-tool">Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="review">🔍 En attente de revue ↓</option>
<option value="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
<option value="name">Nom A → Z</option>
</select>
</label>
<label class="dash-tool dash-tool-check">
<input type="checkbox" id="dash-group"> Grouper par propriétaire
</label>
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
<button type="button" :class="{ active: view === 'list' }" @click="setView('list')">☰ Liste</button>
</div>
</div>
{% macro project_card(repo, archived=False) %}
{% set full = repo.full_name %}
<div class="project-card{% if archived %} archived{% endif %}"
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
data-review="{{ stats.get(full, {}).get('review', 0) if stats else 0 }}"
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
{% if multi_conn %}<span class="conn-badge" title="Connexion {{ repo._conn_slug }}">{% if repo._conn_kind == 'github' %}🐙{% else %}🍵{% endif %} {{ repo._conn_slug }}</span>{% endif %}
{% if repo.health_dot %}
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
{% endif %}
<span class="date">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
<div class="card-menu-wrap">
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
<div class="card-menu" hidden>
<a href="/board/{{ full }}">📋 Board</a>
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
<a href="/health/{{ full }}">🩺 Santé</a>
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
{% if repo.html_url %}
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<h3>
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc">{{ repo.description or '—' }}</p>
{% if full in stats %}
@@ -88,14 +127,28 @@
</div>
{% endif %}
{% endif %}
{% if repo.next_milestone %}
{% set ms = repo.next_milestone %}
<div class="ms-block">
<div class="ms-line">
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% if ms.eta_late %} — ⚠️ après l'échéance{% endif %}{% endif %}</div>
{% endif %}
</div>
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
<a href="/board/{{ full }}" class="card-board-link">📋 Board</a>
{% if repo.forecast_label %}
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
{% endif %}
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
{% for col in column_keys %}
{% set n = stats[full].get(col, 0) %}
{% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
@@ -106,35 +159,108 @@
</div>
{% endif %}
</div>
{% if repo.activity %}
<div class="activity-line">
{% if repo.activity.closed or repo.activity.moved %}
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% if not active_repos %}
{% macro project_row(repo, archived=False) %}
{% set full = repo.full_name %}
{% set st = stats.get(full, {}) %}
<tr data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login|lower }}"
data-open="{{ repo.open_issues_count or 0 }}"
data-inflight="{{ (st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0)) }}"
data-updated="{{ repo.updated_at }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<td><a href="/project/{{ full }}"><strong>{{ repo.name }}</strong></a></td>
<td>{{ repo.owner.login }}</td>
<td>{{ repo.open_issues_count or 0 }}</td>
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
<td>{{ repo.updated_at[:10] }}</td>
</tr>
{% endmacro %}
{% macro project_table(repos, tab_name, archived=False) %}
<div x-show="tab === '{{ tab_name }}'">
<table class="proj-table">
<thead>
<tr>
<th data-sort="name">Projet</th>
<th data-sort="owner">Propriétaire</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="inflight">En vol</th>
<th>Prochain jalon</th>
<th>Santé</th>
<th data-sort="updated" data-first-dir="desc">Dernière activité</th>
</tr>
</thead>
<tbody>
{% for repo in repos %}{{ project_row(repo, archived) }}{% endfor %}
</tbody>
</table>
{% if not repos %}
<div class="empty-state">Aucun projet dans cette vue.</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="view === 'grid'">
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
<h3 class="pinned-title">⭐ Épinglés</h3>
<div class="project-grid" id="pinned-grid">
{% for repo in pinned_repos %}{{ project_card(repo) }}{% endfor %}
</div>
</section>
<div x-show="tab === 'active'" class="project-grid" id="grid-active">
{% for repo in active_listed %}{{ project_card(repo) }}{% endfor %}
{% if not active_listed and not pinned_repos %}
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
{% endif %}
</div>
<div x-show="tab === 'inactive'" class="project-grid">
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_repos %}
<div x-show="tab === 'inactive'" class="project-grid" id="grid-inactive">
{% for repo in inactive_listed %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_listed %}
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
{% endif %}
</div>
<div x-show="tab === 'archived'" class="project-grid">
<div x-show="tab === 'archived'" class="project-grid" id="grid-archived">
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
{% if not archived_repos %}
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
{% endif %}
</div>
</div>
<!-- Vue liste dense (#22) : mêmes onglets, mêmes données. -->
<div x-show="view === 'list'">
{{ project_table(active_repos, 'active') }}
{{ project_table(inactive_repos, 'inactive') }}
{{ project_table(archived_repos, 'archived', archived=True) }}
</div>
</main>
<footer>
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
</footer>
<script src="/static/js/live.js" defer></script>
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
<script src="/static/js/stars.js" defer></script>
<script src="/static/js/dashboard.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
+1 -17
View File
@@ -8,23 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill active">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -12
View File
@@ -7,18 +7,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
{% include "_header.html" %}
<main class="error-main">
<div class="error-box">
<h2>⚠️ Quelque chose s'est mal passé</h2>
+1 -22
View File
@@ -8,28 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill active">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p>
+79 -36
View File
@@ -8,35 +8,77 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill active">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🔮 Prévisions — « on finit quand ? »</h1>
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
<p class="forecast-summary">
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
</p>
<div class="search-bar forecast-filter">
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
</div>
</section>
{% for project in projects %}
<section class="briefing-card forecast-card">
<h2>📂 {{ project.full_name }}</h2>
{% if rows %}
<table class="proj-table pilot-table">
<thead>
<tr>
<th data-sort="project">Projet</th>
<th data-sort="title">Jalon</th>
<th data-sort="due">Échéance</th>
<th>Progression</th>
<th>ETA</th>
<th data-sort="gap" data-first-dir="desc">Écart</th>
<th data-sort="throughput">Débit / sem.</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.anchor %} id="ms-{{ row.owner }}-{{ row.repo }}"{% endif %}
data-project="{{ row.project|lower }}" data-title="{{ row.title|lower }}"
data-due="{{ row.due_sort }}" data-gap="{{ row.gap_days if row.gap_days is not none else '' }}"
data-throughput="{{ row.throughput if row.throughput is not none else '' }}"
data-search="{{ (row.project ~ ' ' ~ row.title)|lower }}">
<td><a href="/project/{{ row.project }}">{{ row.project }}</a></td>
<td><a href="/board/{{ row.project }}?milestone={{ row.title|urlencode }}"><strong>{{ row.title }}</strong></a></td>
<td>{{ row.due_label }}</td>
<td>
{% if row.total %}
<div class="progress ms-progress pilot-progress"><span style="width: {{ row.pct }}%"></span></div>
<span class="ms-meta">{{ row.closed }}/{{ row.total }}</span>
{% else %}—{% endif %}
</td>
<td>{{ row.eta or '—' }}</td>
<td>
{% if row.verdict == 'late' %}
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
{% elif row.verdict == 'ontime' %}
<span class="verdict verdict-ok">✅ dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
{% elif row.verdict == 'nodue' %}
<span class="verdict">sans échéance</span>
{% else %}
<span class="verdict">pas d'ETA</span>
{% endif %}
</td>
<td>{{ row.throughput if row.throughput is not none else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<section class="briefing-card"><p>Aucun jalon ouvert dans les projets actifs. Crée des jalons dans Gitea pour piloter par échéances.</p></section>
{% endif %}
<section class="briefing-card">
<h2>📈 Débit par projet</h2>
{% for project in projects %}
<div class="forecast-flex">
<div class="forecast-proj-name">📂 <a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
{% for bar in project.bars %}
{% set px = (bar.h * 0.42)|round|int %}
@@ -47,25 +89,17 @@
</svg>
<div class="forecast-nums">
{% if project.stats %}
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
{% else %}
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
<p>Débit : <strong>pas assez d'historique</strong>.</p>
{% endif %}
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
</div>
</div>
{% if project.milestones %}
<ul class="forecast-milestones">
{% for ms in project.milestones %}
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% else %}
<p>Aucun projet actif.</p>
{% endfor %}
</section>
{% else %}
<div class="briefing-card"><p>Aucun projet actif.</p></div>
{% endfor %}
</main>
<footer>
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
@@ -73,5 +107,14 @@
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
<script>
document.getElementById('ms-filter')?.addEventListener('input', function (e) {
var q = e.target.value.toLowerCase();
document.querySelectorAll('.pilot-table tbody tr').forEach(function (row) {
row.style.display = !q || (row.dataset.search || '').includes(q) ? '' : 'none';
});
});
</script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé du flux — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🩺 Santé du flux</h1>
<p>Tous les projets actifs, <strong>triés par sévérité</strong> — le rouge d'abord. Quelle colonne accumule (temps médian par colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong>. Cliquez un projet pour le détail et les actions rapides.</p>
</section>
{% if projects %}
<table class="proj-table pilot-table">
<thead>
<tr>
<th data-sort="severity">Santé</th>
<th data-sort="project">Projet</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="zombies" data-first-dir="desc">🧟 Zombies</th>
<th data-sort="slowest">🐌 Goulot</th>
<th></th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr data-severity="{{ {'red': 0, 'orange': 1, 'green': 2}.get(project.dot.level, 3) }}"
data-project="{{ project.full_name|lower }}" data-open="{{ project.open_count }}"
data-zombies="{{ project.zombies|length }}"
data-slowest="{{ project.slowest.median_days if project.slowest else '' }}">
<td><span class="health-dot {{ project.dot.level }}" title="{{ project.dot.title }}"></span></td>
<td><a href="/health/{{ project.full_name }}"><strong>{{ project.full_name }}</strong></a></td>
<td>{{ project.open_count }}</td>
<td>{{ project.zombies|length }}{% if project.zombies %} <span class="ms-meta">(≥ {{ project.zombie_days }} j)</span>{% endif %}</td>
<td>
{% if project.slowest %}
{{ project.column_labels[project.slowest.column] }} — médiane {{ project.slowest.median_days }} j
{% else %}<span class="ms-meta">pas assez de données</span>{% endif %}
</td>
<td><a href="/health/{{ project.full_name }}">détail →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<section class="briefing-card"><p>Aucun projet actif.</p></section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé — {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
{% if project_names %}
<div class="health-switch-bar">
<label for="health-switch">🩺 Projet :</label>
<select id="health-switch">
{% for name in project_names %}
<option value="{{ name }}"{% if name == full_name %} selected{% endif %}>{{ name }}</option>
{% endfor %}
{% if full_name not in project_names %}<option value="{{ full_name }}" selected>{{ full_name }}</option>{% endif %}
</select>
<a href="/health" class="health-back">← tous les projets</a>
</div>
{% endif %}
{% if health is none %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<p>Projet introuvable.</p>
</section>
{% else %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<h2>⏱️ Temps passé par colonne (journal)</h2>
{% if health.dwell %}
<table class="health-table">
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
<tbody>
{% for row in health.dwell %}
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
<td>{{ row.count }}</td>
<td>{{ row.median_days }} j</td>
<td>{{ row.avg_days }} j</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if health.slowest %}
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
{% endif %}
{% else %}
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
{% if health.zombies %}
<ul class="today-list">
{% for z in health.zombies %}
<li>
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
<span class="zombie-actions">
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
</span>
</li>
{% endfor %}
</ul>
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
{% else %}
<p>Aucune carte zombie. Le flux est propre. ✨</p>
{% endif %}
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · seuil zombie : KANBANTE_ZOMBIE_DAYS</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/health.js" defer></script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>En vol partout — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛩️</text></svg>">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>en vol</strong>, tous projets actifs confondus —
regroupé par colonne, les cartes les plus anciennes d'abord.</p>
<div class="inflight-summary">
<span class="inflight-total">🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol</span>
{% for group in groups %}
<span class="col-chip" style="background: {{ column_colors[group.column] }}20; color: {{ column_colors[group.column] }}">
{{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %}
</span>
{% endfor %}
</div>
{% if not groups %}
<p class="focus-empty">🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<h2>
<span class="filter-chip-dot" style="background: {{ column_colors[group.column] }}"></span>
{{ column_labels[group.column] }}
<span class="badge secondary">{{ group.cards|length }}</span>
{% if wip_globals.get(group.column) %}
<span class="inflight-wip">limite WIP globale : {{ wip_globals[group.column] }} (somme des projets qui en définissent une)</span>
{% endif %}
</h2>
<div class="focus-cards">
{% for card in group.cards %}
<div class="kanban-card focus-card">
<div class="card-id">
#{{ card.issue.number }}
<a class="inflight-repo" href="/board/{{ card.repo }}">{{ card.repo }}</a>
{% if card.issue.html_url %}
<a href="{{ card.issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ card.issue.title }}</div>
{% if card.issue.labels %}
<div class="card-labels">
{% for label in card.issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if card.issue.assignee %}
<img src="{{ card.issue.assignee.avatar_url }}" class="avatar" alt="{{ card.issue.assignee.login }}">
{% endif %}
<span class="age-badge{% if card.age_days >= aging_days * 2 %} stale{% elif card.age_days >= aging_days %} warn{% endif %}"
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">⏳ {{ card.age_days }} j dans la colonne</span>
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</main>
<footer>
Kanbante v{{ version }} · en vol partout
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Connexion — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="login-body">
<main class="login-wrap">
<section class="login-card">
<div class="login-brand">
<span class="login-mark" aria-hidden="true">📋</span>
<h1>Kanbante</h1>
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
</div>
{% if error %}
<p class="login-error" role="alert">⚠️ {{ error }}</p>
{% endif %}
<form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label>Nom d'utilisateur
<input type="text" name="username" autocomplete="username" required autofocus>
</label>
<label>Mot de passe
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
<a class="login-sso" href="{{ sso_authorize_url }}">🧭 Se connecter avec ObsiGate (SSO)</a>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
</section>
</main>
</body>
</html>
+57 -24
View File
@@ -5,46 +5,78 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mes issues — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 👤 <strong>Mes issues</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill active">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
{% if not user %}
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
{% else %}
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
{% if groups %}
<div class="my-filters" id="my-filters">
<div class="my-filters-row">
<div class="my-search-wrap">
<span class="my-search-icon" aria-hidden="true">🔍</span>
<input type="search" id="my-search" autocomplete="off"
placeholder="Rechercher un titre, un projet, un #numéro, une étiquette… ( / )">
</div>
<select id="my-repo" title="Filtrer par projet">
<option value="">Tous les projets</option>
{% for f in repo_facets %}
<option value="{{ f.full_name }}">{{ f.full_name }} ({{ f.count }})</option>
{% endfor %}
</select>
<select id="my-label" title="Filtrer par étiquette">
<option value="">Toutes les étiquettes</option>
{% for f in label_facets %}
<option value="{{ f.name }}">{{ f.name }} ({{ f.count }})</option>
{% endfor %}
</select>
<select id="my-sort" title="Trier les résultats">
<option value="recent">Activité récente d'abord</option>
<option value="oldest">Plus anciennes d'abord</option>
<option value="title">Titre (A → Z)</option>
<option value="repo">Projet (A → Z)</option>
</select>
</div>
<div class="my-filters-row my-filters-meta">
<div class="my-col-chips" id="my-col-chips" role="group" aria-label="Filtrer par colonne">
<button type="button" class="filter-chip on" data-col="">Toutes les colonnes</button>
{% for f in column_facets %}
<button type="button" class="filter-chip" data-col="{{ f.key }}" aria-pressed="false">
<span class="filter-chip-dot" style="background: {{ column_colors[f.key] }}"></span>{{ column_labels[f.key] }} <span class="filter-chip-count">{{ f.count }}</span>
</button>
{% endfor %}
</div>
<span class="my-count" id="my-count" role="status"></span>
<button type="button" id="my-reset" class="btn-sm" hidden>↺ Réinitialiser les filtres</button>
</div>
</div>
{% endif %}
<div class="empty-state" id="my-empty" hidden>🔍 Aucune issue ne correspond à ces filtres.
<button type="button" id="my-empty-reset" class="btn-sm">↺ Réinitialiser les filtres</button>
</div>
{% if not groups %}
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<section class="focus-group" data-repo="{{ group.full_name }}">
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
<span class="badge secondary">{{ group.rows|length }}</span></h2>
<span class="badge secondary" data-group-count>{{ group.rows|length }}</span></h2>
<div class="issue-results">
{% for row in group.rows %}
{% set issue = row.issue %}
<div class="issue-row">
<div class="issue-row"
data-column="{{ row.column }}"
data-labels="{{ (issue.labels or [])|map(attribute='name')|join(' ') }}"
data-updated="{{ issue.updated_at or '' }}"
data-title="{{ issue.title }}"
data-search="#{{ issue.number }} {{ issue.title }} {{ group.full_name }} {{ (issue.labels or [])|map(attribute='name')|join(' ') }}">
<span class="issue-state">🟢</span>
<a class="issue-main" href="/board/{{ group.full_name }}">
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
@@ -65,6 +97,7 @@
<footer>
Kanbante v{{ version }} · mes issues
</footer>
<script src="/static/js/my.js?v={{ version }}" defer></script>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Mon profil — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>👤 Mon profil</h1>
<p class="page-sub">{{ profile_user.username }} · rôle : {{ profile_user.role }}</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
{% if profile_user.must_change_password %}<p class="chip-warn">⚠️ Ton mot de passe est temporaire : change-le ci-dessous.</p>{% endif %}
<section class="briefing-card">
<h2>Informations</h2>
<form method="post" action="/profile" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom d'affichage <input type="text" name="display_name" value="{{ profile_user.display_name or '' }}"></label>
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="muted">Mes espaces :
{% for m in memberships %}<span class="ws-dot" style="background: {{ m.color }}"></span> {{ m.name }} ({{ m.member_role }}){% if not loop.last %} · {% endif %}
{% else %}aucun{% endfor %}
</p>
</section>
<section class="briefing-card">
<h2>🔑 Mot de passe</h2>
{% if profile_user.id %}
<form method="post" action="/profile/password" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Mot de passe actuel <input type="password" name="current_password" required autocomplete="current-password"></label>
<label>Nouveau mot de passe <input type="password" name="new_password" required autocomplete="new-password"></label>
<button type="submit" class="btn-save btn-inline">Changer</button>
</form>
{% else %}
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : le mot de passe se change dans la configuration du déploiement.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🗝️ Jetons d'API (MCP)</h2>
<p class="muted">Pour les consommateurs machine — le service d'agents, en premier lieu. Un jeton (préfixe <code>kbt_</code>) authentifie uniquement <code>/mcp</code> et <code>/api/</code>, jamais l'interface web. Il se présente en en-tête <code>Authorization: Bearer kbt_…</code>. Le texte clair n'est affiché qu'à la création ; ensuite, seule son empreinte est conservée.</p>
{% if profile_user.id %}
{% if nouveau_jeton %}
<p>✅ Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
{% endif %}
{% if api_tokens %}
<ul>
{% for t in api_tokens %}
<li>
<strong>{{ t.name }}</strong> · créé le {{ t.created_at }}
· {% if t.last_used_at %}dernier usage : {{ t.last_used_at }}{% else %}jamais utilisé{% endif %}
{% if t.revoked_at %} · <span class="chip-warn">révoqué le {{ t.revoked_at }}</span>
{% else %}
<form method="post" action="/profile/tokens/{{ t.id }}/revoke" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Révoquer</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Aucun jeton pour l'instant.</p>
{% endif %}
<form method="post" action="/profile/tokens" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom du jeton <input type="text" name="name" placeholder="service-agents" required></label>
<button type="submit" class="btn-save btn-inline">Créer un jeton</button>
</form>
{% else %}
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : les jetons appartiennent aux comptes de la base — connecte-toi avec ton compte personnel pour en créer un.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>✦ Liaison draco (SSO)</h2>
{% if profile_user.draco_sub %}
<p>Compte lié à l'identité draco <code>{{ profile_user.draco_sub }}</code> — la connexion via ObsiGate ouvre directement cette session.</p>
<form method="post" action="/profile/draco/unlink" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">Délier</button>
</form>
{% else %}
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate).</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🔌 Mes connexions personnelles</h2>
<p class="muted">Visibles de toi seul. Pour les utiliser, ajoute-les à un de tes espaces (ou demande à un admin d'espace).</p>
{% for c in my_connections %}
<p>
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ c.name }}</strong>
<span class="muted">({{ c.slug }}) · {{ c.base_url }} · compte : {{ c.account_login or 'à tester' }} · jeton : {{ c.token_mask }}</span>
<form method="post" action="/profile/connections/{{ c.id }}/test" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini">🧪 Tester</button>
</form>
<form method="post" action="/profile/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette connexion personnelle ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
</form>
</p>
{% endfor %}
<form method="post" action="/profile/connections" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Type
<select name="kind">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Nom <input type="text" name="name" required></label>
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com"></label>
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
<button type="submit" class="btn-save btn-inline">Ajouter</button>
</form>
</section>
</main>
</body>
</html>
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ full_name }} — Projet · Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<meta name="csrf-token" content="{{ csrf_token }}">
<meta name="repo-full-name" content="{{ full_name }}">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="project-main">
<div class="proj-head">
<div>
<h1>📦 {{ repo_info.name }}</h1>
<p class="proj-sub">{{ owner }} · {{ repo_info.description or 'Aucune description dans Gitea.' }}</p>
</div>
{% if health_dot %}
<a href="/health/{{ full_name }}" class="health-chip" title="{{ health_dot.tooltip }}">
<span class="health-dot {{ health_dot.level }}"></span> Santé du flux
</a>
{% endif %}
</div>
<div class="proj-actions">
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">📋 Ouvrir le board</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/triage/{{ full_name }}" class="nav-pill">📥 Triage</a>
<a href="/health/{{ full_name }}" class="nav-pill">🩺 Santé</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall?repo={{ full_name }}" class="nav-pill">🖥️ Wallboard</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</div>
<div class="proj-grid">
<section class="proj-panel">
<h2>🎯 Jalons ouverts</h2>
{% if milestones %}
{% for ms in milestones %}
<div class="proj-ms">
<div class="ms-line">
<a href="/board/{{ full_name }}?milestone={{ ms.title|urlencode }}"><strong>{{ ms.title }}</strong></a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% endif %}</div>
{% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %}
{% if ms.id %}
<details class="ms-edit">
<summary>✏️ Modifier / fermer</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
<span class="ms-hint">vide = retirer l'échéance</span></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
</div>
</form>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
data-confirm="Fermer le jalon « {{ ms.title }} » ? Ses issues restent ouvertes, sans jalon actif.">
<button type="submit" class="btn-mini btn-danger">Fermer le jalon</button>
</form>
</details>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="proj-empty">Aucun jalon ouvert pour le moment.</p>
{% endif %}
<details class="ms-edit ms-new">
<summary>➕ Nouveau jalon</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones">
<label>Titre <input type="text" name="title" maxlength="255" required placeholder="Ex. : Version 2.0"></label>
<label>Échéance <input type="date" name="due_on"></label>
<label>Description <textarea name="description" rows="2" maxlength="2000" placeholder="Optionnelle"></textarea></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">🎯 Créer le jalon</button>
</div>
</form>
</details>
</section>
<section class="proj-panel">
<h2>🩺 Santé du flux</h2>
{% if health %}
<ul class="proj-list">
<li>📂 <strong>{{ health.open_count }}</strong> cartes ouvertes</li>
{% if health.slowest %}
<li>🐢 Goulot : <strong>{{ COLUMN_LABELS[health.slowest.column] }}</strong> — séjour médian {{ health.slowest.median_days }} j</li>
{% else %}
<li>🐢 Pas encore de goulot mesurable (historique trop court).</li>
{% endif %}
<li>🧟 Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
</ul>
{% if health.zombies %}
<ul class="proj-list proj-zombies">
{% for z in health.zombies[:5] %}
<li><a href="{{ z.html_url }}" target="_blank" rel="noopener">#{{ z.number }}</a> {{ z.title }} <span class="proj-dim">· {{ z.column_label }} · {{ z.days_inactive }} j</span></li>
{% endfor %}
</ul>
{% endif %}
<p><a href="/health/{{ full_name }}">Détail de la santé →</a></p>
{% else %}
<p class="proj-empty">Santé indisponible pour le moment.</p>
{% endif %}
</section>
<section class="proj-panel">
<h2>🔮 Prévisions</h2>
{% if throughput %}
<ul class="proj-list">
<li>Débit : <strong>{{ throughput.avg }}</strong> fermetures / semaine en moyenne (min {{ throughput.min }} · max {{ throughput.max }}, 4 dernières semaines complètes)</li>
<li>Issues ouvertes : <strong>{{ repo_info.open_issues_count or 0 }}</strong></li>
</ul>
{% else %}
<p class="proj-empty">Pas assez d'historique de fermetures pour prévoir — le journal se remplit au fil des cartes terminées.</p>
{% endif %}
<p><a href="/forecast">Toutes les prévisions →</a></p>
</section>
<section class="proj-panel">
<h2>🕘 Activité récente</h2>
{% if events %}
<ul class="proj-list">
{% for e in events %}
<li>{{ EVENT_ICONS.get(e.event_type, '•') }} <a href="/board/{{ full_name }}">#{{ e.issue_number }}</a> — {{ e.event_type }}{% if e.to_column %} → {{ COLUMN_LABELS.get(e.to_column, e.to_column) }}{% endif %} <span class="proj-dim">· {{ e.created_at[:16].replace('T', ' ') }}</span></li>
{% endfor %}
</ul>
{% else %}
<p class="proj-empty">Aucun événement du journal pour ce projet pour le moment.</p>
{% endif %}
<p><a href="/activity">Journal complet →</a></p>
</section>
<section class="proj-panel proj-notes">
<h2>📝 Notes du projet</h2>
{% if notes %}
<div class="proj-notes-body">{{ notes }}</div>
{% else %}
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « 🗒️ Notes » du board et vivent avec le projet.</p>
{% endif %}
<p><a href="/board/{{ full_name }}">Ouvrir le board pour éditer →</a></p>
</section>
</div>
</main>
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %}
<script src="/static/js/project.js?v={{ version }}" defer></script>
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rechercher partout — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/recherche.css">
</head>
<body>
{% include "_header.html" %}
<main class="rp-page" id="recherche" data-host="kanbante"
data-base-obsigate="{{ draco_urls.obsigate }}"
data-base-flowdeck="{{ draco_urls.flowdeck }}"
data-base-tiktak="{{ draco_urls.tiktak }}"
data-base-kanbante="{{ draco_urls.kanbante }}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
<footer>
Kanbante v{{ version }} · recherche partout
</footer>
<script src="/static/js/recherche.js" defer></script>
</body>
</html>
+1 -12
View File
@@ -8,18 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill active">⚙️ Règles</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -20
View File
@@ -8,26 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🔍 <strong>Recherche</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill active">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<form method="get" action="/search" class="search-hero">
+1 -10
View File
@@ -10,16 +10,7 @@
<script src="/static/vendor/purify.min.js"></script>
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
{% if markdown %}
+1 -11
View File
@@ -8,17 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -19
View File
@@ -8,25 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill active">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="today-main">
<h1 class="today-title">🌅 {{ date_label }}</h1>
+1 -19
View File
@@ -8,25 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/standup" class="nav-pill">🗣️ Standup</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/rules" class="nav-pill">⚙️ Règles</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
+1 -8
View File
@@ -8,14 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
<a href="/triage" class="nav-pill">📥 Autre projet</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
{% include "_header.html" %}
<main class="triage-main" data-repo="{{ full_name }}">
<div class="triage-head">
+3
View File
@@ -45,6 +45,9 @@
{% endif %}
</li>
{% endfor %}
{% if slide.cards_total > slide.cards|length %}
<li class="wall-card wall-more">… +{{ slide.cards_total - slide.cards|length }} autres cartes en vol</li>
{% endif %}
</ul>
{% else %}
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
+9
View File
@@ -6,6 +6,15 @@ services:
- "8080:8080"
environment:
- GITEA_URL=${GITEA_URL:-https://git.dracodev.net}
# Noms publics dracodev (dev/prod) : défauts = noms officiels ;
# l'environnement dev déclare ses noms en -dev dans son .env.
- KANBANTE_PUBLIC_URL=${KANBANTE_PUBLIC_URL:-https://kanbante.dracodev.net}
- DRACO_SSO_URL=${DRACO_SSO_URL:-https://og.dracodev.net}
- DRACO_FLOWDECK_URL=${DRACO_FLOWDECK_URL:-https://flowdeck.dracodev.net}
- DRACO_TIKTAK_URL=${DRACO_TIKTAK_URL:-https://tiktak.dracodev.net}
- AGENT_URL=${AGENT_URL:-https://agent.dracodev.net}
- AGENT_EVENEMENTS_URL=${AGENT_EVENEMENTS_URL:-}
- AGENT_EVENEMENTS_CLE=${AGENT_EVENEMENTS_CLE:-}
- GITEA_TOKEN=${GITEA_TOKEN}
- KANBANTE_DB_PATH=/data/kanbante.db
# Strongly recommended: without these, the app is unauthenticated.
+127
View File
@@ -0,0 +1,127 @@
# Kanbante — Références coffre ObsiGate (Phase 4, issue ObsiGate #22)
Patron « même patron, sans variante » de la spec « Coffre de secrets
ObsiGate » §12, appliqué à Kanbante sur la branche
`feat/coffre-reference-obsigate` (aucun merge, aucune PR dans ce lot).
## 1. Inventaire honnête des secrets externes (vérifié dans le code, main 0.37.1)
| Secret | Stockage actuel | Statut dans ce lot |
|---|---|---|
| Jeton d'accès forge (`forge_connections.token_encrypted`, admin + profil) | **Chiffré Fernet au repos** (`app/forge_crypto.py`, clé `KANBANTE_MASTER_KEY` ou clé générée en base) ; jamais réaffiché (masque `••••••••`) | **Non migré, à raison** : ce n'est pas un secret en clair. Le basculer sur une référence exigerait une distribution *à chaque appel* forge, car le registre met en cache les clients (`ForgeRegistry`) : distribuer une fois à la construction puis garder le client reviendrait à conserver la valeur en mémoire pour la durée de vie de l'app, ce qui violerait le « utilise, puis oublie » de la spec. Reste à faire dédié (voir §5). |
| Secret webhook (`forge_connections.webhook_secret`, admin) | **En clair en base** (colonne `TEXT`, saisie via `/admin/connections`) ; repli environnement `KANBANTE_WEBHOOK_SECRET` | **Migré** : une référence `coffre:<slug>/<nom>` saisie dans ce même champ est stockée telle quelle, distribuée à chaque webhook, puis oubliée (§2). Les valeurs historiques en clair restent acceptées (compatibilité, migration opportuniste), sans jamais être réécrites. |
| `GITEA_TOKEN`, `KANBANTE_NTFY_TOKEN`, `KANBANTE_OBSIGATE_MCP_TOKEN`, `KANBANTE_CAPTURE_TOKEN`, `AGENT_EVENEMENTS_CLE`, secrets SSO/session | Variables d'environnement d'exploitation uniquement, jamais persistées par l'app, non surchargeables en base (`OVERRIDABLE_SETTINGS` les exclut explicitement) | **Hors périmètre applicatif** : leur bascule éventuelle vers le coffre relève du déploiement (l'opérateur), pas d'un champ de saisie de Kanbante. Le jeton MCP ObsiGate (`KANBANTE_OBSIGATE_MCP_TOKEN`) n'est **pas** réutilisé ici : le consommateur coffre a son propre jeton, conformément à l'issue. |
| Mots de passe utilisateurs, jetons API Kanbante | Hashés (`password_hash`, `token_hash`) | Hors périmètre (pas des secrets externes). |
Conclusion : Kanbante ne stockait qu'**un** secret externe en clair
persisté par l'app — le secret webhook — et c'est celui qui est migré.
Le client générique ci-dessous reste disponible pour le premier usage
réel suivant (jetons forge, voir §5).
## 2. Ce qui est implémenté
### `app/coffre_client.py` (nouveau, dépendance : `httpx` uniquement)
- `est_reference()` / `parse_reference()` : validation du format
`coffre:<slug>/<nom>` (préfixe, un seul `/`, slug et nom non vides).
- `config_depuis_env()` : `KANBANTE_OBSIGATE_URL` (base ObsiGate, ex.
`https://og.dracodev.net`), `KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`
(jeton du consommateur « kanbante » enregistré dans ObsiGate),
`KANBANTE_COFFRE_TIMEOUT` (défaut 10 s). Absence de l'un des deux
premiers → `CoffreNonConfigure`, échec propre.
- `distribuer()` / `distribuer_async()` : `POST /api/coffres/distribuer`
avec JSON `{reference, contexte_type, contexte_id}` et
`Authorization: Bearer <jeton consommateur>` — contrat vérifié dans le
routeur ObsiGate (`backend/routers/coffres.py`, main). La valeur
retournée n'est **jamais journalisée, jamais persistée, jamais mise
en cache**.
- `usage_distribue()` / `usage_distribue_async()` : gestionnaires de
contexte « un seul usage » — la valeur ne doit pas sortir du bloc.
- Erreurs typées, sans repli, avec messages explicites orientant vers
ObsiGate (voir §3). Aucune valeur distribuée n'apparaît dans un
message, un log ou une réponse HTTP.
### Webhook (`app/main.py`, `_handle_forge_webhook`)
- Si `webhook_secret` stocké est une référence : distribution
asynchrone à chaque webhook (`contexte_type="webhook"`,
`contexte_id=<slug de la connexion>`), HMAC vérifié avec la valeur en
mémoire, puis oubli (fin de portée de la fonction).
- Échec de distribution → **aucun repli** sur le secret
d'environnement ni sur une ancienne valeur (il n'y en a pas de
stockée) : `CoffreAccesRefuse` → HTTP 401 « Accès révoqué ou refusé
dans ObsiGate : ré-accorder l'octroi… » ; autre `CoffreErreur`
(ObsiGate indisponible, consommateur non configuré) → HTTP 503
explicite. Le webhook doit être renvoyé après correction côté
ObsiGate.
- Si la valeur stockée n'est pas une référence : comportement historique
inchangé (secret en clair, puis repli environnement).
### Formulaire admin (`app/main.py` + `app/templates/admin_connections.html`)
- Le champ « Secret webhook » accepte désormais une référence
`coffre:<slug>/<nom>` (création et modification). Une saisie
commençant par `coffre:` mais mal formée est **refusée** (redirection
d'erreur), pour ne jamais la stocker comme secret en clair historique
par accident.
- Le champ « Jeton d'accès » est **inchangé** : une référence saisie
là serait aujourd'hui chiffrée Fernet puis rejetée par la forge —
c'est volontaire tant que la distribution par appel (§5) n'existe pas.
Le bandeau du formulaire le signale.
Aucun changement de schéma n'a été nécessaire : la référence est
stockée dans la colonne `webhook_secret` existante, détectée par son
préfixe `coffre:` (un secret historique ne peut pas porter ce préfixe
sans avoir été saisi comme tel, et ce cas est désormais validé).
## 3. Correspondance des erreurs ObsiGate
| Réponse ObsiGate | Erreur Kanbante | Effet |
|---|---|---|
| 401 `consommateur_invalide`, 403 `consommateur_revoque` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder » |
| 403 `octroi_absent` / `octroi_revoque` / `octroi_expire`, 410 `secret_expire` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder / renouveler » |
| 400 `reference_invalide` / `contexte_manquant` | `CoffreReferenceInvalide` / `CoffreContexteManquant` | Webhook 503 (erreur de configuration) |
| 503 `kek_absente` / `crypto_indisponible`, timeout, connexion refusée, 200 sans valeur | `CoffreIndisponible` | Webhook 503, renvoyer le webhook plus tard |
Dans tous les cas : pas de valeur en cache, pas de continuation.
## 4. Tests
`tests/test_coffre_client.py` (faux endpoint `httpx.MockTransport`,
valeurs fictives `TEST-VALUE-XXXX` uniquement, aucune instance réelle) :
- validation de références (valides / invalides) ;
- succès sync + async, avec vérification de la requête (chemin, Bearer,
corps `reference` + contexte) ;
- refus (octroi révoqué/expiré/absent, consommateur révoqué/invalide,
secret expiré) → `CoffreAccesRefuse` avec message explicite ;
- indisponibilité (503, erreur réseau, 200 sans valeur) →
`CoffreIndisponible` ;
- référence invalide / contexte manquant / configuration absente :
aucun appel HTTP ;
- webhook de bout en bout (TestClient) : octroi révoqué → 401 explicite
sans repli ; distribution réussie → HMAC accepté et ligne en base
toujours porteuse de la référence, jamais de la valeur.
## 5. Restes à faire (exigent validation par Bruno sur instance réelle)
1. **Validation réelle** : enregistrer le consommateur « kanbante »
dans ObsiGate, créer un octroi vers un secret webhook de test,
configurer `KANBANTE_OBSIGATE_URL` +
`KANBANTE_COFFRE_CONSOMMATEUR_TOKEN` sur l'instance Kanbante, saisir
la référence dans `/admin/connections`, envoyer un vrai webhook
Gitea, vérifier l'acceptation, puis révoquer l'octroi et vérifier
le 401 explicite, puis couper ObsiGate et vérifier le 503.
2. **Jetons forge par référence** : distribution à chaque appel dans
`GiteaClient`/`GitHubClient` (ou client jetable par requête), sans
passer par le cache `ForgeRegistry` ; seulement alors, accepter une
référence dans le champ « Jeton d'accès » et documenter la migration
des connexions existantes (chiffrées Fernet → références).
3. **Migration opportuniste des secrets webhook historiques** : proposer
en admin la conversion d'un secret en clair existant vers une
référence (l'admin crée le secret + l'octroi dans ObsiGate, puis
remplace la valeur par la référence) — aucun outil automatique ne
copie une valeur réelle vers ObsiGate dans ce lot.
4. **CI complète** : la suite n'a été exécutée que localement dans le
sandbox (voir rapport) ; le run CI Gitea de la branche reste à
observer avant toute PR.
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.13.0"
version = "0.37.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
"httpx>=0.28",
"python-dotenv>=1.0",
"python-multipart>=0.0.18",
"cryptography>=42",
]
[tool.setuptools.packages.find]
+58
View File
@@ -0,0 +1,58 @@
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
.draco-alertes-overlay {
position: fixed; inset: 0; z-index: 30000;
display: none; align-items: flex-start; justify-content: center;
padding: 8vh 16px 16px;
background: rgb(0 0 0 / .55); overflow-y: auto;
}
.draco-alertes-box {
width: 720px; max-width: 100%;
background: var(--bg-primary, #1f1f1f);
color: var(--text, #eceee9);
border: 1px solid var(--border, #333);
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
padding: 16px 18px 18px;
}
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
.draco-alertes-close {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
}
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
.draco-alertes-item {
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
padding: 9px 12px; margin: 6px 0;
}
.draco-alertes-item.is-active { border-color: #e8762c; }
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
.draco-alertes-title:hover { text-decoration: underline; }
.draco-alertes-detail {
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
.draco-alertes-empty a { color: inherit; }
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
.draco-alertes-tab {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 999px; padding: 5px 11px;
cursor: pointer; font-size: 13px; line-height: 1.3;
}
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
.draco-alertes-filtre input {
flex: 1; min-width: 0; background: transparent; color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
font-size: 13px;
}
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
.draco-alertes-compte:empty { display: none; }
.draco-alertes-item.is-error { border-color: #c2437e; }
+200
View File
@@ -0,0 +1,200 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
.draco-bar-centre { justify-content: center; }
.draco-bar-droite { justify-content: flex-end; }
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
.draco-apps-menu {
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
background: transparent; color: var(--ink); font: inherit; text-align: left;
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
border-bottom: 1px solid var(--line); margin-bottom: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
color: var(--ink); text-decoration: none;
}
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
.draco-apptab {
position: relative; display: flex; align-items: center; gap: 8px;
height: 44px; padding: 0 9px; border-radius: 12px;
color: var(--ink); text-decoration: none; background: transparent;
transition: background .18s ease;
}
.draco-apptab:hover { background: var(--surface-2); }
.draco-apptab-ico {
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
.draco-badge {
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
display: grid; place-items: center; border: 2px solid var(--surface);
}
.draco-badge[hidden] { display: none; }
/* Recherche */
.draco-search {
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
}
.draco-search kbd {
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}
/* Domaines — contrôle segmenté */
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
.draco-domaines button {
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
}
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
}
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
display: grid; place-items: center; flex: none;
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
cursor: pointer;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
.draco-bar-rappel {
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
}
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
@media (max-width: 1440px) {
.draco-brand-name, .draco-domaines-lbl { display: none; }
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche et les domaines disparaissent :
chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
restaient affichés et la barre débordait sur téléphone.
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
barre complète ne tenait pas (fenêtres réduites, tablettes,
webviews) alors que les apps sont déjà en mise en page mobile.) */
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
.draco-apptab { display: none; }
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
}
+189
View File
@@ -0,0 +1,189 @@
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
prefers-color-scheme) et suit les bascules en direct. Toutes les
couleurs passent par les variables ci-dessous — en-tête et fenêtre
partagent donc toujours le même mode. */
.draco-panneau {
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
--p-accent: #7c5cd6; --p-lien: #5b3df0;
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
background: var(--p-fond); color: var(--p-texte); z-index: 400;
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease;
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
}
.draco-panneau[data-draco-theme="dark"] {
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
--p-lien: #b39df5;
}
.draco-panneau.open { transform: translateX(0); }
.draco-poignee {
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
cursor: ew-resize; z-index: 5; touch-action: none;
}
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
@media (max-width: 700px) { .draco-poignee { display: none; } }
.draco-panneau-hd {
display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
}
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
/* Barre des discussions (façon Muse) */
.draco-disc-bar {
display: flex; align-items: center; gap: 7px;
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-disc-bar .d-titre {
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-disc-bar .d-btn {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
}
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
.draco-discussions {
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
max-height: 46%; overflow-y: auto; padding: 8px 12px;
}
.draco-discussions .d-section {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
}
.draco-discussions .d-section:first-child { margin-top: 0; }
.draco-discussions .d-item {
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
}
.draco-discussions .d-item:hover { background: var(--p-chip); }
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
.draco-discussions .d-actions button {
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
}
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
.draco-discussions .d-vide, .draco-discussions .d-chargement {
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
}
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
.draco-panneau-outils button {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
.draco-appro {
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px;
}
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
.draco-appro .resume { margin: 5px 0 8px; }
.draco-appro .btns { display: flex; gap: 7px; }
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; }
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
}
.draco-panneau-form button {
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
}
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
.draco-msg.md code, .m-rapport.md code {
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.draco-msg.md pre, .m-rapport.md pre {
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
overflow-x: auto; margin: 7px 0;
}
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
.draco-msg.md blockquote, .m-rapport.md blockquote {
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
color: var(--p-doux);
}
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
background: var(--p-chip);
}
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
.draco-msg.md table, .m-rapport.md table {
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
display: block; overflow-x: auto; max-width: 100%;
}
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
}
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
+35
View File
@@ -0,0 +1,35 @@
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
* autant que possible : la page reste lisible en thème clair et sombre. */
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
.rp-input {
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
}
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
.rp-section { margin-bottom: 28px; }
.rp-head {
display: flex; align-items: baseline; gap: 10px;
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
}
.rp-head h2 { font-size: 1.05rem; margin: 0; }
.rp-count {
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
}
.rp-count:empty { display: none; }
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
.rp-state { opacity: .75; margin: 8px 0; }
.rp-error { opacity: 1; }
.rp-item {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
.rp-title { display: block; font-weight: 600; }
.rp-context { display: block; font-size: .8rem; opacity: .65; }
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
+644 -45
View File
@@ -60,6 +60,28 @@ a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; }
/* — Base des champs de saisie (#29) : sans règle de fond, tout
champ hors d'un contexte stylé (.login-form, .capture-form,
.admin-form…) gardait le fond BLANC du navigateur — vu sur la
réinitialisation de mot de passe (Administration), la sélection
de projets (Espaces, textarea jamais couverte) et les valeurs
du panneau (Paramètres). Sélecteur en :where() = spécificité
zéro : cette base ne peut écraser AUCUNE règle de contexte
existante, elle ne fait que combler les vides. */
:root { color-scheme: dark; }
:where(input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]):not([type="range"]),
textarea, select) {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
:where(input, textarea, select):focus-visible {
outline: 2px solid rgba(124, 92, 252, 0.55);
outline-offset: 0;
}
/* ── App header ─────────────────────────────── */
.app-header, header {
position: sticky;
@@ -150,17 +172,44 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
.btn-sm:hover { background: var(--surface-3); border-color: var(--border-strong); text-decoration: none; }
.btn-sm:active { transform: scale(0.97); }
/* ── Hero (dashboard) ───────────────────────── */
.hero {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
flex-wrap: wrap;
padding: 30px 24px 6px;
/* ── En-tête de page Projets (0.20.0) ─────────
Titre sobre + bande KPI unifiée ; les contrôles vivent dans
.dash-toolbar (plus bas). Les .stat-tile restent utilisés par
le briefing quotidien. */
.page-head { padding: 22px 24px 0; }
.page-head-row {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 20px; flex-wrap: wrap;
}
.page-title h1 {
font-size: 1.55rem; font-weight: 700; letter-spacing: -0.02em;
line-height: 1.15; margin: 0;
}
.page-sub { color: var(--text-2); font-size: 0.92rem; margin-top: 5px; }
.kpi-strip {
display: flex; align-items: stretch;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.kpi {
display: flex; flex-direction: column; gap: 1px;
padding: 9px 18px 10px;
color: var(--text);
}
.kpi + .kpi { border-left: 1px solid var(--border); }
a.kpi { transition: background 0.15s; }
a.kpi:hover { background: var(--surface-2); text-decoration: none; }
.kpi-value {
font-size: 1.22rem; font-weight: 700; letter-spacing: -0.01em;
font-variant-numeric: tabular-nums; line-height: 1.2;
}
.kpi-unit { font-size: 0.85rem; font-weight: 600; color: var(--text-2); }
.kpi-label {
font-size: 0.68rem; color: var(--text-2);
text-transform: uppercase; letter-spacing: 0.55px; white-space: nowrap;
}
.hero-text h2 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 6px; }
.hero-text p { color: var(--text-2); font-size: 0.95rem; max-width: 520px; }
.stat-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
.stat-tile {
background: linear-gradient(180deg, var(--surface-2), var(--surface));
@@ -175,38 +224,13 @@ header h1 { font-size: 1.2rem; font-weight: 600; }
.stat-num { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
.stat-label { font-size: 0.72rem; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; }
/* ── Tabs + search (dashboard) ──────────────── */
.tabs {
display: flex;
gap: 6px;
padding: 18px 24px 0;
flex-wrap: wrap;
}
.tabs button {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-2);
cursor: pointer;
font-size: 0.88rem;
font-weight: 500;
transition: all 0.15s;
}
.tabs button.active {
color: #fff;
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
border-color: transparent;
}
.tabs button:hover { color: var(--text); }
.tabs button.active:hover { color: #fff; }
/* ── Recherche (dashboard + filtre prévisions) ── */
.search-bar { padding: 14px 24px 2px; display: flex; align-items: center; gap: 10px; }
.search-bar input {
width: 100%;
max-width: 430px;
padding: 11px 16px;
border-radius: 999px;
padding: 9px 13px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
@@ -869,13 +893,18 @@ footer {
/* ── Responsive ─────────────────────────────── */
@media (max-width: 900px) {
.hero { flex-direction: column; align-items: flex-start; }
.page-head-row { flex-direction: column; align-items: flex-start; }
.kpi-strip { width: 100%; }
.kpi { flex: 1 1 0; padding: 9px 12px 10px; }
.kpi-label { white-space: normal; }
.kanban-column { max-height: none; }
}
@media (max-width: 768px) {
.app-header, header { padding: 10px 14px; }
.crumb { display: none; }
.hero, .tabs, .search-bar, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
.page-head, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
.dash-toolbar { margin-left: 14px; margin-right: 14px; }
.search-bar { padding-left: 14px; padding-right: 14px; }
.milestone-banner { margin-left: 14px; margin-right: 14px; }
.board-container { flex-direction: column; overflow: visible; }
.notes-panel { width: 100%; max-height: 240px; border-left: none; border-top: 1px solid var(--border); }
@@ -900,15 +929,21 @@ footer {
color: #e5ecf5;
margin: 0;
font-family: inherit;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.wall-stage { position: relative; min-height: 100vh; }
.wall-stage { position: relative; height: 100vh; height: 100dvh; }
.wall-slide {
display: none;
min-height: 100vh;
height: 100vh;
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
box-sizing: border-box;
padding: 4vh 4vw;
padding: 3.5vh 4vw 96px;
flex-direction: column;
gap: 3vh;
gap: 2.5vh;
}
.wall-slide.active { display: flex; animation: wall-in 0.45s ease; }
@keyframes wall-in { from { opacity: 0; } to { opacity: 1; } }
@@ -934,7 +969,14 @@ footer {
}
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
.wall-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.wall-cards {
list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 12px;
flex: 1 1 auto; min-height: 0; overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent);
mask-image: linear-gradient(to bottom, #000 88%, transparent);
}
.wall-more { justify-content: center; color: #8fa3bf; font-size: 1.1rem; background: transparent; }
.wall-card {
display: flex; align-items: center; gap: 18px;
background: rgba(255, 255, 255, 0.045);
@@ -954,8 +996,17 @@ footer {
.wall-foot {
margin-top: auto;
display: flex; gap: 36px; flex-wrap: wrap;
justify-content: space-between; align-items: center;
width: 100%; box-sizing: border-box;
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
}
@media (max-height: 760px) {
.wall-slide { gap: 1.6vh; padding-top: 2.5vh; }
.wall-card { padding: 10px 16px; }
.wall-count { padding: 8px 8px 7px; }
.wall-project { font-size: clamp(1.8rem, 4vw, 2.6rem); }
.wall-time { font-size: clamp(1.7rem, 3.6vw, 2.6rem); }
}
.wall-live { color: #34d399; font-weight: 700; letter-spacing: 0.08em; }
.wall-empty {
min-height: 100vh; display: flex; flex-direction: column;
@@ -1151,3 +1202,551 @@ footer {
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
/* ── Branche depuis la carte (0.14.0, #3) ── */
.branch-input {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 6px;
padding: 3px 8px; font-family: monospace; font-size: 0.85rem;
}
/* ── Santé du flux (0.14.0, #9) ── */
.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; }
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); }
.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; }
.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ── Header unifié (0.15.0, #16) ── */
.header-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
.nav-burger {
display: none; width: 40px; height: 40px; border-radius: 10px;
background: var(--surface-2); border: 1px solid var(--border);
color: var(--text); font-size: 1.05rem; cursor: pointer;
align-items: center; justify-content: center;
}
.crumb a { color: inherit; text-decoration: none; }
.crumb a:hover { text-decoration: underline; }
@media (max-width: 900px) {
.nav-burger { display: inline-flex; }
.crumb { display: none; }
}
/* ── Barre latérale (0.16.0, #16 refonte) ── */
/* Fluidité (0.23.1) : durée et courbe UNIFIÉES entre le padding du
corps et la largeur de la barre (avant : 0.18 s ease des deux
côtés mais transform à 0.22 s — mouvements désynchronisés), et
retrait du backdrop-filter : le fond est déjà opaque à 96 %,
le flou forçait un re-rendu coûteux à chaque image de l'animation
de largeur, d'où l'effet « lent et saccadé » signalé par Bruno. */
body.with-sidebar { padding-left: 238px; transition: padding-left 0.26s cubic-bezier(0.4, 0, 0.2, 1); }
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
.sidebar {
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
width: 238px; box-sizing: border-box;
display: flex; flex-direction: column; gap: 6px;
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
background: rgba(13, 16, 24, 0.96);
border-right: 1px solid var(--border);
transition: width 0.26s cubic-bezier(0.4, 0, 0.2, 1), transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
}
body.sidebar-collapsed .sidebar { width: 68px; }
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
.side-logo:hover { text-decoration: none; }
.side-head {
display: flex; align-items: center; gap: 10px;
padding: 4px 6px 12px;
}
.side-head .side-logo { padding: 0; }
.side-search {
margin-left: auto; flex-shrink: 0;
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
border: 1px solid var(--border);
transition: border-color 0.15s, color 0.15s, transform 0.15s;
}
.side-search:hover {
color: var(--text); border-color: var(--accent, #58a6ff);
transform: translateY(-1px);
}
body.sidebar-collapsed .side-head { flex-direction: column; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-search { margin-left: 0; }
.side-nav { display: flex; flex-direction: column; gap: 2px; }
.side-section { margin-top: 10px; }
.side-section:first-child { margin-top: 2px; }
.side-group-btn {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px 10px; cursor: pointer;
background: transparent; border: none; border-radius: 8px;
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--text-3); white-space: nowrap;
transition: color 0.15s, background 0.15s;
}
.side-group-btn:hover { color: var(--text); background: var(--surface-2); }
.side-chevron {
font-size: 0.62rem; transition: transform 0.25s ease; opacity: 0.8;
}
.side-acc {
display: grid; grid-template-rows: 1fr;
transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.side-acc-inner {
overflow: hidden; min-height: 0;
display: flex; flex-direction: column; gap: 2px;
}
body.nav-closed-principal [data-group="principal"] .side-acc,
body.nav-closed-rituels [data-group="rituels"] .side-acc,
body.nav-closed-pilotage [data-group="pilotage"] .side-acc,
body.nav-closed-outils [data-group="outils"] .side-acc { grid-template-rows: 0fr; }
body.nav-closed-principal [data-group="principal"] .side-chevron,
body.nav-closed-rituels [data-group="rituels"] .side-chevron,
body.nav-closed-pilotage [data-group="pilotage"] .side-chevron,
body.nav-closed-outils [data-group="outils"] .side-chevron { transform: rotate(-90deg); }
@media (min-width: 901px) {
/* Rail replié : pas d'accordéon, toutes les icônes visibles. */
body.sidebar-collapsed .side-acc { grid-template-rows: 1fr !important; }
body.sidebar-collapsed .side-group-btn { display: none; }
body.sidebar-collapsed .side-section { margin-top: 4px; }
}
.side-link {
display: flex; align-items: center; gap: 11px;
padding: 8px 10px; border-radius: 9px;
color: var(--text-2); font-size: 0.95rem; font-weight: 500;
white-space: nowrap; border-left: 3px solid transparent;
}
.side-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.side-link.active {
color: #fff; border-left-color: #a78bfa;
background: linear-gradient(90deg, rgba(124,92,252,.32), rgba(91,138,247,.14));
}
.side-ico { font-size: 1.05rem; width: 22px; text-align: center; flex-shrink: 0; }
.side-foot {
margin-top: auto; padding-top: 12px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.side-collapse {
/* Depuis la 0.22.0 : bouton icône seule dans l'en-tête de la
barre, à droite de la recherche (même gabarit qu'elle). */
flex-shrink: 0;
width: 38px; height: 38px; border-radius: 10px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
background: var(--surface-2); color: var(--text-2); font-size: 1.02rem;
border: 1px solid var(--border);
transition: border-color 0.15s, color 0.15s, transform 0.15s;
}
.side-collapse:hover {
color: var(--text); border-color: var(--accent, #58a6ff);
transform: translateY(-1px);
}
.side-fresh { color: var(--text-3); font-size: 0.76rem; white-space: nowrap; }
.side-logout { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
.side-logout:hover { color: var(--text); text-decoration: none; }
.side-collapse-ico { display: inline-block; transition: transform 0.18s ease; }
body.sidebar-collapsed .side-collapse-ico { transform: rotate(180deg); }
.side-version { color: var(--text-3); font-size: 0.8rem; }
body.sidebar-collapsed .side-label { display: none; }
body.sidebar-collapsed .side-link { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-logo { justify-content: center; padding-left: 0; padding-right: 0; }
body.sidebar-collapsed .side-foot { justify-content: center; }
.side-backdrop {
position: fixed; inset: 0; z-index: 190;
background: rgba(4, 6, 10, 0.55);
}
.topbar { min-height: 52px; padding: 8px 18px; justify-content: flex-start; gap: 14px; }
.topbar-search { display: none; }
@media (max-width: 900px) {
body.with-sidebar, body.with-sidebar.sidebar-collapsed { padding-left: 0; }
.sidebar { width: 264px; transform: translateX(-105%); box-shadow: none; }
body.sidebar-open .sidebar { transform: none; box-shadow: 18px 0 44px rgba(0,0,0,.5); }
body.sidebar-collapsed .side-label { display: initial; }
body.sidebar-collapsed .side-link { justify-content: flex-start; padding-left: 10px; padding-right: 10px; }
body.sidebar-collapsed .side-head { flex-direction: row; padding: 4px 6px 12px; }
body.sidebar-collapsed .side-search { margin-left: auto; }
.side-foot { display: none; }
.topbar-search { display: inline-flex; }
}
/* ── Dashboard vague 2 (0.17.0, #17 #18 #20) + page Projet (#25) ── */
.health-dot {
width: 11px; height: 11px; border-radius: 50%;
display: inline-block; flex-shrink: 0;
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
}
.health-dot.green { background: #34d399; }
.health-dot.orange { background: #f59e0b; }
.health-dot.red { background: #f87171; }
.star-btn {
background: transparent; border: none; cursor: pointer;
font-size: 1rem; line-height: 1; padding: 2px 4px; margin-left: auto;
color: #fbbf24;
}
.star-btn:hover { transform: scale(1.2); }
.star-btn:disabled { opacity: 0.5; }
.card-board-link { font-weight: 600; }
.ms-block { margin: 10px 0 2px; }
.ms-line { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.ms-title { font-weight: 600; font-size: 0.92rem; }
.ms-due { font-size: 0.8rem; color: var(--text-2); white-space: nowrap; }
.ms-due.late { color: #f87171; font-weight: 700; }
.ms-progress { margin: 6px 0 4px; }
.ms-meta { font-size: 0.8rem; color: var(--text-2); }
.pinned-title { margin: 18px 0 10px; font-size: 1.05rem; }
.project-main { max-width: 1180px; margin: 0 auto; padding: 26px 22px 10px; }
.proj-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.proj-head h1 { margin: 0; font-size: 1.7rem; }
.proj-sub { color: var(--text-2); margin: 6px 0 0; }
.health-chip {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--border); border-radius: 999px;
padding: 7px 14px; color: var(--text); background: var(--surface-1);
font-size: 0.88rem; font-weight: 600;
}
.health-chip:hover { text-decoration: none; border-color: var(--accent, #58a6ff); }
.proj-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 22px; }
.proj-actions .nav-pill { border: 1px solid var(--border); }
.proj-primary { background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85)); color: #fff !important; }
.proj-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.proj-panel {
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 14px; padding: 18px 20px;
}
.proj-panel h2 { margin: 0 0 12px; font-size: 1.05rem; }
.proj-ms { margin-bottom: 14px; }
.proj-ms:last-child { margin-bottom: 0; }
.proj-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 0.93rem; }
.proj-dim { color: var(--text-3); }
.proj-empty { color: var(--text-2); font-size: 0.92rem; }
.proj-notes-body { white-space: pre-wrap; font-size: 0.93rem; background: var(--surface-2); border-radius: 10px; padding: 12px 14px; }
/* ── Dashboard vague 2 suite (0.18.0, #19 #21 #22 #23) ── */
.activity-line { margin-top: 8px; font-size: 0.82rem; color: var(--text-2); }
/* Barre d'outils unique du dashboard (0.20.0) : onglets
segmentés, filtre, tri, regroupement et vue dans UN cadre. */
.dash-toolbar {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
margin: 16px 24px 18px; padding: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
.seg-tabs {
display: inline-flex; gap: 2px; padding: 3px;
background: var(--surface-2); border-radius: 9px;
}
.seg-tabs button {
display: inline-flex; align-items: center; gap: 7px;
padding: 6px 13px; border: none; border-radius: 7px;
background: transparent; color: var(--text-2);
cursor: pointer; font-size: 0.88rem; font-weight: 500;
transition: background 0.15s, color 0.15s;
min-height: 32px;
}
.seg-tabs button:hover { color: var(--text); }
.seg-tabs button.active {
background: var(--surface); color: var(--text); font-weight: 600;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.seg-count {
font-size: 0.74rem; font-weight: 600; font-variant-numeric: tabular-nums;
background: rgba(255, 255, 255, 0.08); color: var(--text-2);
border-radius: 999px; padding: 1px 7px;
}
.seg-tabs button.active .seg-count { background: rgba(124, 92, 252, 0.22); color: var(--text); }
.dash-toolbar .toolbar-search { padding: 0; flex: 1 1 200px; }
.dash-toolbar .toolbar-search input { max-width: none; background: var(--surface-2); }
.dash-tool { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-2); white-space: nowrap; }
.dash-tool select {
background: var(--surface-2); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px;
min-height: 36px;
}
.dash-tool-check input { width: 17px; height: 17px; accent-color: #7c5cfc; }
.dash-view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
.dash-view-toggle button {
background: var(--surface-1); color: var(--text-2); border: none;
padding: 7px 14px; cursor: pointer; font-size: 0.88rem; min-height: 36px;
}
.dash-view-toggle button.active { background: var(--surface-3, #1d2942); color: var(--text); font-weight: 700; }
.owner-group { grid-column: 1 / -1; }
.owner-head {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 9px 12px; margin-bottom: 10px; cursor: pointer;
background: var(--surface-1); color: var(--text);
border: 1px solid var(--border); border-radius: 10px;
font-size: 0.95rem; font-weight: 700;
}
.owner-head:hover { border-color: var(--accent, #58a6ff); }
.owner-head .owner-chevron { transition: transform 0.2s ease; font-size: 0.7rem; }
.owner-head[aria-expanded="false"] .owner-chevron { transform: rotate(-90deg); }
.owner-grid { padding: 0 !important; }
.card-menu-wrap { position: relative; }
.card-menu-btn {
background: transparent; border: none; cursor: pointer;
color: var(--text-2); font-size: 1.15rem; font-weight: 800;
padding: 2px 7px; border-radius: 8px; line-height: 1;
}
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
.card-menu {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 12px; padding: 6px;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
}
.card-menu[hidden] { display: none !important; }
.card-menu a {
display: flex; align-items: center; min-height: 32px;
padding: 8px 10px; border-radius: 8px;
color: var(--text-2); font-size: 0.92rem;
}
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.proj-table {
width: calc(100% - 48px); margin: 0 24px 18px;
border-collapse: collapse; font-size: 0.93rem;
background: var(--surface-1); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
}
.proj-table th, .proj-table td {
text-align: left; padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.proj-table thead th { color: var(--text-2); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; }
.proj-table th.sortable { cursor: pointer; }
.proj-table th.sortable:hover { color: var(--text); }
.proj-table th[data-dir="asc"]::after { content: " ↑"; }
.proj-table th[data-dir="desc"]::after { content: " ↓"; }
.proj-table tbody tr:hover { background: var(--surface-2); }
@media (max-width: 900px) {
.proj-table { display: block; overflow-x: auto; }
.dash-view-toggle { margin-left: 0; }
}
/* ── Pilotage 0.19.0 : tableaux Prévisions/Santé (chantier C) ── */
.pilot-table { margin-bottom: 18px; }
.pilot-table tr[id] { scroll-margin-top: 76px; }
.pilot-progress { min-width: 90px; }
.forecast-summary { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 4px; }
.forecast-summary .chip {
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
background: rgba(255, 255, 255, .07); border: 1px solid var(--border, #333);
}
.forecast-summary .chip-late { background: rgba(248, 81, 73, .15); }
.forecast-summary .chip-ok { background: rgba(63, 185, 80, .15); }
.forecast-filter { margin-top: 10px; max-width: 420px; }
.forecast-filter input { width: 100%; }
.forecast-proj-name { font-weight: 600; min-width: 180px; }
.verdict {
font-size: 12.5px; padding: 2px 9px; border-radius: 999px; white-space: nowrap;
background: rgba(255, 255, 255, .06);
}
.verdict-late { background: rgba(248, 81, 73, .18); }
.verdict-ok { background: rgba(63, 185, 80, .16); }
a.ms-title { color: inherit; text-decoration: none; }
a.ms-title:hover { text-decoration: underline; }
.health-switch-bar {
display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap;
}
.health-switch-bar select {
padding: 8px 10px; border-radius: 8px; font-size: 14px; min-height: 38px;
background: var(--panel-2, #1c2330); color: inherit;
border: 1px solid var(--border, #333);
}
.health-back { font-size: 13.5px; }
/* ── Sélecteur de dépôt Capture (0.21.0) ────── */
.repo-picker { position: relative; display: block; }
.repo-list {
position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 80;
display: flex; flex-direction: column;
background: var(--surface-1, #141a26);
border: 1px solid var(--border);
border-radius: 12px; padding: 5px;
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.5);
max-height: 264px; overflow-y: auto;
}
.repo-list[hidden] { display: none !important; }
.repo-item {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 10px; border-radius: 8px; cursor: pointer;
font-weight: 400; font-size: 0.94rem;
}
.repo-item.active { background: var(--surface-2, #1c2330); }
.repo-item-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.repo-item-meta { color: var(--text-3, #8b94a5); font-size: 0.78rem; white-space: nowrap; }
.repo-hint { font-weight: 400; font-size: 0.85rem; color: #f0a35e; }
.repo-tip { font-weight: 400; font-size: 0.82rem; color: var(--text-3, #8b94a5); }
.repo-tip kbd {
font-size: 0.72rem; border: 1px solid var(--border); border-bottom-width: 2px;
border-radius: 5px; padding: 0 5px; background: var(--surface);
}
.capture-form input.repo-unknown { border-color: #f0a35e; }
/* ── Écran de connexion (0.22.0) ────────────── */
.login-body {
min-height: 100dvh; margin: 0;
display: flex; align-items: center; justify-content: center;
background:
radial-gradient(640px 420px at 18% 12%, rgba(124, 92, 252, 0.20), transparent 62%),
radial-gradient(560px 380px at 84% 88%, rgba(91, 138, 247, 0.16), transparent 60%),
var(--bg, #0a0f16);
padding: 24px 16px;
}
.login-wrap { width: 100%; max-width: 400px; }
.login-card {
background: var(--surface, #10151f);
border: 1px solid var(--border);
border-radius: 18px;
padding: 34px 32px 26px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}
.login-brand { text-align: center; margin-bottom: 22px; }
.login-mark {
display: inline-flex; align-items: center; justify-content: center;
width: 56px; height: 56px; border-radius: 16px; font-size: 1.7rem;
background: linear-gradient(135deg, rgba(124,92,252,.35), rgba(91,138,247,.25));
border: 1px solid var(--border);
}
.login-brand h1 {
margin: 12px 0 4px; font-size: 1.55rem; font-weight: 700;
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-sub { margin: 0; color: var(--text-3, #8b94a5); font-size: 0.9rem; }
.login-error {
margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
background: rgba(240, 120, 90, 0.12); border: 1px solid rgba(240, 120, 90, 0.45);
color: #f5a58c; font-size: 0.9rem;
}
.login-form { display: flex; flex-direction: column; gap: 14px; }
.login-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.92rem; }
.login-form input {
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px;
background: var(--bg-2, #0d1220); color: var(--text-1, #e6ebf2);
font: inherit; font-weight: 400;
}
.login-form input:focus { outline: 2px solid rgba(124, 92, 252, 0.55); outline-offset: 0; border-color: transparent; }
.login-submit {
margin-top: 4px; padding: 12px 18px; cursor: pointer;
background: linear-gradient(135deg, var(--accent, #7c5cfc), var(--accent-2, #5b8af7));
color: #fff; border: none; border-radius: 10px;
font: inherit; font-weight: 600; font-size: 1rem;
transition: filter 0.15s, transform 0.1s;
}
.login-submit:hover { filter: brightness(1.12); }
.login-submit:active { transform: scale(0.98); }
.login-sso {
display: block; margin-top: 12px; padding: 11px 18px; text-align: center;
color: var(--text-1, #e6ebf2); text-decoration: none;
border: 1px solid var(--border); border-radius: 10px;
font: inherit; font-weight: 500; font-size: 0.95rem;
transition: filter 0.15s, transform 0.1s;
}
.login-sso:hover { filter: brightness(1.35); }
.login-sso:active { transform: scale(0.98); }
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
/* ── Fusion ACC-1 (kanbante#28) : cohabitation avec la barre commune ──
La barre d'apps est FIXE et pleine largeur (0.23.1 — en flux, le
padding-left du cadre la repoussait à droite de la barre latérale,
constat de Bruno sur capture) ; le cadre entier est décalé de sa
hauteur, et la barre latérale fixe démarre sous elle. */
body.has-draco-bar { padding-top: 60px; }
body.has-draco-bar .draco-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 250; }
body.has-draco-bar .sidebar { top: 60px; }
@media (max-width: 900px) {
body.has-draco-bar { padding-top: 52px; }
body.has-draco-bar .sidebar { top: 52px; }
}
/* ── Espaces, admin, profils (0.24.0) ─────────────────── */
.ws-switch { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; }
.ws-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
.ws-select { flex: 1; min-width: 0; background: var(--surface-2, #1c1c22); color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 8px; font: inherit; }
body.sidebar-collapsed .ws-switch { display: none; }
.admin-form { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; margin: 10px 0; }
.admin-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85em; }
.admin-form input[type="text"], .admin-form input[type="password"], .admin-form select {
background: var(--surface-2, #1c1c22); color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 7px 9px; font: inherit; }
.inline-form { display: inline-flex; gap: 6px; align-items: center; }
.btn-mini { padding: 4px 10px; font-size: 0.85em; border-radius: 8px;
border: 1px solid var(--border, #333); background: var(--surface-2, #1c1c22);
color: inherit; cursor: pointer; }
.btn-mini:hover { border-color: var(--accent, #7c5cfc); }
.btn-mini:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-danger:hover { border-color: #e5534b; color: #e5534b; }
.chip-ok { display: inline-block; padding: 4px 10px; border-radius: 999px;
background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.5); }
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
border: 1px solid var(--border, #333); white-space: nowrap; }
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */
.kanban-card { cursor: pointer; }
.detail-actions { margin: 10px 0; }
#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; }
#detail-edit-form textarea { width: 100%; }
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); }
.asset-row a { text-decoration: none; font-weight: 600; }
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; }
.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
/* ── Compositeur de nouvelle issue (0.31.1) ── */
/* La toolbar impose display:flex à ses formulaires : le
compositeur est une carte, en bloc et pleine largeur. */
.board-toolbar form.composer { display: block; flex: 1 1 100%; }
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.composer-title { font-weight: 700; font-size: 0.95rem; }
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
.composer-main { display: flex; gap: 8px; }
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-desc { margin-top: 10px; }
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
.composer-desc summary::-webkit-details-marker { display: none; }
.composer-desc summary:hover .composer-desc-label { text-decoration: underline; }
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
/* ── Mes issues : barre de filtres (0.33.0) ── */
.my-filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0 18px; }
.my-filters-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.my-filters-row + .my-filters-row { margin-top: 10px; }
.my-search-wrap { position: relative; flex: 1 1 280px; }
.my-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.65; pointer-events: none; }
.my-search-wrap input[type="search"] { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 9px 14px 9px 38px; color: inherit; font-size: 0.92rem; }
.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.my-filters select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; color: inherit; font-size: 0.9rem; }
.my-filters select:focus { outline: none; border-color: var(--accent); }
.my-col-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
.filter-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border); color: inherit; border-radius: 999px; padding: 5px 12px; font-size: 0.85rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.filter-chip:hover { border-color: var(--border-strong); }
.filter-chip.on { background: rgba(124, 92, 252, 0.18); border-color: var(--accent); font-weight: 600; }
.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.filter-chip-count { opacity: 0.7; font-size: 0.78rem; }
.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; }
/* ── En vol partout (0.34.0, issue #24) ── */
.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; }
.inflight-total { font-weight: 700; margin-right: 4px; }
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
.ms-edit { margin-top: 8px; }
.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; }
.ms-edit summary::-webkit-details-marker { display: none; }
.ms-edit summary:hover { text-decoration: underline; }
.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; }
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
.ms-form-actions { display: flex; gap: 8px; }
+184 -9
View File
@@ -104,6 +104,10 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData();
formData.append('repo_full_name', repoFullName);
formData.append('title', title);
const bodyInput = document.getElementById('new-issue-body');
if (bodyInput && bodyInput.value.trim()) {
formData.append('body', bodyInput.value.trim());
}
formData.append('column', 'backlog');
formData.append('csrf_token', csrfToken);
try {
@@ -118,6 +122,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
createForm?.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
createForm.requestSubmit();
}
});
// The "Glissez une carte ici" hint is rendered by the server only
// for columns empty at page load. After a drag, the DOM is the
// truth: remove the hint from columns that hold cards, restore it
@@ -323,14 +334,17 @@ document.addEventListener('DOMContentLoaded', () => {
// ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel.
// DELEGATED on purpose (0.31.0): the live board swaps card
// fragments in place — listeners attached per card died at the
// first refresh, which made cards "hard to click". One listener
// on the document keeps working whatever the DOM becomes.
let downX = 0, downY = 0;
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
document.querySelectorAll('.kanban-card').forEach(card => {
card.addEventListener('click', (e) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
if (e.target.closest('a')) return;
openDetail(card.dataset.repo, card.dataset.issue);
});
document.addEventListener('click', (e) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
if (e.target.closest('a') || e.target.closest('button')) return;
const card = e.target.closest('.kanban-card');
if (card) openDetail(card.dataset.repo, card.dataset.issue);
});
const drawer = document.getElementById('card-detail');
@@ -355,6 +369,9 @@ document.addEventListener('DOMContentLoaded', () => {
overlay?.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
let currentComments = [];
let currentIssueData = null;
async function openDetail(repoFull, number) {
const [owner, repo] = repoFull.split('/', 2);
currentIssue = { repo: repoFull, number };
@@ -368,6 +385,8 @@ document.addEventListener('DOMContentLoaded', () => {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
currentComments = data.comments || [];
currentIssueData = data.issue;
fillDetail(data.issue, data.comments);
loadCodeLinks(repoFull, number);
} catch (err) {
@@ -397,13 +416,42 @@ document.addEventListener('DOMContentLoaded', () => {
for (const c of (data.commits || []).slice(0, 8)) {
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
}
box.innerHTML = parts.length
? '<h3 class="detail-comments-title">🧬 Code lié</h3>' + parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '';
const hasBranch = (data.branches || []).length > 0;
if (!hasBranch && data.suggested_branch) {
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
}
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
parts.length
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
);
const createBtn = document.getElementById('branch-create');
if (createBtn) {
createBtn.addEventListener('click', async () => {
const name = (document.getElementById('branch-name') || {}).value || '';
const form = new FormData();
form.append('name', name);
form.append('csrf_token', csrfToken);
createBtn.disabled = true;
try {
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
const body = await r2.json().catch(() => ({}));
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
showToast(body.exists
? `🌿 La branche ${body.branch} existait déjà`
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
loadCodeLinks(repoFull, number);
} catch (err) {
createBtn.disabled = false;
showToast('❌ Branche non créée : ' + err.message, true);
}
});
}
} catch (err) { /* le code est un bonus, jamais bloquant */ }
}
function fillDetail(issue, comments) {
currentIssueData = issue;
document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state');
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
@@ -429,6 +477,8 @@ document.addEventListener('DOMContentLoaded', () => {
renderLabelToggles(issue);
document.getElementById('detail-body').innerHTML =
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
renderAssets(issue);
setEditMode(false);
document.getElementById('detail-gitea-link').href = issue.html_url || '#';
const box = document.getElementById('detail-comments');
box.innerHTML = '';
@@ -492,6 +542,131 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
function setEditMode(on) {
const form = document.getElementById('detail-edit-form');
const btn = document.getElementById('detail-edit-btn');
const title = document.getElementById('detail-title');
const body = document.getElementById('detail-body');
if (!form || !btn) return;
form.hidden = !on;
btn.hidden = on;
if (title) title.hidden = on;
if (body) body.hidden = on;
if (on && currentIssueData) {
document.getElementById('detail-title-input').value = currentIssueData.title || '';
document.getElementById('detail-body-input').value = currentIssueData.body || '';
}
}
document.getElementById('detail-edit-btn')?.addEventListener('click', () => setEditMode(true));
document.getElementById('detail-edit-cancel')?.addEventListener('click', () => setEditMode(false));
document.getElementById('detail-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const title = document.getElementById('detail-title-input').value.trim();
const body = document.getElementById('detail-body-input').value;
if (!title) { showToast('❌ Le titre ne peut pas être vide', true); return; }
const [owner, repo] = currentIssue.repo.split('/', 2);
const fd = new FormData();
fd.append('title', title);
fd.append('body', body);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/update`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, currentComments);
const card = document.querySelector(`.kanban-card[data-issue="${currentIssue.number}"]`);
if (card) {
const t = card.querySelector('.card-title');
if (t) t.textContent = data.issue.title;
card.dataset.title = (data.issue.title || '').toLowerCase();
}
showToast('✅ Issue mise à jour');
} catch (err) { showToast('❌ ' + err.message, true); }
});
function humanSize(bytes) {
if (!bytes) return '0 o';
const units = ['o', 'Kio', 'Mio', 'Gio'];
let i = 0, v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(v >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
}
function renderAssets(issue) {
const box = document.getElementById('detail-assets');
const form = document.getElementById('asset-form');
if (!box) return;
box.innerHTML = '';
if (!issue.attachments_supported) {
box.innerHTML = '<p class="detail-empty">Non pris en charge par l\'API de cette forge — joignez les fichiers depuis son interface web.</p>';
if (form) form.hidden = true;
return;
}
if (form) form.hidden = false;
const assets = issue.assets || [];
if (!assets.length) {
box.innerHTML = '<p class="detail-empty">Aucune pièce jointe.</p>';
return;
}
const [owner, repo] = (currentIssue?.repo || '/').split('/', 2);
assets.forEach(a => {
const row = document.createElement('div');
row.className = 'asset-row';
const link = document.createElement('a');
link.href = `/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/download`;
link.textContent = '📎 ' + a.name;
link.title = 'Télécharger';
row.appendChild(link);
const meta = document.createElement('span');
meta.className = 'asset-meta';
meta.textContent = humanSize(a.size) + (a.created_at ? ' · ' + String(a.created_at).slice(0, 10) : '');
row.appendChild(meta);
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn-mini btn-danger';
del.textContent = 'Supprimer';
del.addEventListener('click', async () => {
if (!confirm('Supprimer la pièce jointe « ' + a.name + ' » ?')) return;
const fd = new FormData();
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/delete`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
showToast('✅ Pièce jointe supprimée');
openDetail(currentIssue.repo, currentIssue.number);
} catch (err) { showToast('❌ ' + err.message, true); }
});
row.appendChild(del);
box.appendChild(row);
});
}
document.getElementById('asset-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const input = document.getElementById('asset-file');
const file = input?.files?.[0];
if (!file) { showToast('❌ Choisis un fichier d\'abord', true); return; }
const [owner, repo] = currentIssue.repo.split('/', 2);
const fd = new FormData();
fd.append('attachment', file);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
input.value = '';
showToast('✅ Fichier joint : ' + data.asset.name);
openDetail(currentIssue.repo, currentIssue.number);
} catch (err) { showToast('❌ ' + err.message, true); }
});
function commentNode(c) {
const div = document.createElement('div');
div.className = 'comment';
+181
View File
@@ -0,0 +1,181 @@
/**
* Kanbante — dashboard display tools (issues #21, #22, #23).
* Sort + group-by-owner for the card grids, sortable dense tables,
* and the per-card quick-actions menu. Preferences live in
* localStorage; the server markup stays the single source.
*/
(function () {
var LS_SORT = 'kanbante-dash-sort';
var LS_GROUP = 'kanbante-dash-group';
var LS_OWNERS = 'kanbante-dash-owners-closed';
function lsGet(key, fallback) {
try {
var v = localStorage.getItem(key);
return v === null ? fallback : v;
} catch (e) { return fallback; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* non-fatal */ }
}
/* ── Tri des grilles de cartes ─────────────────────────── */
var sortSelect = document.getElementById('dash-sort');
// 0.20.1 : la grille des Épinglés est incluse — l'exclure
// (choix de la 0.18.0) donnait l'impression que le tri et le
// regroupement « ne faisaient rien », la section épinglée
// étant celle qu'on regarde en premier.
var grids = ['pinned-grid', 'grid-active', 'grid-inactive', 'grid-archived']
.map(function (id) { return document.getElementById(id); })
.filter(Boolean);
function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
if (mode === 'review') return -(parseInt(card.getAttribute('data-review') || '0', 10));
if (mode === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien
}
function sortCards(container, mode) {
var cards = Array.prototype.slice.call(
container.querySelectorAll('.project-card'));
if (mode === 'default' || !cards.length) return;
cards.sort(function (a, b) {
var ka = cardKey(a, mode), kb = cardKey(b, mode);
if (mode === 'updated' || mode === 'activity') {
// Comparaison en instants réels (les fuseaux des
// dates Gitea varient, -04:00/-05:00) ; une carte
// sans date coule en bas du tri décroissant.
var ta = Date.parse(ka), tb = Date.parse(kb);
if (isNaN(ta) && isNaN(tb)) return 0;
if (isNaN(ta)) return 1;
if (isNaN(tb)) return -1;
return tb - ta;
}
// Modes numériques (open, review) : les clés sont
// des compteurs niés (-n) — ka - kb donne donc le
// plus grand compteur en premier, comme l'annonce
// le « ↓ » des options. (Avant 0.37.0, la
// comparaison générique ci-dessous inversait ces
// deux tris : ils ressortaient croissants.)
if (mode === 'open' || mode === 'review') return ka - kb;
if (ka < kb) return mode === 'name' ? -1 : 1;
if (ka > kb) return mode === 'name' ? 1 : -1;
return 0;
});
cards.forEach(function (c) { container.appendChild(c); });
}
function applySort() {
var mode = sortSelect ? sortSelect.value : 'default';
lsSet(LS_SORT, mode);
grids.forEach(function (grid) {
if (groupToggle && groupToggle.checked) {
grid.querySelectorAll('.owner-grid').forEach(function (og) {
sortCards(og, mode);
});
} else {
sortCards(grid, mode);
}
});
}
/* ── Regroupement par propriétaire ─────────────────────── */
var groupToggle = document.getElementById('dash-group');
function closedOwners() {
try { return JSON.parse(lsGet(LS_OWNERS, '[]')); } catch (e) { return []; }
}
function setOwnerClosed(owner, closed) {
var list = closedOwners().filter(function (o) { return o !== owner; });
if (closed) list.push(owner);
lsSet(LS_OWNERS, JSON.stringify(list));
}
function groupGrid(grid) {
var cards = Array.prototype.slice.call(
grid.querySelectorAll(':scope > .project-card'));
if (!cards.length) return;
var byOwner = {};
cards.forEach(function (c) {
var o = c.getAttribute('data-owner') || '—';
(byOwner[o] = byOwner[o] || []).push(c);
});
Object.keys(byOwner).sort().forEach(function (owner) {
var wrap = document.createElement('div');
wrap.className = 'owner-group';
var head = document.createElement('button');
head.type = 'button';
head.className = 'owner-head';
var closed = closedOwners().indexOf(owner) >= 0;
head.setAttribute('aria-expanded', closed ? 'false' : 'true');
head.innerHTML = '<span>🗂️ ' + owner + ' (' + byOwner[owner].length + ')</span><span class="owner-chevron">▾</span>';
var body = document.createElement('div');
body.className = 'project-grid owner-grid';
if (closed) body.hidden = true;
head.addEventListener('click', function () {
body.hidden = !body.hidden;
head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
setOwnerClosed(owner, body.hidden);
});
byOwner[owner].forEach(function (c) { body.appendChild(c); });
wrap.appendChild(head);
wrap.appendChild(body);
grid.appendChild(wrap);
});
}
function ungroupGrid(grid) {
grid.querySelectorAll('.owner-group').forEach(function (wrap) {
var body = wrap.querySelector('.owner-grid');
Array.prototype.slice.call(body.querySelectorAll('.project-card'))
.forEach(function (c) { grid.appendChild(c); });
wrap.remove();
});
}
function applyGrouping() {
var on = !!(groupToggle && groupToggle.checked);
lsSet(LS_GROUP, on ? '1' : '0');
grids.forEach(function (grid) {
ungroupGrid(grid);
if (on) groupGrid(grid);
});
applySort();
}
if (sortSelect) {
sortSelect.value = lsGet(LS_SORT, 'default');
sortSelect.addEventListener('change', applySort);
}
if (groupToggle) {
groupToggle.checked = lsGet(LS_GROUP, '0') === '1';
groupToggle.addEventListener('change', applyGrouping);
}
applyGrouping(); // applique regroupement + tri mémorisés
/* ── Menu ⋯ d'actions rapides (#23) ────────────────────── */
function closeMenus() {
document.querySelectorAll('.card-menu').forEach(function (m) {
m.hidden = true;
});
document.querySelectorAll('[data-menu-btn]').forEach(function (b) {
b.setAttribute('aria-expanded', 'false');
});
}
document.querySelectorAll('[data-menu-btn]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var menu = btn.parentElement.querySelector('.card-menu');
var willOpen = menu.hidden;
closeMenus();
if (willOpen) {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
});
});
document.addEventListener('click', closeMenus);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeMenus();
});
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
partagé avec /forecast et /health (0.19.0). */
})();
+749
View File
@@ -0,0 +1,749 @@
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
* (onglets + composant partagé + onglet Console Spark).
*
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
* place, toute évolution passe par commons puis re-vendoring.
*
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
* ouverture du panneau (et au chargement, pour la pastille), le module
* interroge les API de notifications réellement exposées par chaque
* application, avec les identifiants du navigateur (credentials:
* "include"). Aucune alerte n'est inventée : une source injoignable
* ou non authentifiée affiche son état réel par section (une section
* en échec n'empêche jamais de consulter les autres), jamais du vide
* déguisé. En attendant le correctif CORS des allowlists (autre
* chantier), une section inter-origines peut afficher cet état
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
* normal — aucun état « Injoignable » systématique n'est présenté
* comme normal.
*
* Sources réelles (audit #69, 2026-10-28, inchangé) :
* - Flowdeck : GET {base}/api/notifications?limit=50
* -> { notifications: [{id, ntype, title, message, url, is_read,
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
* - TikTak : GET {base}/api/notifications
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
* base + "/notifications".
* - Kanbante : GET {base}/api/events?limit=100
* -> { events: [{repo_full_name, issue_number, event_type,
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
* n'existe dans Kanbante, donc tout y est historique et rien n'y
* est compté comme actif ; lien = base + "/board/" + repo.
* - ObsiGate : AUCUNE notification entrante consultable. Ses
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
* masqués), pas un journal lisible par l'utilisateur. L'onglet
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
* sur agent-commun main au moment de ce chantier (autre chantier
* en cours). L'onglet Console Spark affiche donc un état vide
* HONNÊTE (« source indisponible », lien vers la console) et ne
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
* l'autre chantier ; forme attendue : tableau, ou
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
* detail|message|context, date|created_at, active (booléen) ou
* status/state (pause budget, accès requis, en attente, fermée…),
* url|link. Les missions en pause budget / accès requis et les
* approbations en attente y apparaîtront avec leurs états réels.
*
* Résolution des bases (même ordre que barre-apps v0.1.6) :
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
* « Console Spark » ajouté par le chantier 5 porte data-app
* "agent", donc la base agent suit le même ordre) ;
* 4. défaut officiel (production).
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
* incluse).
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
* que sur un hôte -dev, exactement comme la barre.
*
* Présentation (identique partout, imposée par ce seul fichier) :
* champs par alerte = application, titre, date, état, lien profond
* (environnement -dev respecté via la résolution ci-dessus) ;
* ordre = date décroissante dans chaque onglet (sans date en
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
* si absente ou illisible ; libellés d'état affichés = « Active »,
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
* ou l'état réel console quand la source existe. Le type brut
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
* libellé d'état en anglais.
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
* par application ne montrent QUE les alertes actives ; Historique
* ne montre QUE les alertes fermées (lues / événements Kanbante /
* console fermées). Le filtre texte (titre + contexte) s'applique
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
* actifs et historique. L'onglet courant est dans l'URL
* (#alertes=<onglet>, partageable, retour arrière via pushState).
*
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
* pour la pastille (repli : mise à jour directe du badge).
* Insertion des données : textContent uniquement, jamais innerHTML
* avec une valeur venue du serveur.
*/
(function (global) {
"use strict";
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/"
};
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
};
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
var TAB_LABELS = {
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante",
console: "Console Spark", historique: "Historique"
};
var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commentée"
};
var CONSOLE_ACTIVE_STATUSES = [
"active", "pending", "pause", "pause_budget", "acces_requis",
"en_attente", "en attente", "requise", "awaiting"
];
var CONSOLE_CLOSED_STATUSES = [
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
];
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function joinUrl(base, path) {
var b = withSlash(base);
if (!path) return b;
if (/^https?:\/\//.test(path)) return path;
return b + String(path).replace(/^\//, "");
}
function parseDate(s) {
if (!s) return null;
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
? String(s).replace(" ", "T") + "Z" : s);
return isNaN(t.getTime()) ? null : t;
}
function fmtDate(s) {
var t = parseDate(s);
if (!t) return "";
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
}
function capitalize(s) {
s = String(s || "");
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
/* Résolution des bases. `env` permet les tests sans navigateur :
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
if (hostname === undefined) {
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
}
var dev = isDevHost(hostname);
var urls = {};
ALL_APPS.forEach(function (key) {
var app = key === "console" ? "agent" : key;
var out = key === "console" ? "agent" : key;
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|| cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
if (!u && dev) u = DEV_URLS[app] || null;
if (!u) {
var href = null;
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
else if (typeof document !== "undefined" && document.querySelector) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
href = tab ? tab.getAttribute("href") : null;
}
u = cleanUrl(href);
}
if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
try {
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
urls[out] = withSlash(u);
if (key === "console") urls.console = withSlash(u);
});
return urls;
}
function browserEnv() {
var cfg = global.DRACO_BAR_CONFIG || {};
var bar = global.DRACO_BAR || {};
var tabs = {};
try {
if (typeof document !== "undefined" && document.querySelectorAll) {
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
for (var i = 0; i < nodes.length; i++) {
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
}
}
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
consoleUrl: null
};
}
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
* n'est devinée : sans configuration, la source est indisponible
* (état vide honnête, voir renderCurrent). */
function consoleSourceUrl(env) {
env = env || {};
if (env.consoleUrl) return env.consoleUrl;
try {
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
var cfg = global.DRACO_BAR_CONFIG || {};
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
} catch (e) { /* rien */ }
return null;
}
function normalizeFlowdeck(base, payload) {
var list = (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
detail: n.message || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.ntype || "", url: joinUrl(base, n.url || "")
};
});
}
function normalizeTiktak(base, payload) {
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
detail: n.body || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.kind || "",
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
};
});
}
function normalizeKanbante(base, payload) {
var list = (payload && payload.events) || [];
return list.map(function (e) {
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
return {
app: "kanbante", id: e.id,
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
detail: detail, date: e.created_at || "",
active: false, state: label, kind: e.event_type || "",
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
};
});
}
function normalizeConsole(base, payload) {
var list = Array.isArray(payload) ? payload
: (payload && (payload.alerts || payload.items)) || [];
return list.map(function (n) {
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
var stateRaw = String(n.state || status || "");
var active;
if (typeof n.active === "boolean") active = n.active;
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
else active = true; /* source réelle, statut inconnu : on l'expose en active */
return {
app: "console", id: n.id, title: n.title || "(sans titre)",
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
kind: n.kind || n.type || "",
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
};
});
}
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
* normalize* sont inchangées, pour compatibilité des tests #69). */
function displayState(a) {
if (a.active) return "Active";
if (a.app === "kanbante") return capitalize(a.state);
if (a.app === "console") return capitalize(a.state);
return "Lue";
}
function metaLine(a) {
var date = fmtDate(a.date) || "sans date";
/* Kanbante : le type brut (« moved ») dupliquait le libellé
* d'état ; il n'est plus affiché. Les autres apps gardent leur
* type quand il apporte une information. */
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
var kind = a.kind ? " · " + a.kind : "";
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
}
/* Actives d'abord, puis date décroissante (sans date en dernier).
* Dans un onglet, le périmètre est déjà homogène (que des actives
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
function sortAlerts(list) {
return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1;
var da = parseDate(a.date), db = parseDate(b.date);
if (da && db) return db - da;
if (da) return -1;
if (db) return 1;
return 0;
});
}
function countActive(list) {
var n = 0;
list.forEach(function (a) { if (a.active) n++; });
return n;
}
function allItems(results) {
var all = [];
ALL_APPS.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
return all;
}
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
* Tout et onglets par application = actives seulement ; Historique =
* fermées seulement. Les erreurs de section sont remontées dans
* chaque onglet concerné (jamais silencieusement avalées). */
function scopeAlerts(all, tab, results) {
results = results || {};
var items = [], errors = [];
function collect(app, pred) {
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
}
if (tab === "historique") {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
} else if (tab === "tout") {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
} else if (ALL_APPS.indexOf(tab) !== -1) {
collect(tab, function (a) { return a.active; });
} else {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
}
return {
items: sortAlerts(items), errors: errors,
consoleMissing: !!(results.console && results.console.sourceMissing)
};
}
function countByTab(all, results) {
var counts = {};
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
return counts;
}
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
* contexte, insensible à la casse, sans jamais mélanger actifs et
* historique (le périmètre est déjà fixé par scopeAlerts). */
function filterAlerts(list, q) {
q = String(q || "").trim().toLowerCase();
if (!q) return list.slice();
return list.filter(function (a) {
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
});
}
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
* "#alertes" -> "tout", autre -> null. */
function parseAlertTab(hash) {
if (!hash) return null;
var h = String(hash).replace(/^#/, "");
if (h === "alertes") return "tout";
var m = /^alertes=([a-z]+)$/.exec(h);
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
return null;
}
function hashForTab(tab) {
if (TABS.indexOf(tab) === -1) tab = "tout";
return "#alertes=" + tab;
}
/* ---- Rendu (navigateur seulement) ---- */
var panel = null, listEl = null, tabsEl = null, filterEl = null,
countEl = null, badgeTimer = null;
var currentTab = "tout", currentData = null, currentFilter = "";
var panelPushed = false, closingViaBack = false, suppressHash = false;
function el(tag, cls, text) {
var d = document.createElement(tag);
if (cls) d.className = cls;
if (text !== undefined && text !== null) d.textContent = text;
return d;
}
function ensurePanel() {
if (panel) return panel;
var ov = el("div", "draco-alertes-overlay");
ov.setAttribute("role", "dialog");
ov.setAttribute("aria-label", "Alertes unifiées");
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", function () { closePanel(); });
head.appendChild(close);
box.appendChild(head);
box.appendChild(el("p", "draco-alertes-note",
"Agrégation à la demande, pas une fédération temps réel : " +
"les alertes sont relues dans chaque application à l'ouverture. " +
"Une section en échec n'empêche pas de consulter les autres."));
tabsEl = el("div", "draco-alertes-tabs");
tabsEl.setAttribute("role", "tablist");
tabsEl.setAttribute("aria-label", "Alertes par application");
tabsEl.addEventListener("keydown", onTabsKeydown);
box.appendChild(tabsEl);
var filtre = el("div", "draco-alertes-filtre");
var lbl = el("label", "", "Filtrer");
lbl.setAttribute("for", "draco-alertes-filtre-input");
filtre.appendChild(lbl);
filterEl = el("input", "");
filterEl.type = "search";
filterEl.id = "draco-alertes-filtre-input";
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
filterEl.addEventListener("input", function () {
currentFilter = filterEl.value;
renderCurrent();
});
filtre.appendChild(filterEl);
box.appendChild(filtre);
countEl = el("p", "draco-alertes-compte", "");
box.appendChild(countEl);
listEl = el("div", "draco-alertes-list");
listEl.setAttribute("role", "tabpanel");
box.appendChild(listEl);
ov.appendChild(box);
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
function closePanel() {
if (!panel) return;
panel.style.display = "none";
if (panelPushed) {
panelPushed = false;
closingViaBack = true;
try { history.back(); } catch (e) { closingViaBack = false; }
return;
}
try {
var h = parseAlertTab(window.location.hash);
if (h) {
suppressHash = true;
history.replaceState(null, "", window.location.pathname + window.location.search);
suppressHash = false;
}
} catch (e) { /* rien */ }
}
function onHashChange() {
if (suppressHash) return;
if (closingViaBack) { closingViaBack = false; return; }
var tab = parseAlertTab(window.location.hash);
if (tab) {
if (!panel || panel.style.display === "none") openPanel(tab);
else setTab(tab, false);
} else if (panel && panel.style.display !== "none") {
panelPushed = false;
panel.style.display = "none";
}
}
function onTabsKeydown(ev) {
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
if (keys.indexOf(ev.key) === -1) return;
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
var idx = TABS.indexOf(currentTab);
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
else if (ev.key === "Home") idx = 0;
else if (ev.key === "End") idx = TABS.length - 1;
ev.preventDefault();
setTab(TABS[idx], true);
if (btns[idx] && btns[idx].focus) btns[idx].focus();
}
function renderTabs() {
if (!tabsEl || !currentData) return;
tabsEl.textContent = "";
var all = allItems(currentData.results);
var counts = countByTab(all, currentData.results);
TABS.forEach(function (tab) {
var b = el("button", "draco-alertes-tab",
TAB_LABELS[tab] + " (" + counts[tab] + ")");
b.type = "button";
b.setAttribute("role", "tab");
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
b.tabIndex = tab === currentTab ? 0 : -1;
b.addEventListener("click", function () { setTab(tab, true); });
tabsEl.appendChild(b);
});
}
function renderRow(a) {
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
var main = el("div", "draco-alertes-main");
var link = el("a", "draco-alertes-title", a.title);
link.href = a.url;
main.appendChild(link);
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
row.appendChild(main);
return row;
}
function renderErrorRow(app, msg) {
var row = el("div", "draco-alertes-item is-error");
var main = el("div", "draco-alertes-main");
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
main.appendChild(el("div", "draco-alertes-detail", msg));
main.appendChild(el("div", "draco-alertes-meta",
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
row.appendChild(main);
return row;
}
function renderCurrent() {
if (!listEl || !currentData) return;
renderTabs();
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
var filtered = filterAlerts(scope.items, currentFilter);
var q = String(currentFilter || "").trim();
if (countEl) {
countEl.textContent = q
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
: "";
}
listEl.textContent = "";
/* Onglet Console sans source : état vide honnête, jamais d'alertes
* inventées, avec le lien profond vers la console. */
if (currentTab === "console" && scope.consoleMissing) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "Console Spark (0)"));
sec.appendChild(el("p", "draco-alertes-empty",
"Aucune alerte console — source indisponible : l'endpoint " +
"d'alertes console (agent-commun, autre chantier) n'est pas " +
"encore publié. Les missions en pause budget / accès requis " +
"et les approbations en attente apparaîtront ici, avec leurs " +
"états réels, dès sa livraison."));
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir la Console Spark");
a.href = currentData.urls.agent || currentData.urls.console || "#";
link.appendChild(a);
sec.appendChild(link);
listEl.appendChild(sec);
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
return;
}
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
var secOg = el("section", "draco-alertes-section");
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
secOg.appendChild(el("p", "draco-alertes-empty",
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
var linkOg = el("div", "draco-alertes-empty");
var aOg = el("a", "", "Ouvrir ObsiGate");
aOg.href = currentData.urls.obsigate;
linkOg.appendChild(aOg);
secOg.appendChild(linkOg);
listEl.appendChild(secOg);
return;
}
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
if (!scope.items.length && !scope.errors.length) {
var empty = currentTab === "historique"
? "Aucune alerte fermée dans les applications joignables."
: currentTab === "tout"
? "Aucune alerte active dans les applications joignables."
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
if (currentTab === "console") empty = "Aucune alerte console.";
listEl.appendChild(el("p", "draco-alertes-empty", empty));
return;
}
if (q && !filtered.length) {
listEl.appendChild(el("p", "draco-alertes-empty",
"Aucune alerte dans cet onglet pour ce filtre."));
return;
}
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
}
function setTab(tab, push) {
if (TABS.indexOf(tab) === -1) tab = "tout";
currentTab = tab;
renderCurrent();
if (push) {
try {
history.pushState(null, "", hashForTab(tab));
panelPushed = true;
} catch (e) { /* URL partageable : repli silencieux */ }
}
}
function fetchJson(url, timeoutMs) {
var ctrl = null, timer = null;
try {
if (typeof AbortController !== "undefined") {
ctrl = new AbortController();
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
}
} catch (e) { ctrl = null; }
var opts = { credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
return fetch(url, opts).then(function (r) {
if (timer) clearTimeout(timer);
if (!r.ok) {
var err = new Error("HTTP " + r.status);
err.status = r.status;
throw err;
}
return r.json();
}).catch(function (e) {
if (timer) clearTimeout(timer);
throw e;
});
}
function loadAll() {
var urls = resolveUrls(browserEnv());
var consoleUrl = consoleSourceUrl(browserEnv());
var jobs = {
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
tiktak: fetchJson(urls.tiktak + "api/notifications")
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
};
if (consoleUrl) {
var agentBase = urls.agent || urls.console;
jobs.console = fetchJson(consoleUrl)
.then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
}
var keys = Object.keys(jobs);
return Promise.all(keys.map(function (k) {
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
var msg = (e && e.status === 401)
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
return [k, { items: [], error: msg }];
});
})).then(function (pairs) {
var results = { obsigate: { items: [] } };
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
return { urls: urls, results: results };
});
}
function setBadge(n) {
try {
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
global.DRACO_BAR.setCounts({ alertes: n });
return;
}
} catch (e) { /* repli ci-dessous */ }
try {
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
b.textContent = n;
b.hidden = !(n > 0);
});
} catch (e) { /* rien */ }
}
function refreshBadge() {
return loadAll().then(function (r) {
var n = countActive(allItems(r.results));
setBadge(n);
return n;
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel(initialTab) {
ensurePanel();
panel.style.display = "flex";
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
currentTab = tab;
currentFilter = "";
if (filterEl) filterEl.value = "";
listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
if (tabsEl) tabsEl.textContent = "";
loadAll().then(function (r) {
currentData = r;
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
* l'URL pour être partageable ; ouverture par lien partagé : le
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
var fromHash = parseAlertTab(window.location.hash);
if (!fromHash) {
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
}
renderCurrent();
setBadge(countActive(allItems(r.results)));
}).catch(function () {
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
renderCurrent();
});
}
var api = {
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
sortAlerts: sortAlerts, countActive: countActive,
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
countByTab: countByTab, parseAlertTab: parseAlertTab,
hashForTab: hashForTab, displayState: displayState,
metaLine: metaLine, fmtDate: fmtDate,
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
}
};
global.DRACO_ALERTES = api;
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
/* Pastille au chargement (après les scripts defer de la barre) ;
* un lien partagé #alertes=<onglet> ouvre directement le panneau
* sur cet onglet. */
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300);
var tab = parseAlertTab(window.location.hash);
if (tab) setTimeout(function () { openPanel(tab); }, 350);
});
window.addEventListener("hashchange", onHashChange);
}
})(typeof window !== "undefined" ? window : globalThis);
+546
View File
@@ -0,0 +1,546 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
* v0.1.5 : noms publics par configuration — aucun nom public d'un
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
* la publication est séparée) : dérivation -dev par l'hôte courant,
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux »).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
* proscrits dans tout le programme).
*
* Configuration attendue avant le chargement :
* window.DRACO_BAR_CONFIG = {
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
*
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
* résolution, dans l'ordre STRICT, pour chaque app :
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
* serveur ou client) depuis SA configuration d'environnement —
* explicite, donc prioritaire, même sur un hôte -dev ;
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
* (première étiquette se terminant par -dev : tiktak-dev,
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
* les noms SANS -dev faute de configuration explicite ;
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
* partout (config injectée comme href rendu) et ne peut pas être exposé.
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
* La clé spark ne correspond à aucun onglet de la barre : elle est
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets, plus
* (depuis le correctif #70) une entrée « Console Spark » vers la
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu compte : voir ci-dessous.
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
* retirées des gabarits hôtes — le menu natif d'ObsiGate
* (#header-menu-btn) lui-même n'est pas touché.
*
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
* son menu par défaut (console agent + applications) sont supprimés
* du fragment, du JS et du CSS. La console agent reste joignable par
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
* onglets présents dans la barre, puis une entrée « Console Spark »
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
* 4) défaut officiel prod https://agent.dracodev.net/.
* AUCUN hôte spark-dev n'est inventé :
* la clé spark, elle, suit toujours son propre contrat (explicite >
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
* addGlobalListener), PAS via document.addEventListener. Cause
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
* remplace document/window.addEventListener par un registre
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
* compris ceux de draco-barre.js (script defer, enregistré après le
* tcInit() initial). La barre survit à la navigation (hors .layout
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
* la marque, lien href="/", est interceptée par l'intercepteur SPA
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
* une navigation interne au lieu de basculer le menu. Les écouteurs
* posés sur le prototype ne figurent pas dans le registre et ne sont
* donc jamais supprimés par le teardown ; en complément, le gabarit
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
* la marque ne déclenche plus de navigation douce. Les autres hôtes
* (SSR, rechargement complet) ne sont pas concernés par le teardown
* mais bénéficient du même code ; repli : si EventTarget.prototype
* est indisponible (mini-DOM de tests), document.addEventListener
* classique est utilisé.
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
/* Défauts OFFICIELS (production) — les seuls noms publics présents
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
* volontairement absent : aucun hôte spark-dev connu, aucune URL
* inventée ; spark retombe sur href rendu puis défaut prod. */
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
* ne déclenche rien). Insensible à la casse. */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function resolveUrls() {
var host = "";
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
var dev = isDevHost(host);
var urls = {};
Object.keys(DEFAULT_URLS).forEach(function (app) {
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
var u = cleanUrl(cfg.urls && cfg.urls[app]);
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
if (!u && dev) u = DEV_URLS[app] || null;
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
if (!u) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var href = tab ? tab.getAttribute("href") : null;
u = cleanUrl(href);
}
/* 4. Défaut officiel (production). */
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function applyUrls(urls) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
});
}
var resolvedUrls = resolveUrls();
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
});
}
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
function userName() {
var u = cfg.user;
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
if (u && typeof u === "object") {
var n = u.display_name || u.name || u.username;
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
}
return null;
}
function titleName() {
var av = document.querySelector(".draco-avatar");
var t = av ? av.getAttribute("title") : null;
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
return null;
}
function displayName() { return userName() || titleName(); }
function syncAvatar() {
var av = document.querySelector(".draco-avatar");
if (!av) return;
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
* sans data-act) : le rendre déclencheur du menu compte. */
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
var name = displayName();
if (name) {
av.textContent = name.charAt(0).toUpperCase();
av.setAttribute("title", name);
av.setAttribute("aria-label", "Compte : " + name);
}
}
/* ── Menus déroulants (apps, compte) ───────────────────────────────
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
* déclencheur. Les href des menus sont (re)synchronisés avec
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
* -dev s'y reflètent même si le menu a été construit avant. */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, compteMenu = null;
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
* lui rend le focus à la fermeture (correctif #70). */
var lastOpener = null;
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
}
function refreshMenuHrefs(menu) {
if (!menu) return;
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups(returnFocus) {
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
if (appsMenu) togglePopup(appsMenu, brand, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
/* Échap seulement : le clic extérieur ne déplace pas le focus
* (voir l'en-tête, « Focus »). */
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
}
function tabLabel(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
return fort ? fort.textContent : (APP_LABELS[app] || app);
}
function tabRole(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
/* Menu des applications (v0.1.3) — construit depuis les onglets
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
* extérieur, Échap, ou le choix d'une application (la navigation
* suit son cours). */
function buildAppsMenu() {
if (!bar || !brand || appsMenu) return;
appsMenu = document.createElement("div");
appsMenu.className = "draco-apps-menu";
appsMenu.hidden = true;
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
* repris ici — l'entrée dédiée ci-dessous le représente déjà
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
* listerait « Console Spark » deux fois. */
if (t.dataset.app === "agent") return;
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
if (ico) a.appendChild(ico.cloneNode(true));
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var fort = t.querySelector(".draco-apptab-txt strong");
var petit = t.querySelector(".draco-apptab-txt small");
var nom = document.createElement("span");
nom.className = "draco-apps-item-nom";
nom.textContent = fort ? fort.textContent : t.dataset.app;
txt.appendChild(nom);
if (petit) {
var role = document.createElement("span");
role.className = "draco-apps-item-role";
role.textContent = petit.textContent;
txt.appendChild(role);
}
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
appsMenu.appendChild(a);
});
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
appsMenu.appendChild(sep);
var sparkItem = document.createElement("a");
sparkItem.className = "draco-apps-item";
sparkItem.href = resolvedUrls.agent || "#";
sparkItem.dataset.app = "agent";
sparkItem.setAttribute("role", "menuitem");
var sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#c2437e");
sparkIco.textContent = "✦";
sparkItem.appendChild(sparkIco);
var sparkTxt = document.createElement("span");
sparkTxt.className = "draco-apps-item-txt";
var sparkNom = document.createElement("span");
sparkNom.className = "draco-apps-item-nom";
sparkNom.textContent = "Console Spark";
sparkTxt.appendChild(sparkNom);
var sparkRole = document.createElement("span");
sparkRole.className = "draco-apps-item-role";
sparkRole.textContent = "Agent commun";
sparkTxt.appendChild(sparkRole);
sparkItem.appendChild(sparkTxt);
appsMenu.appendChild(sparkItem);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
if (!appsMenu.hidden) lastOpener = brand;
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
compteMenu.className = "draco-compte-menu";
compteMenu.hidden = true;
compteMenu.setAttribute("role", "menu");
compteMenu.setAttribute("aria-label", "Compte");
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
});
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
if (!compteMenu) return;
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
}
function toggleCompteMenu(force) {
buildCompteMenu();
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
if (!compteMenu.hidden) lastOpener = avatarBtn;
}
/* Relais d'une action vers l'hôte : onAction si présent, sinon
* événement draco:bar (contrat historique des actions relayées). */
function relay(act, el, ev) {
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
/* Écouteurs globaux posés sur le prototype EventTarget quand il
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
* voir l'en-tête) ; sinon document.addEventListener classique. */
function addGlobalListener(type, fn) {
try {
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
EventTarget.prototype.addEventListener) {
EventTarget.prototype.addEventListener.call(document, type, fn, false);
return;
}
} catch (e) { /* repli ci-dessous */ }
document.addEventListener(type, fn);
}
addGlobalListener("click", function (ev) {
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
toggleCompteMenu(false);
return;
}
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
toggleCompteMenu();
return;
}
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
* sur son déclencheur déjà traité ci-dessus). */
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
relay(act, el, ev);
});
addGlobalListener("keydown", function (ev) {
if (ev.key === "Escape") { closeAllPopups(true); return; }
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
* ouverture explicite + focus sur le premier item du menu (#70).
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
* ci-dessus (pas de double bascule ici). */
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
var brandEl = ev.target.closest('[data-act="apps"]');
if (brandEl) {
if (ev.preventDefault) ev.preventDefault();
toggleAppsMenu();
if (appsMenu && !appsMenu.hidden) {
var first = appsMenu.querySelector(".draco-apps-item");
if (first && typeof first.focus === "function") first.focus();
}
}
}
});
/* API publique : l'app met les compteurs à jour sans recharger.
* setUrls(urls) fusionne de nouvelles bases (même contrat que
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
* les bases résolues (défauts inclus), pour le panneau Spark et tout
* autre consommateur de l'hôte. */
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
}
};
applyUrls(resolvedUrls);
buildAppsMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+859
View File
@@ -0,0 +1,859 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
*
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps ;
* la réponse arrive avec son HTML
* Markdown rendu côté service (v2)
* GET /api/v1/conversations — discussions façon Muse (v2) : une
* principale perpétuelle + des
* parallèles créées/archivées/
* supprimées par l'utilisateur
* GET /api/v1/conversations/… — historique d'une discussion (les
* messages agent portent leur html)
* GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5)
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
* Bouton « Console » (barre des discussions) : ouvre la console web
* du même service (adresse injectée par l'hôte) DANS la page, en surimpression
* pleine page (iframe) qui naît à la géométrie du panneau puis
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
* courante, ?app= app d'origine déduite du sous-domaine) est celle
* du premier chargement — l'iframe est créé paresseusement et
* conservé, la console garde son état entre ouvertures. La barre
* d'outils de la surimpression offre « Mode sidebar » (retour au
* panneau seul, animation inverse) et « Fermer l'agent » (referme
* surimpression et panneau).
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
/* Adresse du service : injectée par l'app hôte (gabarit
* _draco_bar.html, window.DRACO_PANNEAU_CONFIG.api, issu de la
* configuration de l'hôte — AGENT_URL). Aucun nom public en dur
* ici : sans injection, le panneau se déclare non configuré. */
var API = (window.DRACO_PANNEAU_CONFIG && window.DRACO_PANNEAU_CONFIG.api) || "";
var convId = null;
var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
}
function stockerConv() {
try {
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
else sessionStorage.removeItem("draco_panneau_conv");
} catch (e) { /* stockage indisponible */ }
}
function convStockee() {
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
catch (e) { return null; }
}
/* ── Console web (même service, surimpression pleine page) ─── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function urlConsole() {
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
return API + "/" + (params.length ? "?" + params.join("&") : "");
}
/* La surimpression vit entièrement dans ce fichier (DOM + styles
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
* thème suit celui du panneau (data-draco-theme synchronisé par
* appliquerTheme) et sa palette reprend les mêmes valeurs que
* draco-panneau.css. L'iframe est créé au premier clic puis
* conservé : rouvrir la console ne le recharge pas. */
var surEl = null, surCadre = null, surOuverte = false;
var STYLES_CONSOLE =
".draco-console-sur{" +
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
"background:var(--p-fond);color:var(--p-texte);" +
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
"display:flex;flex-direction:column;transition:width .3s ease;" +
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
".draco-console-sur[data-draco-theme=\"dark\"]{" +
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
".draco-console-sur[hidden]{display:none}" +
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
function assurerSurimpression() {
if (surEl) return;
if (!document.getElementById("draco-console-styles")) {
var st = document.createElement("style");
st.id = "draco-console-styles";
st.textContent = STYLES_CONSOLE;
document.head.appendChild(st);
}
surEl = document.createElement("div");
surEl.className = "draco-console-sur";
surEl.setAttribute("aria-label", "Console Spark");
surEl.hidden = true;
surEl.innerHTML =
'<div class="draco-console-barre">' +
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
' <span class="dc-actions">' +
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
' </span></div>' +
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
document.body.appendChild(surEl);
surCadre = surEl.querySelector("iframe");
surEl.querySelector('[data-dc="sidebar"]')
.addEventListener("click", modeSidebarConsole);
surEl.querySelector('[data-dc="fermer"]')
.addEventListener("click", fermerAgentConsole);
appliquerTheme();
}
function largeurPanneau() {
if (root) {
var w = root.getBoundingClientRect().width;
if (w > 0) return Math.round(w);
}
return 388;
}
function ouvrirConsole() {
assurerSurimpression();
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
if (surOuverte) return;
surOuverte = true;
/* Naissance à la géométrie du panneau (ancrée à droite), puis
* extension à toute la page ; le double rAF laisse le temps de
* peindre la largeur de départ, sinon la transition ne joue pas. */
surEl.style.width = largeurPanneau() + "px";
surEl.hidden = false;
requestAnimationFrame(function () {
requestAnimationFrame(function () { surEl.style.width = "100%"; });
});
}
function retrecirConsole() {
if (!surEl || !surOuverte) return;
surOuverte = false;
var fini = false;
function terminer() {
if (fini) return;
fini = true;
surEl.removeEventListener("transitionend", surFin);
surEl.hidden = true; // l'iframe reste chargé, juste masqué
}
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
surEl.addEventListener("transitionend", surFin);
setTimeout(terminer, 380); // filet si transitionend ne vient pas
surEl.style.width = largeurPanneau() + "px";
}
function modeSidebarConsole() { retrecirConsole(); }
function fermerAgentConsole() { fermer(); retrecirConsole(); }
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() {
var t = document.documentElement.getAttribute("data-theme");
if (t === "dark" || t === "light") return t;
if (document.documentElement.classList.contains("dark") ||
document.body.classList.contains("dark")) return "dark";
try {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
} catch (e) { /* pas de matchMedia */ }
return "light";
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
new MutationObserver(appliquerTheme).observe(document.documentElement,
{ attributes: true, attributeFilter: ["data-theme", "class"] });
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", appliquerTheme);
} catch (e) { /* surveillance facultative */ }
}
/* ── Largeur réglable ──────────────────────────────────────── */
function largeurStockee() {
try {
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
return isNaN(v) ? null : v;
} catch (e) { return null; }
}
function appliquerLargeur() {
if (!root) return;
var v = largeurStockee();
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
}
function brancherPoignee(poignee) {
poignee.addEventListener("pointerdown", function (ev) {
if (window.innerWidth <= 700) return; // mobile : pleine largeur
ev.preventDefault();
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
function bouger(e2) {
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
root.style.width = w + "px";
}
function lacher(e2) {
document.removeEventListener("pointermove", bouger);
document.removeEventListener("pointerup", lacher);
var w = Math.round(root.getBoundingClientRect().width);
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
}
document.addEventListener("pointermove", bouger);
document.addEventListener("pointerup", lacher);
});
poignee.addEventListener("dblclick", function () {
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
appliquerLargeur();
});
}
function construire() {
root = document.createElement("aside");
root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun");
root.innerHTML =
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
'<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' +
'<div class="draco-disc-bar">' +
' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme();
surveillerTheme();
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") return;
/* Surimpression ouverte : Échap = retour au panneau seul
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
* console (autre origine), l'événement ne remonte pas
* jusqu'ici : il ne peut donc pas fermer par surprise. */
if (surOuverte) { modeSidebarConsole(); return; }
if (root.classList.contains("open")) fermer();
});
}
function ajouterMsg(kind, texte) {
var d = document.createElement("div");
d.className = "draco-msg " + kind;
d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
/* Message agent rendu en Markdown : le HTML vient du service
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
function ajouterMsgMd(texte, html) {
var d = document.createElement("div");
d.className = "draco-msg agent md";
if (html) d.innerHTML = html;
else d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte;
}
function majTitre() {
titreEl.textContent = convTitre || "Discussion principale";
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
if (!API) {
majEtat("err", "Panneau Spark non configuré");
ajouterMsg("sys", "Adresse du service d'agents absente : l'app hôte ne l'a pas injectée (DRACO_PANNEAU_CONFIG / AGENT_URL).");
etatConnu = true;
return;
}
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
majEtat("err", "Session partagée non reconnue");
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
etatConnu = true;
return null;
}
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (me) {
if (!me) return;
etatConnu = true;
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
})
.catch(function () {
majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (" + API + ") ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
/* ── Discussions (façon Muse) ────────────────────────────── */
function chargerListeDiscussions() {
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
{ credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
}
function demarrerDiscussions() {
chargerListeDiscussions()
.then(function (d) {
var precedente = convStockee();
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
var active = trouvee || d.principale;
if (!active) return;
convId = active.id; convKind = active.kind; convTitre = active.title;
stockerConv(); majTitre();
chargerHistorique();
})
.catch(function () {
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
});
}
function basculerDiscussion(c) {
convId = c.id; convKind = c.kind; convTitre = c.title;
stockerConv(); majTitre();
tiroirEl.hidden = true;
msgsEl.innerHTML = "";
ajouterMsg("sys", (c.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
chargerHistorique();
inputEl.focus();
}
function nouvelleDiscussion() {
fetch(API + "/api/v1/conversations", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: domaine() })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (c) {
basculerDiscussion(c);
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
})
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
}
function actionDiscussion(id, action) {
var methode = action === "supprimer" ? "DELETE" : "POST";
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
return fetch(url, { method: methode, credentials: "include" })
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
});
}
function ligneDiscussion(c, actions) {
var ligne = document.createElement("div");
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
var meta = document.createElement("div");
meta.className = "d-meta";
var quand = "";
try {
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
{ day: "numeric", month: "short" });
} catch (e) { /* date illisible */ }
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
ligne.appendChild(meta);
var sous = document.createElement("div");
sous.className = "d-sous";
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
(c.kind === "principale" ? " · perpétuelle" : "");
ligne.appendChild(sous);
meta.addEventListener("click", function () { basculerDiscussion(c); });
sous.addEventListener("click", function () { basculerDiscussion(c); });
if (actions && actions.length) {
var btns = document.createElement("div");
btns.className = "d-actions";
actions.forEach(function (a) {
var b = document.createElement("button");
b.type = "button"; b.textContent = a.label;
if (a.danger) b.className = "danger";
b.addEventListener("click", function (ev) {
ev.stopPropagation();
if (a.confirmer && !window.confirm(a.confirmer)) return;
actionDiscussion(c.id, a.action)
.then(function () {
if (c.id === convId && a.action !== "desarchiver") {
chargerListeDiscussions().then(function (d) {
if (d.principale) {
basculerDiscussion(d.principale);
ajouterMsg("sys", a.action === "supprimer"
? "Discussion supprimée — retour à la discussion principale."
: "Discussion archivée — retour à la discussion principale.");
}
});
}
remplirTiroir();
})
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
});
btns.appendChild(b);
});
ligne.appendChild(btns);
}
return ligne;
}
function remplirTiroir() {
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
chargerListeDiscussions()
.then(function (d) {
tiroirEl.innerHTML = "";
var h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussion principale";
tiroirEl.appendChild(h);
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussions parallèles";
tiroirEl.appendChild(h);
if (!d.paralleles.length) {
var vide = document.createElement("div");
vide.className = "d-vide";
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
tiroirEl.appendChild(vide);
}
d.paralleles.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Archiver", action: "archive" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
if (d.archivees && d.archivees.length) {
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Archivées";
tiroirEl.appendChild(h);
d.archivees.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Désarchiver", action: "desarchiver" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
}
})
.catch(function () {
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
});
}
function basculerTiroir() {
tiroirEl.hidden = !tiroirEl.hidden;
if (!tiroirEl.hidden) remplirTiroir();
}
function chargerHistorique() {
if (!convId) return;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) { convId = null; stockerConv(); return null; }
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d) return;
convKind = d.kind; convTitre = d.title || convTitre;
majTitre();
if (!msgsEl.querySelector(".draco-msg")) {
ajouterMsg("sys", (d.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
}
var sys = msgsEl.querySelector(".draco-msg.sys");
(d.messages || []).forEach(function (m) {
var el = m.role === "user"
? ajouterMsg("user", m.content)
: ajouterMsgMd(m.content, m.html);
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : on repart à neuf */ });
}
function chargerAujourdhui() {
var bloc = document.createElement("div");
bloc.className = "draco-auj";
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
var t = d.sources.tiktak;
h += "<h5>Tâches du jour — TikTak</h5>";
if (t.status === "ok") {
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (t.status === "non_configure") {
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
} else if (t.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
} else {
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
}
var k = d.sources.kanbante;
h += "<h5>Cartes en Révision — Kanbante</h5>";
if (k.status === "ok") {
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (k.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
} else {
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
}
bloc.innerHTML = h;
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () {
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
});
}
function chargerApprobations() {
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
d.approvals.forEach(function (a) {
var bloc = document.createElement("div");
bloc.className = "draco-appro";
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
"<div class='resume'>" + esc(a.summary) + "</div>" +
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
"<button type='button' class='non'>Rejeter</button></div>";
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
msgsEl.appendChild(bloc);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
}
function decider(id, decision, bloc) {
fetch(API + "/api/v1/approvals/" + id + "/decide", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: decision })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
bloc.style.opacity = ".55";
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
})
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
var entete = document.createElement("div");
entete.className = "m-entete";
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
ligne.appendChild(entete);
var meta = document.createElement("div");
meta.className = "m-meta";
meta.textContent = m.executor +
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
ligne.appendChild(meta);
if (m.result) {
var rapport = document.createElement("div");
rapport.className = "m-rapport md";
if (m.result_html) rapport.innerHTML = m.result_html;
else rapport.textContent = String(m.result).slice(0, 1200);
ligne.appendChild(rapport);
}
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
inputEl.value = "";
ajouterMsg("user", texte);
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
})
.then(function (r) {
if (r.status === 401) {
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
majEtat("err", "Session partagée non reconnue");
return null;
}
if (!r.ok) {
return r.json().catch(function () { return null; }).then(function (e) {
attente.textContent = (e && e.detail) ? e.detail :
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
return null;
});
}
return r.json();
})
.then(function (d) {
if (!d) return;
convId = d.conversation_id;
convKind = d.kind || convKind;
if (d.title) convTitre = d.title;
stockerConv(); majTitre();
if (d.html) {
attente.className = "draco-msg agent md";
attente.innerHTML = d.html;
} else {
attente.textContent = d.reply;
}
msgsEl.scrollTop = msgsEl.scrollHeight;
if (d.tools_used && d.tools_used.length) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
}
majEtat("ok", "Connecté au service — domaine " + d.domain);
})
.catch(function () {
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
}
function ouvrir() {
if (!root) { construire(); demarrerDiscussions(); }
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
window.DRACO_PANNEAU = {
open: ouvrir,
close: fermer,
toggle: function () {
if (root && root.classList.contains("open")) fermer(); else ouvrir();
}
};
})();
+54
View File
@@ -0,0 +1,54 @@
/**
* Kanbante — Flow health (issue #9): quick actions on zombie cards.
* Close/backlog reuse /api/move; the nudge reuses the comments API.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
/* Project switcher (0.19.0): jump between projects without
going back to the health index. */
var switcher = document.getElementById('health-switch');
if (switcher) {
switcher.addEventListener('change', function () {
window.location.href = '/health/' + switcher.value;
});
}
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-zombie]');
if (!btn) return;
var repo = btn.getAttribute('data-repo');
var number = btn.getAttribute('data-number');
var column = btn.getAttribute('data-column');
var action = btn.getAttribute('data-zombie');
var parts = repo.split('/');
btn.disabled = true;
var done = function () { location.reload(); };
var fail = function (err) { btn.disabled = false; alert(err.message); };
if (action === 'comment') {
post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', {
body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?'
}).then(done).catch(fail);
} else {
post('/api/move', {
repo_full_name: repo,
issue_number: number,
from_column: column,
to_column: action === 'close' ? 'done' : 'backlog',
to_order: number
}).then(done).catch(fail);
}
});
})();
+190
View File
@@ -0,0 +1,190 @@
// Mes issues — filtrage instantané côté client (0.33.0).
// Aucune requête réseau : les lignes portent leurs facettes en
// attributs data-*, le moteur ne fait que comparer des chaînes
// normalisées. Les filtres structurels (projet, colonnes,
// étiquette, tri) persistent en localStorage ; la recherche
// texte, elle, est éphémère.
(() => {
const search = document.getElementById('my-search');
if (!search) return; // page sans résultats : rien à filtrer
const repoSel = document.getElementById('my-repo');
const labelSel = document.getElementById('my-label');
const sortSel = document.getElementById('my-sort');
const chipsBox = document.getElementById('my-col-chips');
const countEl = document.getElementById('my-count');
const resetBtn = document.getElementById('my-reset');
const emptyBox = document.getElementById('my-empty');
const emptyReset = document.getElementById('my-empty-reset');
const STORE_KEY = 'kanbante-my-filters';
const norm = (s) => (s || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
const state = {
q: '',
repo: '',
label: '',
cols: new Set(),
sort: 'recent',
};
// ── Persistance des filtres structurels ────────────────
try {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
if (saved.repo) state.repo = saved.repo;
if (saved.label) state.label = saved.label;
if (Array.isArray(saved.cols)) state.cols = new Set(saved.cols);
if (saved.sort) state.sort = saved.sort;
} catch { /* stockage indisponible : état par défaut */ }
function persist() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify({
repo: state.repo,
label: state.label,
cols: [...state.cols],
sort: state.sort,
}));
} catch { /* sans gravité */ }
}
// Refléter l'état restauré dans les contrôles.
if (state.repo) repoSel.value = state.repo;
if (state.label) labelSel.value = state.label;
sortSel.value = state.sort;
// Un filtre restauré peut ne plus exister (projet disparu) :
// dans ce cas le select retombe à « tous » tout seul.
if (repoSel.value !== state.repo) state.repo = repoSel.value;
if (labelSel.value !== state.label) state.label = labelSel.value;
const groups = [...document.querySelectorAll('.focus-group')];
const allRows = [...document.querySelectorAll('.issue-row')];
allRows.forEach((row) => {
row._search = norm(row.dataset.search);
row._labels = norm(row.dataset.labels).split(' ').filter(Boolean);
row._title = norm(row.dataset.title);
});
function syncChips() {
chipsBox.querySelectorAll('.filter-chip').forEach((chip) => {
const col = chip.dataset.col;
const on = col === '' ? state.cols.size === 0 : state.cols.has(col);
chip.classList.toggle('on', on);
if (col !== '') chip.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
function sortRows() {
const mode = state.sort;
if (mode === 'repo') {
const main = document.querySelector('.focus-main');
[...groups].sort((a, b) =>
a.dataset.repo.localeCompare(b.dataset.repo)
).forEach((g) => main.appendChild(g));
}
groups.forEach((group) => {
const box = group.querySelector('.issue-results');
const rows = [...box.querySelectorAll('.issue-row')];
const by = {
recent: (a, b) => (b.dataset.updated || '').localeCompare(a.dataset.updated || ''),
oldest: (a, b) => (a.dataset.updated || '').localeCompare(b.dataset.updated || ''),
title: (a, b) => a._title.localeCompare(b._title),
repo: (a, b) => a._title.localeCompare(b._title),
}[mode];
rows.sort(by).forEach((r) => box.appendChild(r));
});
}
function apply() {
const q = norm(state.q).trim();
let visible = 0;
groups.forEach((group) => {
let groupVisible = 0;
group.querySelectorAll('.issue-row').forEach((row) => {
const okText = !q || row._search.includes(q);
const okRepo = !state.repo || group.dataset.repo === state.repo;
const okCol = state.cols.size === 0 || state.cols.has(row.dataset.column);
const okLabel = !state.label
|| row._labels.includes(norm(state.label));
const ok = okText && okRepo && okCol && okLabel;
row.hidden = !ok;
if (ok) groupVisible++;
});
group.hidden = groupVisible === 0;
const badge = group.querySelector('[data-group-count]');
if (badge) badge.textContent = groupVisible;
visible += groupVisible;
});
countEl.textContent = visible === allRows.length
? `${visible} issue${visible > 1 ? 's' : ''}`
: `${visible} sur ${allRows.length} issues`;
const filtering = q || state.repo || state.label
|| state.cols.size > 0;
resetBtn.hidden = !filtering;
emptyBox.hidden = visible !== 0;
syncChips();
persist();
}
function resetAll() {
state.q = '';
search.value = '';
state.repo = '';
repoSel.value = '';
state.label = '';
labelSel.value = '';
state.cols.clear();
sortSel.value = 'recent';
state.sort = 'recent';
sortRows();
apply();
search.focus();
}
// ── Événements ─────────────────────────────────────────
let raf = 0;
search.addEventListener('input', () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
state.q = search.value;
apply();
});
});
search.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
search.value = '';
state.q = '';
apply();
}
});
repoSel.addEventListener('change', () => { state.repo = repoSel.value; apply(); });
labelSel.addEventListener('change', () => { state.label = labelSel.value; apply(); });
sortSel.addEventListener('change', () => {
state.sort = sortSel.value;
sortRows();
apply();
});
chipsBox.addEventListener('click', (e) => {
const chip = e.target.closest('.filter-chip');
if (!chip) return;
const col = chip.dataset.col;
if (col === '') state.cols.clear();
else if (state.cols.has(col)) state.cols.delete(col);
else state.cols.add(col);
apply();
});
resetBtn.addEventListener('click', resetAll);
emptyReset?.addEventListener('click', resetAll);
document.addEventListener('keydown', (e) => {
if (e.key !== '/') return;
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
e.preventDefault();
search.focus();
});
sortRows();
apply();
})();
+75
View File
@@ -0,0 +1,75 @@
/**
* Kanbante — sidebar behaviour (issue #16, 0.16.0 layout).
* Desktop: collapse to an icon rail (persisted in localStorage).
* Mobile: the same sidebar becomes an off-canvas drawer, toggled
* by the topbar burger, closed by backdrop / Escape / navigation.
*/
(function () {
var burger = document.getElementById('nav-burger');
var backdrop = document.getElementById('side-backdrop');
var collapseBtn = document.getElementById('side-collapse');
function closeDrawer() {
document.body.classList.remove('sidebar-open');
if (backdrop) backdrop.hidden = true;
if (burger) {
burger.setAttribute('aria-expanded', 'false');
burger.textContent = '☰';
}
}
if (burger) {
burger.addEventListener('click', function () {
var open = document.body.classList.toggle('sidebar-open');
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
burger.textContent = open ? '✕' : '☰';
if (backdrop) backdrop.hidden = !open;
});
}
if (backdrop) backdrop.addEventListener('click', closeDrawer);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDrawer();
});
window.addEventListener('resize', function () {
if (window.innerWidth > 900) closeDrawer();
});
if (collapseBtn) {
collapseBtn.addEventListener('click', function () {
var collapsed = document.body.classList.toggle('sidebar-collapsed');
try {
localStorage.setItem('kanbante-sidebar', collapsed ? 'collapsed' : 'open');
} catch (e) { /* private mode: state lasts for the page only */ }
});
}
/* Accordéon EXCLUSIF (0.22.0) : une seule section ouverte
à la fois. Ouvrir une section ferme les autres ; cliquer
la section ouverte la referme. L'état initial vient du
script d'amorce de _header.html (section de la page
courante ou choix mémorisé dans kanbante-nav-open). */
var groupBtns = document.querySelectorAll('[data-group-btn]');
function syncGroupBtn(btn) {
var closed = document.body.classList.contains(
'nav-closed-' + btn.getAttribute('data-group-btn'));
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
}
function setOpenGroup(openKey) {
groupBtns.forEach(function (btn) {
var key = btn.getAttribute('data-group-btn');
document.body.classList.toggle('nav-closed-' + key, key !== openKey);
syncGroupBtn(btn);
});
try {
localStorage.setItem('kanbante-nav-open', openKey);
} catch (e) { /* non-fatal */ }
}
groupBtns.forEach(function (btn) {
syncGroupBtn(btn);
btn.addEventListener('click', function () {
var key = btn.getAttribute('data-group-btn');
var isOpen = !document.body.classList.contains('nav-closed-' + key);
setOpenGroup(isOpen ? '' : key);
});
});
})();
+2
View File
@@ -13,10 +13,12 @@
var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
{ icon: '🛩️', label: 'En vol partout — encours tous projets, par colonne', href: '/inflight' },
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
{ icon: '👤', label: 'Mes issues', href: '/my' },
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
+27
View File
@@ -0,0 +1,27 @@
// Page Projet — écritures de jalons (0.35.0, issue #26).
// Les formulaires data-ms-form sont interceptés : POST vers
// l'API Kanbante (qui écrit dans la forge), puis rechargement
// pour refléter l'état réel.
(() => {
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
document.querySelectorAll('form[data-ms-form]').forEach((form) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const confirmMsg = form.dataset.confirm;
if (confirmMsg && !window.confirm(confirmMsg)) return;
const fd = new FormData(form);
fd.append('csrf_token', csrf);
const btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
try {
const r = await fetch(form.dataset.action, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
window.location.reload();
} catch (err) {
if (btn) btn.disabled = false;
window.alert('❌ ' + err.message);
}
});
});
})();
+514
View File
@@ -0,0 +1,514 @@
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
* Contrat d'API (audité, rien d'inventé) :
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
* -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q=
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
* collections: [{ title, subtitle, url: "/db/<id>" }] }
* - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
*
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
* hôte de la requête > dérivation > défaut officiel) ;
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
* 4. href des onglets .draco-apptab rendus ;
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
*
* Authentification cross-origin : les appels sœurs partent avec
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
* (GET sur l'endpoint de recherche uniquement, pas de "*").
* Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native.
*/
(function (global) {
"use strict";
/* globalThis : le fichier est chargé comme script classique dans les
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
* et importé en ESM par le test ObsiGate (frontend/js est "type":
* "module") — où `this` de niveau supérieur vaut undefined. */
var PROD = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
};
var DEV = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
};
var APPS = [
{ key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
qa: "QA",
review: "Relecture",
todo: "À faire",
done: "Terminé",
};
var TIMEOUT_MS = 8000;
var DEBOUNCE_MS = 250;
var PER_APP_LIMIT = 8;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanUrl(u) {
if (!u) return null;
u = String(u).trim();
if (!u || u.indexOf("{{") !== -1) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) === "/" ? u : u + "/";
}
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
function resolveBases(opts) {
opts = opts || {};
var dev = isDevHost(opts.host);
var out = {};
APPS.forEach(function (app) {
var key = app.key;
var u = cleanUrl(opts.explicit && opts.explicit[key]);
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
if (!u && dev) u = DEV[key];
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
if (!u) u = PROD[key];
out[key] = withSlash(u);
});
return out;
}
function searchPath(app, q) {
if (app === "obsigate") {
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
}
return "api/search?q=" + encodeURIComponent(q);
}
function snippetOf(s, n) {
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
}
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
function normalize(app, data, base, q) {
data = data || {};
if (app === "obsigate") {
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
return {
title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""),
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
url:
base +
"#file=" +
encodeURIComponent(r.vault || "") +
":" +
encodeURIComponent(r.path || ""),
};
});
}
if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []);
return items.slice(0, PER_APP_LIMIT).map(function (r) {
var rel = String(r.url || "").replace(/^\//, "");
return {
title: r.title || "Sans titre",
snippet: snippetOf(r.excerpt || ""),
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
url: base + rel,
};
});
}
if (app === "tiktak") {
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return {
title: t.title || "Sans titre",
snippet: snippetOf(t.description || ""),
context: "Tâche · " + ctx,
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
};
});
}
if (app === "kanbante") {
var rows = [];
(data.groups || []).forEach(function (g) {
(g.rows || []).forEach(function (row) {
rows.push({ group: g.full_name, row: row });
});
});
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
var issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return {
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
snippet: snippetOf(issue.body || ""),
context: x.group + (col ? " · " + col : ""),
url: base + "board/" + x.group,
};
});
}
return [];
}
/* Recherche native de l'app (repli quand la section est en erreur). */
function nativeSearchUrl(app, base, q) {
if (app === "tiktak" || app === "kanbante") {
return base + "search?q=" + encodeURIComponent(q || "");
}
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
return base;
}
function nativeSearchLabel(app) {
if (app === "tiktak" || app === "kanbante") {
return "Ouvrir la recherche dans " + appLabel(app);
}
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
}
function appLabel(key) {
for (var i = 0; i < APPS.length; i++) {
if (APPS[i].key === key) return APPS[i].label;
}
return key;
}
var api = {
esc: esc,
isDevHost: isDevHost,
resolveBases: resolveBases,
searchPath: searchPath,
normalize: normalize,
nativeSearchUrl: nativeSearchUrl,
PROD: PROD,
DEV: DEV,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
global.RecherchePartout = api;
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
if (typeof document === "undefined") return;
function fetchSection(app, url, onDone) {
var ctrl = null;
var timer = null;
try {
ctrl = new AbortController();
timer = setTimeout(function () {
ctrl.abort();
}, TIMEOUT_MS);
} catch (e) {
ctrl = null;
}
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
fetch(url, opts)
.then(function (resp) {
if (!resp.ok) {
var err = new Error("HTTP " + resp.status);
err.status = resp.status;
throw err;
}
return resp.json();
})
.then(function (data) {
if (timer) clearTimeout(timer);
onDone(null, data);
})
.catch(function (err) {
if (timer) clearTimeout(timer);
onDone(err, null);
});
}
function renderSection(section, state) {
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
var body = section.querySelector(".rp-body");
var count = section.querySelector(".rp-count");
var toutVoir = section.querySelector(".rp-all");
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
toutVoir.textContent = "Tout voir";
toutVoir.title = nativeSearchLabel(state.app);
if (state.status === "loading") {
count.textContent = "";
body.innerHTML = '<p class="rp-state">Recherche…</p>';
return;
}
if (state.status === "error") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state rp-error">' +
esc(state.message) +
' — <a href="' +
esc(nativeSearchUrl(state.app, state.base, state.q)) +
'">' +
esc(nativeSearchLabel(state.app)) +
"</a></p>";
return;
}
if (state.status === "empty") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
return;
}
count.textContent = String(state.items.length);
body.innerHTML = state.items
.map(function (item) {
return (
'<a class="rp-item" data-rp-link href="' +
esc(item.url) +
'">' +
'<span class="rp-title">' +
esc(item.title) +
"</span>" +
(item.context
? '<span class="rp-context">' + esc(item.context) + "</span>"
: "") +
(item.snippet
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
: "") +
"</a>"
);
})
.join("");
}
function init() {
var root = document.getElementById("recherche");
if (!root) return;
var input = document.getElementById("rp-q");
if (!input) return;
var hostApp = root.getAttribute("data-host") || "";
var explicit = {};
APPS.forEach(function (app) {
var v = root.getAttribute("data-base-" + app.key);
if (v) explicit[app.key] = v;
});
var barUrls = {};
try {
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
} catch (e) {}
var tabHrefs = {};
try {
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
var href = tab.getAttribute("href");
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
});
} catch (e) {}
var bases = resolveBases({
host: global.location.hostname,
explicit: explicit,
barUrls: barUrls,
tabHrefs: tabHrefs,
});
var sections = {};
APPS.forEach(function (app) {
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
if (el) sections[app.key] = el;
});
var currentQ = "";
var runId = 0;
var debounceTimer = null;
function runSearch(q) {
currentQ = q;
var myRun = ++runId;
if (!q) {
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
sec.querySelector(".rp-body").innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
sec.querySelector(".rp-count").textContent = "";
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
});
return;
}
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
var url;
if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q);
} else {
url = bases[app.key] + searchPath(app.key, q);
}
fetchSection(app.key, url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
else if (err.status === 401 || err.status === 403)
msg = "Connexion requise sur " + app.label;
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
renderSection(sec, {
status: "error",
message: msg,
app: app.key,
base: bases[app.key],
q: q,
});
return;
}
var items = normalize(app.key, data, bases[app.key], q);
renderSection(sec, {
status: items.length ? "ok" : "empty",
items: items,
app: app.key,
base: bases[app.key],
q: q,
});
});
});
}
function scheduleSearch(q, immediate) {
if (debounceTimer) clearTimeout(debounceTimer);
if (immediate) {
runSearch(q);
return;
}
debounceTimer = setTimeout(function () {
runSearch(q);
}, DEBOUNCE_MS);
}
function syncUrl(q) {
try {
var url = new URL(global.location.href);
if (q) url.searchParams.set("q", q);
else url.searchParams.delete("q");
global.history.replaceState(null, "", url.toString());
} catch (e) {}
}
input.addEventListener("input", function () {
var q = input.value.trim();
syncUrl(q);
scheduleSearch(q, false);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var first = document.querySelector("[data-rp-link]");
if (first) {
e.preventDefault();
first.click();
}
} else if (e.key === "Escape") {
input.value = "";
syncUrl("");
scheduleSearch("", true);
input.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
});
document.addEventListener(
"keydown",
function (e) {
var target = e.target;
var typing =
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable);
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
e.preventDefault();
e.stopPropagation();
input.focus();
input.select();
return;
}
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
},
true
);
function moveFocus(delta, e) {
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
if (!links.length) return;
var active = document.activeElement;
var idx = links.indexOf(active);
var next;
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
else next = (idx + delta + links.length) % links.length;
e.preventDefault();
links[next].focus();
}
var params = new URLSearchParams(global.location.search);
var initialQ = (params.get("q") || "").trim();
if (initialQ) {
input.value = initialQ;
runSearch(initialQ);
} else {
runSearch("");
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: this
);
+245
View File
@@ -0,0 +1,245 @@
/**
* Kanbante — sélecteur de dépôt de la Capture éclair (0.21.0).
*
* Le champ Dépôt devient intelligent :
* - suggestions des dépôts réels dès la frappe (données /api/repos,
* épinglés et récents d'abord) ;
* - correspondance sur les deux segments : « kan » suffit pour
* trouver Projets/kanbante, « pr/kan » aussi ;
* - complétion Tab segment par segment, comme un shell :
* « pr » Tab → « Projets/ », puis « kan » Tab → « kanbante » ;
* - ↑/↓ + Entrée, clic souris, Échap ;
* - avertissement doux si le nom final n'existe pas dans Gitea.
*/
(function () {
var input = document.getElementById('capture-repo');
if (!input) return;
var list = document.getElementById('repo-listbox');
var hint = document.getElementById('repo-hint');
var repos = null;
var candidates = [];
var activeIndex = -1;
var isOpen = false;
function load() {
if (repos) return Promise.resolve(repos);
return fetch('/api/repos', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (data) { repos = data.repos || []; return repos; })
.catch(function () { repos = []; return repos; });
}
/* ── Correspondance ──────────────────────────────────── */
function matchRepos(value) {
var raw = value.trim().toLowerCase();
if (!raw) return repos.slice(0, 12);
var parts = raw.split('/').map(function (s) { return s.trim(); });
var scored = [];
repos.forEach(function (repo) {
var full = repo.full_name.toLowerCase();
var slash = full.indexOf('/');
var owner = full.slice(0, slash);
var name = full.slice(slash + 1);
var score = null;
if (parts.length === 1) {
var q = parts[0];
if (!q) score = 4;
else if (name.indexOf(q) === 0) score = 0;
else if (owner.indexOf(q) === 0) score = 1;
else if (name.indexOf(q) > -1) score = 2;
else if (full.indexOf(q) > -1) score = 3;
} else {
if (owner.indexOf(parts[0]) === 0 && name.indexOf(parts[1]) === 0) score = 0;
else if (owner.indexOf(parts[0]) > -1 && name.indexOf(parts[1]) > -1) score = 1;
}
if (score !== null) scored.push({ repo: repo, score: score });
});
scored.sort(function (a, b) {
if (a.score !== b.score) return a.score - b.score;
if (a.repo.starred !== b.repo.starred) return a.repo.starred ? -1 : 1;
return (b.repo.updated_at || '').localeCompare(a.repo.updated_at || '');
});
return scored.slice(0, 12).map(function (s) { return s.repo; });
}
/* ── Rendu / ouverture ───────────────────────────────── */
function highlight() {
Array.prototype.forEach.call(list.children, function (item, i) {
item.classList.toggle('active', i === activeIndex);
});
}
function render() {
list.innerHTML = '';
candidates.forEach(function (repo, i) {
var item = document.createElement('span');
item.className = 'repo-item';
item.setAttribute('role', 'option');
var star = repo.starred ? '⭐ ' : '';
var date = repo.updated_at ? repo.updated_at.slice(0, 10) : '';
var nameEl = document.createElement('span');
nameEl.className = 'repo-item-name';
nameEl.textContent = star + repo.full_name;
var metaEl = document.createElement('span');
metaEl.className = 'repo-item-meta';
metaEl.textContent = (repo.archived ? 'archivé · ' : '') + date;
item.appendChild(nameEl);
item.appendChild(metaEl);
item.addEventListener('mousedown', function (e) {
e.preventDefault();
choose(repo);
});
item.addEventListener('mousemove', function () {
if (activeIndex !== i) { activeIndex = i; highlight(); }
});
list.appendChild(item);
});
highlight();
list.hidden = !isOpen || candidates.length === 0;
input.setAttribute('aria-expanded', isOpen && candidates.length ? 'true' : 'false');
}
function refresh(openList) {
if (!repos) return;
candidates = matchRepos(input.value);
if (activeIndex >= candidates.length) activeIndex = candidates.length - 1;
isOpen = openList;
render();
validate();
}
function close() {
isOpen = false;
activeIndex = -1;
render();
}
function choose(repo) {
input.value = repo.full_name;
close();
validate();
input.focus();
}
function validate() {
if (!hint || !repos) return;
var value = input.value.trim().toLowerCase();
var exact = !value || repos.some(function (r) {
return r.full_name.toLowerCase() === value;
});
hint.hidden = exact;
input.classList.toggle('repo-unknown', !exact);
}
/* ── Complétion Tab, segment par segment ─────────────── */
function commonPrefix(strings) {
if (!strings.length) return '';
var prefix = strings[0];
for (var i = 1; i < strings.length; i++) {
while (strings[i].indexOf(prefix) !== 0) {
prefix = prefix.slice(0, -1);
if (!prefix) return '';
}
}
return prefix;
}
function completeSegment() {
if (!repos) return false;
var value = input.value;
var slash = value.lastIndexOf('/');
var head = slash >= 0 ? value.slice(0, slash + 1) : '';
var segment = slash >= 0 ? value.slice(slash + 1) : value;
var pool = [];
if (slash < 0) {
var owners = {};
repos.forEach(function (r) {
var o = r.full_name.split('/')[0];
if (o.toLowerCase().indexOf(segment.toLowerCase()) === 0) owners[o] = true;
});
pool = Object.keys(owners);
} else {
var ownerPart = value.slice(0, slash).toLowerCase();
repos.forEach(function (r) {
var parts = r.full_name.split('/');
if (parts[0].toLowerCase() === ownerPart &&
parts[1].toLowerCase().indexOf(segment.toLowerCase()) === 0) {
pool.push(parts[1]);
}
});
}
if (!pool.length) return false;
var lcp = commonPrefix(pool);
if (lcp.length > segment.length &&
lcp.toLowerCase().indexOf(segment.toLowerCase()) === 0) {
var completed = head + lcp;
// La complétion tombe sur un nom d'organisation exact :
// poser le séparateur dans le même Tab (comme un shell
// qui complète un répertoire) — le geste « pr » Tab
// donne « Projets/ », prêt pour le segment du dépôt.
if (slash < 0 && pool.indexOf(lcp) !== -1) completed += '/';
input.value = completed;
refresh(true);
return true;
}
// Segment d'organisation déjà complet et sans ambiguïté :
// poser le séparateur et passer au segment du dépôt.
if (slash < 0 && pool.length === 1 &&
lcp.toLowerCase() === segment.toLowerCase()) {
input.value = head + lcp + '/';
refresh(true);
return true;
}
return false;
}
/* ── Événements ──────────────────────────────────────── */
input.addEventListener('focus', function () {
load().then(function () { refresh(true); });
});
input.addEventListener('input', function () {
activeIndex = -1;
load().then(function () { refresh(true); });
});
input.addEventListener('blur', function () {
close();
validate();
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
if (completeSegment()) {
e.preventDefault();
} else {
close();
}
return;
}
if (e.key === 'Escape') {
close();
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
load().then(function () {
if (!isOpen) { refresh(true); activeIndex = 0; highlight(); return; }
var delta = e.key === 'ArrowDown' ? 1 : -1;
activeIndex = Math.max(0, Math.min(candidates.length - 1, activeIndex + delta));
highlight();
});
return;
}
if (e.key === 'Enter') {
if (isOpen && activeIndex >= 0 && candidates[activeIndex]) {
e.preventDefault();
choose(candidates[activeIndex]);
} else if (isOpen && candidates.length === 1) {
e.preventDefault();
choose(candidates[0]);
}
}
});
// Si le champ est pré-rempli (lien ?repo= ou défaut), valider
// dès que la liste est connue.
if (input.value) load().then(validate);
})();
+56
View File
@@ -0,0 +1,56 @@
/**
* Kanbante — pin projects via Gitea stars (issue #20).
* The star lives in Gitea (source of truth); this only toggles it
* through the API and moves the card between the pinned section
* and its home tab grid, without a page reload.
*/
(function () {
var pinnedSection = document.getElementById('pinned-section');
var pinnedGrid = document.getElementById('pinned-grid');
function refreshPinnedVisibility() {
if (!pinnedSection || !pinnedGrid) return;
pinnedSection.hidden = pinnedGrid.querySelectorAll('.project-card').length === 0;
}
document.querySelectorAll('.star-btn').forEach(function (btn) {
btn.addEventListener('click', async function (e) {
e.preventDefault();
e.stopPropagation();
var full = btn.getAttribute('data-full');
var parts = full.split('/');
var want = btn.getAttribute('data-starred') !== '1';
btn.disabled = true;
try {
var fd = new FormData();
fd.append('starred', want ? '1' : '0');
fd.append('csrf_token', window.KANBANTE_CSRF || '');
var res = await fetch('/api/projects/' + parts[0] + '/' + parts[1] + '/star', {
method: 'POST', body: fd,
});
var data = await res.json();
if (!data.ok) throw new Error(data.error || 'échec');
btn.setAttribute('data-starred', want ? '1' : '0');
btn.textContent = want ? '⭐' : '☆';
btn.title = want ? 'Désépingler (étoile Gitea)' : 'Épingler (étoile Gitea)';
var card = btn.closest('.project-card');
if (card) {
if (want && pinnedGrid) {
pinnedGrid.appendChild(card);
} else {
var home = document.getElementById('grid-' + (card.getAttribute('data-home') || 'active'));
if (home) home.appendChild(card);
}
refreshPinnedVisibility();
}
} catch (err) {
btn.textContent = '⚠️';
setTimeout(function () {
btn.textContent = btn.getAttribute('data-starred') === '1' ? '⭐' : '☆';
}, 1600);
} finally {
btn.disabled = false;
}
});
});
})();
+48
View File
@@ -0,0 +1,48 @@
/**
* Kanbante — tri des tableaux « pilotage » (.proj-table), partagé
* entre le dashboard, /forecast et /health (0.19.0).
* Tri client par colonne : les lignes portent leurs valeurs dans
* des attributs data-<clé> ; cliquer un <th data-sort="clé">
* trie dans un sens puis dans l'autre.
*/
(function () {
document.querySelectorAll('.proj-table').forEach(function (table) {
var tbody = table.querySelector('tbody');
if (!tbody) return;
table.querySelectorAll('th[data-sort]').forEach(function (th) {
th.classList.add('sortable');
th.addEventListener('click', function () {
var key = th.getAttribute('data-sort');
// Premier clic : sens par défaut de la colonne
// (data-first-dir="desc" pour les dates d'activité —
// on veut le plus récent d'abord) ; ensuite on alterne.
var dir = th.getAttribute('data-dir')
? (th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc')
: (th.getAttribute('data-first-dir') || 'asc');
table.querySelectorAll('th').forEach(function (h) {
h.removeAttribute('data-dir');
});
th.setAttribute('data-dir', dir);
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var va = a.getAttribute('data-' + key) || '';
var vb = b.getAttribute('data-' + key) || '';
var cmp;
// Numérique SEULEMENT si la valeur entière est
// un nombre. L'ancien parseFloat lisait « 2026 »
// dans les dates ISO : toutes les lignes d'une
// même année se retrouvaient à égalité et le tri
// par date partait dans un ordre arbitraire
// (bug rapporté par Bruno, corrigé en 0.20.1).
if (/^-?\d+(\.\d+)?$/.test(va) && /^-?\d+(\.\d+)?$/.test(vb)) {
cmp = parseFloat(va) - parseFloat(vb);
} else {
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
}
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});
});
})();
+154 -12
View File
@@ -38,6 +38,7 @@ def _repo(name: str, updated: datetime, archived: bool = False) -> dict:
"owner": {"login": "Projets"},
"archived": archived,
"html_url": f"http://gitea.test/Projets/{name}",
"default_branch": "main",
}
@@ -65,13 +66,20 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
class FakeGitea:
"""Drop-in async replacement for GiteaClient used by the API tests."""
kind = "gitea"
def __init__(self) -> None:
now = datetime.now(timezone.utc)
self.connection_id = 1
self.connection_slug = "gitea"
self.key_prefix = ""
self.starred = set()
self.repos = [
_repo("demo", now - timedelta(days=1)),
_repo("vieux", now - timedelta(days=800)),
_repo("archive", now - timedelta(days=1), archived=True),
]
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
self.issues = {
"Projets/demo": [
_issue(1, "Tâche un"),
@@ -133,9 +141,17 @@ class FakeGitea:
results.append({**issue, "repository": {"full_name": full_name}})
return results
async def set_archived(self, owner, repo, archived):
for r in self.repos:
if r["full_name"] == f"{owner}/{repo}":
r["archived"] = archived
return {"archived": archived}
async def delete_label(self, owner, repo, label_id):
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
for name in [n for n, l in labels.items() if l["id"] == label_id]:
for name in [
n for n, l in labels.items() if str(l["id"]) == str(label_id)
]:
del labels[name]
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
@@ -171,6 +187,52 @@ class FakeGitea:
"404", request=request, response=_httpx.Response(404, request=request)
)
supports_attachments = True
async def update_issue(self, owner, repo, number, title=None, body=None):
issue = await self.get_issue(owner, repo, number)
if title is not None:
issue["title"] = title
if body is not None:
issue["body"] = body
return issue
# Pièces jointes en mémoire : {(repo, numéro): [assets]}
@property
def _assets(self):
return self.__dict__.setdefault("assets_store", {})
async def list_issue_assets(self, owner, repo, number):
return list(self._assets.get((f"{owner}/{repo}", number), []))
async def upload_issue_asset(
self, owner, repo, number, filename, content, content_type="application/octet-stream"
):
store = self._assets.setdefault((f"{owner}/{repo}", number), [])
asset = {
"id": 700 + len(store) + number,
"name": filename,
"size": len(content),
"download_count": 0,
"created_at": "2026-10-23T10:00:00Z",
"_content": content,
"_type": content_type,
}
store.append(asset)
return {k: v for k, v in asset.items() if not k.startswith("_")}
async def delete_issue_asset(self, owner, repo, number, asset_id):
store = self._assets.get((f"{owner}/{repo}", number), [])
self._assets[(f"{owner}/{repo}", number)] = [
a for a in store if a["id"] != asset_id
]
async def download_issue_asset(self, owner, repo, number, asset_id):
for asset in self._assets.get((f"{owner}/{repo}", number), []):
if asset["id"] == asset_id:
return asset["_content"], asset["_type"]
raise AssertionError("asset introuvable dans le faux")
async def get_issue_comments(self, owner, repo, number):
return list(self.comments.get((f"{owner}/{repo}", number), []))
@@ -224,11 +286,13 @@ class FakeGitea:
self.label_ops.append(("remove", number, label["name"]))
async def list_branches(self, owner, repo):
return [
{"name": "main"},
{"name": "feature/1-tache-un"},
{"name": "issue-5-focus"},
]
return [{"name": n} for n in self.branches]
async def create_branch(self, owner, repo, new_branch, old_branch):
if new_branch in self.branches:
raise RuntimeError("branch exists")
self.branches.append(new_branch)
return {"name": new_branch}
async def list_pulls(self, owner, repo, state="all"):
return [
@@ -266,15 +330,93 @@ class FakeGitea:
},
]
async def get_milestones(self, owner, repo, state="open"):
return [
# ── Snapshots (chantier B, 0.22.0) : le faux reflète
# l'interface du vrai client.
def hydrate_from_snapshots(self):
return 0
def latest_snapshot_at(self):
return None
async def refresh_search_repos(self):
return await self.search_repos()
async def refresh_starred(self):
return await self.list_starred()
async def refresh_repo_issues(self, owner, repo, state="all"):
return await self.get_repo_issues(owner, repo, state=state)
async def refresh_milestones(self, owner, repo):
return await self.get_milestones(owner, repo)
async def list_starred(self):
return [r for r in self.repos if r["full_name"] in self.starred]
async def set_starred(self, owner, repo, starred):
if starred:
self.starred.add(f"{owner}/{repo}")
else:
self.starred.discard(f"{owner}/{repo}")
@property
def _milestones(self):
return self.__dict__.setdefault(
"milestones_store",
{
"id": 1, "title": "Sprint 1",
"due_on": "2026-10-20T00:00:00Z",
"open_issues": 1, "closed_issues": 1,
}
"Projets/demo": [
{
"id": 1, "title": "Sprint 1",
"description": "",
"due_on": "2026-10-20T00:00:00Z",
"state": "open",
"open_issues": 1, "closed_issues": 1,
}
]
},
)
async def get_milestones(self, owner, repo, state="open"):
items = self._milestones.get(f"{owner}/{repo}", [])
return [
dict(m) for m in items
if state == "all" or m.get("state", "open") == state
]
async def create_milestone(
self, owner, repo, title, description="", due_on=None
):
store = self._milestones.setdefault(f"{owner}/{repo}", [])
milestone = {
"id": 100 + len(store),
"title": title,
"description": description,
"due_on": due_on,
"state": "open",
"open_issues": 0, "closed_issues": 0,
}
store.append(milestone)
return dict(milestone)
async def update_milestone(
self, owner, repo, milestone_id, title=None, description=None,
due_on=None, clear_due=False, state=None,
):
for m in self._milestones.get(f"{owner}/{repo}", []):
if m["id"] == milestone_id:
if title is not None:
m["title"] = title
if description is not None:
m["description"] = description
if clear_due:
m["due_on"] = None
elif due_on:
m["due_on"] = due_on
if state is not None:
m["state"] = state
return dict(m)
raise AssertionError("jalon introuvable dans le faux")
async def create_issue(self, owner, repo, title, body=""):
self._next_number += 1
issue = _issue(self._next_number, title)
+236
View File
@@ -0,0 +1,236 @@
// Tests unitaires (node:test) du module partagé draco-alertes.js.
// Exécutés dans la sandbox : node --test draco-alertes.test.js
// Copie vendangée byte-identique de Projets/dracodev-commons
// ui/alertes/ (chantier 5) — seul le require change selon l'hôte.
// Hôte : Kanbante
// Aucune donnée inventée : les payloads reprennent la forme exacte
// des API réelles (Flowdeck /api/notifications, TikTak
// /api/notifications, Kanbante /api/events). Le payload console
// n'est qu'un EXEMPLE DE FORMAT (voir README commons) : aucun
// endpoint console n'est publié sur agent-commun main au moment
// du chantier.
const test = require("node:test");
const assert = require("node:assert/strict");
const t = require("../static/js/draco-alertes.js");
function sampleResults() {
return {
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
], unread: 1,
}) },
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
]) },
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
],
}) },
obsigate: { items: [] },
console: { items: [], sourceMissing: true },
};
}
function allOf(results) {
const all = [];
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
(results[app] && results[app].items || []).forEach((a) => all.push(a));
}
return all;
}
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
assert.equal(t.TAB_LABELS.console, "Console Spark");
});
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
assert.equal(prod.agent, "https://agent.dracodev.net/");
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
const explicit = t.resolveUrls({
hostname: "tiktak-dev.dracodev.net",
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
});
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
const href = t.resolveUrls({
hostname: "example.org",
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
});
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
assert.equal(href.agent, "https://agent.dracodev.net/");
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
});
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
], unread: 1,
});
assert.equal(items.length, 2);
assert.equal(items[0].active, true);
assert.equal(items[0].state, "active");
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
assert.equal(items[1].active, false);
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
});
test("normalizeTiktak: lien tache, sinon page notifications", () => {
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
]);
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
assert.equal(items[0].active, true);
});
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
],
});
assert.equal(items.length, 2);
assert.ok(items.every((i) => i.active === false));
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
assert.match(items[0].title, /#69/);
});
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
const list = [
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
{ app: "kanbante", active: false, date: "" },
];
const sorted = t.sortAlerts(list);
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
assert.equal(sorted[0].active, true);
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
assert.equal(sorted[3].date, "");
assert.equal(t.countActive(list), 2);
});
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
const r = sampleResults();
const all = allOf(r);
const tout = t.scopeAlerts(all, "tout", r);
assert.equal(tout.items.length, 2);
assert.ok(tout.items.every((a) => a.active));
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
const fd = t.scopeAlerts(all, "flowdeck", r);
assert.equal(fd.items.length, 1);
assert.equal(fd.items[0].app, "flowdeck");
const hist = t.scopeAlerts(all, "historique", r);
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
assert.ok(hist.items.every((a) => !a.active));
const og = t.scopeAlerts(all, "obsigate", r);
assert.equal(og.items.length, 0);
const cs = t.scopeAlerts(all, "console", r);
assert.equal(cs.items.length, 0);
assert.equal(cs.consoleMissing, true);
});
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
const r = sampleResults();
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
const hist = t.scopeAlerts(allOf(r), "historique", r);
assert.equal(hist.errors.length, 1);
assert.equal(hist.errors[0].app, "flowdeck");
const tout = t.scopeAlerts(allOf(r), "tout", r);
assert.equal(tout.errors.length, 1);
});
test("countByTab: compteurs par onglet sur l'echantillon", () => {
const r = sampleResults();
const c = t.countByTab(allOf(r), r);
assert.deepEqual(c, {
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
kanbante: 0, console: 0, historique: 4,
});
});
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
const r = sampleResults();
const all = allOf(r);
const tout = t.scopeAlerts(all, "tout", r).items;
assert.equal(t.filterAlerts(tout, "budget").length, 1);
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
assert.equal(t.filterAlerts(tout, "tache").length, 1);
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
const hist = t.scopeAlerts(all, "historique", r).items;
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
assert.equal(t.filterAlerts(tout, "").length, tout.length);
});
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
})[0];
assert.ok(!t.metaLine(kb).includes("moved"));
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
assert.equal(t.displayState({ active: true }), "Active");
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
assert.match(t.metaLine(sansDate), /sans date/);
});
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
assert.equal(t.parseAlertTab("#alertes=console"), "console");
assert.equal(t.parseAlertTab("#alertes"), "tout");
assert.equal(t.parseAlertTab("#autre"), null);
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
assert.equal(t.parseAlertTab(""), null);
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
});
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
const items = t.normalizeConsole("https://agent.dracodev.net/", {
alerts: [
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
],
});
assert.equal(items.length, 5);
assert.equal(items[0].active, true);
assert.equal(items[1].active, true);
assert.equal(items[2].active, true);
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
assert.equal(items[3].active, false);
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
assert.equal(items[0].url, "https://agent.dracodev.net/");
const r = sampleResults();
r.console = { items };
const c = t.countByTab(allOf(r), r);
assert.equal(c.console, 4);
assert.equal(c.tout, 6);
assert.equal(c.historique, 5);
});
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
assert.equal(t.consoleSourceUrl({}), null);
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
"https://agent-dev.dracodev.net/api/alertes");
});
+68
View File
@@ -0,0 +1,68 @@
/* Tests Node sans navigateur de recherche.js (logique pure).
* Usage : node recherche.test.js — sortie 0 si tout passe. */
"use strict";
const assert = require("node:assert/strict");
const rp = require("../static/js/recherche.js");
let n = 0;
function ok(cond, label) {
assert.ok(cond, label);
n += 1;
}
/* esc */
ok(rp.esc('<a href="x">&') === "&lt;a href=&quot;x&quot;&gt;&amp;", "esc échappe HTML");
/* isDevHost : même règle que la barre v0.1.6 */
ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu");
ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev");
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée");
ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse");
/* resolveBases : ordre explicite > barre > dev > href > prod */
let b = rp.resolveBases({
host: "tiktak-dev.dracodev.net",
explicit: { obsigate: "https://og-perso.example/" },
barUrls: { flowdeck: "https://flowdeck-barre.example/" },
tabHrefs: { kanbante: "https://kanbante-href.example/" },
});
ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire");
ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev");
ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant");
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } });
ok(b.kanbante === "https://kanbante-href.example/", "href en prod");
ok(b.obsigate === "https://og.dracodev.net/", "défaut prod");
b = rp.resolveBases({});
ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte");
/* searchPath */
ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate");
ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak");
/* normalize ObsiGate */
let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée");
ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate");
ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate");
/* normalize Flowdeck */
items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p");
ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck");
ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck");
/* normalize TikTak */
items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire");
ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak");
ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak");
/* normalize Kanbante */
items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug");
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
/* nativeSearchUrl */
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
console.log("recherche.test.js : " + n + " assertions OK");
+186
View File
@@ -0,0 +1,186 @@
"""Lot 2, vague 2 — émission d'événements de Kanbante vers le
journal du service d'agents (contrat ACC-0.2).
Couvre : l'enveloppe signée (HMAC recalculé côté test), l'inactivité
sans clé, l'émission sur déplacement de carte (``kanbante.card.moved``)
et sur entrée en Révision (``kanbante.card.review_requested``), et
l'absence totale d'effet d'une panne du récepteur sur le métier.
"""
import hashlib
import hmac
import json
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from app import agent_journal, models
from tests.conftest import csrf_from
CLE = "cle-kanbante-de-test"
class Recepteur:
"""Faux service d'agents : capture corps bruts et signatures."""
def __init__(self) -> None:
self.recus: list[tuple[bytes, str]] = []
self._verrou = threading.Lock()
recus = self.recus
verrou = self._verrou
class Mains(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
corps = self.rfile.read(int(self.headers.get("Content-Length", 0)))
with verrou:
recus.append((corps, self.headers.get("X-Draco-Signature", "")))
self.send_response(201)
self.end_headers()
self._serveur = ThreadingHTTPServer(("127.0.0.1", 0), Mains)
threading.Thread(target=self._serveur.serve_forever, daemon=True).start()
self.url = f"http://127.0.0.1:{self._serveur.server_address[1]}/api/v1/evenements"
def enveloppes(self, type_: str | None = None, ref: str | None = None) -> list[dict]:
with self._verrou:
bruts = list(self.recus)
envs = [json.loads(corps) for corps, _ in bruts]
if type_ is not None:
envs = [e for e in envs if e["type"] == type_]
if ref is not None:
envs = [e for e in envs if e["object"]["ref"] == ref]
return envs
def attendre(self, type_: str, ref: str, timeout: float = 5.0) -> dict:
"""Poll jusqu'à l'arrivée de l'événement voulu (envoi en
arrière-plan : l'attente fait partie du contrat de test)."""
limite = time.monotonic() + timeout
while time.monotonic() < limite:
trouves = self.enveloppes(type_, ref)
if trouves:
return trouves[0]
time.sleep(0.05)
raise AssertionError(f"Événement {type_} pour {ref} jamais reçu")
@pytest.fixture
def recepteur():
return Recepteur()
@pytest.fixture
def journal_actif(monkeypatch, recepteur):
"""Émission configurée vers le faux récepteur local."""
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
return recepteur
def test_enveloppe_signee_verifiable(journal_actif):
fil = agent_journal.emettre(
"kanbante.card.moved",
"issue:Projets/demo#1",
"Tâche un",
"http://gitea.test/Projets/demo/issues/1",
actor="user",
)
assert fil is not None
fil.join(timeout=5)
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
corps, signature = journal_actif.recus[0]
# La signature se revérifie indépendamment, comme le fait le service.
assert signature == hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert json.loads(corps) == env
assert env["id"]
assert env["source_app"] == "kanbante"
assert env["domain"] == "pro"
assert env["actor"] == "user"
assert env["object"] == {
"ref": "issue:Projets/demo#1",
"label": "Tâche un",
"url": "http://gitea.test/Projets/demo/issues/1",
}
# occurred_at : ISO 8601 lisible par le service.
datetime.fromisoformat(env["occurred_at"])
def test_inactif_sans_cle(monkeypatch, recepteur):
monkeypatch.delenv("AGENT_EVENEMENTS_CLE", raising=False)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
assert agent_journal.actif() is False
assert agent_journal.emettre(
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
"http://gitea.test/Projets/demo/issues/1",
) is None
time.sleep(0.3)
assert recepteur.recus == []
def _deplacer(client, numero: int, de: str, vers: str):
token = csrf_from(client)
r = client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo",
"issue_number": numero,
"to_column": vers,
"from_column": de,
"csrf_token": token,
},
)
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
return r
def test_deplacement_emet_moved(client, journal_actif):
_deplacer(client, 1, "backlog", "in_progress")
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
assert env["actor"] == "user"
assert env["object"]["label"] == "Tâche un"
assert env["object"]["url"] == "http://gitea.test/Projets/demo/issues/1"
# Un déplacement hors Révision n'émet pas de review_requested.
time.sleep(0.3)
assert journal_actif.enveloppes(
"kanbante.card.review_requested", "issue:Projets/demo#1"
) == []
def test_entree_en_revision_emet_review_requested(client, journal_actif):
_deplacer(client, 1, "backlog", "review")
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
env = journal_actif.attendre(
"kanbante.card.review_requested", "issue:Projets/demo#1"
)
assert env["object"]["label"] == "Tâche un"
assert env["actor"] == "user"
# Sortir de Révision ne redemande pas de révision.
_deplacer(client, 1, "review", "done")
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
time.sleep(0.3)
assert len(journal_actif.enveloppes(
"kanbante.card.review_requested", "issue:Projets/demo#1"
)) == 1
def test_panne_du_recepteur_sans_effet_sur_le_deplacement(client, monkeypatch):
# Récepteur mort (port 1 fermé) mais émission configurée.
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", "http://127.0.0.1:1/api/v1/evenements")
_deplacer(client, 1, "backlog", "in_progress")
state = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in state["in_progress"]] == [1]
# L'échec d'envoi, lui, reste silencieux et borné dans le temps.
fil = agent_journal.emettre(
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
"http://gitea.test/Projets/demo/issues/1",
)
assert fil is not None
fil.join(timeout=10)
assert not fil.is_alive()
+13
View File
@@ -39,6 +39,19 @@ def test_board_hides_pull_requests_and_reconciles(client):
assert [c["issue_number"] for c in state["done"]] == [3] # reconciled
def test_board_renders_closed_issue_in_done_column(client):
# Regression: reconcile moved the closed issue's row to "done", but
# the board merged open issues only, so the card never rendered and
# the "Terminé" column stayed empty.
models.move_issue("Projets/demo", 3, "todo") # closed in Gitea
r = client.get("/board/Projets/demo")
assert r.status_code == 200
assert "Ancienne tâche" in r.text
payload = client.get("/api/board/Projets/demo").json()
assert payload["columns"]["done"]["count"] == 1
assert "Ancienne tâche" in payload["columns"]["done"]["html"]
def test_focus_lists_in_progress_across_projects(client):
models.move_issue("Projets/demo", 5, "in_progress")
r = client.get("/focus")
+71
View File
@@ -0,0 +1,71 @@
"""Jetons d'API personnels (0.25.0) : modèle + accès MCP par Bearer.
Le service d'agents se fait refuser par Kanbante depuis la 0.24.0
(auth partout, cookie/Basic seulement). Les jetons kbt_ lui donnent
une identité machine propre, révocable, limitée à /mcp et /api/.
"""
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
@pytest.fixture()
def utilisateur():
# La base de test est partagée par toute la session : nom unique,
# et nettoyage complet au teardown — le pont SSO (test_draco_sso)
# a une sémantique « un seul compte en base » dont hérite la suite.
import uuid
uid = models.create_user(
f"machine-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_jeton_cycle_de_vie(utilisateur):
row, clair = models.create_api_token(utilisateur["id"], "service-agents")
assert clair.startswith("kbt_")
# La base ne connaît que l'empreinte, jamais le texte clair.
assert row["token_hash"] != clair and len(row["token_hash"]) == 64
trouve = models.user_by_api_token(clair)
assert trouve is not None and trouve["username"] == utilisateur["username"]
assert models.list_api_tokens(utilisateur["id"])[0]["last_used_at"] is not None
# Révocation : effet immédiat.
assert models.revoke_api_token(utilisateur["id"], row["id"]) is True
assert models.user_by_api_token(clair) is None
# On ne révoque pas le jeton d'autrui.
row2, _clair2 = models.create_api_token(utilisateur["id"], "autre")
assert models.revoke_api_token(utilisateur["id"] + 999, row2["id"]) is False
def test_jeton_invalide_ou_compte_desactive(utilisateur):
_row, clair = models.create_api_token(utilisateur["id"], "x")
assert models.user_by_api_token("kbt_nimportequoi") is None
assert models.user_by_api_token("") is None
assert models.user_by_api_token("tck_autreformat") is None
models.update_user(utilisateur["id"], active=0)
assert models.user_by_api_token(clair) is None
def test_mcp_par_jeton_quand_auth_active(monkeypatch, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05"}}
with TestClient(main.app) as client:
# Sans jeton : 401 (le refus vu en production le 2026-10-21).
assert client.post("/mcp", json=corps).status_code == 401
# Avec le jeton Bearer : le handshake passe.
r = client.post("/mcp", json=corps,
headers={"Authorization": f"Bearer {clair}"})
assert r.status_code == 200
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
# Mais un jeton n'ouvre pas l'interface web.
assert client.get("/", headers={"Authorization": f"Bearer {clair}",
"Accept": "application/json"}).status_code == 401
+8
View File
@@ -20,9 +20,17 @@ def test_auth_required_when_configured(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
# Sans en-tête navigateur : 401 (comportement historique).
assert client.get("/").status_code == 401
# Le Basic reste accepté pour les scripts (0.22.0).
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
# Un navigateur, lui, est envoyé vers l'écran de connexion.
r = client.get(
"/", headers={"Accept": "text/html"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
# Liveness stays public for Docker HEALTHCHECK.
assert client.get("/api/health").status_code == 200
+227
View File
@@ -0,0 +1,227 @@
"""Tests du client de distribution coffre ObsiGate (Phase 4, issue ObsiGate #22).
Aucune instance ObsiGate réelle n'est contactée : le « faux endpoint » est
un ``httpx.MockTransport`` local, et toutes les valeurs distribuées dans
ces tests sont fictives (``TEST-VALUE-XXXX``), jamais des secrets réels.
"""
import hashlib
import hmac
import json
import httpx
import pytest
import app.main as main
from app import models
from app.coffre_client import (
CoffreAccesRefuse,
CoffreConfig,
CoffreContexteManquant,
CoffreIndisponible,
CoffreNonConfigure,
CoffreReferenceInvalide,
distribuer,
distribuer_async,
est_reference,
parse_reference,
usage_distribue,
)
# Configuration fictive, réservée aux tests.
CONFIG_TEST = CoffreConfig(
base_url="https://obsigate.test",
jeton_consommateur="TEST-CONSUMER-TOKEN",
)
REFERENCE_TEST = "coffre:TEST-SLUG/TEST-NOM"
VALEUR_TEST = "TEST-VALUE-XXXX"
def _transport_faux(statut=200, corps=None, verifier=None):
def handler(request: httpx.Request) -> httpx.Response:
if verifier is not None:
verifier(request)
return httpx.Response(statut, json=corps if corps is not None else {})
return httpx.MockTransport(handler)
def _verifier_requete(request: httpx.Request):
assert request.url.path == "/api/coffres/distribuer"
assert request.headers["Authorization"] == "Bearer TEST-CONSUMER-TOKEN"
corps = json.loads(request.content.decode())
assert corps["reference"] == REFERENCE_TEST
assert corps["contexte_type"] == "webhook"
assert corps["contexte_id"] == "gitea"
@pytest.mark.parametrize(
"reference, attendu",
[
("coffre:personnel/gitea", True),
("coffre:TEST-SLUG/TEST-NOM", True),
("coffre:a/b/c", False), # un seul « / » après le préfixe
("coffre:/nom", False),
("coffre:slug/", False),
("coffre:slug", False),
("gitea", False),
("", False),
(None, False),
],
)
def test_reference_validation(reference, attendu):
assert est_reference(reference) is attendu
if attendu:
assert parse_reference(reference)[0]
else:
with pytest.raises(CoffreReferenceInvalide):
parse_reference(reference or "")
def test_distribuer_succes_sync():
transport = _transport_faux(
200,
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
verifier=_verifier_requete,
)
assert (
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
== VALEUR_TEST
)
async def test_distribuer_succes_async():
transport = _transport_faux(
200,
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
verifier=_verifier_requete,
)
assert (
await distribuer_async(
REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport
)
== VALEUR_TEST
)
@pytest.mark.parametrize(
"statut, detail, message_attendu",
[
(403, "octroi_revoque", "ré-accorder"),
(403, "octroi_expire", "renouveler"),
(403, "octroi_absent", "octroi"),
(401, "consommateur_revoque", "ré-accorder"),
(401, "consommateur_invalide", "invalide"),
(410, "secret_expire", "expiré"),
],
)
def test_distribuer_refus_sans_repli(statut, detail, message_attendu):
transport = _transport_faux(statut, {"detail": detail})
with pytest.raises(CoffreAccesRefuse) as excinfo:
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
assert message_attendu in str(excinfo.value)
assert VALEUR_TEST not in str(excinfo.value)
def test_distribuer_indisponible_503_et_reseau():
transport = _transport_faux(503, {"detail": "kek_absente"})
with pytest.raises(CoffreIndisponible):
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
def _panne(request: httpx.Request):
raise httpx.ConnectError("faux ObsiGate injoignable", request=request)
with pytest.raises(CoffreIndisponible):
distribuer(
REFERENCE_TEST, "webhook", "gitea",
config=CONFIG_TEST, transport=httpx.MockTransport(_panne),
)
def test_distribuer_sans_valeur_malgre_200():
transport = _transport_faux(200, {"reference": REFERENCE_TEST})
with pytest.raises(CoffreIndisponible):
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
def test_distribuer_reference_invalide_sans_appel():
def _interdit(request: httpx.Request):
raise AssertionError("aucun appel HTTP ne doit partir")
with pytest.raises(CoffreReferenceInvalide):
distribuer("valeur-brute", "webhook", "gitea",
config=CONFIG_TEST, transport=httpx.MockTransport(_interdit))
def test_distribuer_contexte_manquant_sans_appel():
with pytest.raises(CoffreContexteManquant):
distribuer(REFERENCE_TEST, "", "gitea", config=CONFIG_TEST)
def test_distribuer_non_configure(monkeypatch):
monkeypatch.delenv("KANBANTE_OBSIGATE_URL", raising=False)
monkeypatch.delenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", raising=False)
with pytest.raises(CoffreNonConfigure):
distribuer(REFERENCE_TEST, "webhook", "gitea")
def test_usage_distribue_ne_conserve_que_le_resultat():
transport = _transport_faux(200, {"valeur": VALEUR_TEST})
# Le bloc ne sert qu'à l'usage : ici, dériver un HMAC, comme le ferait
# la vérification d'un webhook. Seul le résultat de l'usage est
# conservé après le bloc, jamais la valeur distribuée.
with usage_distribue(
REFERENCE_TEST, "webhook", "gitea",
config=CONFIG_TEST, transport=transport,
) as valeur:
signature = hmac.new(valeur.encode(), b"corps-test", hashlib.sha256).hexdigest()
assert len(signature) == 64
def test_webhook_reference_octroi_revoque_sans_repli(client, monkeypatch):
"""Référence webhook + octroi révoqué -> 401 explicite, sans repli."""
models.create_connection(
"coffre-webhook", "gitea", "Coffre webhook", "https://gitea.test",
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
)
async def _refuse(*args, **kwargs):
raise CoffreAccesRefuse("Accès révoqué dans ObsiGate pour cette référence.")
monkeypatch.setattr(main.coffre_client, "distribuer_async", _refuse)
reponse = client.post(
"/webhooks/coffre-webhook",
content=b"{}",
headers={"x-gitea-signature": "0" * 64},
)
assert reponse.status_code == 401
assert "ObsiGate" in reponse.json()["error"]
def test_webhook_reference_distribuee_puis_oubliee(client, monkeypatch):
"""Référence webhook distribuée -> HMAC vérifié, valeur non persistée."""
models.create_connection(
"coffre-webhook-ok", "gitea", "Coffre webhook OK", "https://gitea.test",
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
)
async def _distribue(reference, contexte_type, contexte_id):
assert reference == REFERENCE_TEST
assert contexte_type == "webhook"
assert contexte_id == "coffre-webhook-ok"
return VALEUR_TEST
monkeypatch.setattr(main.coffre_client, "distribuer_async", _distribue)
corps = b"{}"
signature = hmac.new(VALEUR_TEST.encode(), corps, hashlib.sha256).hexdigest()
reponse = client.post(
"/webhooks/coffre-webhook-ok",
content=corps,
headers={"x-gitea-signature": signature},
)
assert reponse.status_code == 200
# La valeur distribuée n'a jamais été persistée : la ligne stocke
# toujours la référence, jamais la valeur fictive.
ligne = models.get_connection_by_slug("coffre-webhook-ok")
assert ligne["webhook_secret"] == REFERENCE_TEST
assert VALEUR_TEST not in (ligne["webhook_secret"] or "")
+63
View File
@@ -0,0 +1,63 @@
"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Kanbante).
Kanbante n'a pas de table de notifications : sa source réelle est le
journal d'événements, déjà exposé par GET /api/events (aucun nouvel
endpoint). Kanbante ne porte aucun état lu/non lu : ses événements
sont donc tous de l'historique et ne sont jamais comptés comme
actifs par le panneau. Couverts ici : placeholder disparu, module
servi, CORS limité à /api/events pour les seules origines soeurs.
"""
SOEUR = "https://flowdeck.dracodev.net"
def test_barre_alertes_sans_placeholder_et_module_servi(client):
r = client.get("/")
assert r.status_code == 200
assert "Alertes unifiées : arrive avec la fédération" not in r.text
assert "draco-alertes.js" in r.text
assert "draco-alertes.css" in r.text
js = client.get("/static/js/draco-alertes.js").text
assert "DRACO_ALERTES" in js
assert "api/events" in js
def test_events_forme(client):
r = client.get("/api/events")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert isinstance(body["events"], list)
def test_cors_events_soeur_preflight_get_et_tiers(client):
r = client.options("/api/events",
headers={"Origin": SOEUR,
"Access-Control-Request-Method": "GET"})
assert r.status_code == 200
assert r.headers.get("access-control-allow-origin") == SOEUR
assert r.headers.get("access-control-allow-credentials") == "true"
r2 = client.get("/api/events", headers={"Origin": SOEUR})
assert r2.status_code == 200
assert r2.headers.get("access-control-allow-origin") == SOEUR
r3 = client.options("/api/events",
headers={"Origin": "https://evil.example",
"Access-Control-Request-Method": "GET"})
assert "access-control-allow-origin" not in r3.headers
def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
"""Chantier 5 : onglet Console Spark en dernier dans la barre, et
vue Alertes en onglets (module + styles vendangés depuis commons)."""
r = client.get("/")
assert r.status_code == 200
nav = r.text.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
assert "Console Spark" in nav
js = client.get("/static/js/draco-alertes.js").text
assert "draco-alertes-tab" in js
assert "historique" in js
assert "source indisponible" in js
css = client.get("/static/css/draco-alertes.css").text
assert "draco-alertes-tab" in css
assert "draco-alertes-filtre" in css
+422
View File
@@ -0,0 +1,422 @@
"""Fusion ACC-1 — barre d'apps commune (carte kanbante#28).
Le snippet figé de dracodev-commons est vendangé (CSS/JS statiques +
partiel _draco_bar.html) et inclus par le cadre partagé _header.html :
toutes les pages applicatives l'affichent avec Kanbante comme app
courante ; l'écran de connexion, autonome, ne l'affiche pas.
"""
def test_barre_presente_sur_le_dashboard(client):
r = client.get("/")
assert r.status_code == 200
assert 'class="draco-bar"' in r.text
assert 'data-current="kanbante"' in r.text
assert "draco-barre.css" in r.text
assert "draco-barre.js" in r.text
assert 'window.DRACO_BAR_CONFIG' in r.text
# Les quatre applications de la barre, aux URL publiques par
# défaut (noms officiels/prod, issus de la configuration).
for url in ("https://og.dracodev.net/", "https://flowdeck.dracodev.net/",
"https://tiktak.dracodev.net/", "https://kanbante.dracodev.net/"):
assert url in r.text
# L'adresse du service d'agents est injectée au panneau.
assert 'window.DRACO_PANNEAU_CONFIG = { api: "https://agent.dracodev.net" }' in r.text
def test_barre_et_panneau_urls_par_configuration(monkeypatch, fake_gitea):
# Dev/prod (0.32.0) : la dev déclare ses noms en -dev par
# l'environnement ; aucun nom public n'est codé en dur.
from fastapi.testclient import TestClient
import app.main as main
monkeypatch.setenv("DRACO_SSO_URL", "https://og-dev.dracodev.net")
monkeypatch.setenv("DRACO_FLOWDECK_URL", "https://flowdeck-dev.dracodev.net")
monkeypatch.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
monkeypatch.setenv("KANBANTE_PUBLIC_URL", "https://kanbante-dev.dracodev.net")
monkeypatch.setenv("AGENT_URL", "https://agent-dev.dracodev.net")
with TestClient(main.app) as client:
r = client.get("/")
assert r.status_code == 200
for url in ("https://og-dev.dracodev.net/",
"https://flowdeck-dev.dracodev.net/",
"https://tiktak-dev.dracodev.net/",
"https://kanbante-dev.dracodev.net/"):
assert url in r.text
assert 'api: "https://agent-dev.dracodev.net"' in r.text
# Le JS statique du panneau ne contient aucun nom en dur :
# il lit l'adresse injectée par l'hôte.
js = client.get("/static/js/draco-panneau.js").text
assert "DRACO_PANNEAU_CONFIG" in js
assert "dracodev.net" not in js
def test_config_agent_evenements_derive_de_agent_url(monkeypatch):
from app.config import _env_settings
monkeypatch.setenv("AGENT_URL", "https://agent-dev.dracodev.net")
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
s = _env_settings()
assert s.agent_url == "https://agent-dev.dracodev.net"
assert s.agent_evenements_url == (
"https://agent-dev.dracodev.net/api/v1/evenements"
)
# L'URL d'événements explicite garde la priorité (override compose).
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://exemple.invalid/journal"
)
assert _env_settings().agent_evenements_url == (
"https://exemple.invalid/journal"
)
# ── Dérivation d'environnement des URL sœurs (0.32.1) ──
# Sur la dev, seul AGENT_EVENEMENTS_URL est posé : les URL sœurs
# doivent suivre son suffixe (-dev) sans quatre variables de plus.
_ENV_URLS_SOEURS = (
"DRACO_SSO_URL", # ObsiGate (émettrice SSO)
"DRACO_FLOWDECK_URL",
"DRACO_TIKTAK_URL",
"KANBANTE_PUBLIC_URL", # Kanbante elle-même
"AGENT_URL",
)
_DEFAUTS_OFFICIELS = (
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
)
_URLS_DEV = (
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
)
def _nettoyer_env_soeurs(monkeypatch):
for nom in _ENV_URLS_SOEURS:
monkeypatch.delenv(nom, raising=False)
def _urls_soeurs(settings):
return (
settings.sso_url,
settings.flowdeck_url,
settings.tiktak_url,
settings.public_url,
)
def test_config_urls_soeurs_derivees_du_seul_agent_evenements(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
assert _urls_soeurs(_env_settings()) == _URLS_DEV
def test_barre_urls_soeurs_derivees_du_seul_agent_evenements(
monkeypatch, fake_gitea
):
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
with TestClient(main.app) as client:
r = client.get("/")
assert r.status_code == 200
for url in _URLS_DEV:
assert f"{url}/" in r.text
def test_config_url_soeur_explicite_gagne_sur_la_derivation(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
monkeypatch.setenv(
"DRACO_FLOWDECK_URL", "https://flowdeck.example.test"
)
s = _env_settings()
assert s.flowdeck_url == "https://flowdeck.example.test"
# Les autres sœurs restent dérivées de l'environnement.
assert s.sso_url == "https://og-dev.dracodev.net"
assert s.tiktak_url == "https://tiktak-dev.dracodev.net"
assert s.public_url == "https://kanbante-dev.dracodev.net"
def test_config_hote_agent_hors_motif_reste_aux_defauts(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
for url_agent in (
"https://agent.example.com/api/v1/evenements",
"https://agent-dev.autre-domaine.net/api/v1/evenements",
"http://192.168.30.101:8790/api/v1/evenements",
"http://127.0.0.1:8790/api/v1/evenements",
):
monkeypatch.setenv("AGENT_EVENEMENTS_URL", url_agent)
assert _urls_soeurs(_env_settings()) == _DEFAUTS_OFFICIELS
def test_config_aucun_env_reste_aux_defauts_officiels(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
assert _urls_soeurs(_env_settings()) == _DEFAUTS_OFFICIELS
def test_statiques_de_la_barre_servis(client):
assert client.get("/static/css/draco-barre.css").status_code == 200
assert client.get("/static/js/draco-barre.js").status_code == 200
def test_menu_des_applications_par_la_marque(client):
# v0.1.3 : la marque ✦ porte data-act="apps" et le JS commun
# construit le menu (.draco-apps-menu) ; le logo passe à 24 px.
accueil = client.get("/")
assert 'data-act="apps"' in accueil.text
# 0.23.3 : assets versionnés, sinon le cache garde l'ancien JS.
assert "draco-barre.css?v=" in accueil.text
assert "draco-barre.js?v=" in accueil.text
js = client.get("/static/js/draco-barre.js").text
assert "draco-apps-menu" in js
css = client.get("/static/css/draco-barre.css").text
assert "font-size: 24px" in css
def test_barre_pleine_largeur_et_palier_mobile(client):
# 0.23.1 : la barre est fixe pleine largeur (le padding du cadre
# ne la repousse plus) et le palier mobile commons v0.1.2 ne
# garde que l'onglet de l'app courante.
style = client.get("/static/css/style.css").text
assert "body.has-draco-bar .draco-bar" in style
assert "position: fixed" in style
barre = client.get("/static/css/draco-barre.css").text
assert '.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"]' in barre
def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea):
from fastapi.testclient import TestClient
import app.main as main
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
r = client.get("/login")
assert r.status_code == 200
assert "draco-bar" not in r.text
# ── Résolution par requête et base de l'agent dérivée (0.32.2) ──
# Couche fautive corrigée : la dérivation 0.32.1 ne couvrait que les
# URL sœurs depuis AGENT_EVENEMENTS_URL — jamais agent_url, donc le
# panneau visait le défaut prod (agent injoignable : la prod n'est
# pas installée) et la console était une page morte sur la dev.
# Aucune résolution ne tenait compte de l'hôte de la requête, et
# aucune CSP n'énonçait l'origine de l'agent.
_ENV_RESOLUTION = _ENV_URLS_SOEURS + ("AGENT_EVENEMENTS_URL",)
def _nettoyer_env_resolution(monkeypatch):
for nom in _ENV_RESOLUTION:
monkeypatch.delenv(nom, raising=False)
def test_config_base_agent_derivee_du_seul_agent_evenements(monkeypatch):
# Le cas réel de la dev : seul le journal est déclaré en -dev.
# La base du service (panneau/console) doit suivre, sinon
# l'iframe de la console charge l'agent prod, injoignable.
from app.config import _env_settings
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
s = _env_settings()
assert s.agent_url == "https://agent-dev.dracodev.net"
assert _urls_soeurs(s) == _URLS_DEV
def test_config_derivation_elargie_a_la_base_du_service(monkeypatch):
# Élargie : si l'URL d'événements est hors motif mais que la
# base du service suit le motif, les sœurs dérivent de la base.
from app.config import _env_settings
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv("AGENT_URL", "https://agent-dev.dracodev.net")
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://journal.example.test/evenements"
)
s = _env_settings()
assert s.agent_url == "https://agent-dev.dracodev.net"
assert s.agent_evenements_url == (
"https://journal.example.test/evenements"
)
assert _urls_soeurs(s) == _URLS_DEV
def test_suffixe_environnement_requete_motif_ferme():
from app.config import suffixe_environnement_requete
assert suffixe_environnement_requete("kanbante-dev.dracodev.net") == "-dev"
assert suffixe_environnement_requete("kanbante.dracodev.net") == ""
assert suffixe_environnement_requete("og-dev.dracodev.net") == "-dev"
assert suffixe_environnement_requete("agent-dev.dracodev.net:8790") == "-dev"
assert suffixe_environnement_requete("Kanbante-Dev.Dracodev.Net") == "-dev"
# Hors motif : jamais de suffixe inventé depuis un hôte arbitraire.
assert suffixe_environnement_requete("192.168.30.101:8082") is None
assert suffixe_environnement_requete("127.0.0.1") is None
assert suffixe_environnement_requete("kanbante-dev.autre-domaine.net") is None
assert suffixe_environnement_requete("dev.dracodev.net") is None
assert suffixe_environnement_requete("testserver") is None
assert suffixe_environnement_requete("") is None
assert suffixe_environnement_requete(None) is None
def test_requete_hote_dev_sans_env_tout_en_dev(monkeypatch, fake_gitea):
# Aucune variable d'environnement : barre, panneau (source de
# l'iframe de la console) et CSP entièrement en -dev, cohérents.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "kanbante-dev.dracodev.net"})
assert r.status_code == 200
for url in _URLS_DEV:
assert f"{url}/" in r.text
# Source de l'iframe du panneau : l'origine injectée au JS.
assert 'api: "https://agent-dev.dracodev.net"' in r.text
# La CSP de la même réponse énonce la même origine résolue.
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent-dev.dracodev.net"
)
# Aucun nom prod ne subsiste dans le rendu.
for url in _DEFAUTS_OFFICIELS:
assert f"{url}/" not in r.text
assert 'api: "https://agent.dracodev.net"' not in r.text
def test_requete_hote_prod_sans_env_noms_officiels(monkeypatch, fake_gitea):
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "kanbante.dracodev.net"})
assert r.status_code == 200
for url in _DEFAUTS_OFFICIELS:
assert f"{url}/" in r.text
assert 'api: "https://agent.dracodev.net"' in r.text
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent.dracodev.net"
)
def test_requete_hote_lan_repli_configuration(monkeypatch, fake_gitea):
# Hôte hors motif (LAN/IP) : repli sur la configuration, ici
# sans variable — donc les défauts officiels, jamais devinés.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "192.168.30.101:8082"})
assert r.status_code == 200
for url in _DEFAUTS_OFFICIELS:
assert f"{url}/" in r.text
assert 'api: "https://agent.dracodev.net"' in r.text
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent.dracodev.net"
)
def test_requete_hote_lan_repli_derivation_configuration(
monkeypatch, fake_gitea
):
# Hôte hors motif mais configuration en -dev (journal seul) :
# le repli est la dérivation de configuration, panneau inclus.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "192.168.30.101:8082"})
assert r.status_code == 200
for url in _URLS_DEV:
assert f"{url}/" in r.text
assert 'api: "https://agent-dev.dracodev.net"' in r.text
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent-dev.dracodev.net"
)
def test_requete_explicite_gagne_sur_hote(monkeypatch, fake_gitea):
# L'explicite reste prioritaire, URL par URL : Flowdeck et
# l'agent explicites gagnent sur l'hôte dev ; les autres
# sœurs suivent l'hôte.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv("DRACO_FLOWDECK_URL", "https://flowdeck.example.test")
monkeypatch.setenv("AGENT_URL", "https://agent.example.test")
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "kanbante-dev.dracodev.net"})
assert r.status_code == 200
assert "https://flowdeck.example.test/" in r.text
assert "https://flowdeck-dev.dracodev.net/" not in r.text
assert 'api: "https://agent.example.test"' in r.text
# La CSP consomme l'origine explicite, comme l'iframe.
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent.example.test"
)
for url in (
"https://og-dev.dracodev.net/",
"https://tiktak-dev.dracodev.net/",
"https://kanbante-dev.dracodev.net/",
):
assert url in r.text
def test_resolution_sans_requete_inchangee(monkeypatch):
# Contextes sans requête : la résolution par configuration
# seule, comme avant (défauts officiels sans environnement).
from app.config import _env_settings, resoudre_urls_draco
_nettoyer_env_resolution(monkeypatch)
urls = resoudre_urls_draco()
assert urls.sso_url == "https://og.dracodev.net"
assert urls.flowdeck_url == "https://flowdeck.dracodev.net"
assert urls.tiktak_url == "https://tiktak.dracodev.net"
assert urls.public_url == "https://kanbante.dracodev.net"
assert urls.agent_url == "https://agent.dracodev.net"
assert _env_settings().agent_url == "https://agent.dracodev.net"
+116
View File
@@ -0,0 +1,116 @@
"""Fusion ACC-3.2 — pont SSO draco_session (carte kanbante#27).
Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
(secret partagé, HS256) et converti en session Kanbante native par
GET /auth/draco. Kanbante est mono-utilisateur : un jeton valide
ouvre la session de l'opérateur configuré. Sans
DRACO_SESSION_SECRET, le pont renvoie simplement au login.
"""
import base64
import hashlib
import hmac
import json
import time
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import draco
SECRET = "secret-partage-de-test"
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test"):
def b64(d: bytes) -> str:
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64(json.dumps({
"sub": sub, "name": "Bruno", "iat": int(time.time()),
"exp": exp if exp is not None else int(time.time()) + 3600,
"jti": jti,
}).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest()
return f"{header}.{payload}.{b64(sig)}"
@pytest.fixture()
def client_sso(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
draco._revoked_cache.clear()
with TestClient(main.app) as client:
yield client
draco._revoked_cache.clear()
def test_verify_token(monkeypatch):
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
assert draco.verify_token(_jeton())["sub"] == "admin-1"
assert draco.verify_token(_jeton(secret="mauvais")) is None
assert draco.verify_token(_jeton(exp=int(time.time()) - 5)) is None
assert draco.verify_token("pas.un.jeton") is None
def test_pont_sans_cookie_vers_login(client_sso):
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/login"
def test_pont_cookie_valide_ouvre_la_session(client_sso):
client_sso.cookies.set("draco_session", _jeton())
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"] == "/"
assert "kanbante_session" in r.headers.get("set-cookie", "")
# La session native créée par le pont donne accès à l'app.
assert client_sso.get("/").status_code == 200
def test_pont_cookie_falsifie_vers_login(client_sso):
client_sso.cookies.set("draco_session", _jeton(secret="mauvais"))
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.headers["location"] == "/login"
def test_pont_jeton_revoque_refuse(client_sso):
draco._revoked_cache["jti-revoque"] = int(time.time()) + 3600
client_sso.cookies.set("draco_session", _jeton(jti="jti-revoque"))
r = client_sso.get("/auth/draco", follow_redirects=False)
assert r.headers["location"] == "/login"
def test_pont_inerte_sans_secret(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
monkeypatch.delenv("DRACO_SESSION_SECRET", raising=False)
with TestClient(main.app) as client:
client.cookies.set("draco_session", _jeton())
r = client.get("/auth/draco", follow_redirects=False)
assert r.headers["location"] == "/login"
def test_bouton_sso_sur_la_page_login(client_sso):
# Défauts = noms officiels (DRACO_SSO_URL / KANBANTE_PUBLIC_URL).
r = client_sso.get("/login")
assert r.status_code == 200
assert "og.dracodev.net/sso/authorize" in r.text
assert "kanbante.dracodev.net%2Fauth%2Fdraco" in r.text
def test_bouton_sso_par_configuration(client_sso, monkeypatch):
# Dev : l'émettrice et le return_to suivent l'environnement.
monkeypatch.setenv("DRACO_SSO_URL", "https://og-dev.dracodev.net")
monkeypatch.setenv(
"KANBANTE_PUBLIC_URL", "https://kanbante-dev.dracodev.net"
)
r = client_sso.get("/login")
assert r.status_code == 200
assert "og-dev.dracodev.net/sso/authorize" in r.text
assert "kanbante-dev.dracodev.net%2Fauth%2Fdraco" in r.text
+117
View File
@@ -0,0 +1,117 @@
"""GitHubClient (0.24.0, P1): same protocol as GiteaClient,
GitHub wire shape underneath. Driven through MockTransport."""
import httpx
import pytest
from app.github_client import GitHubClient
def make_client(handler) -> GitHubClient:
return GitHubClient(
base_url="https://api.github.com",
token="ghp_test",
cache_ttl=0,
transport=httpx.MockTransport(handler),
)
async def test_auth_header_is_bearer():
seen = {}
def handler(request):
seen["auth"] = request.headers.get("authorization")
seen["accept"] = request.headers.get("accept")
return httpx.Response(200, json={"login": "octocat"})
client = make_client(handler)
user = await client.get_user()
assert user["login"] == "octocat"
assert seen["auth"] == "Bearer ghp_test"
assert "application/vnd.github+json" in seen["accept"]
async def test_pagination_follows_link_headers():
def handler(request):
if "page=2" in str(request.url):
return httpx.Response(200, json=[{"full_name": "a/deux"}])
return httpx.Response(
200,
json=[{"full_name": "a/un"}],
headers={
"Link": '<https://api.github.com/user/repos?page=2>; rel="next"'
},
)
client = make_client(handler)
repos = await client._fetch_search_repos()
assert [r["full_name"] for r in repos] == ["a/un", "a/deux"]
async def test_labels_are_addressed_by_name():
seen = {}
def handler(request):
if request.method == "GET" and request.url.path.endswith("/labels"):
return httpx.Response(
200, json=[{"id": 991, "name": "kanban:todo", "color": "3b82f6"}]
)
if request.method == "POST":
seen["body"] = request.content.decode()
return httpx.Response(200, json=[])
raise AssertionError(request.url)
client = make_client(handler)
label = await client.ensure_label("a", "b", "kanban:todo", "3b82f6")
assert label["id"] == "kanban:todo" # name, not GitHub's numeric id
await client.add_issue_label("a", "b", 7, label["id"])
assert '"kanban:todo"' in seen["body"]
async def test_search_issues_normalizes_repository():
def handler(request):
assert "is%3Aissue" in str(request.url) or "is:issue" in str(request.url)
return httpx.Response(
200,
json={
"items": [
{
"number": 3,
"title": "T",
"repository_url": "https://api.github.com/repos/a/b",
}
]
},
)
client = make_client(handler)
issues = await client.search_issues(q="mot", state="open")
assert issues[0]["repository"]["full_name"] == "a/b"
async def test_set_archived_uses_patch():
seen = {}
def handler(request):
seen["method"] = request.method
seen["body"] = request.content.decode()
return httpx.Response(200, json={"archived": True})
client = make_client(handler)
await client.set_archived("a", "b", True)
assert seen["method"] == "PATCH"
assert '"archived": true' in seen["body"] or '"archived":true' in seen["body"]
async def test_starred_roundtrip():
seen = {}
def handler(request):
seen["method"] = request.method
seen["path"] = request.url.path
return httpx.Response(204)
client = make_client(handler)
await client.set_starred("a", "b", True)
assert seen["method"] == "PUT"
assert seen["path"] == "/user/starred/a/b"
+329
View File
@@ -0,0 +1,329 @@
"""Écritures MCP minimales : create_issue + add_comment (0.26.0),
move_issue (0.36.0).
Le service d'agents doit pouvoir proposer des cartes, commenter
et déplacer des cartes via Kanbante, sans passer par l'interface
web. Les trois outils réutilisent les services existants
(création via _do_capture → client de forge, commentaire via
client.create_comment, déplacement via _do_move_card) et sont
gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
"""
import json
import uuid
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
def _call(client, name, arguments, headers=None):
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": name, "arguments": arguments},
}
return client.post("/mcp", json=payload, headers=headers or {})
def _result_text(response):
result = response.json()["result"]
return result, result["content"][0]["text"]
# ── Création de carte ──────────────────────────────────────
def test_mcp_create_issue_places_card(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Carte créée par MCP", "body": "Corps de la carte",
})
result, text = _result_text(r)
assert result["isError"] is False
created = json.loads(text)
assert created["title"] == "Carte créée par MCP"
assert created["repo_full_name"] == "Projets/demo"
assert created["column"] == "backlog"
assert created["number"] > 0
# L'écriture est passée par le client de forge existant.
assert fake_gitea.created == [("Projets", "demo", "Carte créée par MCP")]
# La carte est placée sur le board (lecture MCP relue).
r = _call(client, "get_board", {"owner": "Projets", "repo": "demo"})
board = json.loads(_result_text(r)[1])
assert any(
c["title"] == "Carte créée par MCP" for c in board["backlog"]
)
# Journal : création tracée avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
created_events = [
e for e in events
if e["event_type"] == "created"
and e["issue_number"] == created["number"]
]
assert created_events and created_events[0]["source"] == "mcp"
def test_mcp_create_issue_column_choice(client, fake_gitea):
r = _call(client, "create_issue", {
"owner": "Projets", "repo": "demo",
"title": "Directement à faire", "column": "todo",
})
result, text = _result_text(r)
assert result["isError"] is False
assert json.loads(text)["column"] == "todo"
def test_mcp_create_issue_validation_errors(client, fake_gitea):
# Titre manquant : « Missing argument » (patron d'erreur du serveur).
r = _call(client, "create_issue", {"owner": "Projets", "repo": "demo"})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
# Titre vide et colonne inconnue : erreurs métier en isError.
for arguments in (
{"owner": "Projets", "repo": "demo", "title": " "},
{"owner": "Projets", "repo": "demo", "title": "X",
"column": "nimporte"},
):
result, _text = _result_text(_call(client, "create_issue", arguments))
assert result["isError"] is True
assert fake_gitea.created == []
# ── Commentaire ────────────────────────────────────────────
def test_mcp_add_comment_posts_on_forge(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Commentaire du service d'agents",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["number"] == 1
assert payload["repo_full_name"] == "Projets/demo"
assert payload["body"] == "Commentaire du service d'agents"
assert fake_gitea.posted_comments == [
("Projets", "demo", 1, "Commentaire du service d'agents")
]
events = models.get_events(repo_full_name="Projets/demo", limit=10)
commented = [e for e in events if e["event_type"] == "commented"]
assert commented and commented[0]["source"] == "mcp"
def test_mcp_add_comment_validation_errors(client, fake_gitea):
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": " ",
})
assert _result_text(r)[0]["isError"] is True
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "body": "sans numéro",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Missing argument" in text
assert fake_gitea.posted_comments == []
def test_mcp_write_tools_are_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["create_issue"]["inputSchema"]["required"] == [
"owner", "repo", "title",
]
assert tools["add_comment"]["inputSchema"]["required"] == [
"owner", "repo", "number", "body",
]
# Aucun outil destructeur n'apparaît.
assert not any(
name.startswith(("delete", "remove", "close"))
for name in tools
)
# ── Déplacement de carte (0.36.0) ──────────────────────────
def _seed_carte_todo(fake_gitea, number=1):
"""Place la carte dans « À faire », étiquette kanban:todo
miroir posée (la carte garde son étiquette « bug »)."""
etiquette = {"id": 950, "name": "kanban:todo", "color": "3b82f6"}
fake_gitea.repo_labels["Projets/demo"]["kanban:todo"] = etiquette
issue = next(
i for i in fake_gitea.issues["Projets/demo"]
if i["number"] == number
)
issue["labels"].append(etiquette)
models.move_issue("Projets/demo", number, "todo")
return issue
def test_mcp_move_issue_moves_card_and_replaces_label(client, fake_gitea):
issue = _seed_carte_todo(fake_gitea)
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["from_column"] == "todo"
assert payload["column"] == "review"
assert payload["label_sync"] == "ok"
# Étiquette kanban remplacée, les autres préservées.
noms = [l["name"] for l in issue["labels"]]
assert "kanban:review" in noms
assert "kanban:todo" not in noms
assert "bug" in noms
assert ("remove", 1, "kanban:todo") in fake_gitea.label_ops
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
# Le board suit le déplacement (lecture MCP relue).
board = json.loads(_result_text(_call(
client, "get_board", {"owner": "Projets", "repo": "demo"}
))[1])
assert any(c["number"] == 1 for c in board["review"])
assert not any(c["number"] == 1 for c in board["todo"])
# Journal : déplacement tracé avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
moved = [e for e in events if e["event_type"] == "moved"]
assert moved and moved[0]["source"] == "mcp"
assert moved[0]["from_column"] == "todo"
assert moved[0]["to_column"] == "review"
def test_mcp_move_issue_accepts_french_labels(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle, cle in (
("En cours", "in_progress"),
("Révision", "review"),
("kanban:qa", "qa"),
("À faire", "todo"),
):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is False, (libelle, text)
assert json.loads(text)["column"] == cle
def test_mcp_move_issue_refuses_done_before_any_write(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle in ("done", "Terminé"):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Terminé" in text
# Aucune écriture : ni étiquette, ni état, ni journal.
assert fake_gitea.label_ops == []
assert fake_gitea.state_changes == []
events = models.get_events(repo_full_name="Projets/demo", limit=10)
assert not [e for e in events if e["event_type"] == "moved"]
def test_mcp_move_issue_errors(client, fake_gitea):
# Colonne inconnue : erreur propre, avec les colonnes valides.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "nimporte",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Colonnes valides" in text and "review" in text
# Issue inconnue, puis dépôt inconnu.
for arguments in (
{"owner": "Projets", "repo": "demo", "number": 999,
"column": "review"},
{"owner": "Projets", "repo": "inexistant", "number": 1,
"column": "review"},
):
result, text = _result_text(_call(client, "move_issue", arguments))
assert result["isError"] is True
assert "inconnu" in text
# Une pull request n'est pas une carte.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 2,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "pull request" in text
assert fake_gitea.label_ops == []
def test_mcp_move_issue_is_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["move_issue"]["inputSchema"]["required"] == [
"owner", "repo", "number", "column",
]
info = client.get("/mcp").json()
assert "move_issue" in info["write_tools"]
# ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture()
def utilisateur():
# Base de test partagée : nom unique, nettoyage complet au
# teardown (le pont SSO a une sémantique « un seul compte »).
uid = models.create_user(
f"mcp-{uuid.uuid4().hex[:8]}",
accounts.hash_password("motdepasse"), role="member")
user = models.get_user(uid)
yield user
with models.get_db() as conn:
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
def test_mcp_writes_guarded_by_api_token(monkeypatch, fake_gitea, utilisateur):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
jeton = {"Authorization": f"Bearer {clair}"}
create_args = {
"owner": "Projets", "repo": "demo", "title": "Carte par jeton",
}
with TestClient(main.app) as client:
# Sans jeton : refusé (401), aucune écriture sur la forge.
assert _call(client, "create_issue", create_args).status_code == 401
# Jeton inconnu : refusé aussi.
faux = {"Authorization": "Bearer kbt_inconnu"}
assert _call(
client, "create_issue", create_args, headers=faux
).status_code == 401
assert fake_gitea.created == []
# Jeton valide : création et commentaire passent.
r = _call(client, "create_issue", create_args, headers=jeton)
assert r.status_code == 200
assert _result_text(r)[0]["isError"] is False
assert fake_gitea.created == [("Projets", "demo", "Carte par jeton")]
r = _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1,
"body": "Vu par le service d'agents",
}, headers=jeton)
assert _result_text(r)[0]["isError"] is False
# Le même jeton n'ouvre jamais l'interface web.
assert client.get(
"/", headers={**jeton, "Accept": "application/json"}
).status_code == 401
# Révocation : effet immédiat sur les écritures.
assert models.revoke_api_token(utilisateur["id"], _row["id"]) is True
assert _call(
client, "create_issue", create_args, headers=jeton
).status_code == 401
assert _call(client, "add_comment", {
"owner": "Projets", "repo": "demo", "number": 1, "body": "x",
}, headers=jeton).status_code == 401
+6 -2
View File
@@ -97,11 +97,15 @@ def test_metrics_counts_and_cycle_time():
def test_column_entry_times_ignores_comments():
# Capturer l'horodatage UNE fois : _days_ago() réévalué à
# l'assertion basculait d'une seconde une fois sur ~N
# (échec CI 2333 du 2026-10-26, run jumeau vert).
entry = _days_ago(3)
models.log_event("a/b", 1, "moved", to_column="todo",
created_at=_days_ago(3))
created_at=entry)
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
times = models.column_entry_times("a/b")
assert times[1] == _days_ago(3)
assert times[1] == entry
# ── Panneau détail ─────────────────────────────────────────
+159
View File
@@ -0,0 +1,159 @@
"""Palier 11 (0.14.0): branche depuis la carte (#3), santé du flux (#9)."""
from datetime import datetime, timedelta
from app import code_links as cl
from app import flow
from app import models
from tests.conftest import csrf_from
# ── #3 Branche depuis la carte ────────────────────────────────────
def test_slugify_and_branch_name():
assert cl.slugify_title("Créer la branche depuis la carte") == \
"creer-la-branche-depuis-la-carte"
assert cl.slugify_title("Fix: l'été indien (urgent!)") == \
"fix-l-ete-indien-urgent"
assert cl.slugify_title("Un titre vraiment très long qui dépasse" * 3)
assert len(cl.slugify_title("x" * 100)) == 40
assert cl.slugify_title("!!!") == "issue"
assert cl.branch_name_for(12, "Capture éclair") == "issue-12-capture-eclair"
# La convention est reconnue par l'appariement (#1/#2).
assert cl.branch_issue_number(cl.branch_name_for(12, "Capture")) == 12
def test_create_branch_endpoint(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r.status_code == 200
data = r.json()
assert data["branch"] == "issue-5-tache-focus"
assert data["from"] == "main"
assert data["moved"] is True
assert "issue-5-tache-focus" in fake_gitea.branches
board = models.get_board_state("Projets/demo")
assert 5 in [c["issue_number"] for c in board["in_progress"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(
e["event_type"] == "moved" and e["source"] == "branch"
for e in events
)
# Deuxième appel : la branche existe déjà, pas de doublon.
r2 = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r2.json()["exists"] is True
def test_create_branch_custom_name_and_validation(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "feature/super-truc"})
assert r.status_code == 200
assert r.json()["branch"] == "feature/super-truc"
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "mauvais nom.."})
assert r.status_code == 400
def test_code_endpoint_suggests_branch(client, fake_gitea):
r = client.get("/api/issues/Projets/demo/3/code")
assert r.json()["suggested_branch"] == "issue-3-ancienne-tache"
# ── #9 Santé du flux : fonctions pures ────────────────────────────
def _ev(day_offset: float, kind: str, frm=None, to=None, number=1):
at = (datetime(2026, 9, 1) + timedelta(days=day_offset)).strftime(
"%Y-%m-%d %H:%M:%S"
)
return {
"repo_full_name": "Projets/demo", "issue_number": number,
"event_type": kind, "from_column": frm, "to_column": to,
"created_at": at,
}
def test_column_dwell_reconstruction():
events = [
_ev(0, "created", to="backlog"),
_ev(4, "moved", frm="backlog", to="in_progress"),
_ev(10, "closed", frm="in_progress", to="done"),
_ev(1, "created", to="backlog", number=2),
_ev(3, "moved", frm="backlog", to="review", number=2),
]
dwell = flow.column_dwell(events)
assert dwell["backlog"] == [4.0, 2.0]
assert dwell["in_progress"] == [6.0]
summary = flow.summarize_dwell(dwell)
by_col = {row["column"]: row for row in summary}
assert by_col["backlog"]["median_days"] == 3.0
assert by_col["backlog"]["count"] == 2
# backlog a 2 échantillons (médiane 3 j) : c'est le seul goulot
# nommable à ce stade (in_progress n'a qu'un séjour mesuré).
slowest = flow.slowest_column(summary)
assert slowest["column"] == "backlog"
events.append(_ev(20, "moved", frm="review", to="done", number=2))
events += [
_ev(0, "created", to="backlog", number=3),
_ev(2, "moved", frm="backlog", to="review", number=3),
_ev(19, "moved", frm="review", to="done", number=3),
]
dwell = flow.column_dwell(events)
summary = flow.summarize_dwell(dwell)
slowest = flow.slowest_column(summary)
assert slowest["column"] == "review"
assert slowest["median_days"] == 17.0
def test_last_activity():
events = [_ev(0, "created", to="backlog"), _ev(5, "moved", to="todo")]
latest = flow.last_activity(events)
assert latest[("Projets/demo", 1)] == datetime(2026, 9, 6)
# ── #9 Pages santé ────────────────────────────────────────────────
def _seed_stay(number: int, days_ago_start: int, days_ago_end: int,
frm="backlog", to="review"):
models.log_event("Projets/demo", number, "created", to_column="backlog")
models.log_event("Projets/demo", number, "moved",
from_column=frm, to_column=to)
now = datetime.now()
with models.get_db() as conn:
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'created'",
((now - timedelta(days=days_ago_start)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'moved'",
((now - timedelta(days=days_ago_end)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
def test_health_pages_and_bottleneck(client, fake_gitea):
_seed_stay(1, 40, 10) # 30 j en backlog
_seed_stay(5, 35, 5) # 30 j en backlog aussi
page = client.get("/health/Projets/demo")
assert page.status_code == 200
assert "backlog" in page.text.lower()
index = client.get("/health")
assert index.status_code == 200
assert "Projets/demo" in index.text
def test_health_zombies_threshold(client, fake_gitea, monkeypatch):
# Les issues du faux datent du 2026-09-02 : ~39 j d'inactivité,
# donc zombies au seuil par défaut de 30 j.
page = client.get("/health/Projets/demo")
assert "Tâche un" in page.text
assert 'data-zombie="close"' in page.text
monkeypatch.setenv("KANBANTE_ZOMBIE_DAYS", "10000")
page = client.get("/health/Projets/demo")
assert "Aucune carte zombie" in page.text
+92
View File
@@ -0,0 +1,92 @@
"""Palier 12 (0.15.0 → refonte 0.16.0): layout barre latérale (#16)
et wallboard sans défilement parasite."""
import app.main as main
def test_sidebar_on_app_pages(client, fake_gitea):
for path in ("/", "/focus", "/today", "/briefing", "/forecast",
"/rules", "/health", "/standup", "/capture"):
page = client.get(path)
assert page.status_code == 200, path
assert page.text.count('id="sidebar"') == 1, path
assert page.text.count('id="nav-burger"') == 1, path
assert "with-sidebar" in page.text, path
assert "/static/js/nav.js" in page.text, path
# Les quatre sections accordéon de la barre latérale.
for group in ("Principal", "Rituels", "Pilotage", "Outils"):
assert group in page.text, (path, group)
for gkey in ("principal", "rituels", "pilotage", "outils"):
assert f'data-group-btn="{gkey}"' in page.text, (path, gkey)
assert page.text.count('class="side-acc"') == 4, path
assert "kanbante-nav-closed" in page.text # bootstrap pré-rendu
assert 'href="/health"' in page.text
assert 'id="side-collapse"' in page.text
# Bouton palette : icône seule, à droite du titre — pas de
# libellé texte « Recherche & commandes » visible.
assert 'class="side-head"' in page.text
assert ">🔍</button>" in page.text
# Le libellé de l'ancien bouton palette a disparu (le lien de
# navigation « Recherche », lui, existe toujours — c'est le
# texte complet « Recherche & commandes » qui doit manquer).
assert 'side-label">Recherche &amp; commandes' not in page.text
assert "Recherche & commandes</span>" not in page.text
def test_sidebar_active_states(client, fake_gitea):
home = client.get("/")
assert 'href="/" class="side-link active"' in home.text
forecast = client.get("/forecast")
assert 'href="/forecast" class="side-link active"' in forecast.text
today = client.get("/today")
assert 'href="/today" class="side-link active"' in today.text
def test_board_topbar_crumb_and_gitea_link(client, fake_gitea):
board = client.get("/board/Projets/demo")
assert board.status_code == 200
assert 'href="/board/Projets/demo"' in board.text # fil d'Ariane topbar
assert "Gitea ↗" in board.text
assert 'id="sidebar"' in board.text
def test_wall_has_no_sidebar(client, fake_gitea):
wall = client.get("/wall")
assert wall.status_code == 200
assert 'id="sidebar"' not in wall.text # plein écran TV assumé
assert "wall-foot" in wall.text
def test_nav_js_contract(client, fake_gitea):
js = client.get("/static/js/nav.js")
assert js.status_code == 200
assert "sidebar-collapsed" in js.text
assert "localStorage" in js.text
assert "side-backdrop" in js.text
assert "Escape" in js.text
assert "kanbante-nav-open" in js.text # accordéon exclusif persisté (0.22.0)
assert "setOpenGroup" in js.text
assert "data-group-btn" in js.text
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
async def fake_collect(only_repo, *args, **kwargs):
return [{
"full_name": "Projets/demo",
"counts": {c: 0 for c in
("backlog", "todo", "in_progress", "qa", "review", "done")},
"open_total": 12,
"cards": [
{"number": i, "title": f"Carte {i}", "column": "in_progress",
"column_label": "🚧 En cours", "age_days": 2, "aging": False}
for i in range(1, 10)
],
"cards_total": 12,
"closed_today": 1,
}]
monkeypatch.setattr(main, "_collect_wall_data", fake_collect)
page = client.get("/wall")
assert page.status_code == 200
assert "+3 autres cartes en vol" in page.text
assert "📋 Ouvertes" in page.text
+87
View File
@@ -0,0 +1,87 @@
"""Palier 13 (0.17.0): vague 2 — jalon sur la carte (#17), pastille
de santé (#18), épinglés via étoiles Gitea (#20), page Projet (#25)."""
from tests.conftest import csrf_from
def test_dashboard_shows_next_milestone(client, fake_gitea):
page = client.get("/")
assert page.status_code == 200
# Faux : Sprint 1, échéance 2026-10-20, 1 fermée / 2 au total.
assert "🎯 Sprint 1" in page.text
assert "1/2 terminées" in page.text
assert "échéance" in page.text
def test_dashboard_health_dot(client, fake_gitea):
page = client.get("/")
assert 'class="health-dot ' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_no_starred_section_by_default(client, fake_gitea):
page = client.get("/")
assert 'id="pinned-section" hidden' in page.text
assert 'data-starred="0"' in page.text
def test_starred_repo_is_pinned(client, fake_gitea):
fake_gitea.starred.add("Projets/demo")
page = client.get("/")
assert 'id="pinned-section" hidden' not in page.text
assert 'data-starred="1"' in page.text
# La carte épinglée sort de la grille de son onglet d'origine :
# une seule carte (div) pour demo — le bouton étoile porte aussi
# un data-full, on compte donc le motif de la carte elle-même.
assert page.text.count('data-full="Projets/demo" data-home') == 1
def test_star_toggle_endpoint(client, fake_gitea):
csrf = csrf_from(client)
denied = client.post(
"/api/projects/Projets/demo/star", data={"starred": "1"}
)
assert denied.status_code == 403
resp = client.post(
"/api/projects/Projets/demo/star",
data={"starred": "1", "csrf_token": csrf},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "starred": True}
assert "Projets/demo" in fake_gitea.starred
resp = client.post(
"/api/projects/Projets/demo/star",
data={"starred": "0", "csrf_token": csrf},
)
assert resp.json() == {"ok": True, "starred": False}
assert "Projets/demo" not in fake_gitea.starred
def test_card_title_links_to_project_page(client, fake_gitea):
page = client.get("/")
assert 'href="/project/Projets/demo"' in page.text
assert 'href="/board/Projets/demo"' in page.text # accès board conservé
def test_project_page(client, fake_gitea):
from app import models
models.log_event("Projets/demo", 1, "created", to_column="backlog")
models.log_event(
"Projets/demo", 1, "moved",
from_column="backlog", to_column="in_progress",
)
page = client.get("/project/Projets/demo")
assert page.status_code == 200
assert "🎯 Jalons ouverts" in page.text
assert "Sprint 1" in page.text
assert "🩺 Santé du flux" in page.text
assert "🔮 Prévisions" in page.text
assert "🕘 Activité récente" in page.text
assert "📝 Notes du projet" in page.text
assert 'href="/board/Projets/demo"' in page.text
assert "/wall?repo=Projets/demo" in page.text
# Titre du projet et lien Gitea du faux.
assert "demo" in page.text
+74
View File
@@ -0,0 +1,74 @@
"""Palier 14 (0.18.0): vague 2 suite — activité 7 jours (#19),
tri/regroupement (#21), vue liste dense (#22), actions rapides (#23)."""
from datetime import datetime, timedelta, timezone
from app import models
def _ts(days_ago: int) -> str:
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
return dt.strftime("%Y-%m-%d 12:00:00")
def test_activity_line_recent_week(client, fake_gitea):
for _ in range(2):
models.log_event("Projets/demo", 1, "closed",
to_column="done", created_at=_ts(1))
models.log_event("Projets/demo", 5, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(0))
page = client.get("/")
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
def test_activity_line_idle_project(client, fake_gitea):
models.log_event("Projets/vieux", 9, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(35))
page = client.get("/")
assert "aucune activité kanban depuis 35 j" in page.text
def test_dashboard_display_tools_markup(client, fake_gitea):
page = client.get("/")
assert "dashState" in page.text
assert 'id="dash-sort"' in page.text
assert 'id="dash-group"' in page.text
assert "Grouper par propriétaire" in page.text
assert "setView('grid')" in page.text
assert "setView('list')" in page.text
def test_dense_list_view_tables(client, fake_gitea):
page = client.get("/")
assert page.text.count('class="proj-table"') == 3
assert 'data-sort="open"' in page.text
assert 'data-sort="inflight"' in page.text
# La ligne dense de demo : lien page projet + compteurs.
assert 'href="/project/Projets/demo"' in page.text
def test_dashboard_js_contract(client, fake_gitea):
js = client.get("/static/js/dashboard.js")
assert js.status_code == 200
assert "kanbante-dash-sort" in js.text
assert "kanbante-dash-group" in js.text
assert "owner-group" in js.text
assert "data-menu-btn" in js.text
def test_card_quick_actions_menu(client, fake_gitea):
page = client.get("/")
assert "data-menu-btn" in page.text
# urlencode de Jinja conserve les « / » : le lien est valide tel quel.
assert 'href="/capture?repo=Projets/demo"' in page.text
assert 'href="/triage/Projets/demo"' in page.text
assert 'href="/sprint/Projets/demo"' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_capture_prefilled_repo(client, fake_gitea):
page = client.get("/capture?repo=Projets/kanbante")
assert page.status_code == 200
assert 'value="Projets/kanbante"' in page.text
+112
View File
@@ -0,0 +1,112 @@
"""Palier 15 (0.19.0) — Performance + navigation Pilotage.
- A : les boucles par dépôt sont parallélisées (le comportement
visible ne change pas — couvert par les paliers précédents) et
le cache du client passe en stale-while-revalidate (testé dans
test_gitea_client.py).
- C : /forecast devient un tableau de bord des jalons (une ligne
par jalon, ancres depuis le dashboard) et /health un tableau
trié par sévérité, avec sélecteur de projet sur le détail.
"""
import asyncio
import httpx
from app.gitea_client import GiteaClient
def test_forecast_is_a_portfolio_table(client):
r = client.get("/forecast")
assert r.status_code == 200
assert "pilot-table" in r.text
assert "jalon(s) ouvert(s)" in r.text
# One row for the demo milestone, anchored for dashboard links.
assert 'id="ms-Projets-demo"' in r.text
assert "Sprint 1" in r.text
# The per-project pace table is still there.
assert "Débit par projet" in r.text
def test_dashboard_milestone_links_into_forecast(client):
r = client.get("/")
assert r.status_code == 200
assert 'href="/forecast#ms-Projets-demo"' in r.text
def test_health_index_is_a_severity_table(client):
r = client.get("/health")
assert r.status_code == 200
assert "pilot-table" in r.text
assert "data-severity" in r.text
assert "Projets/demo" in r.text
def test_health_detail_has_project_switcher(client):
r = client.get("/health/Projets/demo")
assert r.status_code == 200
assert 'id="health-switch"' in r.text
assert '<option value="Projets/demo" selected>' in r.text
assert "tous les projets" in r.text
async def test_swr_serves_stale_then_refreshes_in_background():
"""Stale entries are served instantly; the refresh lands
asynchronously and the next read sees the fresh value."""
calls = {"n": 0}
def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
calls["n"] += 1
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
client = GiteaClient(
base_url="http://gitea.test",
token="",
cache_ttl=60,
transport=httpx.MockTransport(handler),
)
first = await client.search_repos()
assert first == [{"id": 1}]
# Fresh cache: no new fetch.
assert await client.search_repos() == [{"id": 1}]
assert calls["n"] == 1
# Age the entry beyond the TTL: the stale value comes back
# immediately (same content, no wait)…
ts, value = client._cache["search_repos"]
client._cache["search_repos"] = (ts - 120, value)
assert await client.search_repos() == [{"id": 1}]
# …and the background refresh replaces it.
await asyncio.sleep(0.3)
assert await client.search_repos() == [{"id": 2}]
await client.aclose()
async def test_swr_mutation_clear_blocks_stale_resurrection():
"""A refresh started before a cache clear (mutation) must not
put its now-stale result back."""
calls = {"n": 0}
def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
calls["n"] += 1
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
client = GiteaClient(
base_url="http://gitea.test",
token="",
cache_ttl=60,
transport=httpx.MockTransport(handler),
)
await client.search_repos()
ts, value = client._cache["search_repos"]
client._cache["search_repos"] = (ts - 120, value)
await client.search_repos() # triggers background refresh
client._cache_clear() # a mutation lands first
await asyncio.sleep(0.3)
# The cache was cleared and the in-flight refresh was disarmed:
# the next read fetches fresh instead of serving the old value.
assert await client.search_repos() == [{"id": 3}]
await client.aclose()
+36
View File
@@ -0,0 +1,36 @@
"""Palier 16 (0.20.0) — refonte professionnelle de l'entête Projets.
Fini le « Bonjour 👋 » et les rangées éparses : titre sobre,
bande KPI unifiée, et une seule barre d'outils (onglets
segmentés, filtre, tri, regroupement, vue). Les crochets
fonctionnels (ids, liaisons Alpine) sont inchangés.
"""
def test_dashboard_professional_header(client):
r = client.get("/")
assert r.status_code == 200
# Titre sobre + sous-titre, plus de formule familière.
assert "<h1>Projets</h1>" in r.text
assert "Bonjour" not in r.text
# Bande KPI unifiée : valeurs + libellés.
assert "kpi-strip" in r.text
assert "Projets actifs" in r.text
assert "Issues fermées · 30 j" in r.text
assert "Temps de cycle moyen" in r.text
# Les KPI journal mènent à l'activité.
assert 'href="/activity"' in r.text
def test_dashboard_single_toolbar_keeps_its_hooks(client):
r = client.get("/")
assert "dash-toolbar" in r.text
# Onglets segmentés avec compteurs, mêmes liaisons Alpine.
assert "seg-tabs" in r.text
assert "seg-count" in r.text
assert "tab = 'inactive'" in r.text
# Filtre, tri, regroupement et vue : ids préservés pour
# dashboard.js et l'état Alpine.
for hook in ('id="project-search"', 'id="dash-sort"',
'id="dash-group"', "setView('list')"):
assert hook in r.text, hook
+36
View File
@@ -0,0 +1,36 @@
"""Palier 17 (0.21.0) — sélecteur de dépôt intelligent (Capture).
- GET /api/repos : liste exhaustive des dépôts pour les
sélecteurs (la palette reste plafonnée à 60), épinglés
d'abord, archivés en dernier.
- La page /capture branche le sélecteur sur le champ Dépôt
(liste filtrée + complétion Tab par segment, repo-picker.js).
"""
def test_api_repos_lists_everything_ranked(client, fake_gitea):
fake_gitea.starred.add("Projets/vieux")
r = client.get("/api/repos")
assert r.status_code == 200
repos = r.json()["repos"]
names = [x["full_name"] for x in repos]
# Exhaustif : actifs + inactifs + archivés du faux Gitea.
assert names == ["Projets/vieux", "Projets/demo", "Projets/archive"] or set(names) == {
"Projets/demo", "Projets/vieux", "Projets/archive",
}
# L'épinglé passe devant malgré son inactivité.
assert names[0] == "Projets/vieux"
by_name = {x["full_name"]: x for x in repos}
assert by_name["Projets/vieux"]["starred"] is True
assert by_name["Projets/demo"]["starred"] is False
assert by_name["Projets/archive"]["archived"] is True
def test_capture_page_wires_the_repo_picker(client):
r = client.get("/capture")
assert r.status_code == 200
assert 'id="capture-repo"' in r.text
assert 'id="repo-listbox"' in r.text
assert "repo-picker.js" in r.text
# Le contrat du formulaire ne change pas.
assert 'name="repo_full_name"' in r.text
+139
View File
@@ -0,0 +1,139 @@
"""Palier 18 (0.22.0) — login screen, sidebar, chantier B.
- Connexion par formulaire + cookie de session signé (le Basic
reste accepté pour les scripts) ; les navigateurs sont
redirigés vers /login, les API reçoivent 401.
- Bouton replier dans l'en-tête de la barre latérale (icône
seule, à droite de la recherche) ; accordéon exclusif.
- Instantanés : persistance des réponses du client, hydratation
au démarrage, étiquette de fraîcheur.
"""
import re
import httpx
from fastapi.testclient import TestClient
import app.main as main
from app import models
from app.gitea_client import GiteaClient
from app.snapshots import freshness_label
def _auth_client(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
return TestClient(main.app)
def _csrf_from(html: str) -> str:
m = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert m, "le formulaire de connexion doit porter le jeton CSRF"
return m.group(1)
def test_browser_is_redirected_to_login(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
# Les API, elles, reçoivent un 401 sec (pas de redirection).
r = client.get("/api/repos")
assert r.status_code == 401
def test_login_flow_and_logout(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
page = client.get("/login")
assert page.status_code == 200
assert "login-card" in page.text
token = _csrf_from(page.text)
bad = client.post(
"/login",
data={"username": "bruno", "password": "non", "csrf_token": token},
follow_redirects=False,
)
assert bad.status_code == 401
assert "Identifiants incorrects" in bad.text
ok = client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"csrf_token": token,
"next": "/focus",
},
follow_redirects=False,
)
assert ok.status_code == 303
assert ok.headers["location"] == "/focus"
# La session ouvre les pages sans autre en-tête.
assert client.get("/").status_code == 200
client.get("/logout")
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
assert r.status_code == 303
def test_login_next_is_local_only(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
token = _csrf_from(client.get("/login").text)
r = client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"csrf_token": token,
"next": "https://evil.example/phish",
},
follow_redirects=False,
)
assert r.status_code == 303
assert r.headers["location"] == "/"
def test_sidebar_head_and_exclusive_accordion(client):
r = client.get("/")
assert r.status_code == 200
head = r.text.split('class="side-nav"')[0]
assert 'id="side-collapse"' in head # replier dans l'en-tête…
assert head.index("side-search") < head.index('id="side-collapse"') # …à droite de la recherche
assert '<span class="side-label">Replier</span>' not in r.text # icône seule
assert "kanbante-nav-open" in r.text # accordéon exclusif (amorce)
async def test_snapshot_write_through_and_hydration():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
return httpx.Response(
200,
json={"data": [{"full_name": "Projets/demo", "archived": False}]},
headers={"X-Total-Count": "1"},
)
writer = GiteaClient(
base_url="http://gitea.test", cache_ttl=30,
transport=httpx.MockTransport(handler),
)
repos = await writer.search_repos()
assert repos and repos[0]["full_name"] == "Projets/demo"
stored = models.load_snapshots()
assert "search_repos" in stored
def boom(request):
raise AssertionError("Gitea ne doit pas être appelé : l'instantané suffit")
reader = GiteaClient(
base_url="http://gitea.test", cache_ttl=30,
transport=httpx.MockTransport(boom),
)
assert reader.hydrate_from_snapshots() >= 1
served = await reader.search_repos()
assert served[0]["full_name"] == "Projets/demo"
assert freshness_label() == "à l'instant"
+360
View File
@@ -0,0 +1,360 @@
"""Palier 19 (0.24.0): multi-forges, utilisateurs, espaces,
paramètres en base, archives — P0 à P4 de l'analyse du 2026-10-21."""
import sqlite3
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, forge_crypto, models
from tests.conftest import csrf_from
# ── Migration des anciennes bases (P0) ────────────────────
def test_migration_legacy_schema_gains_scope(tmp_path, monkeypatch):
db = tmp_path / "legacy.db"
conn = sqlite3.connect(db)
conn.executescript(
"""
CREATE TABLE board_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
column_name TEXT NOT NULL DEFAULT 'backlog',
position REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name, issue_number)
);
CREATE TABLE project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
note TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name)
);
CREATE TABLE today_picks (
pick_date TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, repo_full_name, issue_number)
);
CREATE TABLE board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_column TEXT,
to_column TEXT,
source TEXT NOT NULL DEFAULT 'ui',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO board_state (repo_full_name, issue_number, column_name)
VALUES ('Projets/demo', 1, 'todo');
INSERT INTO project_notes (repo_full_name, note)
VALUES ('Projets/demo', 'note héritée');
"""
)
conn.commit()
conn.close()
monkeypatch.setenv("KANBANTE_DB_PATH", str(db))
models.init_db()
board = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in board["todo"]] == [1]
assert models.get_notes("Projets/demo") == "note héritée"
# La portée par défaut est (1, 1) et les nouvelles tables existent.
assert models.list_connections() == []
assert models.count_users() == 0
# ── Chiffrement des jetons (P0) ───────────────────────────
def test_forge_token_encryption_roundtrip(monkeypatch):
from cryptography.fernet import Fernet
monkeypatch.setenv("KANBANTE_MASTER_KEY", Fernet.generate_key().decode())
forge_crypto._fernet = None # forcer la relecture de la clé
stored = forge_crypto.encrypt_token("ghp_secret")
assert stored != "ghp_secret"
assert forge_crypto.decrypt_token(stored) == "ghp_secret"
assert forge_crypto.mask_token(stored) == "••••••••"
assert forge_crypto.encrypt_token("") == ""
# ── Amorçage (P0/P2/P3) ───────────────────────────────────
@pytest.fixture
def auth_client(tmp_path, monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "auth.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
yield client
def _login(client, username="bruno", password="motdepasse"):
client.get("/login")
token = client.cookies.get("kanbante_csrf") or ""
return client.post(
"/login",
data={
"username": username,
"password": password,
"next": "/",
"csrf_token": token,
},
follow_redirects=False,
)
def test_bootstrap_creates_legacy_connection_admin_workspace(auth_client):
connections = models.list_connections()
assert len(connections) == 1
assert connections[0]["slug"] == "gitea"
assert connections[0]["kind"] == "gitea"
workspaces = models.list_workspaces()
assert [ws["slug"] for ws in workspaces] == ["principal"]
assert models.workspace_connection_ids(workspaces[0]["id"]) == [
connections[0]["id"]
]
users = models.list_users()
assert len(users) == 1
assert users[0]["username"] == "bruno"
assert users[0]["role"] == "admin"
assert accounts.verify_password("motdepasse", users[0]["password_hash"])
def test_db_login_and_password_change(auth_client):
# Le mot de passe d'environnement a créé le hash en base :
# la connexion passe par la base.
r = _login(auth_client)
assert r.status_code == 303
token = csrf_from(auth_client)
r = auth_client.post(
"/profile/password",
data={
"current_password": "motdepasse",
"new_password": "nouveau-secret",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
auth_client.get("/logout")
# Le nouveau mot de passe fonctionne…
assert _login(auth_client, password="nouveau-secret").status_code == 303
auth_client.get("/logout")
# …et pour un compte membre, l'ancien ne passe plus (pas de
# secours environnement pour les membres).
token = csrf_from(auth_client)
_login(auth_client, password="nouveau-secret")
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "alice",
"password": "alice-pass",
"role": "member",
"display_name": "Alice",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
assert _login(auth_client, "alice", "alice-pass").status_code == 303
token = csrf_from(auth_client)
auth_client.post(
"/profile/password",
data={
"current_password": "alice-pass",
"new_password": "alice-nouveau",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
r = _login(auth_client, "alice", "alice-pass")
assert r.status_code == 401
assert _login(auth_client, "alice", "alice-nouveau").status_code == 303
def test_member_cannot_open_admin(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "bob",
"password": "bob-pass",
"role": "member",
"display_name": "",
"csrf_token": token,
},
follow_redirects=False,
)
auth_client.get("/logout")
_login(auth_client, "bob", "bob-pass")
assert auth_client.get("/admin").status_code == 403
assert auth_client.get("/admin/connections").status_code == 403
assert auth_client.get("/profile").status_code == 200
def test_disabled_user_session_dies(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
auth_client.post(
"/admin/users",
data={
"username": "eve",
"password": "eve-pass",
"role": "member",
"display_name": "",
"csrf_token": token,
},
follow_redirects=False,
)
eve_id = models.get_user_by_username("eve")["id"]
auth_client.get("/logout")
_login(auth_client, "eve", "eve-pass")
assert auth_client.get("/profile").status_code == 200
# L'admin désactive Eve pendant que sa session est ouverte.
with TestClient(main.app) as admin_client:
_login(admin_client)
token = csrf_from(admin_client)
admin_client.post(
f"/admin/users/{eve_id}/toggle",
data={"csrf_token": token},
follow_redirects=False,
)
r = auth_client.get(
"/profile", headers={"Accept": "text/html"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
def test_admin_creates_github_connection_encrypted(auth_client):
_login(auth_client)
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/connections",
data={
"kind": "github",
"name": "GitHub perso",
"base_url": "https://api.github.com",
"token": "ghp_secret_token",
"hook_secret": "",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
row = models.get_connection_by_slug("github-perso")
assert row is not None
assert row["kind"] == "github"
assert row["token_encrypted"] != "ghp_secret_token"
assert forge_crypto.decrypt_token(row["token_encrypted"]) == "ghp_secret_token"
page = auth_client.get("/admin/connections")
assert "ghp_secret_token" not in page.text # jamais réaffiché
assert "••••••••" in page.text
# ── Paramètres en base (P3) ───────────────────────────────
def test_settings_panel_overrides_env(auth_client):
from app.config import get_settings
_login(auth_client)
assert get_settings().aging_days == 7
token = csrf_from(auth_client)
auth_client.post(
"/admin/settings",
data={"setting_aging_days": "42", "csrf_token": token},
follow_redirects=False,
)
assert get_settings().aging_days == 42
# Champ vidé = retour à l'environnement.
token = csrf_from(auth_client)
auth_client.post(
"/admin/settings",
data={"setting_aging_days": "", "csrf_token": token},
follow_redirects=False,
)
assert get_settings().aging_days == 7
# ── Espaces et portées (P3) ───────────────────────────────
def test_board_state_is_scoped_per_workspace():
models.move_issue("Projets/demo", 1, "todo", workspace_id=1, connection_id=1)
models.move_issue("Projets/demo", 1, "review", workspace_id=2, connection_id=1)
assert models.get_board_state("Projets/demo", workspace_id=1)["todo"]
board2 = models.get_board_state("Projets/demo", workspace_id=2)
assert board2["review"] and not board2["todo"]
def test_workspace_switch_scopes_dashboard(client, fake_gitea):
# Un espace vide (aucune connexion) ne montre aucun projet.
empty_id = models.create_workspace("vide", "Vide", "#999999")
client.cookies.set("kanbante_workspace", str(empty_id))
page = client.get("/")
assert "/board/Projets/demo" not in page.text
principal = models.list_workspaces()[0]
client.cookies.set("kanbante_workspace", str(principal["id"]))
page = client.get("/")
assert "/board/Projets/demo" in page.text
# ── Archives (P4) ─────────────────────────────────────────
def test_local_archive_hides_and_restores(client, fake_gitea):
token = csrf_from(client)
principal = models.list_workspaces()[0]
assert "/board/Projets/demo" in client.get("/").text
r = client.post(
"/archives/local",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
assert "/board/Projets/demo" not in client.get("/").text
archives = client.get("/archives")
assert "Projets/demo" in archives.text
assert models.list_local_archived(principal["id"])
token = csrf_from(client)
client.post(
"/archives/local",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "0",
"csrf_token": token,
},
follow_redirects=False,
)
assert "/board/Projets/demo" in client.get("/").text
def test_forge_archive_via_page(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/archives/forge",
data={
"conn_id": "1",
"repo_full_name": "Projets/demo",
"archived": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
repo = next(r for r in fake_gitea.repos if r["full_name"] == "Projets/demo")
assert repo["archived"] is True

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