Compare commits

..
Author SHA1 Message Date
bruno 8b871ee39f Rebase chantier 5 (alertes) sur main post-#33 : tests/test_draco_alertes.py
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 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 14:27:58 -04:00
bruno 171fdf063b Rebase chantier 5 (alertes) sur main post-#33 : tests/draco-alertes.test.js
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:27:55 -04:00
bruno ea12938e7c Rebase chantier 5 (alertes) sur main post-#33 : static/js/vendor/draco-barre.js
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:27:52 -04:00
bruno 3f14dc7130 Rebase chantier 5 (alertes) sur main post-#33 : static/js/draco-alertes.js
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:27:49 -04:00
bruno 71454f94d1 Rebase chantier 5 (alertes) sur main post-#33 : static/css/draco-barre.css
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:27:46 -04:00
bruno 3145cea8e1 Rebase chantier 5 (alertes) sur main post-#33 : static/css/draco-alertes.css
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:27:43 -04:00
bruno f49eb62172 Rebase chantier 5 (alertes) sur main post-#33 : app/templates/_draco_bar.html
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:27:41 -04:00
bruno 746ab4c0e1 Merge pull request 'Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page' (#33) from recherche-onglets-ux-68 into main
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:25:36 -04:00
bruno cdf943aff5 Merge pull request 'CORS recherche/alertes : allowlist soeurs prod+dev explicites (fusion #68/#69)' (#32) from cors-recherche-alertes-68-69 into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 14:25:22 -04:00
bruno 751daa43dd Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page
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 13:50:22 -04:00
bruno 3895e79167 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:50:18 -04:00
bruno 8dcddfe34f Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:50:15 -04:00
bruno b506a447c5 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:50:12 -04:00
bruno 9ca831f816 Tests CORS alertes : origine dev autorisee en preflight
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 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 13:45:50 -04:00
bruno c09a0ac51d Tests CORS recherche : prod+dev autorises, tiers refuse
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:45:48 -04:00
bruno 2d3a93511b CORS alertes (fusion #69) : allow_origins /api/notifications prod+dev
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:45:45 -04:00
bruno dd979f81a7 CORS recherche/alertes (fusion #68/#69) : allowlist soeurs prod+dev explicites
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:45:42 -04:00
bruno c2df8d8c00 Bump 7.84.2 (programme Fusion #68/#69/#70)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:02:01 -04:00
bruno a930c8192e Bump 7.84.2 (programme Fusion #68/#69/#70)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:01:56 -04:00
bruno 021d0a93f3 Bump 7.84.2 (programme Fusion #68/#69/#70)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:01:53 -04:00
bruno 40571049d6 Merge pull request 'Recherche partout (#68) : page de résultats unifiée + draco et 401 sur /api/search' (#31) from recherche-partout-68-rebase into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:55:32 -04:00
bruno 6eb7b78534 Recherche partout (#68, rebase sur main post-#70 et post-#69)
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:54:59 -04:00
bruno 31a84539f2 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 35m13s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:56 -04:00
bruno 7250ebc78f Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:53 -04:00
bruno 167e93e295 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:49 -04:00
bruno 30edf2f0c0 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m50s
2026-10-28 12:54:45 -04:00
bruno 67718d1d3c Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:41 -04:00
bruno 1dd148532b Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:37 -04:00
bruno bd9ecf69e1 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:34 -04:00
bruno 4b1df2d2b9 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m52s
2026-10-28 12:54:30 -04:00
bruno fe389d46bc Merge pull request 'Fusion #69 : Alertes unifiées (panneau + pastille, agrégation à la demande)' (#29) from alertes-unifiees-69 into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 3h8m46s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:40:49 -04:00
bruno eef46891de Merge pull request 'fusion #70 : retirer le bouton trois points, Console Spark au menu marque' (#28) from fusion-70-barre-console-spark into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:40:04 -04:00
bruno 36c97e1553 fusion #70 : vendanger CSS sans menu trois points
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 12:02:21 -04:00
bruno c356a56648 fusion #70 : vendanger barre sans trois points, Console Spark, fermeture/focus corriges
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:02:17 -04:00
bruno c2a222fc3b fusion #70 : retirer le bouton trois points et la branche menu morte
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:02:14 -04:00
17 changed files with 2447 additions and 233 deletions
+6
View File
@@ -1,3 +1,9 @@
## 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.1
7.84.2
+19 -1
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.84.1"
# version="7.84.2"
# 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()
@@ -251,6 +251,17 @@ _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(
@@ -262,6 +273,13 @@ app.add_middleware(
allow_credentials=True,
)
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
# après CORSMiddleware donc exécutée avant lui (dernier ajouté = le
# plus externe) : les preflights OPTIONS sont répondus ici, avant
# tout refus d'origine. La politique CORS globale est inchangée.
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)
+193
View File
@@ -0,0 +1,193 @@
"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68).
La page « Rechercher partout » (``/recherche``) appelle, depuis le
navigateur, ``GET /api/search`` sur chaque application sœur. Sans
en-têtes CORS, le navigateur refuse la lecture des réponses
cross-origin ; sans borne, on ouvrirait la lecture à tout le web.
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**
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 ;
- avec credentials (le cookie SSO ``draco_session``, accepté par
``/api/search`` depuis cette livraison, ou la session native).
Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même
aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute
les en-têtes aux réponses de recherche, sans toucher aux autres
routes ni à la politique CORS globale de l'application.
"""
from __future__ import annotations
from urllib.parse import urlparse
SEARCH_PATH = "/api/search"
PROD_ORIGINS = frozenset({
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
})
DEV_ORIGINS = frozenset({
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
})
# Clés de draco_urls.toutes() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
def is_dev_host(hostname: str | None) -> bool:
"""Même règle que la barre v0.1.6 (voir static/js/recherche.js)."""
if not hostname:
return False
h = hostname.lower().split(":")[0]
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 4 and first.endswith("-dev")
def is_prod_host(hostname: str | None) -> bool:
"""Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe."""
if not hostname:
return False
h = hostname.lower().split(":")[0]
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 0 and not first.endswith("-dev")
def origin_of(url: str | None) -> str | None:
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
if not url:
return None
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return None
host = parsed.hostname.lower()
port = f":{parsed.port}" if parsed.port else ""
return f"{parsed.scheme}://{host}{port}"
def sister_origins(resolved: dict | None = None, request_host: str | None = None) -> frozenset:
"""Origines autorisées pour la lecture cross-origin de /api/search.
``resolved`` : dictionnaire rendu par ``draco_urls.toutes()``.
Sans argument, le résolveur est appelé (import paresseux) ; en cas
d'échec, repli sur prod + dev officiels (premier parti uniquement).
``request_host`` ajoute l'ensemble officiel de l'environnement de
la requête (voir le docstring du module).
"""
origins = set()
if resolved is None:
try:
from app.services import draco_urls
resolved = draco_urls.toutes()
except Exception:
resolved = {}
for key in _SOEUR_KEYS:
origin = origin_of((resolved or {}).get(key))
if origin:
origins.add(origin)
# Correctif 2026-10-28 : prod ET dev toujours — ``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
return frozenset(origins)
def cors_headers_for(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
if not origin:
return None
if origin.strip() not in sister_origins(resolved, request_host):
return None
return {
"Access-Control-Allow-Origin": origin.strip(),
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
def preflight_headers(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
headers = cors_headers_for(origin, resolved, request_host)
if headers is None:
return None
headers.update({
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
"Access-Control-Max-Age": "600",
})
return headers
class RechercheCorsMiddleware:
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
headers_in = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope.get("headers", [])
}
origin = headers_in.get("origin", "")
request_host = (
headers_in.get("x-forwarded-host", "") or headers_in.get("host", "")
).split(",")[0].strip()
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
preflight = preflight_headers(origin, request_host=request_host)
if preflight is not None:
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in preflight.items()]
headers_out.append((b"content-length", b"0"))
await send({"type": "http.response.start", "status": 204,
"headers": headers_out})
await send({"type": "http.response.body", "body": b""})
return
if path != SEARCH_PATH:
await self.app(scope, receive, send)
return
async def send_with_cors(message):
if message["type"] == "http.response.start":
cors = cors_headers_for(origin, request_host=request_host)
if cors is not None:
message["headers"] = list(message.get("headers", [])) + [
(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in cors.items()
]
await send(message)
await self.app(scope, receive, send_with_cors)
+43 -1
View File
@@ -2,10 +2,19 @@
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
editor pages and databases scoped to the current user's accessible workspaces.
Recherche partout (fusion-trois-apps #68) : la même route sert la page
unifiée (``GET /recherche``, coquille dont les résultats sont chargés
côté client) et accepte, en plus de la session native, le jeton SSO
``draco_session`` (validateur local ``app.services.draco``). Sans
session ni jeton valide, 401 — avant cette livraison, l'absence de
session donnait une recherche SANS périmètre (``user_id=None`` → ``1=1``
dans ``_scope_where``) : corrigé ici.
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.services.search import search as search_service
@@ -17,6 +26,18 @@ router = APIRouter(tags=["search"])
def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None
if user_id is None:
# Repli SSO draco_session : un jeton valide désigne l'utilisateur
# lié, ou l'unique utilisateur actif (cas mono-utilisateur).
try:
from app.services import draco as draco_service
draco_user = draco_service.user_for_draco(request)
user_id = draco_user.get("id") if draco_user else None
except Exception:
user_id = None
if user_id is None:
raise HTTPException(status_code=401, detail="Authentication required")
data = search_service(q, user_id=user_id)
return {
@@ -25,3 +46,24 @@ def search(request: Request, q: str = Query(default="")):
"collections": data["collections"],
"total": len(data["pages"]) + len(data["collections"]),
}
@router.get("/recherche", response_class=HTMLResponse)
def recherche_page(request: Request, q: str = Query(default="")):
"""Page de résultats unifiée « Rechercher partout » (fusion #68).
Coquille seule : les résultats sont chargés côté client par
static/js/recherche.js (API de recherche de chaque app, en
parallèle, états par application). La palette Ctrl+K native et
``GET /api/search`` restent inchangés par ailleurs.
"""
from app.services import draco_urls
from app.templating import ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
bases = draco_urls.toutes()
template = ENV.get_template("recherche.html")
return HTMLResponse(template.render(
request=request, q=q.strip(), user=user,
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak", "kanbante")},
))
+9 -9
View File
@@ -6,6 +6,9 @@
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). #}
Chantier 5 (fusion #69/#71, sans bump) : 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.
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ asset_version }}">
<header class="draco-bar" data-current="flowdeck">
<div class="draco-bar-zone draco-bar-gauche">
@@ -32,6 +35,10 @@
<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:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
@@ -57,7 +64,6 @@
<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>
@@ -88,14 +94,8 @@
else { var b = document.querySelector(".fd-notif-bell button"); if (b) { b.click(); } }
return;
}
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
/* 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 === "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é. */
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title_prefix %}Rechercher partout{% endblock %}
{% block content %}
<link rel="stylesheet" href="/static/css/recherche.css">
{# Console : pas de data-base-console ici — la route Flowdeck de /recherche
n'expose pas la base agent dans son contexte `bases` ; recherche.js
dérive donc la base console (hôte -dev -> agent-dev, sinon prod) ou
reprend le href d'un onglet de barre console/spark quand il existe.
Les 3 autres hôtes rendent data-base-console explicitement. #}
<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 }}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
{% endblock %}
{% block scripts %}
<script src="/static/js/recherche.js"></script>
{% endblock %}
+19
View File
@@ -37,3 +37,22 @@
.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; }
+16 -9
View File
@@ -3,8 +3,10 @@
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 : menus « ••• » et compte (construits par le JS, mêmes jetons
que le menu des applications) ; l'avatar est un vrai <button>.
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -34,6 +36,7 @@
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
@@ -45,15 +48,15 @@
}
.draco-apps-menu[hidden] { display: none; }
/* 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 {
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
.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;
@@ -174,8 +177,8 @@
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
avatar. Le rail, la recherche et les domaines disparaissent :
chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
@@ -189,5 +192,9 @@
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
}
+90
View File
@@ -0,0 +1,90 @@
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
* autant que possible : la page reste lisible en thème clair et sombre. */
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
.rp-input {
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
}
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
.rp-section { margin-bottom: 28px; }
.rp-head {
display: flex; align-items: baseline; gap: 10px;
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
}
.rp-head h2 { font-size: 1.05rem; margin: 0; }
.rp-count {
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
}
.rp-count:empty { display: none; }
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
.rp-state { opacity: .75; margin: 8px 0; }
.rp-error { opacity: 1; }
.rp-item {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
.rp-title { display: block; font-weight: 600; }
.rp-context { display: block; font-size: .8rem; opacity: .65; }
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
.rp-tabs {
display: flex; gap: 4px; flex-wrap: wrap;
border-bottom: 1px solid #888; margin-bottom: 20px;
}
.rp-tab {
background: none; border: none; border-bottom: 2px solid transparent;
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
color: inherit; opacity: .7; white-space: nowrap;
}
.rp-tab:hover { opacity: 1; }
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
.rp-tab-count {
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
}
.rp-tab-count:empty { display: none; }
.rp-section[hidden] { display: none; }
.rp-all[hidden] { display: none; }
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
* Cause : recherche.html étend base.html sans layout_class, donc
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
* la largeur disponible, sans toucher aux autres pages (elles ne
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
* la grille n'a qu'une colonne : la page la prend entièrement. */
.layout > .rp-page {
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
}
@media (max-width: 760px) {
.layout > .rp-page { grid-column: 1 / -1; }
}
/* ---- ObsiGate : navigation statique de la page autonome (chantier
* 2+3). La page /recherche est servie hors SPA (route avant le
* catch-all) : elle reprend les classes de l'app (style.css :
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
* .content-area) et cette navigation, calée sur les variables de
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
* la SPA (voir le commentaire dans recherche.html). */
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
.rp-og-nav a {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
.rp-og-nav a[aria-current="page"] {
background: rgba(127, 127, 127, .18); font-weight: 700;
}
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
+431 -109
View File
@@ -1,14 +1,25 @@
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
/* 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 (« Connexion requise »)
* avec un lien profond vers sa page native, jamais du vide déguisé.
* 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) :
* 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.
@@ -23,20 +34,55 @@
* 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. La section
* 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] ;
* 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).
* 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).
@@ -50,23 +96,39 @@
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.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/"
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
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;
@@ -98,12 +160,16 @@
}
function fmtDate(s) {
var t = parseDate(s);
if (!t) return s ? String(s).slice(0, 16) : "";
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 }. */
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
@@ -112,28 +178,31 @@
}
var dev = isDevHost(hostname);
var urls = {};
APP_ORDER.forEach(function (app) {
var u = cleanUrl(env.barUrls && env.barUrls[app])
|| cleanUrl(env.configUrls && env.configUrls[app]);
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[app]) href = env.tabHrefs[app];
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[app]) u = cleanUrl(env.dracoUrls[app]);
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[app]) u = cleanUrl(global.DRACO_URLS[app]);
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[app] = withSlash(u);
urls[out] = withSlash(u);
if (key === "console") urls.console = withSlash(u);
});
return urls;
}
@@ -151,9 +220,23 @@
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
tabHrefs: tabs, dracoUrls: global.DRACO_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) || [];
@@ -192,7 +275,48 @@
};
});
}
/* Actives d'abord, puis date décroissante (sans date en dernier). */
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;
@@ -208,9 +332,74 @@
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, badgeTimer = null;
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);
@@ -226,96 +415,204 @@
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
var note = 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.");
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", closePanel);
close.addEventListener("click", function () { closePanel(); });
head.appendChild(close);
box.appendChild(head);
box.appendChild(note);
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") closePanel();
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
function closePanel() { if (panel) panel.style.display = "none"; }
function renderSection(title, items, fallback) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
if (!items.length) {
sec.appendChild(el("p", "draco-alertes-empty", fallback));
return sec;
function closePanel() {
if (!panel) return;
panel.style.display = "none";
if (panelPushed) {
panelPushed = false;
closingViaBack = true;
try { history.back(); } catch (e) { closingViaBack = false; }
return;
}
items.forEach(function (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));
var meta = el("div", "draco-alertes-meta",
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
main.appendChild(meta);
row.appendChild(main);
sec.appendChild(row);
});
return sec;
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 render(results, urls) {
ensurePanel();
listEl.textContent = "";
var all = [];
APP_ORDER.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
APP_ORDER.forEach(function (app) {
var items = actives.filter(function (a) { return a.app === app; });
if (results[app] && results[app].error) {
var sec = renderSection(APP_LABELS[app], [], results[app].error);
listEl.appendChild(sec);
} else if (app === "obsigate") {
listEl.appendChild(renderSection(APP_LABELS[app], [],
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
} else if (items.length) {
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
}
});
if (!actives.length) {
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
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";
}
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
APP_ORDER.forEach(function (app) {
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
if (app === "obsigate") {
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir ObsiGate");
a.href = urls.obsigate;
link.appendChild(a);
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "ObsiGate (0)"));
sec.appendChild(link);
listEl.appendChild(sec);
return;
}
var items = history.filter(function (x) { return x.app === app; });
listEl.appendChild(renderSection(APP_LABELS[app], items,
"Aucun historique disponible pour cette application."));
}
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;
@@ -342,6 +639,7 @@
}
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) }; }),
@@ -350,6 +648,11 @@
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) {
@@ -361,6 +664,7 @@
})).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 };
});
}
@@ -380,28 +684,35 @@
}
function refreshBadge() {
return loadAll().then(function (r) {
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
return countActive(all);
var n = countActive(allItems(r.results));
setBadge(n);
return n;
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel() {
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) {
render(r.results, r.urls);
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
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 () {
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
renderCurrent();
});
}
@@ -410,18 +721,29 @@
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
sortAlerts: sortAlerts, countActive: countActive,
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
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). */
/* 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);
+992
View File
@@ -0,0 +1,992 @@
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
* chantier 2+3 : onglets, « Tout voir », focus).
*
* 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=
* -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q=
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
* collections: [{ title, subtitle, url: "/db/<id>" }] }
* - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
* - Kanbante : GET {base}/api/search?q=
* -> { 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 : AUCUN endpoint de recherche mergé au moment de
* cette livraison (agent-commun : seule la PR #12 est ouverte, autre
* programme — ce dépôt n'est pas touché ici). Le code tente quand
* même l'appel conventionnel GET {base-console}/api/search?q= (même
* convention que les autres hôtes ; chemin À CONFIRMER avec le
* travailleur console dès que son endpoint atterrit) et, en cas
* d'échec, de HTTP non-OK ou de format non reconnu, 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" 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 >
* hôte de la requête > dérivation > défaut officiel) ;
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
* 4. href des onglets .draco-apptab rendus ;
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
*
* Authentification cross-origin : les appels sœurs partent avec
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68).
* 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.
* Le correctif CORS lui-même est chez un autre travailleur.
*/
(function (global) {
"use strict";
/* globalThis : le fichier est chargé comme script classique dans les
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
* et importé en ESM par le test ObsiGate (frontend/js est "type":
* "module") — où `this` de niveau supérieur vaut undefined. */
var PROD = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
};
var DEV = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
};
var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
var CONSOLE_SEARCH_PATH = "api/search";
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 TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
qa: "QA",
review: "Relecture",
todo: "À faire",
done: "Terminé",
};
var TIMEOUT_MS = 8000;
var DEBOUNCE_MS = 250;
var PER_APP_LIMIT = 8;
var FULL_LIMIT = 100;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanUrl(u) {
if (!u) return null;
u = String(u).trim();
if (!u || u.indexOf("{{") !== -1) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) === "/" ? u : u + "/";
}
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
function resolveBases(opts) {
opts = opts || {};
var dev = isDevHost(opts.host);
var out = {};
APPS.forEach(function (app) {
var key = app.key;
var u = cleanUrl(opts.explicit && opts.explicit[key]);
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
if (!u && dev) u = DEV[key];
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
if (!u) u = PROD[key];
out[key] = withSlash(u);
});
return out;
}
/* 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)
);
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) {
if (app === "obsigate") {
return (
"api/search?q=" +
encodeURIComponent(q) +
"&vault=all&limit=" +
(full ? FULL_LIMIT : PER_APP_LIMIT)
);
}
return "api/search?q=" + encodeURIComponent(q);
}
function snippetOf(s, n) {
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
}
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
* base : base résolue de l'app (avec / final). q : requête (liens TikTak).
* 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) {
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 {
title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""),
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
url:
base +
"#file=" +
encodeURIComponent(r.vault || "") +
":" +
encodeURIComponent(r.path || ""),
};
});
}
if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []);
return cut(items).map(function (r) {
var rel = String(r.url || "").replace(/^\//, "");
return {
title: r.title || "Sans titre",
snippet: snippetOf(r.excerpt || ""),
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
url: base + rel,
};
});
}
if (app === "tiktak") {
return cut(data.tasks || []).map(function (t) {
var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return {
title: t.title || "Sans titre",
snippet: snippetOf(t.description || ""),
context: "Tâche · " + ctx,
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
};
});
}
if (app === "kanbante") {
var rows = [];
(data.groups || []).forEach(function (g) {
(g.rows || []).forEach(function (row) {
rows.push({ group: g.full_name, row: row });
});
});
return cut(rows).map(function (x) {
var issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return {
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
snippet: snippetOf(issue.body || ""),
context: x.group + (col ? " · " + col : ""),
url: base + "board/" + x.group,
};
});
}
return [];
}
/* Normalisation console, défensive : accepte results/discussions/
* items. Un élément sans URL exploitable (http(s), /relatif, #ancre)
* garde url=null et sera rendu en <span>, pas en faux lien profond.
* Retourne { recognized, items } : recognized=false -> l'appelant
* affiche « indisponible », jamais un vide mensonger. */
function normalizeConsole(data, base) {
data = data || {};
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 items = 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: items };
}
/* Recherche native de l'app (repli quand la section est en erreur). */
function nativeSearchUrl(app, base, q) {
if (app === "tiktak" || app === "kanbante") {
return base + "search?q=" + encodeURIComponent(q || "");
}
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
return base;
}
function nativeSearchLabel(app) {
if (app === "tiktak" || app === "kanbante") {
return "Ouvrir la recherche dans " + appLabel(app);
}
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
}
function appLabel(key) {
if (key === "console") return CONSOLE_APP.label;
for (var i = 0; i < APPS.length; i++) {
if (APPS[i].key === key) return APPS[i].label;
}
return key;
}
var api = {
esc: esc,
isDevHost: isDevHost,
resolveBases: resolveBases,
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,
PER_APP_LIMIT: PER_APP_LIMIT,
FULL_LIMIT: FULL_LIMIT,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
global.RecherchePartout = api;
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
if (typeof document === "undefined") return;
function fetchSection(app, url, onDone) {
var ctrl = null;
var timer = null;
try {
ctrl = new AbortController();
timer = setTimeout(function () {
ctrl.abort();
}, TIMEOUT_MS);
} catch (e) {
ctrl = null;
}
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
fetch(url, opts)
.then(function (resp) {
if (!resp.ok) {
var err = new Error("HTTP " + resp.status);
err.status = resp.status;
throw err;
}
return resp.json();
})
.then(function (data) {
if (timer) clearTimeout(timer);
onDone(null, data);
})
.catch(function (err) {
if (timer) clearTimeout(timer);
onDone(err, null);
});
}
function renderSection(section, state, 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. */
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);
}
if (state.status === "loading") {
count.textContent = "…";
body.innerHTML = '<p class="rp-state">Recherche…</p>';
return;
}
if (state.status === "error") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state rp-error">' +
esc(state.message) +
' — <a href="' +
esc(nativeSearchUrl(state.app, state.base, state.q)) +
'">' +
esc(nativeSearchLabel(state.app)) +
"</a></p>";
return;
}
if (state.status === "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";
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
.map(function (item) {
var inner =
'<span class="rp-title">' +
esc(item.title) +
"</span>" +
(item.context
? '<span class="rp-context">' + esc(item.context) + "</span>"
: "") +
(item.snippet
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
: "");
if (item.url) {
return (
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
);
}
return '<span class="rp-item">' + inner + "</span>";
})
.join("");
}
function init() {
var root = document.getElementById("recherche");
if (!root) return;
var input = document.getElementById("rp-q");
if (!input) return;
var hostApp = root.getAttribute("data-host") || "";
var explicit = {};
APPS.forEach(function (app) {
var v = root.getAttribute("data-base-" + app.key);
if (v) explicit[app.key] = v;
});
var barUrls = {};
try {
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
} catch (e) {}
var tabHrefs = {};
try {
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
var href = tab.getAttribute("href");
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
});
} catch (e) {}
var bases = resolveBases({
host: global.location.hostname,
explicit: explicit,
barUrls: barUrls,
tabHrefs: tabHrefs,
});
var 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,
};
});
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);
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) 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];
if (!sec) return;
states[key] = {
status: "idle",
items: [],
message: "",
q: "",
base: key === "console" ? consoleBase : bases[key],
app: key,
};
renderSection(sec, states[key], activeTab === 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);
var url;
if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q, true);
} else {
url = bases[app.key] + searchPath(app.key, q, true);
}
fetchSection(app.key, url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
else if (err.status === 401 || err.status === 403)
msg = "Connexion requise sur " + app.label;
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
states[app.key] = {
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,
};
}
renderSection(sec, states[app.key], activeTab === app.key);
renderTabs();
});
});
/* 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) {
if (debounceTimer) clearTimeout(debounceTimer);
if (immediate) {
runSearch(q);
return;
}
debounceTimer = setTimeout(function () {
runSearch(q);
}, DEBOUNCE_MS);
}
function syncUrl(q) {
try {
global.history.replaceState(
null,
"",
global.location.pathname + buildSearchUrl(q, activeTab)
);
} 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);
scheduleSearch(q, false);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var first = visibleLinks()[0];
if (first) {
e.preventDefault();
first.click();
}
} else if (e.key === "Escape") {
input.value = "";
syncUrl("");
scheduleSearch("", true);
input.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
});
document.addEventListener(
"keydown",
function (e) {
var target = e.target;
var typing =
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable);
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
e.preventDefault();
e.stopPropagation();
input.focus();
input.select();
return;
}
/* 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);
}
},
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();
if (!links.length) return;
var active = document.activeElement;
var idx = links.indexOf(active);
var next;
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
else next = (idx + delta + links.length) % links.length;
e.preventDefault();
links[next].focus();
}
var params = new URLSearchParams(global.location.search);
var initialQ = (params.get("q") || "").trim();
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 (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: this
);
+152 -97
View File
@@ -4,7 +4,11 @@
* 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 « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux »).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -47,29 +51,76 @@
* 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).
* 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.
* La clé spark ne correspond à aucun onglet de la barre : elle est
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets ;
* ouvre le menu des applications, construit depuis les onglets, plus
* (depuis le correctif #70) une entrée « Console Spark » vers la
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
* 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é.
*
* 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".
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
* son menu par défaut (console agent + applications) sont supprimés
* du fragment, du JS et du CSS. La console agent reste joignable par
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
* onglets présents dans la barre, puis une entrée « Console Spark »
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
* 4) défaut officiel prod https://agent.dracodev.net/.
* AUCUN hôte spark-dev n'est inventé :
* la clé spark, elle, suit toujours son propre contrat (explicite >
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
* addGlobalListener), PAS via document.addEventListener. Cause
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
* remplace document/window.addEventListener par un registre
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
* compris ceux de draco-barre.js (script defer, enregistré après le
* tcInit() initial). La barre survit à la navigation (hors .layout
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
* la marque, lien href="/", est interceptée par l'intercepteur SPA
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
* une navigation interne au lieu de basculer le menu. Les écouteurs
* posés sur le prototype ne figurent pas dans le registre et ne sont
* donc jamais supprimés par le teardown ; en complément, le gabarit
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
* la marque ne déclenche plus de navigation douce. Les autres hôtes
* (SSR, rechargement complet) ne sont pas concernés par le teardown
* mais bénéficient du même code ; repli : si EventTarget.prototype
* est indisponible (mini-DOM de tests), document.addEventListener
* classique est utilisé.
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
@@ -223,7 +274,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
@@ -231,9 +282,11 @@
* -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, moreMenu = null, compteMenu = null;
var appsMenu = null, compteMenu = null;
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
* lui rend le focus à la fermeture (correctif #70). */
var lastOpener = null;
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
@@ -250,10 +303,13 @@
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups() {
function closeAllPopups(returnFocus) {
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
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 + '"]');
@@ -265,28 +321,6 @@
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
@@ -300,6 +334,11 @@
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");
@@ -326,6 +365,37 @@
if (badge) a.appendChild(badge.cloneNode(true));
appsMenu.appendChild(a);
});
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
appsMenu.appendChild(sep);
var sparkItem = document.createElement("a");
sparkItem.className = "draco-apps-item";
sparkItem.href = resolvedUrls.agent || "#";
sparkItem.dataset.app = "agent";
sparkItem.setAttribute("role", "menuitem");
var sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#c2437e");
sparkIco.textContent = "✦";
sparkItem.appendChild(sparkIco);
var sparkTxt = document.createElement("span");
sparkTxt.className = "draco-apps-item-txt";
var sparkNom = document.createElement("span");
sparkNom.className = "draco-apps-item-nom";
sparkNom.textContent = "Console Spark";
sparkTxt.appendChild(sparkNom);
var sparkRole = document.createElement("span");
sparkRole.className = "draco-apps-item-role";
sparkRole.textContent = "Agent commun";
sparkTxt.appendChild(sparkRole);
sparkItem.appendChild(sparkTxt);
appsMenu.appendChild(sparkItem);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
@@ -333,34 +403,7 @@
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
}
/* 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);
if (!appsMenu.hidden) lastOpener = brand;
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
@@ -399,6 +442,7 @@
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
@@ -408,7 +452,21 @@
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
document.addEventListener("click", function (ev) {
/* Écouteurs globaux posés sur le prototype EventTarget quand il
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
* voir l'en-tête) ; sinon document.addEventListener classique. */
function addGlobalListener(type, fn) {
try {
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
EventTarget.prototype.addEventListener) {
EventTarget.prototype.addEventListener.call(document, type, fn, false);
return;
}
} catch (e) { /* repli ci-dessous */ }
document.addEventListener(type, fn);
}
addGlobalListener("click", function (ev) {
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
@@ -419,38 +477,21 @@
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-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups();
ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups(false);
}
if (!el) return;
var act = el.dataset.act;
@@ -458,8 +499,23 @@
relay(act, el, ev);
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeAllPopups();
addGlobalListener("keydown", function (ev) {
if (ev.key === "Escape") { closeAllPopups(true); return; }
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
* ouverture explicite + focus sur le premier item du menu (#70).
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
* ci-dessus (pas de double bascule ici). */
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
var brandEl = ev.target.closest('[data-act="apps"]');
if (brandEl) {
if (ev.preventDefault) ev.preventDefault();
toggleAppsMenu();
if (appsMenu && !appsMenu.hidden) {
var first = appsMenu.querySelector(".draco-apps-item");
if (first && typeof first.focus === "function") first.focus();
}
}
}
});
/* API publique : l'app met les compteurs à jour sans recharger.
@@ -481,7 +537,6 @@
applyUrls(resolvedUrls);
buildAppsMenu();
buildMoreMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
+157 -5
View File
@@ -1,18 +1,61 @@
// 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).
// /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");
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
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/" },
@@ -20,9 +63,10 @@ test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
const href = t.resolveUrls({
hostname: "example.org",
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/" },
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);
@@ -34,8 +78,7 @@ test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
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,
], unread: 1,
});
assert.equal(items.length, 2);
assert.equal(items[0].active, true);
@@ -82,3 +125,112 @@ test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
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");
});
+150
View File
@@ -0,0 +1,150 @@
/* Tests Node sans navigateur de recherche.js (logique pure).
* Usage : node recherche.test.js — sortie 0 si tout passe. */
"use strict";
const assert = require("node:assert/strict");
const rp = require("../static/js/recherche.js");
let n = 0;
function ok(cond, label) {
assert.ok(cond, label);
n += 1;
}
/* esc */
ok(rp.esc('<a href="x">&') === "&lt;a href=&quot;x&quot;&gt;&amp;", "esc échappe HTML");
/* isDevHost : même règle que la barre v0.1.6 */
ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu");
ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev");
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée");
ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse");
/* resolveBases : ordre explicite > barre > dev > href > prod */
let b = rp.resolveBases({
host: "tiktak-dev.dracodev.net",
explicit: { obsigate: "https://og-perso.example/" },
barUrls: { flowdeck: "https://flowdeck-barre.example/" },
tabHrefs: { kanbante: "https://kanbante-href.example/" },
});
ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire");
ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev");
ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant");
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } });
ok(b.kanbante === "https://kanbante-href.example/", "href en prod");
ok(b.obsigate === "https://og.dracodev.net/", "défaut prod");
b = rp.resolveBases({});
ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte");
/* searchPath */
ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate");
ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak");
/* normalize ObsiGate */
let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée");
ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate");
ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate");
/* normalize Flowdeck */
items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p");
ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck");
ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck");
/* normalize TikTak */
items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire");
ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak");
ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak");
/* normalize Kanbante */
items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug");
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
/* nativeSearchUrl */
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
/* ---- 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");
console.log("recherche.test.js : " + n + " assertions OK");
+28 -1
View File
@@ -1,4 +1,4 @@
"""Fusion #69 — Alertes unifiées (chantier 2, Flowdeck).
"""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
@@ -61,3 +61,30 @@ def test_notifications_api_et_cors_soeur(client):
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
+88
View File
@@ -0,0 +1,88 @@
"""Tests de la recherche fédérée Flowdeck (fusion #68).
Fonctions pures de app/middleware/recherche_cors.py uniquement :
aucune base, aucun serveur.
"""
from app.middleware.recherche_cors import (
cors_headers_for,
is_dev_host,
is_prod_host,
origin_of,
preflight_headers,
sister_origins,
)
RESOLVED_DEV = {
"obsigate": "https://og-dev.dracodev.net/",
"flowdeck": "https://flowdeck-dev.dracodev.net",
"tiktak": "https://tiktak-dev.dracodev.net/",
"kanbante": "https://kanbante-dev.dracodev.net/",
"agent": "https://agent-dev.dracodev.net/",
"gitea": "https://git.dracodev.net/",
}
def test_is_dev_host():
assert is_dev_host("flowdeck-dev.dracodev.net") is True
assert is_dev_host("flowdeck.dracodev.net") is False
assert is_dev_host("x-dev.dracodev.net.evil") is False
assert is_dev_host("flowdeck-dev.dracodev.net:8443") is True
assert is_prod_host("flowdeck.dracodev.net") is True
assert is_prod_host("flowdeck-dev.dracodev.net") is False
def test_origin_of():
assert origin_of("https://og-dev.dracodev.net/") == "https://og-dev.dracodev.net"
assert origin_of("{{URL_OBSIGATE}}") is None
def test_sister_origins_dev():
# Correctif 2026-10-28 : 8 origines officielles, 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",
}
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.
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(
{}, request_host="flowdeck.dracodev.net"
)
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="flowdeck-dev.dracodev.net"
)
def test_cors_headers():
headers = cors_headers_for("https://og-dev.dracodev.net", RESOLVED_DEV)
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
def test_preflight_headers():
headers = preflight_headers("https://kanbante-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None