Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984e92b593 | ||
|
|
8ba38a69a4 | ||
|
|
ee477bf2d1 | ||
|
|
ba5f8c8eab | ||
|
|
d4fef9c1bf | ||
|
|
f708fa77e2 | ||
|
|
7711bd3af7 | ||
|
|
b4923493ac | ||
|
|
0ead9fea4a | ||
|
|
8b871ee39f | ||
|
|
171fdf063b | ||
|
|
ea12938e7c | ||
|
|
3f14dc7130 | ||
|
|
71454f94d1 | ||
|
|
3145cea8e1 | ||
|
|
f49eb62172 | ||
|
|
746ab4c0e1 | ||
|
|
cdf943aff5 | ||
|
|
751daa43dd | ||
|
|
3895e79167 | ||
|
|
8dcddfe34f | ||
|
|
b506a447c5 | ||
|
|
9ca831f816 | ||
|
|
c09a0ac51d | ||
|
|
2d3a93511b | ||
|
|
dd979f81a7 | ||
|
|
c2df8d8c00 | ||
|
|
a930c8192e | ||
|
|
021d0a93f3 | ||
|
|
40571049d6 | ||
|
|
6eb7b78534 | ||
|
|
31a84539f2 | ||
|
|
7250ebc78f | ||
|
|
167e93e295 | ||
|
|
30edf2f0c0 | ||
|
|
67718d1d3c | ||
|
|
1dd148532b | ||
|
|
bd9ecf69e1 | ||
|
|
4b1df2d2b9 | ||
|
|
fe389d46bc | ||
|
|
eef46891de | ||
|
|
7f9eb080b1 | ||
|
|
9abd6c9d4d | ||
|
|
fbe9151ab7 | ||
|
|
ab63000f5d | ||
|
|
52b62b48b8 | ||
|
|
acaae92e3a | ||
|
|
1065ffb017 |
@@ -1,3 +1,19 @@
|
|||||||
|
## v7.84.4 (2026-10-28) — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
|
||||||
|
|
||||||
|
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte.
|
||||||
|
|
||||||
|
## v7.84.3 (2026-10-28) — Corrections recherche/alertes : CORS prod+dev, recherche en onglets, alertes en onglets
|
||||||
|
|
||||||
|
- **CORS recherche/alertes (#68/#69)** : allowlist d'origines soeurs prod+dev explicites + credentials, jamais `*` — chaque serveur n'autorisait que l'environnement resolu, d'ou les echecs inter-apps constates.
|
||||||
|
- **Recherche partout (#68)** : recherche pleine largeur, reintegree dans le gabarit, focus auto, « Tout voir » restant sur place, onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark.
|
||||||
|
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark/Historique, actives sauf Historique = fermees), filtre texte, composant partage vendange byte-identique ; bouton Console Spark en 5e onglet de la barre apres Kanbante.
|
||||||
|
|
||||||
|
## v7.84.2 (2026-10-28) — Programme Fusion : menu barre, alertes unifiées, recherche partout
|
||||||
|
|
||||||
|
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
|
||||||
|
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CSP/CORS bornées à `/api/notifications`.
|
||||||
|
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), draco + 401 sur `/api/search` (corrige la recherche sans périmètre sans session) ; la palette de la barre ouvre désormais `/recherche`.
|
||||||
|
|
||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
## v7.84.1 (2026-10-28) — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
|
## v7.84.1 (2026-10-28) — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
|
||||||
|
|||||||
+31
-1
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
|||||||
|
|
||||||
|
|
||||||
# Littéral de version synchronisé avec le fichier VERSION :
|
# Littéral de version synchronisé avec le fichier VERSION :
|
||||||
# version="7.84.1"
|
# version="7.84.3"
|
||||||
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
|
# 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
|
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||||
@@ -239,6 +239,29 @@ app.add_middleware(RateLimitMiddleware)
|
|||||||
_CORS_ORIGINS = sorted(
|
_CORS_ORIGINS = sorted(
|
||||||
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
|
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
|
||||||
)
|
)
|
||||||
|
# Fusion #69 : le panneau Alertes unifiées lit /api/notifications
|
||||||
|
# depuis les hôtes soeurs, avec credentials — leurs origines doivent
|
||||||
|
# donc figurer dans allow_origins (résolution draco_urls : explicite
|
||||||
|
# > hôte de la requête > dérivation > défaut officiel ; hors requête,
|
||||||
|
# la configuration seule). Lecture seule (GET) ; les POST restent
|
||||||
|
# soumis au CSRFMiddleware.
|
||||||
|
from app.services import draco_urls as _draco_urls69
|
||||||
|
_toutes69 = _draco_urls69.toutes()
|
||||||
|
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | {
|
||||||
|
_draco_urls69.origine(_toutes69[k])
|
||||||
|
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||||
|
} - {""})
|
||||||
|
# Correctif CORS alertes (fusion #69, 2026-10-28) : allow_origins
|
||||||
|
# est figé à l'import (configuration seule, sans hôte de requête)
|
||||||
|
# — il ne doit donc pas dépendre de l'environnement résolu. Les
|
||||||
|
# origines sœurs prod ET dev sont listées explicitement (module
|
||||||
|
# CORS de la recherche, jamais "*") : c'est ce qui manquait à
|
||||||
|
# /api/notifications lu depuis les autres hôtes.
|
||||||
|
from app.middleware.recherche_cors import DEV_ORIGINS as _DEV_ORIGINS
|
||||||
|
from app.middleware.recherche_cors import PROD_ORIGINS as _PROD_ORIGINS
|
||||||
|
|
||||||
|
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | set(_DEV_ORIGINS)
|
||||||
|
| set(_PROD_ORIGINS))
|
||||||
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
|
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
|
||||||
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -250,6 +273,13 @@ app.add_middleware(
|
|||||||
allow_credentials=True,
|
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(auth.router)
|
||||||
app.include_router(sso_router)
|
app.include_router(sso_router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -139,7 +139,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
# l'agent connu en config ; défaut = nom officiel).
|
# l'agent connu en config ; défaut = nom officiel).
|
||||||
# Même origine pour frame-src : la console du service est
|
# Même origine pour frame-src : la console du service est
|
||||||
# encadrée en iframe par le panneau (surimpression v3).
|
# encadrée en iframe par le panneau (surimpression v3).
|
||||||
"connect-src 'self' {agent} ws://{host} wss://{host}; "
|
"connect-src 'self' {agent} {sisters} ws://{host} wss://{host}; "
|
||||||
"media-src 'self' blob:; "
|
"media-src 'self' blob:; "
|
||||||
"frame-src 'self' {agent}; "
|
"frame-src 'self' {agent}; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
@@ -174,8 +174,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
request.headers.get("host", ""))
|
request.headers.get("host", ""))
|
||||||
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
|
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
|
||||||
draco_urls.origine(draco_urls.agent_url()))
|
draco_urls.origine(draco_urls.agent_url()))
|
||||||
|
# Fusion #69 : le panneau Alertes unifiées lit les API
|
||||||
|
# notifications des applications soeurs depuis cet hôte ;
|
||||||
|
# leurs origines rejoignent connect-src (même résolution
|
||||||
|
# draco_urls que les liens de la barre et frame-src).
|
||||||
|
_toutes69 = draco_urls.toutes()
|
||||||
|
sisters = " ".join(sorted({
|
||||||
|
draco_urls.origine(_toutes69[k])
|
||||||
|
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||||
|
} - {""}))
|
||||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
||||||
nonce=nonce, host=host, agent=agent
|
nonce=nonce, host=host, agent=agent, sisters=sisters
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
|
||||||
editor pages and databases scoped to the current user's accessible workspaces.
|
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 __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.auth.session import SessionManager
|
||||||
from app.services.search import search as search_service
|
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="")):
|
def search(request: Request, q: str = Query(default="")):
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
user_id = user.get("id") if user and user.get("id") else None
|
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)
|
data = search_service(q, user_id=user_id)
|
||||||
return {
|
return {
|
||||||
@@ -25,3 +46,24 @@ def search(request: Request, q: str = Query(default="")):
|
|||||||
"collections": data["collections"],
|
"collections": data["collections"],
|
||||||
"total": len(data["pages"]) + len(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")},
|
||||||
|
))
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
v7.82.0 (dev/prod) : les href des onglets ne sont plus figés —
|
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
|
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). #}
|
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">
|
<header class="draco-bar" data-current="flowdeck">
|
||||||
<div class="draco-bar-zone draco-bar-gauche">
|
<div class="draco-bar-zone draco-bar-gauche">
|
||||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||||
@@ -31,6 +35,10 @@
|
|||||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="draco-bar-zone draco-bar-centre">
|
<div class="draco-bar-zone draco-bar-centre">
|
||||||
@@ -63,9 +71,10 @@
|
|||||||
<script nonce="{{ csp_nonce() }}">
|
<script nonce="{{ csp_nonce() }}">
|
||||||
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
|
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
|
||||||
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||||
le rail pilote le hamburger natif, les alertes ouvrent la cloche
|
le rail pilote le hamburger natif, les alertes ouvrent le
|
||||||
native de la page ; la recherche fédérée et Spark arrivent avec
|
panneau draco-alertes.js (Fusion #69, agrégation à la demande,
|
||||||
les phases suivantes du programme Fusion. */
|
pas une fédération temps réel ; la cloche native reste le
|
||||||
|
repli si le module ne charge pas). */
|
||||||
window.DRACO_BAR_CONFIG = {
|
window.DRACO_BAR_CONFIG = {
|
||||||
current: "flowdeck",
|
current: "flowdeck",
|
||||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||||
@@ -81,11 +90,11 @@
|
|||||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||||
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
|
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
|
||||||
if (act === "alertes") {
|
if (act === "alertes") {
|
||||||
var b = document.querySelector(".fd-notif-bell button");
|
if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); }
|
||||||
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
|
else { var b = document.querySelector(".fd-notif-bell button"); if (b) { b.click(); } }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
|
if (act === "palette") { location.href = "/recherche"; return; }
|
||||||
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). Nonce CSP du script inchangé. */
|
/* 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 (act === "spark") {
|
||||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||||
@@ -100,3 +109,4 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/js/draco-alertes.js?v={{ asset_version }}"></script>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
|
||||||
|
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
|
||||||
|
.draco-alertes-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 30000;
|
||||||
|
display: none; align-items: flex-start; justify-content: center;
|
||||||
|
padding: 8vh 16px 16px;
|
||||||
|
background: rgb(0 0 0 / .55); overflow-y: auto;
|
||||||
|
}
|
||||||
|
.draco-alertes-box {
|
||||||
|
width: 720px; max-width: 100%;
|
||||||
|
background: var(--bg-primary, #1f1f1f);
|
||||||
|
color: var(--text, #eceee9);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
|
||||||
|
.draco-alertes-close {
|
||||||
|
border: 1px solid var(--border, #333); background: transparent;
|
||||||
|
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
|
||||||
|
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
|
||||||
|
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
|
||||||
|
.draco-alertes-item {
|
||||||
|
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
|
||||||
|
padding: 9px 12px; margin: 6px 0;
|
||||||
|
}
|
||||||
|
.draco-alertes-item.is-active { border-color: #e8762c; }
|
||||||
|
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
|
||||||
|
.draco-alertes-title:hover { text-decoration: underline; }
|
||||||
|
.draco-alertes-detail {
|
||||||
|
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||||
|
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||||
|
.draco-alertes-empty a { color: inherit; }
|
||||||
|
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
|
||||||
|
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
|
||||||
|
.draco-alertes-tab {
|
||||||
|
border: 1px solid var(--border, #333); background: transparent;
|
||||||
|
color: inherit; border-radius: 999px; padding: 5px 11px;
|
||||||
|
cursor: pointer; font-size: 13px; line-height: 1.3;
|
||||||
|
}
|
||||||
|
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
|
||||||
|
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
|
||||||
|
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
|
||||||
|
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
|
||||||
|
.draco-alertes-filtre input {
|
||||||
|
flex: 1; min-width: 0; background: transparent; color: inherit;
|
||||||
|
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
|
||||||
|
.draco-alertes-compte:empty { display: none; }
|
||||||
|
.draco-alertes-item.is-error { border-color: #c2437e; }
|
||||||
@@ -192,5 +192,9 @@
|
|||||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
|
||||||
|
/* Chantier 5 : sur la console elle-même (data-current="agent",
|
||||||
|
programme console #71), c'est l'onglet Console Spark qui reste
|
||||||
|
visible en mobile — les 4 hôtes gardent leur propre courant. */
|
||||||
|
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||||
|
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||||
|
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||||
|
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||||
|
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||||
|
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||||
|
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||||
|
.rp-input {
|
||||||
|
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||||
|
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||||
|
.rp-section { margin-bottom: 28px; }
|
||||||
|
.rp-head {
|
||||||
|
display: flex; align-items: baseline; gap: 10px;
|
||||||
|
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||||
|
.rp-count {
|
||||||
|
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||||
|
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||||
|
}
|
||||||
|
.rp-count:empty { display: none; }
|
||||||
|
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||||
|
.rp-state { opacity: .75; margin: 8px 0; }
|
||||||
|
.rp-error { opacity: 1; }
|
||||||
|
.rp-item {
|
||||||
|
display: block; padding: 8px 10px; border-radius: 8px;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
}
|
||||||
|
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||||
|
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||||
|
.rp-title { display: block; font-weight: 600; }
|
||||||
|
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||||
|
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||||
|
.rp-tabs {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid #888; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.rp-tab {
|
||||||
|
background: none; border: none; border-bottom: 2px solid transparent;
|
||||||
|
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
|
||||||
|
color: inherit; opacity: .7; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rp-tab:hover { opacity: 1; }
|
||||||
|
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
|
||||||
|
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||||
|
.rp-tab-count {
|
||||||
|
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
|
||||||
|
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
||||||
|
}
|
||||||
|
.rp-tab-count:empty { display: none; }
|
||||||
|
.rp-section[hidden] { display: none; }
|
||||||
|
.rp-all[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
|
||||||
|
* Cause : recherche.html étend base.html sans layout_class, donc
|
||||||
|
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
|
||||||
|
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
|
||||||
|
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
|
||||||
|
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
|
||||||
|
* la largeur disponible, sans toucher aux autres pages (elles ne
|
||||||
|
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
|
||||||
|
* la grille n'a qu'une colonne : la page la prend entièrement. */
|
||||||
|
.layout > .rp-page {
|
||||||
|
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.layout > .rp-page { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- ObsiGate : navigation statique de la page autonome (chantier
|
||||||
|
* 2+3). La page /recherche est servie hors SPA (route avant le
|
||||||
|
* catch-all) : elle reprend les classes de l'app (style.css :
|
||||||
|
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
|
||||||
|
* .content-area) et cette navigation, calée sur les variables de
|
||||||
|
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
|
||||||
|
* la SPA (voir le commentaire dans recherche.html). */
|
||||||
|
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
|
||||||
|
.rp-og-nav a {
|
||||||
|
display: block; padding: 8px 10px; border-radius: 8px;
|
||||||
|
text-decoration: none; color: inherit;
|
||||||
|
}
|
||||||
|
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
|
||||||
|
.rp-og-nav a[aria-current="page"] {
|
||||||
|
background: rgba(127, 127, 127, .18); font-weight: 700;
|
||||||
|
}
|
||||||
|
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
|
||||||
|
* (onglets + composant partagé + onglet Console Spark).
|
||||||
|
*
|
||||||
|
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
|
||||||
|
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
|
||||||
|
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
|
||||||
|
* place, toute évolution passe par commons puis re-vendoring.
|
||||||
|
*
|
||||||
|
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
|
||||||
|
* ouverture du panneau (et au chargement, pour la pastille), le module
|
||||||
|
* interroge les API de notifications réellement exposées par chaque
|
||||||
|
* application, avec les identifiants du navigateur (credentials:
|
||||||
|
* "include"). Aucune alerte n'est inventée : une source injoignable
|
||||||
|
* ou non authentifiée affiche son état réel par section (une section
|
||||||
|
* en échec n'empêche jamais de consulter les autres), jamais du vide
|
||||||
|
* déguisé. En attendant le correctif CORS des allowlists (autre
|
||||||
|
* chantier), une section inter-origines peut afficher cet état
|
||||||
|
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
|
||||||
|
* normal — aucun état « Injoignable » systématique n'est présenté
|
||||||
|
* comme normal.
|
||||||
|
*
|
||||||
|
* Sources réelles (audit #69, 2026-10-28, inchangé) :
|
||||||
|
* - Flowdeck : GET {base}/api/notifications?limit=50
|
||||||
|
* -> { notifications: [{id, ntype, title, message, url, is_read,
|
||||||
|
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
|
||||||
|
* - TikTak : GET {base}/api/notifications
|
||||||
|
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
|
||||||
|
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
|
||||||
|
* base + "/notifications".
|
||||||
|
* - Kanbante : GET {base}/api/events?limit=100
|
||||||
|
* -> { events: [{repo_full_name, issue_number, event_type,
|
||||||
|
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
|
||||||
|
* n'existe dans Kanbante, donc tout y est historique et rien n'y
|
||||||
|
* est compté comme actif ; lien = base + "/board/" + repo.
|
||||||
|
* - ObsiGate : AUCUNE notification entrante consultable. Ses
|
||||||
|
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
|
||||||
|
* masqués), pas un journal lisible par l'utilisateur. L'onglet
|
||||||
|
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||||
|
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
|
||||||
|
* sur agent-commun main au moment de ce chantier (autre chantier
|
||||||
|
* en cours). L'onglet Console Spark affiche donc un état vide
|
||||||
|
* HONNÊTE (« source indisponible », lien vers la console) et ne
|
||||||
|
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
|
||||||
|
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
|
||||||
|
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
|
||||||
|
* l'autre chantier ; forme attendue : tableau, ou
|
||||||
|
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
|
||||||
|
* detail|message|context, date|created_at, active (booléen) ou
|
||||||
|
* status/state (pause budget, accès requis, en attente, fermée…),
|
||||||
|
* url|link. Les missions en pause budget / accès requis et les
|
||||||
|
* approbations en attente y apparaîtront avec leurs états réels.
|
||||||
|
*
|
||||||
|
* Résolution des bases (même ordre que barre-apps v0.1.6) :
|
||||||
|
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
|
||||||
|
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
|
||||||
|
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
|
||||||
|
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
|
||||||
|
* « Console Spark » ajouté par le chantier 5 porte data-app
|
||||||
|
* "agent", donc la base agent suit le même ordre) ;
|
||||||
|
* 4. défaut officiel (production).
|
||||||
|
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
|
||||||
|
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
|
||||||
|
* incluse).
|
||||||
|
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
|
||||||
|
* que sur un hôte -dev, exactement comme la barre.
|
||||||
|
*
|
||||||
|
* Présentation (identique partout, imposée par ce seul fichier) :
|
||||||
|
* champs par alerte = application, titre, date, état, lien profond
|
||||||
|
* (environnement -dev respecté via la résolution ci-dessus) ;
|
||||||
|
* ordre = date décroissante dans chaque onglet (sans date en
|
||||||
|
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
|
||||||
|
* si absente ou illisible ; libellés d'état affichés = « Active »,
|
||||||
|
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
|
||||||
|
* ou l'état réel console quand la source existe. Le type brut
|
||||||
|
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
|
||||||
|
* libellé d'état en anglais.
|
||||||
|
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||||
|
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
|
||||||
|
* par application ne montrent QUE les alertes actives ; Historique
|
||||||
|
* ne montre QUE les alertes fermées (lues / événements Kanbante /
|
||||||
|
* console fermées). Le filtre texte (titre + contexte) s'applique
|
||||||
|
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
|
||||||
|
* actifs et historique. L'onglet courant est dans l'URL
|
||||||
|
* (#alertes=<onglet>, partageable, retour arrière via pushState).
|
||||||
|
*
|
||||||
|
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
|
||||||
|
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||||
|
* pour la pastille (repli : mise à jour directe du badge).
|
||||||
|
* Insertion des données : textContent uniquement, jamais innerHTML
|
||||||
|
* avec une valeur venue du serveur.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var DEFAULT_URLS = {
|
||||||
|
obsigate: "https://og.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante.dracodev.net/",
|
||||||
|
agent: "https://agent.dracodev.net/"
|
||||||
|
};
|
||||||
|
var DEV_URLS = {
|
||||||
|
obsigate: "https://og-dev.dracodev.net/",
|
||||||
|
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||||
|
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||||
|
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||||
|
agent: "https://agent-dev.dracodev.net/"
|
||||||
|
};
|
||||||
|
var APP_LABELS = {
|
||||||
|
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||||
|
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
|
||||||
|
};
|
||||||
|
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||||
|
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||||
|
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
|
||||||
|
var TAB_LABELS = {
|
||||||
|
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||||
|
tiktak: "TikTak", kanbante: "Kanbante",
|
||||||
|
console: "Console Spark", historique: "Historique"
|
||||||
|
};
|
||||||
|
var EVENT_LABELS = {
|
||||||
|
created: "créée", moved: "déplacée", closed: "fermée",
|
||||||
|
reopened: "rouverte", commented: "commentée"
|
||||||
|
};
|
||||||
|
var CONSOLE_ACTIVE_STATUSES = [
|
||||||
|
"active", "pending", "pause", "pause_budget", "acces_requis",
|
||||||
|
"en_attente", "en attente", "requise", "awaiting"
|
||||||
|
];
|
||||||
|
var CONSOLE_CLOSED_STATUSES = [
|
||||||
|
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
|
||||||
|
];
|
||||||
|
|
||||||
|
function isDevHost(hostname) {
|
||||||
|
if (!hostname) return false;
|
||||||
|
var h = String(hostname).toLowerCase();
|
||||||
|
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||||
|
var first = h.split(".")[0];
|
||||||
|
return first.length > 4 && first.slice(-4) === "-dev";
|
||||||
|
}
|
||||||
|
function cleanUrl(u) {
|
||||||
|
if (!u || typeof u !== "string") return null;
|
||||||
|
if (u.indexOf("{{") !== -1) return null;
|
||||||
|
if (!u.replace(/\s/g, "")) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
function withSlash(u) {
|
||||||
|
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||||
|
}
|
||||||
|
function joinUrl(base, path) {
|
||||||
|
var b = withSlash(base);
|
||||||
|
if (!path) return b;
|
||||||
|
if (/^https?:\/\//.test(path)) return path;
|
||||||
|
return b + String(path).replace(/^\//, "");
|
||||||
|
}
|
||||||
|
function parseDate(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
|
||||||
|
? String(s).replace(" ", "T") + "Z" : s);
|
||||||
|
return isNaN(t.getTime()) ? null : t;
|
||||||
|
}
|
||||||
|
function fmtDate(s) {
|
||||||
|
var t = parseDate(s);
|
||||||
|
if (!t) return "";
|
||||||
|
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||||
|
}
|
||||||
|
function capitalize(s) {
|
||||||
|
s = String(s || "");
|
||||||
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||||
|
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
|
||||||
|
function resolveUrls(env) {
|
||||||
|
env = env || {};
|
||||||
|
var hostname = env.hostname;
|
||||||
|
if (hostname === undefined) {
|
||||||
|
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
|
||||||
|
}
|
||||||
|
var dev = isDevHost(hostname);
|
||||||
|
var urls = {};
|
||||||
|
ALL_APPS.forEach(function (key) {
|
||||||
|
var app = key === "console" ? "agent" : key;
|
||||||
|
var out = key === "console" ? "agent" : key;
|
||||||
|
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|
||||||
|
|| cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
|
||||||
|
if (!u && dev) u = DEV_URLS[app] || null;
|
||||||
|
if (!u) {
|
||||||
|
var href = null;
|
||||||
|
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
|
||||||
|
else if (typeof document !== "undefined" && document.querySelector) {
|
||||||
|
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||||
|
href = tab ? tab.getAttribute("href") : null;
|
||||||
|
}
|
||||||
|
u = cleanUrl(href);
|
||||||
|
}
|
||||||
|
if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
|
||||||
|
if (!u) {
|
||||||
|
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||||
|
try {
|
||||||
|
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
if (!u) u = DEFAULT_URLS[app];
|
||||||
|
urls[out] = withSlash(u);
|
||||||
|
if (key === "console") urls.console = withSlash(u);
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
function browserEnv() {
|
||||||
|
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||||
|
var bar = global.DRACO_BAR || {};
|
||||||
|
var tabs = {};
|
||||||
|
try {
|
||||||
|
if (typeof document !== "undefined" && document.querySelectorAll) {
|
||||||
|
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
return {
|
||||||
|
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||||
|
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
|
||||||
|
consoleUrl: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
|
||||||
|
* n'est devinée : sans configuration, la source est indisponible
|
||||||
|
* (état vide honnête, voir renderCurrent). */
|
||||||
|
function consoleSourceUrl(env) {
|
||||||
|
env = env || {};
|
||||||
|
if (env.consoleUrl) return env.consoleUrl;
|
||||||
|
try {
|
||||||
|
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
|
||||||
|
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||||
|
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFlowdeck(base, payload) {
|
||||||
|
var list = (payload && payload.notifications) || [];
|
||||||
|
return list.map(function (n) {
|
||||||
|
return {
|
||||||
|
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
|
||||||
|
detail: n.message || "", date: n.created_at || "",
|
||||||
|
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||||
|
kind: n.ntype || "", url: joinUrl(base, n.url || "")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function normalizeTiktak(base, payload) {
|
||||||
|
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
|
||||||
|
return list.map(function (n) {
|
||||||
|
return {
|
||||||
|
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
|
||||||
|
detail: n.body || "", date: n.created_at || "",
|
||||||
|
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||||
|
kind: n.kind || "",
|
||||||
|
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function normalizeKanbante(base, payload) {
|
||||||
|
var list = (payload && payload.events) || [];
|
||||||
|
return list.map(function (e) {
|
||||||
|
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
|
||||||
|
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
|
||||||
|
return {
|
||||||
|
app: "kanbante", id: e.id,
|
||||||
|
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
|
||||||
|
detail: detail, date: e.created_at || "",
|
||||||
|
active: false, state: label, kind: e.event_type || "",
|
||||||
|
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function normalizeConsole(base, payload) {
|
||||||
|
var list = Array.isArray(payload) ? payload
|
||||||
|
: (payload && (payload.alerts || payload.items)) || [];
|
||||||
|
return list.map(function (n) {
|
||||||
|
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
|
||||||
|
var stateRaw = String(n.state || status || "");
|
||||||
|
var active;
|
||||||
|
if (typeof n.active === "boolean") active = n.active;
|
||||||
|
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
|
||||||
|
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
|
||||||
|
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
|
||||||
|
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
|
||||||
|
else active = true; /* source réelle, statut inconnu : on l'expose en active */
|
||||||
|
return {
|
||||||
|
app: "console", id: n.id, title: n.title || "(sans titre)",
|
||||||
|
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
|
||||||
|
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
|
||||||
|
kind: n.kind || n.type || "",
|
||||||
|
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
|
||||||
|
* normalize* sont inchangées, pour compatibilité des tests #69). */
|
||||||
|
function displayState(a) {
|
||||||
|
if (a.active) return "Active";
|
||||||
|
if (a.app === "kanbante") return capitalize(a.state);
|
||||||
|
if (a.app === "console") return capitalize(a.state);
|
||||||
|
return "Lue";
|
||||||
|
}
|
||||||
|
function metaLine(a) {
|
||||||
|
var date = fmtDate(a.date) || "sans date";
|
||||||
|
/* Kanbante : le type brut (« moved ») dupliquait le libellé
|
||||||
|
* d'état ; il n'est plus affiché. Les autres apps gardent leur
|
||||||
|
* type quand il apporte une information. */
|
||||||
|
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
|
||||||
|
var kind = a.kind ? " · " + a.kind : "";
|
||||||
|
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
|
||||||
|
}
|
||||||
|
/* Actives d'abord, puis date décroissante (sans date en dernier).
|
||||||
|
* Dans un onglet, le périmètre est déjà homogène (que des actives
|
||||||
|
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
|
||||||
|
function sortAlerts(list) {
|
||||||
|
return list.slice().sort(function (a, b) {
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||||
|
var da = parseDate(a.date), db = parseDate(b.date);
|
||||||
|
if (da && db) return db - da;
|
||||||
|
if (da) return -1;
|
||||||
|
if (db) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function countActive(list) {
|
||||||
|
var n = 0;
|
||||||
|
list.forEach(function (a) { if (a.active) n++; });
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
function allItems(results) {
|
||||||
|
var all = [];
|
||||||
|
ALL_APPS.forEach(function (app) {
|
||||||
|
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||||
|
});
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
|
||||||
|
* Tout et onglets par application = actives seulement ; Historique =
|
||||||
|
* fermées seulement. Les erreurs de section sont remontées dans
|
||||||
|
* chaque onglet concerné (jamais silencieusement avalées). */
|
||||||
|
function scopeAlerts(all, tab, results) {
|
||||||
|
results = results || {};
|
||||||
|
var items = [], errors = [];
|
||||||
|
function collect(app, pred) {
|
||||||
|
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
|
||||||
|
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
|
||||||
|
}
|
||||||
|
if (tab === "historique") {
|
||||||
|
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
|
||||||
|
} else if (tab === "tout") {
|
||||||
|
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||||
|
} else if (ALL_APPS.indexOf(tab) !== -1) {
|
||||||
|
collect(tab, function (a) { return a.active; });
|
||||||
|
} else {
|
||||||
|
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: sortAlerts(items), errors: errors,
|
||||||
|
consoleMissing: !!(results.console && results.console.sourceMissing)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function countByTab(all, results) {
|
||||||
|
var counts = {};
|
||||||
|
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
|
||||||
|
* contexte, insensible à la casse, sans jamais mélanger actifs et
|
||||||
|
* historique (le périmètre est déjà fixé par scopeAlerts). */
|
||||||
|
function filterAlerts(list, q) {
|
||||||
|
q = String(q || "").trim().toLowerCase();
|
||||||
|
if (!q) return list.slice();
|
||||||
|
return list.filter(function (a) {
|
||||||
|
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|
||||||
|
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
|
||||||
|
* "#alertes" -> "tout", autre -> null. */
|
||||||
|
function parseAlertTab(hash) {
|
||||||
|
if (!hash) return null;
|
||||||
|
var h = String(hash).replace(/^#/, "");
|
||||||
|
if (h === "alertes") return "tout";
|
||||||
|
var m = /^alertes=([a-z]+)$/.exec(h);
|
||||||
|
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hashForTab(tab) {
|
||||||
|
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||||
|
return "#alertes=" + tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Rendu (navigateur seulement) ---- */
|
||||||
|
var panel = null, listEl = null, tabsEl = null, filterEl = null,
|
||||||
|
countEl = null, badgeTimer = null;
|
||||||
|
var currentTab = "tout", currentData = null, currentFilter = "";
|
||||||
|
var panelPushed = false, closingViaBack = false, suppressHash = false;
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var d = document.createElement(tag);
|
||||||
|
if (cls) d.className = cls;
|
||||||
|
if (text !== undefined && text !== null) d.textContent = text;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
function ensurePanel() {
|
||||||
|
if (panel) return panel;
|
||||||
|
var ov = el("div", "draco-alertes-overlay");
|
||||||
|
ov.setAttribute("role", "dialog");
|
||||||
|
ov.setAttribute("aria-label", "Alertes unifiées");
|
||||||
|
var box = el("div", "draco-alertes-box");
|
||||||
|
var head = el("div", "draco-alertes-head");
|
||||||
|
head.appendChild(el("h2", "", "Alertes unifiées"));
|
||||||
|
var close = el("button", "draco-alertes-close", "✕");
|
||||||
|
close.setAttribute("aria-label", "Fermer");
|
||||||
|
close.addEventListener("click", function () { closePanel(); });
|
||||||
|
head.appendChild(close);
|
||||||
|
box.appendChild(head);
|
||||||
|
box.appendChild(el("p", "draco-alertes-note",
|
||||||
|
"Agrégation à la demande, pas une fédération temps réel : " +
|
||||||
|
"les alertes sont relues dans chaque application à l'ouverture. " +
|
||||||
|
"Une section en échec n'empêche pas de consulter les autres."));
|
||||||
|
tabsEl = el("div", "draco-alertes-tabs");
|
||||||
|
tabsEl.setAttribute("role", "tablist");
|
||||||
|
tabsEl.setAttribute("aria-label", "Alertes par application");
|
||||||
|
tabsEl.addEventListener("keydown", onTabsKeydown);
|
||||||
|
box.appendChild(tabsEl);
|
||||||
|
var filtre = el("div", "draco-alertes-filtre");
|
||||||
|
var lbl = el("label", "", "Filtrer");
|
||||||
|
lbl.setAttribute("for", "draco-alertes-filtre-input");
|
||||||
|
filtre.appendChild(lbl);
|
||||||
|
filterEl = el("input", "");
|
||||||
|
filterEl.type = "search";
|
||||||
|
filterEl.id = "draco-alertes-filtre-input";
|
||||||
|
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
|
||||||
|
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
|
||||||
|
filterEl.addEventListener("input", function () {
|
||||||
|
currentFilter = filterEl.value;
|
||||||
|
renderCurrent();
|
||||||
|
});
|
||||||
|
filtre.appendChild(filterEl);
|
||||||
|
box.appendChild(filtre);
|
||||||
|
countEl = el("p", "draco-alertes-compte", "");
|
||||||
|
box.appendChild(countEl);
|
||||||
|
listEl = el("div", "draco-alertes-list");
|
||||||
|
listEl.setAttribute("role", "tabpanel");
|
||||||
|
box.appendChild(listEl);
|
||||||
|
ov.appendChild(box);
|
||||||
|
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
|
||||||
|
document.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
|
||||||
|
});
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
panel = ov;
|
||||||
|
return ov;
|
||||||
|
}
|
||||||
|
function closePanel() {
|
||||||
|
if (!panel) return;
|
||||||
|
panel.style.display = "none";
|
||||||
|
if (panelPushed) {
|
||||||
|
panelPushed = false;
|
||||||
|
closingViaBack = true;
|
||||||
|
try { history.back(); } catch (e) { closingViaBack = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var h = parseAlertTab(window.location.hash);
|
||||||
|
if (h) {
|
||||||
|
suppressHash = true;
|
||||||
|
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||||
|
suppressHash = false;
|
||||||
|
}
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
function onHashChange() {
|
||||||
|
if (suppressHash) return;
|
||||||
|
if (closingViaBack) { closingViaBack = false; return; }
|
||||||
|
var tab = parseAlertTab(window.location.hash);
|
||||||
|
if (tab) {
|
||||||
|
if (!panel || panel.style.display === "none") openPanel(tab);
|
||||||
|
else setTab(tab, false);
|
||||||
|
} else if (panel && panel.style.display !== "none") {
|
||||||
|
panelPushed = false;
|
||||||
|
panel.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onTabsKeydown(ev) {
|
||||||
|
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
|
||||||
|
if (keys.indexOf(ev.key) === -1) return;
|
||||||
|
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
|
||||||
|
var idx = TABS.indexOf(currentTab);
|
||||||
|
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
|
||||||
|
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
|
||||||
|
else if (ev.key === "Home") idx = 0;
|
||||||
|
else if (ev.key === "End") idx = TABS.length - 1;
|
||||||
|
ev.preventDefault();
|
||||||
|
setTab(TABS[idx], true);
|
||||||
|
if (btns[idx] && btns[idx].focus) btns[idx].focus();
|
||||||
|
}
|
||||||
|
function renderTabs() {
|
||||||
|
if (!tabsEl || !currentData) return;
|
||||||
|
tabsEl.textContent = "";
|
||||||
|
var all = allItems(currentData.results);
|
||||||
|
var counts = countByTab(all, currentData.results);
|
||||||
|
TABS.forEach(function (tab) {
|
||||||
|
var b = el("button", "draco-alertes-tab",
|
||||||
|
TAB_LABELS[tab] + " (" + counts[tab] + ")");
|
||||||
|
b.type = "button";
|
||||||
|
b.setAttribute("role", "tab");
|
||||||
|
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
|
||||||
|
b.tabIndex = tab === currentTab ? 0 : -1;
|
||||||
|
b.addEventListener("click", function () { setTab(tab, true); });
|
||||||
|
tabsEl.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderRow(a) {
|
||||||
|
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
|
||||||
|
var main = el("div", "draco-alertes-main");
|
||||||
|
var link = el("a", "draco-alertes-title", a.title);
|
||||||
|
link.href = a.url;
|
||||||
|
main.appendChild(link);
|
||||||
|
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
|
||||||
|
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
|
||||||
|
row.appendChild(main);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderErrorRow(app, msg) {
|
||||||
|
var row = el("div", "draco-alertes-item is-error");
|
||||||
|
var main = el("div", "draco-alertes-main");
|
||||||
|
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
|
||||||
|
main.appendChild(el("div", "draco-alertes-detail", msg));
|
||||||
|
main.appendChild(el("div", "draco-alertes-meta",
|
||||||
|
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
|
||||||
|
row.appendChild(main);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderCurrent() {
|
||||||
|
if (!listEl || !currentData) return;
|
||||||
|
renderTabs();
|
||||||
|
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
|
||||||
|
var filtered = filterAlerts(scope.items, currentFilter);
|
||||||
|
var q = String(currentFilter || "").trim();
|
||||||
|
if (countEl) {
|
||||||
|
countEl.textContent = q
|
||||||
|
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
listEl.textContent = "";
|
||||||
|
/* Onglet Console sans source : état vide honnête, jamais d'alertes
|
||||||
|
* inventées, avec le lien profond vers la console. */
|
||||||
|
if (currentTab === "console" && scope.consoleMissing) {
|
||||||
|
var sec = el("section", "draco-alertes-section");
|
||||||
|
sec.appendChild(el("h3", "", "Console Spark (0)"));
|
||||||
|
sec.appendChild(el("p", "draco-alertes-empty",
|
||||||
|
"Aucune alerte console — source indisponible : l'endpoint " +
|
||||||
|
"d'alertes console (agent-commun, autre chantier) n'est pas " +
|
||||||
|
"encore publié. Les missions en pause budget / accès requis " +
|
||||||
|
"et les approbations en attente apparaîtront ici, avec leurs " +
|
||||||
|
"états réels, dès sa livraison."));
|
||||||
|
var link = el("div", "draco-alertes-empty");
|
||||||
|
var a = el("a", "", "Ouvrir la Console Spark");
|
||||||
|
a.href = currentData.urls.agent || currentData.urls.console || "#";
|
||||||
|
link.appendChild(a);
|
||||||
|
sec.appendChild(link);
|
||||||
|
listEl.appendChild(sec);
|
||||||
|
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
|
||||||
|
var secOg = el("section", "draco-alertes-section");
|
||||||
|
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||||
|
secOg.appendChild(el("p", "draco-alertes-empty",
|
||||||
|
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||||
|
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||||
|
var linkOg = el("div", "draco-alertes-empty");
|
||||||
|
var aOg = el("a", "", "Ouvrir ObsiGate");
|
||||||
|
aOg.href = currentData.urls.obsigate;
|
||||||
|
linkOg.appendChild(aOg);
|
||||||
|
secOg.appendChild(linkOg);
|
||||||
|
listEl.appendChild(secOg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||||
|
if (!scope.items.length && !scope.errors.length) {
|
||||||
|
var empty = currentTab === "historique"
|
||||||
|
? "Aucune alerte fermée dans les applications joignables."
|
||||||
|
: currentTab === "tout"
|
||||||
|
? "Aucune alerte active dans les applications joignables."
|
||||||
|
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
|
||||||
|
if (currentTab === "console") empty = "Aucune alerte console.";
|
||||||
|
listEl.appendChild(el("p", "draco-alertes-empty", empty));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (q && !filtered.length) {
|
||||||
|
listEl.appendChild(el("p", "draco-alertes-empty",
|
||||||
|
"Aucune alerte dans cet onglet pour ce filtre."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
|
||||||
|
}
|
||||||
|
function setTab(tab, push) {
|
||||||
|
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||||
|
currentTab = tab;
|
||||||
|
renderCurrent();
|
||||||
|
if (push) {
|
||||||
|
try {
|
||||||
|
history.pushState(null, "", hashForTab(tab));
|
||||||
|
panelPushed = true;
|
||||||
|
} catch (e) { /* URL partageable : repli silencieux */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJson(url, timeoutMs) {
|
||||||
|
var ctrl = null, timer = null;
|
||||||
|
try {
|
||||||
|
if (typeof AbortController !== "undefined") {
|
||||||
|
ctrl = new AbortController();
|
||||||
|
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
|
||||||
|
}
|
||||||
|
} catch (e) { ctrl = null; }
|
||||||
|
var opts = { credentials: "include" };
|
||||||
|
if (ctrl) opts.signal = ctrl.signal;
|
||||||
|
return fetch(url, opts).then(function (r) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (!r.ok) {
|
||||||
|
var err = new Error("HTTP " + r.status);
|
||||||
|
err.status = r.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadAll() {
|
||||||
|
var urls = resolveUrls(browserEnv());
|
||||||
|
var consoleUrl = consoleSourceUrl(browserEnv());
|
||||||
|
var jobs = {
|
||||||
|
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||||
|
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||||
|
tiktak: fetchJson(urls.tiktak + "api/notifications")
|
||||||
|
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
|
||||||
|
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||||
|
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||||
|
};
|
||||||
|
if (consoleUrl) {
|
||||||
|
var agentBase = urls.agent || urls.console;
|
||||||
|
jobs.console = fetchJson(consoleUrl)
|
||||||
|
.then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
|
||||||
|
}
|
||||||
|
var keys = Object.keys(jobs);
|
||||||
|
return Promise.all(keys.map(function (k) {
|
||||||
|
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
|
||||||
|
var msg = (e && e.status === 401)
|
||||||
|
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
|
||||||
|
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
|
||||||
|
return [k, { items: [], error: msg }];
|
||||||
|
});
|
||||||
|
})).then(function (pairs) {
|
||||||
|
var results = { obsigate: { items: [] } };
|
||||||
|
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||||
|
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
|
||||||
|
return { urls: urls, results: results };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setBadge(n) {
|
||||||
|
try {
|
||||||
|
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
|
||||||
|
global.DRACO_BAR.setCounts({ alertes: n });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { /* repli ci-dessous */ }
|
||||||
|
try {
|
||||||
|
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
|
||||||
|
b.textContent = n;
|
||||||
|
b.hidden = !(n > 0);
|
||||||
|
});
|
||||||
|
} catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
function refreshBadge() {
|
||||||
|
return loadAll().then(function (r) {
|
||||||
|
var n = countActive(allItems(r.results));
|
||||||
|
setBadge(n);
|
||||||
|
return n;
|
||||||
|
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||||
|
}
|
||||||
|
function openPanel(initialTab) {
|
||||||
|
ensurePanel();
|
||||||
|
panel.style.display = "flex";
|
||||||
|
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
|
||||||
|
currentTab = tab;
|
||||||
|
currentFilter = "";
|
||||||
|
if (filterEl) filterEl.value = "";
|
||||||
|
listEl.textContent = "";
|
||||||
|
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||||
|
if (tabsEl) tabsEl.textContent = "";
|
||||||
|
loadAll().then(function (r) {
|
||||||
|
currentData = r;
|
||||||
|
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
|
||||||
|
* l'URL pour être partageable ; ouverture par lien partagé : le
|
||||||
|
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
|
||||||
|
var fromHash = parseAlertTab(window.location.hash);
|
||||||
|
if (!fromHash) {
|
||||||
|
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
|
||||||
|
}
|
||||||
|
renderCurrent();
|
||||||
|
setBadge(countActive(allItems(r.results)));
|
||||||
|
}).catch(function () {
|
||||||
|
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
|
||||||
|
renderCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var api = {
|
||||||
|
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||||
|
_test: {
|
||||||
|
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||||
|
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||||
|
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
|
||||||
|
sortAlerts: sortAlerts, countActive: countActive,
|
||||||
|
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
|
||||||
|
countByTab: countByTab, parseAlertTab: parseAlertTab,
|
||||||
|
hashForTab: hashForTab, displayState: displayState,
|
||||||
|
metaLine: metaLine, fmtDate: fmtDate,
|
||||||
|
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
|
||||||
|
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
global.DRACO_ALERTES = api;
|
||||||
|
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
|
||||||
|
|
||||||
|
/* Pastille au chargement (après les scripts defer de la barre) ;
|
||||||
|
* un lien partagé #alertes=<onglet> ouvre directement le panneau
|
||||||
|
* sur cet onglet. */
|
||||||
|
if (typeof document !== "undefined" && document.addEventListener) {
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
if (badgeTimer) clearTimeout(badgeTimer);
|
||||||
|
badgeTimer = setTimeout(refreshBadge, 300);
|
||||||
|
var tab = parseAlertTab(window.location.hash);
|
||||||
|
if (tab) setTimeout(function () { openPanel(tab); }, 350);
|
||||||
|
});
|
||||||
|
window.addEventListener("hashchange", onHashChange);
|
||||||
|
}
|
||||||
|
})(typeof window !== "undefined" ? window : globalThis);
|
||||||
File diff suppressed because it is too large
Load Diff
Vendored
+16
-6
@@ -51,9 +51,13 @@
|
|||||||
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
|
* 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
|
* 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).
|
* (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 :
|
* La clé spark ne correspond à aucun onglet de la barre : elle est
|
||||||
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
|
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
|
||||||
* Spark (panneau-agent), qui suit le même contrat d'injection.
|
* (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),
|
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||||
@@ -79,9 +83,10 @@
|
|||||||
* onglets présents dans la barre, puis une entrée « Console Spark »
|
* onglets présents dans la barre, puis une entrée « Console Spark »
|
||||||
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
|
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
|
||||||
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
|
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
|
||||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) défaut officiel
|
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
|
||||||
* prod https://agent.dracodev.net/ (il n'y a pas d'onglet agent, donc
|
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
|
||||||
* pas d'étape « href rendu »). AUCUN hôte spark-dev n'est inventé :
|
* 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 >
|
* la clé spark, elle, suit toujours son propre contrat (explicite >
|
||||||
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
|
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
|
||||||
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
|
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
|
||||||
@@ -329,6 +334,11 @@
|
|||||||
appsMenu.setAttribute("role", "menu");
|
appsMenu.setAttribute("role", "menu");
|
||||||
appsMenu.setAttribute("aria-label", "Applications");
|
appsMenu.setAttribute("aria-label", "Applications");
|
||||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
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");
|
var a = document.createElement("a");
|
||||||
a.className = "draco-apps-item";
|
a.className = "draco-apps-item";
|
||||||
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Tests unitaires (node:test) du module partagé draco-alertes.js.
|
||||||
|
// Exécutés dans la sandbox : node --test draco-alertes.test.js
|
||||||
|
// Copie vendangée byte-identique de Projets/dracodev-commons
|
||||||
|
// ui/alertes/ (chantier 5) — seul le require change selon l'hôte.
|
||||||
|
// Hôte : Flowdeck
|
||||||
|
// Aucune donnée inventée : les payloads reprennent la forme exacte
|
||||||
|
// des API réelles (Flowdeck /api/notifications, TikTak
|
||||||
|
// /api/notifications, Kanbante /api/events). Le payload console
|
||||||
|
// n'est qu'un EXEMPLE DE FORMAT (voir README commons) : aucun
|
||||||
|
// endpoint console n'est publié sur agent-commun main au moment
|
||||||
|
// du chantier.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const t = require("../static/js/draco-alertes.js");
|
||||||
|
|
||||||
|
function sampleResults() {
|
||||||
|
return {
|
||||||
|
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||||
|
notifications: [
|
||||||
|
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||||
|
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||||
|
], unread: 1,
|
||||||
|
}) },
|
||||||
|
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||||
|
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||||
|
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||||
|
]) },
|
||||||
|
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||||
|
events: [
|
||||||
|
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||||
|
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||||
|
],
|
||||||
|
}) },
|
||||||
|
obsigate: { items: [] },
|
||||||
|
console: { items: [], sourceMissing: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function allOf(results) {
|
||||||
|
const all = [];
|
||||||
|
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
|
||||||
|
(results[app] && results[app].items || []).forEach((a) => all.push(a));
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
|
||||||
|
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
|
||||||
|
assert.equal(t.TAB_LABELS.console, "Console Spark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
|
||||||
|
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||||
|
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||||
|
assert.equal(prod.agent, "https://agent.dracodev.net/");
|
||||||
|
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||||
|
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||||
|
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||||
|
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
|
||||||
|
const explicit = t.resolveUrls({
|
||||||
|
hostname: "tiktak-dev.dracodev.net",
|
||||||
|
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||||
|
});
|
||||||
|
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||||
|
const href = t.resolveUrls({
|
||||||
|
hostname: "example.org",
|
||||||
|
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
|
||||||
|
});
|
||||||
|
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||||
|
assert.equal(href.agent, "https://agent.dracodev.net/");
|
||||||
|
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||||
|
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||||
|
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||||
|
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||||
|
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||||
|
notifications: [
|
||||||
|
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||||
|
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||||
|
], unread: 1,
|
||||||
|
});
|
||||||
|
assert.equal(items.length, 2);
|
||||||
|
assert.equal(items[0].active, true);
|
||||||
|
assert.equal(items[0].state, "active");
|
||||||
|
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
|
||||||
|
assert.equal(items[1].active, false);
|
||||||
|
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeTiktak: lien tache, sinon page notifications", () => {
|
||||||
|
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||||
|
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||||
|
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||||
|
]);
|
||||||
|
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
|
||||||
|
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
|
||||||
|
assert.equal(items[0].active, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
|
||||||
|
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||||
|
events: [
|
||||||
|
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||||
|
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(items.length, 2);
|
||||||
|
assert.ok(items.every((i) => i.active === false));
|
||||||
|
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
|
||||||
|
assert.match(items[0].title, /#69/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||||
|
const list = [
|
||||||
|
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
|
||||||
|
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
|
||||||
|
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
|
||||||
|
{ app: "kanbante", active: false, date: "" },
|
||||||
|
];
|
||||||
|
const sorted = t.sortAlerts(list);
|
||||||
|
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
|
||||||
|
assert.equal(sorted[0].active, true);
|
||||||
|
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
|
||||||
|
assert.equal(sorted[3].date, "");
|
||||||
|
assert.equal(t.countActive(list), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
|
||||||
|
const r = sampleResults();
|
||||||
|
const all = allOf(r);
|
||||||
|
const tout = t.scopeAlerts(all, "tout", r);
|
||||||
|
assert.equal(tout.items.length, 2);
|
||||||
|
assert.ok(tout.items.every((a) => a.active));
|
||||||
|
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
|
||||||
|
const fd = t.scopeAlerts(all, "flowdeck", r);
|
||||||
|
assert.equal(fd.items.length, 1);
|
||||||
|
assert.equal(fd.items[0].app, "flowdeck");
|
||||||
|
const hist = t.scopeAlerts(all, "historique", r);
|
||||||
|
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
|
||||||
|
assert.ok(hist.items.every((a) => !a.active));
|
||||||
|
const og = t.scopeAlerts(all, "obsigate", r);
|
||||||
|
assert.equal(og.items.length, 0);
|
||||||
|
const cs = t.scopeAlerts(all, "console", r);
|
||||||
|
assert.equal(cs.items.length, 0);
|
||||||
|
assert.equal(cs.consoleMissing, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
|
||||||
|
const r = sampleResults();
|
||||||
|
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
|
||||||
|
const hist = t.scopeAlerts(allOf(r), "historique", r);
|
||||||
|
assert.equal(hist.errors.length, 1);
|
||||||
|
assert.equal(hist.errors[0].app, "flowdeck");
|
||||||
|
const tout = t.scopeAlerts(allOf(r), "tout", r);
|
||||||
|
assert.equal(tout.errors.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("countByTab: compteurs par onglet sur l'echantillon", () => {
|
||||||
|
const r = sampleResults();
|
||||||
|
const c = t.countByTab(allOf(r), r);
|
||||||
|
assert.deepEqual(c, {
|
||||||
|
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
|
||||||
|
kanbante: 0, console: 0, historique: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
|
||||||
|
const r = sampleResults();
|
||||||
|
const all = allOf(r);
|
||||||
|
const tout = t.scopeAlerts(all, "tout", r).items;
|
||||||
|
assert.equal(t.filterAlerts(tout, "budget").length, 1);
|
||||||
|
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
|
||||||
|
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
|
||||||
|
assert.equal(t.filterAlerts(tout, "tache").length, 1);
|
||||||
|
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
|
||||||
|
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
|
||||||
|
const hist = t.scopeAlerts(all, "historique", r).items;
|
||||||
|
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
|
||||||
|
assert.equal(t.filterAlerts(tout, "").length, tout.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
|
||||||
|
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||||
|
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
|
||||||
|
})[0];
|
||||||
|
assert.ok(!t.metaLine(kb).includes("moved"));
|
||||||
|
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
|
||||||
|
assert.equal(t.displayState({ active: true }), "Active");
|
||||||
|
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
|
||||||
|
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
|
||||||
|
assert.match(t.metaLine(sansDate), /sans date/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
|
||||||
|
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
|
||||||
|
assert.equal(t.parseAlertTab("#alertes=console"), "console");
|
||||||
|
assert.equal(t.parseAlertTab("#alertes"), "tout");
|
||||||
|
assert.equal(t.parseAlertTab("#autre"), null);
|
||||||
|
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
|
||||||
|
assert.equal(t.parseAlertTab(""), null);
|
||||||
|
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
|
||||||
|
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
|
||||||
|
const items = t.normalizeConsole("https://agent.dracodev.net/", {
|
||||||
|
alerts: [
|
||||||
|
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
|
||||||
|
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
|
||||||
|
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
|
||||||
|
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
|
||||||
|
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(items.length, 5);
|
||||||
|
assert.equal(items[0].active, true);
|
||||||
|
assert.equal(items[1].active, true);
|
||||||
|
assert.equal(items[2].active, true);
|
||||||
|
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
|
||||||
|
assert.equal(items[3].active, false);
|
||||||
|
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
|
||||||
|
assert.equal(items[0].url, "https://agent.dracodev.net/");
|
||||||
|
const r = sampleResults();
|
||||||
|
r.console = { items };
|
||||||
|
const c = t.countByTab(allOf(r), r);
|
||||||
|
assert.equal(c.console, 4);
|
||||||
|
assert.equal(c.tout, 6);
|
||||||
|
assert.equal(c.historique, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
|
||||||
|
assert.equal(t.consoleSourceUrl({}), null);
|
||||||
|
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
|
||||||
|
"https://agent-dev.dracodev.net/api/alertes");
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/* Tests Node sans navigateur de recherche.js (logique pure).
|
||||||
|
* Usage : node recherche.test.js — sortie 0 si tout passe. */
|
||||||
|
"use strict";
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const rp = require("../static/js/recherche.js");
|
||||||
|
|
||||||
|
let n = 0;
|
||||||
|
function ok(cond, label) {
|
||||||
|
assert.ok(cond, label);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* esc */
|
||||||
|
ok(rp.esc('<a href="x">&') === "<a href="x">&", "esc échappe HTML");
|
||||||
|
|
||||||
|
/* isDevHost : même règle que la barre v0.1.6 */
|
||||||
|
ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu");
|
||||||
|
ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev");
|
||||||
|
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
|
||||||
|
ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée");
|
||||||
|
ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse");
|
||||||
|
|
||||||
|
/* resolveBases : ordre explicite > barre > dev > href > prod */
|
||||||
|
let b = rp.resolveBases({
|
||||||
|
host: "tiktak-dev.dracodev.net",
|
||||||
|
explicit: { obsigate: "https://og-perso.example/" },
|
||||||
|
barUrls: { flowdeck: "https://flowdeck-barre.example/" },
|
||||||
|
tabHrefs: { kanbante: "https://kanbante-href.example/" },
|
||||||
|
});
|
||||||
|
ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire");
|
||||||
|
ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev");
|
||||||
|
ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant");
|
||||||
|
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
|
||||||
|
b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } });
|
||||||
|
ok(b.kanbante === "https://kanbante-href.example/", "href en prod");
|
||||||
|
ok(b.obsigate === "https://og.dracodev.net/", "défaut prod");
|
||||||
|
b = rp.resolveBases({});
|
||||||
|
ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte");
|
||||||
|
|
||||||
|
/* searchPath */
|
||||||
|
ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate");
|
||||||
|
ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak");
|
||||||
|
|
||||||
|
/* normalize ObsiGate */
|
||||||
|
let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée");
|
||||||
|
ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate");
|
||||||
|
ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate");
|
||||||
|
|
||||||
|
/* normalize Flowdeck */
|
||||||
|
items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p");
|
||||||
|
ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck");
|
||||||
|
ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck");
|
||||||
|
|
||||||
|
/* normalize TikTak */
|
||||||
|
items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire");
|
||||||
|
ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak");
|
||||||
|
ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak");
|
||||||
|
|
||||||
|
/* normalize Kanbante */
|
||||||
|
items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug");
|
||||||
|
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
|
||||||
|
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
|
||||||
|
|
||||||
|
/* nativeSearchUrl */
|
||||||
|
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
|
||||||
|
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
|
||||||
|
|
||||||
|
|
||||||
|
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
|
||||||
|
|
||||||
|
/* parseTab */
|
||||||
|
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
|
||||||
|
ok(rp.parseTab("console") === "console", "onglet console valide");
|
||||||
|
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
|
||||||
|
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
|
||||||
|
|
||||||
|
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
|
||||||
|
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
|
||||||
|
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
|
||||||
|
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
|
||||||
|
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
|
||||||
|
|
||||||
|
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
|
||||||
|
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
|
||||||
|
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
|
||||||
|
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
|
||||||
|
|
||||||
|
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
|
||||||
|
let c = rp.tabCounts({
|
||||||
|
obsigate: { status: "loading", total: 0 },
|
||||||
|
flowdeck: { status: "ok", total: 3 },
|
||||||
|
tiktak: { status: "empty", total: 0 },
|
||||||
|
kanbante: { status: "error", total: 0 },
|
||||||
|
console: { status: "idle", total: 0 },
|
||||||
|
});
|
||||||
|
ok(c.obsigate === "…", "compteur … pendant le chargement");
|
||||||
|
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
|
||||||
|
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
|
||||||
|
ok(c.all === "…", "Tout = … tant qu'une section charge");
|
||||||
|
c = rp.tabCounts({
|
||||||
|
obsigate: { status: "ok", total: 5 },
|
||||||
|
flowdeck: { status: "ok", total: 2 },
|
||||||
|
tiktak: { status: "empty", total: 0 },
|
||||||
|
kanbante: { status: "error", total: 0 },
|
||||||
|
console: { status: "ok", total: 4 },
|
||||||
|
});
|
||||||
|
ok(c.all === "7", "Tout = somme des totaux connus");
|
||||||
|
ok(c.console === "4", "compteur console");
|
||||||
|
c = rp.tabCounts({
|
||||||
|
obsigate: { status: "error", total: 0 },
|
||||||
|
flowdeck: { status: "error", total: 0 },
|
||||||
|
tiktak: { status: "error", total: 0 },
|
||||||
|
kanbante: { status: "error", total: 0 },
|
||||||
|
console: { status: "idle", total: 0 },
|
||||||
|
});
|
||||||
|
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
|
||||||
|
|
||||||
|
/* buildSearchUrl : état onglet partageable */
|
||||||
|
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
|
||||||
|
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
|
||||||
|
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
|
||||||
|
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
|
||||||
|
|
||||||
|
/* searchPath complet (onglet) vs tronqué (Tout) */
|
||||||
|
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
|
||||||
|
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
|
||||||
|
|
||||||
|
/* normalize complet vs tronqué */
|
||||||
|
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
|
||||||
|
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
|
||||||
|
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
|
||||||
|
|
||||||
|
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
|
||||||
|
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
|
||||||
|
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
|
||||||
|
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
|
||||||
|
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
|
||||||
|
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
|
||||||
|
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
|
||||||
|
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
|
||||||
|
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
|
||||||
|
|
||||||
|
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
|
||||||
|
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
|
||||||
|
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
|
||||||
|
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
|
||||||
|
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
|
||||||
|
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
|
||||||
|
|
||||||
|
|
||||||
|
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
|
||||||
|
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
|
||||||
|
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
|
||||||
|
let rc = rp.normalizeConsole({ resultats: [
|
||||||
|
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
|
||||||
|
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
|
||||||
|
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
|
||||||
|
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
|
||||||
|
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
|
||||||
|
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
|
||||||
|
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
|
||||||
|
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
|
||||||
|
] }, "https://agent-dev.dracodev.net/");
|
||||||
|
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
|
||||||
|
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
|
||||||
|
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
|
||||||
|
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
|
||||||
|
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
|
||||||
|
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
|
||||||
|
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
|
||||||
|
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
|
||||||
|
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
|
||||||
|
rc = rp.normalizeConsole({ resultats: [
|
||||||
|
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
|
||||||
|
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
|
||||||
|
] }, "https://agent.dracodev.net/");
|
||||||
|
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
|
||||||
|
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
|
||||||
|
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
|
||||||
|
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
|
||||||
|
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||||
|
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||||
|
|
||||||
|
console.log("recherche.test.js : " + n + " assertions OK");
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Flowdeck).
|
||||||
|
|
||||||
|
Source réelle de Flowdeck : GET /api/notifications (non lues =
|
||||||
|
actives, `url` relative pour le lien profond), déjà exposée et
|
||||||
|
inchangée ici. Couverts : placeholder Alertes disparu, module servi,
|
||||||
|
CSP connect-src avec les origines soeurs, CORS (origines soeurs
|
||||||
|
ajoutées à allow_origins ; tiers sans en-tête).
|
||||||
|
"""
|
||||||
|
|
||||||
|
SOEURS = ("https://og.dracodev.net", "https://flowdeck.dracodev.net",
|
||||||
|
"https://tiktak.dracodev.net", "https://kanbante.dracodev.net")
|
||||||
|
|
||||||
|
|
||||||
|
def test_barre_alertes_sans_placeholder_et_module_servi(client):
|
||||||
|
# Les pages applicatives authentifiées ne sont pas atteignables
|
||||||
|
# telles quelles dans la sandbox (l'écran de connexion est servi
|
||||||
|
# pour "/" comme pour "/dashboard") : on vérifie donc le gabarit
|
||||||
|
# livré lui-même, plus les fichiers statiques servis.
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
gabarit = Path("app/templates/_draco_bar.html").read_text()
|
||||||
|
assert "Alertes unifiées : arrive avec la fédération" not in gabarit
|
||||||
|
assert "draco-alertes.js" in gabarit
|
||||||
|
assert "draco-alertes.css" in gabarit
|
||||||
|
assert "DRACO_ALERTES.open()" in gabarit
|
||||||
|
js = client.get("/static/js/draco-alertes.js")
|
||||||
|
assert js.status_code == 200
|
||||||
|
assert "DRACO_ALERTES" in js.text
|
||||||
|
assert "api/notifications" in js.text
|
||||||
|
css = client.get("/static/css/draco-alertes.css")
|
||||||
|
assert css.status_code == 200
|
||||||
|
assert "draco-alertes-overlay" in css.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_connect_src_contient_les_soeurs(client):
|
||||||
|
# La CSP est posée sur toute réponse HTML, y compris l'écran de
|
||||||
|
# connexion servi ici : elle prouve les origines soeurs en
|
||||||
|
# connect-src pour les pages de l'hôte.
|
||||||
|
r = client.get("/")
|
||||||
|
csp = r.headers.get("content-security-policy", "")
|
||||||
|
assert "connect-src" in csp
|
||||||
|
for origine in SOEURS:
|
||||||
|
assert origine in csp, csp
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifications_api_et_cors_soeur(client):
|
||||||
|
r = client.get("/api/notifications")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert isinstance(body["notifications"], list)
|
||||||
|
assert "unread" in body
|
||||||
|
r2 = client.options("/api/notifications",
|
||||||
|
headers={"Origin": "https://tiktak.dracodev.net",
|
||||||
|
"Access-Control-Request-Method": "GET"})
|
||||||
|
assert r2.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
|
||||||
|
assert r2.headers.get("access-control-allow-credentials") == "true"
|
||||||
|
r3 = client.get("/api/notifications",
|
||||||
|
headers={"Origin": "https://tiktak.dracodev.net"})
|
||||||
|
assert r3.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
|
||||||
|
r4 = client.options("/api/notifications",
|
||||||
|
headers={"Origin": "https://evil.example",
|
||||||
|
"Access-Control-Request-Method": "GET"})
|
||||||
|
assert "access-control-allow-origin" not in r4.headers
|
||||||
|
# Correctif 2026-10-28 : une origine sœur dev passe aussi.
|
||||||
|
r5 = client.options("/api/notifications",
|
||||||
|
headers={"Origin": "https://tiktak-dev.dracodev.net",
|
||||||
|
"Access-Control-Request-Method": "GET"})
|
||||||
|
assert r5.headers.get("access-control-allow-origin") == "https://tiktak-dev.dracodev.net"
|
||||||
|
assert r5.headers.get("access-control-allow-credentials") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
|
||||||
|
"""Chantier 5 : onglet Console Spark en dernier dans la barre
|
||||||
|
(gabarit lu, pages authentifiées inatteignables en sandbox comme
|
||||||
|
pour le test #69), et vue Alertes en onglets (module + styles)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
gabarit = Path("app/templates/_draco_bar.html").read_text()
|
||||||
|
nav = gabarit.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||||
|
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
|
||||||
|
assert "Console Spark" in nav
|
||||||
|
js = client.get("/static/js/draco-alertes.js")
|
||||||
|
assert js.status_code == 200
|
||||||
|
assert "draco-alertes-tab" in js.text
|
||||||
|
assert "historique" in js.text
|
||||||
|
assert "source indisponible" in js.text
|
||||||
|
css = client.get("/static/css/draco-alertes.css")
|
||||||
|
assert css.status_code == 200
|
||||||
|
assert "draco-alertes-tab" in css.text
|
||||||
|
assert "draco-alertes-filtre" in css.text
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user