Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ba39bf01 | ||
|
|
fc1b40cd98 | ||
|
|
5f184f033c | ||
|
|
d17f3e56b3 | ||
|
|
11b1b5f11d | ||
|
|
37fd4de63d | ||
|
|
1b218ce030 | ||
|
|
40f034e86e | ||
|
|
911f07362f | ||
|
|
ee837972be | ||
|
|
4470e6ccbc | ||
|
|
d8cb4664e5 | ||
|
|
f2e702a3d9 | ||
|
|
435954c31b | ||
|
|
7f66775039 | ||
|
|
9586e310ba | ||
|
|
f58339fd77 | ||
|
|
0fc8673cf8 | ||
|
|
a090824db3 | ||
|
|
0393a91ff8 | ||
|
|
50596fd8b2 | ||
|
|
a65e92f6e8 | ||
|
|
6fd8e9fffe | ||
|
|
842268577b | ||
|
|
b3735e4056 | ||
|
|
ce1bbf0e59 | ||
|
|
336c79fd8b | ||
|
|
7a23aff31b | ||
|
|
a2ebefbda1 | ||
|
|
9653f851c8 | ||
|
|
144957ccd3 | ||
|
|
204b9ebbe6 | ||
|
|
05fde96377 | ||
|
|
7856cdb0c2 | ||
|
|
9757f3717a | ||
|
|
0faabf0078 | ||
|
|
a5c351e0b3 | ||
|
|
f033b18e86 | ||
|
|
3ade3174bb | ||
|
|
44d905c25d | ||
|
|
1273924eab | ||
|
|
fdc42332b2 | ||
|
|
304f30847a | ||
|
|
61739b6115 | ||
|
|
4a338fea79 | ||
|
|
659ff36f84 | ||
|
|
86f216be64 | ||
|
|
89f4493713 | ||
|
|
6008f5bb23 | ||
|
|
0fa828b48c | ||
|
|
060a51c32e | ||
|
|
0180c610b8 | ||
|
|
fe41eca899 | ||
|
|
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 |
@@ -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=
|
||||
+531
@@ -1,5 +1,536 @@
|
||||
## 0.45.0 — 2026-10-29 — Mobile : tiroir, pied et menu compte
|
||||
|
||||
- **Tiroir mobile** : l'état « replié » mémorisé sur desktop ne rétrécit plus le tiroir (cause : `body.sidebar-collapsed .sidebar { width: 68px; }` gagnait par spécificité sur le 264 px mobile, libellés tronqués par overflow-x). Le tiroir fait 264 px, replié ou non ; le fond ne défile plus derrière lui ; le bouton de repli dans le tiroir le ferme ; un clic sur un lien de navigation le ferme aussi.
|
||||
- **Pied de sidebar visible en mobile** : version, fraîcheur des données, thème et Sortir sont désormais accessibles dans le tiroir (avant : pied masqué, aucun moyen de sortir depuis un téléphone).
|
||||
- **Menu compte draco** : Profil et Déconnexion fonctionnent enfin (ils étaient relayés par draco-barre.js mais jamais pris en charge par le onAction Kanbante — le menu se fermait sans rien faire). En mobile, c'était le seul accès au profil et à la déconnexion.
|
||||
- **Menus draco** : largeur plafonnée à 100vw - 20 px pour ne jamais déborder d'un petit écran.
|
||||
|
||||
## 0.44.0 — 2026-10-29 — Ergonomie : tuile Projets et pied de sidebar
|
||||
|
||||
- **Tuile Projets** : en-tête groupé (gauche : organisation + santé, droite : date + étoile + menu) au lieu de 5 éléments éparpillés ; avancement global labellisé (« Avancement — X/Y terminées ») au lieu d'une barre anonyme ; jalon séparé par un filet, avec étiquette « Prochain jalon » et échéance distincte ; pied en 2 rangées — (1) compte d'issues + bouton Board, (2) répartition par colonne labellisée (« En cours 8 », « Terminé 59 », au lieu de chiffres nus 8 / 59) + prévision ; activité de la semaine en texte secondaire (« Cette semaine : … »).
|
||||
- **Pied de sidebar** : 2 rangées séparées par un filet — (1) profil + bouton Sortir icône seule (⏻, aria-label, sans texte), (2) version + fraîcheur des données en pastille discrète (point vert + « à l'instant », infobulle expliquant les instantanés Gitea). En rail replié, la rangée méta est masquée, les icônes restent centrées.
|
||||
|
||||
## 0.43.0 — 2026-10-29 — Icônes monochromes sur tout le site (#59, suite)
|
||||
|
||||
- **Icônes colorées retirées de toutes les pages** : emoji décoratifs devant titres, boutons et liens supprimés (le texte porte le sens) ; icônes fonctionnelles passées en trait/monochrome — étoile ★/☆ (couleur texte, plus jaune), recherche en SVG, épingler déjà en SVG, thème ☀/☾ en symboles texte.
|
||||
- **Statuts** : 🟢/🔴/✅/❌ remplacés par le mot (Actif, Désactivé…) ou par ●/✓ monochromes pour les états d'issue ; les pastilles de couleur sémantique (chip-ok, santé, colonnes) sont conservées, ce sont des informations, pas des décorations.
|
||||
- **Forges** : 🐙/🍵 remplacés par les mots GitHub/Gitea.
|
||||
- **Hors portée assumé** : barre Fusion partagée non touchée (ses icônes colorées sont l'identité des autres apps — ObsiGate, Flowdeck, TikTak, Spark) ; favicons et contenu utilisateur (titres/labels Gitea) inchangés ; symboles monochromes déjà en place conservés (⋯, ▾, ☰, ↗, ❮❯, ⏻).
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -111,6 +111,12 @@ 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 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 |
|
||||
|
||||
@@ -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 = ""
|
||||
+265
-2
@@ -96,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.27.0"
|
||||
APP_VERSION = "0.45.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -140,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"
|
||||
@@ -179,7 +390,13 @@ class Settings:
|
||||
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:
|
||||
@@ -194,6 +411,29 @@ def get_settings() -> 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", ""),
|
||||
@@ -236,8 +476,31 @@ def _env_settings() -> Settings:
|
||||
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=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
|
||||
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", ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+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.24.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": APP_VERSION},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ class GiteaClient:
|
||||
"""
|
||||
|
||||
kind = "gitea"
|
||||
supports_attachments = True
|
||||
api_suffix = "/api/v1"
|
||||
auth_scheme = "token"
|
||||
|
||||
@@ -476,6 +477,59 @@ class GiteaClient:
|
||||
),
|
||||
)
|
||||
|
||||
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)."""
|
||||
@@ -503,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:
|
||||
|
||||
@@ -27,6 +27,10 @@ _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"
|
||||
|
||||
|
||||
+1355
-162
File diff suppressed because it is too large
Load Diff
+137
-7
@@ -154,6 +154,21 @@ def init_db() -> None:
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Colonnes personnalisables par espace (0.30.0) ──
|
||||
CREATE TABLE IF NOT EXISTS board_columns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
aliases TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'flow',
|
||||
in_focus INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(workspace_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_connections (
|
||||
workspace_id INTEGER NOT NULL,
|
||||
connection_id INTEGER NOT NULL,
|
||||
@@ -219,7 +234,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
# get_board_state() with a KeyError. Drop them once, here.
|
||||
placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
|
||||
conn.execute(
|
||||
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders})",
|
||||
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders}) "
|
||||
"AND NOT EXISTS (SELECT 1 FROM board_columns bc "
|
||||
"WHERE bc.workspace_id = board_state.workspace_id "
|
||||
"AND bc.key = board_state.column_name)",
|
||||
KANBAN_COLUMNS,
|
||||
)
|
||||
|
||||
@@ -340,7 +358,9 @@ def get_board_state(
|
||||
(workspace_id, connection_id, repo_full_name),
|
||||
).fetchall()
|
||||
|
||||
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
|
||||
columns: dict[str, list[dict]] = {
|
||||
col: [] for col in column_keys(workspace_id)
|
||||
}
|
||||
for row in rows:
|
||||
column = row["column_name"]
|
||||
if column not in columns:
|
||||
@@ -364,7 +384,7 @@ def move_issue(
|
||||
connection_id: int = 1,
|
||||
) -> None:
|
||||
"""Move an issue to a different column (appended at the end)."""
|
||||
if to_column not in KANBAN_COLUMNS:
|
||||
if to_column not in column_keys(workspace_id):
|
||||
raise ValueError(f"Invalid column: {to_column}")
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
@@ -400,7 +420,7 @@ def set_column_order(
|
||||
its index in the list. Used after a drag & drop so manual ordering
|
||||
survives reloads.
|
||||
"""
|
||||
if column_name not in KANBAN_COLUMNS:
|
||||
if column_name not in column_keys(workspace_id):
|
||||
raise ValueError(f"Invalid column: {column_name}")
|
||||
with get_db() as conn:
|
||||
for index, issue_number in enumerate(ordered_issue_numbers):
|
||||
@@ -583,7 +603,7 @@ def latest_snapshot_at() -> str | None:
|
||||
|
||||
# ── Event journal ──────────────────────────────────────────
|
||||
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule", "edited")
|
||||
|
||||
|
||||
def log_event(
|
||||
@@ -793,11 +813,10 @@ def get_stats() -> dict:
|
||||
|
||||
projects: dict[str, dict[str, int]] = {}
|
||||
for row in rows:
|
||||
if row["column_name"] not in KANBAN_COLUMNS:
|
||||
continue
|
||||
name = row["repo_full_name"]
|
||||
if name not in projects:
|
||||
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
|
||||
# Les colonnes personnalisées (0.30.0) s'ajoutent au vol.
|
||||
projects[name][row["column_name"]] = row["cnt"]
|
||||
return projects
|
||||
|
||||
@@ -1253,3 +1272,114 @@ def list_admin_audit(limit: int = 100) -> list[dict]:
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Colonnes de board personnalisées (0.30.0) ─────────────
|
||||
# Définitions PAR ESPACE : clé immuable, nom/couleur libres,
|
||||
# étiquette forge mappée (+ alias reconnus à l'adoption).
|
||||
# Sans lignes pour un espace, column_keys retombe sur les
|
||||
# défauts historiques — comportement 0.24 strictement conservé.
|
||||
|
||||
def list_board_columns(workspace_id: int) -> list[dict]:
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM board_columns WHERE workspace_id = ? "
|
||||
"ORDER BY position, id",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def column_keys(workspace_id: int = 1) -> list[str]:
|
||||
"""Ordered column keys of a workspace (defaults if unset)."""
|
||||
cols = list_board_columns(workspace_id)
|
||||
if not cols:
|
||||
return list(KANBAN_COLUMNS)
|
||||
return [c["key"] for c in cols]
|
||||
|
||||
|
||||
def upsert_board_column(workspace_id: int, column: dict) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO board_columns
|
||||
(workspace_id, key, name, color, position, label,
|
||||
aliases, kind, in_focus)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(workspace_id, key) DO UPDATE SET
|
||||
name = excluded.name, color = excluded.color,
|
||||
position = excluded.position, label = excluded.label,
|
||||
aliases = excluded.aliases, kind = excluded.kind,
|
||||
in_focus = excluded.in_focus""",
|
||||
(
|
||||
workspace_id, column["key"], column["name"],
|
||||
column.get("color", "#6b7280"),
|
||||
int(column.get("position", 0)),
|
||||
column.get("label", ""), column.get("aliases", ""),
|
||||
column.get("kind", "flow"),
|
||||
1 if column.get("in_focus") else 0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def delete_board_column(workspace_id: int, key: str) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM board_columns WHERE workspace_id = ? AND key = ?",
|
||||
(workspace_id, key),
|
||||
)
|
||||
|
||||
|
||||
def clear_board_columns(workspace_id: int) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM board_columns WHERE workspace_id = ?",
|
||||
(workspace_id,),
|
||||
)
|
||||
|
||||
|
||||
def count_column_cards(workspace_id: int, key: str) -> int:
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM board_state "
|
||||
"WHERE workspace_id = ? AND column_name = ?",
|
||||
(workspace_id, key),
|
||||
).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
def migrate_column_cards(
|
||||
workspace_id: int, from_key: str, to_key: str
|
||||
) -> int:
|
||||
"""Move every card of a column to another (column deletion).
|
||||
|
||||
Positions are appended after the target column's current
|
||||
maximum, per repository and connection.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT connection_id, repo_full_name, issue_number "
|
||||
"FROM board_state WHERE workspace_id = ? AND column_name = ? "
|
||||
"ORDER BY position, issue_number",
|
||||
(workspace_id, from_key),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
nxt = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 AS p FROM board_state "
|
||||
"WHERE workspace_id = ? AND connection_id = ? "
|
||||
"AND repo_full_name = ? AND column_name = ?",
|
||||
(
|
||||
workspace_id, row["connection_id"],
|
||||
row["repo_full_name"], to_key,
|
||||
),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"UPDATE board_state SET column_name = ?, position = ?, "
|
||||
"updated_at = datetime('now') WHERE workspace_id = ? "
|
||||
"AND connection_id = ? AND repo_full_name = ? "
|
||||
"AND issue_number = ?",
|
||||
(
|
||||
to_key, nxt["p"], workspace_id, row["connection_id"],
|
||||
row["repo_full_name"], row["issue_number"],
|
||||
),
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
|
||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||
passe par une nouvelle version de commons.
|
||||
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">
|
||||
@@ -15,24 +22,28 @@
|
||||
<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="https://og.dracodev.net/">
|
||||
<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="https://flowdeck.dracodev.net/">
|
||||
<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="https://tiktak.dracodev.net/">
|
||||
<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="https://kanbante.dracodev.net/">
|
||||
<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">
|
||||
@@ -58,8 +69,7 @@
|
||||
<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-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||
<span class="draco-avatar" title="Opérateur Kanbante">{{ draco_avatar }}</span>
|
||||
<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>
|
||||
@@ -68,10 +78,19 @@
|
||||
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, la recherche fédérée et Spark arrivent avec les
|
||||
phases suivantes du programme Fusion. */
|
||||
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) {
|
||||
@@ -84,9 +103,16 @@
|
||||
};
|
||||
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") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
|
||||
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
||||
/* Menu compte (image 2) : ces deux actions étaient relayées
|
||||
par draco-barre.js mais jamais prises en charge ici —
|
||||
le menu se fermait sans rien faire. En mobile, c'est le
|
||||
seul accès au profil et à la déconnexion (le pied de
|
||||
sidebar était masqué). */
|
||||
if (act === "profil") { location.href = "/profile"; return; }
|
||||
if (act === "deconnexion") { location.href = "/logout"; 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."); }
|
||||
@@ -101,4 +127,7 @@
|
||||
};
|
||||
</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>
|
||||
|
||||
|
||||
+64
-35
@@ -1,3 +1,15 @@
|
||||
<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 :
|
||||
@@ -9,36 +21,38 @@
|
||||
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
|
||||
{% set nav_groups = [
|
||||
("principal", "Principal", [
|
||||
("home", "/", "🏠", "Projets"),
|
||||
("focus", "/focus", "🎯", "Focus"),
|
||||
("today", "/today", "🌅", "Aujourd'hui"),
|
||||
("my", "/my", "👤", "Mes issues"),
|
||||
("home", "/", "", "Projets"),
|
||||
("focus", "/focus", "", "Focus"),
|
||||
("today", "/today", "", "Aujourd'hui"),
|
||||
("my", "/my", "", "Mes issues"),
|
||||
]),
|
||||
("rituels", "Rituels", [
|
||||
("briefing", "/briefing", "📊", "Briefing"),
|
||||
("standup", "/standup", "🗣️", "Standup"),
|
||||
("digest", "/digest", "🧠", "Digest"),
|
||||
("briefing", "/briefing", "", "Briefing"),
|
||||
("standup", "/standup", "", "Standup"),
|
||||
("digest", "/digest", "", "Digest"),
|
||||
]),
|
||||
("pilotage", "Pilotage", [
|
||||
("forecast", "/forecast", "🔮", "Prévisions"),
|
||||
("health", "/health", "🩺", "Santé"),
|
||||
("rules", "/rules", "⚙️", "Règles"),
|
||||
("activity", "/activity", "📰", "Activité"),
|
||||
("inflight", "/inflight", "", "En vol"),
|
||||
("forecast", "/forecast", "", "Prévisions"),
|
||||
("health", "/health", "", "Santé"),
|
||||
("rules", "/rules", "", "Règles"),
|
||||
("activity", "/activity", "", "Activité"),
|
||||
]),
|
||||
("outils", "Outils", [
|
||||
("search", "/search", "🔍", "Recherche"),
|
||||
("capture", "/capture", "⚡", "Capture"),
|
||||
("archives", "/archives", "🗄️", "Archives"),
|
||||
("wall", "/wall", "🖥️", "Wallboard"),
|
||||
("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_settings", "/admin/settings", "🎛️", "Paramètres"),
|
||||
("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 %}
|
||||
@@ -76,10 +90,10 @@
|
||||
<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 %}
|
||||
@@ -93,6 +107,9 @@
|
||||
</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 }}">
|
||||
@@ -104,26 +121,39 @@
|
||||
<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">
|
||||
{% if data_freshness %}
|
||||
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
|
||||
{% endif %}
|
||||
{% if current_user %}
|
||||
<a href="/profile" class="side-logout" title="Mon profil">👤<span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
|
||||
{% endif %}
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
{% if auth_enabled %}
|
||||
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
|
||||
{% endif %}
|
||||
<div class="side-foot-user">
|
||||
{% if current_user %}
|
||||
<a href="/profile" class="side-logout side-profile" title="Mon profil"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" style="width:18px;height:18px;display:inline-block;vertical-align:-3px"><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"/></svg><span class="side-label"> {{ current_user.display_name or current_user.username }}</span></a>
|
||||
{% endif %}
|
||||
{% 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 logout-icon" title="Se déconnecter" aria-label="Se déconnecter">⏻</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="side-foot-meta">
|
||||
<span class="side-version side-label">v{{ version }}</span>
|
||||
{% if data_freshness %}
|
||||
<span class="side-fresh" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan) — les données affichées datent de cet instantané"><span class="fresh-dot" aria-hidden="true"></span><span class="side-label">{{ data_freshness }}</span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="side-backdrop" id="side-backdrop" hidden></div>
|
||||
@@ -136,9 +166,8 @@
|
||||
{% endif %}
|
||||
<div class="header-actions">
|
||||
{% if repo_info is defined and repo_info and repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="palette-overlay" id="palette-overlay" hidden>
|
||||
<div class="palette" role="dialog" aria-modal="true" aria-label="Palette de commandes">
|
||||
<div class="palette-input-row">
|
||||
<span class="palette-icon">⌨️</span>
|
||||
<span class="palette-icon"></span>
|
||||
<input id="palette-input" type="text" autocomplete="off" spellcheck="false"
|
||||
placeholder="Chercher un projet, une issue, une action…">
|
||||
<kbd>Échap</kbd>
|
||||
|
||||
+13
-13
@@ -12,14 +12,14 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🛠️ Administration</h1>
|
||||
<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 %}
|
||||
{% 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>
|
||||
<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>
|
||||
@@ -37,8 +37,8 @@
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td>{% if u.active %}🟢 Actif{% else %}🔴 Désactivé{% endif %}</td>
|
||||
<td>{% if u.draco_sub %}🔗 lié{% else %}—{% endif %}</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">
|
||||
@@ -56,7 +56,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>➕ Nouvel utilisateur</h3>
|
||||
<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>
|
||||
@@ -74,17 +74,17 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔭 Vue d'ensemble</h2>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -12,15 +12,15 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🔌 Connexions aux forges</h1>
|
||||
<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 %}
|
||||
{% 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 }}
|
||||
<h2>{% if c.kind == 'github' %}GitHub{% else %}Gitea{% 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 %}
|
||||
@@ -35,13 +35,13 @@
|
||||
<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é) <input type="password" name="hook_secret" 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>
|
||||
<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 }}">
|
||||
@@ -56,7 +56,7 @@
|
||||
{% endfor %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvelle connexion</h2>
|
||||
<h2>Nouvelle connexion</h2>
|
||||
<form method="post" action="/admin/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Type
|
||||
@@ -68,10 +68,10 @@
|
||||
<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) <input type="password" name="hook_secret" 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">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>
|
||||
<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>
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🎛️ Paramètres</h1>
|
||||
<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 %}
|
||||
{% 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">
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🧭 Espaces de travail</h1>
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
@@ -30,19 +30,19 @@
|
||||
<button type="submit" class="btn-save btn-inline">Renommer</button>
|
||||
</form>
|
||||
|
||||
<h3>🔌 Connexions de l'espace</h3>
|
||||
<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>
|
||||
{% if c.kind == 'github' %}GitHub{% else %}Gitea{% 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>
|
||||
<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>
|
||||
@@ -81,7 +81,7 @@
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
|
||||
</form>
|
||||
|
||||
<h3>📦 Sélection de projets</h3>
|
||||
<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 }}">
|
||||
@@ -101,7 +101,7 @@
|
||||
|
||||
{% if is_global_admin %}
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvel espace</h2>
|
||||
<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>
|
||||
|
||||
+16
-16
@@ -12,11 +12,11 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🗄️ Archives</h1>
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
@@ -26,7 +26,7 @@
|
||||
<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>
|
||||
<button type="submit" class="btn-mini">Archiver ici</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'local' %}
|
||||
@@ -35,7 +35,7 @@
|
||||
<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>
|
||||
<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 }}">
|
||||
@@ -50,7 +50,7 @@
|
||||
<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>
|
||||
<button type="submit" class="btn-mini">Archiver sur la forge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'forge' %}
|
||||
@@ -59,14 +59,14 @@
|
||||
<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>
|
||||
<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>
|
||||
<button type="submit" class="btn-mini">Récap (.md)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -74,19 +74,19 @@
|
||||
{% macro rowline(row, kind) %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %}
|
||||
{% if row.conn_kind == 'github' %}GitHub{% else %}Gitea{% 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>{% 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>
|
||||
<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 }}">
|
||||
@@ -96,15 +96,15 @@
|
||||
{% 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>{% if row.conn_kind == 'github' %}GitHub{% else %}Gitea{% 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>{% 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>
|
||||
<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>
|
||||
@@ -112,7 +112,7 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📥 Archives locales de cet espace ({{ groups.local|length }})</h2>
|
||||
<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>
|
||||
@@ -124,7 +124,7 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔒 Archivés sur la forge ({{ groups.forge|length }})</h2>
|
||||
<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>
|
||||
|
||||
+53
-20
@@ -5,18 +5,18 @@
|
||||
<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" %}
|
||||
|
||||
{% if milestones %}
|
||||
<div class="milestone-bar">
|
||||
<span class="milestone-label">🎯 Milestone</span>
|
||||
<span class="milestone-label">Milestone</span>
|
||||
<a href="/board/{{ owner }}/{{ repo }}" class="milestone-chip{% if not milestone_param %} active{% endif %}">Toutes</a>
|
||||
{% for m in milestones %}
|
||||
<a href="/board/{{ owner }}/{{ repo }}?milestone={{ m.title|urlencode }}"
|
||||
@@ -27,15 +27,15 @@
|
||||
{% if milestone_progress %}
|
||||
<div class="milestone-banner">
|
||||
<div class="milestone-info">
|
||||
<strong>🎯 {{ milestone_progress.title }}</strong>
|
||||
<strong>{{ milestone_progress.title }}</strong>
|
||||
{% if milestone_progress.due_on %}
|
||||
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
||||
<span class="milestone-due">échéance {{ milestone_progress.due_on[:10] }}</span>
|
||||
{% endif %}
|
||||
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
|
||||
{% if milestone_forecast %}
|
||||
<span class="milestone-forecast">🔮 {{ milestone_forecast }}</span>
|
||||
<span class="milestone-forecast">{{ milestone_forecast }}</span>
|
||||
{% endif %}
|
||||
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">📖 Récit de sprint</a>
|
||||
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">Récit de sprint</a>
|
||||
</div>
|
||||
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
||||
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
||||
@@ -44,25 +44,40 @@
|
||||
|
||||
<!-- 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 %}
|
||||
<option value="{{ label.name }}">{{ label.name }}</option>
|
||||
{% 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>
|
||||
<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>
|
||||
{% 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] }}">
|
||||
@@ -87,7 +102,7 @@
|
||||
<aside class="notes-panel" id="notes-panel">
|
||||
<div class="notes-resize" id="notes-resize" title="Glisser pour ajuster la largeur"></div>
|
||||
<div class="notes-head">
|
||||
<h3>📝 Notes du projet</h3>
|
||||
<h3>Notes du projet</h3>
|
||||
<button type="button" id="notes-toggle" class="btn-sm notes-toggle"
|
||||
title="Replier / déplier le panneau">⤢</button>
|
||||
</div>
|
||||
@@ -99,7 +114,7 @@
|
||||
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
|
||||
<div class="notes-foot">
|
||||
<span id="notes-status" class="notes-status"></span>
|
||||
<button type="submit" class="btn-save">💾 Sauvegarder</button>
|
||||
<button type="submit" class="btn-save">Sauvegarder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -110,7 +125,7 @@
|
||||
<div id="label-modal" class="modal" hidden>
|
||||
<div class="modal-box">
|
||||
<div class="detail-head">
|
||||
<h2>🏷️ Étiquettes de {{ full_name }}</h2>
|
||||
<h2>Étiquettes de {{ full_name }}</h2>
|
||||
<button id="label-modal-close" class="btn-sm" type="button">✕</button>
|
||||
</div>
|
||||
<p class="notes-hint">Ces étiquettes vivent dans Gitea : les créer ou les supprimer ici les modifie pour tout le dépôt.</p>
|
||||
@@ -145,12 +160,30 @@
|
||||
</div>
|
||||
<div id="detail-code" class="detail-code"></div>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||
<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">
|
||||
<textarea id="comment-body" rows="3" maxlength="10000"
|
||||
placeholder="Écrire un commentaire (markdown)..."></textarea>
|
||||
<button type="submit" class="btn-save">💬 Commenter dans Gitea</button>
|
||||
<button type="submit" class="btn-save">Commenter dans Gitea</button>
|
||||
</form>
|
||||
<p class="detail-foot"><a id="detail-gitea-link" href="#" target="_blank">Ouvrir dans Gitea ↗</a></p>
|
||||
</aside>
|
||||
|
||||
@@ -22,17 +22,17 @@
|
||||
</div>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📄 Contenu du briefing</h2>
|
||||
<h2>Contenu du briefing</h2>
|
||||
<pre class="briefing-text">{{ text }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📲 Envoi ntfy</h2>
|
||||
<h2>Envoi ntfy</h2>
|
||||
{% if ntfy_configured %}
|
||||
<p class="notes-hint">ntfy est configuré. {% if briefing_time %}Envoi automatique chaque jour à <strong>{{ briefing_time }}</strong> (heure du serveur).{% else %}Aucune heure d'envoi automatique (<code>KANBANTE_BRIEFING_TIME</code> non défini).{% endif %}{% if last_sent %} Dernier envoi : {{ last_sent }}.{% endif %}</p>
|
||||
<form id="briefing-send-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-save">📤 Envoyer maintenant via ntfy</button>
|
||||
<button type="submit" class="btn-save">Envoyer maintenant via ntfy</button>
|
||||
</form>
|
||||
<p id="briefing-status" class="notes-hint"></p>
|
||||
{% else %}
|
||||
@@ -52,7 +52,7 @@
|
||||
status.textContent = 'Envoi en cours...';
|
||||
const r = await fetch('/api/briefing/send', { method: 'POST', body: new FormData(e.target) });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
|
||||
status.textContent = data.ok ? 'Briefing envoyé sur ntfy.' : '' + (data.error || ('HTTP ' + r.status));
|
||||
});
|
||||
</script>
|
||||
{% include "_palette.html" %}
|
||||
|
||||
+10
-10
@@ -12,13 +12,13 @@
|
||||
|
||||
<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>
|
||||
|
||||
{% if created %}
|
||||
<div class="briefing-card">
|
||||
<h2>✅ Capturé !</h2>
|
||||
<h2>Capturé !</h2>
|
||||
<p>Issue <strong>#{{ created.number }}</strong> créée dans
|
||||
<strong>{{ created.repo_full_name }}</strong> (colonne {{ created.column }}) :
|
||||
{% if created.html_url %}<a href="{{ created.html_url }}" target="_blank">{{ created.title }}</a>{% else %}{{ created.title }}{% endif %}
|
||||
@@ -27,13 +27,13 @@
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="briefing-card">
|
||||
<h2>❌ Capture impossible</h2>
|
||||
<h2>Capture impossible</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>✍️ Saisie rapide</h2>
|
||||
<h2>Saisie rapide</h2>
|
||||
<form method="post" action="/capture" class="capture-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Dépôt
|
||||
@@ -41,7 +41,7 @@
|
||||
<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-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
|
||||
@@ -52,16 +52,16 @@
|
||||
</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>
|
||||
<button type="submit" class="btn-save">Capturer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>🔌 Depuis un script ou un raccourci</h2>
|
||||
<h2>Depuis un script ou un raccourci</h2>
|
||||
{% if api_enabled %}
|
||||
<p>L'endpoint <code>POST /api/capture</code> est <strong>actif</strong> (jeton de capture configuré). Exemples :</p>
|
||||
<pre class="capture-pre">curl -X POST https://votre-kanbante/api/capture \
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>📱 Depuis le téléphone (ntfy)</h2>
|
||||
<h2>Depuis le téléphone (ntfy)</h2>
|
||||
{% if ntfy_topic %}
|
||||
<p>Écoute active sur le topic <code>{{ ntfy_topic }}</code> : chaque message publié devient une issue dans <code>{{ capture_repo or "— (KANBANTE_CAPTURE_REPO manquant)" }}</code>. La première ligne fait le titre. Publie depuis l'application ntfy ou :</p>
|
||||
<pre class="capture-pre">curl -d "Idée : relire le rapport\n\nDétails…" {{ ntfy_url }}/{{ ntfy_topic }}</pre>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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>
|
||||
@@ -55,7 +55,7 @@
|
||||
<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>
|
||||
<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">
|
||||
@@ -64,6 +64,7 @@
|
||||
<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>
|
||||
@@ -85,84 +86,98 @@
|
||||
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-header-left">
|
||||
<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' %}GitHub{% else %}Gitea{% 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 %}
|
||||
</div>
|
||||
<div class="card-header-actions">
|
||||
<span class="date" title="Dernière activité">{{ 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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<h3>
|
||||
<a href="/project/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||
</h3>
|
||||
<p class="desc">{{ repo.description or '—' }}</p>
|
||||
<p class="desc" title="{{ repo.description or '' }}">{{ repo.description or '—' }}</p>
|
||||
{% if full in stats %}
|
||||
{% set total = stats[full].values()|sum %}
|
||||
{% set done_n = stats[full].get('done', 0) %}
|
||||
{% if total > 0 %}
|
||||
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
|
||||
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
|
||||
<div class="card-progress">
|
||||
<div class="progress-head"><span>Avancement</span><span>{{ done_n }}/{{ total }} terminées</span></div>
|
||||
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
|
||||
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if repo.next_milestone %}
|
||||
{% set ms = repo.next_milestone %}
|
||||
<div class="ms-block">
|
||||
<div class="ms-head"><span class="ms-kicker">Prochain jalon</span>{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">échéance {{ ms.due_label }}</span>{% endif %}</div>
|
||||
<div class="ms-line">
|
||||
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
|
||||
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
|
||||
<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>
|
||||
</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>
|
||||
{% 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'] %}
|
||||
{% set n = stats[full].get(col, 0) %}
|
||||
{% if n > 0 %}
|
||||
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
||||
{{ n }}
|
||||
</span>
|
||||
<div class="foot-main">
|
||||
<span class="foot-count">{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||
<a href="/board/{{ full }}" class="card-board-link btn-sm">Board →</a>
|
||||
</div>
|
||||
<div class="foot-dist">
|
||||
{% if full in stats %}
|
||||
<div class="mini-progress" aria-label="Répartition par colonne">
|
||||
{% for col in column_keys %}
|
||||
{% set n = stats[full].get(col, 0) %}
|
||||
{% if n > 0 %}
|
||||
<span class="chip" title="{{ COLUMN_LABELS[col] }}" style="background: color-mix(in srgb, {{ COLUMN_COLORS[col] }} 25%, transparent); color: var(--text)">
|
||||
{{ COLUMN_LABELS[col] }} {{ n }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</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
|
||||
Cette semaine : {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s)
|
||||
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
|
||||
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
|
||||
aucune activité kanban depuis {{ repo.activity.idle_days }} j
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -182,7 +197,7 @@
|
||||
<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.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>
|
||||
@@ -215,7 +230,7 @@
|
||||
<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>
|
||||
<h3 class="pinned-title">Épinglés</h3>
|
||||
<div class="project-grid" id="pinned-grid">
|
||||
{% for repo in pinned_repos %}{{ project_card(repo) }}{% endfor %}
|
||||
</div>
|
||||
@@ -224,21 +239,21 @@
|
||||
<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>
|
||||
@@ -257,7 +272,7 @@
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
|
||||
<script src="/static/js/stars.js" defer></script>
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
<script src="/static/js/dashboard.js?v={{ version }}" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
|
||||
<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 %}
|
||||
{% if digest_time %}<p>🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
|
||||
{% if last_result %}<p>Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
|
||||
{% if digest_time %}<p>Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
|
||||
</section>
|
||||
|
||||
{% if banner %}
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
{% if tools is not none %}
|
||||
<div class="briefing-card">
|
||||
<h2>🛠️ Outils du serveur MCP ObsiGate</h2>
|
||||
<h2>Outils du serveur MCP ObsiGate</h2>
|
||||
{% if tools %}
|
||||
<ul>
|
||||
{% for tool in tools %}<li><code>{{ tool }}</code></li>{% endfor %}
|
||||
@@ -38,29 +38,29 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>📤 Livraison</h2>
|
||||
<h2>Livraison</h2>
|
||||
<p>
|
||||
ObsiGate (MCP) : {% if obsigate_configured %}<strong>configuré</strong> — <code>{{ obsigate_url }}</code>, outil <code>{{ obsigate_tool or "(à définir)" }}</code>{% else %}non configuré (<code>KANBANTE_OBSIGATE_MCP_URL</code>){% endif %}<br>
|
||||
Webhook générique : {% if webhook_configured %}<strong>configuré</strong>{% else %}non configuré (<code>KANBANTE_DIGEST_WEBHOOK_URL</code>){% endif %}
|
||||
</p>
|
||||
<form method="post" action="/digest/probe" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-save">🔌 Tester la connexion MCP</button>
|
||||
<button type="submit" class="btn-save">Tester la connexion MCP</button>
|
||||
</form>
|
||||
<form method="post" action="/digest/send" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="obsigate">
|
||||
<button type="submit" class="btn-save">🧠 Envoyer vers ObsiGate</button>
|
||||
<button type="submit" class="btn-save">Envoyer vers ObsiGate</button>
|
||||
</form>
|
||||
<form method="post" action="/digest/send" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="webhook">
|
||||
<button type="submit" class="btn-save">🔗 Envoyer au webhook</button>
|
||||
<button type="submit" class="btn-save">Envoyer au webhook</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>👀 Aperçu du digest de la semaine</h2>
|
||||
<h2>Aperçu du digest de la semaine</h2>
|
||||
{% if pending %}
|
||||
<pre class="capture-pre">{{ pending }}</pre>
|
||||
{% else %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
+10
-10
@@ -12,15 +12,15 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🔮 Prévisions — « on finit quand ? »</h1>
|
||||
<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 %}
|
||||
<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">
|
||||
<input type="text" id="ms-filter" placeholder="Filtrer par projet ou jalon..." autocomplete="off">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
<td>{{ row.eta or '—' }}</td>
|
||||
<td>
|
||||
{% if row.verdict == 'late' %}
|
||||
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
|
||||
<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>
|
||||
<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 %}
|
||||
@@ -75,10 +75,10 @@
|
||||
{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📈 Débit par projet</h2>
|
||||
<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>
|
||||
<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 %}
|
||||
@@ -93,7 +93,7 @@
|
||||
{% else %}
|
||||
<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>
|
||||
{% else %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 Santé du flux</h1>
|
||||
<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>
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
<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 data-sort="zombies" data-first-dir="desc">Zombies</th>
|
||||
<th data-sort="slowest">Goulot</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<main class="focus-main">
|
||||
{% if project_names %}
|
||||
<div class="health-switch-bar">
|
||||
<label for="health-switch">🩺 Projet :</label>
|
||||
<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>
|
||||
@@ -25,20 +25,20 @@
|
||||
{% 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>
|
||||
<h2>⏱️ Temps passé par colonne (journal)</h2>
|
||||
<h1>{{ full_name }}</h1>
|
||||
<h2>Temps passé par colonne (journal)</h2>
|
||||
{% if health.dwell %}
|
||||
<table class="health-table">
|
||||
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in health.dwell %}
|
||||
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
|
||||
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
|
||||
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %}{% endif %}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
<td>{{ row.median_days }} j</td>
|
||||
<td>{{ row.avg_days }} j</td>
|
||||
@@ -47,7 +47,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% if health.slowest %}
|
||||
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
|
||||
<p><strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
|
||||
@@ -55,23 +55,23 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
|
||||
<h2>Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
|
||||
{% if health.zombies %}
|
||||
<ul class="today-list">
|
||||
{% for z in health.zombies %}
|
||||
<li>
|
||||
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
|
||||
<span class="zombie-actions">
|
||||
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
|
||||
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
|
||||
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
|
||||
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">Toujours pertinent ?</button>
|
||||
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">Backlog</button>
|
||||
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">Fermer</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
|
||||
{% else %}
|
||||
<p>Aucune carte zombie. Le flux est propre. ✨</p>
|
||||
<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>
|
||||
@@ -1,7 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<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>
|
||||
@@ -11,12 +12,12 @@
|
||||
<main class="login-wrap">
|
||||
<section class="login-card">
|
||||
<div class="login-brand">
|
||||
<span class="login-mark" aria-hidden="true">📋</span>
|
||||
<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>
|
||||
<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 }}">
|
||||
@@ -29,7 +30,7 @@
|
||||
</label>
|
||||
<button type="submit" class="login-submit">Se connecter</button>
|
||||
</form>
|
||||
<a class="login-sso" href="https://og.dracodev.net/sso/authorize?return_to=https%3A%2F%2Fkanbante.dracodev.net%2Fauth%2Fdraco">🧭 Se connecter avec ObsiGate (SSO)</a>
|
||||
<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>
|
||||
|
||||
+61
-9
@@ -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"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" style="width:18px;height:18px;display:block"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg></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">
|
||||
<span class="issue-state">🟢</span>
|
||||
<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" aria-label="Ouverte">●</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>
|
||||
|
||||
+11
-11
@@ -12,12 +12,12 @@
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>👤 Mon profil</h1>
|
||||
<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 %}
|
||||
{% 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>
|
||||
@@ -33,7 +33,7 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔑 Mot de passe</h2>
|
||||
<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 }}">
|
||||
@@ -47,11 +47,11 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🗝️ Jetons d'API (MCP)</h2>
|
||||
<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>Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
|
||||
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
|
||||
{% endif %}
|
||||
{% if api_tokens %}
|
||||
@@ -84,7 +84,7 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>✦ Liaison draco (SSO)</h2>
|
||||
<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">
|
||||
@@ -97,15 +97,15 @@
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔌 Mes connexions personnelles</h2>
|
||||
<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>
|
||||
{% if c.kind == 'github' %}GitHub{% else %}Gitea{% 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>
|
||||
<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 }}">
|
||||
|
||||
+53
-22
@@ -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,51 +26,79 @@
|
||||
</div>
|
||||
|
||||
<div class="proj-actions">
|
||||
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">📋 Ouvrir le board</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/triage/{{ full_name }}" class="nav-pill">📥 Triage</a>
|
||||
<a href="/health/{{ full_name }}" class="nav-pill">🩺 Santé</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/wall?repo={{ full_name }}" class="nav-pill">🖥️ Wallboard</a>
|
||||
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">Ouvrir le board</a>
|
||||
<a href="/capture" class="nav-pill">Capture</a>
|
||||
<a href="/triage/{{ full_name }}" class="nav-pill">Triage</a>
|
||||
<a href="/health/{{ full_name }}" class="nav-pill">Santé</a>
|
||||
<a href="/forecast" class="nav-pill">Prévisions</a>
|
||||
<a href="/wall?repo={{ full_name }}" class="nav-pill">Wallboard</a>
|
||||
{% if repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">🔗 Gitea ↗</a>
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">Gitea ↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="proj-grid">
|
||||
<section class="proj-panel">
|
||||
<h2>🎯 Jalons ouverts</h2>
|
||||
<h2>Jalons ouverts</h2>
|
||||
{% if milestones %}
|
||||
{% for ms in milestones %}
|
||||
<div class="proj-ms">
|
||||
<div class="ms-line">
|
||||
<a href="/board/{{ full_name }}?milestone={{ ms.title|urlencode }}"><strong>{{ ms.title }}</strong></a>
|
||||
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
|
||||
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
|
||||
</div>
|
||||
{% if ms.total %}
|
||||
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
|
||||
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% endif %}</div>
|
||||
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · {{ ms.eta_label }}{% endif %}</div>
|
||||
{% else %}
|
||||
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
|
||||
{% endif %}
|
||||
{% if ms.id %}
|
||||
<details class="ms-edit">
|
||||
<summary>Modifier / fermer</summary>
|
||||
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
|
||||
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
|
||||
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
|
||||
<span class="ms-hint">vide = retirer l'échéance</span></label>
|
||||
<div class="ms-form-actions">
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
|
||||
data-confirm="Fermer le jalon « {{ ms.title }} » ? Ses issues restent ouvertes, sans jalon actif.">
|
||||
<button type="submit" class="btn-mini btn-danger">Fermer le jalon</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="proj-empty">Aucun jalon ouvert. 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>
|
||||
<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>
|
||||
<li>Goulot : <strong>{{ COLUMN_LABELS[health.slowest.column] }}</strong> — séjour médian {{ health.slowest.median_days }} j</li>
|
||||
{% else %}
|
||||
<li>🐢 Pas encore de goulot mesurable (historique trop court).</li>
|
||||
<li>Pas encore de goulot mesurable (historique trop court).</li>
|
||||
{% endif %}
|
||||
<li>🧟 Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
|
||||
<li>Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
|
||||
</ul>
|
||||
{% if health.zombies %}
|
||||
<ul class="proj-list proj-zombies">
|
||||
@@ -84,7 +114,7 @@
|
||||
</section>
|
||||
|
||||
<section class="proj-panel">
|
||||
<h2>🔮 Prévisions</h2>
|
||||
<h2>Prévisions</h2>
|
||||
{% if throughput %}
|
||||
<ul class="proj-list">
|
||||
<li>Débit : <strong>{{ throughput.avg }}</strong> fermetures / semaine en moyenne (min {{ throughput.min }} · max {{ throughput.max }}, 4 dernières semaines complètes)</li>
|
||||
@@ -97,7 +127,7 @@
|
||||
</section>
|
||||
|
||||
<section class="proj-panel">
|
||||
<h2>🕘 Activité récente</h2>
|
||||
<h2>Activité récente</h2>
|
||||
{% if events %}
|
||||
<ul class="proj-list">
|
||||
{% for e in events %}
|
||||
@@ -111,11 +141,11 @@
|
||||
</section>
|
||||
|
||||
<section class="proj-panel proj-notes">
|
||||
<h2>📝 Notes du projet</h2>
|
||||
<h2>Notes du projet</h2>
|
||||
{% if notes %}
|
||||
<div class="proj-notes-body">{{ notes }}</div>
|
||||
{% else %}
|
||||
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « 🗒️ Notes » du board et vivent avec le projet.</p>
|
||||
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « Notes » du board et vivent avec le projet.</p>
|
||||
{% endif %}
|
||||
<p><a href="/board/{{ full_name }}">Ouvrir le board pour éditer →</a></p>
|
||||
</section>
|
||||
@@ -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,15 +25,15 @@
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{% if rules %}
|
||||
<section class="briefing-card">
|
||||
<h2>📜 Règles actives ({{ rules|length }})</h2>
|
||||
<h2>Règles actives ({{ rules|length }})</h2>
|
||||
<ul class="forecast-milestones">
|
||||
{% for rule in rules %}
|
||||
<li><strong>{{ rule.name }}</strong> — quand
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
{% if last_run %}
|
||||
<section class="briefing-card">
|
||||
<h2>🕐 Dernière évaluation — {{ last_run.at }}</h2>
|
||||
<h2>Dernière évaluation — {{ last_run.at }}</h2>
|
||||
{% if last_run.actions %}
|
||||
<ul class="forecast-milestones">
|
||||
{% for action in last_run.actions %}
|
||||
@@ -75,10 +75,10 @@
|
||||
const r = await fetch('/api/rules/run', { method: 'POST', body: data });
|
||||
const body = await r.json();
|
||||
if (!r.ok || !body.ok) throw new Error((body && body.error) || ('HTTP ' + r.status));
|
||||
out.textContent = '✔ ' + body.summary.actions.length + ' action(s) — rechargement…';
|
||||
out.textContent = '' + body.summary.actions.length + ' action(s) — rechargement…';
|
||||
setTimeout(() => location.reload(), 700);
|
||||
} catch (e) {
|
||||
out.textContent = '❌ ' + e.message;
|
||||
out.textContent = '' + e.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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">
|
||||
@@ -30,13 +30,13 @@
|
||||
{% for row in group.rows %}
|
||||
{% set issue = row.issue %}
|
||||
<div class="issue-row">
|
||||
<span class="issue-state">{{ '✅' if issue.state != 'open' else '🟢' }}</span>
|
||||
<span class="issue-state">{{ '✓' if issue.state != 'open' else '●' }}</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>
|
||||
@@ -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,11 +15,11 @@
|
||||
<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>
|
||||
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
|
||||
<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>
|
||||
</section>
|
||||
@@ -31,7 +31,7 @@
|
||||
document.getElementById('sprint-copy').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(SPRINT_MD);
|
||||
document.getElementById('sprint-copy').textContent = '✅ Copié !';
|
||||
document.getElementById('sprint-copy').textContent = 'Copié !';
|
||||
} catch (e) { alert('Copie impossible : ' + e); }
|
||||
});
|
||||
document.getElementById('sprint-download').addEventListener('click', () => {
|
||||
@@ -45,11 +45,11 @@
|
||||
</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 %}
|
||||
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">🎯 {{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
|
||||
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">{{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
|
||||
{% else %}
|
||||
<li>Aucun milestone dans ce projet.</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
<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>
|
||||
<span class="chip-ok">Inclus dans le briefing ntfy du matin </span>
|
||||
{% else %}
|
||||
<span class="notes-hint">Astuce : <code>KANBANTE_BRIEFING_INCLUDE_STANDUP=1</code> l'ajoute au briefing ntfy du matin.</span>
|
||||
{% endif %}
|
||||
@@ -37,7 +37,7 @@
|
||||
const text = document.getElementById('standup-text').textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
document.getElementById('standup-copy').textContent = '✅ Copié !';
|
||||
document.getElementById('standup-copy').textContent = 'Copié !';
|
||||
} catch (e) { alert('Copie impossible : ' + e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
+10
-11
@@ -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,24 +21,24 @@
|
||||
<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 %}
|
||||
|
||||
{% if recap %}
|
||||
<section class="briefing-card">
|
||||
<h2>🌆 Ça a bougé aujourd'hui</h2>
|
||||
<h2>Ça a bougé aujourd'hui</h2>
|
||||
<ul>
|
||||
{% for event in recap %}
|
||||
<li>{{ event.created_at[11:16] }} — {{ event.repo_full_name }} #{{ event.issue_number }} : {{ event.event_type }}{% if event.to_column %} → {{ column_labels.get(event.to_column, event.to_column) }}{% endif %}</li>
|
||||
@@ -50,12 +49,12 @@
|
||||
|
||||
{% if carry_over %}
|
||||
<section class="briefing-card">
|
||||
<h2>↩️ À reprendre d'hier</h2>
|
||||
<h2>À reprendre d'hier</h2>
|
||||
<ul class="today-list">
|
||||
{% 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>
|
||||
@@ -64,12 +63,12 @@
|
||||
|
||||
{% if candidates and picks|length < 3 %}
|
||||
<section class="briefing-card">
|
||||
<h2>🎯 Candidates (encours actuel)</h2>
|
||||
<h2>Candidates (encours actuel)</h2>
|
||||
<ul class="today-list">
|
||||
{% 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,32 +32,32 @@
|
||||
</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="/triage/{{ full_name }}">🔁 Nouvelle session</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>
|
||||
|
||||
<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="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="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>
|
||||
<button type="button" data-act="assign"><kbd>A</kbd> 👤 M'assigner</button>
|
||||
<button type="button" data-act="close"><kbd>X</kbd> 🚫 Fermer</button>
|
||||
<button type="button" data-act="next"><kbd>Entrée</kbd> ⏭️ Suivante</button>
|
||||
<button type="button" data-act="undo"><kbd>Z</kbd> ↩️ Annuler</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="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>
|
||||
<button type="button" data-act="assign"><kbd>A</kbd> M'assigner</button>
|
||||
<button type="button" data-act="close"><kbd>X</kbd> Fermer</button>
|
||||
<button type="button" data-act="next"><kbd>Entrée</kbd> Suivante</button>
|
||||
<button type="button" data-act="undo"><kbd>Z</kbd> Annuler</button>
|
||||
</section>
|
||||
|
||||
<div class="triage-labels" id="triage-labels" hidden>
|
||||
<div class="triage-labels-head">
|
||||
<strong>🏷️ Étiquettes de la carte</strong>
|
||||
<strong>Étiquettes de la carte</strong>
|
||||
<button type="button" class="btn-sm" id="triage-labels-close">✕ (Échap)</button>
|
||||
</div>
|
||||
<div class="triage-labels-list" id="triage-labels-list">Chargement…</div>
|
||||
|
||||
@@ -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>Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
|
||||
<span>Ouvertes : <strong>{{ slide.open_total }}</strong></span>
|
||||
<span class="wall-live">● EN DIRECT</span>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -66,12 +67,12 @@
|
||||
<button type="button" class="wall-nav-btn" id="wall-prev" title="Projet précédent (←)">❮</button>
|
||||
<div class="wall-dots" id="wall-dots"></div>
|
||||
<button type="button" class="wall-nav-btn" id="wall-next" title="Projet suivant (→)">❯</button>
|
||||
<div class="wall-paused" id="wall-paused" hidden>⏸️ Rotation en pause — Espace pour reprendre</div>
|
||||
<div class="wall-paused" id="wall-paused" hidden>Rotation en pause — Espace pour reprendre</div>
|
||||
</nav>
|
||||
{% 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.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.27.0"
|
||||
version = "0.37.1"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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; }
|
||||
@@ -2,7 +2,11 @@
|
||||
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.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. */
|
||||
@@ -32,6 +36,7 @@
|
||||
/* 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 */
|
||||
@@ -42,6 +47,29 @@
|
||||
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;
|
||||
@@ -122,8 +150,10 @@
|
||||
}
|
||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||
.draco-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: #2f6fed; color: #fff; font-weight: 700;
|
||||
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) */
|
||||
@@ -147,8 +177,8 @@
|
||||
@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, les domaines et le menu « ••• »
|
||||
disparaissent : chaque app garde ses contrôles natifs (barre
|
||||
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
|
||||
@@ -162,5 +192,9 @@
|
||||
.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"] { display: flex; }
|
||||
.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,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; }
|
||||
+472
-175
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';
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
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
|
||||
@@ -52,6 +53,13 @@
|
||||
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;
|
||||
@@ -168,6 +176,47 @@
|
||||
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);
|
||||
+460
-42
@@ -1,5 +1,14 @@
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||
* v0.1.3 : menu des applications ouvert par la marque ✦).
|
||||
* 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
|
||||
@@ -10,19 +19,205 @@
|
||||
* 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, menu, rappel-entete. L'app intégratrice décide de
|
||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
||||
* 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. */
|
||||
@@ -43,26 +238,110 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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). */
|
||||
/* ── 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 menu = 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 buildMenu() {
|
||||
if (!bar || !brand || menu) return;
|
||||
menu = document.createElement("div");
|
||||
menu.className = "draco-apps-menu";
|
||||
menu.hidden = true;
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Applications");
|
||||
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 = t.getAttribute("href");
|
||||
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");
|
||||
@@ -84,44 +363,183 @@
|
||||
a.appendChild(txt);
|
||||
var badge = t.querySelector(".draco-badge");
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
menu.appendChild(a);
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
bar.appendChild(menu);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
function toggleMenu(force) {
|
||||
buildMenu();
|
||||
if (!menu) return;
|
||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
/* 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;
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var el = ev.target.closest("[data-act]");
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
toggleMenu();
|
||||
/* 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;
|
||||
}
|
||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
||||
toggleMenu(false);
|
||||
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);
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
relay(act, el, ev);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") toggleMenu(false);
|
||||
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. */
|
||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||
/* 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);
|
||||
}
|
||||
};
|
||||
|
||||
buildMenu();
|
||||
applyUrls(resolvedUrls);
|
||||
buildAppsMenu();
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
+183
-3
@@ -18,17 +18,32 @@
|
||||
* 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";
|
||||
var API = "https://agent.dracodev.net";
|
||||
/* 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"; }
|
||||
@@ -44,6 +59,124 @@
|
||||
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");
|
||||
@@ -57,6 +190,7 @@
|
||||
}
|
||||
function appliquerTheme() {
|
||||
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||
}
|
||||
function surveillerTheme() {
|
||||
try {
|
||||
@@ -122,6 +256,7 @@
|
||||
' <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">' +
|
||||
@@ -134,6 +269,7 @@
|
||||
' <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");
|
||||
@@ -142,9 +278,11 @@
|
||||
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);
|
||||
@@ -155,7 +293,13 @@
|
||||
appliquerLargeur();
|
||||
window.addEventListener("resize", appliquerLargeur);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,8 +333,43 @@
|
||||
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) {
|
||||
@@ -209,7 +388,7 @@
|
||||
})
|
||||
.catch(function () {
|
||||
majEtat("err", "Service d'agents injoignable");
|
||||
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
||||
ajouterMsg("sys", "Le service d'agents (" + API + ") ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -665,6 +844,7 @@
|
||||
root.classList.add("open");
|
||||
appliquerTheme();
|
||||
verifierIdentite();
|
||||
chargerVersion();
|
||||
setTimeout(function () { inputEl.focus(); }, 300);
|
||||
}
|
||||
function fermer() { if (root) root.classList.remove("open"); }
|
||||
|
||||
+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();
|
||||
})();
|
||||
@@ -34,8 +34,47 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/* En mobile, le bouton de repli dans le tiroir FERME le tiroir :
|
||||
replier un tiroir de 264 px n'a pas de sens, et avant ce
|
||||
correctif il basculait l'état desktop (tiroir 68 px). */
|
||||
var sideLinks = document.querySelectorAll('.side-link');
|
||||
sideLinks.forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
if (window.innerWidth <= 900) closeDrawer();
|
||||
});
|
||||
});
|
||||
if (collapseBtn) {
|
||||
collapseBtn.addEventListener('click', function () {
|
||||
if (window.innerWidth <= 900) { closeDrawer(); return; }
|
||||
var collapsed = document.body.classList.toggle('sidebar-collapsed');
|
||||
try {
|
||||
localStorage.setItem('kanbante-sidebar', collapsed ? 'collapsed' : 'open');
|
||||
|
||||
@@ -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
@@ -75,7 +75,7 @@
|
||||
var item = document.createElement('span');
|
||||
item.className = 'repo-item';
|
||||
item.setAttribute('role', 'option');
|
||||
var star = repo.starred ? '⭐ ' : '';
|
||||
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';
|
||||
|
||||
+3
-3
@@ -31,7 +31,7 @@
|
||||
var data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || 'échec');
|
||||
btn.setAttribute('data-starred', want ? '1' : '0');
|
||||
btn.textContent = want ? '⭐' : '☆';
|
||||
btn.textContent = want ? '★' : '☆';
|
||||
btn.title = want ? 'Désépingler (étoile Gitea)' : 'Épingler (étoile Gitea)';
|
||||
var card = btn.closest('.project-card');
|
||||
if (card) {
|
||||
@@ -44,9 +44,9 @@
|
||||
refreshPinnedVisibility();
|
||||
}
|
||||
} catch (err) {
|
||||
btn.textContent = '⚠️';
|
||||
btn.textContent = '!';
|
||||
setTimeout(function () {
|
||||
btn.textContent = btn.getAttribute('data-starred') === '1' ? '⭐' : '☆';
|
||||
btn.textContent = btn.getAttribute('data-starred') === '1' ? '★' : '☆';
|
||||
}, 1600);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
|
||||
+101
-6
@@ -187,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), []))
|
||||
|
||||
@@ -313,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()
|
||||
@@ -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
|
||||
+359
-1
@@ -15,10 +15,171 @@ def test_barre_presente_sur_le_dashboard(client):
|
||||
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.
|
||||
# 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):
|
||||
@@ -62,3 +223,200 @@ def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea):
|
||||
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"
|
||||
|
||||
@@ -97,7 +97,20 @@ def test_pont_inerte_sans_secret(monkeypatch, fake_gitea):
|
||||
|
||||
|
||||
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,36 @@
|
||||
"""Icônes monochromes (#59, suite) : plus d'emoji colorés dans les
|
||||
gabarits applicatifs (hors barre Fusion partagée, favicons et
|
||||
contenu utilisateur ), étoile et thème en symboles monochromes.
|
||||
"""
|
||||
|
||||
|
||||
def test_pas_d_emoji_colores_dans_les_gabarits():
|
||||
import pathlib
|
||||
emoji = ["🎯", "🔮", "🩺", "📖", "🏷️", "🧱", "➕", "📝", "💾", "📎", "💬",
|
||||
"📄", "📲", "📤", "✍️", "⚡", "🔌", "📱", "📈", "💤", "👥", "🔭",
|
||||
"📜", "🧾", "🧠", "👀", "📦", "🐌", "🧟", "🗝️", "🐙", "🍵", "🟢",
|
||||
"🔴", "✅", "❌", "⭐", "🐢", "⏱️", "📊", "🗣️", "🛩️", "⚙️", "📰",
|
||||
"🗄️", "🖥️", "🛠️", "🧭", "🎛️", "⌨️", "🔗", "🌆", "🚧", "🚫", "⏭️",
|
||||
"🔁", "☀️", "🌙"]
|
||||
for path in pathlib.Path("app/templates").glob("*.html"):
|
||||
if path.name in ("_draco_bar.html",):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
if 'rel="icon"' in line:
|
||||
continue
|
||||
for e in emoji:
|
||||
assert e not in line, (path.name, e, line.strip()[:100])
|
||||
|
||||
|
||||
def test_etoile_et_theme_monochromes(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
# L'étoile n'est plus jaune : couleur texte, épinglée = texte plein.
|
||||
# (#fbbf24 reste pour l'âge des cartes sur le wallboard, une
|
||||
# information sémantique, pas une icône.)
|
||||
assert '.star-btn[data-starred="1"]' in css
|
||||
assert "color: var(--text-2);" in css
|
||||
js = client.get("/static/js/stars.js").text
|
||||
assert "⭐" not in js
|
||||
nav = client.get("/static/js/nav.js").text
|
||||
assert "☀️" not in nav and "🌙" not in nav
|
||||
@@ -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
|
||||
+132
-5
@@ -1,9 +1,11 @@
|
||||
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
|
||||
"""Écritures MCP minimales : create_issue + add_comment (0.26.0),
|
||||
move_issue (0.36.0).
|
||||
|
||||
Le service d'agents doit pouvoir proposer des cartes et commenter
|
||||
via Kanbante, sans passer par l'interface web. Les deux outils
|
||||
réutilisent les services existants (création via _do_capture →
|
||||
client de forge, commentaire via client.create_comment) et sont
|
||||
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.
|
||||
"""
|
||||
@@ -145,6 +147,131 @@ def test_mcp_write_tools_are_listed(client):
|
||||
)
|
||||
|
||||
|
||||
# ── 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()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Mobile : tiroir, pied et menu compte.
|
||||
|
||||
Bugs signalés sur captures (29 octobre 2026) :
|
||||
- tiroir rétréci à 68 px en mobile quand l'état « replié » desktop
|
||||
était mémorisé (spécificité de `body.sidebar-collapsed .sidebar`) ;
|
||||
- pied de sidebar masqué en mobile (`display: none`) ;
|
||||
- Profil / Déconnexion du menu compte draco sans effet (onAction
|
||||
ne les prenait pas en charge).
|
||||
"""
|
||||
|
||||
|
||||
def test_tiroir_mobile_largeur_fixe():
|
||||
css = open("static/css/style.css", encoding="utf-8").read()
|
||||
assert "body.sidebar-collapsed .sidebar,\n body.sidebar-open.sidebar-collapsed .sidebar { width: 264px; }" in css
|
||||
assert "body.sidebar-open { overflow: hidden; }" in css
|
||||
|
||||
|
||||
def test_pied_visible_en_mobile():
|
||||
css = open("static/css/style.css", encoding="utf-8").read()
|
||||
assert ".side-foot { display: none; }" not in css
|
||||
assert "body.sidebar-collapsed .side-foot-meta { display: flex; }" in css
|
||||
|
||||
|
||||
def test_menu_compte_draco_fonctionnel():
|
||||
tpl = open("app/templates/_draco_bar.html", encoding="utf-8").read()
|
||||
assert 'act === "profil"' in tpl
|
||||
assert 'location.href = "/profile"' in tpl
|
||||
assert 'act === "deconnexion"' in tpl
|
||||
assert 'location.href = "/logout"' in tpl
|
||||
|
||||
|
||||
def test_tiroir_ferme_en_mobile():
|
||||
js = open("static/js/nav.js", encoding="utf-8").read()
|
||||
assert "window.innerWidth <= 900" in js
|
||||
assert "querySelectorAll('.side-link')" in js
|
||||
@@ -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).
|
||||
@@ -89,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
|
||||
|
||||
@@ -8,7 +8,7 @@ def test_dashboard_shows_next_milestone(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert page.status_code == 200
|
||||
# Faux : Sprint 1, échéance 2026-10-20, 1 fermée / 2 au total.
|
||||
assert "🎯 Sprint 1" in page.text
|
||||
assert "Sprint 1" in page.text
|
||||
assert "1/2 terminées" in page.text
|
||||
assert "échéance" in page.text
|
||||
|
||||
@@ -75,12 +75,12 @@ def test_project_page(client, fake_gitea):
|
||||
)
|
||||
page = client.get("/project/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
assert "🎯 Jalons ouverts" in page.text
|
||||
assert "Jalons ouverts" in page.text
|
||||
assert "Sprint 1" in page.text
|
||||
assert "🩺 Santé du flux" in page.text
|
||||
assert "🔮 Prévisions" in page.text
|
||||
assert "🕘 Activité récente" in page.text
|
||||
assert "📝 Notes du projet" in page.text
|
||||
assert "Santé du flux" in page.text
|
||||
assert "Prévisions" in page.text
|
||||
assert "Activité récente" in page.text
|
||||
assert "Notes du projet" in page.text
|
||||
assert 'href="/board/Projets/demo"' in page.text
|
||||
assert "/wall?repo=Projets/demo" in page.text
|
||||
# Titre du projet et lien Gitea du faux.
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_activity_line_recent_week(client, fake_gitea):
|
||||
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
|
||||
assert "Cette semaine : 2 fermée(s) · 1 déplacement(s)" in page.text
|
||||
|
||||
|
||||
def test_activity_line_idle_project(client, fake_gitea):
|
||||
|
||||
@@ -197,7 +197,7 @@ 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 "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
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Palier 22 (0.33.0): barre de filtres de la page Mes issues —
|
||||
facettes serveur, lignes annotées, moteur client chargé."""
|
||||
|
||||
|
||||
def test_my_page_has_filter_bar_and_facets(client):
|
||||
r = client.get("/my")
|
||||
assert r.status_code == 200
|
||||
# La barre et ses contrôles.
|
||||
assert 'id="my-filters"' in r.text
|
||||
assert 'id="my-search"' in r.text
|
||||
assert 'id="my-repo"' in r.text
|
||||
assert 'id="my-label"' in r.text
|
||||
assert 'id="my-sort"' in r.text
|
||||
assert 'id="my-col-chips"' in r.text
|
||||
assert 'id="my-count"' in r.text
|
||||
assert 'id="my-reset"' in r.text
|
||||
# Facettes avec compteurs (démo : 1 issue assignée,
|
||||
# étiquette « bug »).
|
||||
assert "Projets/demo (1)" in r.text
|
||||
assert "bug (1)" in r.text
|
||||
# Le moteur de filtrage est chargé, versionné.
|
||||
assert "/static/js/my.js?v=" in r.text
|
||||
|
||||
|
||||
def test_my_rows_carry_filter_attributes(client):
|
||||
r = client.get("/my")
|
||||
# Attributs data-* consommés par le moteur client.
|
||||
assert 'data-column="backlog"' in r.text
|
||||
assert 'data-search="#1 Tâche un Projets/demo bug"' in r.text
|
||||
assert 'data-updated="2026-09-02T09:00:00Z"' in r.text
|
||||
assert 'data-repo="Projets/demo"' in r.text
|
||||
# Chips de colonnes présentes, avec compteurs.
|
||||
assert 'data-col="backlog"' in r.text
|
||||
assert 'data-col=""' in r.text # « Toutes les colonnes »
|
||||
|
||||
|
||||
def test_my_empty_state_present_but_hidden(client):
|
||||
r = client.get("/my")
|
||||
assert 'id="my-empty" hidden' in r.text
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Palier 23 (0.34.0, issue #24): vue « En vol partout » —
|
||||
l'encours tous projets, regroupé par colonne, plus vieilles
|
||||
cartes d'abord."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import models
|
||||
|
||||
|
||||
def _days_ago(days: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
|
||||
def test_inflight_groups_by_column_with_project(client):
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
models.move_issue("Projets/demo", 1, "review")
|
||||
r = client.get("/inflight")
|
||||
assert r.status_code == 200
|
||||
assert "2 cartes en vol" in r.text
|
||||
assert "Tâche focus" in r.text
|
||||
assert "Tâche un" in r.text
|
||||
# Le projet est indiqué sur la carte.
|
||||
assert "Projets/demo" in r.text
|
||||
# Groupes par colonne, pas par projet.
|
||||
assert "🚧 En cours" in r.text
|
||||
assert "🔍 Révision" in r.text
|
||||
# L'âge dans la colonne est affiché.
|
||||
assert "dans la colonne" in r.text
|
||||
|
||||
|
||||
def test_inflight_excludes_backlog_and_todo(client):
|
||||
models.move_issue("Projets/demo", 5, "todo")
|
||||
r = client.get("/inflight")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche focus" not in r.text
|
||||
assert "Rien en vol" in r.text
|
||||
|
||||
|
||||
def test_inflight_oldest_first(client):
|
||||
# Deux cartes en cours : entrée il y a 10 j et il y a 2 j.
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
models.move_issue("Projets/demo", 1, "in_progress")
|
||||
models.log_event(
|
||||
"Projets/demo", 5, "moved", to_column="in_progress",
|
||||
created_at=_days_ago(10),
|
||||
)
|
||||
models.log_event(
|
||||
"Projets/demo", 1, "moved", to_column="in_progress",
|
||||
created_at=_days_ago(2),
|
||||
)
|
||||
r = client.get("/inflight")
|
||||
assert r.status_code == 200
|
||||
assert r.text.index("Tâche focus") < r.text.index("Tâche un")
|
||||
|
||||
|
||||
def test_inflight_closed_issue_disappears(client, fake_gitea):
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
issue = next(
|
||||
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
|
||||
)
|
||||
issue["state"] = "closed"
|
||||
r = client.get("/inflight")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche focus" not in r.text
|
||||
|
||||
|
||||
def test_inflight_nav_and_page_chrome(client):
|
||||
r = client.get("/inflight")
|
||||
assert 'href="/inflight"' in r.text # menu latéral Pilotage
|
||||
assert "En vol partout" in r.text
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Palier 24 (0.35.0, issue #26): jalons en écriture —
|
||||
créer, modifier (titre/échéance), fermer, depuis Kanbante."""
|
||||
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def test_milestone_create_via_api(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones",
|
||||
data={
|
||||
"title": "Version 2.0",
|
||||
"description": "Le gros morceau",
|
||||
"due_on": "2027-01-31",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["milestone"]["title"] == "Version 2.0"
|
||||
assert payload["milestone"]["due_on"] == "2027-01-31T12:00:00Z"
|
||||
titles = [
|
||||
m["title"] for m in fake_gitea._milestones["Projets/demo"]
|
||||
]
|
||||
assert "Version 2.0" in titles
|
||||
|
||||
|
||||
def test_milestone_create_rejects_bad_date(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
avant = len(fake_gitea._milestones["Projets/demo"])
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones",
|
||||
data={
|
||||
"title": "Jalon invalide",
|
||||
"due_on": "31/01/2027",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
# Rien d'écrit côté forge.
|
||||
assert len(fake_gitea._milestones["Projets/demo"]) == avant
|
||||
|
||||
|
||||
def test_milestone_update_title_and_clear_due(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones/1/update",
|
||||
data={"title": "Sprint 1 — prolongé", "due_on": "", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
sprint = fake_gitea._milestones["Projets/demo"][0]
|
||||
assert sprint["title"] == "Sprint 1 — prolongé"
|
||||
assert sprint["due_on"] is None # échéance retirée
|
||||
|
||||
|
||||
def test_milestone_close(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/milestones/1/close",
|
||||
data={"csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert fake_gitea._milestones["Projets/demo"][0]["state"] == "closed"
|
||||
# La page Projet ne liste plus le jalon fermé parmi les ouverts.
|
||||
page = client.get("/project/Projets/demo")
|
||||
assert "Version" in page.text # la page répond toujours
|
||||
assert 'data-action="/api/repos/Projets/demo/milestones"' in page.text
|
||||
|
||||
|
||||
def test_project_page_has_milestone_management(client):
|
||||
page = client.get("/project/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
assert "Nouveau jalon" in page.text
|
||||
assert "Modifier / fermer" in page.text
|
||||
assert 'name="due_on"' in page.text
|
||||
assert "/static/js/project.js?v=" in page.text
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Palier 25 (0.37.0): tri « En attente de revue » du dashboard —
|
||||
les projets (section Épinglés incluse) s'ordonnent par le nombre
|
||||
de cartes dans la colonne de validation."""
|
||||
|
||||
from app import models
|
||||
|
||||
|
||||
def test_dashboard_sort_offers_review_mode(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert '<option value="review">En attente de revue ↓</option>' in r.text
|
||||
|
||||
|
||||
def test_dashboard_cards_carry_review_count(client):
|
||||
# Aucune carte en révision au départ.
|
||||
r = client.get("/")
|
||||
assert 'data-review="0"' in r.text
|
||||
# Une carte passe en Révision : le compteur de SA carte monte.
|
||||
models.move_issue("Projets/demo", 5, "review")
|
||||
models.move_issue("Projets/demo", 1, "review")
|
||||
r = client.get("/")
|
||||
assert 'data-review="2"' in r.text
|
||||
|
||||
|
||||
def test_dashboard_js_knows_review_mode(client):
|
||||
# Le moteur de tri partagé est servi tel quel : le mode
|
||||
# 'review' doit y figurer (tri décroissant sur data-review).
|
||||
r = client.get("/static/js/dashboard.js")
|
||||
assert r.status_code == 200
|
||||
assert "mode === 'review'" in r.text
|
||||
assert "data-review" in r.text
|
||||
# Le sens : les modes numeriques trient en ka - kb (cles
|
||||
# niees = plus grand compteur d\'abord). Sans cette
|
||||
# branche, le comparateur generique inversait le tri :
|
||||
# bug attrape en navigateur reel sur le tri historique.
|
||||
assert "return ka - kb" in r.text
|
||||
@@ -121,7 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
|
||||
assert names == [
|
||||
"list_projects", "get_board", "get_issue", "get_focus",
|
||||
"search_issues", "get_activity", "get_metrics",
|
||||
"create_issue", "add_comment",
|
||||
"create_issue", "add_comment", "move_issue",
|
||||
]
|
||||
|
||||
|
||||
@@ -167,8 +167,11 @@ def test_mcp_info_endpoint(client):
|
||||
assert r.status_code == 200
|
||||
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
|
||||
# plus en lecture seule, et il le déclare honnêtement.
|
||||
# 0.36.0 : move_issue s'ajoute aux écritures déclarées.
|
||||
assert r.json()["read_only"] is False
|
||||
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
|
||||
assert r.json()["write_tools"] == [
|
||||
"create_issue", "add_comment", "move_issue",
|
||||
]
|
||||
|
||||
|
||||
# ── Board : gestion des étiquettes ────────────────────────
|
||||
|
||||
@@ -81,7 +81,7 @@ def test_forecast_page_and_milestone_banner(client, fake_gitea):
|
||||
assert "Sprint 1" in r.text
|
||||
board = client.get("/board/Projets/demo", params={"milestone": "Sprint 1"})
|
||||
assert board.status_code == 200
|
||||
assert "🔮" in board.text
|
||||
assert "milestone-forecast" in board.text
|
||||
assert "historique pour prévoir" in board.text # empty journal
|
||||
assert "/sprint/Projets/demo" in board.text
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests de la recherche fédérée Kanbante (fusion #68).
|
||||
|
||||
Fonctions pures de app/recherche.py uniquement : aucune base, aucun
|
||||
serveur, aucune forge.
|
||||
"""
|
||||
|
||||
from app.recherche import (
|
||||
cors_headers_for,
|
||||
groupes_vers_json,
|
||||
is_dev_host,
|
||||
origin_of,
|
||||
preflight_headers,
|
||||
sister_origins,
|
||||
)
|
||||
|
||||
RESOLVED_DEV = {
|
||||
"obsigate": "https://og-dev.dracodev.net/",
|
||||
"flowdeck": "https://flowdeck-dev.dracodev.net",
|
||||
"tiktak": "https://tiktak-dev.dracodev.net/",
|
||||
"kanbante": "https://kanbante-dev.dracodev.net/",
|
||||
}
|
||||
|
||||
|
||||
def test_is_dev_host():
|
||||
assert is_dev_host("kanbante-dev.dracodev.net") is True
|
||||
assert is_dev_host("kanbante.dracodev.net") is False
|
||||
assert is_dev_host("x-dev.dracodev.net.evil") is False
|
||||
assert is_dev_host(None) is False
|
||||
|
||||
|
||||
def test_origin_of():
|
||||
assert origin_of("https://og-dev.dracodev.net/") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("{{URL_OBSIGATE}}") is None
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
|
||||
# quelle que soit la résolution.
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_cors_headers_agent_console_autorise():
|
||||
# Fédération depuis la console (2026-10-28) : les origines agent
|
||||
# prod ET dev passent, avec credentials ; un tiers reste refusé.
|
||||
for origine in ("https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net"):
|
||||
headers = cors_headers_for(origine, RESOLVED_DEV)
|
||||
assert headers is not None
|
||||
assert headers["Access-Control-Allow-Origin"] == origine
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://agent.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
|
||||
def test_cors_headers():
|
||||
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og-dev.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
# Autre environnement autorisé (correctif 2026-10-28).
|
||||
assert cors_headers_for("https://og.dracodev.net",
|
||||
RESOLVED_DEV) is not None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
headers = preflight_headers("https://og-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
||||
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
|
||||
|
||||
|
||||
def test_groupes_vers_json_tronque_et_filtre():
|
||||
groups = [{
|
||||
"full_name": "Projets/site",
|
||||
"rows": [
|
||||
{"issue": {"number": 12, "title": "Bug", "state": "open",
|
||||
"body": "x" * 500, "labels": [{"name": "bug", "color": "ff0000"}],
|
||||
"token_secret": "ne-doit-pas-sortir"},
|
||||
"column": "in_progress"},
|
||||
{"issue": {"number": 13, "title": "Autre", "state": "closed",
|
||||
"body": "", "labels": []},
|
||||
"column": "done"},
|
||||
],
|
||||
}]
|
||||
data, total = groupes_vers_json(groups, limit=1)
|
||||
assert total == 1
|
||||
assert len(data[0]["rows"]) == 1
|
||||
row = data[0]["rows"][0]
|
||||
assert row["issue"]["number"] == 12
|
||||
assert len(row["issue"]["body"]) == 160
|
||||
assert "token_secret" not in row["issue"]
|
||||
assert row["issue"]["labels"] == [{"name": "bug", "color": "ff0000"}]
|
||||
assert row["column"] == "in_progress"
|
||||
|
||||
|
||||
def test_groupes_vers_json_vide():
|
||||
assert groupes_vers_json([], limit=20) == ([], 0)
|
||||
assert groupes_vers_json(None, limit=20) == ([], 0)
|
||||
|
||||
|
||||
def test_sister_origins_hote_requete():
|
||||
# Correctif 2026-10-28 : configuration muette, quel que soit
|
||||
# l'hôte de la requête, prod ET dev passent.
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="kanbante.dracodev.net"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Thème du site (#49, #54) : Kanbante doit ressembler à la console
|
||||
agent / barre Fusion (draco-barre, Console v7.2), pas au bleu/mauve
|
||||
historique — et l'accent est désormais le vert Kanbante, pas le
|
||||
rose Spark. Le test fige les jetons et interdit le retour des
|
||||
littéraux rose/violet/bleu de thème dans style.css.
|
||||
"""
|
||||
|
||||
|
||||
def test_theme_agent_tokens(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert "--bg: #101211;" in css
|
||||
assert "--surface: #1a1c1a;" in css
|
||||
assert "--surface-2: #242624;" in css
|
||||
assert "--surface-3: #20221f;" in css
|
||||
assert "--border: #2e302c;" in css
|
||||
assert "--text: #eceee9;" in css
|
||||
assert "--accent: #0d9488;" in css
|
||||
assert "--accent-2: #14b8a6;" in css
|
||||
|
||||
|
||||
def test_no_blue_purple_theme_literals(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
for banned in (
|
||||
"#7c5cfc", "#5b8af7", "#58a6ff", "#a78bfa", "#7dd3fc",
|
||||
"#c4b5fd", "#a5c4fd", "#0b0e14", "#141822", "#1b202e",
|
||||
"#232939", "124, 92, 252", "124,92,252",
|
||||
"91, 138, 247", "91,138,247",
|
||||
"#c2437e", "#e078a8", "194, 67, 126", "194,67,126",
|
||||
"224, 120, 168", "224,120,168",
|
||||
):
|
||||
assert banned not in css, banned
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tuile Projets et pied de sidebar : ergonomie refondue.
|
||||
|
||||
Tuile : en-tête groupé, avancement labellisé, jalon étiqueté,
|
||||
pied en 2 rangées avec répartition labellisée par colonne.
|
||||
Pied : Sortir icône seule, fraîcheur en pastille discrète.
|
||||
"""
|
||||
|
||||
|
||||
def test_tuile_structure(client, fake_gitea):
|
||||
page = client.get("/")
|
||||
assert 'class="card-header-left"' in page.text
|
||||
assert 'class="card-header-actions"' in page.text
|
||||
assert 'class="foot-main"' in page.text
|
||||
assert 'class="foot-dist"' in page.text
|
||||
# Répartition, avancement et jalon sont conditionnels (stats /
|
||||
# jalon) : on les vérifie dans le gabarit, pas dans la page de
|
||||
# test sans données.
|
||||
tpl = open("app/templates/dashboard.html", encoding="utf-8").read()
|
||||
assert 'aria-label="Répartition par colonne"' in tpl
|
||||
assert 'class="progress-head"' in tpl
|
||||
assert "Avancement" in tpl
|
||||
assert 'class="ms-kicker">Prochain jalon' in tpl
|
||||
assert "COLUMN_LABELS[col]" in tpl
|
||||
|
||||
|
||||
def test_pied_sidebar_structure():
|
||||
tpl = open("app/templates/_header.html", encoding="utf-8").read()
|
||||
assert 'class="side-foot-user"' in tpl
|
||||
assert 'class="side-foot-meta"' in tpl
|
||||
assert 'class="side-logout logout-icon"' in tpl
|
||||
assert 'aria-label="Se déconnecter"' in tpl
|
||||
assert " Sortir</span>" not in tpl
|
||||
assert 'class="fresh-dot"' in tpl
|
||||
css = open("static/css/style.css", encoding="utf-8").read()
|
||||
assert ".side-foot-meta" in css
|
||||
assert ".fresh-dot" in css
|
||||
assert ".logout-icon" in css
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Lot UX (#57 à #68) : jetons, boutons, accent-text, icônes,
|
||||
thème clair sur connexion/wallboard, en-tête de page commun.
|
||||
"""
|
||||
|
||||
|
||||
def test_tokens_et_accent_text(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
for token in (
|
||||
"--accent-text: #14b8a6;",
|
||||
"--accent-text: #0f766e;",
|
||||
"--radius-full: 999px;",
|
||||
"--shadow-lg:",
|
||||
"--font-xl: 1.55rem;",
|
||||
):
|
||||
assert token in css
|
||||
assert "linear-gradient(135deg, rgba(13,148,136,.9)" not in css
|
||||
assert '"Inter"' not in css
|
||||
|
||||
|
||||
def test_boutons_et_skeleton(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
for cls in (".btn-primary", ".btn-danger", ".btn-icon", ".skeleton", ".field-error-msg"):
|
||||
assert cls in css
|
||||
|
||||
|
||||
def test_cartes_et_labels_lisibles(client, fake_gitea):
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert "-webkit-line-clamp: 2" in css
|
||||
card = open("app/templates/_card.html", encoding="utf-8").read()
|
||||
assert "pin-btn btn-icon" in card
|
||||
assert "color-mix(in srgb" in card
|
||||
assert "📌" not in card
|
||||
|
||||
|
||||
def test_pas_de_recherche_en_double_et_theme_clair_login_wall(client, fake_gitea):
|
||||
header = open("app/templates/_header.html", encoding="utf-8").read()
|
||||
assert "topbar-search" not in header
|
||||
for tpl in ("app/templates/login.html", "app/templates/wall.html"):
|
||||
assert "kanbante-theme" in open(tpl, encoding="utf-8").read()
|
||||
css = client.get("/static/css/style.css").text
|
||||
assert 'html[data-theme="light"] .wall-body' in css
|
||||
assert 'html[data-theme="light"] .login-body' in css
|
||||
|
||||
|
||||
def test_pages_avec_en_tete_commun(client, fake_gitea):
|
||||
today = open("app/templates/today.html", encoding="utf-8").read()
|
||||
focus = open("app/templates/focus.html", encoding="utf-8").read()
|
||||
assert 'class="page-head"' in today
|
||||
assert 'class="page-head"' in focus
|
||||
assert "🌅" not in today
|
||||
Reference in New Issue
Block a user