Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b0b219e8 | ||
|
|
14488f662c | ||
|
|
ec0174f9d4 | ||
|
|
687c968f3e | ||
|
|
c8fdf61cf3 | ||
|
|
12e6db7fe2 | ||
|
|
f25862d838 | ||
|
|
dc1f00f24a | ||
|
|
1b8b83c005 | ||
|
|
94900ccd53 | ||
|
|
8719d0d6f8 | ||
|
|
1bc0696d6d | ||
|
|
505a1e1542 | ||
|
|
037f0e0b98 | ||
|
|
4190ee825d | ||
|
|
1eed495342 | ||
|
|
e7ceb14d2b | ||
|
|
f976d22d4b | ||
|
|
a0b461f468 | ||
|
|
c2052959d1 | ||
|
|
e23dd499a9 | ||
|
|
c091189381 | ||
|
|
e49f498ca0 | ||
|
|
9eb236ba63 | ||
|
|
e3a89710d7 | ||
|
|
546e6fb81b | ||
|
|
bcd9c2face | ||
|
|
b4f3cce0df | ||
|
|
0ce5de46f3 | ||
|
|
cb056ee456 | ||
|
|
a01b6a484a | ||
|
|
2735905c7e | ||
|
|
8596ad5708 | ||
|
|
642c7bbb2d | ||
|
|
7a3081c408 | ||
|
|
21414de936 | ||
|
|
bb3da870a0 | ||
|
|
7cf2a3fc25 | ||
|
|
6602a070be | ||
|
|
9e1ff33698 | ||
|
|
47dd9c06a1 | ||
|
|
90b9189169 | ||
|
|
91ddfcd3b3 | ||
|
|
2c10040046 | ||
|
|
32fbe1d137 | ||
|
|
4d1757e3d2 | ||
|
|
9dc62885b0 | ||
|
|
cc007dfa04 | ||
|
|
f0ca8b9ec6 | ||
|
|
6a938cf778 | ||
|
|
c60694bf23 | ||
|
|
a08fa53bab | ||
|
|
1c81259e0d | ||
|
|
5749b1c718 | ||
|
|
c35aafd2e2 | ||
|
|
219c614216 | ||
|
|
0d6499ed1a | ||
|
|
a09ae26d78 | ||
|
|
56f39c0959 | ||
|
|
99a7ebd504 | ||
|
|
e8f48d3009 | ||
|
|
dd6a4dd82c | ||
|
|
a2f3564cc8 | ||
|
|
ea39916db5 | ||
|
|
724d7a7799 | ||
|
|
5ff51d8e2d | ||
|
|
436084aebd | ||
|
|
8ab30d25ef | ||
|
|
6cac4e71f5 | ||
|
|
210f4f8983 | ||
|
|
f150d4e136 | ||
|
|
5787bcf168 | ||
|
|
80006bdf35 | ||
|
|
0219fafa34 | ||
|
|
c97dfb0ef4 | ||
|
|
1b1c793919 | ||
|
|
d11ca0cbff | ||
|
|
0e2df86be5 | ||
|
|
fd71204b5e | ||
|
|
5c8d9f1a4d | ||
|
|
7a4fb2fb7d | ||
|
|
3888e4ad6a | ||
|
|
96898bc5e6 | ||
|
|
fb61169c23 | ||
|
|
16403821de | ||
|
|
49bf9b32bf | ||
|
|
53d1d2c29d | ||
|
|
e723366755 | ||
|
|
fc70d125d4 | ||
|
|
0336f19f0f | ||
|
|
8f437e3a3e | ||
|
|
00676834e8 | ||
|
|
0e78ae3c60 | ||
|
|
b72d2607a8 | ||
|
|
2235d0be02 | ||
|
|
699600eda3 | ||
|
|
3c4bbbf2f1 | ||
|
|
7556cd0bee | ||
|
|
aa98eb6866 | ||
|
|
409e67d57b | ||
|
|
1234429a46 | ||
|
|
f7a0597575 | ||
|
|
e89f7903e1 | ||
|
|
938f8b5eb5 | ||
|
|
5f365b4b9f | ||
|
|
d867fbffbf | ||
|
|
b90941111a | ||
|
|
35f1cccd03 | ||
|
|
f29a640d9a | ||
|
|
cf98880fc8 | ||
|
|
6e86b09327 | ||
|
|
d41a79a9ed | ||
|
|
725f78cb68 | ||
|
|
39750851b0 | ||
|
|
b2d2fd1eee | ||
|
|
0de3b65869 | ||
|
|
903616a8cd | ||
|
|
e5282079b8 | ||
|
|
a30fa9d8f3 | ||
|
|
61f299305a | ||
|
|
ecf7f09fc5 | ||
|
|
9772ecdcf6 | ||
|
|
f503331773 | ||
|
|
c75ef7764f | ||
|
|
961556fb78 | ||
|
|
7a68606aa7 | ||
|
|
8015d22a06 | ||
|
|
39b5fd88f9 | ||
|
|
f5b007f286 | ||
|
|
2b9b852308 | ||
|
|
d3886b6bf5 | ||
|
|
671ee68c96 | ||
|
|
c46d6dc519 | ||
|
|
4184ba0272 | ||
|
|
751e2f4f44 | ||
|
|
975aff33a5 | ||
|
|
003e44f55e | ||
|
|
53f54b1b79 | ||
|
|
80fc260a40 | ||
|
|
8ad3a86e60 | ||
|
|
a92353f8ef | ||
|
|
8c889878ee | ||
|
|
dfb4e595ff | ||
|
|
9fe35f6c8f | ||
|
|
d419fe3086 | ||
|
|
65c467bc90 | ||
|
|
4a97e0d0dc | ||
|
|
7852d860fe | ||
|
|
d4cf8d1275 | ||
|
|
28322c12cf | ||
|
|
3964323abd | ||
|
|
4230ac1052 | ||
|
|
3809462cc7 | ||
|
|
868496891d | ||
|
|
4a947747f2 | ||
|
|
5ef6c21756 | ||
|
|
13182f5d53 | ||
|
|
2381ae0b35 | ||
|
|
7b6e72e6ce | ||
|
|
6e31122c46 | ||
|
|
d51ef5a970 | ||
|
|
735d7cdfd0 | ||
|
|
f1a05279b6 | ||
|
|
ed2834c332 | ||
|
|
5bc21e1840 | ||
|
|
f087b41b4b | ||
|
|
6066890bae | ||
|
|
96c08ed15e | ||
|
|
52e87608ad | ||
|
|
90be279a2b | ||
|
|
eb39c1ed5d | ||
|
|
121c59760e | ||
|
|
9e2096c9b6 | ||
|
|
e63fe39578 | ||
|
|
b7d21f80df | ||
|
|
c7711c0ca2 | ||
|
|
319619d200 | ||
|
|
0bdde28b79 | ||
|
|
4b38acdcba | ||
|
|
760fbd8c6d | ||
|
|
f5d2d7d716 | ||
|
|
71e705ea64 | ||
|
|
7918b54943 | ||
|
|
3695bfac8a | ||
|
|
57d93c4f4b | ||
|
|
900590efed | ||
|
|
0aa991047f | ||
|
|
79122c20d0 | ||
|
|
3e3187d597 | ||
|
|
593f076555 | ||
|
|
311a14090d | ||
|
|
c67a14da65 | ||
|
|
7ec34245c5 |
@@ -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=
|
||||
+923
@@ -1,5 +1,928 @@
|
||||
## 0.42.0 — 2026-10-28 — Lot UX complet (#57 à #68)
|
||||
|
||||
- **#57 Jetons** : échelle fermée documentée (typo xs/sm/base/lg/xl, rayons sm/md/base/full, ombres, espacements) ; rayons pilule passés au jeton `--radius-full`.
|
||||
- **#58 Boutons** : système primary uni (fini les dégradés), secondary, danger, icon 36px, états unifiés ; `.btn-save` et `.proj-primary` alignés sur le primary.
|
||||
- **#59 Icônes** : emoji d'interface retirés des gabarits (cartes, tableaux, états vides, boutons, titres) ; épingler en SVG trait, recherche en SVG ; favicons et contenu utilisateur (titres/labels Gitea) inchangés.
|
||||
- **#60 Accessibilité** : jeton `--accent-text` distinct (`#14b8a6` sombre, `#0f766e` clair, 5,47:1 sur blanc) pour tous les textes d'accent ; focus visible unifié.
|
||||
- **#61 Typographie** : pile système assumée (Inter déclarée mais jamais chargée, pas de CDN tiers pour une app auto-hébergée) ; hiérarchie H1/H2 via `.page-title`.
|
||||
- **#62 Pages** : Aujourd'hui et Focus reprennent l'en-tête `.page-head` / `.page-title` commun ; titre Aujourd'hui ramené à l'échelle xl.
|
||||
- **#63 Navigation** : recherche en double supprimée de l'en-tête (palette Ctrl+K + recherche sidebar restent).
|
||||
- **#64 Mobile** : cibles 44px sous 640px, en-tête et KPI compacts.
|
||||
- **#65 États** : `.empty-state` / `.focus-empty` unifiés, classe `.skeleton` (pulsation, respect de `prefers-reduced-motion`).
|
||||
- **#66 Formulaires** : classes `.field-error` / `.field-error-msg` (bordure rouge + message, jamais la couleur seule).
|
||||
- **#67 Cartes** : titre clampé à 2 lignes, code discret, labels/chips en texte thème sur fond teinté `color-mix` (contraste garanti quelle que soit la couleur Gitea).
|
||||
- **#68 Thème clair** : connexion et wallboard suivent le thème mémorisé (init avant rendu, comme `_header`) et ne restent plus sombres par défaut ; variantes claires ajoutées.
|
||||
|
||||
## 0.41.0 — 2026-10-28 — Identité Kanbante : sidebar charbon, accent vert, icônes ligne, bouton thème en Administration (#54, #55)
|
||||
|
||||
- **Sidebar (#54)** : fond charbon draco (`rgba(26, 28, 26, 0.96)`) à la place du bleu-noir `rgba(13, 16, 24)` oublié par le re-thème 0.39.0 — la barre latérale a désormais la même teinte que le reste du site, en sombre comme en clair (clair déjà blanc).
|
||||
- **Accent vert (#54)** : le rose Spark cède la place au vert Kanbante — `--accent: #0d9488`, second ton `#14b8a6` (clair : `#0f766e`), anneaux, focus, dégradés et replis associés. La barre Fusion partagée n'est pas touchée (Spark rose, marques des autres apps).
|
||||
- **Icônes (#54)** : les emoji de la barre latérale sont remplacés par des SVG fins en trait (stroke currentColor), style rail Spark, pour chaque entrée de navigation ; le logo Kanbante devient des colonnes kanban blanches sur fond vert sarcelle.
|
||||
- **Bouton Dark/Light (#55)** : déplacé de l'en-tête applicatif vers la section Administration de la barre latérale (icône seule en rail replié, libellé explicite sinon). Comportement inchangé (bascule + choix mémorisé). Conséquence : seuls les administrateurs le voient, la section Administration leur étant réservée.
|
||||
|
||||
## 0.40.0 — 2026-10-28 — Navigation : section Administration + bouton Dark/Light (#51, #52)
|
||||
|
||||
- **Accordéon (#51)** : la section Administration ne fermait jamais — le JS posait `nav-closed-admin`, mais `style.css` ne définissait les règles de fermeture que pour les 4 autres sections. Les règles admin sont ajoutées (fermeture et rotation du chevron) : l'accordéon redevient exclusif.
|
||||
- **Bouton Dark/Light (#52)** : bouton « ☀️ Mode clair / 🌙 Mode sombre » dans l'en-tête applicatif, qui bascule `data-theme` sur `<html>` et mémorise le choix (localStorage, appliqué avant le rendu par l'amorce de `_header.html`). Thème clair sur les jetons draco (fond #f2f2f0, surfaces blanches/grises, accent rose conservé, second ton assombri pour les liens), barre Fusion et panneau Spark qui suivent. Défaut inchangé : sombre. `nav.js` est désormais versionné (`?v=`), comme dashboard.js depuis la 0.38.1.
|
||||
|
||||
## 0.39.0 — 2026-10-28 — Thème du site : look agent-dev / barre Fusion (#49)
|
||||
|
||||
- **Re-thème complet** : `style.css` passe sur les jetons draco-barre (Console v7.2) — fond #101211, surfaces #1a1c1a / #242624 / #20221f, bordure #2e302c, textes #eceee9 / #a6aba2 / #8b9089, accent rose Spark #c2437e (second ton #e078a8). Fond plat, plus de halos ni de dégradés violet→bleu, y compris les valeurs de repli et l'écran de connexion.
|
||||
- **Inchangé** : couleurs sémantiques (vert/jaune/rouge) et couleurs de colonnes choisies par l'utilisateur. Pas de mode clair : l'app force le sombre, comme la console de référence.
|
||||
|
||||
## 0.38.1 — 2026-10-28 — Page Projets : le correctif 0.38.0 atteint enfin le navigateur (#47)
|
||||
|
||||
- **Cache-busting (#47)** : `dashboard.html` charge désormais `style.css?v={{ version }}` et `dashboard.js?v={{ version }}`, comme les autres pages (admin, board, project) et la barre Fusion. Sans ça, le navigateur gardait l'ancien CSS (menu transparent, bouton de copie non stylé) et l'ancien JS (aucun gestionnaire de copie) alors que le HTML était déjà neuf — c'est pourquoi la 0.38.0 semblait ne rien changer, bouton présent mais inerte. Le code 0.38.0 lui-même (jetons `--menu-bg` opaques, gestionnaire `data-copy-url`) était correct dans le dépôt.
|
||||
|
||||
## 0.38.0 — 2026-10-28 — Page Projets : copie d'URL et refonte du menu ⋯
|
||||
|
||||
- **Copie d'URL (#44)** : chaque tuile projet (grille, y compris Épinglés) propose désormais « 🔗 Copier l'URL du projet » dans son menu ⋯ — l'URL Gitea du dépôt est copiée dans le presse-papiers, avec confirmation « URL du projet copiée » et repli (textarea + execCommand) quand l'API Clipboard n'est pas disponible.
|
||||
- **Menu ⋯ (#45)** : refonte sur les jetons de la barre d'apps commune (draco-barre.css, prototype Console v7.2 / console Spark) — fond opaque, bordure, rayon 14 px, ombre portée, survol/focus visibles, séparateur avant l'action de copie. Corrige la cause du menu transparent : il utilisait `var(--surface-1)`, variable jamais définie.
|
||||
- **Thèmes sombre et clair** : jetons clairs par défaut, variante sombre via `[data-theme="dark"]` sur `<html>` et repli `prefers-color-scheme`, comme la barre Fusion.
|
||||
|
||||
## 0.37.6 — 2026-10-28 — Corrections console/recherche : CORS console Spark
|
||||
|
||||
- **CORS recherche (PR #43)** : la recherche Kanbante autorise désormais la console Spark (agent prod+dev) en inter-origines — les pages console peuvent appeler la recherche de l'hôte sans erreur CORS/réseau.
|
||||
|
||||
## 0.37.5 — 2026-10-28 — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
|
||||
|
||||
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte.
|
||||
|
||||
## 0.37.4 — 2026-10-28 — Correctifs recherche/alertes/console : CORS sœurs prod+dev, recherche en onglets, alertes en onglets
|
||||
|
||||
- **CORS recherche/alertes (#68/#69)** : allowlist explicite des 8 origines sœurs prod+dev (`*.dracodev.net`, avec et sans `-dev`), jamais `*`, avec credentials — pour `app/recherche.py` et le middleware CORS de `GET /api/events`.
|
||||
- **Recherche partout (#68)** : onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark, « Tout voir » restant sur place, focus automatique, mise en page corrigée.
|
||||
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout, par application, Console Spark, Historique = fermées seules), compteurs, filtre texte, onglet dans l'URL ; onglet « Console Spark » en dernier dans la barre d'apps, après Kanbante.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -33,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
|
||||
@@ -111,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 |
|
||||
@@ -210,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
|
||||
{
|
||||
@@ -231,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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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 = ""
|
||||
+363
-4
@@ -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).
|
||||
@@ -81,15 +96,88 @@ Environment variables:
|
||||
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.17.0"
|
||||
APP_VERSION = "0.42.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -125,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"
|
||||
@@ -158,6 +384,19 @@ class Settings:
|
||||
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:
|
||||
@@ -165,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", ""),
|
||||
@@ -203,4 +471,95 @@ 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
|
||||
|
||||
@@ -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
@@ -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.17.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": APP_VERSION},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+124
@@ -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
|
||||
@@ -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 "—"
|
||||
+343
-42
@@ -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,25 +470,72 @@ 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)."""
|
||||
key = "user:starred"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
repos = await self._get_all_pages("/user/starred")
|
||||
self._cache_put(key, repos)
|
||||
return repos
|
||||
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."""
|
||||
@@ -348,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:
|
||||
@@ -361,6 +651,17 @@ 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:
|
||||
|
||||
@@ -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()
|
||||
+4001
-428
File diff suppressed because it is too large
Load Diff
+984
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
"""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 officielles **prod ET dev, listées
|
||||
explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus les
|
||||
origines de la console Spark** (``AGENT_ORIGINS`` : agent prod ET
|
||||
dev — fédération de la recherche depuis la console, 2026-10-28),
|
||||
**plus** les
|
||||
origines résolues par ``app.config`` — jamais ``*``. Correctif
|
||||
2026-10-28 (fusion #68/#69, matrice constatée par Bruno) :
|
||||
l'ancienne allowlist ne contenait que l'environnement résolu
|
||||
(prod OU dev), donc une page de l'autre environnement voyait sa
|
||||
section afficher « Injoignable depuis cet hôte (CORS ou réseau) ».
|
||||
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",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-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)
|
||||
# Correctif 2026-10-28 : prod ET dev toujours, quelle que soit
|
||||
# la résolution — ``resolved`` n'ajoute que des origines de
|
||||
# configuration sans jamais retirer une origine officielle.
|
||||
return frozenset(origins) | PROD_ORIGINS | DEV_ORIGINS | AGENT_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
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
@@ -13,14 +13,14 @@
|
||||
{% if issue.labels %}
|
||||
<div class="card-labels">
|
||||
{% for label in issue.labels %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
|
||||
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if issue._code %}
|
||||
<div class="card-code">⑂{% if issue._code.branches %} 🌿 {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</div>
|
||||
<div class="card-code">⑂{% if issue._code.branches %} {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</div>
|
||||
{% endif %}
|
||||
<div class="card-meta">
|
||||
{% if issue.assignee %}
|
||||
@@ -30,8 +30,8 @@
|
||||
{% set age = ages.get(issue.number) %}
|
||||
{% if age and age.days >= aging_days %}
|
||||
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
|
||||
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
|
||||
title="Dans cette colonne depuis le {{ age.since[:10] }}">{{ age.days }} j</span>
|
||||
{% endif %}
|
||||
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
|
||||
<button type="button" class="pin-btn btn-icon" title="Épingler au Focus du jour (/today)" aria-label="Épingler au Focus du jour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 4h6l-1 8 3 3H7l3-3z"/><path d="M12 15v5"/><path d="M9 20h6"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'application" title="Changer d'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>
|
||||
|
||||
+95
-22
@@ -1,3 +1,16 @@
|
||||
<script>
|
||||
/* Thème (#52) : appliqué avant le rendu de la barre, pour
|
||||
éviter le flash sombre quand l'utilisateur a choisi le
|
||||
clair. Défaut = sombre (les gabarits portent
|
||||
data-theme="dark") ; seul un choix mémorisé bascule. */
|
||||
try {
|
||||
var _t = localStorage.getItem('kanbante-theme');
|
||||
if (_t === 'light' || _t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', _t);
|
||||
}
|
||||
} catch (e) { /* mode privé : le thème ne survit qu'à la page */ }
|
||||
</script>
|
||||
{% 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é),
|
||||
@@ -6,18 +19,6 @@
|
||||
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. #}
|
||||
<script>
|
||||
document.body.classList.add('with-sidebar');
|
||||
try {
|
||||
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
}
|
||||
JSON.parse(localStorage.getItem('kanbante-nav-closed') || '[]')
|
||||
.forEach(function (k) {
|
||||
document.body.classList.add('nav-closed-' + k);
|
||||
});
|
||||
} catch (e) { /* stockage indisponible : état par défaut */ }
|
||||
</script>
|
||||
{% set nav_groups = [
|
||||
("principal", "Principal", [
|
||||
("home", "/", "🏠", "Projets"),
|
||||
@@ -31,25 +32,84 @@
|
||||
("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"),
|
||||
("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="logo-mark"><svg viewBox="0 0 24 24" fill="#fff" aria-hidden="true"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="9.5" y="4" width="5" height="10" rx="1"/><rect x="16" y="4" width="5" height="13" rx="1"/></svg></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-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg></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 %}
|
||||
{# Icônes de navigation (#54) : SVG fins en trait, style rail
|
||||
Spark — monochromes (currentColor), pas d'emoji. #}
|
||||
{% macro nav_icon(key) %}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{% if key == 'home' %}<path d="M3 11l9-8 9 8"/><path d="M5 10v11h14V10"/><path d="M10 21v-6h4v6"/>{% elif key == 'focus' %}<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1" fill="currentColor"/>{% elif key == 'today' %}<path d="M7 18a5 5 0 0 1 10 0"/><path d="M3 18h18"/><path d="M12 9V6"/><path d="M5.5 11l1.5 1.5"/><path d="M18.5 11l-1.5 1.5"/>{% elif key == 'my' %}<circle cx="12" cy="8" r="3.5"/><path d="M4 20c0-3.5 3.5-5.5 8-5.5s8 2 8 5.5"/>{% elif key == 'briefing' %}<path d="M4 20V11"/><path d="M10 20V4"/><path d="M16 20v-8"/><path d="M3 20h18"/>{% elif key == 'standup' %}<path d="M4 5h16v11h-7l-4 4v-4H4z"/>{% elif key == 'digest' %}<path d="M6 3h12v18H6z"/><path d="M9 8h6"/><path d="M9 12h6"/><path d="M9 16h4"/>{% elif key == 'inflight' %}<path d="M3 12l19-9-7 18-4-6z"/><path d="M11 15L22 3"/>{% elif key == 'forecast' %}<path d="M3 18l5-6 4 3 9-9"/><path d="M16 6h5v5"/>{% elif key == 'health' %}<path d="M12 20C7 16 3 13 3 9a4.5 4.5 0 0 1 8-2.5A4.5 4.5 0 0 1 21 9c0 4-4 7-9 11z"/>{% elif key == 'rules' %}<path d="M6 3h12v18H6z"/><path d="M9 12l2 2 4-4"/>{% elif key == 'activity' %}<path d="M4 4h16v16H4z"/><path d="M7 8h10"/><path d="M7 12h10"/><path d="M7 16h6"/>{% elif key == 'search' %}<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>{% elif key == 'capture' %}<path d="M13 2L4 14h6l-1 8 10-13h-6z"/>{% elif key == 'archives' %}<path d="M3 8h18"/><path d="M5 8v12h14V8"/><path d="M9 8v5h6V8"/>{% elif key == 'wall' %}<path d="M3 5h18v11H3z"/><path d="M9 21h6"/><path d="M12 16v5"/>{% elif key == 'admin' %}<path d="M4 4h7v7H4z"/><path d="M13 4h7v7h-7z"/><path d="M4 13h7v7H4z"/><path d="M13 13h7v7h-7z"/>{% elif key == 'admin_connections' %}<path d="M9 3v5"/><path d="M15 3v5"/><path d="M7 8h10v5a5 5 0 0 1-10 0z"/><path d="M12 18v3"/>{% elif key == 'admin_workspaces' %}<circle cx="12" cy="12" r="9"/><path d="M15 9l-2 4-4 2 2-4z"/>{% elif key == 'admin_columns' %}<path d="M4 4h4v16H4z"/><path d="M10 4h4v16h-4z"/><path d="M16 4h4v16h-4z"/>{% elif key == 'admin_settings' %}<circle cx="12" cy="12" r="3"/><path d="M12 2v3"/><path d="M12 19v3"/><path d="M2 12h3"/><path d="M19 12h3"/><path d="M4.5 4.5l2 2"/><path d="M17.5 17.5l2 2"/><path d="M19.5 4.5l-2 2"/><path d="M6.5 17.5l-2 2"/>{% else %}<circle cx="12" cy="12" r="9"/>{% endif %}</svg>{% endmacro %}
|
||||
<nav class="side-nav">
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
<div class="side-section" data-group="{{ gkey }}">
|
||||
@@ -61,21 +121,35 @@
|
||||
<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-ico">{{ nav_icon(key) }}</span>
|
||||
<span class="side-label">{{ label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if gkey == 'admin' %}
|
||||
<button type="button" class="side-link theme-toggle-link" id="theme-toggle" title="Basculer entre thème sombre et clair" aria-label="Basculer entre thème sombre et clair">
|
||||
<span class="side-ico" data-theme-ico>☀️</span>
|
||||
<span class="side-label" data-theme-label>Mode clair</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<button type="button" class="side-collapse" id="side-collapse" aria-label="Replier ou déplier le menu">
|
||||
<span class="side-collapse-ico">«</span>
|
||||
<span class="side-label">Replier</span>
|
||||
</button>
|
||||
{% 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 not is_admin %}
|
||||
<button type="button" class="theme-toggle-foot" id="theme-toggle" title="Basculer entre thème sombre et clair" aria-label="Basculer entre thème sombre et clair"><span data-theme-ico>☀️</span></button>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
@@ -90,7 +164,6 @@
|
||||
{% 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>
|
||||
<script src="/static/js/nav.js?v={{ version }}" defer></script>
|
||||
</header>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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:<slug>/<nom>) <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:<slug>/<nom>) <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:<slug>/<nom></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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -5,11 +5,11 @@
|
||||
<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>
|
||||
{% include "_header.html" %}
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<!-- Toolbar: text + label filters, and issue creation (Gitea-backed) -->
|
||||
<div class="board-toolbar">
|
||||
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes... ( / )">
|
||||
<input type="text" id="card-filter" placeholder="Filtrer les cartes... ( / )">
|
||||
<select id="label-filter">
|
||||
<option value="">Toutes les étiquettes</option>
|
||||
{% for label in labels %}
|
||||
@@ -52,17 +52,32 @@
|
||||
{% endfor %}
|
||||
</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>
|
||||
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">Triage du backlog</a>
|
||||
{% 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] }}">
|
||||
@@ -145,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">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>⚡ Capture éclair</h1>
|
||||
<h1>Capture éclair</h1>
|
||||
<p>Une idée, un bogue, une pensée — capturés en deux secondes, ils deviennent une issue dans le bon backlog.</p>
|
||||
</section>
|
||||
|
||||
@@ -37,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>
|
||||
@@ -47,8 +52,8 @@
|
||||
</label>
|
||||
<label>Colonne
|
||||
<select name="column">
|
||||
<option value="backlog" selected>📥 Backlog</option>
|
||||
<option value="todo">📋 À faire</option>
|
||||
<option value="backlog" selected>Backlog</option>
|
||||
<option value="todo">À faire</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save">⚡ Capturer</button>
|
||||
@@ -84,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>
|
||||
|
||||
+157
-40
@@ -5,61 +5,115 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kanbante — Dashboard</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 }}">
|
||||
<script defer src="/static/vendor/alpine.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{% 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>
|
||||
<div class="card-menu-sep" role="separator"></div>
|
||||
<button type="button" class="card-menu-item" data-copy-url="{{ repo.html_url }}" title="Copier l'URL Gitea de {{ repo.name }}" aria-label="Copier l'URL du projet {{ repo.name }}">🔗 Copier l'URL du projet</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3>
|
||||
@@ -79,27 +133,27 @@
|
||||
{% set ms = repo.next_milestone %}
|
||||
<div class="ms-block">
|
||||
<div class="ms-line">
|
||||
<span class="ms-title">🎯 {{ ms.title }}</span>
|
||||
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
|
||||
<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>
|
||||
<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>
|
||||
<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] }}">
|
||||
<span class="chip" style="background: color-mix(in srgb, {{ COLUMN_COLORS[col] }} 25%, transparent); color: var(--text)">
|
||||
{{ n }}
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -107,9 +161,62 @@
|
||||
</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 %}
|
||||
|
||||
{% 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>
|
||||
@@ -121,23 +228,31 @@
|
||||
<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>
|
||||
<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" 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>
|
||||
<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" 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>
|
||||
<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>
|
||||
@@ -146,6 +261,8 @@
|
||||
<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?v={{ version }}" 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 || '')) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🧠 Digest hebdo → ObsiGate</h1>
|
||||
<h1>Digest hebdo → ObsiGate</h1>
|
||||
<p>Chaque semaine, Kanbante compose le résumé de l'activité (livré, en cours, chiffres du journal) en une note markdown.
|
||||
L'envoi vers ObsiGate est une écriture dans un autre système : <strong>il n'a jamais lieu sans ton approbation ici</strong>.</p>
|
||||
{% if last_result %}<p>🧾 Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% include "_header.html" %}
|
||||
<main class="error-main">
|
||||
<div class="error-box">
|
||||
<h2>⚠️ Quelque chose s'est mal passé</h2>
|
||||
<h2>Quelque chose s'est mal passé</h2>
|
||||
<p>{{ message }}</p>
|
||||
<p><a href="/" class="btn-sm">← Retour au dashboard</a></p>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
{% 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>
|
||||
<div class="page-head"><div class="page-title"><h1>Focus</h1><p class="page-sub">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p></div></div>
|
||||
{% if not groups %}
|
||||
<p class="focus-empty">🎉 Rien en cours ni en révision pour le moment.</p>
|
||||
<p class="focus-empty">Rien en cours ni en révision pour le moment.</p>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="kanban-card focus-card">
|
||||
<div class="card-id">
|
||||
#{{ card.issue.number }}
|
||||
<span class="chip" style="background: {{ column_colors[card.column] }}20; color: {{ column_colors[card.column] }}">
|
||||
<span class="chip" style="background: color-mix(in srgb, {{ column_colors[card.column] }} 25%, transparent); color: var(--text)">
|
||||
{{ column_labels[card.column] }}
|
||||
</span>
|
||||
{% if card.issue.html_url %}
|
||||
@@ -37,7 +37,7 @@
|
||||
{% 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 }}">
|
||||
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
|
||||
+79
-19
@@ -12,14 +12,73 @@
|
||||
|
||||
<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>
|
||||
<h1>Prévisions — « on finit quand ? »</h1>
|
||||
<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 %}
|
||||
@@ -30,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>
|
||||
@@ -56,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>
|
||||
|
||||
+37
-15
@@ -12,24 +12,45 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 Santé du flux</h1>
|
||||
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
|
||||
<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>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
|
||||
{% if project.slowest %}
|
||||
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
|
||||
{% else %}
|
||||
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
|
||||
{% endif %}
|
||||
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
|
||||
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></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 %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
<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>
|
||||
@@ -37,5 +58,6 @@
|
||||
{% 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>
|
||||
|
||||
@@ -11,14 +11,26 @@
|
||||
{% 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>
|
||||
<h1>{{ full_name }}</h1>
|
||||
<p>Projet introuvable.</p>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
<h1>{{ full_name }}</h1>
|
||||
<h2>⏱️ Temps passé par colonne (journal)</h2>
|
||||
{% if health.dwell %}
|
||||
<table class="health-table">
|
||||
@@ -59,7 +71,7 @@
|
||||
</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>
|
||||
<p>Aucune carte zombie. Le flux est propre. </p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -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: color-mix(in srgb, {{ column_colors[group.column] }} 25%, transparent); color: var(--text)">
|
||||
{{ 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: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">
|
||||
{{ 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>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script>try{var t=localStorage.getItem("kanbante-theme");document.documentElement.setAttribute("data-theme",t==="light"?"light":"dark");}catch(e){document.documentElement.setAttribute("data-theme","dark");}</script>
|
||||
<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"><svg viewBox="0 0 24 24" fill="#fff" aria-hidden="true" style="width:20px;height:20px;display:block"><rect x="3" y="4" width="5" height="16" rx="1"/><rect x="9.5" y="4" width="5" height="10" rx="1"/><rect x="16" y="4" width="5" height="13" rx="1"/></svg></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>
|
||||
+60
-8
@@ -5,34 +5,85 @@
|
||||
<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>
|
||||
{% 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>
|
||||
<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>
|
||||
<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 }}
|
||||
</a>
|
||||
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
|
||||
<span class="col-chip" style="background: color-mix(in srgb, {{ column_colors[row.column] }} 25%, transparent); color: var(--text)">{{ column_labels[row.column] }}</span>
|
||||
{% for label in issue.labels or [] %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
|
||||
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
|
||||
</div>
|
||||
@@ -46,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -5,7 +5,9 @@
|
||||
<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>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<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" %}
|
||||
@@ -13,7 +15,7 @@
|
||||
<main class="project-main">
|
||||
<div class="proj-head">
|
||||
<div>
|
||||
<h1>📦 {{ repo_info.name }}</h1>
|
||||
<h1>{{ repo_info.name }}</h1>
|
||||
<p class="proj-sub">{{ owner }} · {{ repo_info.description or 'Aucune description dans Gitea.' }}</p>
|
||||
</div>
|
||||
{% if health_dot %}
|
||||
@@ -24,9 +26,9 @@
|
||||
</div>
|
||||
|
||||
<div class="proj-actions">
|
||||
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">📋 Ouvrir le board</a>
|
||||
<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="/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>
|
||||
@@ -43,7 +45,7 @@
|
||||
<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 %}
|
||||
{% 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>
|
||||
@@ -51,18 +53,46 @@
|
||||
{% 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. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
|
||||
<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>
|
||||
<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 %}
|
||||
@@ -124,6 +154,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<!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 }}"
|
||||
data-base-console="{{ agent_url }}">
|
||||
<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. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</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>
|
||||
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
|
||||
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
|
||||
</nav>
|
||||
<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>
|
||||
<section class="rp-section" data-rp-section="console">
|
||||
<div class="rp-head"><h2>Console Spark — discussions</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>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>⚙️ Règles d'automatisation</h1>
|
||||
<h1>Règles d'automatisation</h1>
|
||||
<p>Des réflexes déclaratifs, évalués toutes les 15 minutes par le planificateur (et à la demande ci-dessous). Chaque action est journalisée comme événement <code>rule</code>. <strong>Garde-fou v1 : une règle ne peut ni fermer ni déplacer une carte entre colonnes</strong> — seulement étiqueter, notifier (ntfy) et remonter en tête de colonne.</p>
|
||||
{% if not configured %}
|
||||
<p>Aucune règle configurée. Définis <code>KANBANTE_RULES</code> en JSON, par exemple :</p>
|
||||
@@ -25,7 +25,7 @@
|
||||
<p class="notes-hint">Conditions : <code>column</code>, <code>min_age_days</code> (temps dans la colonne), <code>label</code> (présente), <code>wip_over</code> (colonne au-delà de N cartes). Actions : <code>add_label</code>, <code>notify</code>, <code>move_top</code>.</p>
|
||||
{% endif %}
|
||||
{% for err in errors %}
|
||||
<p class="chip-warn">⚠️ {{ err }}</p>
|
||||
<p class="chip-warn">{{ err }}</p>
|
||||
{% endfor %}
|
||||
<p><button type="button" class="btn-save btn-inline" id="rules-run">▶️ Évaluer maintenant</button>
|
||||
<span id="rules-run-result" class="notes-hint"></span></p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
{% if q %}
|
||||
<p class="focus-intro">{{ total }} résultat(s) pour « {{ q }} » — recherche Gitea, tous projets confondus.</p>
|
||||
{% if not groups %}
|
||||
<div class="empty-state">🔍 Aucune issue ne correspond. Essayez un mot plus court ou vérifiez l'orthographe.</div>
|
||||
<div class="empty-state">Aucune issue ne correspond. Essayez un mot plus court ou vérifiez l'orthographe.</div>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
@@ -34,9 +34,9 @@
|
||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||
</a>
|
||||
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
|
||||
<span class="col-chip" style="background: color-mix(in srgb, {{ column_colors[row.column] }} 25%, transparent); color: var(--text)">{{ column_labels[row.column] }}</span>
|
||||
{% for label in issue.labels or [] %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
|
||||
<span class="label-tag" style="background: color-mix(in srgb, #{{ label.color }} 25%, transparent); color: var(--text)">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">🔍 Tapez un mot-clé : la recherche couvre les titres et corps d'issues de <strong>tous</strong> vos projets Gitea, ouverts comme fermés.</div>
|
||||
<div class="empty-state">Tapez un mot-clé : la recherche couvre les titres et corps d'issues de <strong>tous</strong> vos projets Gitea, ouverts comme fermés.</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<main class="focus-main">
|
||||
{% if markdown %}
|
||||
<section class="briefing-card">
|
||||
<h1>📖 {{ milestone_title }}</h1>
|
||||
<h1>{{ milestone_title }}</h1>
|
||||
<p>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-copy">📋 Copier le markdown</button>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-download">⬇️ Télécharger (.md)</button>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-copy">Copier le markdown</button>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-download">Télécharger (.md)</button>
|
||||
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
|
||||
</p>
|
||||
<p class="notes-hint">Le texte est archivable tel quel dans ObsiGate — c'est le markdown ci-dessous, rendu pour lecture.</p>
|
||||
@@ -45,7 +45,7 @@
|
||||
</script>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<h1>📖 Récit de sprint — {{ full_name }}</h1>
|
||||
<h1>Récit de sprint — {{ full_name }}</h1>
|
||||
<p>Choisis un milestone pour générer son récit :</p>
|
||||
<ul>
|
||||
{% for m in milestones %}
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🗣️ Standup automatique</h1>
|
||||
<h1>Standup automatique</h1>
|
||||
<p>Écrit par le journal, pas par la mémoire : fermé/déplacé <strong>hier</strong>, focus <strong>aujourd'hui</strong>, cartes qui vieillissent en QA/Révision en <strong>blocages</strong>.</p>
|
||||
<p>
|
||||
<button type="button" class="btn-save btn-inline" id="standup-copy">📋 Copier le standup</button>
|
||||
<button type="button" class="btn-save btn-inline" id="standup-copy">Copier le standup</button>
|
||||
{% if include_in_briefing %}
|
||||
<span class="chip-ok">Inclus dans le briefing ntfy du matin ✔</span>
|
||||
{% else %}
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="today-main">
|
||||
<h1 class="today-title">🌅 {{ date_label }}</h1>
|
||||
<p class="today-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p>
|
||||
<div class="page-head"><div class="page-title"><h1 class="today-title">{{ date_label }}</h1><p class="page-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p></div></div>
|
||||
|
||||
{% if picks %}
|
||||
<section class="today-picks">
|
||||
@@ -22,18 +21,18 @@
|
||||
<span class="today-card-repo">{{ pick.repo }}</span>
|
||||
<span class="today-card-col">{{ pick.column_label }}</span>
|
||||
</div>
|
||||
<h2>{% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
|
||||
<h2>{% if pick.state == 'closed' %}{% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
|
||||
<div class="today-card-actions">
|
||||
<a href="/board/{{ pick.repo }}">📋 Ouvrir le board</a>
|
||||
<a href="/board/{{ pick.repo }}">Ouvrir le board</a>
|
||||
{% if pick.html_url %}<a href="{{ pick.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>{% endif %}
|
||||
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Retirer</button>
|
||||
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">Retirer</button>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.</p>
|
||||
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton épingler sur les cartes) ou depuis les candidates ci-dessous.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -55,7 +54,7 @@
|
||||
{% for pick in carry_over %}
|
||||
<li>
|
||||
<span>{{ pick.repo }} #{{ pick.number }} — {{ pick.title }} <em>({{ pick.column_label }})</em></span>
|
||||
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Reprendre aujourd'hui</button>{% endif %}
|
||||
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">Reprendre aujourd'hui</button>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -69,7 +68,7 @@
|
||||
{% for card in candidates %}
|
||||
<li>
|
||||
<span>{{ card.repo }} #{{ card.number }} — {{ card.title }} <em>({{ card.column_label }})</em></span>
|
||||
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">📌 Épingler</button>
|
||||
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">Épingler</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -12,20 +12,20 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>📥 Mode Triage</h1>
|
||||
<h1>Mode Triage</h1>
|
||||
<p>Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.</p>
|
||||
</section>
|
||||
{% if projects %}
|
||||
<div class="triage-projects">
|
||||
{% for p in projects %}
|
||||
<a class="triage-project" href="/triage/{{ p.full_name }}">
|
||||
<span class="triage-project-name">📂 {{ p.full_name }}</span>
|
||||
<span class="triage-project-name">{{ p.full_name }}</span>
|
||||
<span class="triage-project-count">{{ p.backlog }} carte{{ 's' if p.backlog > 1 }} au backlog</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
|
||||
<div class="briefing-card"><p>Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<main class="triage-main" data-repo="{{ full_name }}">
|
||||
<div class="triage-head">
|
||||
<h1>📥 Triage — {{ full_name }}</h1>
|
||||
<h1>Triage — {{ full_name }}</h1>
|
||||
<div class="triage-progress-wrap">
|
||||
<div class="triage-progress"><div class="triage-progress-bar" id="triage-bar"></div></div>
|
||||
<span class="triage-progress-label" id="triage-progress-label"></span>
|
||||
@@ -32,10 +32,10 @@
|
||||
</section>
|
||||
|
||||
<section class="triage-done" id="triage-done" hidden>
|
||||
<h2>🎉 Session terminée</h2>
|
||||
<h2>Session terminée</h2>
|
||||
<ul class="triage-summary" id="triage-summary"></ul>
|
||||
<p>
|
||||
<a class="btn-save btn-inline" href="/board/{{ full_name }}">📋 Retour au board</a>
|
||||
<a class="btn-save btn-inline" href="/board/{{ full_name }}">Retour au board</a>
|
||||
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">🔁 Nouvelle session</a>
|
||||
</p>
|
||||
</section>
|
||||
@@ -43,9 +43,9 @@
|
||||
<div class="triage-status" id="triage-status" role="status"></div>
|
||||
|
||||
<section class="triage-keys" id="triage-keys">
|
||||
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> 📋 À faire</button>
|
||||
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> À faire</button>
|
||||
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> 🚧 En cours</button>
|
||||
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> 🧪 QA</button>
|
||||
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> QA</button>
|
||||
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> 👀 Révision</button>
|
||||
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> ✅ Terminé</button>
|
||||
<button type="button" data-act="labels"><kbd>L</kbd> 🏷️ Étiquettes</button>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>try{var t=localStorage.getItem("kanbante-theme");document.documentElement.setAttribute("data-theme",t==="light"?"light":"dark");}catch(e){document.documentElement.setAttribute("data-theme","dark");}</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kanbante — Wallboard</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
@@ -13,7 +14,7 @@
|
||||
<section class="wall-slide{% if loop.first %} active{% endif %}">
|
||||
<header class="wall-head">
|
||||
<div>
|
||||
<div class="wall-kicker">📂 Projet</div>
|
||||
<div class="wall-kicker">Projet</div>
|
||||
<h1 class="wall-project">{{ slide.full_name }}</h1>
|
||||
</div>
|
||||
<div class="wall-clock">
|
||||
@@ -41,7 +42,7 @@
|
||||
<span class="wall-card-col" style="--col: {{ column_colors[card.column] }}">{{ card.column_label }}</span>
|
||||
<span class="wall-card-title">#{{ card.number }} · {{ card.title }}</span>
|
||||
{% if card.age_days is not none and card.age_days >= 1 %}
|
||||
<span class="wall-card-age{% if card.aging %} hot{% endif %}">⏳ {{ card.age_days }} j</span>
|
||||
<span class="wall-card-age{% if card.aging %} hot{% endif %}">{{ card.age_days }} j</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -50,12 +51,12 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
|
||||
<p class="wall-idle">Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
|
||||
{% endif %}
|
||||
|
||||
<footer class="wall-foot">
|
||||
<span>✅ Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
|
||||
<span>📋 Ouvertes : <strong>{{ slide.open_total }}</strong></span>
|
||||
<span>Ouvertes : <strong>{{ slide.open_total }}</strong></span>
|
||||
<span class="wall-live">● EN DIRECT</span>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -71,7 +72,7 @@
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="wall-empty">
|
||||
<h1>🖥️ Wallboard</h1>
|
||||
<h1>Wallboard</h1>
|
||||
<p>Aucun projet actif à afficher{% if single %} pour ce dépôt{% endif %}.</p>
|
||||
<p class="wall-empty-hint">Astuce : <code>/wall?repo=Proprietaire/depot</code> fige un seul projet.</p>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.17.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]
|
||||
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,90 @@
|
||||
/* 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; }
|
||||
|
||||
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||
.rp-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid #888; margin-bottom: 20px;
|
||||
}
|
||||
.rp-tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
|
||||
color: inherit; opacity: .7; white-space: nowrap;
|
||||
}
|
||||
.rp-tab:hover { opacity: 1; }
|
||||
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
|
||||
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
.rp-tab-count {
|
||||
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
|
||||
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
||||
}
|
||||
.rp-tab-count:empty { display: none; }
|
||||
.rp-section[hidden] { display: none; }
|
||||
.rp-all[hidden] { display: none; }
|
||||
|
||||
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
|
||||
* Cause : recherche.html étend base.html sans layout_class, donc
|
||||
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
|
||||
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
|
||||
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
|
||||
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
|
||||
* la largeur disponible, sans toucher aux autres pages (elles ne
|
||||
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
|
||||
* la grille n'a qu'une colonne : la page la prend entièrement. */
|
||||
.layout > .rp-page {
|
||||
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.layout > .rp-page { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* ---- ObsiGate : navigation statique de la page autonome (chantier
|
||||
* 2+3). La page /recherche est servie hors SPA (route avant le
|
||||
* catch-all) : elle reprend les classes de l'app (style.css :
|
||||
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
|
||||
* .content-area) et cette navigation, calée sur les variables de
|
||||
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
|
||||
* la SPA (voir le commentaire dans recherche.html). */
|
||||
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
|
||||
.rp-og-nav a {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
|
||||
.rp-og-nav a[aria-current="page"] {
|
||||
background: rgba(127, 127, 127, .18); font-weight: 700;
|
||||
}
|
||||
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
|
||||
+701
-162
File diff suppressed because it is too large
Load Diff
+153
-6
@@ -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) {
|
||||
@@ -432,6 +451,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -457,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 = '';
|
||||
@@ -520,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';
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
|
||||
/* ── Copier l'URL du projet (#44) ─────────────────────── */
|
||||
function showCopyToast(message) {
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'kanbante-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function () { toast.remove(); }, 2600);
|
||||
}
|
||||
function copyTextFallback(text) {
|
||||
var area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.style.cssText = 'position:fixed;opacity:0;pointer-events:none;';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
|
||||
area.remove();
|
||||
return ok;
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text).then(
|
||||
function () { return true; },
|
||||
function () { return copyTextFallback(text); }
|
||||
);
|
||||
}
|
||||
return Promise.resolve(copyTextFallback(text));
|
||||
}
|
||||
document.querySelectorAll('[data-copy-url]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var url = btn.getAttribute('data-copy-url') || '';
|
||||
if (!url) return;
|
||||
copyText(url).then(function (ok) {
|
||||
closeMenus();
|
||||
showCopyToast(ok ? 'URL du projet copiée' : 'La copie a échoué');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
|
||||
partagé avec /forecast et /health (0.19.0). */
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """ }[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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -5,6 +5,15 @@
|
||||
(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]); });
|
||||
|
||||
+190
@@ -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();
|
||||
})();
|
||||
+47
-31
@@ -34,6 +34,35 @@
|
||||
if (window.innerWidth > 900) closeDrawer();
|
||||
});
|
||||
|
||||
/* Bascule Dark/Light (#52) : le choix est mémorisé et
|
||||
l'amorce de _header.html l'a déjà appliqué avant le rendu.
|
||||
Ici, on ne fait que synchroniser le libellé et le clic. */
|
||||
var themeBtn = document.getElementById('theme-toggle');
|
||||
function syncThemeBtn(theme) {
|
||||
if (!themeBtn) return;
|
||||
var light = theme === 'light';
|
||||
/* Bouton dans la section Administration (#55) : icône et
|
||||
libellé sont des <span> séparés ; l'ancien bouton de
|
||||
l'en-tête, lui, n'avait qu'un texte. */
|
||||
var ico = themeBtn.querySelector('[data-theme-ico]');
|
||||
var lbl = themeBtn.querySelector('[data-theme-label]');
|
||||
if (ico) ico.textContent = light ? '🌙' : '☀️';
|
||||
if (lbl) lbl.textContent = light ? 'Mode sombre' : 'Mode clair';
|
||||
if (!ico && !lbl) {
|
||||
themeBtn.textContent = light ? '🌙 Mode sombre' : '☀️ Mode clair';
|
||||
}
|
||||
themeBtn.setAttribute('aria-pressed', light ? 'true' : 'false');
|
||||
}
|
||||
if (themeBtn) {
|
||||
syncThemeBtn(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||
themeBtn.addEventListener('click', function () {
|
||||
var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('kanbante-theme', next); } catch (e) { /* non-fatal */ }
|
||||
syncThemeBtn(next);
|
||||
});
|
||||
}
|
||||
|
||||
if (collapseBtn) {
|
||||
collapseBtn.addEventListener('click', function () {
|
||||
var collapsed = document.body.classList.toggle('sidebar-collapsed');
|
||||
@@ -43,46 +72,33 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* Accordion sections: the closed set lives in body classes
|
||||
(applied pre-paint by the bootstrap script in _header.html)
|
||||
and is persisted here on every toggle. */
|
||||
/* 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 persistClosedGroups() {
|
||||
var closed = [];
|
||||
groupBtns.forEach(function (btn) {
|
||||
if (document.body.classList.contains('nav-closed-' + btn.getAttribute('data-group-btn'))) {
|
||||
closed.push(btn.getAttribute('data-group-btn'));
|
||||
}
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('kanbante-nav-closed', JSON.stringify(closed));
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
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 () {
|
||||
document.body.classList.toggle(
|
||||
'nav-closed-' + btn.getAttribute('data-group-btn'));
|
||||
syncGroupBtn(btn);
|
||||
persistClosedGroups();
|
||||
var key = btn.getAttribute('data-group-btn');
|
||||
var isOpen = !document.body.classList.contains('nav-closed-' + key);
|
||||
setOpenGroup(isOpen ? '' : key);
|
||||
});
|
||||
});
|
||||
/* The section holding the current page is always opened. */
|
||||
var activeLink = document.querySelector('.sidebar .side-link.active');
|
||||
if (activeLink) {
|
||||
var section = activeLink.closest('.side-section');
|
||||
if (section) {
|
||||
var key = section.getAttribute('data-group');
|
||||
if (document.body.classList.contains('nav-closed-' + key)) {
|
||||
document.body.classList.remove('nav-closed-' + key);
|
||||
groupBtns.forEach(syncGroupBtn);
|
||||
persistClosedGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
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' },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
})();
|
||||
@@ -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); });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
+135
-7
@@ -66,8 +66,13 @@ 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)),
|
||||
@@ -136,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))
|
||||
|
||||
@@ -174,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), []))
|
||||
|
||||
@@ -271,6 +330,26 @@ class FakeGitea:
|
||||
},
|
||||
]
|
||||
|
||||
# ── 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]
|
||||
|
||||
@@ -280,15 +359,64 @@ class FakeGitea:
|
||||
else:
|
||||
self.starred.discard(f"{owner}/{repo}")
|
||||
|
||||
async def get_milestones(self, owner, repo, state="open"):
|
||||
return [
|
||||
@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)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/* 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">&') === "<a href="x">&", "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)");
|
||||
|
||||
|
||||
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
|
||||
|
||||
/* parseTab */
|
||||
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
|
||||
ok(rp.parseTab("console") === "console", "onglet console valide");
|
||||
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
|
||||
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
|
||||
|
||||
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
|
||||
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
|
||||
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
|
||||
|
||||
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
|
||||
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
|
||||
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
|
||||
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
|
||||
|
||||
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
|
||||
let c = rp.tabCounts({
|
||||
obsigate: { status: "loading", total: 0 },
|
||||
flowdeck: { status: "ok", total: 3 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.obsigate === "…", "compteur … pendant le chargement");
|
||||
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
|
||||
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
|
||||
ok(c.all === "…", "Tout = … tant qu'une section charge");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "ok", total: 5 },
|
||||
flowdeck: { status: "ok", total: 2 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "ok", total: 4 },
|
||||
});
|
||||
ok(c.all === "7", "Tout = somme des totaux connus");
|
||||
ok(c.console === "4", "compteur console");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "error", total: 0 },
|
||||
flowdeck: { status: "error", total: 0 },
|
||||
tiktak: { status: "error", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
|
||||
|
||||
/* buildSearchUrl : état onglet partageable */
|
||||
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
|
||||
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
|
||||
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
|
||||
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
|
||||
|
||||
/* searchPath complet (onglet) vs tronqué (Tout) */
|
||||
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
|
||||
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
|
||||
|
||||
/* normalize complet vs tronqué */
|
||||
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
|
||||
|
||||
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
|
||||
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
|
||||
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
|
||||
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
|
||||
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
|
||||
|
||||
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
|
||||
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
|
||||
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
|
||||
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
|
||||
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
|
||||
|
||||
|
||||
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
|
||||
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
|
||||
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
|
||||
let rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
|
||||
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
|
||||
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
|
||||
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
|
||||
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
|
||||
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
|
||||
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
|
||||
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
|
||||
] }, "https://agent-dev.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
|
||||
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
|
||||
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
|
||||
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
|
||||
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
|
||||
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
|
||||
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
|
||||
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
|
||||
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
|
||||
rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
|
||||
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
|
||||
] }, "https://agent.dracodev.net/");
|
||||
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
|
||||
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
|
||||
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
|
||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||
|
||||
console.log("recherche.test.js : " + n + " assertions OK");
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 "")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Menu ⋯ des tuiles projets : copie d'URL (#44) et thème
|
||||
opaque barre Fusion / Spark, sombre et clair (#45).
|
||||
|
||||
Le menu était transparent parce que son fond utilisait
|
||||
var(--surface-1), variable jamais définie dans :root.
|
||||
"""
|
||||
|
||||
|
||||
def test_card_menu_offers_copy_url(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "Copier l'URL du projet" in page.text
|
||||
assert 'data-copy-url="http://gitea.test/Projets/demo"' in page.text
|
||||
|
||||
|
||||
def test_card_menu_theme_is_opaque_and_dual(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
# Le menu ne repose plus sur la variable indéfinie.
|
||||
menu = css.split(".card-menu {", 1)[1].split("}", 1)[0]
|
||||
assert "var(--surface-1)" not in menu
|
||||
assert "var(--menu-bg)" in menu
|
||||
# Jetons barre Fusion / Console v7.2, clair par défaut…
|
||||
assert "--menu-bg: #ffffff" in css
|
||||
# …et variante sombre explicite, comme draco-barre.css.
|
||||
assert '[data-theme="dark"] .card-menu' in css
|
||||
assert "--menu-bg: #1a1c1a" in css
|
||||
assert "prefers-color-scheme: dark" in css
|
||||
|
||||
|
||||
def test_dashboard_js_copy_contract(client, fake_gitea):
|
||||
js = client.get("/static/js/dashboard.js").text
|
||||
assert "data-copy-url" in js
|
||||
assert "navigator.clipboard" in js
|
||||
assert "execCommand('copy')" in js
|
||||
assert "URL du projet copiée" in js
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Page Projets (#47) : les fichiers qui portent le menu ⋯ et la
|
||||
copie d'URL doivent être versionnés, sinon le navigateur garde
|
||||
l'ancien CSS/JS après une release alors que le HTML est neuf —
|
||||
c'est exactement ce qui a fait paraître la 0.38.0 inefficace
|
||||
(menu toujours transparent, bouton de copie présent mais inerte).
|
||||
"""
|
||||
|
||||
|
||||
def test_dashboard_static_assets_are_versioned(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert "/static/css/style.css?v=" in page.text
|
||||
assert "/static/js/dashboard.js?v=" in page.text
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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
|
||||
# Correctif 2026-10-28 : une origine sœur dev passe aussi.
|
||||
r4 = client.options("/api/events",
|
||||
headers={"Origin": "https://tiktak-dev.dracodev.net",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert r4.status_code == 200
|
||||
assert r4.headers.get("access-control-allow-origin") == "https://tiktak-dev.dracodev.net"
|
||||
assert r4.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Identité Kanbante (#54, #55) : sidebar charbon (pas bleutée),
|
||||
accent vert Kanbante (pas rose Spark), icônes de navigation en
|
||||
trait (pas d'emoji), bouton Dark/Light dans la section
|
||||
Administration (pas dans l'en-tête).
|
||||
"""
|
||||
|
||||
|
||||
def test_sidebar_charbon_et_accent_vert(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert "rgba(26, 28, 26, 0.96)" in css
|
||||
assert "rgba(13, 16, 24" not in css
|
||||
assert "--accent: #0d9488;" in css
|
||||
assert "#c2437e" not in css
|
||||
|
||||
|
||||
def test_nav_icons_sont_des_svg(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'class="side-ico"><svg' in page.text
|
||||
# Le logo Kanbante est aussi un SVG (colonnes blanches).
|
||||
assert 'class="logo-mark"><svg' in page.text
|
||||
|
||||
|
||||
def test_theme_toggle_dans_admin_pas_dans_en_tete(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'id="theme-toggle"' in page.text
|
||||
head = page.text.split("header-actions", 1)[1].split("</header>", 1)[0]
|
||||
assert 'id="theme-toggle"' not in head
|
||||
# Placement administrateur : le bouton est dans le bloc de la
|
||||
# section Administration du gabarit (les non-admins, eux, le
|
||||
# voient dans le pied de la barre, la section n'existant pas
|
||||
# pour eux — sans quoi ils perdraient le basculeur).
|
||||
tpl = open("app/templates/_header.html", encoding="utf-8").read()
|
||||
admin_block = tpl.split("gkey == 'admin'", 1)[1]
|
||||
assert 'id="theme-toggle"' in admin_block
|
||||
assert "data-theme-label" in admin_block
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Navigation (#51) et bascule Dark/Light (#52).
|
||||
|
||||
L'accordéon de la barre latérale omettait la section admin :
|
||||
le JS posait nav-closed-admin, mais aucun style ne la suivait.
|
||||
Le thème clair, lui, doit être atteignable par un bouton et
|
||||
mémorisé, avec les jetons draco clairs.
|
||||
"""
|
||||
|
||||
|
||||
def test_admin_accordion_closes(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert 'body.nav-closed-admin [data-group="admin"] .side-acc' in css
|
||||
assert 'body.nav-closed-admin [data-group="admin"] .side-chevron' in css
|
||||
|
||||
|
||||
def test_theme_toggle_present_and_versioned(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'id="theme-toggle"' in page.text
|
||||
assert "/static/js/nav.js?v=" in page.text
|
||||
# #55 : le bouton n'est plus dans l'en-tête applicatif.
|
||||
head = page.text.split("header-actions", 1)[1].split("</header>", 1)[0]
|
||||
assert 'id="theme-toggle"' not in head
|
||||
|
||||
|
||||
def test_theme_js_contract(client, fake_gitea):
|
||||
js = client.get("/static/js/nav.js").text
|
||||
assert "kanbante-theme" in js
|
||||
assert "theme-toggle" in js
|
||||
assert "data-theme" in js
|
||||
|
||||
|
||||
def test_light_theme_tokens(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert 'html[data-theme="light"]' in css
|
||||
assert "--bg: #f2f2f0;" in css
|
||||
assert "--surface: #ffffff;" in css
|
||||
assert "--accent-2: #0f766e;" in css
|
||||
assert 'html[data-theme="light"] .draco-bar' in css
|
||||
assert 'html[data-theme="light"] .card-menu' in css
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_sidebar_on_app_pages(client, fake_gitea):
|
||||
# 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
|
||||
assert 'class="side-search palette-trigger"' 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).
|
||||
@@ -64,12 +64,13 @@ def test_nav_js_contract(client, fake_gitea):
|
||||
assert "localStorage" in js.text
|
||||
assert "side-backdrop" in js.text
|
||||
assert "Escape" in js.text
|
||||
assert "kanbante-nav-closed" in js.text # accordéons persistés
|
||||
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):
|
||||
async def fake_collect(only_repo, *args, **kwargs):
|
||||
return [{
|
||||
"full_name": "Projets/demo",
|
||||
"counts": {c: 0 for c in
|
||||
@@ -88,4 +89,4 @@ def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
||||
page = client.get("/wall")
|
||||
assert page.status_code == 200
|
||||
assert "+3 autres cartes en vol" in page.text
|
||||
assert "📋 Ouvertes" in page.text
|
||||
assert "Ouvertes" in page.text
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -197,6 +197,8 @@ def test_board_filter_by_milestone_id(client):
|
||||
def test_dashboard_uses_new_design_system(client):
|
||||
r = client.get("/")
|
||||
assert "app-header" in r.text
|
||||
assert "nav-pill" in r.text
|
||||
assert "stat-tile" in r.text
|
||||
assert "seg-tabs" in r.text
|
||||
# En-tête refondu en 0.20.0 : bande KPI unifiée (les stat-tile
|
||||
# ne servent plus qu'au briefing).
|
||||
assert "kpi-strip" in r.text
|
||||
assert "live.js" in r.text
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Palier 20 (0.30.0): colonnes personnalisables par
|
||||
espace et mapping étiquette ↔ colonne configurable."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import board_columns as bcols
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(tmp_path, monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "cols.db"))
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
client.get("/login")
|
||||
token = client.cookies.get("kanbante_csrf") or ""
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "bruno",
|
||||
"password": "motdepasse",
|
||||
"next": "/",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_cols():
|
||||
"""Un espace dédié rattaché à la connexion héritée."""
|
||||
ws_id = models.create_workspace("cols-test", "ColonnesTest", "#123456")
|
||||
models.set_workspace_connections(ws_id, [1])
|
||||
bcols.invalidate(ws_id)
|
||||
yield ws_id
|
||||
models.clear_board_columns(ws_id)
|
||||
bcols.invalidate(ws_id)
|
||||
models.delete_workspace(ws_id)
|
||||
|
||||
|
||||
def _issue(number, title, labels=None):
|
||||
return {
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"labels": labels or [],
|
||||
"assignee": None,
|
||||
"assignees": [],
|
||||
"milestone": None,
|
||||
"due_date": None,
|
||||
"created_at": "2026-09-01T09:00:00Z",
|
||||
"updated_at": "2026-09-02T09:00:00Z",
|
||||
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
|
||||
"pull_request": None,
|
||||
}
|
||||
|
||||
|
||||
# ── Défauts et service ────────────────────────────────────
|
||||
|
||||
def test_defaults_match_historical_columns(ws_cols):
|
||||
columns = bcols.get_columns(ws_cols)
|
||||
assert [c["key"] for c in columns] == [
|
||||
"backlog", "todo", "in_progress", "qa", "review", "done",
|
||||
]
|
||||
assert bcols.names(ws_cols)["todo"] == "📋 À faire"
|
||||
assert bcols.label_for(ws_cols, "review") == "kanban:review"
|
||||
assert bcols.label_for(ws_cols, "backlog") is None
|
||||
assert bcols.focus_keys(ws_cols) == ["in_progress", "qa", "review"]
|
||||
assert bcols.wall_keys(ws_cols) == [
|
||||
"todo", "in_progress", "qa", "review",
|
||||
]
|
||||
|
||||
|
||||
def test_custom_column_at_model_level(ws_cols):
|
||||
columns = bcols.get_columns(ws_cols)
|
||||
models.upsert_board_column(
|
||||
ws_cols,
|
||||
{
|
||||
"key": "dev", "name": "🛠️ Dev", "color": "#123123",
|
||||
"position": 4, "label": "etape:dev", "aliases": "",
|
||||
"kind": "flow", "in_focus": 1,
|
||||
},
|
||||
)
|
||||
bcols.invalidate(ws_cols)
|
||||
assert "dev" in models.column_keys(ws_cols)
|
||||
models.move_issue(
|
||||
"Projets/demo", 5, "dev", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
state = models.get_board_state(
|
||||
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
assert [c["issue_number"] for c in state["dev"]] == [5]
|
||||
assert bcols.label_for(ws_cols, "dev") == "etape:dev"
|
||||
assert "dev" in bcols.focus_keys(ws_cols)
|
||||
# Un autre espace n'est pas affecté.
|
||||
other = models.create_workspace("cols-temoin", "Témoin", "#000000")
|
||||
try:
|
||||
assert "dev" not in models.column_keys(other)
|
||||
finally:
|
||||
models.delete_workspace(other)
|
||||
|
||||
|
||||
# ── Panneau d'administration ──────────────────────────────
|
||||
|
||||
def test_admin_columns_crud(auth_client, ws_cols):
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
page = auth_client.get("/admin/columns")
|
||||
assert page.status_code == 200
|
||||
assert "Colonnes du board" in page.text
|
||||
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "🛠️ En développement",
|
||||
"color": "#224466",
|
||||
"label": "etape:dev",
|
||||
"aliases": "en-dev",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
keys = bcols.keys(ws_cols)
|
||||
assert "en_developpement" in keys
|
||||
# Insérée avant « done ».
|
||||
assert keys.index("en_developpement") == keys.index("done") - 1
|
||||
|
||||
# Renommage + mapping via le formulaire de la colonne.
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/en_developpement",
|
||||
data={
|
||||
"name": "🚀 Dev",
|
||||
"color": "#224466",
|
||||
"label": "etape:dev",
|
||||
"aliases": "en-dev, dev",
|
||||
"in_focus": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert bcols.names(ws_cols)["en_developpement"] == "🚀 Dev"
|
||||
lookup = bcols.label_lookup(ws_cols)
|
||||
assert lookup["etape:dev"] == "en_developpement"
|
||||
assert lookup["en-dev"] == "en_developpement"
|
||||
|
||||
# Le board affiche la nouvelle colonne.
|
||||
board = auth_client.get("/board/Projets/demo")
|
||||
assert 'data-column="en_developpement"' in board.text
|
||||
assert "🚀 Dev" in board.text
|
||||
|
||||
|
||||
def test_admin_column_delete_migrates_cards(auth_client, ws_cols):
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "Attente client", "color": "#445566", "label": "",
|
||||
"aliases": "", "csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "attente_client" in bcols.keys(ws_cols)
|
||||
models.move_issue(
|
||||
"Projets/demo", 5, "attente_client",
|
||||
workspace_id=ws_cols, connection_id=1,
|
||||
)
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/admin/columns/attente_client/delete",
|
||||
data={"target": "backlog", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert "attente_client" not in bcols.keys(ws_cols)
|
||||
state = models.get_board_state(
|
||||
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
assert any(c["issue_number"] == 5 for c in state["backlog"])
|
||||
# Les colonnes système ne se suppriment pas.
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/backlog/delete",
|
||||
data={"target": "todo", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "backlog" in bcols.keys(ws_cols)
|
||||
|
||||
|
||||
def test_admin_columns_reset(auth_client, ws_cols):
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "Temporaire", "color": "#445566", "label": "",
|
||||
"aliases": "", "csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "temporaire" in bcols.keys(ws_cols)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/reset",
|
||||
data={"csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert bcols.keys(ws_cols) == [
|
||||
"backlog", "todo", "in_progress", "qa", "review", "done",
|
||||
]
|
||||
|
||||
|
||||
# ── Mapping : adoption par alias et miroir personnalisé ──
|
||||
|
||||
def test_label_alias_adoption(auth_client, ws_cols, fake_gitea):
|
||||
"""L'étiquette Flowdeck kanban_a_faire, déclarée alias de la
|
||||
colonne todo, fait adopter À faire par le board."""
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/todo",
|
||||
data={
|
||||
"name": "📋 À faire", "color": "#3b82f6",
|
||||
"label": "kanban:todo", "aliases": "kanban_a_faire",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_issue(42, "Carte Flowdeck", labels=[
|
||||
{"id": 950, "name": "kanban_a_faire", "color": "aaaaaa"},
|
||||
])
|
||||
)
|
||||
payload = auth_client.get("/api/board/Projets/demo").json()
|
||||
assert "42" in payload["columns"]["todo"]["html"]
|
||||
state = models.get_board_state(
|
||||
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
assert any(c["issue_number"] == 42 for c in state["todo"])
|
||||
|
||||
|
||||
def test_custom_label_mirror_on_move(auth_client, ws_cols, fake_gitea):
|
||||
"""Déplacer une carte pose l'étiquette mappée de SA colonne."""
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "En développement", "color": "#224466",
|
||||
"label": "etape:dev", "aliases": "", "csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo",
|
||||
"issue_number": "5",
|
||||
"to_column": "en_developpement",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
issue = next(
|
||||
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
|
||||
)
|
||||
names = [l["name"] for l in issue.get("labels") or []]
|
||||
assert "etape:dev" in names
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Palier 21 (0.31.0): description éditable, pièces jointes
|
||||
d'issues, clic carte robuste (délégation côté JS)."""
|
||||
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def test_issue_update_title_and_body(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/update",
|
||||
data={
|
||||
"title": "Tâche un — précisée",
|
||||
"body": "## Contexte\n\nLa description qui manquait.",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["issue"]["body"].startswith("## Contexte")
|
||||
issue = next(
|
||||
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 1
|
||||
)
|
||||
assert issue["title"] == "Tâche un — précisée"
|
||||
assert "description qui manquait" in issue["body"]
|
||||
# Le journal consigne l'édition.
|
||||
events = client.get("/api/events?repo=Projets/demo").json()["events"]
|
||||
assert any(e["event_type"] == "edited" for e in events)
|
||||
|
||||
|
||||
def test_issue_update_rejects_empty_title(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/update",
|
||||
data={"title": " ", "body": "x", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_issue_detail_includes_assets_support(client, fake_gitea):
|
||||
data = client.get("/api/issues/Projets/demo/1").json()
|
||||
assert data["issue"]["attachments_supported"] is True
|
||||
assert data["issue"]["assets"] == []
|
||||
|
||||
|
||||
def test_asset_roundtrip(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/assets",
|
||||
data={"csrf_token": token},
|
||||
files={"attachment": ("notes.txt", b"bonjour kanbante", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
asset = r.json()["asset"]
|
||||
assert asset["name"] == "notes.txt"
|
||||
assert asset["size"] == len(b"bonjour kanbante")
|
||||
|
||||
# Visible dans le détail.
|
||||
data = client.get("/api/issues/Projets/demo/1").json()
|
||||
assert [a["name"] for a in data["issue"]["assets"]] == ["notes.txt"]
|
||||
|
||||
# Téléchargement proxifié : contenu + disposition.
|
||||
dl = client.get(
|
||||
f"/api/issues/Projets/demo/1/assets/{asset['id']}/download"
|
||||
)
|
||||
assert dl.status_code == 200
|
||||
assert dl.content == b"bonjour kanbante"
|
||||
assert "notes.txt" in dl.headers["content-disposition"]
|
||||
|
||||
# Suppression.
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
f"/api/issues/Projets/demo/1/assets/{asset['id']}/delete",
|
||||
data={"csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = client.get("/api/issues/Projets/demo/1").json()
|
||||
assert data["issue"]["assets"] == []
|
||||
|
||||
|
||||
def test_asset_upload_rejects_empty_and_huge(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/assets",
|
||||
data={"csrf_token": token},
|
||||
files={"attachment": ("vide.txt", b"", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_board_page_has_editor_and_assets_ui(client, fake_gitea):
|
||||
page = client.get("/board/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
assert 'id="detail-edit-btn"' in page.text
|
||||
assert 'id="detail-edit-form"' in page.text
|
||||
assert 'id="detail-assets"' in page.text
|
||||
assert 'id="asset-form"' in page.text
|
||||
# Création avec description dépliable (0.31.1 : <details>
|
||||
# natif plutôt qu'un bouton piloté en JS).
|
||||
assert 'id="new-issue-body"' in page.text
|
||||
assert 'id="new-issue-desc"' in page.text
|
||||
assert 'class="composer"' in page.text
|
||||
|
||||
|
||||
async def test_gitea_download_follows_browser_download_url():
|
||||
"""Gitea renvoie la fiche JSON de l'asset, pas le binaire :
|
||||
le téléchargement doit suivre browser_download_url (réel
|
||||
1.27.3, attrapé au smoke de la 0.31.0)."""
|
||||
import httpx
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
def handler(request):
|
||||
url = str(request.url)
|
||||
if url.endswith("/assets/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42, "name": "preuve.txt",
|
||||
"browser_download_url": "https://gitea.test/attachments/uuid-1",
|
||||
})
|
||||
if url.endswith("/attachments/uuid-1"):
|
||||
assert request.headers.get("authorization", "").startswith("token ")
|
||||
return httpx.Response(
|
||||
200, content=b"contenu reel",
|
||||
headers={"content-type": "text/plain"},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="https://gitea.test", token="jeton" + "-factice",
|
||||
cache_ttl=0, transport=httpx.MockTransport(handler),
|
||||
)
|
||||
content, content_type = await client.download_issue_asset(
|
||||
"Projets", "demo", 1, 42
|
||||
)
|
||||
assert content == b"contenu reel"
|
||||
assert content_type == "text/plain"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user