Compare commits

..
Author SHA1 Message Date
bruno cf837edfbb Recherche partout (#68) : JS compatible ESM (test ObsiGate)
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 12:18:21 -04:00
bruno edd2c24014 Recherche partout (#68) : Recherche-partout-68-palette
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 12:11:51 -04:00
bruno fc3e61f266 Recherche partout (#68) : Recherche-partout-68-CSP
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:46 -04:00
bruno 842204dadb Recherche partout (#68) : Recherche-partout-68-middleware
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:42 -04:00
bruno c8be099aed Recherche partout (#68) : Recherche-partout-68-route
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:36 -04:00
bruno 1abb9f97de Recherche partout (#68) : Recherche-partout-68-tests-Node
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:31 -04:00
bruno cd4fcf78a9 Recherche partout (#68) : Recherche-partout-68-tests
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:27 -04:00
bruno 79ea841a81 Recherche partout (#68) : Recherche-partout-68-CORS
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:22 -04:00
bruno 46d26ca0c7 Recherche partout (#68) : Recherche-partout-68-gabarit
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:18 -04:00
bruno 5c570b485c Recherche partout (#68) : Recherche-partout-68-CSS
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:14 -04:00
bruno 7365d00c4c Recherche partout (#68) : Recherche-partout-68-JS
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Successful in 34m52s
FlowDeck CI / docker (push) Successful in 2m31s
2026-10-28 12:11:09 -04:00
29 changed files with 261 additions and 2674 deletions
-48
View File
@@ -1,51 +1,3 @@
## v7.84.9 (2026-10-29) — Barre commune : marque monochrome, anneau rouge avatar
- **Barre d'apps v0.1.11 vendangée (dracodev-commons)** : `static/css/draco-barre.css` remplacé verbatim par la v0.1.11 ; `static/js/vendor/draco-barre.js` inchangé (déjà identique à commons, JS inchangé en v0.1.11) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.11, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP, `asset_version`) est inchangé.
- **Marque monochrome** : la marque n'est plus le logo rouge — c'est l'étoile Spark monochrome (`static/img/brand-spark.png`, copie de `ui/barre-apps/brand-spark.png` de commons, étoile blanche sur fond transparent, 26 px), blanche sur barre foncée et noire sur barre claire via le filtre du CSS vendangé. L'onglet agent et son entrée de menu GARDENT le logo rouge.
- **Anneau rouge avatar** : le bouton avatar flottant bas-centre gagne un anneau rouge `#c00000` (bordure 3 px du CSS vendangé, `var(--agent)`).
- **Thème** : la barre suit `data-theme` sur `<html>` (choix Flowdeck `fd_theme`, défaut light) — les règles thème de commons s'appliquent telles quelles, aucune surcharge propre n'a été ajoutée.
- **Cache** : le bump 7.84.8 → 7.84.9 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
## v7.84.8 (2026-10-29) — Barre commune : rouge Spark, avatar flottant, Alertes cloche seule
- **Barre d'apps v0.1.10 vendangée (dracodev-commons)** : `static/css/draco-barre.css` remplacé verbatim par la v0.1.10 ; `static/js/vendor/draco-barre.js` inchangé (déjà identique à commons entre v0.1.9 et v0.1.10) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.10, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP, `asset_version`) est inchangé.
- **Rouge Spark** : `--agent` rose `#c2437e` → rouge `#c00000` (échantillonné dans le logo Spark) ; onglet agent `--app #c00000`, marque = logo Spark (26 px) au lieu du ✦.
- **Bouton Spark rose supprimé** de la barre, remplacé par le bouton avatar flottant bas-centre (cercle personnage Spark, `static/img/avatar-spark.png`, `data-act="spark"` → le panneau Spark de l'hôte, comme l'ancien bouton).
- **Alertes** : cloche seule, sans libellé — bouton moins large.
- **Cache** : le bump 7.84.7 → 7.84.8 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
## v7.84.7 (2026-10-29) — Barre commune : les 5 logos dans le menu et la barre
- **Barre d'apps v0.1.9 vendangée (dracodev-commons)** : `static/js/vendor/draco-barre.js` et `static/css/draco-barre.css` remplacés verbatim par la v0.1.9 (les fichiers vendangés précédents étaient identiques à l'octet près à ceux de commons avant la v0.1.9, aucune adaptation locale n'est perdue) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.9, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP) est inchangé.
- **Icônes** : les 5 tuiles de la barre — et donc du menu de la marque, qui les clone — sont désormais les logos fournis, tels quels, jamais redessinés : ObsiGate (arbre), Flowdeck (cartes), TikTak (logo officiel SVG), Kanbante (cycle vert), Console Spark (étoile rouge validée). Fichiers servis depuis `static/img/logos/` (copie de `ui/barre-apps/logos/` de commons, PNG 180 px) ; l'entrée « Console Spark » du menu clone l'icône de l'onglet agent. Les glyphes texte ▤/▦/✓/▥/✦ disparaissent des onglets (la marque ✦ et le bouton Spark gardent leur ✦).
- **Cache** : le bump 7.84.6 → 7.84.7 change aussi `asset_version`, donc les navigateurs ne garderont pas les anciennes icônes.
## v7.84.6 (2026-10-28) — Recherche console : CORS Flowdeck sans doublons (PR #41)
- **CORS recherche (PR #41)** : le middleware de recherche remplace désormais les en-têtes CORS existants au lieu de les empiler — le CORSMiddleware global posait déjà Allow-Credentials: true, l'ancien middleware ajoutait les siens par-dessus (« true, true » rejeté par le navigateur) ; l'onglet Flowdeck de la recherche console agent-dev n'est plus injoignable.
## v7.84.5 (2026-10-28) — Corrections console/recherche : texte parasite, onglets, CORS console
- **Barre (PR #37)** : suppression du texte parasite « Chantier 5 » rendu au-dessus du header.
- **Recherche (PR #38)** : onglets visibles après déploiement (base console explicite + purge cache service worker).
- **CORS recherche (PR #40)** : la recherche Flowdeck autorise désormais la console Spark (agent prod+dev) en inter-origines.
## v7.84.4 (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.
## v7.84.3 (2026-10-28) — Corrections recherche/alertes : CORS prod+dev, recherche en onglets, alertes en onglets
- **CORS recherche/alertes (#68/#69)** : allowlist d'origines soeurs prod+dev explicites + credentials, jamais `*` — chaque serveur n'autorisait que l'environnement resolu, d'ou les echecs inter-apps constates.
- **Recherche partout (#68)** : recherche pleine largeur, reintegree dans le gabarit, focus auto, « Tout voir » restant sur place, onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark.
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark/Historique, actives sauf Historique = fermees), filtre texte, composant partage vendange byte-identique ; bouton Console Spark en 5e onglet de la barre apres Kanbante.
## v7.84.2 (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 ; CSP/CORS bornées à `/api/notifications`.
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), draco + 401 sur `/api/search` (corrige la recherche sans périmètre sans session) ; la palette de la barre ouvre désormais `/recherche`.
# Changelog - FlowDeck
## v7.84.1 (2026-10-28) — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
+1 -1
View File
@@ -1 +1 @@
7.84.9
7.84.1
+3 -24
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.84.3"
# version="7.84.1"
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
@@ -239,29 +239,6 @@ app.add_middleware(RateLimitMiddleware)
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Fusion #69 : le panneau Alertes unifiées lit /api/notifications
# depuis les hôtes soeurs, avec credentials — leurs origines doivent
# donc figurer dans allow_origins (résolution draco_urls : explicite
# > hôte de la requête > dérivation > défaut officiel ; hors requête,
# la configuration seule). Lecture seule (GET) ; les POST restent
# soumis au CSRFMiddleware.
from app.services import draco_urls as _draco_urls69
_toutes69 = _draco_urls69.toutes()
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | {
_draco_urls69.origine(_toutes69[k])
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""})
# Correctif CORS alertes (fusion #69, 2026-10-28) : allow_origins
# est figé à l'import (configuration seule, sans hôte de requête)
# — il ne doit donc pas dépendre de l'environnement résolu. Les
# origines sœurs prod ET dev sont listées explicitement (module
# CORS de la recherche, jamais "*") : c'est ce qui manquait à
# /api/notifications lu depuis les autres hôtes.
from app.middleware.recherche_cors import DEV_ORIGINS as _DEV_ORIGINS
from app.middleware.recherche_cors import PROD_ORIGINS as _PROD_ORIGINS
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | set(_DEV_ORIGINS)
| set(_PROD_ORIGINS))
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
@@ -280,6 +257,7 @@ app.add_middleware(
from app.middleware.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
app.include_router(auth.router)
app.include_router(sso_router)
app.include_router(dashboard.router)
@@ -457,3 +435,4 @@ def http_exception_handler(request: Request, exc: _StarHTTPException):
return problem_response(request, exc)
from fastapi.responses import JSONResponse
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
+16 -53
View File
@@ -8,21 +8,15 @@ Ce module n'autorise donc que :
- le chemin ``/api/search`` (jamais les autres routes, jamais les
écritures) ;
- les origines sœurs : les origines 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**
celles résolues par ``app.services.draco_urls.toutes()``
(explicite > hôte > dérivation > défaut officiel) — jamais ``*``.
Correctif 2026-10-28 (fusion #68/#69, matrice constatée par
Bruno) : l'ancienne allowlist ne contenait que l'environnement
résolu (configuration seule, ce middleware s'exécutant avant le
middleware CSP qui pose le contexte d'hôte, plus l'environnement
de l'hôte de la requête) — jamais les deux à la fois. Une page
servie dans l'autre environnement voyait donc sa section
afficher « Injoignable depuis cet hôte (CORS ou réseau) », et
c'est Flowdeck qui échouait depuis tous les hôtes ;
- les origines sœurs : celles résolues par
``app.services.draco_urls.toutes()`` (explicite > hôte > dérivation
> défaut officiel), **plus** les origines prod ou ``-dev``
officielles correspondant à l'hôte de la requête — ce second
ensemble est nécessaire car ce middleware s'exécute avant le
middleware CSP qui pose le contexte d'hôte de la requête pour
``draco_urls`` ; sans lui, une page servie par ``flowdeck-dev``
verrait ses sœurs ``-dev`` refusées quand la configuration seule
ne les déclare pas ;
- avec credentials (le cookie SSO ``draco_session``, accepté par
``/api/search`` depuis cette livraison, ou la session native).
@@ -52,12 +46,6 @@ DEV_ORIGINS = frozenset({
"https://kanbante-dev.dracodev.net",
})
AGENT_ORIGINS = frozenset({
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
})
# Clés de draco_urls.toutes() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
@@ -117,12 +105,12 @@ def sister_origins(resolved: dict | None = None, request_host: str | None = None
origin = origin_of((resolved or {}).get(key))
if origin:
origins.add(origin)
# Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
# ne restreint plus l'allowlist (il ne servait qu'à ajouter
# l'environnement de la requête, désormais inclus d'office).
# ``resolved`` n'ajoute que des origines de configuration sans
# jamais retirer une origine officielle.
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
if is_dev_host(request_host):
origins |= DEV_ORIGINS
elif is_prod_host(request_host):
origins |= PROD_ORIGINS
if not origins:
return PROD_ORIGINS | DEV_ORIGINS
return frozenset(origins)
@@ -193,32 +181,7 @@ class RechercheCorsMiddleware:
if message["type"] == "http.response.start":
cors = cors_headers_for(origin, request_host=request_host)
if cors is not None:
# Remplacer, pas empiler (défaut 3b, 2026-10-28) :
# le CORSMiddleware global de l'application pose
# déjà Access-Control-Allow-Credentials: true sur
# TOUTES les réponses (comportement Starlette avec
# allow_credentials=True, même pour une origine
# hors allowlist et hors /api/search) ; empiler les
# en-têtes de recherche donnait, sur /api/search
# depuis agent-dev, Allow-Credentials en double
# (« true, true » ≠ « true » pour le contrôle CORS
# du navigateur → fetch rejeté → « injoignable
# (CORS ou réseau) ») et, depuis une sœur
# (origine déjà dans la allowlist globale),
# Allow-Origin en double (rejet certain). Les
# hôtes TikTak/Kanbante, sans ce cumul, répondent
# avec des en-têtes uniques — d'où l'échec
# Flowdeck seul. Une origine non autorisée
# traverse sans modification (comportement
# antérieur préservé : le seul en-tête global
# reste, sans Allow-Origin, donc bloqué).
restants = [
(k, v) for k, v in message.get("headers", [])
if k.decode("latin-1").lower() not in (
"access-control-allow-origin",
"access-control-allow-credentials",
"vary")]
message["headers"] = restants + [
message["headers"] = list(message.get("headers", [])) + [
(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in cors.items()
]
+24 -12
View File
@@ -139,7 +139,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# l'agent connu en config ; défaut = nom officiel).
# Même origine pour frame-src : la console du service est
# encadrée en iframe par le panneau (surimpression v3).
"connect-src 'self' {agent} {sisters} ws://{host} wss://{host}; "
"connect-src 'self' {agent} ws://{host} wss://{host}; "
"media-src 'self' blob:; "
"frame-src 'self' {agent}; "
"object-src 'none'; "
@@ -174,18 +174,29 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
request.headers.get("host", ""))
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
draco_urls.origine(draco_urls.agent_url()))
# Fusion #69 : le panneau Alertes unifiées lit les API
# notifications des applications soeurs depuis cet hôte ;
# leurs origines rejoignent connect-src (même résolution
# draco_urls que les liens de la barre et frame-src).
_toutes69 = draco_urls.toutes()
sisters = " ".join(sorted({
draco_urls.origine(_toutes69[k])
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
nonce=nonce, host=host, agent=agent, sisters=sisters
csp = self.CSP_VALUE.format(
nonce=nonce, host=host, agent=agent
)
# Recherche partout (#68) : la page /recherche appelle les
# API de recherche des sœurs depuis le navigateur — sa
# connect-src doit les nommer, sinon le navigateur bloque
# les appels avant même CORS. Les autres pages gardent la
# CSP inchangée. `draco_urls` est déjà importé dans ce
# dispatch (contexte d'hôte posé plus haut).
if request.url.path == "/recherche":
try:
soeurs = " ".join(sorted({
draco_urls.origine(draco_urls.toutes()[k])
for k in ("obsigate", "tiktak", "kanbante")
}))
csp = csp.replace(
"connect-src 'self' " + agent + " ",
"connect-src 'self' " + agent + " " + soeurs + " ",
1,
)
except Exception:
pass
response.headers[self.CSP_HEADER] = csp
return response
@@ -297,3 +308,4 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
for k in expired:
del self._store[k]
self._last_prune = now
+1 -6
View File
@@ -63,12 +63,7 @@ def recherche_page(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
bases = draco_urls.toutes()
template = ENV.get_template("recherche.html")
# Defaut 6 : la base agent est exposee explicitement (data-base-console
# dans recherche.html), comme les 3 autres hotes — recherche.js ne doit
# plus la deviner (derivation -dev / href d'onglet de barre) pour
# l'onglet Console Spark (GET /api/v1/recherche, alignement #36).
return HTMLResponse(template.render(
request=request, q=q.strip(), user=user,
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
"kanbante", "agent")},
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak", "kanbante")},
))
+26 -43
View File
@@ -1,63 +1,36 @@
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.11
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons.
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
v7.82.0 (dev/prod) : les href des onglets ne sont plus figés —
l'hôte les injecte depuis sa config (draco_urls(), noms officiels
par défaut, noms en -dev déclarés dans le .env de l'environnement).
5e onglet Console Spark (data-app="agent", draco_urls().agent),
après Kanbante ; l'entrée « Console Spark » du menu de la marque
est conservée (menu construit par draco-barre.js depuis les onglets).
Icônes logos v0.1.9 (2026-10-29) : les 5 tuiles sont les logos
fournis, tels quels, servis depuis /static/img/logos/ (copie de
ui/barre-apps/logos/ de dracodev-commons) — jamais de glyphe
texte, jamais de redessin ; l'entrée « Console Spark » du menu
clone l'icône de l'onglet agent (draco-barre.js).
v0.1.10 (2026-10-29) : --agent rose → rouge Spark #c00000 ;
marque ✦ → logo Spark (26 px) ; bouton « Spark » rose SUPPRIMÉ
de la barre, remplacé par le bouton avatar flottant bas-centre
(static/img/avatar-spark.png, data-act="spark" → panneau de l'hôte) ;
Alertes = cloche seule, sans libellé. JS inchangé depuis v0.1.9.
v0.1.11 (2026-10-29) : marque = étoile Spark monochrome
(static/img/brand-spark.png, copie de ui/barre-apps/brand-spark.png
de commons — blanche sur barre foncée, noire sur barre claire
via le filtre du CSS vendangé) ; onglet agent et son entrée de
menu GARDENT le logo rouge ; anneau rouge #c00000 autour du
bouton avatar flottant (bordure du CSS vendangé). Thème de la
barre : data-theme sur <html> (fd_theme, défaut light) — les
règles thème de commons s'appliquent, aucune surcharge propre
n'est nécessaire. #}
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ asset_version }}">
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
<header class="draco-bar" data-current="flowdeck">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d&#39;application" title="Changer d&#39;application">
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="" width="26" height="26"></span><span class="draco-brand-name">dracodev</span>
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="{{ draco_urls().obsigate }}/">
<span class="draco-apptab-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="{{ draco_urls().flowdeck }}/">
<span class="draco-apptab-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="{{ draco_urls().tiktak }}/">
<span class="draco-apptab-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></span>
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="{{ draco_urls().kanbante }}/">
<span class="draco-apptab-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
<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="{{ draco_urls().agent }}/">
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></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">
@@ -76,20 +49,24 @@
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</button>
</div>
</header>
<button type="button" class="draco-spark-flottant" data-act="spark" aria-label="Ouvrir Spark, l'agent commun"><img src="/static/img/avatar-spark.png" alt="" width="64" height="64"></button>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script nonce="{{ csp_nonce() }}">
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
Phase 1 : navigation entre apps et domaine mémorisé localement ;
le rail pilote le hamburger natif, les alertes ouvrent le
panneau draco-alertes.js (Fusion #69, agrégation à la demande,
pas une fédération temps réel ; la cloche native reste le
repli si le module ne charge pas). */
le rail pilote le hamburger natif, les alertes ouvrent la cloche
native de la page ; la recherche fédérée et Spark arrivent avec
les phases suivantes du programme Fusion. */
window.DRACO_BAR_CONFIG = {
current: "flowdeck",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
@@ -105,12 +82,18 @@
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
if (act === "alertes") {
if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); }
else { var b = document.querySelector(".fd-notif-bell button"); if (b) { b.click(); } }
var b = document.querySelector(".fd-notif-bell button");
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
return;
}
if (act === "palette") { location.href = "/recherche"; return; }
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). Nonce CSP du script inchangé. */
/* v0.1.6 : le toast placeholder "fédération" est remplacé
par le menu par défaut du nouveau JS — retourner false le
demande explicitement (un onAction existant garde sinon la
main). Contenu : Console agent + applications, URL résolues
donc dérivées -dev sur un hôte -dev. Nonce CSP du script
inchangé. */
if (act === "menu") { return false; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
@@ -124,4 +107,4 @@
}
};
</script>
<script src="/static/js/draco-alertes.js?v={{ asset_version }}"></script>
+4 -26
View File
@@ -1,36 +1,18 @@
{% extends "base.html" %}
{% block title_prefix %}Rechercher partout{% endblock %}
{% block content %}
{# Versionnes (?v=asset_version) comme les autres feuilles/scripts de
base.html : sans version, le service worker (static/sw.js, cache-first
des .js/.css) resservait indefiniment les copies pre-#33 de ces deux
fichiers aux navigateurs les ayant deja visites — la page paraissait
donc « non corrigee » malgre les PR #33/#36. #}
<link rel="stylesheet" href="/static/css/recherche.css?v={{ asset_version }}">
{# Console : data-base-console rendu explicitement, comme les 3 autres
hotes — la route Flowdeck de /recherche expose la base agent dans
son contexte `bases` (defaut 6). recherche.js garde ses replis
(derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
<link rel="stylesheet" href="/static/css/recherche.css">
<main class="rp-page" id="recherche" data-host="flowdeck"
data-base-obsigate="{{ bases.obsigate }}"
data-base-flowdeck="{{ bases.flowdeck }}"
data-base-tiktak="{{ bases.tiktak }}"
data-base-kanbante="{{ bases.kanbante }}"
data-base-console="{{ bases.agent }}">
data-base-kanbante="{{ bases.kanbante }}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<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>
@@ -47,12 +29,8 @@
<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>
{% endblock %}
{% block scripts %}
<script src="/static/js/recherche.js?v={{ asset_version }}"></script>
<script src="/static/js/recherche.js"></script>
{% endblock %}
-58
View File
@@ -1,58 +0,0 @@
/* 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; }
+20 -52
View File
@@ -3,15 +3,8 @@
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 ».
v0.1.10 : rose --agent → rouge Spark #c00000 ; marque = logo Spark ;
bouton Spark supprimé (remplacé par l'avatar flottant bas-centre) ;
Alertes icône seule.
v0.1.11 : marque = étoile Spark monochrome (blanche barre foncée,
noire barre claire) ; anneau rouge #c00000 autour de l'avatar flottant.
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
que le menu des applications) ; l'avatar est un vrai <button>.
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. */
@@ -19,7 +12,7 @@
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c00000; --danger: #cf4444;
--agent: #c2437e; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
@@ -41,16 +34,7 @@
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { line-height: 0; }
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
fond transparent — noire sur barre claire (filtre), blanche sur
barre foncée (filtre annulé par les règles thème ci-dessous). */
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
@media (prefers-color-scheme: dark) {
.draco-bar .draco-brand-mark img { filter: none; }
}
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
.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 {
@@ -61,15 +45,15 @@
}
.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 {
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
.draco-more-menu, .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-more-menu[hidden], .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;
@@ -107,13 +91,6 @@
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
/* Icônes logos (2026-10-29, corrigé le même jour) : l'image du logo
* fourni, telle quelle, remplit la tuile 30 px — jamais de glyphe
* texte, jamais de redessin. Le fond de la tuile est transparent :
* les logos PNG portent déjà leur fond (dégradé), le SVG TikTak
* reste sur le fond de la barre. */
.draco-apptab-ico { background: transparent; }
.draco-apptab-ico img { width: 30px; height: 30px; display: block; border-radius: 9px; object-fit: cover; }
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
@@ -151,23 +128,18 @@
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; height: 38px;
padding: 0 9px; border-radius: 10px; border: 1px solid var(--line);
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Bouton avatar Spark flottant — bas-centre, ouvre le panneau Spark
de l'hôte via data-act="spark" (délégation globale existante).
La console Spark ne l'inclut pas : son fragment est propre. */
.draco-spark-flottant {
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
z-index: 65; width: 64px; height: 64px; padding: 0; border: 0;
border-radius: 50%; background: transparent; cursor: pointer;
box-shadow: 0 8px 28px rgb(0 0 0 / .45);
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
}
.draco-spark-flottant img { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: block; box-sizing: border-box; border: 3px solid var(--agent); }
.draco-spark-flottant:hover { transform: translateX(-50%) scale(1.05); }
.draco-spark-flottant:focus-visible { outline: 2px solid var(--agent); outline-offset: 3px; }
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
@@ -194,7 +166,7 @@
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd { display: none; }
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
@@ -202,8 +174,8 @@
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche et les domaines disparaissent :
chaque app garde ses contrôles natifs (barre
avatar. Le rail, la recherche, les domaines et le menu « ••• »
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
@@ -217,9 +189,5 @@
.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; }
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
}
-55
View File
@@ -33,58 +33,3 @@
.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; }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<!-- TikTak — deux arcs (les deux temps), coche-flèche. Bleu #0076fb, orange #fc750c.
Géométrie mesurée sur le visuel validé par Bruno (variante B). -->
<path d="M 38.2 101.1 A 84 84 0 0 1 201.8 101.1" fill="none" stroke="#0076fb" stroke-width="28" stroke-linecap="round"/>
<path d="M 201.5 140.3 A 84 84 0 0 1 38.5 140.3" fill="none" stroke="#fc750c" stroke-width="28" stroke-linecap="round"/>
<polyline points="80,129 106,146 163,84" fill="none" stroke="#0076fb" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 128 80 L 163 84 L 167 122" fill="none" stroke="#0076fb" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 760 B

-749
View File
@@ -1,749 +0,0 @@
/* 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);
+65 -640
View File
@@ -1,39 +1,11 @@
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
* chantier 2+3 : onglets, « Tout voir », focus).
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
* de cette application (aucune troncature côté client — la requête
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
* jeu complet). « Tout voir » d'une section ne quitte plus la page :
* il bascule vers l'onglet de l'application (son href reste une URL
* partageable ?q=&tab= au cas où le JS ne tournerait pas).
* L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable,
* et le retour arrière navigateur le restaure (popstate).
*
* Compteurs : chaque onglet affiche le nombre TOTAL de résultats de
* son application (pas le nombre tronqué affiché dans « Tout »),
* « … » pendant le chargement, « 0 » pour un vide honnête, rien pour
* une section en erreur/indisponible (pas de faux 0). L'onglet Tout
* affiche « … » tant qu'une section charge, sinon la somme des totaux
* connus (une section en erreur compte pour 0) ; il est vide si les
* 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu.
* Une section injoignable ne casse jamais les autres.
*
* Focus : l'attribut autofocus seul ne survit pas au chargement des
* surcouches (barre, palette, app) — la page appelle donc focus()
* explicitement en fin d'init (plus un rappel rAF/timeout et un
* rappel pageshow). Cliquer la barre « Rechercher partout » (bouton
* data-act="palette") sur cette même page replace aussi le curseur
* dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant.
*
* Contrat d'API (audité, rien d'inventé) :
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
* -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q=
@@ -42,39 +14,10 @@
* - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
* - Kanbante : GET {base}/api/search?q=
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
* (agent-commun, authentifié par le cookie SSO draco_session) ->
* { q, domain, limite_par_type, resultats[], par_type,
* non_cherchables[] } ; chaque résultat porte type, id, titre,
* extrait, statut, domaine, section, objet et lien (relatif à
* l'origine console). q < 2 caractères -> 422. Types cherchables :
* discussion, mission, approbation, livrable, activite,
* planification, objectif, identite (aucun secret). Liens profonds
* (préfixés par la base console) : discussion
* /?section=conversation&disc=<id>, mission
* /?section=missions&objet=<id>, approbation
* /?section=approbations&objet=<id>, objectif
* /?section=objectifs&objet=<id>, activite /?activite=<id> ;
* livrable /?section=livrables, planification /?section=planifie
* et identite /?section=identites ouvrent la vue sans sélection
* d'objet. L'onglet appelle avec limite=20 (plafond par type) car
* il affiche la liste complète, comme les onglets d'applications.
* En cas d'échec, de HTTP non-OK ou de format non reconnu, la
* section affiche honnêtement « Console Spark : recherche
* indisponible pour l'instant » — jamais de faux résultats,
* jamais de faux 0. La base console vient, dans l'ordre, de
* data-base-console rendu par l'hôte, de window.DRACO_URLS.agent
* (ObsiGate) / window.DRACO_AGENT_URL (TikTak), de la dérivation
* -dev si l'hôte courant est -dev, du href d'un onglet de barre
* data-app="console"/"spark"/"agent" s'il existe, sinon du défaut
* prod agent.dracodev.net. L'onglet console n'est interrogé que
* lorsqu'il est affiché (ou quand une recherche a lieu pendant
* qu'il est affiché) : « Tout » ne le contient pas, pour ne pas
* polluer la vue groupée d'un message d'indisponibilité.
*
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
@@ -88,12 +31,9 @@
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* et par GET /api/v1/recherche de la console (identity() accepte
* draco_session). Chaque sœur n'autorise en CORS que les origines
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
* côté console, un middleware borné à /api/v1/recherche autorise
* de même les 8 origines hôtes prod ET dev, avec credentials.
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
* (GET sur l'endpoint de recherche uniquement, pas de "*").
* Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native.
*/
@@ -116,36 +56,12 @@
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
};
var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
var CONSOLE_SEARCH_PATH = "api/v1/recherche";
var CONSOLE_SEARCH_LIMIT = 20;
var APPS = [
{ key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
];
var CONSOLE_APP = { key: "console", label: "Console Spark" };
var CONSOLE_TYPE_LABELS = {
discussion: "Discussion",
mission: "Mission",
approbation: "Approbation",
livrable: "Livrable",
activite: "Activité",
planification: "Planification",
objectif: "Objectif",
identite: "Identité",
};
/* Lien profond console (relatif à l'origine console) quand le
* résultat n'en porte pas : même règle que _lien_recherche
* d'agent-commun (section + objet, activite en section=null). */
var CONSOLE_VIEW_LINKS = {
livrable: "/?section=livrables",
planification: "/?section=planifie",
identite: "/?section=identites",
};
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
@@ -157,7 +73,6 @@
var TIMEOUT_MS = 8000;
var DEBOUNCE_MS = 250;
var PER_APP_LIMIT = 8;
var FULL_LIMIT = 100;
function esc(s) {
return String(s == null ? "" : s)
@@ -205,92 +120,9 @@
return out;
}
/* Base console : explicite > URL agent injectée par l'hôte >
* dérivation -dev > href d'onglet de barre console/spark > prod.
* opts : { host, explicit, agentUrl, tabHrefs:{} } */
function resolveConsoleBase(opts) {
opts = opts || {};
var u = cleanUrl(opts.explicit);
if (!u) u = cleanUrl(opts.agentUrl);
if (!u && isDevHost(opts.host)) u = DEV_AGENT;
if (!u)
u = cleanUrl(
opts.tabHrefs &&
(opts.tabHrefs.console || opts.tabHrefs.spark || opts.tabHrefs.agent)
);
if (!u) u = PROD_AGENT;
return withSlash(u);
}
/* Onglet valide dans l'URL, sinon "all". */
function parseTab(v) {
return TABS.indexOf(v) !== -1 ? v : "all";
}
/* Sections visibles pour un onglet. « Tout » = les 4 sections
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
function visibleKeys(tab) {
tab = parseTab(tab);
if (tab === "all") return APPS.map(function (a) { return a.key; });
return [tab];
}
/* Troncature d'affichage : liste complète dans l'onglet propre à
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
function pageItems(items, full) {
items = items || [];
return full ? items : items.slice(0, PER_APP_LIMIT);
}
/* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */
function buildSearchUrl(q, tab) {
var parts = [];
if (q) parts.push("q=" + encodeURIComponent(q));
tab = parseTab(tab);
if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab));
return parts.length ? "?" + parts.join("&") : "";
}
/* Libellés des compteurs d'onglets à partir des états :
* states : { key: { status, total } }.
* Chargement -> "…", ok/vide -> total ("0" honnête),
* erreur/indisponible/inactif -> "" (pas de faux 0).
* Tout : "…" tant qu'une section charge, sinon somme des totaux
* connus, "" si les 4 sections sont en erreur/inactives. */
function tabCounts(states) {
states = states || {};
var out = {};
function labelFor(key) {
var st = states[key] || {};
if (st.status === "loading") return "…";
if (st.status === "ok" || st.status === "empty") return String(st.total || 0);
return "";
}
var allLoading = false;
var allTotal = 0;
var allKnown = false;
APPS.forEach(function (app) {
var st = states[app.key] || {};
out[app.key] = labelFor(app.key);
if (st.status === "loading") allLoading = true;
if (st.status === "ok" || st.status === "empty") {
allKnown = true;
allTotal += st.total || 0;
}
});
out.console = labelFor("console");
out.all = allLoading ? "…" : allKnown ? String(allTotal) : "";
return out;
}
function searchPath(app, q, full) {
function searchPath(app, q) {
if (app === "obsigate") {
return (
"api/search?q=" +
encodeURIComponent(q) +
"&vault=all&limit=" +
(full ? FULL_LIMIT : PER_APP_LIMIT)
);
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
}
return "api/search?q=" + encodeURIComponent(q);
}
@@ -301,17 +133,11 @@
}
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
* base : base résolue de l'app (avec / final). q : requête (liens TikTak).
* full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT
* (comportement historique, conservé par défaut pour les tests). */
function normalize(app, data, base, q, full) {
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
function normalize(app, data, base, q) {
data = data || {};
var limit = full ? null : PER_APP_LIMIT;
function cut(arr) {
return limit == null ? arr : arr.slice(0, limit);
}
if (app === "obsigate") {
return cut(data.results || []).map(function (r) {
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
return {
title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""),
@@ -327,7 +153,7 @@
}
if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []);
return cut(items).map(function (r) {
return items.slice(0, PER_APP_LIMIT).map(function (r) {
var rel = String(r.url || "").replace(/^\//, "");
return {
title: r.title || "Sans titre",
@@ -338,7 +164,7 @@
});
}
if (app === "tiktak") {
return cut(data.tasks || []).map(function (t) {
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return {
@@ -356,7 +182,7 @@
rows.push({ group: g.full_name, row: row });
});
});
return cut(rows).map(function (x) {
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
var issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return {
@@ -370,86 +196,6 @@
return [];
}
/* Normalisation console : format réel GET /api/v1/recherche,
* { resultats: [{ type, id, titre, extrait, statut, domaine,
* section, objet, lien }] }. Le lien relatif est préfixé par la
* base console ; sans lien, il est reconstruit depuis type/id
* (même règle que la console) ; sans lien reconstructible,
* url=null et le résultat est rendu en <span>, pas en faux lien.
* Les anciennes formes tolérées (results/discussions/items, avec
* url/href/link) restent acceptées en repli, pour ne pas casser
* un déploiement console antérieur. Retourne { recognized,
* items } : recognized=false -> l'appelant affiche
* « indisponible », jamais un vide mensonger. */
function consoleLinkFor(r) {
var lien = r.lien || "";
if (lien) return lien;
var id = r.id != null ? String(r.id) : "";
if (r.type === "discussion")
return "/?section=conversation&disc=" + encodeURIComponent(id);
if (r.type === "mission")
return "/?section=missions&objet=" + encodeURIComponent(id);
if (r.type === "approbation")
return "/?section=approbations&objet=" + encodeURIComponent(id);
if (r.type === "objectif")
return "/?section=objectifs&objet=" + encodeURIComponent(id);
if (r.type === "activite") return "/?activite=" + encodeURIComponent(id);
if (CONSOLE_VIEW_LINKS[r.type]) return CONSOLE_VIEW_LINKS[r.type];
if (r.section)
return (
"/?section=" +
encodeURIComponent(r.section) +
(r.objet != null
? "&objet=" + encodeURIComponent(String(r.objet))
: "")
);
return "";
}
function normalizeConsole(data, base) {
data = data || {};
if (Array.isArray(data.resultats)) {
var items = data.resultats.map(function (r) {
r = r || {};
var rel = consoleLinkFor(r);
var url = null;
if (/^https?:\/\//.test(rel)) url = rel;
else if (rel.charAt(0) === "/" || rel.charAt(0) === "#")
url = base + String(rel).replace(/^\//, "");
var label = CONSOLE_TYPE_LABELS[r.type] || r.type || "Console";
var context = label;
if (r.statut) context += " · " + r.statut;
if (r.domaine) context += " · " + r.domaine;
return {
title: r.titre || r.title || "Sans titre",
snippet: snippetOf(r.extrait || r.snippet || ""),
context: context,
url: url,
};
});
return { recognized: true, items: items };
}
var list = null;
if (Array.isArray(data.results)) list = data.results;
else if (Array.isArray(data.discussions)) list = data.discussions;
else if (Array.isArray(data.items)) list = data.items;
if (!list) return { recognized: false, items: [] };
var legacy = list.map(function (r) {
r = r || {};
var raw = r.url || r.href || r.link || "";
var url = null;
if (/^https?:\/\//.test(raw)) url = raw;
else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, "");
return {
title: r.title || r.name || r.subject || "Sans titre",
snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""),
context: r.subtitle || r.source || r.type || "",
url: url,
};
});
return { recognized: true, items: legacy };
}
/* Recherche native de l'app (repli quand la section est en erreur). */
function nativeSearchUrl(app, base, q) {
if (app === "tiktak" || app === "kanbante") {
@@ -468,7 +214,6 @@
}
function appLabel(key) {
if (key === "console") return CONSOLE_APP.label;
for (var i = 0; i < APPS.length; i++) {
if (APPS[i].key === key) return APPS[i].label;
}
@@ -479,26 +224,11 @@
esc: esc,
isDevHost: isDevHost,
resolveBases: resolveBases,
resolveConsoleBase: resolveConsoleBase,
parseTab: parseTab,
visibleKeys: visibleKeys,
pageItems: pageItems,
buildSearchUrl: buildSearchUrl,
tabCounts: tabCounts,
searchPath: searchPath,
normalize: normalize,
normalizeConsole: normalizeConsole,
nativeSearchUrl: nativeSearchUrl,
PROD: PROD,
DEV: DEV,
PROD_AGENT: PROD_AGENT,
DEV_AGENT: DEV_AGENT,
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
CONSOLE_SEARCH_LIMIT: CONSOLE_SEARCH_LIMIT,
CONSOLE_TYPE_LABELS: CONSOLE_TYPE_LABELS,
consoleLinkFor: consoleLinkFor,
PER_APP_LIMIT: PER_APP_LIMIT,
FULL_LIMIT: FULL_LIMIT,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
global.RecherchePartout = api;
@@ -538,24 +268,16 @@
});
}
function renderSection(section, state, full) {
/* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable",
* items (complets), message, q, base, app }.
* full=true (onglet propre à l'app) : tout afficher ; sinon
* PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */
function renderSection(section, state) {
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
var body = section.querySelector(".rp-body");
var count = section.querySelector(".rp-count");
var toutVoir = section.querySelector(".rp-all");
if (toutVoir) {
try {
toutVoir.href =
global.location.pathname + buildSearchUrl(state.q, state.app);
} catch (e) {}
toutVoir.textContent = "Tout voir";
toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app);
}
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
toutVoir.textContent = "Tout voir";
toutVoir.title = nativeSearchLabel(state.app);
if (state.status === "loading") {
count.textContent = "…";
count.textContent = "";
body.innerHTML = '<p class="rp-state">Recherche…</p>';
return;
}
@@ -571,31 +293,19 @@
"</a></p>";
return;
}
if (state.status === "unavailable") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Console Spark : recherche indisponible pour l\'instant' +
(state.message ? " (" + esc(state.message) + ")" : "") +
".</p>";
return;
}
if (state.status === "idle") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
return;
}
if (state.status === "empty") {
count.textContent = "0";
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
return;
}
var shown = pageItems(state.items, full);
count.textContent = String(state.items.length);
body.innerHTML = shown
body.innerHTML = state.items
.map(function (item) {
var inner =
return (
'<a class="rp-item" data-rp-link href="' +
esc(item.url) +
'">' +
'<span class="rp-title">' +
esc(item.title) +
"</span>" +
@@ -604,13 +314,9 @@
: "") +
(item.snippet
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
: "");
if (item.url) {
return (
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
);
}
return '<span class="rp-item">' + inner + "</span>";
: "") +
"</a>"
);
})
.join("");
}
@@ -643,213 +349,40 @@
barUrls: barUrls,
tabHrefs: tabHrefs,
});
var consoleBase = resolveConsoleBase({
host: global.location.hostname,
explicit: root.getAttribute("data-base-console"),
agentUrl:
(global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL,
tabHrefs: tabHrefs,
});
var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]);
var sections = {};
allKeys.forEach(function (key) {
var el = document.querySelector('[data-rp-section="' + key + '"]');
if (el) sections[key] = el;
});
var tabButtons = {};
document.querySelectorAll("[data-rp-tab]").forEach(function (btn) {
tabButtons[btn.getAttribute("data-rp-tab")] = btn;
});
var tablist = document.querySelector(".rp-tabs");
/* États par application (+ console). total = items.length. */
var states = {};
allKeys.forEach(function (key) {
states[key] = {
status: "idle",
items: [],
message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
APPS.forEach(function (app) {
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
if (el) sections[app.key] = el;
});
var activeTab = "all";
var currentQ = "";
var runId = 0;
var consoleFetchedQ = null;
var debounceTimer = null;
function statesForCounts() {
var out = {};
allKeys.forEach(function (key) {
out[key] = { status: states[key].status, total: states[key].items.length };
});
return out;
}
function renderTabs() {
var counts = tabCounts(statesForCounts());
Object.keys(tabButtons).forEach(function (key) {
var btn = tabButtons[key];
var selected = key === activeTab;
btn.classList.toggle("active", selected);
btn.setAttribute("aria-selected", selected ? "true" : "false");
btn.tabIndex = selected ? 0 : -1;
var span = btn.querySelector(".rp-tab-count");
if (span) span.textContent = counts[key] || "";
});
}
/* Bascule d'onglet : visibilité des sections, re-rendu (plein dans
* l'onglet propre à l'app, tronqué dans « Tout »), compteurs.
* opts : { push (historique), focusTab }. */
function applyTab(tab, opts) {
opts = opts || {};
activeTab = parseTab(tab);
try {
root.setAttribute("data-rp-tab", activeTab);
} catch (e) {}
var visible = visibleKeys(activeTab);
allKeys.forEach(function (key) {
var sec = sections[key];
if (!sec) return;
var show = visible.indexOf(key) !== -1;
sec.hidden = !show;
if (show) renderSection(sec, states[key], activeTab === key);
var tv = sec.querySelector(".rp-all");
if (tv) tv.hidden = activeTab === key;
});
renderTabs();
if (opts.push) {
try {
global.history.pushState(
null,
"",
global.location.pathname + buildSearchUrl(currentQ, activeTab)
);
} catch (e) {}
}
if (opts.focusTab && tabButtons[activeTab]) {
try {
tabButtons[activeTab].focus();
} catch (e) {}
}
}
function setTab(tab, opts) {
applyTab(tab, opts);
/* La console n'est interrogée qu'une fois affichée. */
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
fetchConsole(currentQ, runId);
}
}
function fetchConsole(q, myRun) {
var sec = sections.console;
if (!sec) return;
consoleFetchedQ = q;
states.console = {
status: "loading",
items: [],
message: "",
q: q,
base: consoleBase,
app: "console",
};
renderSection(sec, states.console, true);
renderTabs();
var url =
consoleBase +
CONSOLE_SEARCH_PATH +
"?q=" +
encodeURIComponent(q) +
"&limite=" +
CONSOLE_SEARCH_LIMIT;
fetchSection("console", url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
else if (err.status === 422)
msg = "au moins 2 caractères requis";
else if (err.status) msg = "HTTP " + err.status;
else msg = "injoignable (CORS ou réseau)";
states.console = {
status: "unavailable",
items: [],
message: msg,
q: q,
base: consoleBase,
app: "console",
};
} else {
var norm = normalizeConsole(data, consoleBase);
if (!norm.recognized) {
states.console = {
status: "unavailable",
items: [],
message: "format inattendu",
q: q,
base: consoleBase,
app: "console",
};
} else {
states.console = {
status: norm.items.length ? "ok" : "empty",
items: norm.items,
message: "",
q: q,
base: consoleBase,
app: "console",
};
}
}
renderSection(sec, states.console, true);
renderTabs();
});
}
function runSearch(q) {
currentQ = q;
var myRun = ++runId;
if (!q) {
allKeys.forEach(function (key) {
var sec = sections[key];
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
states[key] = {
status: "idle",
items: [],
message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
renderSection(sec, states[key], activeTab === key);
sec.querySelector(".rp-body").innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
sec.querySelector(".rp-count").textContent = "";
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
});
consoleFetchedQ = null;
renderTabs();
return;
}
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
states[app.key] = {
status: "loading",
items: [],
message: "",
q: q,
base: bases[app.key],
app: app.key,
};
renderSection(sec, states[app.key], activeTab === app.key);
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
var url;
if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q, true);
url = "/" + searchPath(app.key, q);
} else {
url = bases[app.key] + searchPath(app.key, q, true);
url = bases[app.key] + searchPath(app.key, q);
}
fetchSection(app.key, url, function (err, data) {
if (myRun !== runId) return;
@@ -860,45 +393,25 @@
msg = "Connexion requise sur " + app.label;
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
states[app.key] = {
renderSection(sec, {
status: "error",
items: [],
message: msg,
q: q,
base: bases[app.key],
app: app.key,
};
} else {
var items = normalize(app.key, data, bases[app.key], q, true);
states[app.key] = {
status: items.length ? "ok" : "empty",
items: items,
message: "",
q: q,
base: bases[app.key],
app: app.key,
};
q: q,
});
return;
}
renderSection(sec, states[app.key], activeTab === app.key);
renderTabs();
var items = normalize(app.key, data, bases[app.key], q);
renderSection(sec, {
status: items.length ? "ok" : "empty",
items: items,
app: app.key,
base: bases[app.key],
q: q,
});
});
});
/* Console : seulement si son onglet est affiché ; sinon son
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
if (activeTab === "console") {
fetchConsole(q, myRun);
} else {
states.console = {
status: "idle",
items: [],
message: "",
q: q,
base: consoleBase,
app: "console",
};
consoleFetchedQ = null;
renderTabs();
}
}
function scheduleSearch(q, immediate) {
@@ -914,33 +427,13 @@
function syncUrl(q) {
try {
global.history.replaceState(
null,
"",
global.location.pathname + buildSearchUrl(q, activeTab)
);
var url = new URL(global.location.href);
if (q) url.searchParams.set("q", q);
else url.searchParams.delete("q");
global.history.replaceState(null, "", url.toString());
} catch (e) {}
}
function focusSearch() {
try {
input.focus({ preventScroll: true });
} catch (e) {
try {
input.focus();
} catch (e2) {}
}
}
function visibleLinks() {
return Array.prototype.slice
.call(document.querySelectorAll("[data-rp-link]"))
.filter(function (a) {
var s = a.closest ? a.closest("[data-rp-section]") : null;
return !s || !s.hidden;
});
}
input.addEventListener("input", function () {
var q = input.value.trim();
syncUrl(q);
@@ -948,7 +441,7 @@
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var first = visibleLinks()[0];
var first = document.querySelector("[data-rp-link]");
if (first) {
e.preventDefault();
first.click();
@@ -978,9 +471,6 @@
input.select();
return;
}
/* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les
* flèches ←/→ du tablist s'en chargent (voir ci-dessous). */
if (target && target.closest && target.closest(".rp-tab")) return;
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
@@ -988,62 +478,8 @@
true
);
/* « Tout voir » reste sur la page : bascule vers l'onglet. */
root.addEventListener("click", function (e) {
var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null;
if (!all) return;
var sec = all.closest ? all.closest("[data-rp-section]") : null;
if (!sec) return;
e.preventDefault();
setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true });
});
/* Clic sur la barre « Rechercher partout » (bouton
* data-act="palette") pendant qu'on est déjà sur la page :
* replacer le curseur dans la boîte au lieu de recharger. */
document.addEventListener("click", function (e) {
var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null;
if (!p) return;
e.preventDefault();
focusSearch();
try {
input.select();
} catch (err) {}
});
/* Onglets : clic + clavier (←/→, Début/Fin). */
Object.keys(tabButtons).forEach(function (key) {
tabButtons[key].addEventListener("click", function () {
setTab(key, { push: true });
});
});
if (tablist) {
tablist.addEventListener("keydown", function (e) {
var order = TABS;
var idx = order.indexOf(activeTab);
var next = null;
if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
else if (e.key === "ArrowLeft")
next = order[(idx - 1 + order.length) % order.length];
else if (e.key === "Home") next = order[0];
else if (e.key === "End") next = order[order.length - 1];
if (!next) return;
e.preventDefault();
setTab(next, { push: true, focusTab: true });
});
}
/* Retour arrière navigateur : restaurer q + onglet. */
global.addEventListener("popstate", function () {
var params = new URLSearchParams(global.location.search);
var q = (params.get("q") || "").trim();
input.value = q;
applyTab(params.get("tab"));
runSearch(q);
});
function moveFocus(delta, e) {
var links = visibleLinks();
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
if (!links.length) return;
var active = document.activeElement;
var idx = links.indexOf(active);
@@ -1056,23 +492,12 @@
var params = new URLSearchParams(global.location.search);
var initialQ = (params.get("q") || "").trim();
var initialTab = parseTab(params.get("tab"));
if (initialQ) input.value = initialQ;
applyTab(initialTab);
runSearch(initialQ);
/* Focus explicite (voir en-tête) : l'attribut autofocus seul ne
* survit pas au chargement des surcouches. */
focusSearch();
try {
if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch);
} catch (e) {}
try {
setTimeout(focusSearch, 0);
} catch (e) {}
global.addEventListener("pageshow", function (e) {
if (e.persisted) focusSearch();
});
if (initialQ) {
input.value = initialQ;
runSearch(initialQ);
} else {
runSearch("");
}
}
if (document.readyState === "loading") {
+97 -163
View File
@@ -4,11 +4,7 @@
* 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 »).
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
*
* 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
@@ -51,76 +47,29 @@
* 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.
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
* Spark (panneau-agent), qui suit le même contrat d'injection.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets, plus
* (depuis le correctif #70) une entrée « Console Spark » vers la
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
* ouvre le menu des applications, construit depuis les onglets ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). 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é.
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
*
* 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 « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
* connu à gérer l'action "menu" via onAction (il déclenche son propre
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
* pas et le bouton restait mort (relais vers un onAction absent, puis
* événement draco:bar sans écouteur). Comportement désormais :
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
* la console agent (URL agent résolue, donc dérivée -dev sur un
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
* par clic extérieur, Échap ou choix d'un lien ;
* - avec onAction : l'hôte garde la main (comportement historique,
* zéro régression pour ObsiGate) ; il peut demander explicitement
* le menu par défaut en retournant false pour l'action "menu".
* 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
@@ -274,7 +223,7 @@
}
}
/* ── Menus déroulants (apps, compte) ───────────────────────────────
/* ── 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
@@ -282,11 +231,9 @@
* -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 moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, compteMenu = null;
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
* lui rend le focus à la fermeture (correctif #70). */
var lastOpener = null;
var appsMenu = null, moreMenu = null, compteMenu = null;
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
@@ -303,13 +250,10 @@
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups(returnFocus) {
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
function closeAllPopups() {
if (appsMenu) togglePopup(appsMenu, brand, false);
if (moreMenu) togglePopup(moreMenu, moreBtn, 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 + '"]');
@@ -321,6 +265,28 @@
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
function menuLink(app, nom, role) {
var a = document.createElement("a");
a.className = "draco-apps-item draco-menu-item";
a.href = resolvedUrls[app] || "#";
a.dataset.app = app;
a.setAttribute("role", "menuitem");
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var n = document.createElement("span");
n.className = "draco-apps-item-nom";
n.textContent = nom;
txt.appendChild(n);
if (role) {
var r = document.createElement("span");
r.className = "draco-apps-item-role";
r.textContent = role;
txt.appendChild(r);
}
a.appendChild(txt);
return a;
}
/* 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
@@ -334,11 +300,6 @@
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");
@@ -365,48 +326,6 @@
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 ; son icône CLONE celle de l'onglet agent
* (2026-10-29, logos barre-apps, corrigé le même jour) : le logo
* fourni, tel quel, jamais redessiné — le CSS --agent (rose) des
* autres éléments de la barre n'est pas changé ici. */
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;
var agentTab = document.querySelector('.draco-apptab[data-app="agent"]');
var agentIco = agentTab ? agentTab.querySelector(".draco-apptab-ico") : null;
if (agentIco) {
sparkIco = agentIco.cloneNode(true);
} else {
sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#d21f2c");
sparkIco.setAttribute("data-icon", "agent");
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) {
@@ -414,7 +333,34 @@
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
if (!appsMenu.hidden) lastOpener = brand;
}
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
function buildMoreMenu() {
if (!bar || !moreBtn || moreMenu) return;
moreMenu = document.createElement("div");
moreMenu.className = "draco-more-menu";
moreMenu.hidden = true;
moreMenu.setAttribute("role", "menu");
moreMenu.setAttribute("aria-label", "Plus d'actions");
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
moreMenu.appendChild(sep);
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
});
bar.appendChild(moreMenu);
}
function toggleMoreMenu(force) {
buildMoreMenu();
if (!moreMenu) return;
if (force !== false) refreshMenuHrefs(moreMenu);
togglePopup(moreMenu, moreBtn, force);
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
@@ -453,7 +399,6 @@
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
@@ -463,21 +408,7 @@
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) {
document.addEventListener("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) {
@@ -488,21 +419,38 @@
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (moreMenu) toggleMoreMenu(false);
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "menu") {
if (appsMenu) toggleAppsMenu(false);
if (compteMenu) toggleCompteMenu(false);
/* Hôte prioritaire : il garde la main, sauf s'il demande
* explicitement le menu par défaut en retournant false.
* Sans onAction, le menu par défaut s'ouvre (il ne restait
* auparavant qu'un événement draco:bar sans écouteur). */
if (typeof cfg.onAction === "function") {
var handled = cfg.onAction("menu", el, ev);
if (handled === false) toggleMoreMenu();
} else {
toggleMoreMenu();
}
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
if (moreMenu) toggleMoreMenu(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);
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups();
}
if (!el) return;
var act = el.dataset.act;
@@ -510,23 +458,8 @@
relay(act, el, ev);
});
addGlobalListener("keydown", function (ev) {
if (ev.key === "Escape") { closeAllPopups(true); return; }
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
* ouverture explicite + focus sur le premier item du menu (#70).
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
* ci-dessus (pas de double bascule ici). */
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
var brandEl = ev.target.closest('[data-act="apps"]');
if (brandEl) {
if (ev.preventDefault) ev.preventDefault();
toggleAppsMenu();
if (appsMenu && !appsMenu.hidden) {
var first = appsMenu.querySelector(".draco-apps-item");
if (first && typeof first.focus === "function") first.focus();
}
}
}
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeAllPopups();
});
/* API publique : l'app met les compteurs à jour sans recharger.
@@ -548,6 +481,7 @@
applyUrls(resolvedUrls);
buildAppsMenu();
buildMoreMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
+1 -8
View File
@@ -8,14 +8,7 @@
═══════════════════════════════════════════════════════════ */
'use strict';
const CACHE_NAME = 'flowdeck-v9'; // v9 : purge des caches pre-#33/#36 —
// recherche.js/recherche.css etaient servis cache-first SANS version
// (?v= absent dans recherche.html) : les navigateurs ayant visite
// Flowdeck avant les onglets gardaient les copies pre-#33 (JS sans
// onglets/compteurs/console, CSS sans .rp-tabs) malgre les deplois
// 7.84.3/7.84.4. Le bump purge ces caches a l'activation, et les
// liens versionnes (?v=asset_version) font des futurs changements
// de nouvelles cles de cache.
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
const DATA_CACHE = 'flowdeck-data-v7';
// App shell (assets versionnés comme référencés dans les templates).
-236
View File
@@ -1,236 +0,0 @@
// 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 : Flowdeck
// 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");
});
-116
View File
@@ -65,120 +65,4 @@ ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
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");
-90
View File
@@ -1,90 +0,0 @@
"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Flowdeck).
Source réelle de Flowdeck : GET /api/notifications (non lues =
actives, `url` relative pour le lien profond), déjà exposée et
inchangée ici. Couverts : placeholder Alertes disparu, module servi,
CSP connect-src avec les origines soeurs, CORS (origines soeurs
ajoutées à allow_origins ; tiers sans en-tête).
"""
SOEURS = ("https://og.dracodev.net", "https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net", "https://kanbante.dracodev.net")
def test_barre_alertes_sans_placeholder_et_module_servi(client):
# Les pages applicatives authentifiées ne sont pas atteignables
# telles quelles dans la sandbox (l'écran de connexion est servi
# pour "/" comme pour "/dashboard") : on vérifie donc le gabarit
# livré lui-même, plus les fichiers statiques servis.
from pathlib import Path
gabarit = Path("app/templates/_draco_bar.html").read_text()
assert "Alertes unifiées : arrive avec la fédération" not in gabarit
assert "draco-alertes.js" in gabarit
assert "draco-alertes.css" in gabarit
assert "DRACO_ALERTES.open()" in gabarit
js = client.get("/static/js/draco-alertes.js")
assert js.status_code == 200
assert "DRACO_ALERTES" in js.text
assert "api/notifications" in js.text
css = client.get("/static/css/draco-alertes.css")
assert css.status_code == 200
assert "draco-alertes-overlay" in css.text
def test_csp_connect_src_contient_les_soeurs(client):
# La CSP est posée sur toute réponse HTML, y compris l'écran de
# connexion servi ici : elle prouve les origines soeurs en
# connect-src pour les pages de l'hôte.
r = client.get("/")
csp = r.headers.get("content-security-policy", "")
assert "connect-src" in csp
for origine in SOEURS:
assert origine in csp, csp
def test_notifications_api_et_cors_soeur(client):
r = client.get("/api/notifications")
assert r.status_code == 200
body = r.json()
assert isinstance(body["notifications"], list)
assert "unread" in body
r2 = client.options("/api/notifications",
headers={"Origin": "https://tiktak.dracodev.net",
"Access-Control-Request-Method": "GET"})
assert r2.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
assert r2.headers.get("access-control-allow-credentials") == "true"
r3 = client.get("/api/notifications",
headers={"Origin": "https://tiktak.dracodev.net"})
assert r3.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
r4 = client.options("/api/notifications",
headers={"Origin": "https://evil.example",
"Access-Control-Request-Method": "GET"})
assert "access-control-allow-origin" not in r4.headers
# Correctif 2026-10-28 : une origine sœur dev passe aussi.
r5 = client.options("/api/notifications",
headers={"Origin": "https://tiktak-dev.dracodev.net",
"Access-Control-Request-Method": "GET"})
assert r5.headers.get("access-control-allow-origin") == "https://tiktak-dev.dracodev.net"
assert r5.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
(gabarit lu, pages authentifiées inatteignables en sandbox comme
pour le test #69), et vue Alertes en onglets (module + styles)."""
from pathlib import Path
gabarit = Path("app/templates/_draco_bar.html").read_text()
nav = gabarit.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")
assert js.status_code == 200
assert "draco-alertes-tab" in js.text
assert "historique" in js.text
assert "source indisponible" in js.text
css = client.get("/static/css/draco-alertes.css")
assert css.status_code == 200
assert "draco-alertes-tab" in css.text
assert "draco-alertes-filtre" in css.text
-48
View File
@@ -1,48 +0,0 @@
"""Barre d'apps Flowdeck : aucun texte de chantier rendu hors commentaire.
Defaut 5 : les lignes « Chantier 5 (fusion #69/#71, sans bump) : ... »
etaient placees APRES la fermeture `#}` du commentaire Jinja d'en-tete
de `app/templates/_draco_bar.html` (lignes 9-11) : elles etaient donc
rendues telles quelles, juste au-dessus du <header> de la barre, sur
toutes les pages (la barre est incluse par base.html).
Test sans dependance (lecture du gabarit seul, comme
test_draco_alertes.py) : il tourne sans serveur ni base, donc aussi
en sandbox. La note documentaire peut vivre DANS le commentaire Jinja
(jamais rendu) ; elle ne doit jamais en sortir.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _hors_commentaires(texte):
"""Texte du gabarit sans les commentaires Jinja {# ... #}."""
return re.sub(r"\{#.*?#\}", "", texte, flags=re.S)
def test_aucun_texte_chantier_rendu():
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
rendu = _hors_commentaires(gabarit)
assert "Chantier 5 (fusion" not in rendu
assert "sans bump" not in rendu
# Aucune ligne de texte libre entre le commentaire d'en-tete et <link> :
# le rendu doit commencer (apres commentaires) par <link> ou <header>.
assert rendu.lstrip().startswith("<link")
def test_barre_intacte_apres_suppression():
"""Le 5e onglet Console Spark et la marque survivent a la suppression."""
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
rendu = _hors_commentaires(gabarit)
nav = rendu.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
assert 'data-app="agent"' in nav
assert 'class="draco-brand"' in rendu
# L'entree « Console Spark » du menu de la marque est construite par
# draco-barre.js depuis les onglets : l'onglet agent suffit a l'alimenter.
js_barre = (ROOT / "static/js/vendor/draco-barre.js").read_text()
assert "Console Spark" in js_barre
+3 -33
View File
@@ -38,51 +38,25 @@ def test_origin_of():
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_sister_origins_ajoute_env_de_la_requete():
# Correctif 2026-10-28 : configuration muette, quel que soit
# l'hôte de la requête, prod ET dev passent — l'hôte ne
# restreint plus l'allowlist.
# Configuration muette (hors requête) + hôte dev : les -dev passent.
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="flowdeck-dev.dracodev.net"
)
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="flowdeck.dracodev.net"
)
assert "https://og-dev.dracodev.net" in sister_origins(
assert "https://og-dev.dracodev.net" not in sister_origins(
{}, request_host="flowdeck.dracodev.net"
)
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="flowdeck-dev.dracodev.net"
)
def test_cors_headers():
@@ -90,11 +64,7 @@ def test_cors_headers():
assert headers["Access-Control-Allow-Origin"] == "https://og-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
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
def test_preflight_headers():
-120
View File
@@ -1,120 +0,0 @@
"""Défaut 3b — /api/search Flowdeck depuis la console agent-dev.
Cause prouvée en déployé (flowdeck-dev, 2026-10-28) : sur une réponse
GET /api/search, le CORSMiddleware global pose déjà
``Access-Control-Allow-Credentials: true`` (comportement Starlette
avec ``allow_credentials=True``, même hors allowlist), et l'ancien
middleware de recherche EMPILAIT ses en-têtes par-dessus — d'où, pour
l'origine agent-dev, ``Allow-Credentials`` en double (« true, true »
≠ « true » pour le contrôle CORS du navigateur → fetch rejeté →
« injoignable (CORS ou réseau) » dans la console, alors que TikTak et
Kanbante, sans ce cumul, répondent avec des en-têtes uniques) et,
pour une origine sœur déjà dans la allowlist globale, ``Allow-Origin``
en double (rejet certain). Le correctif : le middleware de recherche
REMPLACE les en-têtes CORS existants au lieu de les empiler (même
patron que app/recherche_cors.py de la console).
Ces tests pilotent le middleware en ASGI pur, avec une application
interne qui imite le CORSMiddleware global (un en-tête
Allow-Credentials déjà posé, et, pour une sœur, Allow-Origin aussi).
``sister_origins`` est figé sur les listes officielles (les fonctions
pures sont déjà couvertes par tests/test_recherche.py) pour garder le
test hermétique (aucune résolution draco_urls, aucune base).
"""
import asyncio
import pytest
from app.middleware import recherche_cors as rc
ORIGINES = rc.PROD_ORIGINS | rc.DEV_ORIGINS | rc.AGENT_ORIGINS
def _lancer(middleware, scope):
"""Exécute le middleware ASGI et retourne les messages envoyés."""
recus = []
async def recevoir():
return {"type": "http.request", "body": b"", "more_body": False}
async def envoyer(message):
recus.append(message)
asyncio.run(middleware(scope, recevoir, envoyer))
return recus
def _application_interne(entetes):
async def appli(scope, recevoir, envoyer):
await envoyer({"type": "http.response.start", "status": 401,
"headers": list(entetes)})
await envoyer({"type": "http.response.body", "body": b"{}"})
return appli
def _scope(methode, origine):
return {"type": "http", "method": methode, "path": "/api/search",
"headers": [(b"origin", origine.encode("latin-1")),
(b"host", b"flowdeck-dev.dracodev.net")]}
def _compter(entetes, nom):
return sum(1 for k, _v in entetes if k == nom.encode("latin-1"))
@pytest.fixture(autouse=True)
def _origines_figees(monkeypatch):
monkeypatch.setattr(rc, "sister_origins", lambda *a, **k: ORIGINES)
def test_get_agent_dev_un_seul_jeu_d_entetes():
"""Cas du défaut : globale (ACAC seul) + recherche → un seul jeu."""
interne = _application_interne([
(b"content-type", b"application/json"),
(b"access-control-allow-credentials", b"true")])
recus = _lancer(rc.RechercheCorsMiddleware(interne),
_scope("GET", "https://agent-dev.dracodev.net"))
entetes = recus[0]["headers"]
assert _compter(entetes, "access-control-allow-credentials") == 1
assert _compter(entetes, "access-control-allow-origin") == 1
assert _compter(entetes, "vary") == 1
assert (b"access-control-allow-origin",
b"https://agent-dev.dracodev.net") in entetes
assert (b"content-type", b"application/json") in entetes
assert recus[0]["status"] == 401 # le statut traverse inchangé
def test_get_origine_soeur_pas_d_allow_origin_double():
"""Sœur déjà dans la allowlist globale : pas de doublon non plus."""
interne = _application_interne([
(b"access-control-allow-origin", b"https://og-dev.dracodev.net"),
(b"access-control-allow-credentials", b"true"),
(b"vary", b"Origin")])
recus = _lancer(rc.RechercheCorsMiddleware(interne),
_scope("GET", "https://og-dev.dracodev.net"))
entetes = recus[0]["headers"]
assert _compter(entetes, "access-control-allow-origin") == 1
assert _compter(entetes, "access-control-allow-credentials") == 1
assert _compter(entetes, "vary") == 1
def test_origine_non_autorisee_traverse_sans_modification():
"""Une origine refusée ne reçoit aucun Allow-Origin ajouté."""
interne = _application_interne([
(b"access-control-allow-credentials", b"true")])
recus = _lancer(rc.RechercheCorsMiddleware(interne),
_scope("GET", "https://example.com"))
entetes = recus[0]["headers"]
assert _compter(entetes, "access-control-allow-origin") == 0
assert entetes == [(b"access-control-allow-credentials", b"true")]
def test_preflight_agent_dev_toujours_unique():
"""Le preflight (court-circuit avant l'interne) reste propre."""
recus = _lancer(rc.RechercheCorsMiddleware(_application_interne([])),
_scope("OPTIONS", "https://agent-dev.dracodev.net"))
entetes = recus[0]["headers"]
assert recus[0]["status"] == 204
assert _compter(entetes, "access-control-allow-origin") == 1
assert _compter(entetes, "access-control-allow-credentials") == 1
-85
View File
@@ -1,85 +0,0 @@
"""Page /recherche Flowdeck : onglets, base console, anti-cache (defaut 6).
Audit prealable (main 7.84.4, tag v7.84.4 = 984e92b) : la route
(app/routers/search.py:51), le gabarit (app/templates/recherche.html),
le JS (static/js/recherche.js) et le CSS (static/css/recherche.css)
contenaient DEJA la page a onglets des PR #33/#36 — aucune route
fantome, aucun bloc `scripts` manquant dans base.html:869, aucune
regle CSP ne bloquant le script (script-src 'self'). Le symptome
deploie (« pas d'onglets, resultats Flowdeck seuls ») ne s'explique
donc PAS par un mauvais gabarit dans main, mais par deux causes
reelles, corrigees ici :
1. Assets recherche servis CACHE-FIRST par le service worker
(static/sw.js) SANS version (?v= absent) sous un CACHE_NAME
inchange depuis A20 ('flowdeck-v8') : un navigateur ayant visite
Flowdeck avant #33 gardait le recherche.js pre-#33 (sans onglets,
sans compteurs, sans logique console, « Tout voir » naviguant
ailleurs) et le recherche.css pre-#33 (sans .rp-tabs) — la page
paraissait donc « non corrigee » malgre les deplois 7.84.3/7.84.4,
et le repli networkFirst du HTML vers le cache couvrait aussi le
cas reseau lent. Correctif : liens versionnes (?v=asset_version,
comme tous les autres assets de base.html) + bump CACHE_NAME
(purge a l'activation du nouveau worker).
2. Flowdeck etait le seul hote a ne pas rendre data-base-console
(son propre commentaire l'admettait) : la base console de l'onglet
Console Spark etait devine (derivation -dev / href d'onglet de
barre) au lieu d'etre explicite. Correctif : la route expose
`bases.agent` et le gabarit le rend en data-base-console.
La logique d'onglets elle-meme (focus, compteurs, « Tout voir » sur
place, etats par application, liens -dev, console sur
GET /api/v1/recherche) est deja couverte par tests/recherche.test.js
(suite Node, lancee en sandbox) ; ce fichier-ci verrouille le
branchement gabarit/route/service worker, sans serveur ni base.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ONGLETS_ATTENDUS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante",
"console"]
def test_gabarit_recherche_onglets_complets():
gabarit = (ROOT / "app/templates/recherche.html").read_text()
trouves = re.findall(r'data-rp-tab="([^"]+)"', gabarit)
assert trouves == ONGLETS_ATTENDUS, trouves
for section in ("obsigate", "flowdeck", "tiktak", "kanbante", "console"):
assert f'data-rp-section="{section}"' in gabarit
assert 'id="rp-q"' in gabarit and "autofocus" in gabarit
def test_gabarit_recherche_base_console_explicite():
gabarit = (ROOT / "app/templates/recherche.html").read_text()
assert 'data-base-console="{{ bases.agent }}"' in gabarit
def test_gabarit_recherche_assets_versionnes():
"""Sans ?v=, le SW cache-first ressert les copies pre-#33."""
gabarit = (ROOT / "app/templates/recherche.html").read_text()
assert "/static/css/recherche.css?v={{ asset_version }}" in gabarit
assert "/static/js/recherche.js?v={{ asset_version }}" in gabarit
def test_route_recherche_expose_base_agent():
route = (ROOT / "app/routers/search.py").read_text()
assert '"agent"' in route or "'agent'" in route
assert "bases.agent" in (ROOT / "app/templates/recherche.html").read_text()
def test_service_worker_purge_anciens_caches():
"""Le bump de CACHE_NAME purge les caches pre-#33 a l'activation."""
sw = (ROOT / "static/sw.js").read_text()
assert "const CACHE_NAME = 'flowdeck-v9'" in sw
assert "flowdeck-v8" not in sw
assert "cache-first" in sw or "cacheFirst" in sw
def test_recherche_js_branche_console_v1():
"""Rappel : l'onglet console appelle GET /api/v1/recherche (cf. #36)."""
js = (ROOT / "static/js/recherche.js").read_text()
assert 'CONSOLE_SEARCH_PATH = "api/v1/recherche"' in js
assert "CONSOLE_SEARCH_LIMIT = 20" in js