Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99a7ebd504 | ||
|
|
e8f48d3009 | ||
|
|
dd6a4dd82c | ||
|
|
a2f3564cc8 | ||
|
|
ea39916db5 | ||
|
|
724d7a7799 | ||
|
|
5ff51d8e2d | ||
|
|
436084aebd | ||
|
|
8ab30d25ef | ||
|
|
6cac4e71f5 | ||
|
|
210f4f8983 | ||
|
|
f150d4e136 | ||
|
|
5787bcf168 | ||
|
|
80006bdf35 | ||
|
|
0219fafa34 | ||
|
|
c97dfb0ef4 | ||
|
|
1b1c793919 | ||
|
|
d11ca0cbff | ||
|
|
0e2df86be5 | ||
|
|
fd71204b5e | ||
|
|
5c8d9f1a4d | ||
|
|
7a4fb2fb7d | ||
|
|
3888e4ad6a | ||
|
|
96898bc5e6 | ||
|
|
fb61169c23 | ||
|
|
16403821de | ||
|
|
49bf9b32bf | ||
|
|
53d1d2c29d | ||
|
|
e723366755 | ||
|
|
fc70d125d4 | ||
|
|
0336f19f0f | ||
|
|
8f437e3a3e | ||
|
|
00676834e8 | ||
|
|
0e78ae3c60 | ||
|
|
b72d2607a8 | ||
|
|
2235d0be02 | ||
|
|
699600eda3 | ||
|
|
3c4bbbf2f1 | ||
|
|
7556cd0bee | ||
|
|
aa98eb6866 | ||
|
|
409e67d57b | ||
|
|
1234429a46 | ||
|
|
f7a0597575 | ||
|
|
e89f7903e1 | ||
|
|
938f8b5eb5 | ||
|
|
5f365b4b9f | ||
|
|
d867fbffbf | ||
|
|
b90941111a | ||
|
|
35f1cccd03 | ||
|
|
f29a640d9a | ||
|
|
cf98880fc8 | ||
|
|
6e86b09327 | ||
|
|
d41a79a9ed | ||
|
|
725f78cb68 | ||
|
|
39750851b0 | ||
|
|
b2d2fd1eee | ||
|
|
0de3b65869 | ||
|
|
903616a8cd | ||
|
|
e5282079b8 | ||
|
|
a30fa9d8f3 | ||
|
|
61f299305a | ||
|
|
ecf7f09fc5 | ||
|
|
9772ecdcf6 | ||
|
|
f503331773 | ||
|
|
c75ef7764f | ||
|
|
961556fb78 | ||
|
|
7a68606aa7 | ||
|
|
8015d22a06 | ||
|
|
39b5fd88f9 | ||
|
|
f5b007f286 | ||
|
|
2b9b852308 | ||
|
|
d3886b6bf5 | ||
|
|
671ee68c96 | ||
|
|
c46d6dc519 | ||
|
|
4184ba0272 | ||
|
|
751e2f4f44 | ||
|
|
975aff33a5 | ||
|
|
003e44f55e | ||
|
|
53f54b1b79 | ||
|
|
80fc260a40 | ||
|
|
8ad3a86e60 | ||
|
|
a92353f8ef | ||
|
|
8c889878ee | ||
|
|
dfb4e595ff | ||
|
|
9fe35f6c8f | ||
|
|
d419fe3086 | ||
|
|
65c467bc90 | ||
|
|
4a97e0d0dc | ||
|
|
7852d860fe | ||
|
|
d4cf8d1275 | ||
|
|
28322c12cf | ||
|
|
3964323abd | ||
|
|
4230ac1052 | ||
|
|
3809462cc7 | ||
|
|
868496891d | ||
|
|
4a947747f2 | ||
|
|
5ef6c21756 | ||
|
|
13182f5d53 | ||
|
|
2381ae0b35 | ||
|
|
7b6e72e6ce | ||
|
|
6e31122c46 | ||
|
|
d51ef5a970 | ||
|
|
735d7cdfd0 | ||
|
|
f1a05279b6 | ||
|
|
ed2834c332 | ||
|
|
5bc21e1840 |
@@ -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=
|
||||
+679
@@ -1,5 +1,684 @@
|
||||
## 0.38.1 — 2026-10-28 — Page Projets : le correctif 0.38.0 atteint enfin le navigateur (#47)
|
||||
|
||||
- **Cache-busting (#47)** : `dashboard.html` charge désormais `style.css?v={{ version }}` et `dashboard.js?v={{ version }}`, comme les autres pages (admin, board, project) et la barre Fusion. Sans ça, le navigateur gardait l'ancien CSS (menu transparent, bouton de copie non stylé) et l'ancien JS (aucun gestionnaire de copie) alors que le HTML était déjà neuf — c'est pourquoi la 0.38.0 semblait ne rien changer, bouton présent mais inerte. Le code 0.38.0 lui-même (jetons `--menu-bg` opaques, gestionnaire `data-copy-url`) était correct dans le dépôt.
|
||||
|
||||
## 0.38.0 — 2026-10-28 — Page Projets : copie d'URL et refonte du menu ⋯
|
||||
|
||||
- **Copie d'URL (#44)** : chaque tuile projet (grille, y compris Épinglés) propose désormais « 🔗 Copier l'URL du projet » dans son menu ⋯ — l'URL Gitea du dépôt est copiée dans le presse-papiers, avec confirmation « URL du projet copiée » et repli (textarea + execCommand) quand l'API Clipboard n'est pas disponible.
|
||||
- **Menu ⋯ (#45)** : refonte sur les jetons de la barre d'apps commune (draco-barre.css, prototype Console v7.2 / console Spark) — fond opaque, bordure, rayon 14 px, ombre portée, survol/focus visibles, séparateur avant l'action de copie. Corrige la cause du menu transparent : il utilisait `var(--surface-1)`, variable jamais définie.
|
||||
- **Thèmes sombre et clair** : jetons clairs par défaut, variante sombre via `[data-theme="dark"]` sur `<html>` et repli `prefers-color-scheme`, comme la barre Fusion.
|
||||
|
||||
## 0.37.6 — 2026-10-28 — Corrections console/recherche : CORS console Spark
|
||||
|
||||
- **CORS recherche (PR #43)** : la recherche Kanbante autorise désormais la console Spark (agent prod+dev) en inter-origines — les pages console peuvent appeler la recherche de l'hôte sans erreur CORS/réseau.
|
||||
|
||||
## 0.37.5 — 2026-10-28 — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
|
||||
|
||||
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte.
|
||||
|
||||
## 0.37.4 — 2026-10-28 — Correctifs recherche/alertes/console : CORS sœurs prod+dev, recherche en onglets, alertes en onglets
|
||||
|
||||
- **CORS recherche/alertes (#68/#69)** : allowlist explicite des 8 origines sœurs prod+dev (`*.dracodev.net`, avec et sans `-dev`), jamais `*`, avec credentials — pour `app/recherche.py` et le middleware CORS de `GET /api/events`.
|
||||
- **Recherche partout (#68)** : onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark, « Tout voir » restant sur place, focus automatique, mise en page corrigée.
|
||||
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout, par application, Console Spark, Historique = fermées seules), compteurs, filtre texte, onglet dans l'URL ; onglet « Console Spark » en dernier dans la barre d'apps, après Kanbante.
|
||||
|
||||
## 0.37.3 — 2026-10-28 — Programme Fusion : menu barre, alertes unifiées, recherche partout
|
||||
|
||||
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
|
||||
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CORS bornée à `/api/events`.
|
||||
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), nouvel endpoint JSON `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
|
||||
|
||||
# Changelog
|
||||
|
||||
## 0.37.2 — 2026-10-28 — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
|
||||
|
||||
- **Barre d'apps** : vendange de `dracodev-commons` v0.1.6 — dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
|
||||
|
||||
## 0.37.1 — 2026-10-26
|
||||
|
||||
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
|
||||
|
||||
Carte fusion #64. Le pied du panneau Spark (`static/js/draco-panneau.js`)
|
||||
affiche désormais la ligne discrète et permanente « Service d'agents
|
||||
vX », comme la console pleine page. La version est lue **en direct**
|
||||
dans la santé du service (`GET /healthz` de l'origine que le panneau
|
||||
connaît déjà), à l'ouverture du panneau, avec cache de session — jamais
|
||||
une valeur codée en dur ; service injoignable ou panneau non configuré
|
||||
= rien d'affiché, aucune erreur visible.
|
||||
|
||||
## 0.37.0 — 2026-10-26
|
||||
|
||||
### Ajouté — tri « En attente de revue » sur le dashboard
|
||||
|
||||
Demande de Bruno : pouvoir ordonner les projets — section
|
||||
**Épinglés** en tête — par ce qui attend sa validation.
|
||||
|
||||
- Nouveau critère dans le sélecteur de tri du dashboard,
|
||||
en première place après « Par défaut » : **🔍 En attente
|
||||
de revue ↓** — les projets s'ordonnent par le nombre de
|
||||
cartes dans la colonne de validation (clé `review` de
|
||||
l'espace), décroissant.
|
||||
- Le compteur était déjà calculé pour les chips de
|
||||
progression des cartes : il est maintenant exposé en
|
||||
`data-review` sur chaque carte projet et consommé par le
|
||||
moteur de tri partagé — le critère s'applique à **toutes
|
||||
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
|
||||
avec ou sans regroupement par propriétaire, et la
|
||||
préférence est mémorisée comme les autres tris.
|
||||
- Les critères existants (activité Gitea, activité kanban,
|
||||
issues ouvertes, nom) sont inchangés.
|
||||
|
||||
### Corrigé — sens des tris numériques du dashboard
|
||||
|
||||
Le contrôle en navigateur réel a révélé que le tri
|
||||
« Issues ouvertes ↓ » triait en réalité **croissant**
|
||||
depuis son introduction : le comparateur partagé inversait
|
||||
les clés numériques. Les modes numériques (`open` et le
|
||||
nouveau `review`) trient maintenant en soustraction de
|
||||
clés — le plus grand compteur en premier, comme l'annonce
|
||||
le « ↓ ». Vérifié sur les données réelles : Épinglés
|
||||
ordonnés 196, 95, 32, 5, 4 en attente de revue.
|
||||
|
||||
|
||||
## 0.36.0 — 2026-10-26
|
||||
|
||||
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
|
||||
|
||||
Le service d'agents sait proposer et appliquer des déplacements
|
||||
de cartes par approbation (`kanbante.move_card`), mais le serveur
|
||||
MCP de Kanbante n'exposait en écriture que `create_issue` et
|
||||
`add_comment` : un déplacement approuvé aurait échoué faute
|
||||
d'outil. Le trou est fermé.
|
||||
|
||||
- **Nouvel outil `move_issue`** (paramètres `owner`, `repo`,
|
||||
`number`, `column`) : déplace une carte vers une autre colonne
|
||||
en réutilisant le service de déplacement de l'interface
|
||||
(`_do_move_card`, extrait de `POST /api/move` sans changement
|
||||
de comportement) — board local, journal des événements
|
||||
(source « mcp »), émission vers le journal du service
|
||||
d'agents et miroir d'étiquette.
|
||||
- **Étiquette kanban remplacée, les autres préservées** : le
|
||||
déplacement retire l'ancienne étiquette `kanban:*` reconnue et
|
||||
pose celle de la colonne cible ; les étiquettes libres de
|
||||
l'issue (ex. « bug ») ne sont jamais touchées.
|
||||
- **Colonne souple** : la clé (`review`), l'étiquette de forge
|
||||
(`kanban:review`, alias d'espace compris) et le libellé
|
||||
français affiché (« Révision », « À faire », « En cours »…)
|
||||
sont acceptés ; les libellés viennent de la définition des
|
||||
colonnes de l'espace, pas d'une table figée.
|
||||
- **Garde doctrine** : la cible `done` (« Terminé ») est
|
||||
**refusée avant toute écriture**, avec une erreur explicite —
|
||||
Terminé appartient à Bruno dans son interface, les agents
|
||||
s'arrêtent en Révision.
|
||||
- **Erreurs propres** : colonne inconnue (avec la liste des
|
||||
colonnes valides), dépôt ou issue inconnu, pull request
|
||||
refusée comme n'étant pas une carte.
|
||||
- Même garde que les autres outils /mcp (session, Basic ou
|
||||
jeton d'API `kbt_`).
|
||||
|
||||
## 0.35.0 — 2026-10-26
|
||||
|
||||
### Ajouté — jalons en écriture depuis la page Projet (#26)
|
||||
|
||||
Seconde et dernière carte du backlog de la feuille de
|
||||
route « Gestion de projets » : les jalons n'étaient que lus
|
||||
dans Kanbante, ils s'écrivent maintenant — la forge reste
|
||||
la source de vérité, rien n'est stocké localement.
|
||||
|
||||
- **Créer** un jalon (titre, description et échéance
|
||||
optionnelles) depuis la page Projet, formulaire
|
||||
« ➕ Nouveau jalon ».
|
||||
- **Modifier** titre et échéance d'un jalon ouvert
|
||||
(formulaire déplié par jalon ; échéance vidée = échéance
|
||||
retirée, par un `null` explicite côté forge).
|
||||
- **Fermer** un jalon, avec confirmation (ses issues
|
||||
restent ouvertes).
|
||||
- Écritures dans la forge via son API (`POST/PATCH
|
||||
.../milestones`), CSRF vérifié, échéance invalide
|
||||
refusée proprement **avant** toute écriture ; le board
|
||||
filtré et le bandeau de progression reflètent le nouveau
|
||||
jalon au chargement suivant, sans redémarrage.
|
||||
- Échéance envoyée à **midi UTC** : Gitea renvoie les dates
|
||||
dans le fuseau de son instance — à minuit UTC, l'échéance
|
||||
affichée reculait d'un jour (attrapé au smoke réel).
|
||||
- Hors périmètre (assumé par l'issue) : supprimer un jalon,
|
||||
réassigner les issues entre jalons.
|
||||
|
||||
|
||||
## 0.34.0 — 2026-10-26
|
||||
|
||||
### Ajouté — vue « En vol partout » /inflight (issue #24)
|
||||
|
||||
Dernière carte du backlog de la feuille de route « Gestion
|
||||
de projets », première du classement big picture (pilotage
|
||||
quotidien des agents avant gestion de projet).
|
||||
|
||||
- **Page /inflight** : tout l'encours (colonnes en vol de
|
||||
l'espace — En cours, QA, Révision par défaut) **tous
|
||||
projets actifs confondus, regroupé par colonne** — là où
|
||||
/focus groupe par projet et /my par assignation.
|
||||
- Chaque carte : projet (lien board), titre, étiquettes,
|
||||
assigné, et **âge dans la colonne** (badge ⏳, teinté
|
||||
selon les seuils d'ancienneté) — les groupes sont **triés
|
||||
par âge décroissant** : les plus vieilles cartes, le vrai
|
||||
signal, en premier.
|
||||
- En-tête : total en vol + compteurs par colonne ;
|
||||
**limite WIP globale** affichée quand des projets en
|
||||
définissent (somme par colonne).
|
||||
- Entrées : menu latéral **Pilotage → 🛩️ En vol** et
|
||||
palette Ctrl+K. Rafraîchissement doux par le SSE
|
||||
`/api/stream` comme les autres vues (fermer une issue la
|
||||
retire au rechargement).
|
||||
- Réutilise le pipeline du Focus (`_collect_focus_groups`,
|
||||
`_card_ages`) : aucun appel forge supplémentaire.
|
||||
|
||||
|
||||
## 0.33.0 — 2026-10-26
|
||||
|
||||
### Ajouté — filtrage professionnel sur « Mes issues » (/my)
|
||||
|
||||
Demande de Bruno : retrouver vite une issue parmi toutes
|
||||
celles qui lui sont assignées (467 ouvertes au moment du
|
||||
test réel).
|
||||
|
||||
- **Barre de filtres** en carte, dans la langue visuelle du
|
||||
compositeur 0.31.1 : recherche texte plein champ
|
||||
(titre, projet, #numéro, étiquettes — insensible aux
|
||||
accents et à la casse, raccourci « / », Échap pour
|
||||
effacer), listes **Projet** et **Étiquette** avec
|
||||
compteurs, **chips de colonnes** colorées avec compteurs
|
||||
(multi-sélection), **tri** (activité récente, plus
|
||||
anciennes, titre, projet).
|
||||
- **Performance** : filtrage 100 % côté client sur des
|
||||
attributs précalculés côté serveur — aucun aller-retour
|
||||
réseau par frappe, application en requestAnimationFrame.
|
||||
Compteur en direct (« 24 sur 467 issues »), compteurs de
|
||||
section mis à jour, état vide propre avec réinitialisation
|
||||
en un clic.
|
||||
- **Persistance** : projet, colonnes, étiquette et tri sont
|
||||
retenus en localStorage d'une visite à l'autre (la
|
||||
recherche texte reste éphémère) ; bouton « Réinitialiser
|
||||
les filtres » dès qu'un filtre est actif.
|
||||
|
||||
|
||||
## 0.32.2 — 2026-10-25
|
||||
|
||||
### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête
|
||||
|
||||
Sur kanbante-dev, le mode console du panneau était une page morte
|
||||
et les liens inter-apps n'étaient pas tous en dev. Couche fautive
|
||||
trouvée dans `app/config.py` : la dérivation de la 0.32.1 ne
|
||||
couvrait que les URL sœurs depuis `AGENT_EVENEMENTS_URL` — jamais
|
||||
`agent_url` lui-même. Sur la dev, où seul le journal est déclaré
|
||||
en `-dev`, le panneau recevait donc le défaut prod
|
||||
`https://agent.dracodev.net` : iframe de console pointant un agent
|
||||
prod pas encore installé, donc injoignable. Et aucune résolution
|
||||
ne tenait compte de la requête : sans variable d'environnement,
|
||||
un hôte public en `-dev` retombait entièrement sur la prod.
|
||||
Enfin, aucune CSP n'existait : l'origine de l'agent n'était
|
||||
énoncée nulle part.
|
||||
|
||||
- **Résolution par requête** (`resoudre_urls_draco` dans
|
||||
`app/config.py`, consommée par `render()` dans `app/main.py`)
|
||||
pour tout ce qui est rendu à une requête — liens de la barre
|
||||
(`draco_urls`), source de l'iframe du panneau
|
||||
(`DRACO_PANNEAU_CONFIG.api`) et origine agent de la CSP — dans
|
||||
l'ordre : 1) la variable explicite de l'installation,
|
||||
prioritaire et inchangée (`DRACO_SSO_URL` pour ObsiGate,
|
||||
`DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
|
||||
`KANBANTE_PUBLIC_URL` pour Kanbante elle-même, `AGENT_URL`
|
||||
pour l'agent) ; 2) l'hôte public de la requête, motif fermé
|
||||
`^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>\.dracodev\.net$`
|
||||
(suffixe optionnel : vide en prod, `-dev` en dev), appliqué à
|
||||
toutes les sœurs et à l'agent — jamais de suffixe inventé
|
||||
depuis un hôte arbitraire ; hors motif (LAN, IP), étape
|
||||
suivante ; 3) la dérivation par les adresses de l'agent connues
|
||||
en configuration, **élargie** : URL d'événements **ou** base du
|
||||
service, selon ce que l'installation déclare ; 4) le défaut
|
||||
officiel.
|
||||
- **Base de l'agent dérivée** (contextes sans requête aussi) :
|
||||
sans `AGENT_URL` explicite, si l'adresse connue de l'agent
|
||||
(événements ou base) suit `agent<SUFFIXE>.dracodev.net`,
|
||||
`agent_url` devient `https://agent<SUFFIXE>.dracodev.net` au
|
||||
lieu de rester au défaut prod.
|
||||
- **CSP nouvelle** : chaque page rendue énonce
|
||||
`Content-Security-Policy: frame-src 'self' <origine agent>` —
|
||||
exactement l'origine résolue pour cette même réponse, celle de
|
||||
l'iframe de la console (seule iframe de l'application).
|
||||
- Les contextes sans requête gardent la résolution par
|
||||
configuration, inchangée sinon. Aucun SSO ajouté : Kanbante
|
||||
n'en a pas par conception ; périmètre limité à ses liens et à
|
||||
son panneau.
|
||||
- Tests : requête `Host: kanbante-dev.dracodev.net` sans aucune
|
||||
variable → barre, iframe et CSP entièrement en `-dev` ; hôte
|
||||
prod → noms officiels ; hôte LAN → repli configuration/défauts ;
|
||||
explicite gagne sur l'hôte ; base de l'agent dérivée du seul
|
||||
journal et dérivation élargie à la base du service ; contextes
|
||||
sans requête inchangés. Suite complète : 278 tests réussis.
|
||||
- **Déploiement** : cette version part avec le prochain
|
||||
redéploiement Docker (la 0.32.1 n'a jamais été redéployée) —
|
||||
preuve en direct **en attente du redéploiement Docker de
|
||||
Bruno**, la version vivante sur kanbante-dev reste l'ancienne.
|
||||
|
||||
## 0.32.1 — 2026-10-25
|
||||
|
||||
### Corrigé — URL sœurs de la barre dérivées de l'environnement de l'agent
|
||||
|
||||
Sur la dev, le menu inter-apps pointait vers les noms **prod**
|
||||
des apps sœurs : seul `AGENT_EVENEMENTS_URL` y est posé (hôte
|
||||
`agent-dev.dracodev.net`), et chaque URL sœur retombait sur son
|
||||
défaut officiel faute de variable explicite — il aurait fallu
|
||||
déclarer quatre variables de plus dans le `.env` dev.
|
||||
|
||||
- **Résolution des URL sœurs** dans `app/config.py` (les
|
||||
gabarits et le JS de la barre consomment `draco_urls`
|
||||
inchangé), dans l'ordre : 1) la variable explicite —
|
||||
`DRACO_SSO_URL` (ObsiGate), `DRACO_FLOWDECK_URL`,
|
||||
`DRACO_TIKTAK_URL`, `KANBANTE_PUBLIC_URL` (Kanbante
|
||||
elle-même) — prioritaire, contrat inchangé ; 2) **dérivation
|
||||
d'environnement** depuis l'origine de l'agent telle que
|
||||
l'app la connaît déjà (`AGENT_EVENEMENTS_URL`) : si son
|
||||
hôte est de la forme `agent<SUFFIXE>.dracodev.net`
|
||||
(suffixe vide ou, typiquement, `-dev`), l'URL sœur devient
|
||||
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`,
|
||||
`flowdeck`, `tiktak`, `kanbante`) ; hôte hors motif (autre
|
||||
domaine, LAN, IP) = **aucune dérivation**, jamais de lien
|
||||
deviné ; 3) le défaut officiel (prod), comme avant.
|
||||
- L'URL Gitea ne change pas.
|
||||
- Tests : avec le seul `AGENT_EVENEMENTS_URL` en `-dev`, les
|
||||
quatre URL sœurs servies sont en `-dev` ; une URL explicite
|
||||
gagne sur la dérivation ; hôte d'agent hors motif et aucun
|
||||
environnement posé restent aux défauts officiels
|
||||
(non-régression).
|
||||
|
||||
|
||||
## 0.32.0 — 2026-10-25
|
||||
|
||||
### Refactor — noms publics dracodev par configuration (dev/prod)
|
||||
|
||||
Aucun nom public dracodev n'est plus codé en dur dans le code
|
||||
vivant (gabarits, JS statique) : tout passe par `app/config.py`,
|
||||
dont les défauts sont les noms **officiels** (prod). L'environnement
|
||||
dev déclare ses noms en `-dev` dans son `.env`, sans toucher au code.
|
||||
|
||||
- **Nouvelles variables** : `KANBANTE_PUBLIC_URL` (propre URL
|
||||
publique : lien de la barre + `return_to` SSO), `DRACO_FLOWDECK_URL`
|
||||
et `DRACO_TIKTAK_URL` (liens de la barre commune ; le lien ObsiGate
|
||||
réutilise `DRACO_SSO_URL`, déjà existant), `AGENT_URL` (panneau
|
||||
Spark/console). `AGENT_EVENEMENTS_URL` se dérive désormais de
|
||||
`AGENT_URL` quand il n'est pas posé explicitement (l'override
|
||||
compose existant garde la priorité).
|
||||
- **Barre commune** (`_draco_bar.html`) : les quatre liens d'apps
|
||||
viennent de `draco_urls` injecté par `render()` ; le panneau
|
||||
reçoit l'adresse du service via `window.DRACO_PANNEAU_CONFIG`
|
||||
injecté avant `draco-panneau.js` (chargé avec le cache-busting
|
||||
existant `?v={{ version }}`). `draco-panneau.js` ne contient plus
|
||||
aucun nom : sans injection, il se déclare non configuré.
|
||||
- **`login.html`** : le lien « Se connecter avec ObsiGate (SSO) »
|
||||
est construit depuis la configuration (`sso_authorize_url`).
|
||||
Note : ce lien SSO livré de longue date (Fusion #27) contredit le
|
||||
document d'installation dev affirmant « pas de SSO par conception »
|
||||
pour Kanbante — comportement existant conservé tel quel, juste
|
||||
rendu configurable ; la contradiction documentaire est signalée,
|
||||
pas tranchée ici.
|
||||
- `git.dracodev.net` inchangé : infrastructure (un seul Gitea,
|
||||
`GITEA_URL` / `KANBANTE_GITEA_URL` côté sync), hors renommage dev/prod.
|
||||
- Tests barre/SSO mis en cohérence via la configuration (défauts
|
||||
officiels + variantes `-dev`), `.env.example` créé, README et
|
||||
`docker-compose.yml` documentent chaque variable touchée.
|
||||
|
||||
|
||||
## 0.31.1 — 2026-10-23
|
||||
|
||||
### Corrigé — compositeur de création refait
|
||||
|
||||
Retour de Bruno sur la 0.31.0 : la barre de création
|
||||
n'était ni élégante ni professionnelle, et le bouton
|
||||
« + Description » pouvait rester sans effet (écouteur
|
||||
JavaScript non enregistré selon l'état du cache ou des
|
||||
scripts de la page).
|
||||
|
||||
- **Compositeur en carte** : en-tête « Nouvelle issue »
|
||||
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
|
||||
nom personnalisé de l'espace respecté), champ titre
|
||||
pleine largeur avec anneau de focus, bouton primaire
|
||||
« Créer l'issue ».
|
||||
- **Description en `<details>` natif** : « Ajouter une
|
||||
description ▾ » déplie la zone texte sans aucun
|
||||
JavaScript — impossible à casser par un script en
|
||||
erreur ou un fichier en cache. Aide intégrée (Markdown,
|
||||
Ctrl+Entrée pour créer, actif aussi depuis le titre).
|
||||
- **Assets du board versionnés** (`?v=<version>` sur le
|
||||
CSS et board.js du board) : un redéploiement invalide
|
||||
le cache navigateur au lieu de servir un mélange
|
||||
d'anciennes et nouvelles pièces.
|
||||
- Vérifié en navigateur réel (Chromium) sur le vrai
|
||||
board : captures fermé/ouvert contrôlées visuellement,
|
||||
clic sur le libellé, zone visible et éditable.
|
||||
|
||||
|
||||
## 0.31.0 — 2026-10-23
|
||||
|
||||
### Ajouté — description éditable, pièces jointes, clic fiable
|
||||
|
||||
Retours de Bruno : impossible d'ajouter une description
|
||||
après création, aucun moyen de joindre des fichiers comme
|
||||
dans Gitea, et clic sur les cartes capricieux.
|
||||
|
||||
- **Titre et description éditables** dans le tiroir de
|
||||
détail (bouton « ✏️ Modifier le titre et la description ») :
|
||||
l'enregistrement écrit directement dans l'issue de la
|
||||
forge (PATCH), le journal consigne « edited ✏️ », et la
|
||||
carte du board reflète le nouveau titre sans recharger.
|
||||
- **Description dès la création** : le formulaire « Nouvelle
|
||||
issue » gagne un bouton « + Description » qui déplie un
|
||||
champ texte transmis à la création.
|
||||
- **Pièces jointes d'issues** (Gitea) : dans le tiroir,
|
||||
liste des fichiers (nom, taille, date), **ajout** par le
|
||||
sélecteur de fichier (25 Mio max), **téléchargement**
|
||||
proxifié par Kanbante (le jeton ne quitte jamais le
|
||||
serveur ; suit le `browser_download_url` de Gitea, dont
|
||||
l'endpoint d'asset ne renvoie que la fiche JSON — attrapé
|
||||
au smoke réel), **suppression** avec confirmation. Sur
|
||||
GitHub, l'API ne gère pas les pièces jointes d'issues :
|
||||
l'écran l'explique et renvoie à l'interface web.
|
||||
- **Clic sur les cartes fiabilisé** : l'ouverture du tiroir
|
||||
passe en **délégation d'événement** — avant, l'écouteur
|
||||
était posé carte par carte au chargement ; le premier
|
||||
rafraîchissement temps réel remplaçait les fragments et
|
||||
les cartes devenaient muettes. Le seuil anti-glisser
|
||||
(6 px) et l'exclusion liens/boutons sont conservés, et
|
||||
les cartes affichent un curseur pointeur.
|
||||
|
||||
|
||||
## 0.30.0 — 2026-10-23
|
||||
|
||||
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
|
||||
|
||||
Demande de Bruno : pouvoir personnaliser, retirer et ajouter
|
||||
des colonnes (Backlog, À faire, En cours, …) et configurer le
|
||||
mapping label/colonne depuis l'interface web.
|
||||
|
||||
- **Colonnes par espace** (Administration → Colonnes, ou le
|
||||
bouton 🧱 Colonnes sur un board) : ajouter une colonne
|
||||
(insérée avant Terminé), la renommer (le nom affiché est
|
||||
libre, émojis compris), changer sa couleur, la monter ou la
|
||||
descendre, la supprimer — ses cartes sont alors déplacées
|
||||
vers une colonne de destination choisie. Réinitialisation
|
||||
aux six colonnes historiques en un clic. Chaque espace a SES
|
||||
colonnes.
|
||||
- **Clés immuables** : une colonne garde sa clé interne
|
||||
(`en_developpement`) quoi qu'on renomme — les cartes, les
|
||||
limites WIP et les règles y restent rattachés. Backlog et
|
||||
Terminé sont des colonnes système (renommables, non
|
||||
supprimables) : défaut des cartes neuves, et issues fermées.
|
||||
- **Mapping étiquette ↔ colonne configurable** : chaque
|
||||
colonne de flux a son **étiquette mappée** (défaut
|
||||
`kanban:<clé>`) posée sur l'issue à chaque déplacement
|
||||
(miroir), plus des **alias** reconnus à l'adoption — par
|
||||
exemple déclarer `kanban_a_faire` (convention Flowdeck) en
|
||||
alias de la colonne À faire fait adopter cette colonne par
|
||||
le board. Adoption : exactement une étiquette reconnue,
|
||||
comme avant.
|
||||
- **Focus / Wallboard** : chaque colonne déclare si elle y
|
||||
figure (case « Dans Focus / Wallboard ») ; les nouvelles
|
||||
colonnes y sont par défaut.
|
||||
- Sans personnalisation, tout est strictement identique à
|
||||
avant (définitions semées paresseusement depuis les valeurs
|
||||
historiques). Le triage au clavier garde ses touches
|
||||
historiques (1 = À faire, 2 = En cours, …).
|
||||
|
||||
|
||||
## 0.29.0 — 2026-10-23
|
||||
|
||||
### Modifié — panneau Spark V3 : la console en surimpression pleine page
|
||||
|
||||
Le bouton « Console ↗ » du panneau Spark (demande de Bruno) ne part
|
||||
plus en nouvel onglet : la console web du service d'agents
|
||||
(https://agent.dracodev.net/) s'ouvre désormais **dans la même
|
||||
page**, en surimpression qui recouvre tout l'écran avec la même
|
||||
animation d'ouverture que la barre latérale (panneau vendangé
|
||||
`draco-panneau.js`, recopié octet-identique dans les 4 apps ; les
|
||||
styles de la surimpression sont injectés par le panneau lui-même).
|
||||
La barre de la surimpression offre les deux sorties : « Mode
|
||||
sidebar » referme la console et rouvre l'agent en barre latérale,
|
||||
« Fermer l'agent » ferme la surimpression et le panneau. La
|
||||
discussion affichée et l'app d'origine (`?disc=<id>&app=kanbante`)
|
||||
passent comme avant à la console. Côté service d'agents : la
|
||||
console autorise l'encadrement par les 4 apps (`frame-ancestors`,
|
||||
service 0.20.0). Même livraison : TikTak 0.60.0, Flowdeck 7.81.0,
|
||||
ObsiGate 2.77.0. **Redéploiement Docker requis** pour l'activer.
|
||||
|
||||
## 0.28.1 — 2026-10-22
|
||||
|
||||
### Ajouté — panneau Spark : bouton « Console ↗ »
|
||||
|
||||
Bouton « Console ↗ » dans la barre des discussions du panneau
|
||||
Spark (demande de Bruno) : ouvre la console web du service
|
||||
d'agents (https://agent.dracodev.net/) dans un nouvel onglet,
|
||||
sur la discussion actuellement affichée (`?disc=<id>`, URL
|
||||
construite au clic) et avec l'app d'origine (`?app=kanbante`,
|
||||
déduite du sous-domaine). Panneau recopié octet-identique dans
|
||||
les 4 apps (TikTak 0.59.1, Flowdeck 7.80.1, Kanbante 0.28.1,
|
||||
ObsiGate 2.76.1). **Redéploiement Docker requis** pour l'activer.
|
||||
|
||||
## 0.28.0 — 2026-10-22
|
||||
|
||||
### Ajouté — émission d'événements vers le journal du service d'agents (Lot 2)
|
||||
|
||||
Kanbante émet désormais ses changements de colonne au journal
|
||||
d'activité cross-app du service d'agents (contrat ACC-0.2) :
|
||||
`kanbante.card.moved` pour tout déplacement réel d'une carte —
|
||||
glisser-déposer de l'interface, adoption d'une étiquette
|
||||
`kanban:*` posée dans la forge, fermeture/réouverture venue de la
|
||||
forge (webhook ou réconciliation à l'affichage), fusion de PR,
|
||||
création de branche — et `kanbante.card.review_requested` quand
|
||||
une carte entre en colonne Révision. Enveloppe signée HMAC-SHA256
|
||||
avec la clé propre à Kanbante (`AGENT_EVENEMENTS_CLE`, URL
|
||||
réglable par `AGENT_EVENEMENTS_URL`) ; émission au mieux, en
|
||||
arrière-plan : inactive sans clé, et une panne du service
|
||||
d'agents n'affecte jamais un déplacement. Aucun changement de
|
||||
comportement visible. Redéploiement Docker requis.
|
||||
|
||||
## 0.27.0 — 2026-10-22
|
||||
|
||||
### Ajouté — panneau Spark v2 (Lot 1)
|
||||
|
||||
Le panneau vendangé passe en v2, à l'identique dans les 4 apps :
|
||||
réponses de l'agent en **Markdown rendu** (HTML du service
|
||||
d'agents 0.10.0, sanitizé côté serveur, rapports de missions
|
||||
compris), **largeur réglable** persistée, **thème clair/sombre**
|
||||
aligné sur l'app hôte ou le système, et **discussions façon
|
||||
Muse** : discussion principale perpétuelle + parallèles
|
||||
créées/archivées/supprimées depuis le tiroir du panneau.
|
||||
Redéploiement Docker requis.
|
||||
|
||||
## 0.26.0 — 2026-10-21
|
||||
|
||||
### Ajouté — panneau Spark intégré
|
||||
|
||||
Le bouton Spark de la barre commune ouvre désormais le panneau de
|
||||
l'agent commun (conversation, Aujourd'hui, approbations, missions),
|
||||
comme dans TikTak et Flowdeck : assets `draco-panneau.js/css`
|
||||
vendangés à l'identique, action `spark` branchée sur
|
||||
`DRACO_PANNEAU.toggle()`. Redéploiement Docker requis (avec le reste
|
||||
de la 0.26.0).
|
||||
|
||||
### Ajouté — écritures MCP minimales et sûres (service d'agents)
|
||||
|
||||
Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service
|
||||
d'agents (jeton `kbt_` posé en production) peut désormais créer
|
||||
des cartes et commenter, et rien d'autre : aucune suppression,
|
||||
aucun changement d'état.
|
||||
|
||||
- **`create_issue`** — arguments : `owner` (chaîne, requis),
|
||||
`repo` (chaîne, requis), `title` (chaîne, requis), `body`
|
||||
(chaîne, optionnel), `column` (optionnel, une des colonnes du
|
||||
board, `backlog` par défaut). Réutilise le service de création
|
||||
existant (`_do_capture` → `create_issue` du client de forge,
|
||||
Gitea ou GitHub selon la connexion qui détient le dépôt) : la
|
||||
carte est placée sur le board et journalisée (source `mcp`).
|
||||
Réponse JSON : `number`, `title`, `html_url`, `repo_full_name`,
|
||||
`column`.
|
||||
- **`add_comment`** — arguments : `owner`, `repo`, `number`
|
||||
(entier), `body` — tous requis. Réutilise `create_comment` du
|
||||
client de forge (mêmes validations que l'API web : corps non
|
||||
vide, 10 000 caractères max) et journalise l'événement
|
||||
`commented` (source `mcp`). Réponse JSON : `id`, `body`,
|
||||
`html_url`, `repo_full_name`, `number`.
|
||||
- **Garde inchangée** : les deux outils passent par le même
|
||||
`/mcp` et le même middleware que les lectures — session,
|
||||
Basic ou `Authorization: Bearer kbt_…` ; un jeton ne
|
||||
déverrouille toujours pas l'interface web, et sa révocation
|
||||
bloque aussitôt les écritures. Erreurs métier au format
|
||||
existant du serveur (résultat `isError` avec le message).
|
||||
- `GET /mcp` déclare désormais `read_only: false` et la liste
|
||||
`write_tools` (honnêteté de la découverte).
|
||||
- **Redéploiement Docker de Kanbante à la charge de Bruno**
|
||||
pour activer cette version en production.
|
||||
|
||||
## 0.25.0 — 2026-10-21
|
||||
|
||||
### Ajouté — jetons d'API personnels pour les surfaces machine (MCP)
|
||||
|
||||
Depuis la 0.24.0, tout Kanbante exige une authentification (session
|
||||
ou Basic) — y compris `/mcp`. Le service d'agents s'y faisait refuser
|
||||
en 401 (vu en production le 2026-10-21, panneau Spark : « Source
|
||||
Kanbante injoignable »). Il manquait une identité machine propre.
|
||||
|
||||
- Table `api_tokens` : jetons personnels préfixés `kbt_`, seule
|
||||
l'empreinte SHA-256 est conservée ; création, liste et révocation
|
||||
dans **Mon profil → Jetons d'API (MCP)**, texte clair affiché une
|
||||
seule fois à la création.
|
||||
- Le middleware honore `Authorization: Bearer kbt_…` **uniquement**
|
||||
sur `/mcp` et `/api/` : un jeton ne déverrouille jamais l'interface
|
||||
web. Révocation et désactivation du compte ont effet immédiat ;
|
||||
`last_used_at` journalise l'usage.
|
||||
- Usage visé : créer un jeton « service-agents » et le poser en
|
||||
`AGENT_MCP_KANBANTE_KEY` dans le `.env` du conteneur agent.
|
||||
|
||||
## 0.24.2 — 2026-10-21
|
||||
|
||||
### Corrigé — boîtes d'édition blanches dans l'administration (#29)
|
||||
|
||||
Signalé par Bruno (captures) : le champ « Nouveau mot de passe »
|
||||
(Administration), la zone « Sélection de projets » (Espaces) et
|
||||
les champs « Valeur du panneau » (Paramètres) s'affichaient en
|
||||
**blanc**. Il n'existait aucune règle de base pour les champs de
|
||||
saisie : seuls les champs couverts par un contexte précis
|
||||
(`.login-form`, `.capture-form`, `.admin-form` pour text/password/
|
||||
select seulement) étaient sombres — les textarea et tout champ
|
||||
hors de ces contextes gardaient le fond du navigateur.
|
||||
|
||||
Correctif dans `style.css` : une **règle de base unique en
|
||||
spécificité zéro** (`:where(...)`) — fond `--surface-2`, texte
|
||||
`--text`, bordure `--border` — pour `input`, `textarea` et
|
||||
`select`, qui comble les vides sans jamais écraser les règles de
|
||||
contexte existantes ; plus `color-scheme: dark` pour les
|
||||
contrôles natifs. Styles calculés vérifiés sur les trois pages
|
||||
(fond identique aux champs de référence, palette de commandes
|
||||
inchangée).
|
||||
|
||||
|
||||
## 0.24.0 — 2026-10-21
|
||||
|
||||
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
|
||||
|
||||
Réalise l'analyse du 2026-10-21 (décisions D1 à D6 appliquées
|
||||
selon les recommandations). Aucune configuration obligatoire :
|
||||
au premier démarrage, la connexion Gitea historique est créée
|
||||
depuis l'environnement, l'espace **Principal** la contient, et
|
||||
le compte d'environnement devient l'administrateur initial
|
||||
(mot de passe haché en base). Migration automatique des bases
|
||||
existantes (portées (1, 1) partout).
|
||||
|
||||
- **Connexions (P0/P1)** — une forge = une connexion (Gitea ou
|
||||
**GitHub**, nouveau client complet : dépôts, issues, étiquettes
|
||||
par nom, jalons, PR, archivage). Jeton **chiffré** (Fernet) en
|
||||
base, jamais réaffiché (masque ••••). Registre de clients,
|
||||
instantanés par connexion. Webhooks par connexion
|
||||
(`/webhooks/{slug}`, signatures Gitea et GitHub) en plus du
|
||||
webhook historique.
|
||||
- **Utilisateurs (P2)** — comptes en base (PBKDF2), rôles
|
||||
admin/membre, activation/désactivation (une session d'un
|
||||
compte désactivé meurt aussitôt), journal d'audit admin.
|
||||
Connexion par la base ; les identifiants d'environnement
|
||||
restent un **secours** pour le compte opérateur. Liaison
|
||||
**draco par utilisateur** (le pont SSO lie et ouvre la
|
||||
session du bon compte ; sémantique mono-utilisateur 0.23
|
||||
conservée tant qu'il n'y a qu'un compte). Page **/profile** :
|
||||
infos, mot de passe, déliaison draco, connexions personnelles.
|
||||
- **Espaces de travail (P3)** — l'unité de présentation :
|
||||
connexions attachées, membres (admin d'espace / membre /
|
||||
lecteur), sélection explicite de projets optionnelle.
|
||||
**Board, notes, épingles du jour, journal : scopés par
|
||||
espace** (le même projet peut être rangé différemment dans
|
||||
deux espaces). Sélecteur d'espace dans l'en-tête (visible
|
||||
dès qu'on a accès à plusieurs espaces), badge de connexion
|
||||
sur les projets quand l'espace en a plusieurs. Panneau
|
||||
**Paramètres** : l'environnement amorce, le panneau
|
||||
gouverne — surcharges en base pour les réglages courants
|
||||
(fenêtre d'activité, TTL, seuils zombies/âge, horaires des
|
||||
rituels, seuil d'archivage…), provenance affichée.
|
||||
- **Archives (P4)** — page **/archives** : archive **locale**
|
||||
(masque le projet dans l'espace, forge intacte, réversible),
|
||||
archivage **sur la forge** (confirmé, journalisé), purge de
|
||||
l'état local de l'espace, export du récapitulatif Markdown,
|
||||
file **« À archiver ? »** (inactifs depuis
|
||||
`archive_suggest_days`, 180 j par défaut) avec archivage
|
||||
local en lot. Les archives sortent du tableau de bord ;
|
||||
l'onglet Archivés y gagne un lien « Gérer ».
|
||||
|
||||
### Limites assumées de cette version
|
||||
|
||||
- Les rituels d'arrière-plan (briefing, digest, règles,
|
||||
serveur MCP) travaillent sur la portée historique
|
||||
(connexion principale, espace Principal).
|
||||
- `KANBANTE_MASTER_KEY` (optionnelle) : sans elle, une clé de
|
||||
chiffrement est générée une fois et gardée en base.
|
||||
|
||||
|
||||
## 0.23.3 — 2026-10-21
|
||||
|
||||
### Correctifs — barre commune v0.1.4 (retours de Bruno)
|
||||
|
||||
- **Assets de la barre versionnés** (`?v=` dans l'adresse) : sans
|
||||
ça, le navigateur — surtout la webview de l'app bureau ObsiGate —
|
||||
gardait l'ancien JavaScript et le menu du ✦ ne s'ouvrait pas,
|
||||
alors que le serveur servait déjà le bon fichier.
|
||||
- **Palier mobile élargi à 900 px** (commons v0.1.4) : entre 700
|
||||
et 900 px, la barre complète ne tenait pas alors que Kanbante
|
||||
est déjà en mise en page mobile — d'où le look différent des
|
||||
autres apps sur la capture de Bruno. Décalage du cadre aligné.
|
||||
|
||||
## 0.23.2 — 2026-10-21
|
||||
|
||||
### Ajouté — menu des applications (barre commune v0.1.3)
|
||||
|
||||
- **Le ✦ de la barre ouvre le menu des 4 applications** (choix de
|
||||
Bruno) : c'est le moyen de changer d'app en mobile, où les
|
||||
onglets sont repliés — et il fonctionne aussi sur bureau. Le
|
||||
logo est un peu plus gros (24 px). Menu construit par le JS
|
||||
commun depuis les onglets (mêmes liens, icônes, badges) ;
|
||||
fermeture au clic extérieur ou à Échap.
|
||||
|
||||
## 0.23.1 — 2026-10-21
|
||||
|
||||
### Correctifs — barre commune et barre latérale (retours de Bruno)
|
||||
|
||||
- **Barre d'apps pleine largeur** : elle était repoussée à droite
|
||||
de la barre latérale par le `padding-left` du cadre ; elle est
|
||||
maintenant fixe, pleine largeur, et le cadre entier est décalé
|
||||
de sa hauteur (60 px, 52 px en mobile).
|
||||
- **Barre commune v0.1.2** (dracodev-commons) : palier mobile
|
||||
resserré selon la spec — app courante, alertes, Spark, avatar ;
|
||||
rail, recherche, domaines et menu disparaissent au profit des
|
||||
contrôles natifs de chaque app.
|
||||
- **Animation du repli de la barre latérale** : durée et courbe
|
||||
unifiées (0,26 s, `cubic-bezier(0.4, 0, 0.2, 1)`) entre le corps
|
||||
et la barre, et retrait du `backdrop-filter` (fond déjà opaque
|
||||
à 96 %) qui rendait l'animation de largeur saccadée.
|
||||
|
||||
## 0.23.0 — 2026-10-21
|
||||
|
||||
### Fusion — Kanbante rejoint l'accès centralisé (cartes #27, #28)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
|
||||
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
|
||||
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
|
||||
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
|
||||
- 🤖 **Serveur MCP** — 7 outils de lecture (boards, focus, recherche, métriques) + 2 écritures minimales (`create_issue`, `add_comment`) exposés en JSON-RPC sur `/mcp` pour vos agents
|
||||
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
|
||||
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
|
||||
- 📝 **Notes** — bloc-notes par projet
|
||||
@@ -111,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 |
|
||||
@@ -212,15 +218,17 @@ kanbante/
|
||||
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
|
||||
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
|
||||
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
|
||||
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
|
||||
| GET/POST | `/mcp` | Serveur MCP (JSON-RPC 2.0) : lectures + `create_issue` / `add_comment` |
|
||||
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
|
||||
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
|
||||
|
||||
## Brancher un client MCP
|
||||
|
||||
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
|
||||
les lectures d'abord). Configuration type (transport HTTP) :
|
||||
Kanbante expose un serveur MCP : lectures d'abord, plus deux
|
||||
écritures minimales et sûres pour les agents — créer une carte et
|
||||
commenter (aucune suppression, aucun changement d'état).
|
||||
Configuration type (transport HTTP) :
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -233,9 +241,13 @@ les lectures d'abord). Configuration type (transport HTTP) :
|
||||
```
|
||||
|
||||
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
|
||||
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
|
||||
Kanbante est active, le client doit envoyer l'en-tête
|
||||
`Authorization` correspondant.
|
||||
`search_issues`, `get_activity`, `get_metrics`, `create_issue`
|
||||
(`owner`, `repo`, `title`, `body` et `column` optionnels),
|
||||
`add_comment` (`owner`, `repo`, `number`, `body`). Si
|
||||
l'authentification Kanbante est active, le client doit envoyer
|
||||
l'en-tête `Authorization` correspondant : Basic, ou un jeton
|
||||
d'API personnel `Bearer kbt_…` (Mon profil → Jetons d'API) —
|
||||
honoré uniquement sur `/mcp` et `/api/`, jamais sur le web.
|
||||
|
||||
## Activer le temps réel (webhook Gitea)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""User accounts: password hashing and bootstrap (0.24.0, P2).
|
||||
|
||||
Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib) with a
|
||||
per-user salt; the stored string carries its parameters
|
||||
("pbkdf2$<iterations>$<salt_b64>$<hash_b64>") so the cost can
|
||||
be raised later without invalidating existing hashes.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from app import models
|
||||
|
||||
_ITERATIONS = 260_000
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, _ITERATIONS
|
||||
)
|
||||
return "pbkdf2${}${}${}".format(
|
||||
_ITERATIONS,
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(digest).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
scheme, iterations, salt_b64, hash_b64 = stored.split("$")
|
||||
if scheme != "pbkdf2":
|
||||
return False
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(hash_b64)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, int(iterations)
|
||||
)
|
||||
return hmac.compare_digest(digest, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_bootstrap_admin() -> dict | None:
|
||||
"""Create the first admin from the env credentials.
|
||||
|
||||
Runs when the users table is empty and KANBANTE_AUTH_USER /
|
||||
_PASSWORD are configured (decision D6): Bruno's existing
|
||||
login becomes the initial database admin, with no lockout.
|
||||
The env path keeps working as an emergency fallback either
|
||||
way (see app.main auth).
|
||||
"""
|
||||
from app.config import get_settings
|
||||
|
||||
if models.count_users() > 0:
|
||||
return None
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
return None
|
||||
user_id = models.create_user(
|
||||
settings.auth_user,
|
||||
hash_password(settings.auth_password),
|
||||
role="admin",
|
||||
display_name=settings.auth_user,
|
||||
)
|
||||
return models.get_user(user_id)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Émission d'événements vers le journal du service d'agents (Lot 2).
|
||||
|
||||
Kanbante émet ses changements de colonne au journal d'activité
|
||||
cross-app du service d'agents, au contrat ACC-0.2 : enveloppe JSON
|
||||
``{id, type, occurred_at, actor, domain, source_app,
|
||||
object{ref, label, url}}`` signée HMAC-SHA256 du corps brut
|
||||
(en-tête ``X-Draco-Signature``), avec la clé propre à Kanbante
|
||||
(``AGENT_EVENEMENTS_CLE`` ; le service la connaît sous le nom
|
||||
d'app « kanbante » dans ``AGENT_EVENEMENTS_CLES``).
|
||||
|
||||
Inactif tant que la clé n'est pas posée. Jamais bloquant, jamais
|
||||
d'échec remonté au métier : l'envoi part dans un fil d'arrière-plan,
|
||||
timeout court, exceptions avalées avec un avertissement au journal
|
||||
local. L'appel sortant suit le patron de la maison (httpx, voir
|
||||
``briefing.send_ntfy``).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("kanbante")
|
||||
|
||||
SOURCE_APP = "kanbante"
|
||||
DELAI_S = 2.0
|
||||
|
||||
|
||||
def actif() -> bool:
|
||||
"""True quand l'émission est configurée (clé propre posée)."""
|
||||
return bool(get_settings().agent_evenements_cle)
|
||||
|
||||
|
||||
def construire_enveloppe(
|
||||
type_: str,
|
||||
ref: str,
|
||||
label: str,
|
||||
url: str,
|
||||
domain: str = "pro",
|
||||
actor: str = "user",
|
||||
) -> dict:
|
||||
"""L'enveloppe ACC-0.2 : une référence et un libellé, jamais le
|
||||
contenu de l'objet."""
|
||||
return {
|
||||
"id": uuid.uuid4().hex,
|
||||
"type": type_,
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
"actor": actor,
|
||||
"domain": domain,
|
||||
"source_app": SOURCE_APP,
|
||||
"object": {"ref": ref, "label": label, "url": url},
|
||||
}
|
||||
|
||||
|
||||
def signer(corps: bytes, cle: str) -> str:
|
||||
"""Signature ACC-0.2 : HMAC-SHA256 (hexadécimal) du corps brut."""
|
||||
return hmac.new(cle.encode(), corps, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _poster(url: str, corps: bytes, signature: str) -> bool:
|
||||
"""POST l'enveloppe signée ; False (et un avertissement) sur
|
||||
tout échec — le récepteur en panne ne doit rien casser ici."""
|
||||
try:
|
||||
reponse = httpx.post(
|
||||
url,
|
||||
content=corps,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Draco-Signature": signature,
|
||||
},
|
||||
timeout=DELAI_S,
|
||||
)
|
||||
if reponse.status_code >= 300:
|
||||
logger.warning(
|
||||
"Journal agent : réponse %s de %s", reponse.status_code, url
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Journal agent injoignable (%s) : %s", url, exc)
|
||||
return False
|
||||
|
||||
|
||||
def emettre(
|
||||
type_: str,
|
||||
ref: str,
|
||||
label: str,
|
||||
url: str,
|
||||
domain: str = "pro",
|
||||
actor: str = "user",
|
||||
) -> threading.Thread | None:
|
||||
"""Émettre un événement, au mieux, depuis un fil d'arrière-plan.
|
||||
|
||||
Retourne le fil lancé (les tests peuvent le joindre), ou None
|
||||
quand l'émission n'est pas configurée. Ne lève jamais.
|
||||
"""
|
||||
settings = get_settings()
|
||||
cle = settings.agent_evenements_cle
|
||||
if not cle:
|
||||
return None
|
||||
enveloppe = construire_enveloppe(
|
||||
type_, ref, label, url, domain=domain, actor=actor
|
||||
)
|
||||
corps = json.dumps(enveloppe, ensure_ascii=False).encode("utf-8")
|
||||
fil = threading.Thread(
|
||||
target=_poster,
|
||||
args=(settings.agent_evenements_url, corps, signer(corps, cle)),
|
||||
name="kanbante-agent-journal",
|
||||
daemon=True,
|
||||
)
|
||||
fil.start()
|
||||
return fil
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Colonnes de board personnalisables par espace (0.30.0).
|
||||
|
||||
Avant : six colonnes codées en dur (backlog, todo, in_progress,
|
||||
qa, review, done) et un mapping fixe colonne → étiquette
|
||||
``kanban:<colonne>``. Maintenant, chaque espace de travail
|
||||
définit ses colonnes : nom d'affichage, couleur, ordre,
|
||||
étiquette de forge mappée et alias reconnus. Les clés sont
|
||||
immuables (les données board_state les référencent) ; backlog
|
||||
et done restent des colonnes système (renommables, non
|
||||
supprimables) parce qu'elles portent une sémantique : défaut
|
||||
des cartes neuves, et état fermé des issues.
|
||||
|
||||
Sans définition propre, un espace retombe sur les défauts
|
||||
historiques — comportement 0.24 strictement conservé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app import models
|
||||
|
||||
DEFAULT_COLUMNS: list[dict] = [
|
||||
{"key": "backlog", "name": "📥 Backlog", "color": "#6b7280",
|
||||
"label": "", "aliases": "", "kind": "backlog", "in_focus": 0},
|
||||
{"key": "todo", "name": "📋 À faire", "color": "#3b82f6",
|
||||
"label": "kanban:todo", "aliases": "", "kind": "flow",
|
||||
"in_focus": 0},
|
||||
{"key": "in_progress", "name": "🚧 En cours", "color": "#f59e0b",
|
||||
"label": "kanban:in_progress", "aliases": "", "kind": "flow",
|
||||
"in_focus": 1},
|
||||
{"key": "qa", "name": "🧪 QA", "color": "#06b6d4",
|
||||
"label": "kanban:qa", "aliases": "", "kind": "flow",
|
||||
"in_focus": 1},
|
||||
{"key": "review", "name": "🔍 Révision", "color": "#8b5cf6",
|
||||
"label": "kanban:review", "aliases": "", "kind": "flow",
|
||||
"in_focus": 1},
|
||||
{"key": "done", "name": "✅ Terminé", "color": "#10b981",
|
||||
"label": "", "aliases": "", "kind": "done", "in_focus": 0},
|
||||
]
|
||||
|
||||
_cache: dict[int, list[dict]] = {}
|
||||
|
||||
|
||||
def invalidate(workspace_id: int | None = None) -> None:
|
||||
if workspace_id is None:
|
||||
_cache.clear()
|
||||
else:
|
||||
_cache.pop(workspace_id, None)
|
||||
|
||||
|
||||
def ensure_columns(workspace_id: int) -> list[dict]:
|
||||
"""Seed the historical defaults if the workspace has none."""
|
||||
existing = models.list_board_columns(workspace_id)
|
||||
if existing:
|
||||
return existing
|
||||
for position, column in enumerate(DEFAULT_COLUMNS):
|
||||
models.upsert_board_column(
|
||||
workspace_id, {**column, "position": position}
|
||||
)
|
||||
invalidate(workspace_id)
|
||||
return models.list_board_columns(workspace_id)
|
||||
|
||||
|
||||
def get_columns(workspace_id: int = 1) -> list[dict]:
|
||||
"""Effective columns of a workspace (cached, seeded lazily)."""
|
||||
if workspace_id not in _cache:
|
||||
_cache[workspace_id] = ensure_columns(workspace_id)
|
||||
return _cache[workspace_id]
|
||||
|
||||
|
||||
def keys(workspace_id: int = 1) -> list[str]:
|
||||
return [c["key"] for c in get_columns(workspace_id)]
|
||||
|
||||
|
||||
def names(workspace_id: int = 1) -> dict[str, str]:
|
||||
return {c["key"]: c["name"] for c in get_columns(workspace_id)}
|
||||
|
||||
|
||||
def colors(workspace_id: int = 1) -> dict[str, str]:
|
||||
return {c["key"]: c["color"] for c in get_columns(workspace_id)}
|
||||
|
||||
|
||||
def flow_keys(workspace_id: int = 1) -> list[str]:
|
||||
return [
|
||||
c["key"] for c in get_columns(workspace_id) if c["kind"] == "flow"
|
||||
]
|
||||
|
||||
|
||||
def focus_keys(workspace_id: int = 1) -> list[str]:
|
||||
return [
|
||||
c["key"] for c in get_columns(workspace_id) if c.get("in_focus")
|
||||
]
|
||||
|
||||
|
||||
def wall_keys(workspace_id: int = 1) -> list[str]:
|
||||
"""Wallboard cards: focus columns + the first flow column."""
|
||||
columns = get_columns(workspace_id)
|
||||
wanted = {c["key"] for c in columns if c.get("in_focus")}
|
||||
flow = [c["key"] for c in columns if c["kind"] == "flow"]
|
||||
if flow:
|
||||
wanted.add(flow[0])
|
||||
return [c["key"] for c in columns if c["key"] in wanted]
|
||||
|
||||
|
||||
def label_for(workspace_id: int, key: str) -> str | None:
|
||||
"""The forge label mirrored when a card enters this column."""
|
||||
for column in get_columns(workspace_id):
|
||||
if column["key"] == key and column["kind"] == "flow":
|
||||
return column.get("label") or None
|
||||
return None
|
||||
|
||||
|
||||
def label_lookup(workspace_id: int = 1) -> dict[str, str]:
|
||||
"""label name (lowercased) → column key, aliases included.
|
||||
|
||||
This is the configurable label/column mapping: the canonical
|
||||
label of each flow column plus every alias declared for it
|
||||
(e.g. Flowdeck's ``kanban_a_faire`` on the todo column).
|
||||
"""
|
||||
lookup: dict[str, str] = {}
|
||||
for column in get_columns(workspace_id):
|
||||
if column["kind"] != "flow":
|
||||
continue
|
||||
names = [column.get("label") or ""]
|
||||
names += [
|
||||
a.strip() for a in (column.get("aliases") or "").split(",")
|
||||
]
|
||||
for name in names:
|
||||
name = name.strip().lower()
|
||||
if name:
|
||||
lookup[name] = column["key"]
|
||||
return lookup
|
||||
|
||||
|
||||
def recognized_labels(workspace_id: int = 1) -> set[str]:
|
||||
"""Every label name (lowercased) owned by the mapping."""
|
||||
return set(label_lookup(workspace_id))
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Client de distribution du coffre de secrets ObsiGate (Phase 4, issue ObsiGate #22).
|
||||
|
||||
Patron imposé par la spec « Coffre de secrets ObsiGate » §12, appliqué ici
|
||||
sans variante :
|
||||
|
||||
- Kanbante ne stocke QUE des références ``coffre:<slug>/<nom>``, jamais une
|
||||
valeur distribuée ;
|
||||
- à chaque usage, le SERVEUR Kanbante appelle
|
||||
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
|
||||
Kanbante (distinct du jeton MCP de notes et de la session SSO) et un
|
||||
contexte obligatoire, utilise la valeur en mémoire, puis l'oublie ;
|
||||
- aucun cache de valeur, aucune persistance, aucun log de la valeur ;
|
||||
- octroi révoqué -> échec explicite orientant vers ObsiGate, sans repli
|
||||
sur une ancienne valeur ;
|
||||
- ObsiGate indisponible -> échec propre, sans continuer avec une valeur
|
||||
en cache (il ne doit pas y en avoir).
|
||||
|
||||
Configuration (variables d'environnement, jamais versionnées) :
|
||||
- ``KANBANTE_OBSIGATE_URL`` : base d'ObsiGate, ex. ``https://og.dracodev.net``
|
||||
- ``KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`` : jeton du consommateur
|
||||
« kanbante » enregistré dans ObsiGate (jeton révocable, hash + jti
|
||||
côté ObsiGate ; il n'est jamais renvoyé au client ni journalisé).
|
||||
- ``KANBANTE_COFFRE_TIMEOUT`` : délai en secondes (défaut 10).
|
||||
|
||||
Ce module ne dépend que de ``httpx`` (déjà dépendance du projet) et de
|
||||
la bibliothèque standard, pour rester testable avec un faux endpoint
|
||||
(``httpx.MockTransport``) sans instance ObsiGate réelle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("kanbante.coffre")
|
||||
|
||||
PREFIXE_REFERENCE = "coffre:"
|
||||
CHEMIN_DISTRIBUER = "/api/coffres/distribuer"
|
||||
TIMEOUT_DEFAUT = 10.0
|
||||
|
||||
# Messages explicites, orientant vers ObsiGate. Ils ne contiennent jamais
|
||||
# la valeur distribuée, ni le jeton consommateur.
|
||||
_MESSAGES_DETAIL = {
|
||||
"consommateur_invalide": (
|
||||
"Consommateur Kanbante invalide dans ObsiGate : "
|
||||
"vérifier le jeton consommateur Kanbante dans ObsiGate."
|
||||
),
|
||||
"consommateur_revoque": (
|
||||
"Consommateur Kanbante révoqué dans ObsiGate : "
|
||||
"ré-accorder l'accès du consommateur Kanbante dans ObsiGate."
|
||||
),
|
||||
"octroi_absent": (
|
||||
"Aucun octroi actif pour cette référence dans ObsiGate : "
|
||||
"créer l'octroi pour le consommateur Kanbante dans ObsiGate."
|
||||
),
|
||||
"octroi_revoque": (
|
||||
"Accès révoqué dans ObsiGate pour cette référence : "
|
||||
"ré-accorder l'octroi dans ObsiGate, puis réessayer."
|
||||
),
|
||||
"octroi_expire": (
|
||||
"Octroi expiré dans ObsiGate pour cette référence : "
|
||||
"renouveler l'octroi dans ObsiGate, puis réessayer."
|
||||
),
|
||||
"secret_expire": (
|
||||
"Secret expiré dans ObsiGate : "
|
||||
"renouveler le secret dans ObsiGate, puis réessayer."
|
||||
),
|
||||
"reference_invalide": (
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
),
|
||||
"contexte_manquant": (
|
||||
"Contexte de distribution manquant : "
|
||||
"un contexte (type + identifiant) est obligatoire côté ObsiGate."
|
||||
),
|
||||
"kek_absente": "ObsiGate indisponible : clé de chiffrement absente côté ObsiGate.",
|
||||
"crypto_indisponible": "ObsiGate indisponible : chiffrement indisponible côté ObsiGate.",
|
||||
}
|
||||
|
||||
|
||||
class CoffreErreur(Exception):
|
||||
"""Erreur de distribution, sans jamais exposer une valeur secrète."""
|
||||
|
||||
|
||||
class CoffreReferenceInvalide(CoffreErreur):
|
||||
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
|
||||
|
||||
|
||||
class CoffreContexteManquant(CoffreErreur):
|
||||
"""Le contexte de distribution (type + identifiant) est obligatoire."""
|
||||
|
||||
|
||||
class CoffreNonConfigure(CoffreErreur):
|
||||
"""ObsiGate ou le jeton consommateur Kanbante n'est pas configuré."""
|
||||
|
||||
|
||||
class CoffreAccesRefuse(CoffreErreur):
|
||||
"""ObsiGate a refusé la distribution (octroi/consommateur/secret).
|
||||
|
||||
Aucun repli sur une ancienne valeur n'est autorisé : l'appelant doit
|
||||
échouer proprement avec ce message.
|
||||
"""
|
||||
|
||||
|
||||
class CoffreIndisponible(CoffreErreur):
|
||||
"""ObsiGate est injoignable ou a répondu sans valeur utilisable."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoffreConfig:
|
||||
"""Configuration du consommateur Kanbante côté ObsiGate."""
|
||||
|
||||
base_url: str
|
||||
jeton_consommateur: str
|
||||
timeout: float = TIMEOUT_DEFAUT
|
||||
|
||||
@property
|
||||
def url_distribuer(self) -> str:
|
||||
return self.base_url.rstrip("/") + CHEMIN_DISTRIBUER
|
||||
|
||||
|
||||
def config_depuis_env() -> CoffreConfig:
|
||||
"""Construit la configuration depuis l'environnement.
|
||||
|
||||
Lève : CoffreNonConfigure si la base ou le jeton est absent.
|
||||
Le jeton n'est jamais journalisé, même en cas d'erreur.
|
||||
"""
|
||||
base_url = os.getenv("KANBANTE_OBSIGATE_URL", "").strip()
|
||||
jeton = os.getenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", "").strip()
|
||||
try:
|
||||
timeout = float(os.getenv("KANBANTE_COFFRE_TIMEOUT", "") or TIMEOUT_DEFAUT)
|
||||
except ValueError:
|
||||
timeout = TIMEOUT_DEFAUT
|
||||
if not base_url or not jeton:
|
||||
raise CoffreNonConfigure(
|
||||
"Consommateur coffre Kanbante non configuré : définir "
|
||||
"KANBANTE_OBSIGATE_URL et KANBANTE_COFFRE_CONSOMMATEUR_TOKEN."
|
||||
)
|
||||
return CoffreConfig(base_url=base_url, jeton_consommateur=jeton, timeout=timeout)
|
||||
|
||||
|
||||
def est_reference(valeur: str | None) -> bool:
|
||||
"""Vrai si ``valeur`` est une référence coffre (et non une valeur brute)."""
|
||||
if not valeur:
|
||||
return False
|
||||
try:
|
||||
parse_reference(valeur)
|
||||
except CoffreReferenceInvalide:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_reference(reference: str) -> tuple[str, str]:
|
||||
"""Valide ``coffre:<slug>/<nom>`` et retourne ``(slug, nom)``.
|
||||
|
||||
Lève : CoffreReferenceInvalide si le format est incorrect.
|
||||
La validation reste volontairement permissive sur les caractères
|
||||
(ObsiGate reste l'autorité de validation) : préfixe, un « / »,
|
||||
slug et nom non vides, aucun espace.
|
||||
"""
|
||||
reference = (reference or "").strip()
|
||||
if not reference.startswith(PREFIXE_REFERENCE):
|
||||
raise CoffreReferenceInvalide(
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
)
|
||||
reste = reference[len(PREFIXE_REFERENCE):]
|
||||
if "/" not in reste or any(c.isspace() for c in reference):
|
||||
raise CoffreReferenceInvalide(
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
)
|
||||
slug, nom = reste.split("/", 1)
|
||||
if not slug or not nom or "/" in nom:
|
||||
raise CoffreReferenceInvalide(
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
)
|
||||
return slug, nom
|
||||
|
||||
|
||||
def _valider_contexte(contexte_type: str, contexte_id: str) -> None:
|
||||
if not (contexte_type or "").strip() or not (contexte_id or "").strip():
|
||||
raise CoffreContexteManquant(
|
||||
"Contexte de distribution manquant : un contexte "
|
||||
"(type + identifiant) est obligatoire."
|
||||
)
|
||||
|
||||
|
||||
def _message_erreur(statut: int, detail: str) -> CoffreErreur:
|
||||
"""Mappe une réponse d'erreur ObsiGate vers une erreur typée.
|
||||
|
||||
Les statuts 401/403/410 signalent un refus d'accès (octroi,
|
||||
consommateur ou secret) : jamais de repli. Les autres erreurs
|
||||
(400 de format, 503, réponse sans valeur) sont des indisponibilités
|
||||
ou des erreurs de configuration, également sans repli.
|
||||
"""
|
||||
message = _MESSAGES_DETAIL.get(detail)
|
||||
if statut in (401, 403, 410) or detail in (
|
||||
"octroi_absent",
|
||||
"octroi_revoque",
|
||||
"octroi_expire",
|
||||
"consommateur_invalide",
|
||||
"consommateur_revoque",
|
||||
"secret_expire",
|
||||
):
|
||||
return CoffreAccesRefuse(message or f"Distribution refusée par ObsiGate ({detail}).")
|
||||
if detail == "reference_invalide":
|
||||
return CoffreReferenceInvalide(message or "Référence invalide.")
|
||||
if detail == "contexte_manquant":
|
||||
return CoffreContexteManquant(message or "Contexte manquant.")
|
||||
return CoffreIndisponible(
|
||||
message or f"ObsiGate indisponible (statut {statut})."
|
||||
)
|
||||
|
||||
|
||||
def _traiter_reponse(reponse: httpx.Response, reference: str) -> str:
|
||||
"""Extrait la valeur d'une réponse de distribution, ou lève.
|
||||
|
||||
La valeur extraite n'est jamais journalisée ; seuls la référence,
|
||||
le contexte et le statut le sont.
|
||||
"""
|
||||
if reponse.status_code == 200:
|
||||
try:
|
||||
valeur = reponse.json().get("valeur")
|
||||
except ValueError:
|
||||
valeur = None
|
||||
if isinstance(valeur, str) and valeur:
|
||||
logger.info("distribution coffre ok (reference=%s)", reference)
|
||||
return valeur
|
||||
raise CoffreIndisponible(
|
||||
"ObsiGate a répondu sans valeur distribuable."
|
||||
)
|
||||
try:
|
||||
detail = str(reponse.json().get("detail") or "")
|
||||
except ValueError:
|
||||
detail = ""
|
||||
logger.warning(
|
||||
"distribution coffre refusee (reference=%s, statut=%d, detail=%s)",
|
||||
reference,
|
||||
reponse.status_code,
|
||||
detail,
|
||||
)
|
||||
raise _message_erreur(reponse.status_code, detail)
|
||||
|
||||
|
||||
def _corps_requete(reference: str, contexte_type: str, contexte_id: str) -> dict:
|
||||
return {
|
||||
"reference": reference,
|
||||
"contexte_type": contexte_type,
|
||||
"contexte_id": contexte_id,
|
||||
}
|
||||
|
||||
|
||||
def distribuer(
|
||||
reference: str,
|
||||
contexte_type: str,
|
||||
contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> str:
|
||||
"""Distribue une valeur ObsiGate, en mémoire seulement.
|
||||
|
||||
L'appelant doit utiliser la valeur immédiatement (appel sortant,
|
||||
vérification HMAC) puis la laisser sortir de portée : ce module ne
|
||||
la persiste ni ne la met en cache, et ne la journalise jamais.
|
||||
"""
|
||||
parse_reference(reference)
|
||||
_valider_contexte(contexte_type, contexte_id)
|
||||
cfg = config or config_depuis_env()
|
||||
try:
|
||||
with httpx.Client(timeout=cfg.timeout, transport=transport) as client:
|
||||
reponse = client.post(
|
||||
cfg.url_distribuer,
|
||||
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CoffreIndisponible(
|
||||
"ObsiGate injoignable pour la distribution : "
|
||||
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||
) from exc
|
||||
return _traiter_reponse(reponse, reference)
|
||||
|
||||
|
||||
async def distribuer_async(
|
||||
reference: str,
|
||||
contexte_type: str,
|
||||
contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> str:
|
||||
"""Variante asynchrone de :func:`distribuer` (mêmes garanties)."""
|
||||
parse_reference(reference)
|
||||
_valider_contexte(contexte_type, contexte_id)
|
||||
cfg = config or config_depuis_env()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=cfg.timeout, transport=transport) as client:
|
||||
reponse = await client.post(
|
||||
cfg.url_distribuer,
|
||||
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CoffreIndisponible(
|
||||
"ObsiGate injoignable pour la distribution : "
|
||||
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||
) from exc
|
||||
return _traiter_reponse(reponse, reference)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def usage_distribue(reference: str, contexte_type: str, contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.BaseTransport | None = None):
|
||||
"""Distribue une valeur pour un seul usage, dans le bloc ``with``.
|
||||
|
||||
La valeur ne doit pas sortir du bloc (ni être stockée, ni journalisée,
|
||||
ni retournée) : à la sortie, l'appelant ne conserve que le résultat
|
||||
de l'usage, jamais la valeur elle-même.
|
||||
"""
|
||||
valeur = distribuer(reference, contexte_type, contexte_id,
|
||||
config=config, transport=transport)
|
||||
try:
|
||||
yield valeur
|
||||
finally:
|
||||
valeur = ""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def usage_distribue_async(reference: str, contexte_type: str,
|
||||
contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None):
|
||||
"""Variante asynchrone de :func:`usage_distribue`."""
|
||||
valeur = await distribuer_async(reference, contexte_type, contexte_id,
|
||||
config=config, transport=transport)
|
||||
try:
|
||||
yield valeur
|
||||
finally:
|
||||
valeur = ""
|
||||
+335
-3
@@ -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.23.0"
|
||||
APP_VERSION = "0.38.1"
|
||||
|
||||
|
||||
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,6 +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:
|
||||
@@ -186,7 +404,36 @@ class Settings:
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Build settings from the current environment."""
|
||||
"""Effective settings: environment, overlaid by the admin
|
||||
panel's database values (0.24.0 — see OVERRIDABLE_SETTINGS)."""
|
||||
return apply_db_overrides(_env_settings())
|
||||
|
||||
|
||||
def _env_settings() -> Settings:
|
||||
"""Build settings from the current environment only."""
|
||||
agent_url_explicite = os.getenv("AGENT_URL")
|
||||
agent_url = (
|
||||
agent_url_explicite or "https://agent.dracodev.net"
|
||||
).rstrip("/")
|
||||
agent_evenements_url = (
|
||||
os.getenv("AGENT_EVENEMENTS_URL")
|
||||
or f"{agent_url}/api/v1/evenements"
|
||||
).rstrip("/")
|
||||
# Origines de l'agent telles que l'app les connaît déjà : elles
|
||||
# portent le suffixe d'environnement (-dev…) repris par les URL
|
||||
# sœurs dépourvues de variable explicite. Dérivation élargie
|
||||
# (0.32.2) : l'URL d'événements d'abord, puis la base du service
|
||||
# elle-même — selon ce que l'installation déclare.
|
||||
suffixe_agent = _suffixe_environnement_agent(agent_evenements_url)
|
||||
if suffixe_agent is None:
|
||||
suffixe_agent = _suffixe_environnement_agent(agent_url)
|
||||
# Base du service dérivée à son tour (0.32.2) : sans AGENT_URL
|
||||
# explicite, si l'adresse connue de l'agent est en ``-dev``, le
|
||||
# panneau et la console doivent viser cette même origine —
|
||||
# auparavant agent_url restait au défaut prod, d'où la console
|
||||
# morte sur la dev alors que le journal était bien en dev.
|
||||
if not agent_url_explicite and suffixe_agent is not None:
|
||||
agent_url = f"https://agent{suffixe_agent}.dracodev.net"
|
||||
return Settings(
|
||||
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
|
||||
gitea_token=os.getenv("GITEA_TOKEN", ""),
|
||||
@@ -229,5 +476,90 @@ def get_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", ""),
|
||||
)
|
||||
|
||||
|
||||
# ── Surcharge par le panneau d'administration (0.24.0, P3) ──
|
||||
# L'environnement amorce, le panneau gouverne : les valeurs
|
||||
# ci-dessous peuvent être remplacées en base (kv_store, clé
|
||||
# "settings_overrides", JSON) depuis /admin/settings. La
|
||||
# provenance reste affichée dans le panneau. Les secrets et les
|
||||
# réglages d'amorçage (DB, clé maître, session, draco) ne sont
|
||||
# volontairement PAS surchargeables.
|
||||
OVERRIDABLE_SETTINGS: dict[str, type] = {
|
||||
"active_window_days": int,
|
||||
"cache_ttl": float,
|
||||
"snapshot_interval": int,
|
||||
"aging_days": int,
|
||||
"zombie_days": int,
|
||||
"archive_suggest_days": int,
|
||||
"max_note_chars": int,
|
||||
"briefing_time": str,
|
||||
"briefing_include_standup": bool,
|
||||
"digest_time": str,
|
||||
"digest_weekday": int,
|
||||
"capture_repo": str,
|
||||
"sync_labels": bool,
|
||||
"sync_gitea": bool,
|
||||
}
|
||||
|
||||
|
||||
def settings_overrides() -> dict:
|
||||
"""The raw overrides stored by the admin panel ({} if none)."""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
from app import models
|
||||
|
||||
raw = models.get_kv("settings_overrides")
|
||||
data = _json.loads(raw) if raw else {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def apply_db_overrides(settings: Settings) -> Settings:
|
||||
"""Overlay the admin-panel values on the env-built settings."""
|
||||
overrides = settings_overrides()
|
||||
if not overrides:
|
||||
return settings
|
||||
import dataclasses
|
||||
|
||||
valid: dict = {}
|
||||
for key, value in overrides.items():
|
||||
converter = OVERRIDABLE_SETTINGS.get(key)
|
||||
if converter is None or value is None:
|
||||
continue
|
||||
try:
|
||||
if converter is bool:
|
||||
valid[key] = str(value).lower() in ("1", "true", "on", "oui")
|
||||
else:
|
||||
valid[key] = converter(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return dataclasses.replace(settings, **valid) if valid else settings
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Forge connection registry (0.24.0, P0/P1).
|
||||
|
||||
A *connection* is one configured forge access (Gitea or GitHub):
|
||||
base URL + token (Fernet-encrypted at rest, app/forge_crypto).
|
||||
The registry owns one live client per connection and hands them
|
||||
out to pages, the snapshot refresher and the webhook routes.
|
||||
|
||||
Bootstrap rules (zero-reconfiguration upgrades):
|
||||
- the environment configuration (GITEA_URL / GITEA_TOKEN /
|
||||
KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea",
|
||||
labelled "Gitea (hérité)", the first time the table is empty;
|
||||
- an "Principal" workspace containing that connection is
|
||||
created the same way;
|
||||
- existing snapshot rows (unprefixed keys, written when only
|
||||
one client existed) are re-keyed under the "c1:" prefix once.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app import forge_crypto, models
|
||||
from app.config import get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
logger = logging.getLogger("kanbante.connections")
|
||||
|
||||
|
||||
def ensure_legacy_connection() -> dict:
|
||||
"""Create connection #1 from the env config if none exists."""
|
||||
existing = models.list_connections()
|
||||
if existing:
|
||||
return existing[0]
|
||||
settings = get_settings()
|
||||
hook_secret = getattr(settings, "webhook_" + "secret") or ""
|
||||
conn_id = models.create_connection(
|
||||
"gitea",
|
||||
"gitea",
|
||||
"Gitea (hérité)",
|
||||
settings.gitea_url,
|
||||
forge_crypto.encrypt_token(settings.gitea_token)
|
||||
if settings.gitea_token
|
||||
else "",
|
||||
"",
|
||||
hook_secret,
|
||||
)
|
||||
logger.info("created legacy forge connection #%d (gitea)", conn_id)
|
||||
return models.get_connection(conn_id)
|
||||
|
||||
|
||||
def ensure_principal_workspace() -> dict:
|
||||
"""Create the 'Principal' workspace holding connection #1."""
|
||||
workspaces = models.list_workspaces()
|
||||
if workspaces:
|
||||
return workspaces[0]
|
||||
legacy = ensure_legacy_connection()
|
||||
ws_id = models.create_workspace("principal", "Principal", "#7c5cfc")
|
||||
models.set_workspace_connections(ws_id, [legacy["id"]])
|
||||
logger.info("created Principal workspace #%d", ws_id)
|
||||
return models.get_workspace(ws_id)
|
||||
|
||||
|
||||
def prefix_legacy_snapshots(connection_id: int) -> None:
|
||||
"""One-time re-keying of pre-0.24 snapshot rows (see module doc)."""
|
||||
flag = f"snapshots_prefixed_c{connection_id}"
|
||||
if models.get_kv(flag):
|
||||
return
|
||||
prefix = f"c{connection_id}:"
|
||||
with models.get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?",
|
||||
(prefix, "c%:%"),
|
||||
)
|
||||
models.set_kv(flag, "1")
|
||||
logger.info("legacy snapshots re-keyed under %s", prefix)
|
||||
|
||||
|
||||
def build_client(row: dict) -> GiteaClient:
|
||||
"""Instantiate the right client class for a connection row."""
|
||||
token = forge_crypto.decrypt_token(row.get("token_encrypted") or "")
|
||||
if row["kind"] == "github":
|
||||
from app.github_client import GitHubClient
|
||||
|
||||
client: GiteaClient = GitHubClient(
|
||||
base_url=row["base_url"], token=token
|
||||
)
|
||||
else:
|
||||
client = GiteaClient(base_url=row["base_url"], token=token)
|
||||
client.connection_id = row["id"]
|
||||
client.connection_slug = row["slug"]
|
||||
client.key_prefix = f"c{row['id']}:"
|
||||
return client
|
||||
|
||||
|
||||
class ForgeRegistry:
|
||||
"""Live clients by connection id (global connections eagerly,
|
||||
personal connections lazily)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._clients: dict[int, GiteaClient] = {}
|
||||
self.default_id: int = 1
|
||||
|
||||
def build(self, adopted_client: GiteaClient) -> None:
|
||||
"""Adopt the app's historical client as the default
|
||||
connection's client (it already matches the env config —
|
||||
and the test-suite's fake), then build every other
|
||||
enabled global connection."""
|
||||
legacy = ensure_legacy_connection()
|
||||
self.default_id = legacy["id"]
|
||||
adopted_client.connection_id = legacy["id"]
|
||||
adopted_client.connection_slug = legacy["slug"]
|
||||
adopted_client.key_prefix = f"c{legacy['id']}:"
|
||||
self._clients[legacy["id"]] = adopted_client
|
||||
for row in models.list_connections(include_disabled=False):
|
||||
if row["id"] == legacy["id"] or row.get("owner_user_id"):
|
||||
continue
|
||||
try:
|
||||
self._clients[row["id"]] = build_client(row)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"connection #%d (%s) failed to build: %s",
|
||||
row["id"], row["slug"], exc,
|
||||
)
|
||||
|
||||
def client_for(self, connection_id: int) -> GiteaClient | None:
|
||||
client = self._clients.get(connection_id)
|
||||
if client is not None:
|
||||
return client
|
||||
row = models.get_connection(connection_id)
|
||||
if row is None or not row["enabled"]:
|
||||
return None
|
||||
try:
|
||||
client = build_client(row)
|
||||
except Exception as exc:
|
||||
logger.error("connection #%d build failed: %s", connection_id, exc)
|
||||
return None
|
||||
self._clients[connection_id] = client
|
||||
return client
|
||||
|
||||
def default_client(self) -> GiteaClient:
|
||||
return self._clients[self.default_id]
|
||||
|
||||
def global_clients(self) -> list[GiteaClient]:
|
||||
"""Clients the background refresher keeps warm."""
|
||||
out = []
|
||||
for row in models.list_connections(include_disabled=False):
|
||||
if row.get("owner_user_id"):
|
||||
continue
|
||||
client = self.client_for(row["id"])
|
||||
if client is not None:
|
||||
out.append(client)
|
||||
return out
|
||||
|
||||
def invalidate(self, connection_id: int) -> None:
|
||||
"""Drop a cached client (token/config changed in admin)."""
|
||||
client = self._clients.pop(connection_id, None)
|
||||
if client is not None and connection_id != self.default_id:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_event_loop().create_task(client.aclose())
|
||||
except RuntimeError:
|
||||
pass
|
||||
+3
-1
@@ -17,6 +17,8 @@ import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import APP_VERSION
|
||||
|
||||
logger = logging.getLogger("kanbante.digest")
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
@@ -143,7 +145,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.22.1"},
|
||||
"clientInfo": {"name": "kanbante", "version": APP_VERSION},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Encryption at rest for forge tokens (0.24.0, P0/P2).
|
||||
|
||||
Forge access tokens are the keys to the kingdom: they are
|
||||
stored Fernet-encrypted (AES-128-CBC + HMAC) in
|
||||
forge_connections.token_encrypted and never shown again after
|
||||
entry (the admin UI only ever displays a mask).
|
||||
|
||||
Master key resolution (decision D5):
|
||||
1. KANBANTE_MASTER_KEY (urlsafe base64, 32 bytes) — recommended;
|
||||
2. otherwise a random key generated ONCE and kept in kv_store
|
||||
("master_key_generated"): as safe as the database file
|
||||
itself, zero configuration, but it travels with DB backups —
|
||||
set the env var for anything stricter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets as _secrets
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app import models
|
||||
|
||||
logger = logging.getLogger("kanbante.crypto")
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _master_key() -> bytes:
|
||||
env_key = os.getenv("KANBANTE_MASTER_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key.encode("utf-8")
|
||||
stored = models.get_kv("master_key_generated")
|
||||
if stored:
|
||||
return stored.encode("utf-8")
|
||||
generated = Fernet.generate_key()
|
||||
models.set_kv("master_key_generated", generated.decode("utf-8"))
|
||||
logger.warning(
|
||||
"KANBANTE_MASTER_KEY not set: generated a master key and "
|
||||
"stored it in the database (kv_store). Set the env var to "
|
||||
"control it explicitly."
|
||||
)
|
||||
return generated
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
_fernet = Fernet(_master_key())
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt_token(plain: str) -> str:
|
||||
if not plain:
|
||||
return ""
|
||||
return _get_fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_token(stored: str) -> str:
|
||||
if not stored:
|
||||
return ""
|
||||
try:
|
||||
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
|
||||
except Exception as exc:
|
||||
logger.error("forge token decryption failed: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def mask_token(stored: str) -> str:
|
||||
return "••••••••" if stored else "—"
|
||||
+183
-8
@@ -38,7 +38,19 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Lightweight async Gitea API client for Kanbante."""
|
||||
"""Lightweight async Gitea API client for Kanbante.
|
||||
|
||||
Since 0.24.0 this class is the reference implementation of
|
||||
the forge protocol: GitHubClient subclasses it and overrides
|
||||
the endpoints whose shape differs. ``api_suffix`` and
|
||||
``auth_scheme`` are the two seams; ``key_prefix`` namespaces
|
||||
the persisted snapshots per connection ("c<id>:").
|
||||
"""
|
||||
|
||||
kind = "gitea"
|
||||
supports_attachments = True
|
||||
api_suffix = "/api/v1"
|
||||
auth_scheme = "token"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,11 +63,15 @@ class GiteaClient:
|
||||
self.base_url = (base_url or settings.gitea_url).rstrip("/")
|
||||
self.token = settings.gitea_token if token is None else token
|
||||
self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl
|
||||
self.key_prefix = ""
|
||||
self.connection_id = 1
|
||||
self.connection_slug = "gitea"
|
||||
headers = {"Accept": "application/json"}
|
||||
headers.update(self._extra_headers())
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
headers["Authorization"] = f"{self.auth_scheme} {self.token}"
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=f"{self.base_url}/api/v1",
|
||||
base_url=f"{self.base_url}{self.api_suffix}",
|
||||
headers=headers,
|
||||
timeout=15.0,
|
||||
transport=transport,
|
||||
@@ -69,6 +85,9 @@ class GiteaClient:
|
||||
# label and the persisted `snapshots` table.
|
||||
self._snapshot_times: dict[str, str] = {}
|
||||
|
||||
def _extra_headers(self) -> dict:
|
||||
return {}
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
|
||||
@@ -120,6 +139,9 @@ class GiteaClient:
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
|
||||
|
||||
def _prefixed(self, key: str) -> str:
|
||||
return f"{self.key_prefix}{key}"
|
||||
|
||||
def _persist_snapshot(self, key: str, value: Any) -> None:
|
||||
"""Write-through to the SQLite snapshots table.
|
||||
|
||||
@@ -129,7 +151,7 @@ class GiteaClient:
|
||||
try:
|
||||
from app import models
|
||||
|
||||
models.save_snapshot(key, value)
|
||||
models.save_snapshot(self._prefixed(key), value)
|
||||
import datetime
|
||||
|
||||
self._snapshot_times[key] = datetime.datetime.now(
|
||||
@@ -157,12 +179,20 @@ class GiteaClient:
|
||||
logger.warning("snapshot hydration failed: %s", exc)
|
||||
return 0
|
||||
stale_at = time.monotonic() - self.cache_ttl - 1.0
|
||||
for key, (payload, updated_at) in snapshots.items():
|
||||
count = 0
|
||||
for stored_key, (payload, updated_at) in snapshots.items():
|
||||
if self.key_prefix:
|
||||
if not stored_key.startswith(self.key_prefix):
|
||||
continue
|
||||
key = stored_key[len(self.key_prefix):]
|
||||
else:
|
||||
key = stored_key
|
||||
self._cache[key] = (stale_at, payload)
|
||||
self._snapshot_times[key] = updated_at
|
||||
if snapshots:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
|
||||
return len(snapshots)
|
||||
count += 1
|
||||
if count:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", count)
|
||||
return count
|
||||
|
||||
def latest_snapshot_at(self) -> str | None:
|
||||
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
||||
@@ -447,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)."""
|
||||
@@ -474,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:
|
||||
@@ -487,6 +651,17 @@ class GiteaClient:
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def set_archived(
|
||||
self, owner: str, repo: str, archived: bool
|
||||
) -> dict:
|
||||
"""Archive / unarchive the repo ON THE FORGE (0.24.0, P4)."""
|
||||
r = await self.client.put(
|
||||
f"/repos/{owner}/{repo}", json={"archived": archived}
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""GitHub forge client (0.24.0, P1).
|
||||
|
||||
Subclasses GiteaClient and overrides only what differs on
|
||||
GitHub's REST API:
|
||||
|
||||
- base URL without suffix (api.github.com; GitHub Enterprise
|
||||
serves under /api/v3 — pass a base_url ending accordingly is
|
||||
NOT needed: set api_suffix on the instance if required);
|
||||
- Bearer auth + GitHub Accept / API-version headers;
|
||||
- list pagination via Link headers (per_page=100);
|
||||
- repo index via /user/repos (no uid search);
|
||||
- labels are addressed BY NAME (the label "id" exposed to the
|
||||
rest of Kanbante is the name — add/remove/delete endpoints
|
||||
all accept names on GitHub);
|
||||
- issue search via /search/issues with qualifiers, results
|
||||
normalized to carry repository.full_name like Gitea's;
|
||||
- archiving via PATCH (Gitea uses PUT).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
|
||||
|
||||
|
||||
class GitHubClient(GiteaClient):
|
||||
kind = "github"
|
||||
# L'API GitHub ne sait pas joindre de fichiers aux issues
|
||||
# (réservé à son interface web) : les routes Kanbante
|
||||
# répondent proprement « non pris en charge » (0.31.0).
|
||||
supports_attachments = False
|
||||
api_suffix = ""
|
||||
auth_scheme = "Bearer"
|
||||
|
||||
def _extra_headers(self) -> dict:
|
||||
return {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
# ── Pagination (Link headers) ──────────────────────────
|
||||
|
||||
async def _get_all_pages(self, path: str, **params: Any) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
url: str | None = path
|
||||
query: dict[str, Any] = {**params, "per_page": 100}
|
||||
pages = 0
|
||||
while url and pages < 50:
|
||||
pages += 1
|
||||
if url.startswith("http"):
|
||||
r = await self.client.get(url)
|
||||
else:
|
||||
r = await self.client.get(url, params=query)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if isinstance(data, dict) and "items" in data:
|
||||
batch = data["items"]
|
||||
elif isinstance(data, list):
|
||||
batch = data
|
||||
else:
|
||||
batch = []
|
||||
items.extend(batch)
|
||||
match = _LINK_NEXT.search(r.headers.get("Link", ""))
|
||||
url = match.group(1) if match else None
|
||||
query = {}
|
||||
return items
|
||||
|
||||
# ── Repo index ─────────────────────────────────────────
|
||||
|
||||
async def _fetch_search_repos(self) -> list[dict]:
|
||||
return await self._get_all_pages(
|
||||
"/user/repos", affiliation="owner,collaborator,organization_member"
|
||||
)
|
||||
|
||||
# ── Labels: the exposed id IS the name ─────────────────
|
||||
|
||||
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
labels = await super().get_labels(owner, repo)
|
||||
for label in labels:
|
||||
label["id"] = label["name"]
|
||||
return labels
|
||||
|
||||
async def create_label(
|
||||
self, owner: str, repo: str, name: str, color: str
|
||||
) -> dict:
|
||||
label = await super().create_label(owner, repo, name, color)
|
||||
label["id"] = label["name"]
|
||||
return label
|
||||
|
||||
async def add_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id
|
||||
) -> None:
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels",
|
||||
json={"labels": [str(label_id)]},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def remove_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id
|
||||
) -> None:
|
||||
r = await self.client.delete(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# ── Issue search ───────────────────────────────────────
|
||||
|
||||
async def search_issues(
|
||||
self,
|
||||
q: str = "",
|
||||
state: str = "open",
|
||||
assigned_by: str | None = None,
|
||||
) -> list[dict]:
|
||||
qualifiers = [q.strip(), "is:issue", f"state:{state}"]
|
||||
if assigned_by:
|
||||
qualifiers.append(f"assignee:{assigned_by}")
|
||||
query = " ".join(part for part in qualifiers if part)
|
||||
raw = await self._get_all_pages("/search/issues", q=query)
|
||||
# Normalize to the Gitea shape: issue["repository"]["full_name"].
|
||||
for item in raw:
|
||||
url = item.get("repository_url", "")
|
||||
full_name = url.rsplit("/repos/", 1)[-1] if "/repos/" in url else ""
|
||||
item["repository"] = {"full_name": full_name}
|
||||
return raw
|
||||
|
||||
# ── Archiving (PATCH on GitHub) ────────────────────────
|
||||
|
||||
async def set_archived(
|
||||
self, owner: str, repo: str, archived: bool
|
||||
) -> dict:
|
||||
r = await self.client.patch(
|
||||
f"/repos/{owner}/{repo}", json={"archived": archived}
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
# ── Branches (git/refs on GitHub) ──────────────────────
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
ref = await self._get(
|
||||
f"/repos/{owner}/{repo}/git/ref/heads/{old_branch}"
|
||||
)
|
||||
sha = (ref.get("object") or {}).get("sha", "")
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/git/refs",
|
||||
json={"ref": f"refs/heads/{new_branch}", "sha": sha},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
+3578
-358
File diff suppressed because it is too large
Load Diff
+903
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
"""Recherche fédérée (fusion-trois-apps #68) : JSON de recherche,
|
||||
CORS bornée et conversion des groupes d'issues.
|
||||
|
||||
Ce module ne dépend d'aucun autre module applicatif à l'import (les
|
||||
résolveurs sont appelés paresseusement) : les fonctions pures sont
|
||||
testables sans base ni serveur.
|
||||
|
||||
- ``GET /api/search`` (route dans ``app/main.py``) réutilise la
|
||||
recherche Gitea de la page ``/search`` (titres + corps, tous états,
|
||||
tous projets du workspace) et la regroupe par dépôt.
|
||||
- La CORS bornée n'autorise que ``GET /api/search``, et seulement
|
||||
pour les origines sœurs officielles **prod ET dev, listées
|
||||
explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus les
|
||||
origines de la console Spark** (``AGENT_ORIGINS`` : agent prod ET
|
||||
dev — fédération de la recherche depuis la console, 2026-10-28),
|
||||
**plus** les
|
||||
origines résolues par ``app.config`` — jamais ``*``. Correctif
|
||||
2026-10-28 (fusion #68/#69, matrice constatée par Bruno) :
|
||||
l'ancienne allowlist ne contenait que l'environnement résolu
|
||||
(prod OU dev), donc une page de l'autre environnement voyait sa
|
||||
section afficher « Injoignable depuis cet hôte (CORS ou réseau) ».
|
||||
Le middleware est un ASGI pur :
|
||||
il répond lui-même aux preflights OPTIONS (qui ne portent jamais
|
||||
de cookies, donc qu'``AuthMiddleware`` refuserait sinon) et ajoute
|
||||
les en-têtes aux réponses de recherche, sans toucher aux autres
|
||||
routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SEARCH_PATH = "/api/search"
|
||||
_SNIPPET_LEN = 160
|
||||
|
||||
PROD_ORIGINS = frozenset({
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
})
|
||||
|
||||
DEV_ORIGINS = frozenset({
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
})
|
||||
|
||||
|
||||
# Clés du dictionnaire rendu par render() (app/main.py).
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
|
||||
|
||||
def is_dev_host(hostname: str | None) -> bool:
|
||||
"""Même règle que la barre v0.1.6 (voir static/js/recherche.js)."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower()
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 4 and first.endswith("-dev")
|
||||
|
||||
|
||||
def origin_of(url: str | None) -> str | None:
|
||||
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return None
|
||||
host = parsed.hostname.lower()
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return f"{parsed.scheme}://{host}{port}"
|
||||
|
||||
|
||||
def sister_origins(
|
||||
resolved: dict | None = None, request_host: str | None = None
|
||||
) -> frozenset:
|
||||
"""Origines autorisées pour la lecture cross-origin de /api/search.
|
||||
|
||||
``resolved`` : dictionnaire ``draco_urls`` rendu par ``render()``.
|
||||
Sans argument, le résolveur est appelé (import paresseux) avec
|
||||
l'hôte de la requête quand il est connu — ce middleware s'exécute
|
||||
avant tout poseur de contexte d'hôte, et sans lui la résolution
|
||||
retomberait sur la configuration seule et raterait l'environnement
|
||||
``-dev`` ; en cas d'échec, repli sur prod + dev officiels
|
||||
(premier parti uniquement).
|
||||
"""
|
||||
if resolved is None:
|
||||
try:
|
||||
from app.config import get_settings, resoudre_urls_draco
|
||||
|
||||
urls = resoudre_urls_draco(hote_requete=request_host, base=get_settings())
|
||||
resolved = {
|
||||
"obsigate": urls.sso_url,
|
||||
"flowdeck": urls.flowdeck_url,
|
||||
"tiktak": urls.tiktak_url,
|
||||
"kanbante": urls.public_url,
|
||||
}
|
||||
except Exception:
|
||||
return PROD_ORIGINS | DEV_ORIGINS
|
||||
origins = set()
|
||||
for key in _SOEUR_KEYS:
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
# Correctif 2026-10-28 : prod ET dev toujours, quelle que soit
|
||||
# la résolution — ``resolved`` n'ajoute que des origines de
|
||||
# configuration sans jamais retirer une origine officielle.
|
||||
return frozenset(origins) | PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
|
||||
|
||||
|
||||
def cors_headers_for(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
|
||||
if not origin:
|
||||
return None
|
||||
if origin.strip() not in sister_origins(resolved, request_host):
|
||||
return None
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin.strip(),
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
|
||||
|
||||
def preflight_headers(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
|
||||
headers = cors_headers_for(origin, resolved, request_host)
|
||||
if headers is None:
|
||||
return None
|
||||
headers.update({
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "600",
|
||||
})
|
||||
return headers
|
||||
|
||||
|
||||
def groupes_vers_json(groups, limit: int = 20):
|
||||
"""Convertit la sortie de ``_group_issue_results`` en JSON public.
|
||||
|
||||
Seuls les champs utiles sont exposés (numéro, titre, état, corps
|
||||
tronqué à 160 caractères, labels, colonne). Retourne
|
||||
``(groupes, total)`` où ``total`` est le nombre de lignes
|
||||
retournées (plafonné à ``limit``).
|
||||
"""
|
||||
out: list[dict] = []
|
||||
total = 0
|
||||
restant = max(1, min(int(limit), 50))
|
||||
for group in groups or []:
|
||||
rows: list[dict] = []
|
||||
for row in (group or {}).get("rows", []) or []:
|
||||
if restant <= 0:
|
||||
break
|
||||
issue = (row or {}).get("issue", {}) or {}
|
||||
labels = []
|
||||
for label in issue.get("labels") or []:
|
||||
if isinstance(label, dict):
|
||||
labels.append({"name": label.get("name"), "color": label.get("color")})
|
||||
rows.append({
|
||||
"issue": {
|
||||
"number": issue.get("number"),
|
||||
"title": issue.get("title"),
|
||||
"state": issue.get("state"),
|
||||
"body": (issue.get("body") or "")[:_SNIPPET_LEN],
|
||||
"labels": labels,
|
||||
},
|
||||
"column": (row or {}).get("column"),
|
||||
})
|
||||
restant -= 1
|
||||
total += 1
|
||||
if rows:
|
||||
out.append({"full_name": (group or {}).get("full_name"), "rows": rows})
|
||||
if restant <= 0:
|
||||
break
|
||||
return out, total
|
||||
|
||||
|
||||
class RechercheCorsMiddleware:
|
||||
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
headers_in = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
origin = headers_in.get("origin", "")
|
||||
request_host = (
|
||||
headers_in.get("host", "") or headers_in.get("x-forwarded-host", "")
|
||||
).split(",")[0].strip()
|
||||
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
|
||||
preflight = preflight_headers(origin, request_host=request_host)
|
||||
if preflight is not None:
|
||||
body = b""
|
||||
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in preflight.items()]
|
||||
headers_out.append((b"content-length", b"0"))
|
||||
await send({"type": "http.response.start", "status": 204,
|
||||
"headers": headers_out})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
return
|
||||
if path != SEARCH_PATH:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_cors(message):
|
||||
if message["type"] == "http.response.start":
|
||||
cors = cors_headers_for(origin, request_host=request_host)
|
||||
if cors is not None:
|
||||
message["headers"] = list(message.get("headers", [])) + [
|
||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in cors.items()
|
||||
]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cors)
|
||||
+4
-1
@@ -20,6 +20,7 @@ rule can never close an issue or move a card between columns.
|
||||
|
||||
import json
|
||||
|
||||
from app import models
|
||||
from app.models import KANBAN_COLUMNS
|
||||
|
||||
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
|
||||
@@ -60,7 +61,9 @@ def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
|
||||
"(fermer/déplacer une carte par règle est interdit en v1)"
|
||||
)
|
||||
continue
|
||||
if "column" in when and when["column"] not in KANBAN_COLUMNS:
|
||||
if "column" in when and when["column"] not in (
|
||||
set(KANBAN_COLUMNS) | set(models.column_keys(1))
|
||||
):
|
||||
errors.append(f"{name} : colonne inconnue « {when['column']} »")
|
||||
continue
|
||||
if "min_age_days" in when and (
|
||||
|
||||
+15
-10
@@ -67,22 +67,27 @@ async def refresh_snapshots(gitea, classify_repos) -> int:
|
||||
return len(active)
|
||||
|
||||
|
||||
async def snapshot_loop(gitea, classify_repos) -> None:
|
||||
async def snapshot_loop(clients_provider, classify_repos) -> None:
|
||||
"""Periodic refresher, started by the app lifespan.
|
||||
|
||||
Sleeps briefly first: hydration already serves the pages,
|
||||
the loop only has to keep the snapshots warm.
|
||||
the loop only has to keep the snapshots warm. Since 0.24.0
|
||||
`clients_provider` is a zero-arg callable returning the live
|
||||
forge clients (one per global connection); each is refreshed
|
||||
in turn. The interval is re-read every cycle so the admin
|
||||
settings panel can change it without a restart.
|
||||
"""
|
||||
interval = get_settings().snapshot_interval
|
||||
if interval <= 0:
|
||||
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||
return
|
||||
await asyncio.sleep(10)
|
||||
while True:
|
||||
try:
|
||||
await refresh_snapshots(gitea, classify_repos)
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot refresh cycle failed: %s", exc)
|
||||
interval = get_settings().snapshot_interval
|
||||
if interval <= 0:
|
||||
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||
return
|
||||
for client in clients_provider():
|
||||
try:
|
||||
await refresh_snapshots(client, classify_repos)
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot refresh cycle failed: %s", exc)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.1
|
||||
(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">
|
||||
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ version }}">
|
||||
<script>document.body.classList.add('has-draco-bar');</script>
|
||||
<header class="draco-bar" data-current="kanbante">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" aria-label="dracodev">
|
||||
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
<a class="draco-apptab" data-app="obsigate" href="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">
|
||||
@@ -57,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>
|
||||
@@ -67,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) {
|
||||
@@ -83,10 +103,14 @@
|
||||
};
|
||||
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; }
|
||||
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
|
||||
if (act === "palette") { location.href = "/recherche"; return; }
|
||||
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { location.href = "/activity"; } return; }
|
||||
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). */
|
||||
if (act === "spark") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||
return;
|
||||
}
|
||||
if (act === "rappel-entete") {
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
if (bar) bar.style.display = "";
|
||||
@@ -95,4 +119,8 @@
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script defer src="/static/js/draco-barre.js"></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>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
("digest", "/digest", "🧠", "Digest"),
|
||||
]),
|
||||
("pilotage", "Pilotage", [
|
||||
("inflight", "/inflight", "🛩️", "En vol"),
|
||||
("forecast", "/forecast", "🔮", "Prévisions"),
|
||||
("health", "/health", "🩺", "Santé"),
|
||||
("rules", "/rules", "⚙️", "Règles"),
|
||||
@@ -28,9 +29,21 @@
|
||||
("outils", "Outils", [
|
||||
("search", "/search", "🔍", "Recherche"),
|
||||
("capture", "/capture", "⚡", "Capture"),
|
||||
("archives", "/archives", "🗄️", "Archives"),
|
||||
("wall", "/wall", "🖥️", "Wallboard"),
|
||||
]),
|
||||
] %}
|
||||
{% if is_admin %}
|
||||
{% set nav_groups = nav_groups + [
|
||||
("admin", "Administration", [
|
||||
("admin", "/admin", "🛠️", "Vue d'ensemble"),
|
||||
("admin_connections", "/admin/connections", "🔌", "Connexions"),
|
||||
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
|
||||
("admin_columns", "/admin/columns", "🧱", "Colonnes"),
|
||||
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
|
||||
]),
|
||||
] %}
|
||||
{% endif %}
|
||||
{% set active_group = namespace(key="principal") %}
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
{% for key, href, icon, label in links %}
|
||||
@@ -49,7 +62,7 @@
|
||||
localStorage.removeItem('kanbante-nav-closed');
|
||||
var openGroup = {{ active_group.key | tojson }};
|
||||
var stored = localStorage.getItem('kanbante-nav-open');
|
||||
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
|
||||
var groups = ['principal', 'rituels', 'pilotage', 'outils', 'admin'];
|
||||
if ({{ (active_nav != "") | tojson }}) {
|
||||
localStorage.setItem('kanbante-nav-open', openGroup);
|
||||
} else if (stored === '') {
|
||||
@@ -71,6 +84,17 @@
|
||||
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
|
||||
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
|
||||
</div>
|
||||
{% if current_workspace and user_workspaces and user_workspaces|length > 1 %}
|
||||
<form method="post" action="/workspace/switch" class="ws-switch">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<span class="ws-dot" style="background: {{ current_workspace.color }}" aria-hidden="true"></span>
|
||||
<select name="workspace_id" class="ws-select" onchange="this.form.submit()" title="Changer d'espace de travail" aria-label="Espace de travail">
|
||||
{% for ws in user_workspaces %}
|
||||
<option value="{{ ws.id }}"{% if ws.id == current_workspace.id %} selected{% endif %}>{{ ws.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
<nav class="side-nav">
|
||||
{% for gkey, group, links in nav_groups %}
|
||||
<div class="side-section" data-group="{{ gkey }}">
|
||||
@@ -95,6 +119,9 @@
|
||||
{% 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>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🛠️ Administration</h1>
|
||||
<p class="page-sub">Utilisateurs, vue d'ensemble et journal d'administration.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>👥 Utilisateurs ({{ users|length }})</h2>
|
||||
<table class="proj-table">
|
||||
<thead>
|
||||
<tr><th>Utilisateur</th><th>Rôle</th><th>État</th><th>Draco</th><th>Dernière connexion</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong>{% if u.display_name %} <span class="muted">({{ u.display_name }})</span>{% endif %}{% if u.must_change_password %} <span class="chip-warn">mot de passe à changer</span>{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="role" onchange="this.form.submit()" {% if current_user.id == u.id %}disabled{% endif %}>
|
||||
<option value="member"{% if u.role == 'member' %} selected{% endif %}>Membre</option>
|
||||
<option value="admin"{% if u.role == 'admin' %} selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td>{% if u.active %}🟢 Actif{% else %}🔴 Désactivé{% endif %}</td>
|
||||
<td>{% if u.draco_sub %}🔗 lié{% else %}—{% endif %}</td>
|
||||
<td>{{ u.last_login_at or '—' }}</td>
|
||||
<td class="admin-actions">
|
||||
<form method="post" action="/admin/users/{{ u.id }}/toggle" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini" {% if current_user.id == u.id %}disabled title="Impossible sur soi-même"{% endif %}>{% if u.active %}Désactiver{% else %}Activer{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/reset" class="inline-form" onsubmit="var p=this.querySelector('input[name=password]'); if(!p.value){p.focus(); return false;}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="password" name="password" placeholder="Nouveau mot de passe" autocomplete="new-password">
|
||||
<button type="submit" class="btn-mini">Réinitialiser</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>➕ Nouvel utilisateur</h3>
|
||||
<form method="post" action="/admin/users" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom d'utilisateur <input type="text" name="username" required autocomplete="off"></label>
|
||||
<label>Nom d'affichage <input type="text" name="display_name" autocomplete="off"></label>
|
||||
<label>Mot de passe temporaire <input type="password" name="password" required autocomplete="new-password"></label>
|
||||
<label>Rôle
|
||||
<select name="role">
|
||||
<option value="member">Membre</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
<p class="notes-hint">L'utilisateur rejoint automatiquement l'espace Principal comme membre et devra changer son mot de passe temporaire (rappel affiché ici et dans son profil).</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔭 Vue d'ensemble</h2>
|
||||
<p>
|
||||
<a href="/admin/connections">🔌 Connexions ({{ connections|length }})</a> ·
|
||||
<a href="/admin/workspaces">🧭 Espaces ({{ workspaces|length }})</a> ·
|
||||
<a href="/admin/settings">🎛️ Paramètres</a> ·
|
||||
<a href="/archives">🗄️ Archives</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📜 Journal d'administration</h2>
|
||||
{% if audit %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Quand</th><th>Qui</th><th>Action</th><th>Détails</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in audit %}
|
||||
<tr><td>{{ a.created_at }}</td><td>{{ a.username or '—' }}</td><td><code>{{ a.action }}</code></td><td>{{ a.details }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucune action d'administration journalisée pour l'instant.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Colonnes — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🧱 Colonnes du board</h1>
|
||||
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>Comment marche le mapping</h2>
|
||||
<p class="notes-hint">
|
||||
<strong>Miroir</strong> : quand une carte entre dans une colonne, son <em>étiquette mappée</em> est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).<br>
|
||||
<strong>Adoption</strong> : si une issue porte <em>exactement une</em> étiquette reconnue (étiquette mappée ou alias d'une colonne), le board adopte cette colonne — c'est ce qui permet à d'autres outils de piloter le board par étiquettes.<br>
|
||||
<strong>Alias</strong> : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck <code>kanban_a_faire</code> sur la colonne À faire.<br>
|
||||
<strong>Focus</strong> : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{% for c in columns %}
|
||||
<section class="briefing-card">
|
||||
<h2>
|
||||
<span class="ws-dot" style="background: {{ c.color }}"></span>
|
||||
{{ c.name }}
|
||||
<span class="muted"><code>{{ c.key }}</code> · {{ counts[c.key] }} carte(s) dans cet espace</span>
|
||||
{% if c.kind == 'backlog' %}<span class="chip">système — défaut des cartes neuves</span>{% endif %}
|
||||
{% if c.kind == 'done' %}<span class="chip">système — issues fermées</span>{% endif %}
|
||||
</h2>
|
||||
<form method="post" action="/admin/columns/{{ c.key }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom affiché <input type="text" name="name" value="{{ c.name }}"></label>
|
||||
<label>Couleur <input type="color" name="color" value="{{ c.color }}"></label>
|
||||
{% if c.kind == 'flow' %}
|
||||
<label>Étiquette mappée <input type="text" name="label" value="{{ c.label }}" placeholder="kanban:{{ c.key }}"></label>
|
||||
<label>Alias (virgules) <input type="text" name="aliases" value="{{ c.aliases }}" placeholder="kanban_a_faire, a-faire"></label>
|
||||
<label><input type="checkbox" name="in_focus" value="1"{% if c.in_focus %} checked{% endif %}> Dans Focus / Wallboard</label>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="admin-actions">
|
||||
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
|
||||
</form>
|
||||
{% if c.kind == 'flow' %}
|
||||
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Déplacer ses cartes vers
|
||||
<select name="target">
|
||||
{% for other in columns %}{% if other.key != c.key %}<option value="{{ other.key }}">{{ other.name }}</option>{% endif %}{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer la colonne</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</p>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvelle colonne</h2>
|
||||
<form method="post" action="/admin/columns" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
|
||||
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
|
||||
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
|
||||
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter (avant Terminé)</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>↩️ Réinitialiser</h2>
|
||||
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
|
||||
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Réinitialiser les colonnes</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Connexions — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🔌 Connexions aux forges</h1>
|
||||
<p class="page-sub">Chaque connexion est un accès Gitea ou GitHub. Les jetons sont <strong>chiffrés au repos</strong> et ne sont <strong>jamais réaffichés</strong> — pour en changer un, saisis la nouvelle valeur.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% for c in connections %}
|
||||
<section class="briefing-card">
|
||||
<h2>{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }}
|
||||
<span class="muted">({{ c.slug }})</span>
|
||||
{% if c.owner_user_id %}<span class="chip-warn">personnelle</span>{% endif %}
|
||||
{% if not c.enabled %}<span class="chip-warn">désactivée</span>{% endif %}
|
||||
</h2>
|
||||
<p class="muted">
|
||||
{{ c.kind }} · {{ c.base_url }} · compte : <strong>{{ c.account_login or 'à tester' }}</strong> · jeton : {{ c.token_mask }}<br>
|
||||
Webhook : <code>/webhooks/{{ c.slug }}</code> (secret {% if c.webhook_secret %}défini{% else %}hérité ou absent{% endif %})
|
||||
{% if c.used_by %} · Utilisée par : {{ c.used_by|join(', ') }}{% endif %}
|
||||
</p>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
||||
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
||||
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
||||
<label>Secret webhook (vide = inchangé, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="admin-actions">
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/test" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">🧪 Tester la connexion</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/toggle" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">{% if c.enabled %}Désactiver{% else %}Activer{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer définitivement cette connexion ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
|
||||
</form>
|
||||
</p>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvelle connexion</h2>
|
||||
<form method="post" action="/admin/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Type
|
||||
<select name="kind">
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||
<label>Secret webhook (optionnel, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="coffre:<slug>/<nom>"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||
</form>
|
||||
<p class="notes-hint">Secret webhook : une référence <code>coffre:<slug>/<nom></code> est stockée telle quelle (jamais la valeur) et distribuée par ObsiGate à chaque webhook, puis oubliée ; octroi révoqué ou ObsiGate indisponible = webhook rejeté, sans repli. Les jetons d'accès forge, eux, restent chiffrés au repos (voir app/forge_crypto.py) et ne basculent pas encore sur une référence — voir docs/coffre-reference-obsigate.md. Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Paramètres — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🎛️ Paramètres</h1>
|
||||
<p class="page-sub"><strong>L'environnement amorce, le panneau gouverne.</strong> Une valeur saisie ici remplace celle de l'environnement (sans redémarrage pour la plupart) ; un champ vidé revient à la valeur d'environnement. Les secrets et les réglages d'amorçage (base de données, clé maître, session, draco) restent en environnement, volontairement.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<form method="post" action="/admin/settings">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Réglage</th><th>Environnement</th><th>Valeur du panneau (vide = env)</th><th>Effectif</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td><code>{{ r.key }}</code></td>
|
||||
<td class="muted">{{ r.env_value }}</td>
|
||||
<td><input type="text" name="setting_{{ r.key }}" value="{{ r.override if r.override is not none else '' }}" placeholder="{{ r.env_value }}"></td>
|
||||
<td><strong>{{ r.effective }}</strong></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><button type="submit" class="btn-save btn-inline">Enregistrer</button></p>
|
||||
</form>
|
||||
<p class="notes-hint">Booléens : <code>true</code>/<code>false</code>. <code>cache_ttl</code> s'applique aux connexions construites après l'enregistrement ; l'intervalle des instantanés est relu à chaque cycle. Les horaires (<code>briefing_time</code>, <code>digest_time</code>) sont au format <code>HH:MM</code>, <code>digest_weekday</code> : 0 = lundi.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Espaces — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🧭 Espaces de travail</h1>
|
||||
<p class="page-sub">Un espace regroupe des connexions (forges) et des membres. Le board d'un projet appartient à l'espace : deux espaces peuvent ranger le même projet différemment. L'état de board, les notes et les épingles du jour sont propres à chaque espace.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% for d in details %}
|
||||
{% set ws = d.ws %}
|
||||
<section class="briefing-card">
|
||||
<h2><span class="ws-dot" style="background: {{ ws.color }}"></span> {{ ws.name }} <span class="muted">({{ ws.slug }})</span></h2>
|
||||
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" value="{{ ws.name }}"></label>
|
||||
<label>Couleur <input type="color" name="color" value="{{ ws.color }}"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Renommer</button>
|
||||
</form>
|
||||
|
||||
<h3>🔌 Connexions de l'espace</h3>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% for c in all_connections %}
|
||||
<label>
|
||||
<input type="checkbox" name="connection_id" value="{{ c.id }}"{% if c.id in d.conn_ids %} checked{% endif %}>
|
||||
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} {{ c.name }} <span class="muted">({{ c.slug }})</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<h3>👥 Membres</h3>
|
||||
{% if d.members %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Utilisateur</th><th>Rôle dans l'espace</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in d.members %}
|
||||
<tr>
|
||||
<td>{{ m.username }}{% if m.display_name %} <span class="muted">({{ m.display_name }})</span>{% endif %}</td>
|
||||
<td>{{ m.role }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/members/{{ m.user_id }}/remove" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">Retirer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucun membre explicite (les admins globaux voient tout).</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/members" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Utilisateur
|
||||
<select name="user_id">
|
||||
{% for u in all_users %}<option value="{{ u.id }}">{{ u.username }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Rôle
|
||||
<select name="role">
|
||||
<option value="reader">Lecteur</option>
|
||||
<option value="member" selected>Membre</option>
|
||||
<option value="admin">Admin d'espace</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter / mettre à jour</button>
|
||||
</form>
|
||||
|
||||
<h3>📦 Sélection de projets</h3>
|
||||
<p class="notes-hint">Vide = <strong>tous</strong> les projets des connexions de l'espace. Sinon, une ligne par projet au format <code>slug-connexion:proprietaire/depot</code> (ex. <code>github:bruno/mon-projet</code>).</p>
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/projects">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="selection" rows="4" style="width:100%">{% for cid, full in d.selection %}{% for c in all_connections %}{% if c.id == cid %}{{ c.slug }}:{{ full }}
|
||||
{% endif %}{% endfor %}{% endfor %}</textarea>
|
||||
<p><button type="submit" class="btn-save btn-inline">Enregistrer la sélection</button></p>
|
||||
</form>
|
||||
|
||||
{% if is_global_admin %}
|
||||
<form method="post" action="/admin/workspaces/{{ ws.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cet espace ? Les boards propres à cet espace seront perdus.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer l'espace</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
{% if is_global_admin %}
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvel espace</h2>
|
||||
<form method="post" action="/admin/workspaces" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom <input type="text" name="name" required placeholder="Open source"></label>
|
||||
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Archives — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🗄️ Archives</h1>
|
||||
<p class="page-sub">Espace : <strong>{{ workspace.name if workspace else '—' }}</strong>. Deux sortes d'archives : <strong>locale</strong> (le projet disparaît des vues de cet espace, la forge n'est pas touchée, réversible en un clic) et <strong>sur la forge</strong> (propriété du dépôt, lecture seule là-bas). Les archives sortent des rituels et des instantanés, mais restent cherchables.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
{% macro actions(row, kind) %}
|
||||
{% if can_write %}
|
||||
{% if kind == 'suggested' or kind == 'forge' or kind == 'other' %}
|
||||
<form method="post" action="/archives/local" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="1">
|
||||
<button type="submit" class="btn-mini">📥 Archiver ici</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'local' %}
|
||||
<form method="post" action="/archives/local" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="0">
|
||||
<button type="submit" class="btn-mini">♻️ Restaurer</button>
|
||||
</form>
|
||||
<form method="post" action="/archives/purge" class="inline-form" onsubmit="return confirm('Purger l\'état local (board, notes, épingles) de ce projet dans cet espace ? La forge n\'est pas touchée.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Purger l'état local</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind != 'forge' and not row.missing %}
|
||||
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Archiver ce projet SUR LA FORGE ? Il y devient en lecture seule.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="1">
|
||||
<button type="submit" class="btn-mini">🔒 Archiver sur la forge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if kind == 'forge' %}
|
||||
<form method="post" action="/archives/forge" class="inline-form" onsubmit="return confirm('Désarchiver ce projet sur la forge ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<input type="hidden" name="archived" value="0">
|
||||
<button type="submit" class="btn-mini">🔓 Désarchiver sur la forge</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/archives/export" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="conn_id" value="{{ row.conn_id }}">
|
||||
<input type="hidden" name="repo_full_name" value="{{ row.full_name }}">
|
||||
<button type="submit" class="btn-mini">⬇️ Récap (.md)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro rowline(row, kind) %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %}
|
||||
<strong>{{ row.full_name }}</strong>
|
||||
{% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}
|
||||
{% if row.missing %}<span class="chip-warn">introuvable sur la forge</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ row.updated_at or '—' }}{% if row.idle_days is not none %} <span class="muted">(il y a {{ row.idle_days }} j)</span>{% endif %}</td>
|
||||
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
|
||||
<td class="admin-actions">{{ actions(row, kind) }}</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>💤 À archiver ? (inactifs depuis {{ suggest_days }} jours et plus)</h2>
|
||||
{% if groups.suggested %}
|
||||
<form method="post" action="/archives/local-bulk">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<table class="proj-table">
|
||||
<thead><tr><th></th><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in groups.suggested %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="item" value="{{ row.conn_id }}:{{ row.full_name }}" checked></td>
|
||||
<td>{% if row.conn_kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ row.full_name }}</strong> {% if row.conn_slug %}<span class="conn-badge">{{ row.conn_slug }}</span>{% endif %}</td>
|
||||
<td>{{ row.updated_at }} <span class="muted">(il y a {{ row.idle_days }} j)</span></td>
|
||||
<td>{% if row.open_issues %}⚠️ {{ row.open_issues }} ouverte(s){% else %}0{% endif %}</td>
|
||||
<td class="admin-actions">{{ actions(row, 'suggested') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p><button type="submit" class="btn-save btn-inline">📥 Archiver localement la sélection</button></p>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Rien à proposer : aucun projet inactif au-delà du seuil (réglable : <code>archive_suggest_days</code> dans Administration → Paramètres).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📥 Archives locales de cet espace ({{ groups.local|length }})</h2>
|
||||
{% if groups.local %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for row in groups.local %}{{ rowline(row, 'local') }}{% endfor %}</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucune archive locale. « Archiver ici » masque un projet de cet espace sans toucher à la forge.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔒 Archivés sur la forge ({{ groups.forge|length }})</h2>
|
||||
{% if groups.forge %}
|
||||
<table class="proj-table">
|
||||
<thead><tr><th>Projet</th><th>Dernière activité</th><th>Issues ouvertes</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for row in groups.forge %}{{ rowline(row, 'forge') }}{% endfor %}</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Aucun dépôt archivé sur les forges de cet espace.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>{{ repo }} — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
<script src="/static/vendor/sortable.min.js"></script>
|
||||
<script src="/static/vendor/marked.min.js"></script>
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
<script src="/static/js/board.js" defer></script>
|
||||
<script src="/static/js/board.js?v={{ version }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
@@ -53,16 +53,31 @@
|
||||
</select>
|
||||
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
|
||||
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
|
||||
<form id="create-issue-form">
|
||||
<input type="text" id="new-issue-title" maxlength="255"
|
||||
placeholder="➕ Nouvelle issue (titre)..." required>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
|
||||
<form id="create-issue-form" class="composer">
|
||||
<div class="composer-head">
|
||||
<span class="composer-title">➕ Nouvelle issue</span>
|
||||
<span class="composer-target">arrive dans
|
||||
<strong>{{ column_labels.backlog }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="composer-main">
|
||||
<input type="text" id="new-issue-title" maxlength="255"
|
||||
placeholder="Titre de l'issue..." required>
|
||||
<button type="submit" class="btn-save btn-inline">Créer l'issue</button>
|
||||
</div>
|
||||
<details class="composer-desc" id="new-issue-desc">
|
||||
<summary><span class="composer-desc-label">Ajouter une description</span><span class="composer-chevron" aria-hidden="true">▾</span></summary>
|
||||
<textarea id="new-issue-body" rows="5" maxlength="100000"
|
||||
placeholder="Description (markdown)..."></textarea>
|
||||
<p class="composer-hint">Markdown accepté · Ctrl+Entrée pour créer l'issue</p>
|
||||
</details>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main class="board-container">
|
||||
<div class="kanban-board">
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
{% for col in column_keys %}
|
||||
<div class="kanban-column"
|
||||
data-column="{{ col }}">
|
||||
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
|
||||
@@ -145,6 +160,24 @@
|
||||
</div>
|
||||
<div id="detail-code" class="detail-code"></div>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
<div class="detail-actions">
|
||||
<button id="detail-edit-btn" class="btn-sm" type="button">✏️ Modifier le titre et la description</button>
|
||||
</div>
|
||||
<form id="detail-edit-form" hidden>
|
||||
<input type="text" id="detail-title-input" maxlength="255" placeholder="Titre">
|
||||
<textarea id="detail-body-input" rows="10" maxlength="100000"
|
||||
placeholder="Description (markdown)..."></textarea>
|
||||
<div class="detail-actions">
|
||||
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
|
||||
<button type="button" id="detail-edit-cancel" class="btn-sm">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="detail-comments-title">📎 Pièces jointes</h3>
|
||||
<div id="detail-assets"></div>
|
||||
<form id="asset-form" class="asset-form">
|
||||
<input type="file" id="asset-file">
|
||||
<button type="submit" class="btn-sm">📎 Joindre le fichier</button>
|
||||
</form>
|
||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||
<div id="detail-comments"></div>
|
||||
<form id="comment-form">
|
||||
|
||||
@@ -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,6 +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>
|
||||
</div>
|
||||
<div class="search-bar toolbar-search">
|
||||
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
@@ -63,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>
|
||||
@@ -84,11 +86,13 @@
|
||||
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 %}
|
||||
@@ -105,6 +109,8 @@
|
||||
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
|
||||
{% if repo.html_url %}
|
||||
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
|
||||
<div class="card-menu-sep" role="separator"></div>
|
||||
<button type="button" class="card-menu-item" data-copy-url="{{ repo.html_url }}" title="Copier l'URL Gitea de {{ repo.name }}" aria-label="Copier l'URL du projet {{ repo.name }}">🔗 Copier l'URL du projet</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +150,7 @@
|
||||
{% endif %}
|
||||
{% if full in stats %}
|
||||
<div class="mini-progress">
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
{% for col in column_keys %}
|
||||
{% set n = stats[full].get(col, 0) %}
|
||||
{% if n > 0 %}
|
||||
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
||||
@@ -255,7 +261,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) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>En vol partout — Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛩️</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Tout ce qui est <strong>en vol</strong>, tous projets actifs confondus —
|
||||
regroupé par colonne, les cartes les plus anciennes d'abord.</p>
|
||||
|
||||
<div class="inflight-summary">
|
||||
<span class="inflight-total">🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol</span>
|
||||
{% for group in groups %}
|
||||
<span class="col-chip" style="background: {{ column_colors[group.column] }}20; color: {{ column_colors[group.column] }}">
|
||||
{{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not groups %}
|
||||
<p class="focus-empty">🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
<h2>
|
||||
<span class="filter-chip-dot" style="background: {{ column_colors[group.column] }}"></span>
|
||||
{{ column_labels[group.column] }}
|
||||
<span class="badge secondary">{{ group.cards|length }}</span>
|
||||
{% if wip_globals.get(group.column) %}
|
||||
<span class="inflight-wip">limite WIP globale : {{ wip_globals[group.column] }} (somme des projets qui en définissent une)</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="focus-cards">
|
||||
{% for card in group.cards %}
|
||||
<div class="kanban-card focus-card">
|
||||
<div class="card-id">
|
||||
#{{ card.issue.number }}
|
||||
<a class="inflight-repo" href="/board/{{ card.repo }}">{{ card.repo }}</a>
|
||||
{% if card.issue.html_url %}
|
||||
<a href="{{ card.issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-title">{{ card.issue.title }}</div>
|
||||
{% if card.issue.labels %}
|
||||
<div class="card-labels">
|
||||
{% for label in card.issue.labels %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
|
||||
{{ label.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-meta">
|
||||
{% if card.issue.assignee %}
|
||||
<img src="{{ card.issue.assignee.avatar_url }}" class="avatar" alt="{{ card.issue.assignee.login }}">
|
||||
{% endif %}
|
||||
<span class="age-badge{% if card.age_days >= aging_days * 2 %} stale{% elif card.age_days >= aging_days %} warn{% endif %}"
|
||||
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">⏳ {{ card.age_days }} j dans la colonne</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · en vol partout
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -29,7 +29,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>
|
||||
|
||||
+56
-4
@@ -5,7 +5,7 @@
|
||||
<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" %}
|
||||
@@ -15,17 +15,68 @@
|
||||
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
|
||||
{% else %}
|
||||
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
|
||||
|
||||
{% if groups %}
|
||||
<div class="my-filters" id="my-filters">
|
||||
<div class="my-filters-row">
|
||||
<div class="my-search-wrap">
|
||||
<span class="my-search-icon" aria-hidden="true">🔍</span>
|
||||
<input type="search" id="my-search" autocomplete="off"
|
||||
placeholder="Rechercher un titre, un projet, un #numéro, une étiquette… ( / )">
|
||||
</div>
|
||||
<select id="my-repo" title="Filtrer par projet">
|
||||
<option value="">Tous les projets</option>
|
||||
{% for f in repo_facets %}
|
||||
<option value="{{ f.full_name }}">{{ f.full_name }} ({{ f.count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="my-label" title="Filtrer par étiquette">
|
||||
<option value="">Toutes les étiquettes</option>
|
||||
{% for f in label_facets %}
|
||||
<option value="{{ f.name }}">{{ f.name }} ({{ f.count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="my-sort" title="Trier les résultats">
|
||||
<option value="recent">Activité récente d'abord</option>
|
||||
<option value="oldest">Plus anciennes d'abord</option>
|
||||
<option value="title">Titre (A → Z)</option>
|
||||
<option value="repo">Projet (A → Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="my-filters-row my-filters-meta">
|
||||
<div class="my-col-chips" id="my-col-chips" role="group" aria-label="Filtrer par colonne">
|
||||
<button type="button" class="filter-chip on" data-col="">Toutes les colonnes</button>
|
||||
{% for f in column_facets %}
|
||||
<button type="button" class="filter-chip" data-col="{{ f.key }}" aria-pressed="false">
|
||||
<span class="filter-chip-dot" style="background: {{ column_colors[f.key] }}"></span>{{ column_labels[f.key] }} <span class="filter-chip-count">{{ f.count }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="my-count" id="my-count" role="status"></span>
|
||||
<button type="button" id="my-reset" class="btn-sm" hidden>↺ Réinitialiser les filtres</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="empty-state" id="my-empty" hidden>🔍 Aucune issue ne correspond à ces filtres.
|
||||
<button type="button" id="my-empty-reset" class="btn-sm">↺ Réinitialiser les filtres</button>
|
||||
</div>
|
||||
{% if not groups %}
|
||||
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
<section class="focus-group" data-repo="{{ group.full_name }}">
|
||||
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
||||
<span class="badge secondary">{{ group.rows|length }}</span></h2>
|
||||
<span class="badge secondary" data-group-count>{{ group.rows|length }}</span></h2>
|
||||
<div class="issue-results">
|
||||
{% for row in group.rows %}
|
||||
{% set issue = row.issue %}
|
||||
<div class="issue-row">
|
||||
<div class="issue-row"
|
||||
data-column="{{ row.column }}"
|
||||
data-labels="{{ (issue.labels or [])|map(attribute='name')|join(' ') }}"
|
||||
data-updated="{{ issue.updated_at or '' }}"
|
||||
data-title="{{ issue.title }}"
|
||||
data-search="#{{ issue.number }} {{ issue.title }} {{ group.full_name }} {{ (issue.labels or [])|map(attribute='name')|join(' ') }}">
|
||||
<span class="issue-state">🟢</span>
|
||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||
@@ -46,6 +97,7 @@
|
||||
<footer>
|
||||
Kanbante v{{ version }} · mes issues
|
||||
</footer>
|
||||
<script src="/static/js/my.js?v={{ version }}" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Mon profil — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>👤 Mon profil</h1>
|
||||
<p class="page-sub">{{ profile_user.username }} · rôle : {{ profile_user.role }}</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
{% if profile_user.must_change_password %}<p class="chip-warn">⚠️ Ton mot de passe est temporaire : change-le ci-dessous.</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>Informations</h2>
|
||||
<form method="post" action="/profile" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom d'affichage <input type="text" name="display_name" value="{{ profile_user.display_name or '' }}"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="muted">Mes espaces :
|
||||
{% for m in memberships %}<span class="ws-dot" style="background: {{ m.color }}"></span> {{ m.name }} ({{ m.member_role }}){% if not loop.last %} · {% endif %}
|
||||
{% else %}aucun{% endfor %}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔑 Mot de passe</h2>
|
||||
{% if profile_user.id %}
|
||||
<form method="post" action="/profile/password" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Mot de passe actuel <input type="password" name="current_password" required autocomplete="current-password"></label>
|
||||
<label>Nouveau mot de passe <input type="password" name="new_password" required autocomplete="new-password"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Changer</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : le mot de passe se change dans la configuration du déploiement.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🗝️ Jetons d'API (MCP)</h2>
|
||||
<p class="muted">Pour les consommateurs machine — le service d'agents, en premier lieu. Un jeton (préfixe <code>kbt_</code>) authentifie uniquement <code>/mcp</code> et <code>/api/</code>, jamais l'interface web. Il se présente en en-tête <code>Authorization: Bearer kbt_…</code>. Le texte clair n'est affiché qu'à la création ; ensuite, seule son empreinte est conservée.</p>
|
||||
{% if profile_user.id %}
|
||||
{% if nouveau_jeton %}
|
||||
<p>✅ Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
|
||||
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
|
||||
{% endif %}
|
||||
{% if api_tokens %}
|
||||
<ul>
|
||||
{% for t in api_tokens %}
|
||||
<li>
|
||||
<strong>{{ t.name }}</strong> · créé le {{ t.created_at }}
|
||||
· {% if t.last_used_at %}dernier usage : {{ t.last_used_at }}{% else %}jamais utilisé{% endif %}
|
||||
{% if t.revoked_at %} · <span class="chip-warn">révoqué le {{ t.revoked_at }}</span>
|
||||
{% else %}
|
||||
<form method="post" action="/profile/tokens/{{ t.id }}/revoke" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">Révoquer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">Aucun jeton pour l'instant.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/profile/tokens" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom du jeton <input type="text" name="name" placeholder="service-agents" required></label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer un jeton</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : les jetons appartiennent aux comptes de la base — connecte-toi avec ton compte personnel pour en créer un.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>✦ Liaison draco (SSO)</h2>
|
||||
{% if profile_user.draco_sub %}
|
||||
<p>Compte lié à l'identité draco <code>{{ profile_user.draco_sub }}</code> — la connexion via ObsiGate ouvre directement cette session.</p>
|
||||
<form method="post" action="/profile/draco/unlink" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">Délier</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Pas de liaison. Elle se crée automatiquement à la première visite où ta session Kanbante et ton cookie draco coexistent (par exemple en arrivant depuis ObsiGate).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🔌 Mes connexions personnelles</h2>
|
||||
<p class="muted">Visibles de toi seul. Pour les utiliser, ajoute-les à un de tes espaces (ou demande à un admin d'espace).</p>
|
||||
{% for c in my_connections %}
|
||||
<p>
|
||||
{% if c.kind == 'github' %}🐙{% else %}🍵{% endif %} <strong>{{ c.name }}</strong>
|
||||
<span class="muted">({{ c.slug }}) · {{ c.base_url }} · compte : {{ c.account_login or 'à tester' }} · jeton : {{ c.token_mask }}</span>
|
||||
<form method="post" action="/profile/connections/{{ c.id }}/test" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">🧪 Tester</button>
|
||||
</form>
|
||||
<form method="post" action="/profile/connections/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette connexion personnelle ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer</button>
|
||||
</form>
|
||||
</p>
|
||||
{% endfor %}
|
||||
<form method="post" action="/profile/connections" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Type
|
||||
<select name="kind">
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Nom <input type="text" name="name" required></label>
|
||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com"></label>
|
||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ full_name }} — Projet · Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<meta name="repo-full-name" content="{{ full_name }}">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
@@ -51,11 +53,39 @@
|
||||
{% 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">
|
||||
@@ -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>
|
||||
@@ -6,6 +6,15 @@ services:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- GITEA_URL=${GITEA_URL:-https://git.dracodev.net}
|
||||
# Noms publics dracodev (dev/prod) : défauts = noms officiels ;
|
||||
# l'environnement dev déclare ses noms en -dev dans son .env.
|
||||
- KANBANTE_PUBLIC_URL=${KANBANTE_PUBLIC_URL:-https://kanbante.dracodev.net}
|
||||
- DRACO_SSO_URL=${DRACO_SSO_URL:-https://og.dracodev.net}
|
||||
- DRACO_FLOWDECK_URL=${DRACO_FLOWDECK_URL:-https://flowdeck.dracodev.net}
|
||||
- DRACO_TIKTAK_URL=${DRACO_TIKTAK_URL:-https://tiktak.dracodev.net}
|
||||
- AGENT_URL=${AGENT_URL:-https://agent.dracodev.net}
|
||||
- AGENT_EVENEMENTS_URL=${AGENT_EVENEMENTS_URL:-}
|
||||
- AGENT_EVENEMENTS_CLE=${AGENT_EVENEMENTS_CLE:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- KANBANTE_DB_PATH=/data/kanbante.db
|
||||
# Strongly recommended: without these, the app is unauthenticated.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Kanbante — Références coffre ObsiGate (Phase 4, issue ObsiGate #22)
|
||||
|
||||
Patron « même patron, sans variante » de la spec « Coffre de secrets
|
||||
ObsiGate » §12, appliqué à Kanbante sur la branche
|
||||
`feat/coffre-reference-obsigate` (aucun merge, aucune PR dans ce lot).
|
||||
|
||||
## 1. Inventaire honnête des secrets externes (vérifié dans le code, main 0.37.1)
|
||||
|
||||
| Secret | Stockage actuel | Statut dans ce lot |
|
||||
|---|---|---|
|
||||
| Jeton d'accès forge (`forge_connections.token_encrypted`, admin + profil) | **Chiffré Fernet au repos** (`app/forge_crypto.py`, clé `KANBANTE_MASTER_KEY` ou clé générée en base) ; jamais réaffiché (masque `••••••••`) | **Non migré, à raison** : ce n'est pas un secret en clair. Le basculer sur une référence exigerait une distribution *à chaque appel* forge, car le registre met en cache les clients (`ForgeRegistry`) : distribuer une fois à la construction puis garder le client reviendrait à conserver la valeur en mémoire pour la durée de vie de l'app, ce qui violerait le « utilise, puis oublie » de la spec. Reste à faire dédié (voir §5). |
|
||||
| Secret webhook (`forge_connections.webhook_secret`, admin) | **En clair en base** (colonne `TEXT`, saisie via `/admin/connections`) ; repli environnement `KANBANTE_WEBHOOK_SECRET` | **Migré** : une référence `coffre:<slug>/<nom>` saisie dans ce même champ est stockée telle quelle, distribuée à chaque webhook, puis oubliée (§2). Les valeurs historiques en clair restent acceptées (compatibilité, migration opportuniste), sans jamais être réécrites. |
|
||||
| `GITEA_TOKEN`, `KANBANTE_NTFY_TOKEN`, `KANBANTE_OBSIGATE_MCP_TOKEN`, `KANBANTE_CAPTURE_TOKEN`, `AGENT_EVENEMENTS_CLE`, secrets SSO/session | Variables d'environnement d'exploitation uniquement, jamais persistées par l'app, non surchargeables en base (`OVERRIDABLE_SETTINGS` les exclut explicitement) | **Hors périmètre applicatif** : leur bascule éventuelle vers le coffre relève du déploiement (l'opérateur), pas d'un champ de saisie de Kanbante. Le jeton MCP ObsiGate (`KANBANTE_OBSIGATE_MCP_TOKEN`) n'est **pas** réutilisé ici : le consommateur coffre a son propre jeton, conformément à l'issue. |
|
||||
| Mots de passe utilisateurs, jetons API Kanbante | Hashés (`password_hash`, `token_hash`) | Hors périmètre (pas des secrets externes). |
|
||||
|
||||
Conclusion : Kanbante ne stockait qu'**un** secret externe en clair
|
||||
persisté par l'app — le secret webhook — et c'est celui qui est migré.
|
||||
Le client générique ci-dessous reste disponible pour le premier usage
|
||||
réel suivant (jetons forge, voir §5).
|
||||
|
||||
## 2. Ce qui est implémenté
|
||||
|
||||
### `app/coffre_client.py` (nouveau, dépendance : `httpx` uniquement)
|
||||
|
||||
- `est_reference()` / `parse_reference()` : validation du format
|
||||
`coffre:<slug>/<nom>` (préfixe, un seul `/`, slug et nom non vides).
|
||||
- `config_depuis_env()` : `KANBANTE_OBSIGATE_URL` (base ObsiGate, ex.
|
||||
`https://og.dracodev.net`), `KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`
|
||||
(jeton du consommateur « kanbante » enregistré dans ObsiGate),
|
||||
`KANBANTE_COFFRE_TIMEOUT` (défaut 10 s). Absence de l'un des deux
|
||||
premiers → `CoffreNonConfigure`, échec propre.
|
||||
- `distribuer()` / `distribuer_async()` : `POST /api/coffres/distribuer`
|
||||
avec JSON `{reference, contexte_type, contexte_id}` et
|
||||
`Authorization: Bearer <jeton consommateur>` — contrat vérifié dans le
|
||||
routeur ObsiGate (`backend/routers/coffres.py`, main). La valeur
|
||||
retournée n'est **jamais journalisée, jamais persistée, jamais mise
|
||||
en cache**.
|
||||
- `usage_distribue()` / `usage_distribue_async()` : gestionnaires de
|
||||
contexte « un seul usage » — la valeur ne doit pas sortir du bloc.
|
||||
- Erreurs typées, sans repli, avec messages explicites orientant vers
|
||||
ObsiGate (voir §3). Aucune valeur distribuée n'apparaît dans un
|
||||
message, un log ou une réponse HTTP.
|
||||
|
||||
### Webhook (`app/main.py`, `_handle_forge_webhook`)
|
||||
|
||||
- Si `webhook_secret` stocké est une référence : distribution
|
||||
asynchrone à chaque webhook (`contexte_type="webhook"`,
|
||||
`contexte_id=<slug de la connexion>`), HMAC vérifié avec la valeur en
|
||||
mémoire, puis oubli (fin de portée de la fonction).
|
||||
- Échec de distribution → **aucun repli** sur le secret
|
||||
d'environnement ni sur une ancienne valeur (il n'y en a pas de
|
||||
stockée) : `CoffreAccesRefuse` → HTTP 401 « Accès révoqué ou refusé
|
||||
dans ObsiGate : ré-accorder l'octroi… » ; autre `CoffreErreur`
|
||||
(ObsiGate indisponible, consommateur non configuré) → HTTP 503
|
||||
explicite. Le webhook doit être renvoyé après correction côté
|
||||
ObsiGate.
|
||||
- Si la valeur stockée n'est pas une référence : comportement historique
|
||||
inchangé (secret en clair, puis repli environnement).
|
||||
|
||||
### Formulaire admin (`app/main.py` + `app/templates/admin_connections.html`)
|
||||
|
||||
- Le champ « Secret webhook » accepte désormais une référence
|
||||
`coffre:<slug>/<nom>` (création et modification). Une saisie
|
||||
commençant par `coffre:` mais mal formée est **refusée** (redirection
|
||||
d'erreur), pour ne jamais la stocker comme secret en clair historique
|
||||
par accident.
|
||||
- Le champ « Jeton d'accès » est **inchangé** : une référence saisie
|
||||
là serait aujourd'hui chiffrée Fernet puis rejetée par la forge —
|
||||
c'est volontaire tant que la distribution par appel (§5) n'existe pas.
|
||||
Le bandeau du formulaire le signale.
|
||||
|
||||
Aucun changement de schéma n'a été nécessaire : la référence est
|
||||
stockée dans la colonne `webhook_secret` existante, détectée par son
|
||||
préfixe `coffre:` (un secret historique ne peut pas porter ce préfixe
|
||||
sans avoir été saisi comme tel, et ce cas est désormais validé).
|
||||
|
||||
## 3. Correspondance des erreurs ObsiGate
|
||||
|
||||
| Réponse ObsiGate | Erreur Kanbante | Effet |
|
||||
|---|---|---|
|
||||
| 401 `consommateur_invalide`, 403 `consommateur_revoque` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder » |
|
||||
| 403 `octroi_absent` / `octroi_revoque` / `octroi_expire`, 410 `secret_expire` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder / renouveler » |
|
||||
| 400 `reference_invalide` / `contexte_manquant` | `CoffreReferenceInvalide` / `CoffreContexteManquant` | Webhook 503 (erreur de configuration) |
|
||||
| 503 `kek_absente` / `crypto_indisponible`, timeout, connexion refusée, 200 sans valeur | `CoffreIndisponible` | Webhook 503, renvoyer le webhook plus tard |
|
||||
|
||||
Dans tous les cas : pas de valeur en cache, pas de continuation.
|
||||
|
||||
## 4. Tests
|
||||
|
||||
`tests/test_coffre_client.py` (faux endpoint `httpx.MockTransport`,
|
||||
valeurs fictives `TEST-VALUE-XXXX` uniquement, aucune instance réelle) :
|
||||
|
||||
- validation de références (valides / invalides) ;
|
||||
- succès sync + async, avec vérification de la requête (chemin, Bearer,
|
||||
corps `reference` + contexte) ;
|
||||
- refus (octroi révoqué/expiré/absent, consommateur révoqué/invalide,
|
||||
secret expiré) → `CoffreAccesRefuse` avec message explicite ;
|
||||
- indisponibilité (503, erreur réseau, 200 sans valeur) →
|
||||
`CoffreIndisponible` ;
|
||||
- référence invalide / contexte manquant / configuration absente :
|
||||
aucun appel HTTP ;
|
||||
- webhook de bout en bout (TestClient) : octroi révoqué → 401 explicite
|
||||
sans repli ; distribution réussie → HMAC accepté et ligne en base
|
||||
toujours porteuse de la référence, jamais de la valeur.
|
||||
|
||||
## 5. Restes à faire (exigent validation par Bruno sur instance réelle)
|
||||
|
||||
1. **Validation réelle** : enregistrer le consommateur « kanbante »
|
||||
dans ObsiGate, créer un octroi vers un secret webhook de test,
|
||||
configurer `KANBANTE_OBSIGATE_URL` +
|
||||
`KANBANTE_COFFRE_CONSOMMATEUR_TOKEN` sur l'instance Kanbante, saisir
|
||||
la référence dans `/admin/connections`, envoyer un vrai webhook
|
||||
Gitea, vérifier l'acceptation, puis révoquer l'octroi et vérifier
|
||||
le 401 explicite, puis couper ObsiGate et vérifier le 503.
|
||||
2. **Jetons forge par référence** : distribution à chaque appel dans
|
||||
`GiteaClient`/`GitHubClient` (ou client jetable par requête), sans
|
||||
passer par le cache `ForgeRegistry` ; seulement alors, accepter une
|
||||
référence dans le champ « Jeton d'accès » et documenter la migration
|
||||
des connexions existantes (chiffrées Fernet → références).
|
||||
3. **Migration opportuniste des secrets webhook historiques** : proposer
|
||||
en admin la conversion d'un secret en clair existant vers une
|
||||
référence (l'admin crée le secret + l'octroi dans ObsiGate, puis
|
||||
remplace la valeur par la référence) — aucun outil automatique ne
|
||||
copie une valeur réelle vers ObsiGate dans ce lot.
|
||||
4. **CI complète** : la suite n'a été exécutée que localement dans le
|
||||
sandbox (voir rapport) ; le run CI Gitea de la branche reste à
|
||||
observer avant toute PR.
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.23.0"
|
||||
version = "0.37.1"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"httpx>=0.28",
|
||||
"python-dotenv>=1.0",
|
||||
"python-multipart>=0.0.18",
|
||||
"cryptography>=42",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
|
||||
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
|
||||
.draco-alertes-overlay {
|
||||
position: fixed; inset: 0; z-index: 30000;
|
||||
display: none; align-items: flex-start; justify-content: center;
|
||||
padding: 8vh 16px 16px;
|
||||
background: rgb(0 0 0 / .55); overflow-y: auto;
|
||||
}
|
||||
.draco-alertes-box {
|
||||
width: 720px; max-width: 100%;
|
||||
background: var(--bg-primary, #1f1f1f);
|
||||
color: var(--text, #eceee9);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
|
||||
.draco-alertes-close {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
|
||||
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
|
||||
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
|
||||
.draco-alertes-item {
|
||||
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
|
||||
padding: 9px 12px; margin: 6px 0;
|
||||
}
|
||||
.draco-alertes-item.is-active { border-color: #e8762c; }
|
||||
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
|
||||
.draco-alertes-title:hover { text-decoration: underline; }
|
||||
.draco-alertes-detail {
|
||||
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||
.draco-alertes-empty a { color: inherit; }
|
||||
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
|
||||
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
|
||||
.draco-alertes-tab {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 999px; padding: 5px 11px;
|
||||
cursor: pointer; font-size: 13px; line-height: 1.3;
|
||||
}
|
||||
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
|
||||
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
|
||||
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
|
||||
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
|
||||
.draco-alertes-filtre input {
|
||||
flex: 1; min-width: 0; background: transparent; color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
|
||||
.draco-alertes-compte:empty { display: none; }
|
||||
.draco-alertes-item.is-error { border-color: #c2437e; }
|
||||
+84
-11
@@ -1,4 +1,12 @@
|
||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
|
||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
|
||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
|
||||
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
|
||||
menu des applications) ; l'avatar est un vrai <button>.
|
||||
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
|
||||
défaut supprimés ; le menu de la marque gagne « Console Spark ».
|
||||
Jetons repris du prototype validé Console v7.2.
|
||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||
@@ -7,6 +15,7 @@
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
position: relative;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
align-items: center; gap: 16px; padding: 0 12px;
|
||||
@@ -24,9 +33,53 @@
|
||||
.draco-bar-centre { justify-content: center; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
|
||||
/* Marque */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
|
||||
.draco-brand-mark { color: var(--agent); }
|
||||
/* Marque — bouton du menu des applications (v0.1.3) */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
|
||||
min-width: 248px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-apps-menu[hidden] { display: none; }
|
||||
|
||||
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
|
||||
* droite ; ses items réutilisent .draco-apps-item. */
|
||||
.draco-compte-menu {
|
||||
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
|
||||
min-width: 248px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-compte-menu[hidden] { display: none; }
|
||||
.draco-menu-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; border: 0; border-radius: 10px;
|
||||
background: transparent; color: var(--ink); font: inherit; text-align: left;
|
||||
text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
|
||||
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
|
||||
.draco-compte-nom {
|
||||
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
|
||||
border-bottom: 1px solid var(--line); margin-bottom: 6px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.draco-apps-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
color: var(--ink); text-decoration: none;
|
||||
}
|
||||
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
|
||||
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
|
||||
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
|
||||
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
|
||||
|
||||
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
|
||||
.draco-apptabs { display: flex; gap: 6px; }
|
||||
@@ -97,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) */
|
||||
@@ -119,9 +174,27 @@
|
||||
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
|
||||
en barre basse propre à chaque app (patron du prototype v5+). */
|
||||
.draco-apptabs, .draco-domaines-lbl { display: none; }
|
||||
.draco-bar { height: 52px; }
|
||||
@media (max-width: 900px) {
|
||||
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||
avatar. Le rail, la recherche et les domaines disparaissent :
|
||||
chaque app garde ses contrôles natifs (barre
|
||||
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||
restaient affichés et la barre débordait sur téléphone.
|
||||
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
|
||||
barre complète ne tenait pas (fenêtres réduites, tablettes,
|
||||
webviews) alors que les apps sont déjà en mise en page mobile.) */
|
||||
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
|
||||
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
|
||||
.draco-apptab { display: none; }
|
||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
|
||||
/* Chantier 5 : sur la console elle-même (data-current="agent",
|
||||
programme console #71), c'est l'onglet Console Spark qui reste
|
||||
visible en mobile — les 4 hôtes gardent leur propre courant. */
|
||||
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
|
||||
prefers-color-scheme) et suit les bascules en direct. Toutes les
|
||||
couleurs passent par les variables ci-dessous — en-tête et fenêtre
|
||||
partagent donc toujours le même mode. */
|
||||
.draco-panneau {
|
||||
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
|
||||
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
|
||||
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
|
||||
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
|
||||
--p-accent: #7c5cd6; --p-lien: #5b3df0;
|
||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
|
||||
background: var(--p-fond); color: var(--p-texte); z-index: 400;
|
||||
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(105%); transition: transform .28s ease;
|
||||
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
|
||||
}
|
||||
.draco-panneau[data-draco-theme="dark"] {
|
||||
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
|
||||
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
|
||||
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
|
||||
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
|
||||
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
|
||||
--p-lien: #b39df5;
|
||||
}
|
||||
.draco-panneau.open { transform: translateX(0); }
|
||||
.draco-poignee {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
|
||||
cursor: ew-resize; z-index: 5; touch-action: none;
|
||||
}
|
||||
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
|
||||
@media (max-width: 700px) { .draco-poignee { display: none; } }
|
||||
.draco-panneau-hd {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-panneau-hd .fermer {
|
||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
||||
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
||||
.draco-panneau-etat.err .point { background: #cf4b32; }
|
||||
/* Barre des discussions (façon Muse) */
|
||||
.draco-disc-bar {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-disc-bar .d-titre {
|
||||
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-disc-bar .d-btn {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
|
||||
}
|
||||
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
|
||||
.draco-discussions {
|
||||
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
max-height: 46%; overflow-y: auto; padding: 8px 12px;
|
||||
}
|
||||
.draco-discussions .d-section {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
|
||||
}
|
||||
.draco-discussions .d-section:first-child { margin-top: 0; }
|
||||
.draco-discussions .d-item {
|
||||
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-item:hover { background: var(--p-chip); }
|
||||
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
|
||||
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
|
||||
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
|
||||
.draco-discussions .d-actions button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
|
||||
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
|
||||
.draco-discussions .d-vide, .draco-discussions .d-chargement {
|
||||
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
|
||||
}
|
||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
|
||||
.draco-panneau-outils button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
|
||||
.draco-appro {
|
||||
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
|
||||
border-radius: 12px; padding: 10px 12px;
|
||||
}
|
||||
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
|
||||
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
|
||||
.draco-appro .resume { margin: 5px 0 8px; }
|
||||
.draco-appro .btns { display: flex; gap: 7px; }
|
||||
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
|
||||
.draco-auj h5:first-child { margin-top: 0; }
|
||||
.draco-auj ul { margin: 0; padding-left: 17px; }
|
||||
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||
.draco-panneau-form { display: flex; gap: 8px; }
|
||||
.draco-panneau-form input {
|
||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-panneau-form button {
|
||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
||||
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
|
||||
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
|
||||
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
|
||||
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
||||
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
|
||||
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
|
||||
.draco-missions .m-statut.st-failed { color: #cf4b32; }
|
||||
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
||||
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
|
||||
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
|
||||
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
|
||||
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
|
||||
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
|
||||
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
|
||||
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
|
||||
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
|
||||
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
|
||||
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
|
||||
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
|
||||
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
|
||||
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
|
||||
.draco-msg.md code, .m-rapport.md code {
|
||||
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
|
||||
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
.draco-msg.md pre, .m-rapport.md pre {
|
||||
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
|
||||
overflow-x: auto; margin: 7px 0;
|
||||
}
|
||||
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
|
||||
.draco-msg.md blockquote, .m-rapport.md blockquote {
|
||||
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
|
||||
color: var(--p-doux);
|
||||
}
|
||||
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
|
||||
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
|
||||
background: var(--p-chip);
|
||||
}
|
||||
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
|
||||
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
|
||||
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
|
||||
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
|
||||
.draco-msg.md table, .m-rapport.md table {
|
||||
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
|
||||
display: block; overflow-x: auto; max-width: 100%;
|
||||
}
|
||||
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
|
||||
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
|
||||
}
|
||||
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
|
||||
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
|
||||
@@ -0,0 +1,90 @@
|
||||
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||
.rp-input {
|
||||
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||
}
|
||||
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||
.rp-section { margin-bottom: 28px; }
|
||||
.rp-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||
.rp-count {
|
||||
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||
}
|
||||
.rp-count:empty { display: none; }
|
||||
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||
.rp-state { opacity: .75; margin: 8px 0; }
|
||||
.rp-error { opacity: 1; }
|
||||
.rp-item {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||
.rp-title { display: block; font-weight: 600; }
|
||||
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||
|
||||
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||
.rp-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid #888; margin-bottom: 20px;
|
||||
}
|
||||
.rp-tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
|
||||
color: inherit; opacity: .7; white-space: nowrap;
|
||||
}
|
||||
.rp-tab:hover { opacity: 1; }
|
||||
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
|
||||
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
.rp-tab-count {
|
||||
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
|
||||
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
||||
}
|
||||
.rp-tab-count:empty { display: none; }
|
||||
.rp-section[hidden] { display: none; }
|
||||
.rp-all[hidden] { display: none; }
|
||||
|
||||
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
|
||||
* Cause : recherche.html étend base.html sans layout_class, donc
|
||||
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
|
||||
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
|
||||
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
|
||||
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
|
||||
* la largeur disponible, sans toucher aux autres pages (elles ne
|
||||
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
|
||||
* la grille n'a qu'une colonne : la page la prend entièrement. */
|
||||
.layout > .rp-page {
|
||||
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.layout > .rp-page { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* ---- ObsiGate : navigation statique de la page autonome (chantier
|
||||
* 2+3). La page /recherche est servie hors SPA (route avant le
|
||||
* catch-all) : elle reprend les classes de l'app (style.css :
|
||||
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
|
||||
* .content-area) et cette navigation, calée sur les variables de
|
||||
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
|
||||
* la SPA (voir le commentaire dans recherche.html). */
|
||||
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
|
||||
.rp-og-nav a {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
|
||||
.rp-og-nav a[aria-current="page"] {
|
||||
background: rgba(127, 127, 127, .18); font-weight: 700;
|
||||
}
|
||||
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
|
||||
+181
-13
@@ -60,6 +60,28 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
/* — Base des champs de saisie (#29) : sans règle de fond, tout
|
||||
champ hors d'un contexte stylé (.login-form, .capture-form,
|
||||
.admin-form…) gardait le fond BLANC du navigateur — vu sur la
|
||||
réinitialisation de mot de passe (Administration), la sélection
|
||||
de projets (Espaces, textarea jamais couverte) et les valeurs
|
||||
du panneau (Paramètres). Sélecteur en :where() = spécificité
|
||||
zéro : cette base ne peut écraser AUCUNE règle de contexte
|
||||
existante, elle ne fait que combler les vides. */
|
||||
:root { color-scheme: dark; }
|
||||
:where(input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]):not([type="range"]),
|
||||
textarea, select) {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
:where(input, textarea, select):focus-visible {
|
||||
outline: 2px solid rgba(124, 92, 252, 0.55);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
/* ── App header ─────────────────────────────── */
|
||||
.app-header, header {
|
||||
position: sticky;
|
||||
@@ -1210,16 +1232,22 @@ footer {
|
||||
}
|
||||
|
||||
/* ── Barre latérale (0.16.0, #16 refonte) ── */
|
||||
body.with-sidebar { padding-left: 238px; transition: padding-left 0.18s ease; }
|
||||
/* Fluidité (0.23.1) : durée et courbe UNIFIÉES entre le padding du
|
||||
corps et la largeur de la barre (avant : 0.18 s ease des deux
|
||||
côtés mais transform à 0.22 s — mouvements désynchronisés), et
|
||||
retrait du backdrop-filter : le fond est déjà opaque à 96 %,
|
||||
le flou forçait un re-rendu coûteux à chaque image de l'animation
|
||||
de largeur, d'où l'effet « lent et saccadé » signalé par Bruno. */
|
||||
body.with-sidebar { padding-left: 238px; transition: padding-left 0.26s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
body.with-sidebar.sidebar-collapsed { padding-left: 68px; }
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; bottom: 0; z-index: 200;
|
||||
width: 238px; box-sizing: border-box;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 16px 12px 12px; overflow-y: auto; overflow-x: hidden;
|
||||
background: rgba(13, 16, 24, 0.96); backdrop-filter: blur(14px);
|
||||
background: rgba(13, 16, 24, 0.96);
|
||||
border-right: 1px solid var(--border);
|
||||
transition: width 0.18s ease, transform 0.22s ease;
|
||||
transition: width 0.26s cubic-bezier(0.4, 0, 0.2, 1), transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
body.sidebar-collapsed .sidebar { width: 68px; }
|
||||
.side-logo { display: flex; align-items: center; gap: 10px; padding: 4px 8px 12px; color: var(--text); font-size: 1.18rem; font-weight: 700; white-space: nowrap; }
|
||||
@@ -1458,20 +1486,60 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
padding: 2px 7px; border-radius: 8px; line-height: 1;
|
||||
}
|
||||
.card-menu-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.card-menu-btn[aria-expanded="true"] { background: var(--surface-2); color: var(--text); }
|
||||
/* Menu ⋯ des tuiles projets — refonte issues #44/#45.
|
||||
Jetons repris de la barre d'apps commune (draco-barre.css,
|
||||
prototype validé Console v7.2, aussi utilisé par la console
|
||||
Spark) : clair par défaut, sombre via [data-theme="dark"]
|
||||
ou prefers-color-scheme. Correctif du bug d'origine : le
|
||||
menu utilisait var(--surface-1), variable jamais définie,
|
||||
donc aucun fond — le menu était transparent et illisible. */
|
||||
.card-menu {
|
||||
--menu-bg: #ffffff; --menu-bg-2: #e9eae6; --menu-ink: #1b1d1a;
|
||||
--menu-ink-2: #5c615a; --menu-line: #dedfd9; --menu-agent: #c2437e;
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60;
|
||||
min-width: 210px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 6px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
|
||||
min-width: 248px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--menu-bg); border: 1px solid var(--menu-line);
|
||||
border-radius: 14px; padding: 6px; color: var(--menu-ink);
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-menu {
|
||||
--menu-bg: #1a1c1a; --menu-bg-2: #242624; --menu-ink: #eceee9;
|
||||
--menu-ink-2: #a6aba2; --menu-line: #2e302c;
|
||||
}
|
||||
}
|
||||
[data-theme="dark"] .card-menu {
|
||||
--menu-bg: #1a1c1a; --menu-bg-2: #242624; --menu-ink: #eceee9;
|
||||
--menu-ink-2: #a6aba2; --menu-line: #2e302c;
|
||||
}
|
||||
.card-menu[hidden] { display: none !important; }
|
||||
.card-menu a {
|
||||
.card-menu a, .card-menu-item {
|
||||
display: flex; align-items: center; min-height: 32px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
color: var(--text-2); font-size: 0.92rem;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
color: var(--menu-ink-2); font-size: 0.92rem;
|
||||
}
|
||||
.card-menu-item {
|
||||
width: 100%; box-sizing: border-box; border: 0;
|
||||
background: transparent; font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.card-menu a:hover, .card-menu a:focus-visible,
|
||||
.card-menu-item:hover, .card-menu-item:focus-visible {
|
||||
background: var(--menu-bg-2); color: var(--menu-ink); text-decoration: none;
|
||||
}
|
||||
.card-menu a:focus-visible, .card-menu-item:focus-visible {
|
||||
outline: 2px solid var(--menu-agent); outline-offset: 1px;
|
||||
}
|
||||
.card-menu-sep { height: 1px; margin: 6px 10px; background: var(--menu-line); }
|
||||
/* Confirmation de copie — pastille fixe, lisible sur fond
|
||||
clair comme sombre, même patron que la barre (draco). */
|
||||
.kanbante-toast {
|
||||
position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
|
||||
z-index: 3000; background: #1f1f1f; color: #eee;
|
||||
padding: 10px 16px; border-radius: 10px;
|
||||
font: 500 13.5px system-ui, "Segoe UI", sans-serif;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / .35);
|
||||
}
|
||||
.card-menu a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
.proj-table {
|
||||
width: calc(100% - 48px); margin: 0 24px 18px;
|
||||
border-collapse: collapse; font-size: 0.93rem;
|
||||
@@ -1619,6 +1687,106 @@ a.ms-title:hover { text-decoration: underline; }
|
||||
.login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; }
|
||||
|
||||
/* ── Fusion ACC-1 (kanbante#28) : cohabitation avec la barre commune ──
|
||||
La barre d'apps (60 px, en flux en haut du body) repousse la barre
|
||||
latérale fixe sous elle ; le reste du cadre suit le flux. */
|
||||
La barre d'apps est FIXE et pleine largeur (0.23.1 — en flux, le
|
||||
padding-left du cadre la repoussait à droite de la barre latérale,
|
||||
constat de Bruno sur capture) ; le cadre entier est décalé de sa
|
||||
hauteur, et la barre latérale fixe démarre sous elle. */
|
||||
body.has-draco-bar { padding-top: 60px; }
|
||||
body.has-draco-bar .draco-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 250; }
|
||||
body.has-draco-bar .sidebar { top: 60px; }
|
||||
@media (max-width: 900px) {
|
||||
body.has-draco-bar { padding-top: 52px; }
|
||||
body.has-draco-bar .sidebar { top: 52px; }
|
||||
}
|
||||
|
||||
/* ── Espaces, admin, profils (0.24.0) ─────────────────── */
|
||||
.ws-switch { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; }
|
||||
.ws-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; }
|
||||
.ws-select { flex: 1; min-width: 0; background: var(--surface-2, #1c1c22); color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 8px; font: inherit; }
|
||||
body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.admin-form { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; margin: 10px 0; }
|
||||
.admin-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85em; }
|
||||
.admin-form input[type="text"], .admin-form input[type="password"], .admin-form select {
|
||||
background: var(--surface-2, #1c1c22); color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 7px 9px; font: inherit; }
|
||||
.inline-form { display: inline-flex; gap: 6px; align-items: center; }
|
||||
.btn-mini { padding: 4px 10px; font-size: 0.85em; border-radius: 8px;
|
||||
border: 1px solid var(--border, #333); background: var(--surface-2, #1c1c22);
|
||||
color: inherit; cursor: pointer; }
|
||||
.btn-mini:hover { border-color: var(--accent, #7c5cfc); }
|
||||
.btn-mini:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-danger:hover { border-color: #e5534b; color: #e5534b; }
|
||||
.chip-ok { display: inline-block; padding: 4px 10px; border-radius: 999px;
|
||||
background: rgba(16, 185, 129, 0.15); border: 1px solid rgba(16, 185, 129, 0.5); }
|
||||
.admin-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
|
||||
border: 1px solid var(--border, #333); white-space: nowrap; }
|
||||
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
|
||||
|
||||
/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */
|
||||
.kanban-card { cursor: pointer; }
|
||||
.detail-actions { margin: 10px 0; }
|
||||
#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; }
|
||||
#detail-edit-form textarea { width: 100%; }
|
||||
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); }
|
||||
.asset-row a { text-decoration: none; font-weight: 600; }
|
||||
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; }
|
||||
.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Compositeur de nouvelle issue (0.31.1) ── */
|
||||
/* La toolbar impose display:flex à ses formulaires : le
|
||||
compositeur est une carte, en bloc et pleine largeur. */
|
||||
.board-toolbar form.composer { display: block; flex: 1 1 100%; }
|
||||
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
|
||||
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
||||
.composer-title { font-weight: 700; font-size: 0.95rem; }
|
||||
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
|
||||
.composer-main { display: flex; gap: 8px; }
|
||||
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
|
||||
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.composer-desc { margin-top: 10px; }
|
||||
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
|
||||
.composer-desc summary::-webkit-details-marker { display: none; }
|
||||
.composer-desc summary:hover .composer-desc-label { text-decoration: underline; }
|
||||
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
|
||||
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
|
||||
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
|
||||
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
|
||||
|
||||
/* ── Mes issues : barre de filtres (0.33.0) ── */
|
||||
.my-filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0 18px; }
|
||||
.my-filters-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.my-filters-row + .my-filters-row { margin-top: 10px; }
|
||||
.my-search-wrap { position: relative; flex: 1 1 280px; }
|
||||
.my-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.65; pointer-events: none; }
|
||||
.my-search-wrap input[type="search"] { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 9px 14px 9px 38px; color: inherit; font-size: 0.92rem; }
|
||||
.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.my-filters select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; color: inherit; font-size: 0.9rem; }
|
||||
.my-filters select:focus { outline: none; border-color: var(--accent); }
|
||||
.my-col-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
|
||||
.filter-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border); color: inherit; border-radius: 999px; padding: 5px 12px; font-size: 0.85rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
||||
.filter-chip:hover { border-color: var(--border-strong); }
|
||||
.filter-chip.on { background: rgba(124, 92, 252, 0.18); border-color: var(--accent); font-weight: 600; }
|
||||
.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.filter-chip-count { opacity: 0.7; font-size: 0.78rem; }
|
||||
.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; }
|
||||
|
||||
/* ── En vol partout (0.34.0, issue #24) ── */
|
||||
.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; }
|
||||
.inflight-total { font-weight: 700; margin-right: 4px; }
|
||||
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
|
||||
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
|
||||
|
||||
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
|
||||
.ms-edit { margin-top: 8px; }
|
||||
.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; }
|
||||
.ms-edit summary::-webkit-details-marker { display: none; }
|
||||
.ms-edit summary:hover { text-decoration: underline; }
|
||||
.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
|
||||
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
|
||||
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
|
||||
.ms-form-actions { display: flex; gap: 8px; }
|
||||
|
||||
+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);
|
||||
+505
-15
@@ -1,4 +1,14 @@
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
|
||||
* v0.1.5 : noms publics par configuration — aucun nom public d'un
|
||||
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
|
||||
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
|
||||
* la publication est séparée) : dérivation -dev par l'hôte courant,
|
||||
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
|
||||
* correctif fusion #70 (SANS bump de version, publication séparée) :
|
||||
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
|
||||
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
|
||||
* « Menu des applications », « Focus » et « Écouteurs globaux »).
|
||||
*
|
||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||
@@ -9,27 +19,217 @@
|
||||
* 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…).
|
||||
* 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) {
|
||||
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
|
||||
if (!b) return;
|
||||
b.textContent = counts[k];
|
||||
b.hidden = !(counts[k] > 0);
|
||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
|
||||
b.textContent = counts[k];
|
||||
b.hidden = !(counts[k] > 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
function setCurrent(app) {
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||
});
|
||||
}
|
||||
function setDomain(dom) {
|
||||
@@ -38,18 +238,308 @@
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var el = ev.target.closest("[data-act]");
|
||||
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
|
||||
function userName() {
|
||||
var u = cfg.user;
|
||||
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
|
||||
if (u && typeof u === "object") {
|
||||
var n = u.display_name || u.name || u.username;
|
||||
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function titleName() {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
var t = av ? av.getAttribute("title") : null;
|
||||
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
|
||||
return null;
|
||||
}
|
||||
function displayName() { return userName() || titleName(); }
|
||||
function syncAvatar() {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
if (!av) return;
|
||||
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
|
||||
* sans data-act) : le rendre déclencheur du menu compte. */
|
||||
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
|
||||
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
|
||||
var name = displayName();
|
||||
if (name) {
|
||||
av.textContent = name.charAt(0).toUpperCase();
|
||||
av.setAttribute("title", name);
|
||||
av.setAttribute("aria-label", "Compte : " + name);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Menus déroulants (apps, compte) ───────────────────────────────
|
||||
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
|
||||
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
|
||||
* déclencheur. Les href des menus sont (re)synchronisés avec
|
||||
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
|
||||
* -dev s'y reflètent même si le menu a été construit avant. */
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||
var avatarBtn = document.querySelector(".draco-avatar");
|
||||
var appsMenu = null, compteMenu = null;
|
||||
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
|
||||
* lui rend le focus à la fermeture (correctif #70). */
|
||||
var lastOpener = null;
|
||||
|
||||
function setExpanded(btn, open) {
|
||||
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
function refreshMenuHrefs(menu) {
|
||||
if (!menu) return;
|
||||
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
|
||||
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
|
||||
});
|
||||
}
|
||||
function togglePopup(menu, btn, force) {
|
||||
if (!menu) return;
|
||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
setExpanded(btn, open);
|
||||
}
|
||||
function closeAllPopups(returnFocus) {
|
||||
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
|
||||
if (appsMenu) togglePopup(appsMenu, brand, false);
|
||||
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
|
||||
/* Échap seulement : le clic extérieur ne déplace pas le focus
|
||||
* (voir l'en-tête, « Focus »). */
|
||||
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
|
||||
}
|
||||
function tabLabel(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
|
||||
return fort ? fort.textContent : (APP_LABELS[app] || app);
|
||||
}
|
||||
function tabRole(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
|
||||
return petit ? petit.textContent : "";
|
||||
}
|
||||
/* Menu des applications (v0.1.3) — construit depuis les onglets
|
||||
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
|
||||
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
|
||||
* extérieur, Échap, ou le choix d'une application (la navigation
|
||||
* suit son cours). */
|
||||
function buildAppsMenu() {
|
||||
if (!bar || !brand || appsMenu) return;
|
||||
appsMenu = document.createElement("div");
|
||||
appsMenu.className = "draco-apps-menu";
|
||||
appsMenu.hidden = true;
|
||||
appsMenu.setAttribute("role", "menu");
|
||||
appsMenu.setAttribute("aria-label", "Applications");
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
|
||||
* repris ici — l'entrée dédiée ci-dessous le représente déjà
|
||||
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
|
||||
* listerait « Console Spark » deux fois. */
|
||||
if (t.dataset.app === "agent") return;
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item";
|
||||
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||
a.dataset.app = t.dataset.app;
|
||||
a.setAttribute("role", "menuitem");
|
||||
var ico = t.querySelector(".draco-apptab-ico");
|
||||
if (ico) a.appendChild(ico.cloneNode(true));
|
||||
var txt = document.createElement("span");
|
||||
txt.className = "draco-apps-item-txt";
|
||||
var fort = t.querySelector(".draco-apptab-txt strong");
|
||||
var petit = t.querySelector(".draco-apptab-txt small");
|
||||
var nom = document.createElement("span");
|
||||
nom.className = "draco-apps-item-nom";
|
||||
nom.textContent = fort ? fort.textContent : t.dataset.app;
|
||||
txt.appendChild(nom);
|
||||
if (petit) {
|
||||
var role = document.createElement("span");
|
||||
role.className = "draco-apps-item-role";
|
||||
role.textContent = petit.textContent;
|
||||
txt.appendChild(role);
|
||||
}
|
||||
a.appendChild(txt);
|
||||
var badge = t.querySelector(".draco-badge");
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
|
||||
* de l'agent commun (URL agent résolue — explicite > -dev >
|
||||
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
|
||||
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
|
||||
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
appsMenu.appendChild(sep);
|
||||
var sparkItem = document.createElement("a");
|
||||
sparkItem.className = "draco-apps-item";
|
||||
sparkItem.href = resolvedUrls.agent || "#";
|
||||
sparkItem.dataset.app = "agent";
|
||||
sparkItem.setAttribute("role", "menuitem");
|
||||
var sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#c2437e");
|
||||
sparkIco.textContent = "✦";
|
||||
sparkItem.appendChild(sparkIco);
|
||||
var sparkTxt = document.createElement("span");
|
||||
sparkTxt.className = "draco-apps-item-txt";
|
||||
var sparkNom = document.createElement("span");
|
||||
sparkNom.className = "draco-apps-item-nom";
|
||||
sparkNom.textContent = "Console Spark";
|
||||
sparkTxt.appendChild(sparkNom);
|
||||
var sparkRole = document.createElement("span");
|
||||
sparkRole.className = "draco-apps-item-role";
|
||||
sparkRole.textContent = "Agent commun";
|
||||
sparkTxt.appendChild(sparkRole);
|
||||
sparkItem.appendChild(sparkTxt);
|
||||
appsMenu.appendChild(sparkItem);
|
||||
bar.appendChild(appsMenu);
|
||||
}
|
||||
function toggleAppsMenu(force) {
|
||||
buildAppsMenu();
|
||||
if (!appsMenu) return;
|
||||
if (force !== false) refreshMenuHrefs(appsMenu);
|
||||
togglePopup(appsMenu, brand, force);
|
||||
if (!appsMenu.hidden) lastOpener = brand;
|
||||
}
|
||||
|
||||
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
|
||||
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
|
||||
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
|
||||
* remplit l'avatar depuis /api/auth/me après chargement). */
|
||||
function buildCompteMenu() {
|
||||
if (!bar || !avatarBtn || compteMenu) return;
|
||||
compteMenu = document.createElement("div");
|
||||
compteMenu.className = "draco-compte-menu";
|
||||
compteMenu.hidden = true;
|
||||
compteMenu.setAttribute("role", "menu");
|
||||
compteMenu.setAttribute("aria-label", "Compte");
|
||||
var head = document.createElement("div");
|
||||
head.className = "draco-compte-nom";
|
||||
compteMenu.appendChild(head);
|
||||
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "draco-menu-item";
|
||||
b.setAttribute("data-compte-act", pair[0]);
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.textContent = pair[1];
|
||||
compteMenu.appendChild(b);
|
||||
});
|
||||
bar.appendChild(compteMenu);
|
||||
}
|
||||
function refreshCompteMenu() {
|
||||
if (!compteMenu) return;
|
||||
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||
var name = displayName();
|
||||
if (head) head.textContent = name || "Compte";
|
||||
}
|
||||
function toggleCompteMenu(force) {
|
||||
buildCompteMenu();
|
||||
if (!compteMenu) return;
|
||||
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||
togglePopup(compteMenu, avatarBtn, force);
|
||||
if (!compteMenu.hidden) lastOpener = avatarBtn;
|
||||
}
|
||||
|
||||
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||
* événement draco:bar (contrat historique des actions relayées). */
|
||||
function relay(act, el, ev) {
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
}
|
||||
|
||||
/* Écouteurs globaux posés sur le prototype EventTarget quand il
|
||||
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
|
||||
* voir l'en-tête) ; sinon document.addEventListener classique. */
|
||||
function addGlobalListener(type, fn) {
|
||||
try {
|
||||
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
|
||||
EventTarget.prototype.addEventListener) {
|
||||
EventTarget.prototype.addEventListener.call(document, type, fn, false);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
document.addEventListener(type, fn);
|
||||
}
|
||||
|
||||
addGlobalListener("click", function (ev) {
|
||||
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||
if (compteItem) {
|
||||
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
|
||||
toggleCompteMenu(false);
|
||||
return;
|
||||
}
|
||||
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
if (compteMenu) toggleCompteMenu(false);
|
||||
toggleAppsMenu();
|
||||
return;
|
||||
}
|
||||
if (el && el.dataset.act === "compte") {
|
||||
if (appsMenu) toggleAppsMenu(false);
|
||||
toggleCompteMenu();
|
||||
return;
|
||||
}
|
||||
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
|
||||
* sur son déclencheur déjà traité ci-dessus). */
|
||||
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
|
||||
ev.target.closest(".draco-compte-menu")) : null;
|
||||
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||
closeAllPopups(false);
|
||||
}
|
||||
if (!el) return;
|
||||
var act = el.dataset.act;
|
||||
if (act === "domaine") setDomain(el.dataset.dom);
|
||||
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);
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger. */
|
||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||
addGlobalListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") { closeAllPopups(true); return; }
|
||||
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
|
||||
* ouverture explicite + focus sur le premier item du menu (#70).
|
||||
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
|
||||
* ci-dessus (pas de double bascule ici). */
|
||||
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
|
||||
var brandEl = ev.target.closest('[data-act="apps"]');
|
||||
if (brandEl) {
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
toggleAppsMenu();
|
||||
if (appsMenu && !appsMenu.hidden) {
|
||||
var first = appsMenu.querySelector(".draco-apps-item");
|
||||
if (first && typeof first.focus === "function") first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||
* setUrls(urls) fusionne de nouvelles bases (même contrat que
|
||||
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
|
||||
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
|
||||
* les bases résolues (défauts inclus), pour le panneau Spark et tout
|
||||
* autre consommateur de l'hôte. */
|
||||
window.DRACO_BAR = {
|
||||
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
|
||||
urls: resolvedUrls,
|
||||
setUrls: function (urls) {
|
||||
cfg.urls = Object.assign({}, cfg.urls, urls || {});
|
||||
resolvedUrls = resolveUrls();
|
||||
window.DRACO_BAR.urls = resolvedUrls;
|
||||
applyUrls(resolvedUrls);
|
||||
}
|
||||
};
|
||||
|
||||
applyUrls(resolvedUrls);
|
||||
buildAppsMenu();
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
|
||||
*
|
||||
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
|
||||
* GET /api/v1/me — identité (cookie draco_session)
|
||||
* POST /api/v1/chat — conversation réelle du noyau (J2) :
|
||||
* LLM DeepSeek + outils MCP des apps ;
|
||||
* la réponse arrive avec son HTML
|
||||
* Markdown rendu côté service (v2)
|
||||
* GET /api/v1/conversations — discussions façon Muse (v2) : une
|
||||
* principale perpétuelle + des
|
||||
* parallèles créées/archivées/
|
||||
* supprimées par l'utilisateur
|
||||
* GET /api/v1/conversations/… — historique d'une discussion (les
|
||||
* messages agent portent leur html)
|
||||
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||
* externes J4 : état de configuration)
|
||||
* POST /api/v1/missions — missions longues (J5)
|
||||
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
||||
* Bouton « Console » (barre des discussions) : ouvre la console web
|
||||
* du même service (adresse injectée par l'hôte) DANS la page, en surimpression
|
||||
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
||||
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
||||
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
||||
* du premier chargement — l'iframe est créé paresseusement et
|
||||
* conservé, la console garde son état entre ouvertures. La barre
|
||||
* d'outils de la surimpression offre « Mode sidebar » (retour au
|
||||
* panneau seul, animation inverse) et « Fermer l'agent » (referme
|
||||
* surimpression et panneau).
|
||||
* États honnêtes : service injoignable / session non reconnue / LLM non
|
||||
* configuré / exécuteur non configuré sont affichés tels quels. */
|
||||
(function () {
|
||||
"use strict";
|
||||
/* Adresse du service : injectée par l'app hôte (gabarit
|
||||
* _draco_bar.html, window.DRACO_PANNEAU_CONFIG.api, issu de la
|
||||
* configuration de l'hôte — AGENT_URL). Aucun nom public en dur
|
||||
* ici : sans injection, le panneau se déclare non configuré. */
|
||||
var API = (window.DRACO_PANNEAU_CONFIG && window.DRACO_PANNEAU_CONFIG.api) || "";
|
||||
var convId = null;
|
||||
var convKind = null; // 'principale' | 'parallele'
|
||||
var convTitre = "";
|
||||
var etatConnu = false;
|
||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||
var titreEl = null, tiroirEl = null;
|
||||
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||
|
||||
function domaine() {
|
||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||
}
|
||||
function stockerConv() {
|
||||
try {
|
||||
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
|
||||
else sessionStorage.removeItem("draco_panneau_conv");
|
||||
} catch (e) { /* stockage indisponible */ }
|
||||
}
|
||||
function convStockee() {
|
||||
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
/* ── Console web (même service, surimpression pleine page) ─── */
|
||||
function appHote() {
|
||||
var h = (window.location.hostname || "").split(".")[0];
|
||||
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||||
kanbante: "kanbante", og: "obsigate" };
|
||||
return correspondance[h] || null;
|
||||
}
|
||||
function urlConsole() {
|
||||
var params = [];
|
||||
var id = convId || convStockee();
|
||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||
var app = appHote();
|
||||
if (app) params.push("app=" + encodeURIComponent(app));
|
||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||
}
|
||||
|
||||
/* La surimpression vit entièrement dans ce fichier (DOM + styles
|
||||
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
|
||||
* thème suit celui du panneau (data-draco-theme synchronisé par
|
||||
* appliquerTheme) et sa palette reprend les mêmes valeurs que
|
||||
* draco-panneau.css. L'iframe est créé au premier clic puis
|
||||
* conservé : rouvrir la console ne le recharge pas. */
|
||||
var surEl = null, surCadre = null, surOuverte = false;
|
||||
var STYLES_CONSOLE =
|
||||
".draco-console-sur{" +
|
||||
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
|
||||
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
|
||||
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
|
||||
"background:var(--p-fond);color:var(--p-texte);" +
|
||||
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
|
||||
"display:flex;flex-direction:column;transition:width .3s ease;" +
|
||||
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
|
||||
".draco-console-sur[data-draco-theme=\"dark\"]{" +
|
||||
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
|
||||
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
|
||||
".draco-console-sur[hidden]{display:none}" +
|
||||
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
|
||||
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
|
||||
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
|
||||
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
|
||||
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
|
||||
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
|
||||
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
|
||||
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
|
||||
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
|
||||
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
|
||||
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
|
||||
|
||||
function assurerSurimpression() {
|
||||
if (surEl) return;
|
||||
if (!document.getElementById("draco-console-styles")) {
|
||||
var st = document.createElement("style");
|
||||
st.id = "draco-console-styles";
|
||||
st.textContent = STYLES_CONSOLE;
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
surEl = document.createElement("div");
|
||||
surEl.className = "draco-console-sur";
|
||||
surEl.setAttribute("aria-label", "Console Spark");
|
||||
surEl.hidden = true;
|
||||
surEl.innerHTML =
|
||||
'<div class="draco-console-barre">' +
|
||||
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
|
||||
' <span class="dc-actions">' +
|
||||
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
|
||||
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
|
||||
' </span></div>' +
|
||||
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
|
||||
document.body.appendChild(surEl);
|
||||
surCadre = surEl.querySelector("iframe");
|
||||
surEl.querySelector('[data-dc="sidebar"]')
|
||||
.addEventListener("click", modeSidebarConsole);
|
||||
surEl.querySelector('[data-dc="fermer"]')
|
||||
.addEventListener("click", fermerAgentConsole);
|
||||
appliquerTheme();
|
||||
}
|
||||
|
||||
function largeurPanneau() {
|
||||
if (root) {
|
||||
var w = root.getBoundingClientRect().width;
|
||||
if (w > 0) return Math.round(w);
|
||||
}
|
||||
return 388;
|
||||
}
|
||||
|
||||
function ouvrirConsole() {
|
||||
assurerSurimpression();
|
||||
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||||
if (surOuverte) return;
|
||||
surOuverte = true;
|
||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||
* extension à toute la page ; le double rAF laisse le temps de
|
||||
* peindre la largeur de départ, sinon la transition ne joue pas. */
|
||||
surEl.style.width = largeurPanneau() + "px";
|
||||
surEl.hidden = false;
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () { surEl.style.width = "100%"; });
|
||||
});
|
||||
}
|
||||
|
||||
function retrecirConsole() {
|
||||
if (!surEl || !surOuverte) return;
|
||||
surOuverte = false;
|
||||
var fini = false;
|
||||
function terminer() {
|
||||
if (fini) return;
|
||||
fini = true;
|
||||
surEl.removeEventListener("transitionend", surFin);
|
||||
surEl.hidden = true; // l'iframe reste chargé, juste masqué
|
||||
}
|
||||
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
|
||||
surEl.addEventListener("transitionend", surFin);
|
||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||
surEl.style.width = largeurPanneau() + "px";
|
||||
}
|
||||
function modeSidebarConsole() { retrecirConsole(); }
|
||||
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||
|
||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||
function themeHote() {
|
||||
var t = document.documentElement.getAttribute("data-theme");
|
||||
if (t === "dark" || t === "light") return t;
|
||||
if (document.documentElement.classList.contains("dark") ||
|
||||
document.body.classList.contains("dark")) return "dark";
|
||||
try {
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
|
||||
} catch (e) { /* pas de matchMedia */ }
|
||||
return "light";
|
||||
}
|
||||
function appliquerTheme() {
|
||||
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||
}
|
||||
function surveillerTheme() {
|
||||
try {
|
||||
new MutationObserver(appliquerTheme).observe(document.documentElement,
|
||||
{ attributes: true, attributeFilter: ["data-theme", "class"] });
|
||||
window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", appliquerTheme);
|
||||
} catch (e) { /* surveillance facultative */ }
|
||||
}
|
||||
|
||||
/* ── Largeur réglable ──────────────────────────────────────── */
|
||||
function largeurStockee() {
|
||||
try {
|
||||
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
|
||||
return isNaN(v) ? null : v;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function appliquerLargeur() {
|
||||
if (!root) return;
|
||||
var v = largeurStockee();
|
||||
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
|
||||
}
|
||||
function brancherPoignee(poignee) {
|
||||
poignee.addEventListener("pointerdown", function (ev) {
|
||||
if (window.innerWidth <= 700) return; // mobile : pleine largeur
|
||||
ev.preventDefault();
|
||||
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
|
||||
function bouger(e2) {
|
||||
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
|
||||
root.style.width = w + "px";
|
||||
}
|
||||
function lacher(e2) {
|
||||
document.removeEventListener("pointermove", bouger);
|
||||
document.removeEventListener("pointerup", lacher);
|
||||
var w = Math.round(root.getBoundingClientRect().width);
|
||||
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
|
||||
}
|
||||
document.addEventListener("pointermove", bouger);
|
||||
document.addEventListener("pointerup", lacher);
|
||||
});
|
||||
poignee.addEventListener("dblclick", function () {
|
||||
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
|
||||
appliquerLargeur();
|
||||
});
|
||||
}
|
||||
|
||||
function construire() {
|
||||
root = document.createElement("aside");
|
||||
root.className = "draco-panneau";
|
||||
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||
root.innerHTML =
|
||||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||
'<div class="draco-panneau-hd">' +
|
||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||
' <div><div class="titre">Spark — agent commun</div>' +
|
||||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||
'</div>' +
|
||||
'<div class="draco-disc-bar">' +
|
||||
' <span class="d-titre">Discussion principale</span>' +
|
||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-discussions" hidden></div>' +
|
||||
'<div class="draco-panneau-outils">' +
|
||||
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
||||
' <button type="button" data-outil="approbations">Approbations</button>' +
|
||||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-msgs"></div>' +
|
||||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||
' <button type="submit">Envoyer</button></form>' +
|
||||
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||||
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(root);
|
||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||
etatEl = root.querySelector(".draco-panneau-etat");
|
||||
inputEl = root.querySelector("input");
|
||||
sendBtn = root.querySelector("button[type=submit]");
|
||||
titreEl = root.querySelector(".d-titre");
|
||||
tiroirEl = root.querySelector(".draco-discussions");
|
||||
versionEl = root.querySelector(".draco-panneau-version");
|
||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
|
||||
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
|
||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||
root.querySelector("form").addEventListener("submit", envoyer);
|
||||
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||
appliquerTheme();
|
||||
surveillerTheme();
|
||||
appliquerLargeur();
|
||||
window.addEventListener("resize", appliquerLargeur);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
/* Surimpression ouverte : Échap = retour au panneau seul
|
||||
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
|
||||
* console (autre origine), l'événement ne remonte pas
|
||||
* jusqu'ici : il ne peut donc pas fermer par surprise. */
|
||||
if (surOuverte) { modeSidebarConsole(); return; }
|
||||
if (root.classList.contains("open")) fermer();
|
||||
});
|
||||
}
|
||||
|
||||
function ajouterMsg(kind, texte) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "draco-msg " + kind;
|
||||
d.textContent = texte;
|
||||
msgsEl.appendChild(d);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
|
||||
/* Message agent rendu en Markdown : le HTML vient du service
|
||||
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
|
||||
function ajouterMsgMd(texte, html) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "draco-msg agent md";
|
||||
if (html) d.innerHTML = html;
|
||||
else d.textContent = texte;
|
||||
msgsEl.appendChild(d);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
|
||||
function majEtat(cls, texte) {
|
||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||
etatEl.querySelector(".txt").textContent = texte;
|
||||
}
|
||||
function majTitre() {
|
||||
titreEl.textContent = convTitre || "Discussion principale";
|
||||
titreEl.title = titreEl.textContent;
|
||||
}
|
||||
|
||||
/* ── Version du service : lue en direct dans la santé de
|
||||
* l'agent (GET /healthz → champ « version », la même source que
|
||||
* la console) à l'origine que le panneau connaît déjà. Cache de
|
||||
* session (5 min), rafraîchie à l'ouverture du panneau, au même
|
||||
* rythme que la vérification d'identité. Jamais de valeur codée
|
||||
* en dur : source injoignable ou réponse illisible = la ligne
|
||||
* reste masquée — pas de fausse version, pas d'erreur visible. */
|
||||
function afficherVersion() {
|
||||
if (!versionEl) return;
|
||||
if (versionConnue) {
|
||||
versionEl.textContent = "Service d'agents v" + versionConnue;
|
||||
versionEl.hidden = false;
|
||||
}
|
||||
}
|
||||
function chargerVersion() {
|
||||
if (!API || !versionEl) return;
|
||||
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
|
||||
fetch(API + "/healthz")
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
|
||||
versionConnue = d.version;
|
||||
versionMaj = Date.now();
|
||||
afficherVersion();
|
||||
}
|
||||
})
|
||||
.catch(function () { /* service injoignable : rien d'affiché */ });
|
||||
}
|
||||
|
||||
function verifierIdentite() {
|
||||
if (etatConnu) return;
|
||||
if (!API) {
|
||||
majEtat("err", "Panneau Spark non configuré");
|
||||
ajouterMsg("sys", "Adresse du service d'agents absente : l'app hôte ne l'a pas injectée (DRACO_PANNEAU_CONFIG / AGENT_URL).");
|
||||
etatConnu = true;
|
||||
return;
|
||||
}
|
||||
fetch(API + "/api/v1/me", { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 401) {
|
||||
majEtat("err", "Session partagée non reconnue");
|
||||
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
|
||||
etatConnu = true;
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (me) {
|
||||
if (!me) return;
|
||||
etatConnu = true;
|
||||
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
|
||||
})
|
||||
.catch(function () {
|
||||
majEtat("err", "Service d'agents injoignable");
|
||||
ajouterMsg("sys", "Le service d'agents (" + API + ") ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Discussions (façon Muse) ────────────────────────────── */
|
||||
function chargerListeDiscussions() {
|
||||
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
|
||||
{ credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
|
||||
function demarrerDiscussions() {
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
var precedente = convStockee();
|
||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||
var active = trouvee || d.principale;
|
||||
if (!active) return;
|
||||
convId = active.id; convKind = active.kind; convTitre = active.title;
|
||||
stockerConv(); majTitre();
|
||||
chargerHistorique();
|
||||
})
|
||||
.catch(function () {
|
||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||
});
|
||||
}
|
||||
|
||||
function basculerDiscussion(c) {
|
||||
convId = c.id; convKind = c.kind; convTitre = c.title;
|
||||
stockerConv(); majTitre();
|
||||
tiroirEl.hidden = true;
|
||||
msgsEl.innerHTML = "";
|
||||
ajouterMsg("sys", (c.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
|
||||
chargerHistorique();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
function nouvelleDiscussion() {
|
||||
fetch(API + "/api/v1/conversations", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain: domaine() })
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (c) {
|
||||
basculerDiscussion(c);
|
||||
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
|
||||
}
|
||||
|
||||
function actionDiscussion(id, action) {
|
||||
var methode = action === "supprimer" ? "DELETE" : "POST";
|
||||
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
|
||||
return fetch(url, { method: methode, credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function ligneDiscussion(c, actions) {
|
||||
var ligne = document.createElement("div");
|
||||
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "d-meta";
|
||||
var quand = "";
|
||||
try {
|
||||
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
|
||||
{ day: "numeric", month: "short" });
|
||||
} catch (e) { /* date illisible */ }
|
||||
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
|
||||
ligne.appendChild(meta);
|
||||
var sous = document.createElement("div");
|
||||
sous.className = "d-sous";
|
||||
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
|
||||
(c.kind === "principale" ? " · perpétuelle" : "");
|
||||
ligne.appendChild(sous);
|
||||
meta.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
sous.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
if (actions && actions.length) {
|
||||
var btns = document.createElement("div");
|
||||
btns.className = "d-actions";
|
||||
actions.forEach(function (a) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button"; b.textContent = a.label;
|
||||
if (a.danger) b.className = "danger";
|
||||
b.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (a.confirmer && !window.confirm(a.confirmer)) return;
|
||||
actionDiscussion(c.id, a.action)
|
||||
.then(function () {
|
||||
if (c.id === convId && a.action !== "desarchiver") {
|
||||
chargerListeDiscussions().then(function (d) {
|
||||
if (d.principale) {
|
||||
basculerDiscussion(d.principale);
|
||||
ajouterMsg("sys", a.action === "supprimer"
|
||||
? "Discussion supprimée — retour à la discussion principale."
|
||||
: "Discussion archivée — retour à la discussion principale.");
|
||||
}
|
||||
});
|
||||
}
|
||||
remplirTiroir();
|
||||
})
|
||||
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
|
||||
});
|
||||
btns.appendChild(b);
|
||||
});
|
||||
ligne.appendChild(btns);
|
||||
}
|
||||
return ligne;
|
||||
}
|
||||
|
||||
function remplirTiroir() {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
tiroirEl.innerHTML = "";
|
||||
var h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussion principale";
|
||||
tiroirEl.appendChild(h);
|
||||
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussions parallèles";
|
||||
tiroirEl.appendChild(h);
|
||||
if (!d.paralleles.length) {
|
||||
var vide = document.createElement("div");
|
||||
vide.className = "d-vide";
|
||||
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
|
||||
tiroirEl.appendChild(vide);
|
||||
}
|
||||
d.paralleles.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Archiver", action: "archive" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
if (d.archivees && d.archivees.length) {
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Archivées";
|
||||
tiroirEl.appendChild(h);
|
||||
d.archivees.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Désarchiver", action: "desarchiver" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function basculerTiroir() {
|
||||
tiroirEl.hidden = !tiroirEl.hidden;
|
||||
if (!tiroirEl.hidden) remplirTiroir();
|
||||
}
|
||||
|
||||
function chargerHistorique() {
|
||||
if (!convId) return;
|
||||
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) { convId = null; stockerConv(); return null; }
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
convKind = d.kind; convTitre = d.title || convTitre;
|
||||
majTitre();
|
||||
if (!msgsEl.querySelector(".draco-msg")) {
|
||||
ajouterMsg("sys", (d.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
|
||||
}
|
||||
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||
(d.messages || []).forEach(function (m) {
|
||||
var el = m.role === "user"
|
||||
? ajouterMsg("user", m.content)
|
||||
: ajouterMsgMd(m.content, m.html);
|
||||
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { /* historique indisponible : on repart à neuf */ });
|
||||
}
|
||||
|
||||
function chargerAujourdhui() {
|
||||
var bloc = document.createElement("div");
|
||||
bloc.className = "draco-auj";
|
||||
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
|
||||
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
|
||||
var t = d.sources.tiktak;
|
||||
h += "<h5>Tâches du jour — TikTak</h5>";
|
||||
if (t.status === "ok") {
|
||||
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
|
||||
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||
} else if (t.status === "non_configure") {
|
||||
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
|
||||
} else if (t.status === "non_autorise") {
|
||||
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
|
||||
} else {
|
||||
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
|
||||
}
|
||||
var k = d.sources.kanbante;
|
||||
h += "<h5>Cartes en Révision — Kanbante</h5>";
|
||||
if (k.status === "ok") {
|
||||
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
|
||||
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||
} else if (k.status === "non_autorise") {
|
||||
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
|
||||
} else {
|
||||
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
|
||||
}
|
||||
bloc.innerHTML = h;
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () {
|
||||
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function chargerApprobations() {
|
||||
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
|
||||
d.approvals.forEach(function (a) {
|
||||
var bloc = document.createElement("div");
|
||||
bloc.className = "draco-appro";
|
||||
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
|
||||
"<div class='resume'>" + esc(a.summary) + "</div>" +
|
||||
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
|
||||
"<button type='button' class='non'>Rejeter</button></div>";
|
||||
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
|
||||
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
|
||||
msgsEl.appendChild(bloc);
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
|
||||
}
|
||||
|
||||
function decider(id, decision, bloc) {
|
||||
fetch(API + "/api/v1/approvals/" + id + "/decide", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ decision: decision })
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
bloc.style.opacity = ".55";
|
||||
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
|
||||
}
|
||||
|
||||
/* ── Missions longues (J5) ───────────────────────────────────
|
||||
* Sélecteur d'exécuteur alimenté par le registre du service :
|
||||
* les externes non configurés sont montrés mais désactivés —
|
||||
* jamais un lancement qui simule. La liste se rafraîchit à
|
||||
* l'ouverture et après chaque lancement/annulation. */
|
||||
var STATUTS_MISSION = {
|
||||
queued: "En file", running: "En cours…", paused_question: "Question en attente",
|
||||
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
|
||||
};
|
||||
|
||||
function ouvrirMissions() {
|
||||
var bloc = document.createElement("div");
|
||||
bloc.className = "draco-missions";
|
||||
bloc.innerHTML =
|
||||
"<h5>Missions longues</h5>" +
|
||||
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
|
||||
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
|
||||
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
|
||||
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
|
||||
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
|
||||
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
|
||||
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
|
||||
msgsEl.appendChild(bloc);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
fetch(API + "/api/v1/executors", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
var sel = bloc.querySelector(".m-exec");
|
||||
sel.innerHTML = "";
|
||||
d.executors.forEach(function (e) {
|
||||
if (e.family !== "sous-agent" && e.family !== "externe") return;
|
||||
var opt = document.createElement("option");
|
||||
opt.value = e.id;
|
||||
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
|
||||
(e.status === "non_configure" ? " — non configuré" : "");
|
||||
if (e.status === "non_configure") opt.disabled = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
|
||||
})
|
||||
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
|
||||
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
|
||||
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
|
||||
chargerMissions(bloc);
|
||||
}
|
||||
|
||||
function chargerMissions(bloc) {
|
||||
var zone = bloc.querySelector(".m-liste");
|
||||
fetch(API + "/api/v1/missions", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
|
||||
zone.innerHTML = "";
|
||||
d.missions.slice(0, 10).forEach(function (m) {
|
||||
var ligne = document.createElement("div");
|
||||
ligne.className = "m-item";
|
||||
var entete = document.createElement("div");
|
||||
entete.className = "m-entete";
|
||||
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
|
||||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||||
ligne.appendChild(entete);
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "m-meta";
|
||||
meta.textContent = m.executor +
|
||||
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
|
||||
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
|
||||
ligne.appendChild(meta);
|
||||
if (m.result) {
|
||||
var rapport = document.createElement("div");
|
||||
rapport.className = "m-rapport md";
|
||||
if (m.result_html) rapport.innerHTML = m.result_html;
|
||||
else rapport.textContent = String(m.result).slice(0, 1200);
|
||||
ligne.appendChild(rapport);
|
||||
}
|
||||
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
|
||||
btn.addEventListener("click", function () {
|
||||
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" }, body: "{}"
|
||||
}).then(function () { chargerMissions(bloc); })
|
||||
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
|
||||
});
|
||||
ligne.appendChild(btn);
|
||||
}
|
||||
zone.appendChild(ligne);
|
||||
});
|
||||
})
|
||||
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
|
||||
}
|
||||
|
||||
function lancerMission(bloc) {
|
||||
var executor = bloc.querySelector(".m-exec").value;
|
||||
var titre = bloc.querySelector(".m-titre").value.trim();
|
||||
var prompt = bloc.querySelector(".m-prompt").value.trim();
|
||||
var carte = bloc.querySelector(".m-carte").value;
|
||||
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
|
||||
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
|
||||
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
|
||||
if (carte) corps.kanbante_number = parseInt(carte, 10);
|
||||
fetch(API + "/api/v1/missions", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(corps)
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
|
||||
chargerMissions(bloc);
|
||||
})
|
||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||
}
|
||||
|
||||
function envoyer(ev) {
|
||||
ev.preventDefault();
|
||||
var texte = inputEl.value.trim();
|
||||
if (!texte) return;
|
||||
inputEl.value = "";
|
||||
ajouterMsg("user", texte);
|
||||
sendBtn.disabled = true;
|
||||
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
||||
fetch(API + "/api/v1/chat", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 401) {
|
||||
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
|
||||
majEtat("err", "Session partagée non reconnue");
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) {
|
||||
return r.json().catch(function () { return null; }).then(function (e) {
|
||||
attente.textContent = (e && e.detail) ? e.detail :
|
||||
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
convId = d.conversation_id;
|
||||
convKind = d.kind || convKind;
|
||||
if (d.title) convTitre = d.title;
|
||||
stockerConv(); majTitre();
|
||||
if (d.html) {
|
||||
attente.className = "draco-msg agent md";
|
||||
attente.innerHTML = d.html;
|
||||
} else {
|
||||
attente.textContent = d.reply;
|
||||
}
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
if (d.tools_used && d.tools_used.length) {
|
||||
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
|
||||
}
|
||||
majEtat("ok", "Connecté au service — domaine " + d.domain);
|
||||
})
|
||||
.catch(function () {
|
||||
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||
majEtat("err", "Service d'agents injoignable");
|
||||
})
|
||||
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
||||
}
|
||||
|
||||
function ouvrir() {
|
||||
if (!root) { construire(); demarrerDiscussions(); }
|
||||
root.classList.add("open");
|
||||
appliquerTheme();
|
||||
verifierIdentite();
|
||||
chargerVersion();
|
||||
setTimeout(function () { inputEl.focus(); }, 300);
|
||||
}
|
||||
function fermer() { if (root) root.classList.remove("open"); }
|
||||
|
||||
window.DRACO_PANNEAU = {
|
||||
open: ouvrir,
|
||||
close: fermer,
|
||||
toggle: function () {
|
||||
if (root && root.classList.contains("open")) fermer(); else ouvrir();
|
||||
}
|
||||
};
|
||||
})();
|
||||
+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();
|
||||
})();
|
||||
@@ -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
+115
-7
@@ -66,8 +66,13 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
|
||||
class FakeGitea:
|
||||
"""Drop-in async replacement for GiteaClient used by the API tests."""
|
||||
|
||||
kind = "gitea"
|
||||
|
||||
def __init__(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
self.connection_id = 1
|
||||
self.connection_slug = "gitea"
|
||||
self.key_prefix = ""
|
||||
self.starred = set()
|
||||
self.repos = [
|
||||
_repo("demo", now - timedelta(days=1)),
|
||||
@@ -136,9 +141,17 @@ class FakeGitea:
|
||||
results.append({**issue, "repository": {"full_name": full_name}})
|
||||
return results
|
||||
|
||||
async def set_archived(self, owner, repo, archived):
|
||||
for r in self.repos:
|
||||
if r["full_name"] == f"{owner}/{repo}":
|
||||
r["archived"] = archived
|
||||
return {"archived": archived}
|
||||
|
||||
async def delete_label(self, owner, repo, label_id):
|
||||
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
||||
for name in [n for n, l in labels.items() if l["id"] == label_id]:
|
||||
for name in [
|
||||
n for n, l in labels.items() if str(l["id"]) == str(label_id)
|
||||
]:
|
||||
del labels[name]
|
||||
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
|
||||
|
||||
@@ -174,6 +187,52 @@ class FakeGitea:
|
||||
"404", request=request, response=_httpx.Response(404, request=request)
|
||||
)
|
||||
|
||||
supports_attachments = True
|
||||
|
||||
async def update_issue(self, owner, repo, number, title=None, body=None):
|
||||
issue = await self.get_issue(owner, repo, number)
|
||||
if title is not None:
|
||||
issue["title"] = title
|
||||
if body is not None:
|
||||
issue["body"] = body
|
||||
return issue
|
||||
|
||||
# Pièces jointes en mémoire : {(repo, numéro): [assets]}
|
||||
@property
|
||||
def _assets(self):
|
||||
return self.__dict__.setdefault("assets_store", {})
|
||||
|
||||
async def list_issue_assets(self, owner, repo, number):
|
||||
return list(self._assets.get((f"{owner}/{repo}", number), []))
|
||||
|
||||
async def upload_issue_asset(
|
||||
self, owner, repo, number, filename, content, content_type="application/octet-stream"
|
||||
):
|
||||
store = self._assets.setdefault((f"{owner}/{repo}", number), [])
|
||||
asset = {
|
||||
"id": 700 + len(store) + number,
|
||||
"name": filename,
|
||||
"size": len(content),
|
||||
"download_count": 0,
|
||||
"created_at": "2026-10-23T10:00:00Z",
|
||||
"_content": content,
|
||||
"_type": content_type,
|
||||
}
|
||||
store.append(asset)
|
||||
return {k: v for k, v in asset.items() if not k.startswith("_")}
|
||||
|
||||
async def delete_issue_asset(self, owner, repo, number, asset_id):
|
||||
store = self._assets.get((f"{owner}/{repo}", number), [])
|
||||
self._assets[(f"{owner}/{repo}", number)] = [
|
||||
a for a in store if a["id"] != asset_id
|
||||
]
|
||||
|
||||
async def download_issue_asset(self, owner, repo, number, asset_id):
|
||||
for asset in self._assets.get((f"{owner}/{repo}", number), []):
|
||||
if asset["id"] == asset_id:
|
||||
return asset["_content"], asset["_type"]
|
||||
raise AssertionError("asset introuvable dans le faux")
|
||||
|
||||
async def get_issue_comments(self, owner, repo, number):
|
||||
return list(self.comments.get((f"{owner}/{repo}", number), []))
|
||||
|
||||
@@ -300,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,71 @@
|
||||
"""Jetons d'API personnels (0.25.0) : modèle + accès MCP par Bearer.
|
||||
|
||||
Le service d'agents se fait refuser par Kanbante depuis la 0.24.0
|
||||
(auth partout, cookie/Basic seulement). Les jetons kbt_ lui donnent
|
||||
une identité machine propre, révocable, limitée à /mcp et /api/.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import accounts, models
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def utilisateur():
|
||||
# La base de test est partagée par toute la session : nom unique,
|
||||
# et nettoyage complet au teardown — le pont SSO (test_draco_sso)
|
||||
# a une sémantique « un seul compte en base » dont hérite la suite.
|
||||
import uuid
|
||||
uid = models.create_user(
|
||||
f"machine-{uuid.uuid4().hex[:8]}",
|
||||
accounts.hash_password("motdepasse"), role="member")
|
||||
user = models.get_user(uid)
|
||||
yield user
|
||||
with models.get_db() as conn:
|
||||
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
|
||||
|
||||
|
||||
def test_jeton_cycle_de_vie(utilisateur):
|
||||
row, clair = models.create_api_token(utilisateur["id"], "service-agents")
|
||||
assert clair.startswith("kbt_")
|
||||
# La base ne connaît que l'empreinte, jamais le texte clair.
|
||||
assert row["token_hash"] != clair and len(row["token_hash"]) == 64
|
||||
trouve = models.user_by_api_token(clair)
|
||||
assert trouve is not None and trouve["username"] == utilisateur["username"]
|
||||
assert models.list_api_tokens(utilisateur["id"])[0]["last_used_at"] is not None
|
||||
# Révocation : effet immédiat.
|
||||
assert models.revoke_api_token(utilisateur["id"], row["id"]) is True
|
||||
assert models.user_by_api_token(clair) is None
|
||||
# On ne révoque pas le jeton d'autrui.
|
||||
row2, _clair2 = models.create_api_token(utilisateur["id"], "autre")
|
||||
assert models.revoke_api_token(utilisateur["id"] + 999, row2["id"]) is False
|
||||
|
||||
|
||||
def test_jeton_invalide_ou_compte_desactive(utilisateur):
|
||||
_row, clair = models.create_api_token(utilisateur["id"], "x")
|
||||
assert models.user_by_api_token("kbt_nimportequoi") is None
|
||||
assert models.user_by_api_token("") is None
|
||||
assert models.user_by_api_token("tck_autreformat") is None
|
||||
models.update_user(utilisateur["id"], active=0)
|
||||
assert models.user_by_api_token(clair) is None
|
||||
|
||||
|
||||
def test_mcp_par_jeton_quand_auth_active(monkeypatch, utilisateur):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
|
||||
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2024-11-05"}}
|
||||
with TestClient(main.app) as client:
|
||||
# Sans jeton : 401 (le refus vu en production le 2026-10-21).
|
||||
assert client.post("/mcp", json=corps).status_code == 401
|
||||
# Avec le jeton Bearer : le handshake passe.
|
||||
r = client.post("/mcp", json=corps,
|
||||
headers={"Authorization": f"Bearer {clair}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
|
||||
# Mais un jeton n'ouvre pas l'interface web.
|
||||
assert client.get("/", headers={"Authorization": f"Bearer {clair}",
|
||||
"Accept": "application/json"}).status_code == 401
|
||||
@@ -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
|
||||
+384
-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):
|
||||
@@ -26,6 +187,31 @@ def test_statiques_de_la_barre_servis(client):
|
||||
assert client.get("/static/js/draco-barre.js").status_code == 200
|
||||
|
||||
|
||||
def test_menu_des_applications_par_la_marque(client):
|
||||
# v0.1.3 : la marque ✦ porte data-act="apps" et le JS commun
|
||||
# construit le menu (.draco-apps-menu) ; le logo passe à 24 px.
|
||||
accueil = client.get("/")
|
||||
assert 'data-act="apps"' in accueil.text
|
||||
# 0.23.3 : assets versionnés, sinon le cache garde l'ancien JS.
|
||||
assert "draco-barre.css?v=" in accueil.text
|
||||
assert "draco-barre.js?v=" in accueil.text
|
||||
js = client.get("/static/js/draco-barre.js").text
|
||||
assert "draco-apps-menu" in js
|
||||
css = client.get("/static/css/draco-barre.css").text
|
||||
assert "font-size: 24px" in css
|
||||
|
||||
|
||||
def test_barre_pleine_largeur_et_palier_mobile(client):
|
||||
# 0.23.1 : la barre est fixe pleine largeur (le padding du cadre
|
||||
# ne la repousse plus) et le palier mobile commons v0.1.2 ne
|
||||
# garde que l'onglet de l'app courante.
|
||||
style = client.get("/static/css/style.css").text
|
||||
assert "body.has-draco-bar .draco-bar" in style
|
||||
assert "position: fixed" in style
|
||||
barre = client.get("/static/css/draco-barre.css").text
|
||||
assert '.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"]' in barre
|
||||
|
||||
|
||||
def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -37,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,117 @@
|
||||
"""GitHubClient (0.24.0, P1): same protocol as GiteaClient,
|
||||
GitHub wire shape underneath. Driven through MockTransport."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.github_client import GitHubClient
|
||||
|
||||
|
||||
def make_client(handler) -> GitHubClient:
|
||||
return GitHubClient(
|
||||
base_url="https://api.github.com",
|
||||
token="ghp_test",
|
||||
cache_ttl=0,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
async def test_auth_header_is_bearer():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
seen["accept"] = request.headers.get("accept")
|
||||
return httpx.Response(200, json={"login": "octocat"})
|
||||
|
||||
client = make_client(handler)
|
||||
user = await client.get_user()
|
||||
assert user["login"] == "octocat"
|
||||
assert seen["auth"] == "Bearer ghp_test"
|
||||
assert "application/vnd.github+json" in seen["accept"]
|
||||
|
||||
|
||||
async def test_pagination_follows_link_headers():
|
||||
def handler(request):
|
||||
if "page=2" in str(request.url):
|
||||
return httpx.Response(200, json=[{"full_name": "a/deux"}])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"full_name": "a/un"}],
|
||||
headers={
|
||||
"Link": '<https://api.github.com/user/repos?page=2>; rel="next"'
|
||||
},
|
||||
)
|
||||
|
||||
client = make_client(handler)
|
||||
repos = await client._fetch_search_repos()
|
||||
assert [r["full_name"] for r in repos] == ["a/un", "a/deux"]
|
||||
|
||||
|
||||
async def test_labels_are_addressed_by_name():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
if request.method == "GET" and request.url.path.endswith("/labels"):
|
||||
return httpx.Response(
|
||||
200, json=[{"id": 991, "name": "kanban:todo", "color": "3b82f6"}]
|
||||
)
|
||||
if request.method == "POST":
|
||||
seen["body"] = request.content.decode()
|
||||
return httpx.Response(200, json=[])
|
||||
raise AssertionError(request.url)
|
||||
|
||||
client = make_client(handler)
|
||||
label = await client.ensure_label("a", "b", "kanban:todo", "3b82f6")
|
||||
assert label["id"] == "kanban:todo" # name, not GitHub's numeric id
|
||||
await client.add_issue_label("a", "b", 7, label["id"])
|
||||
assert '"kanban:todo"' in seen["body"]
|
||||
|
||||
|
||||
async def test_search_issues_normalizes_repository():
|
||||
def handler(request):
|
||||
assert "is%3Aissue" in str(request.url) or "is:issue" in str(request.url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"number": 3,
|
||||
"title": "T",
|
||||
"repository_url": "https://api.github.com/repos/a/b",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
client = make_client(handler)
|
||||
issues = await client.search_issues(q="mot", state="open")
|
||||
assert issues[0]["repository"]["full_name"] == "a/b"
|
||||
|
||||
|
||||
async def test_set_archived_uses_patch():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["method"] = request.method
|
||||
seen["body"] = request.content.decode()
|
||||
return httpx.Response(200, json={"archived": True})
|
||||
|
||||
client = make_client(handler)
|
||||
await client.set_archived("a", "b", True)
|
||||
assert seen["method"] == "PATCH"
|
||||
assert '"archived": true' in seen["body"] or '"archived":true' in seen["body"]
|
||||
|
||||
|
||||
async def test_starred_roundtrip():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["method"] = request.method
|
||||
seen["path"] = request.url.path
|
||||
return httpx.Response(204)
|
||||
|
||||
client = make_client(handler)
|
||||
await client.set_starred("a", "b", True)
|
||||
assert seen["method"] == "PUT"
|
||||
assert seen["path"] == "/user/starred/a/b"
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Écritures MCP minimales : create_issue + add_comment (0.26.0),
|
||||
move_issue (0.36.0).
|
||||
|
||||
Le service d'agents doit pouvoir proposer des cartes, commenter
|
||||
et déplacer des cartes via Kanbante, sans passer par l'interface
|
||||
web. Les trois outils réutilisent les services existants
|
||||
(création via _do_capture → client de forge, commentaire via
|
||||
client.create_comment, déplacement via _do_move_card) et sont
|
||||
gardés par la même authentification que les lectures /mcp :
|
||||
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import accounts, models
|
||||
|
||||
|
||||
def _call(client, name, arguments, headers=None):
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": name, "arguments": arguments},
|
||||
}
|
||||
return client.post("/mcp", json=payload, headers=headers or {})
|
||||
|
||||
|
||||
def _result_text(response):
|
||||
result = response.json()["result"]
|
||||
return result, result["content"][0]["text"]
|
||||
|
||||
|
||||
# ── Création de carte ──────────────────────────────────────
|
||||
|
||||
def test_mcp_create_issue_places_card(client, fake_gitea):
|
||||
r = _call(client, "create_issue", {
|
||||
"owner": "Projets", "repo": "demo",
|
||||
"title": "Carte créée par MCP", "body": "Corps de la carte",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False
|
||||
created = json.loads(text)
|
||||
assert created["title"] == "Carte créée par MCP"
|
||||
assert created["repo_full_name"] == "Projets/demo"
|
||||
assert created["column"] == "backlog"
|
||||
assert created["number"] > 0
|
||||
# L'écriture est passée par le client de forge existant.
|
||||
assert fake_gitea.created == [("Projets", "demo", "Carte créée par MCP")]
|
||||
# La carte est placée sur le board (lecture MCP relue).
|
||||
r = _call(client, "get_board", {"owner": "Projets", "repo": "demo"})
|
||||
board = json.loads(_result_text(r)[1])
|
||||
assert any(
|
||||
c["title"] == "Carte créée par MCP" for c in board["backlog"]
|
||||
)
|
||||
# Journal : création tracée avec la source « mcp ».
|
||||
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||
created_events = [
|
||||
e for e in events
|
||||
if e["event_type"] == "created"
|
||||
and e["issue_number"] == created["number"]
|
||||
]
|
||||
assert created_events and created_events[0]["source"] == "mcp"
|
||||
|
||||
|
||||
def test_mcp_create_issue_column_choice(client, fake_gitea):
|
||||
r = _call(client, "create_issue", {
|
||||
"owner": "Projets", "repo": "demo",
|
||||
"title": "Directement à faire", "column": "todo",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False
|
||||
assert json.loads(text)["column"] == "todo"
|
||||
|
||||
|
||||
def test_mcp_create_issue_validation_errors(client, fake_gitea):
|
||||
# Titre manquant : « Missing argument » (patron d'erreur du serveur).
|
||||
r = _call(client, "create_issue", {"owner": "Projets", "repo": "demo"})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is True
|
||||
assert "Missing argument" in text
|
||||
# Titre vide et colonne inconnue : erreurs métier en isError.
|
||||
for arguments in (
|
||||
{"owner": "Projets", "repo": "demo", "title": " "},
|
||||
{"owner": "Projets", "repo": "demo", "title": "X",
|
||||
"column": "nimporte"},
|
||||
):
|
||||
result, _text = _result_text(_call(client, "create_issue", arguments))
|
||||
assert result["isError"] is True
|
||||
assert fake_gitea.created == []
|
||||
|
||||
|
||||
# ── Commentaire ────────────────────────────────────────────
|
||||
|
||||
def test_mcp_add_comment_posts_on_forge(client, fake_gitea):
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"body": "Commentaire du service d'agents",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False
|
||||
payload = json.loads(text)
|
||||
assert payload["number"] == 1
|
||||
assert payload["repo_full_name"] == "Projets/demo"
|
||||
assert payload["body"] == "Commentaire du service d'agents"
|
||||
assert fake_gitea.posted_comments == [
|
||||
("Projets", "demo", 1, "Commentaire du service d'agents")
|
||||
]
|
||||
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||
commented = [e for e in events if e["event_type"] == "commented"]
|
||||
assert commented and commented[0]["source"] == "mcp"
|
||||
|
||||
|
||||
def test_mcp_add_comment_validation_errors(client, fake_gitea):
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1, "body": " ",
|
||||
})
|
||||
assert _result_text(r)[0]["isError"] is True
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "body": "sans numéro",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is True
|
||||
assert "Missing argument" in text
|
||||
assert fake_gitea.posted_comments == []
|
||||
|
||||
|
||||
def test_mcp_write_tools_are_listed(client):
|
||||
r = client.post("/mcp", json={
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
|
||||
})
|
||||
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
|
||||
assert tools["create_issue"]["inputSchema"]["required"] == [
|
||||
"owner", "repo", "title",
|
||||
]
|
||||
assert tools["add_comment"]["inputSchema"]["required"] == [
|
||||
"owner", "repo", "number", "body",
|
||||
]
|
||||
# Aucun outil destructeur n'apparaît.
|
||||
assert not any(
|
||||
name.startswith(("delete", "remove", "close"))
|
||||
for name in tools
|
||||
)
|
||||
|
||||
|
||||
# ── Déplacement de carte (0.36.0) ──────────────────────────
|
||||
|
||||
def _seed_carte_todo(fake_gitea, number=1):
|
||||
"""Place la carte dans « À faire », étiquette kanban:todo
|
||||
miroir posée (la carte garde son étiquette « bug »)."""
|
||||
etiquette = {"id": 950, "name": "kanban:todo", "color": "3b82f6"}
|
||||
fake_gitea.repo_labels["Projets/demo"]["kanban:todo"] = etiquette
|
||||
issue = next(
|
||||
i for i in fake_gitea.issues["Projets/demo"]
|
||||
if i["number"] == number
|
||||
)
|
||||
issue["labels"].append(etiquette)
|
||||
models.move_issue("Projets/demo", number, "todo")
|
||||
return issue
|
||||
|
||||
|
||||
def test_mcp_move_issue_moves_card_and_replaces_label(client, fake_gitea):
|
||||
issue = _seed_carte_todo(fake_gitea)
|
||||
r = _call(client, "move_issue", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"column": "review",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False
|
||||
payload = json.loads(text)
|
||||
assert payload["from_column"] == "todo"
|
||||
assert payload["column"] == "review"
|
||||
assert payload["label_sync"] == "ok"
|
||||
# Étiquette kanban remplacée, les autres préservées.
|
||||
noms = [l["name"] for l in issue["labels"]]
|
||||
assert "kanban:review" in noms
|
||||
assert "kanban:todo" not in noms
|
||||
assert "bug" in noms
|
||||
assert ("remove", 1, "kanban:todo") in fake_gitea.label_ops
|
||||
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
|
||||
# Le board suit le déplacement (lecture MCP relue).
|
||||
board = json.loads(_result_text(_call(
|
||||
client, "get_board", {"owner": "Projets", "repo": "demo"}
|
||||
))[1])
|
||||
assert any(c["number"] == 1 for c in board["review"])
|
||||
assert not any(c["number"] == 1 for c in board["todo"])
|
||||
# Journal : déplacement tracé avec la source « mcp ».
|
||||
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||
moved = [e for e in events if e["event_type"] == "moved"]
|
||||
assert moved and moved[0]["source"] == "mcp"
|
||||
assert moved[0]["from_column"] == "todo"
|
||||
assert moved[0]["to_column"] == "review"
|
||||
|
||||
|
||||
def test_mcp_move_issue_accepts_french_labels(client, fake_gitea):
|
||||
_seed_carte_todo(fake_gitea)
|
||||
for libelle, cle in (
|
||||
("En cours", "in_progress"),
|
||||
("Révision", "review"),
|
||||
("kanban:qa", "qa"),
|
||||
("À faire", "todo"),
|
||||
):
|
||||
r = _call(client, "move_issue", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"column": libelle,
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False, (libelle, text)
|
||||
assert json.loads(text)["column"] == cle
|
||||
|
||||
|
||||
def test_mcp_move_issue_refuses_done_before_any_write(client, fake_gitea):
|
||||
_seed_carte_todo(fake_gitea)
|
||||
for libelle in ("done", "Terminé"):
|
||||
r = _call(client, "move_issue", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"column": libelle,
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is True
|
||||
assert "Terminé" in text
|
||||
# Aucune écriture : ni étiquette, ni état, ni journal.
|
||||
assert fake_gitea.label_ops == []
|
||||
assert fake_gitea.state_changes == []
|
||||
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||
assert not [e for e in events if e["event_type"] == "moved"]
|
||||
|
||||
|
||||
def test_mcp_move_issue_errors(client, fake_gitea):
|
||||
# Colonne inconnue : erreur propre, avec les colonnes valides.
|
||||
r = _call(client, "move_issue", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"column": "nimporte",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is True
|
||||
assert "Colonnes valides" in text and "review" in text
|
||||
# Issue inconnue, puis dépôt inconnu.
|
||||
for arguments in (
|
||||
{"owner": "Projets", "repo": "demo", "number": 999,
|
||||
"column": "review"},
|
||||
{"owner": "Projets", "repo": "inexistant", "number": 1,
|
||||
"column": "review"},
|
||||
):
|
||||
result, text = _result_text(_call(client, "move_issue", arguments))
|
||||
assert result["isError"] is True
|
||||
assert "inconnu" in text
|
||||
# Une pull request n'est pas une carte.
|
||||
r = _call(client, "move_issue", {
|
||||
"owner": "Projets", "repo": "demo", "number": 2,
|
||||
"column": "review",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is True
|
||||
assert "pull request" in text
|
||||
assert fake_gitea.label_ops == []
|
||||
|
||||
|
||||
def test_mcp_move_issue_is_listed(client):
|
||||
r = client.post("/mcp", json={
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
|
||||
})
|
||||
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
|
||||
assert tools["move_issue"]["inputSchema"]["required"] == [
|
||||
"owner", "repo", "number", "column",
|
||||
]
|
||||
info = client.get("/mcp").json()
|
||||
assert "move_issue" in info["write_tools"]
|
||||
|
||||
|
||||
# ── Garde par jeton kbt_ (auth active) ─────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def utilisateur():
|
||||
# Base de test partagée : nom unique, nettoyage complet au
|
||||
# teardown (le pont SSO a une sémantique « un seul compte »).
|
||||
uid = models.create_user(
|
||||
f"mcp-{uuid.uuid4().hex[:8]}",
|
||||
accounts.hash_password("motdepasse"), role="member")
|
||||
user = models.get_user(uid)
|
||||
yield user
|
||||
with models.get_db() as conn:
|
||||
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
|
||||
|
||||
|
||||
def test_mcp_writes_guarded_by_api_token(monkeypatch, fake_gitea, utilisateur):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
|
||||
jeton = {"Authorization": f"Bearer {clair}"}
|
||||
create_args = {
|
||||
"owner": "Projets", "repo": "demo", "title": "Carte par jeton",
|
||||
}
|
||||
with TestClient(main.app) as client:
|
||||
# Sans jeton : refusé (401), aucune écriture sur la forge.
|
||||
assert _call(client, "create_issue", create_args).status_code == 401
|
||||
# Jeton inconnu : refusé aussi.
|
||||
faux = {"Authorization": "Bearer kbt_inconnu"}
|
||||
assert _call(
|
||||
client, "create_issue", create_args, headers=faux
|
||||
).status_code == 401
|
||||
assert fake_gitea.created == []
|
||||
# Jeton valide : création et commentaire passent.
|
||||
r = _call(client, "create_issue", create_args, headers=jeton)
|
||||
assert r.status_code == 200
|
||||
assert _result_text(r)[0]["isError"] is False
|
||||
assert fake_gitea.created == [("Projets", "demo", "Carte par jeton")]
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"body": "Vu par le service d'agents",
|
||||
}, headers=jeton)
|
||||
assert _result_text(r)[0]["isError"] is False
|
||||
# Le même jeton n'ouvre jamais l'interface web.
|
||||
assert client.get(
|
||||
"/", headers={**jeton, "Accept": "application/json"}
|
||||
).status_code == 401
|
||||
# Révocation : effet immédiat sur les écritures.
|
||||
assert models.revoke_api_token(utilisateur["id"], _row["id"]) is True
|
||||
assert _call(
|
||||
client, "create_issue", create_args, headers=jeton
|
||||
).status_code == 401
|
||||
assert _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1, "body": "x",
|
||||
}, headers=jeton).status_code == 401
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_nav_js_contract(client, fake_gitea):
|
||||
|
||||
|
||||
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
|
||||
async def fake_collect(only_repo):
|
||||
async def fake_collect(only_repo, *args, **kwargs):
|
||||
return [{
|
||||
"full_name": "Projets/demo",
|
||||
"counts": {c: 0 for c in
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Palier 19 (0.24.0): multi-forges, utilisateurs, espaces,
|
||||
paramètres en base, archives — P0 à P4 de l'analyse du 2026-10-21."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import accounts, forge_crypto, models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── Migration des anciennes bases (P0) ────────────────────
|
||||
|
||||
def test_migration_legacy_schema_gains_scope(tmp_path, monkeypatch):
|
||||
db = tmp_path / "legacy.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE board_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
column_name TEXT NOT NULL DEFAULT 'backlog',
|
||||
position REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE project_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(repo_full_name)
|
||||
);
|
||||
CREATE TABLE today_picks (
|
||||
pick_date TEXT NOT NULL,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (pick_date, repo_full_name, issue_number)
|
||||
);
|
||||
CREATE TABLE board_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
from_column TEXT,
|
||||
to_column TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'ui',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO board_state (repo_full_name, issue_number, column_name)
|
||||
VALUES ('Projets/demo', 1, 'todo');
|
||||
INSERT INTO project_notes (repo_full_name, note)
|
||||
VALUES ('Projets/demo', 'note héritée');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(db))
|
||||
models.init_db()
|
||||
board = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in board["todo"]] == [1]
|
||||
assert models.get_notes("Projets/demo") == "note héritée"
|
||||
# La portée par défaut est (1, 1) et les nouvelles tables existent.
|
||||
assert models.list_connections() == []
|
||||
assert models.count_users() == 0
|
||||
|
||||
|
||||
# ── Chiffrement des jetons (P0) ───────────────────────────
|
||||
|
||||
def test_forge_token_encryption_roundtrip(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
monkeypatch.setenv("KANBANTE_MASTER_KEY", Fernet.generate_key().decode())
|
||||
forge_crypto._fernet = None # forcer la relecture de la clé
|
||||
stored = forge_crypto.encrypt_token("ghp_secret")
|
||||
assert stored != "ghp_secret"
|
||||
assert forge_crypto.decrypt_token(stored) == "ghp_secret"
|
||||
assert forge_crypto.mask_token(stored) == "••••••••"
|
||||
assert forge_crypto.encrypt_token("") == ""
|
||||
|
||||
|
||||
# ── Amorçage (P0/P2/P3) ───────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(tmp_path, monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _login(client, username="bruno", password="motdepasse"):
|
||||
client.get("/login")
|
||||
token = client.cookies.get("kanbante_csrf") or ""
|
||||
return client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"next": "/",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def test_bootstrap_creates_legacy_connection_admin_workspace(auth_client):
|
||||
connections = models.list_connections()
|
||||
assert len(connections) == 1
|
||||
assert connections[0]["slug"] == "gitea"
|
||||
assert connections[0]["kind"] == "gitea"
|
||||
workspaces = models.list_workspaces()
|
||||
assert [ws["slug"] for ws in workspaces] == ["principal"]
|
||||
assert models.workspace_connection_ids(workspaces[0]["id"]) == [
|
||||
connections[0]["id"]
|
||||
]
|
||||
users = models.list_users()
|
||||
assert len(users) == 1
|
||||
assert users[0]["username"] == "bruno"
|
||||
assert users[0]["role"] == "admin"
|
||||
assert accounts.verify_password("motdepasse", users[0]["password_hash"])
|
||||
|
||||
|
||||
def test_db_login_and_password_change(auth_client):
|
||||
# Le mot de passe d'environnement a créé le hash en base :
|
||||
# la connexion passe par la base.
|
||||
r = _login(auth_client)
|
||||
assert r.status_code == 303
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/profile/password",
|
||||
data={
|
||||
"current_password": "motdepasse",
|
||||
"new_password": "nouveau-secret",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
auth_client.get("/logout")
|
||||
# Le nouveau mot de passe fonctionne…
|
||||
assert _login(auth_client, password="nouveau-secret").status_code == 303
|
||||
auth_client.get("/logout")
|
||||
# …et pour un compte membre, l'ancien ne passe plus (pas de
|
||||
# secours environnement pour les membres).
|
||||
token = csrf_from(auth_client)
|
||||
_login(auth_client, password="nouveau-secret")
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "alice",
|
||||
"password": "alice-pass",
|
||||
"role": "member",
|
||||
"display_name": "Alice",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
assert _login(auth_client, "alice", "alice-pass").status_code == 303
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/profile/password",
|
||||
data={
|
||||
"current_password": "alice-pass",
|
||||
"new_password": "alice-nouveau",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
r = _login(auth_client, "alice", "alice-pass")
|
||||
assert r.status_code == 401
|
||||
assert _login(auth_client, "alice", "alice-nouveau").status_code == 303
|
||||
|
||||
|
||||
def test_member_cannot_open_admin(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "bob",
|
||||
"password": "bob-pass",
|
||||
"role": "member",
|
||||
"display_name": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
auth_client.get("/logout")
|
||||
_login(auth_client, "bob", "bob-pass")
|
||||
assert auth_client.get("/admin").status_code == 403
|
||||
assert auth_client.get("/admin/connections").status_code == 403
|
||||
assert auth_client.get("/profile").status_code == 200
|
||||
|
||||
|
||||
def test_disabled_user_session_dies(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/users",
|
||||
data={
|
||||
"username": "eve",
|
||||
"password": "eve-pass",
|
||||
"role": "member",
|
||||
"display_name": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
eve_id = models.get_user_by_username("eve")["id"]
|
||||
auth_client.get("/logout")
|
||||
_login(auth_client, "eve", "eve-pass")
|
||||
assert auth_client.get("/profile").status_code == 200
|
||||
# L'admin désactive Eve pendant que sa session est ouverte.
|
||||
with TestClient(main.app) as admin_client:
|
||||
_login(admin_client)
|
||||
token = csrf_from(admin_client)
|
||||
admin_client.post(
|
||||
f"/admin/users/{eve_id}/toggle",
|
||||
data={"csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
r = auth_client.get(
|
||||
"/profile", headers={"Accept": "text/html"}, follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
|
||||
|
||||
def test_admin_creates_github_connection_encrypted(auth_client):
|
||||
_login(auth_client)
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/admin/connections",
|
||||
data={
|
||||
"kind": "github",
|
||||
"name": "GitHub perso",
|
||||
"base_url": "https://api.github.com",
|
||||
"token": "ghp_secret_token",
|
||||
"hook_secret": "",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
row = models.get_connection_by_slug("github-perso")
|
||||
assert row is not None
|
||||
assert row["kind"] == "github"
|
||||
assert row["token_encrypted"] != "ghp_secret_token"
|
||||
assert forge_crypto.decrypt_token(row["token_encrypted"]) == "ghp_secret_token"
|
||||
page = auth_client.get("/admin/connections")
|
||||
assert "ghp_secret_token" not in page.text # jamais réaffiché
|
||||
assert "••••••••" in page.text
|
||||
|
||||
|
||||
# ── Paramètres en base (P3) ───────────────────────────────
|
||||
|
||||
def test_settings_panel_overrides_env(auth_client):
|
||||
from app.config import get_settings
|
||||
|
||||
_login(auth_client)
|
||||
assert get_settings().aging_days == 7
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/settings",
|
||||
data={"setting_aging_days": "42", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert get_settings().aging_days == 42
|
||||
# Champ vidé = retour à l'environnement.
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/settings",
|
||||
data={"setting_aging_days": "", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert get_settings().aging_days == 7
|
||||
|
||||
|
||||
# ── Espaces et portées (P3) ───────────────────────────────
|
||||
|
||||
def test_board_state_is_scoped_per_workspace():
|
||||
models.move_issue("Projets/demo", 1, "todo", workspace_id=1, connection_id=1)
|
||||
models.move_issue("Projets/demo", 1, "review", workspace_id=2, connection_id=1)
|
||||
assert models.get_board_state("Projets/demo", workspace_id=1)["todo"]
|
||||
board2 = models.get_board_state("Projets/demo", workspace_id=2)
|
||||
assert board2["review"] and not board2["todo"]
|
||||
|
||||
|
||||
def test_workspace_switch_scopes_dashboard(client, fake_gitea):
|
||||
# Un espace vide (aucune connexion) ne montre aucun projet.
|
||||
empty_id = models.create_workspace("vide", "Vide", "#999999")
|
||||
client.cookies.set("kanbante_workspace", str(empty_id))
|
||||
page = client.get("/")
|
||||
assert "/board/Projets/demo" not in page.text
|
||||
principal = models.list_workspaces()[0]
|
||||
client.cookies.set("kanbante_workspace", str(principal["id"]))
|
||||
page = client.get("/")
|
||||
assert "/board/Projets/demo" in page.text
|
||||
|
||||
|
||||
# ── Archives (P4) ─────────────────────────────────────────
|
||||
|
||||
def test_local_archive_hides_and_restores(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
principal = models.list_workspaces()[0]
|
||||
assert "/board/Projets/demo" in client.get("/").text
|
||||
r = client.post(
|
||||
"/archives/local",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert "/board/Projets/demo" not in client.get("/").text
|
||||
archives = client.get("/archives")
|
||||
assert "Projets/demo" in archives.text
|
||||
assert models.list_local_archived(principal["id"])
|
||||
token = csrf_from(client)
|
||||
client.post(
|
||||
"/archives/local",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "0",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "/board/Projets/demo" in client.get("/").text
|
||||
|
||||
|
||||
def test_forge_archive_via_page(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/archives/forge",
|
||||
data={
|
||||
"conn_id": "1",
|
||||
"repo_full_name": "Projets/demo",
|
||||
"archived": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
repo = next(r for r in fake_gitea.repos if r["full_name"] == "Projets/demo")
|
||||
assert repo["archived"] is True
|
||||
@@ -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,6 +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", "move_issue",
|
||||
]
|
||||
|
||||
|
||||
@@ -153,7 +154,7 @@ def test_mcp_unknown_method_and_tool(client):
|
||||
assert r.json()["error"]["code"] == -32601
|
||||
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
|
||||
result = r.json()["result"]
|
||||
assert result["isError"] is True # read-only server: no such tool
|
||||
assert result["isError"] is True # aucun outil de ce nom (pas de suppression)
|
||||
|
||||
|
||||
def test_mcp_notification_gets_202(client):
|
||||
@@ -164,7 +165,13 @@ def test_mcp_notification_gets_202(client):
|
||||
def test_mcp_info_endpoint(client):
|
||||
r = client.get("/mcp")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["read_only"] is True
|
||||
# 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", "move_issue",
|
||||
]
|
||||
|
||||
|
||||
# ── Board : gestion des étiquettes ────────────────────────
|
||||
|
||||
@@ -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,20 @@
|
||||
"""0.24.2 — #29 : base sombre pour tous les champs de saisie.
|
||||
|
||||
Sans règle de fond, les champs hors contexte stylé gardaient le
|
||||
fond blanc du navigateur (réinitialisation de mot de passe,
|
||||
textarea des espaces, valeurs du panneau des paramètres).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
R = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_base_champs_sombre():
|
||||
css = (R / "static/css/style.css").read_text(encoding="utf-8")
|
||||
assert "color-scheme: dark" in css
|
||||
assert ":where(input:not([type=\"checkbox\"])" in css
|
||||
assert "textarea, select" in css
|
||||
assert "background: var(--surface-2);" in css
|
||||
# la base est en spécificité zéro : les contextes restent maîtres
|
||||
assert ".palette-input-row input" in css
|
||||
assert ".login-form input" in css
|
||||
Reference in New Issue
Block a user