Compare commits

..
Author SHA1 Message Date
bruno 3e96ebf3c5 Fusion #69 : Alertes unifiees (tests/frontend/draco-alertes.test.mjs)
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 12:13:23 -04:00
bruno f88c05a053 Fusion #69 : Alertes unifiees (frontend/draco-alertes.css) 2026-10-28 12:13:20 -04:00
bruno 29ce94808d Fusion #69 : Alertes unifiees (frontend/draco-alertes.js) 2026-10-28 12:13:17 -04:00
bruno 6f31275c12 Fusion #69 : Alertes unifiees (backend/main.py) 2026-10-28 12:13:15 -04:00
bruno f11dc26930 Fusion #69 : Alertes unifiees (frontend/index.html) 2026-10-28 12:13:11 -04:00
22 changed files with 264 additions and 2910 deletions
+1 -29
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.82.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.81.3**.
---
@@ -14,34 +14,6 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.82.2] — 2026-10-28
- **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 ; barre de `/recherche` re-vendangée depuis `index.html` (5e onglet Console Spark) et `connect-src` CSP étendu à l'origine agent.
## [2.82.1] — 2026-10-28
### Modifié
- **CORS recherche/alertes (#44)** : allowlist explicite des 8 origines sœurs prod+dev, avec credentials, jamais `*` — chaque serveur n'autorisait que l'environnement résolu (prod OU dev, jamais les deux).
- **Recherche partout (#46)** : page `/recherche` réintégrée dans le gabarit applicatif, focus automatique, « Tout voir » restant sur la page, onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark.
- **Alertes unifiées (#47)** : onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark / Historique (actives sauf Historique = fermées), filtre texte, composant partagé, bouton Console Spark en 5e onglet de l'en-tête après Kanbante.
---
## [2.82.0] — 2026-10-28
### Ajouté
- **Coffre : secrets distribuables (#43)** : nouvel endpoint `GET /api/coffres/distribuables` — authentifié par jeton consommateur (la même vérification que `/distribuer`), il renvoie les métadonnées seules des secrets pour lesquels ce consommateur a un octroi actif (coffre actif, secret non expiré, octroi ni révoqué ni expiré, octrois coffre-wide inclus). Un consommateur sans octroi reçoit une liste vide. Jamais de valeur dans la réponse. Consommateur : la console Spark (agent-commun #14 / PR #15), pour la liste déroulante des clés de fournisseurs LLM.
## [2.81.4] — 2026-10-28
### Modifié
- **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 connect-src sœurs.
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), CORS bornée à `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
## [2.81.3] — 2026-10-28
### Modifié
+1 -1
View File
@@ -1 +1 @@
2.82.2
2.81.3
-79
View File
@@ -531,15 +531,6 @@ app.add_middleware(
allow_headers=["*"],
)
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
# après le CORSMiddleware global donc exécutée avant lui (dernier
# ajouté = le plus externe) : les preflights OPTIONS sont répondus
# ici, et les réponses de /api/search reçoivent les en-têtes sœurs.
# La politique same-origin explicite du middleware global reste la
# règle par défaut pour tout le reste.
from backend.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
# Auth router
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
@@ -959,76 +950,6 @@ if FRONTEND_DIR.exists():
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Admin page not found")
@app.get("/recherche", response_class=HTMLResponse)
async def serve_recherche_page(request: Request):
"""Page « Rechercher partout » (fusion #68) — déclarée AVANT le
catch-all SPA comme /admin.html, sinon index.html serait servi.
frontend/recherche.html est servi avec les jetons {{URL_*}}
remplacés par urls_publiques() (même contrat que le fragment
de barre, jeton {{URL_AGENT}} inclus pour l'onglet Console
Spark de la barre re-vendangée) et le nonce CSP frais ; la
réponse pose sa propre CSP (préservée par
SecurityHeadersMiddleware, qui ne la pose que si elle est
absente) dont le connect-src autorise les origines sœurs ET
l'origine de l'agent — sinon le navigateur bloquerait les
appels sœurs et l'appel console (onglet Console Spark,
GET /api/v1/recherche) avant même CORS. La CSP par défaut
(same-origin) reste inchangée pour toutes les autres pages.
"""
import json as _json
from backend.csp import inject_csp_nonce
from backend.draco_config import urls_publiques
from backend.recherche_cors import origin_of
recherche_file = FRONTEND_DIR / "recherche.html"
if not recherche_file.exists():
raise HTTPException(status_code=404, detail="Recherche page not found")
html = recherche_file.read_text(encoding="utf-8")
urls = urls_publiques()
for jeton, cle in (
("{{URL_OBSIGATE}}", "obsigate"),
("{{URL_FLOWDECK}}", "flowdeck"),
("{{URL_TIKTAK}}", "tiktak"),
("{{URL_KANBANTE}}", "kanbante"),
("{{URL_AGENT}}", "agent"),
):
html = html.replace(jeton, urls.get(cle, ""))
script = (
"<script>window.DRACO_URLS = "
+ _json.dumps(urls).replace("<", "\\u003c")
+ ";</script>"
)
if "</head>" in html:
html = html.replace("</head>", script + "</head>", 1)
else:
html = script + html
html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante",
"agent")
} - {""}))
csp = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{request.state.csp_nonce}'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
f"connect-src 'self' {soeurs}; "
"font-src 'self' data:; "
"worker-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
return HTMLResponse(
content=html,
headers={"Cache-Control": "no-cache", "Content-Security-Policy": csp},
)
@app.get("/{full_path:path}")
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA index.html for all non-API routes."""
-193
View File
@@ -1,193 +0,0 @@
"""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 de ``backend.draco_config`` (``urls_publiques()`` :
explicite > dérivation par l'hôte de l'agent > défaut officiel)
— jamais ``*``. Correctif 2026-10-28 (fusion #68/#69, matrice
constatée par Bruno) : l'ancienne allowlist ne contenait que
l'environnement de la configuration (plus celui de l'hôte de la
requête), jamais les deux à la fois — une page de l'autre
environnement voyait sa section afficher « Injoignable depuis
cet hôte (CORS ou réseau) » ;
- avec credentials (le cookie SSO ``draco_session``, accepté par
``/api/search`` via le repli de ``backend.auth.middleware``, ou
le cookie natif ``access_token``).
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 same-origin explicite du CORSMiddleware
global (``allow_origins=[]``), qui reste la règle par défaut.
"""
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_config.urls_publiques() 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 frontend/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 ``urls_publiques()``.
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 backend.draco_config import urls_publiques
resolved = urls_publiques()
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)
-45
View File
@@ -57,7 +57,6 @@ from backend.coffre_store import (
SecretExpireError,
ValidationError,
compter_octrois_actifs,
est_expire,
compter_secrets,
create_coffre,
create_consommateur,
@@ -496,50 +495,6 @@ async def api_distribuer(request: Request, body: dict = Body(...)):
}
@router.get("/api/coffres/distribuables")
async def api_distribuables(request: Request):
"""Secrets distribuables pour un consommateur (issue #43).
Authentification par jeton consommateur (la même que
/distribuer), jamais par session utilisateur : c'est le
serveur agent qui appelle, pour alimenter la liste déroulante
de ses fournisseurs LLM. Retour = métadonnées seules des
secrets pour lesquels ce consommateur a un octroi ACTIF
(coffre actif, secret non expiré, octroi ni révoqué ni
expiré, y compris les octrois coffre-wide). Un consommateur
sans octroi reçoit une liste vide — jamais d'erreur
révélatrice, jamais de valeur.
"""
_exiger_actives()
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
raise HTTPException(status_code=401,
detail="consommateur_invalide")
if consommateur["statut"] != "actif":
raise HTTPException(status_code=403,
detail="consommateur_revoque")
sortie = []
for coffre in list_coffres():
if coffre["statut"] != "actif":
continue
for secret in list_secrets(coffre["id"]):
if est_expire(secret.get("expiration")):
continue
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"])
if statut != "ok":
continue
sortie.append({
"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
"coffre": coffre["slug"],
"nom": secret["nom"],
"version": secret["version_courante"],
})
return sortie
# ---------------------------------------------------------------------------
# Un coffre, ses secrets, ses octrois, son journal
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.82.2"
version = "2.81.3"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.82.2"
version = "2.81.3"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.82.2",
"version": "2.81.3",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.82.2 | **Dernière mise à jour :** 2026-10-28
> **Version :** 2.81.3 | **Dernière mise à jour :** 2026-10-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
-19
View File
@@ -37,22 +37,3 @@
.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; }
+108 -430
View File
@@ -1,25 +1,14 @@
/* 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.
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
*
* 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.
* ou non authentifiée affiche son état réel (« Connexion requise »)
* avec un lien profond vers sa page native, jamais du vide déguisé.
*
* Sources réelles (audit #69, 2026-10-28, inchangé) :
* Sources réelles (audit #69, 2026-10-28) :
* - 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.
@@ -34,55 +23,20 @@
* 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
* masqués), pas un journal lisible par l'utilisateur. La section
* 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) ;
* 3. href rendu de l'onglet .draco-apptab[data-app] ;
* 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).
* lit aussi window.DRACO_URLS (injecté par son backend).
* 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).
@@ -96,39 +50,23 @@
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/"
kanbante: "https://kanbante.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/"
kanbante: "https://kanbante-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
tiktak: "TikTak", kanbante: "Kanbante"
};
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;
@@ -160,16 +98,12 @@
}
function fmtDate(s) {
var t = parseDate(s);
if (!t) return "";
if (!t) return s ? String(s).slice(0, 16) : "";
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 }. */
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
@@ -178,31 +112,28 @@
}
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]));
APP_ORDER.forEach(function (app) {
var u = cleanUrl(env.barUrls && env.barUrls[app])
|| cleanUrl(env.configUrls && 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];
if (env.tabHrefs && env.tabHrefs[app]) href = 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 && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(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]);
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
urls[out] = withSlash(u);
if (key === "console") urls.console = withSlash(u);
urls[app] = withSlash(u);
});
return urls;
}
@@ -220,23 +151,9 @@
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
consoleUrl: null
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || 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) || [];
@@ -275,48 +192,7 @@
};
});
}
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. */
/* Actives d'abord, puis date décroissante (sans date en dernier). */
function sortAlerts(list) {
return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1;
@@ -332,74 +208,9 @@
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;
var panel = null, listEl = null, badgeTimer = null;
function el(tag, cls, text) {
var d = document.createElement(tag);
@@ -415,203 +226,95 @@
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
var note = el("p", "draco-alertes-note",
"Agrégation à la demande, pas une fédération temps réel : " +
"les alertes sont relues dans chaque application à l'ouverture.");
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", function () { closePanel(); });
close.addEventListener("click", 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);
box.appendChild(note);
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();
if (ev.key === "Escape") 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;
function closePanel() { if (panel) panel.style.display = "none"; }
function renderSection(title, items, fallback) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
if (!items.length) {
sec.appendChild(el("p", "draco-alertes-empty", fallback));
return sec;
}
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);
items.forEach(function (a) {
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
var main = el("div", "draco-alertes-main");
var link = el("a", "draco-alertes-title", a.title);
link.href = a.url;
main.appendChild(link);
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
var meta = el("div", "draco-alertes-meta",
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
main.appendChild(meta);
row.appendChild(main);
sec.appendChild(row);
});
return sec;
}
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 + " »"
: "";
}
function render(results, urls) {
ensurePanel();
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 */ }
var all = [];
APP_ORDER.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
APP_ORDER.forEach(function (app) {
var items = actives.filter(function (a) { return a.app === app; });
if (results[app] && results[app].error) {
var sec = renderSection(APP_LABELS[app], [], results[app].error);
listEl.appendChild(sec);
} else if (app === "obsigate") {
listEl.appendChild(renderSection(APP_LABELS[app], [],
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
} else if (items.length) {
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
}
});
if (!actives.length) {
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
}
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
APP_ORDER.forEach(function (app) {
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
if (app === "obsigate") {
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir ObsiGate");
a.href = urls.obsigate;
link.appendChild(a);
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "ObsiGate (0)"));
sec.appendChild(link);
listEl.appendChild(sec);
return;
}
var items = history.filter(function (x) { return x.app === app; });
listEl.appendChild(renderSection(APP_LABELS[app], items,
"Aucun historique disponible pour cette application."));
});
}
function fetchJson(url, timeoutMs) {
@@ -639,7 +342,6 @@
}
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) }; }),
@@ -648,11 +350,6 @@
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) {
@@ -664,7 +361,6 @@
})).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 };
});
}
@@ -684,35 +380,28 @@
}
function refreshBadge() {
return loadAll().then(function (r) {
var n = countActive(allItems(r.results));
setBadge(n);
return n;
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
return countActive(all);
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel(initialTab) {
function openPanel() {
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)));
render(r.results, r.urls);
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
}).catch(function () {
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
renderCurrent();
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
});
}
@@ -721,29 +410,18 @@
_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
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
}
};
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. */
/* Pastille au chargement (après les scripts defer de la barre). */
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);
+9 -16
View File
@@ -3,10 +3,8 @@
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
v0.1.6 : menus « ••• » et compte (construits par le JS, mêmes jetons
que le menu des applications) ; l'avatar est un vrai <button>.
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -36,7 +34,6 @@
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
@@ -48,15 +45,15 @@
}
.draco-apps-menu[hidden] { display: none; }
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
.draco-more-menu, .draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-compte-menu[hidden] { display: none; }
.draco-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
@@ -177,8 +174,8 @@
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche et les domaines disparaissent :
chaque app garde ses contrôles natifs (barre
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
@@ -192,9 +189,5 @@
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
}
+97 -152
View File
@@ -4,11 +4,7 @@
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
* la publication est séparée) : dérivation -dev par l'hôte courant,
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux »).
* menu « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -51,76 +47,29 @@
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
* La clé spark ne correspond à aucun onglet de la barre : elle est
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls.
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
* Spark (panneau-agent), qui suit le même contrat d'injection.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets, plus
* (depuis le correctif #70) une entrée « Console Spark » vers la
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
* ouvre le menu des applications, construit depuis les onglets ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu compte : voir ci-dessous.
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
* retirées des gabarits hôtes — le menu natif d'ObsiGate
* (#header-menu-btn) lui-même n'est pas touché.
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
*
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
* son menu par défaut (console agent + applications) sont supprimés
* du fragment, du JS et du CSS. La console agent reste joignable par
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
* onglets présents dans la barre, puis une entrée « Console Spark »
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
* 4) défaut officiel prod https://agent.dracodev.net/.
* AUCUN hôte spark-dev n'est inventé :
* la clé spark, elle, suit toujours son propre contrat (explicite >
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
* addGlobalListener), PAS via document.addEventListener. Cause
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
* remplace document/window.addEventListener par un registre
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
* compris ceux de draco-barre.js (script defer, enregistré après le
* tcInit() initial). La barre survit à la navigation (hors .layout
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
* la marque, lien href="/", est interceptée par l'intercepteur SPA
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
* une navigation interne au lieu de basculer le menu. Les écouteurs
* posés sur le prototype ne figurent pas dans le registre et ne sont
* donc jamais supprimés par le teardown ; en complément, le gabarit
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
* la marque ne déclenche plus de navigation douce. Les autres hôtes
* (SSR, rechargement complet) ne sont pas concernés par le teardown
* mais bénéficient du même code ; repli : si EventTarget.prototype
* est indisponible (mini-DOM de tests), document.addEventListener
* classique est utilisé.
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
* connu à gérer l'action "menu" via onAction (il déclenche son propre
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
* pas et le bouton restait mort (relais vers un onAction absent, puis
* événement draco:bar sans écouteur). Comportement désormais :
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
* la console agent (URL agent résolue, donc dérivée -dev sur un
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
* par clic extérieur, Échap ou choix d'un lien ;
* - avec onAction : l'hôte garde la main (comportement historique,
* zéro régression pour ObsiGate) ; il peut demander explicitement
* le menu par défaut en retournant false pour l'action "menu".
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
@@ -274,7 +223,7 @@
}
}
/* ── Menus déroulants (apps, compte) ───────────────────────────────
/* ── Menus déroulants (apps, •••, compte) ──────────────────────────
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
* déclencheur. Les href des menus sont (re)synchronisés avec
@@ -282,11 +231,9 @@
* -dev s'y reflètent même si le menu a été construit avant. */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, compteMenu = null;
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
* lui rend le focus à la fermeture (correctif #70). */
var lastOpener = null;
var appsMenu = null, moreMenu = null, compteMenu = null;
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
@@ -303,13 +250,10 @@
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups(returnFocus) {
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
function closeAllPopups() {
if (appsMenu) togglePopup(appsMenu, brand, false);
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
/* Échap seulement : le clic extérieur ne déplace pas le focus
* (voir l'en-tête, « Focus »). */
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
}
function tabLabel(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
@@ -321,6 +265,28 @@
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
function menuLink(app, nom, role) {
var a = document.createElement("a");
a.className = "draco-apps-item draco-menu-item";
a.href = resolvedUrls[app] || "#";
a.dataset.app = app;
a.setAttribute("role", "menuitem");
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var n = document.createElement("span");
n.className = "draco-apps-item-nom";
n.textContent = nom;
txt.appendChild(n);
if (role) {
var r = document.createElement("span");
r.className = "draco-apps-item-role";
r.textContent = role;
txt.appendChild(r);
}
a.appendChild(txt);
return a;
}
/* Menu des applications (v0.1.3) — construit depuis les onglets
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
@@ -334,11 +300,6 @@
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
* repris ici — l'entrée dédiée ci-dessous le représente déjà
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
* listerait « Console Spark » deux fois. */
if (t.dataset.app === "agent") return;
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
@@ -365,37 +326,6 @@
if (badge) a.appendChild(badge.cloneNode(true));
appsMenu.appendChild(a);
});
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
appsMenu.appendChild(sep);
var sparkItem = document.createElement("a");
sparkItem.className = "draco-apps-item";
sparkItem.href = resolvedUrls.agent || "#";
sparkItem.dataset.app = "agent";
sparkItem.setAttribute("role", "menuitem");
var sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#c2437e");
sparkIco.textContent = "✦";
sparkItem.appendChild(sparkIco);
var sparkTxt = document.createElement("span");
sparkTxt.className = "draco-apps-item-txt";
var sparkNom = document.createElement("span");
sparkNom.className = "draco-apps-item-nom";
sparkNom.textContent = "Console Spark";
sparkTxt.appendChild(sparkNom);
var sparkRole = document.createElement("span");
sparkRole.className = "draco-apps-item-role";
sparkRole.textContent = "Agent commun";
sparkTxt.appendChild(sparkRole);
sparkItem.appendChild(sparkTxt);
appsMenu.appendChild(sparkItem);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
@@ -403,7 +333,34 @@
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
if (!appsMenu.hidden) lastOpener = brand;
}
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
function buildMoreMenu() {
if (!bar || !moreBtn || moreMenu) return;
moreMenu = document.createElement("div");
moreMenu.className = "draco-more-menu";
moreMenu.hidden = true;
moreMenu.setAttribute("role", "menu");
moreMenu.setAttribute("aria-label", "Plus d'actions");
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
moreMenu.appendChild(sep);
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
});
bar.appendChild(moreMenu);
}
function toggleMoreMenu(force) {
buildMoreMenu();
if (!moreMenu) return;
if (force !== false) refreshMenuHrefs(moreMenu);
togglePopup(moreMenu, moreBtn, force);
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
@@ -442,7 +399,6 @@
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
if (!compteMenu.hidden) lastOpener = avatarBtn;
}
/* Relais d'une action vers l'hôte : onAction si présent, sinon
@@ -452,21 +408,7 @@
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
/* Écouteurs globaux posés sur le prototype EventTarget quand il
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
* voir l'en-tête) ; sinon document.addEventListener classique. */
function addGlobalListener(type, fn) {
try {
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
EventTarget.prototype.addEventListener) {
EventTarget.prototype.addEventListener.call(document, type, fn, false);
return;
}
} catch (e) { /* repli ci-dessous */ }
document.addEventListener(type, fn);
}
addGlobalListener("click", function (ev) {
document.addEventListener("click", function (ev) {
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
@@ -477,21 +419,38 @@
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (moreMenu) toggleMoreMenu(false);
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "menu") {
if (appsMenu) toggleAppsMenu(false);
if (compteMenu) toggleCompteMenu(false);
/* Hôte prioritaire : il garde la main, sauf s'il demande
* explicitement le menu par défaut en retournant false.
* Sans onAction, le menu par défaut s'ouvre (il ne restait
* auparavant qu'un événement draco:bar sans écouteur). */
if (typeof cfg.onAction === "function") {
var handled = cfg.onAction("menu", el, ev);
if (handled === false) toggleMoreMenu();
} else {
toggleMoreMenu();
}
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
if (moreMenu) toggleMoreMenu(false);
toggleCompteMenu();
return;
}
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
* sur son déclencheur déjà traité ci-dessus). */
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups(false);
ev.target.closest(".draco-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups();
}
if (!el) return;
var act = el.dataset.act;
@@ -499,23 +458,8 @@
relay(act, el, ev);
});
addGlobalListener("keydown", function (ev) {
if (ev.key === "Escape") { closeAllPopups(true); return; }
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
* ouverture explicite + focus sur le premier item du menu (#70).
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
* ci-dessus (pas de double bascule ici). */
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
var brandEl = ev.target.closest('[data-act="apps"]');
if (brandEl) {
if (ev.preventDefault) ev.preventDefault();
toggleAppsMenu();
if (appsMenu && !appsMenu.hidden) {
var first = appsMenu.querySelector(".draco-apps-item");
if (first && typeof first.focus === "function") first.focus();
}
}
}
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeAllPopups();
});
/* API publique : l'app met les compteurs à jour sans recharger.
@@ -537,6 +481,7 @@
applyUrls(resolvedUrls);
buildAppsMenu();
buildMoreMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
+4 -11
View File
@@ -336,11 +336,7 @@
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js.
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
(data-app="agent", défaut prod ; la réécriture DRACO_URLS
couvre la clé "agent" comme les autres onglets), après Kanbante ;
l'entrée « Console Spark » du menu de la marque est conservée. -->
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
@@ -366,10 +362,6 @@
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="https://agent.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
@@ -395,6 +387,7 @@
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div>
</header>
@@ -440,8 +433,8 @@
if (b) b.click();
return;
}
if (act === "palette") { location.href = "/recherche"; return; }
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
File diff suppressed because it is too large Load Diff
-90
View File
@@ -1,90 +0,0 @@
/* 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; }
-153
View File
@@ -1,153 +0,0 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rechercher partout — ObsiGate</title>
<!-- Gabarit ObsiGate (chantier 2+3) : la page /recherche est servie
hors SPA (route déclarée avant le catch-all, comme /admin.html),
elle reprend donc les feuilles de l'app (style.css + barre) et
la structure de index.html (barre de fusion, en-tête, sidebar,
zone de contenu) au lieu de la page nue précédente.
Jetons {{URL_*}} remplacés par la route /recherche (même contrat
que le fragment de barre) ; servie brute via /static, la page
retomberait sur les défauts prod (les jetons sont ignorés par
le JS) — jamais d'URL inventée.
La barre ci-dessous est une copie statique du snippet figé de
index.html (commons v0.1.6 + chantier 5 fusion #69/#71, sans
bump) : les onglets sont des liens réels, le bouton central
replace le curseur dans la boîte (recherche.js,
clic data-act="palette"). Re-vendoring alignement recherche :
5e onglet Console Spark (data-app="agent", jeton {{URL_AGENT}}
remplacé par la route /recherche comme les 4 autres) et
icône SVG du bouton Alertes, repris d'index.html. Les menus,
le panneau Spark, les alertes et le domaine restent des
comportements de la SPA et ne sont pas rejoués ici.
Sidebar statique (navigation de l'app) : les vaults, tags et
récents dynamiques vivent dans la SPA, pas sur cette page. -->
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/draco-barre.css">
<link rel="stylesheet" href="/static/recherche.css">
</head>
<body class="has-draco-bar rp-og">
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="{{URL_OBSIGATE}}">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="{{URL_FLOWDECK}}">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="{{URL_TIKTAK}}">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="{{URL_KANBANTE}}">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="{{URL_AGENT}}">
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div>
</header>
<div id="app" class="app-container">
<header class="header">
<div class="header-left">
<div class="header-logo">ObsiGate</div>
<a href="/">Retour à l'application</a>
</div>
</header>
<div class="main-body">
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation ObsiGate">
<nav class="rp-og-nav">
<a href="/">Tableau de bord</a>
<a href="/recherche" aria-current="page">Rechercher partout</a>
<p class="rp-og-note">La navigation par vaults (fichiers, tags, récents) vit dans l'application ObsiGate.</p>
</nav>
</aside>
<div class="content-wrapper">
<div class="content-area">
<main class="rp-page" id="recherche" data-host="obsigate"
data-base-obsigate="{{URL_OBSIGATE}}"
data-base-flowdeck="{{URL_FLOWDECK}}"
data-base-tiktak="{{URL_TIKTAK}}"
data-base-kanbante="{{URL_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=""
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>
</div>
</div>
</div>
</div>
<script src="/static/js/recherche.js"></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.82.2",
"version": "2.81.3",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+39 -234
View File
@@ -1,14 +1,11 @@
#!/usr/bin/env node
/**
* ObsiGate — Alertes unifiées (Fusion #69, chantier 2 + chantier 5).
* ObsiGate — Alertes unifiées (Fusion #69, chantier 2).
*
* Tests du module partagé frontend/draco-alertes.js (logique pure,
* sans DOM) + vérification du gabarit livré frontend/index.html.
* Copie vendangée byte-identique de Projets/dracodev-commons
* ui/alertes/ (chantier 5) — seul le chargement change (ESM ici).
* 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.
* sans DOM) + vérification du gabarit livré frontend/index.html :
* le placeholder « Alertes unifiées : arrive avec la fédération »
* a disparu et le module est chargé.
*
* Usage: node tests/frontend/draco-alertes.test.mjs
*/
@@ -24,226 +21,56 @@ const REPO_ROOT = path.resolve(__dirname, "..", "..");
const require = createRequire(import.meta.url);
const t = require(path.join(REPO_ROOT, "frontend", "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", () => {
assert.equal(
t.resolveUrls({ hostname: "og.dracodev.net", tabHrefs: {} }).tiktak,
"https://tiktak.dracodev.net/"
);
assert.equal(
t.resolveUrls({ hostname: "og-dev.dracodev.net", tabHrefs: {} }).flowdeck,
"https://flowdeck-dev.dracodev.net/"
);
assert.equal(
t.resolveUrls({
hostname: "og-dev.dracodev.net",
configUrls: { kanbante: "https://kanbante.dracodev.net/" },
}).kanbante,
"https://kanbante.dracodev.net/"
);
assert.equal(t.isDevHost("og-dev.dracodev.net.evil.com"), false);
});
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/", {
test("normalizeFlowdeck/TikTak/Kanbante: actifs = non lus, Kanbante jamais actif", () => {
const fd = 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,
{ id: 1, ntype: "mention", title: "Mention", message: "m", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10: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(fd[0].active, true);
assert.equal(fd[0].url, "https://flowdeck.dracodev.net/collections/3");
const tt = t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel", body: "", task_id: 42, is_read: 0, created_at: "2026-10-28T08: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/", {
assert.equal(tt[0].url, "https://tiktak.dracodev.net/?task=42");
const kb = 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/);
assert.equal(kb[0].active, false);
assert.equal(kb[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
assert.equal(t.countActive([...fd, ...tt, ...kb]), 2);
});
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
const list = [
test("sortAlerts: actives d'abord, date decroissante", () => {
const sorted = t.sortAlerts([
{ 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");
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
});
test("index.html: placeholder Alertes disparu, module charge", () => {
@@ -253,25 +80,3 @@ test("index.html: placeholder Alertes disparu, module charge", () => {
assert.equal(html.includes("/static/draco-alertes.css"), true);
assert.equal(html.includes("DRACO_ALERTES.open()"), true);
});
test("index.html chantier 5: onglet Console Spark en dernier, apres Kanbante", () => {
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
const nav = /<nav class="draco-apptabs"[^>]*>([\s\S]*?)<\/nav>/.exec(html)[1];
const apps = [...nav.matchAll(/data-app="([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(apps, ["obsigate", "flowdeck", "tiktak", "kanbante", "agent"]);
assert.ok(nav.includes('data-app="agent" href="https://agent.dracodev.net/"'));
assert.ok(nav.includes("<strong>Console Spark</strong>"));
// La réécriture DRACO_URLS couvre la clé "agent" (même boucle que les autres onglets).
assert.ok(html.includes('window.DRACO_URLS[a.dataset.app]'));
});
test("draco-alertes.js/.css chantier 5: onglets, filtre, etat console honnete", () => {
const js = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.js"), "utf8");
assert.ok(js.includes("draco-alertes-tab"));
assert.ok(js.includes("historique"));
assert.ok(js.includes("source indisponible"));
assert.ok(js.includes("DRACO_ALERTES_CONSOLE_URL"));
const css = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.css"), "utf8");
assert.ok(css.includes(".draco-alertes-tab"));
assert.ok(css.includes(".draco-alertes-filtre"));
});
-179
View File
@@ -1,179 +0,0 @@
#!/usr/bin/env node
/**
* ObsiGate — tests de la logique pure de la page « Rechercher partout »
* (fusion #68), sans DOM ni jsdom : résolution des bases, liens
* profonds et normalisation des 4 formats d'API.
*
* Le fichier frontend/js/recherche.js est chargé en ESM (frontend/js
* est "type": "module") ; ses helpers sont exposés via
* globalThis.RecherchePartout. Usage : node tests/frontend/recherche.test.mjs
*/
import { strict as assert } from "node:assert";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_FILE = path.resolve(__dirname, "..", "..", "frontend", "js", "recherche.js");
await import(pathToFileURL(JS_FILE).href);
const rp = globalThis.RecherchePartout;
assert.ok(rp, "RecherchePartout exposé");
let n = 0;
const ok = (cond, label) => {
assert.ok(cond, label);
n += 1;
};
ok(rp.isDevHost("og-dev.dracodev.net") === true, "dev reconnu");
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
const b = rp.resolveBases({
host: "og-dev.dracodev.net",
explicit: { tiktak: "https://tiktak-perso.example/" },
barUrls: {},
tabHrefs: {},
});
ok(b.tiktak === "https://tiktak-perso.example/", "explicite prioritaire");
ok(b.obsigate === "https://og-dev.dracodev.net/", "dev pour l'hôte courant");
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
ok(
rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8",
"path ObsiGate",
);
const 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[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md",
"lien profond ObsiGate",
);
ok(
rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") ===
"https://tiktak-dev.dracodev.net/search?q=q",
"repli TikTak",
);
/* ---- 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) ---- */
let rc;
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)");
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.mjs : ${n} assertions OK`);
-100
View File
@@ -44,8 +44,6 @@ from backend.coffre_crypto import (
)
from backend.coffre_store import (
CoffreExisteError,
create_octroi,
list_secrets,
SecretExpireError,
ValidationError,
create_coffre,
@@ -133,17 +131,6 @@ def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VAL
return resp.json()
def _creer_secret(client, slug="personnel", nom="second",
valeur=VALEUR_TEST_2):
resp = client.post(
f"/api/coffres/{slug}/secrets",
json={"nom": nom, "type": "token", "service": "Test",
"valeur": valeur},
)
assert resp.status_code == 200, resp.text
return resp.json()
def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
assert resp.status_code == 200, resp.text
@@ -691,90 +678,3 @@ class TestConsommateurAvecOctrois:
assert len(resp.json()["octrois"]) == 1
assert len(resp.json()["erreurs"]) == 1
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
# ---------------------------------------------------------------------------
# Liste des distribuables pour un consommateur (issue #43)
# ---------------------------------------------------------------------------
class TestDistribuables:
def _lister(self, client, jeton=None):
headers = {}
if jeton is not None:
headers["Authorization"] = f"Bearer {jeton}"
return client.get("/api/coffres/distribuables", headers=headers)
def test_ne_liste_que_les_octrois_actifs_metadonnees_seules(
self, client, env_coffre):
_creer_coffre_secret(client) # personnel/gitea-token
_creer_secret(client, nom="sans-octroi")
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
resp = self._lister(client, jeton)
assert resp.status_code == 200, resp.text
assert resp.json() == [{
"reference": "coffre:personnel/gitea-token",
"coffre": "personnel",
"nom": "gitea-token",
"version": 1,
}]
assert VALEUR_TEST not in resp.text
assert VALEUR_TEST_2 not in resp.text
assert "valeur" not in resp.text
# Octroi révoqué : le secret disparaît, sans erreur.
client.delete(f"/api/coffres/octrois/{octroi['id']}")
assert self._lister(client, jeton).json() == []
def test_octroi_expire_exclu_et_coffre_wide_inclus(
self, client, env_coffre):
from datetime import datetime, timedelta, timezone
_creer_coffre_secret(client)
_creer_secret(client, nom="second")
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
# Octroi expiré (hier) sur « second » : exclu.
coffre = get_coffre_or_404("personnel")
second = [x for x in list_secrets(coffre["id"])
if x["nom"] == "second"][0]
create_octroi(coffre, "consommateur", consommateur["id"],
second["id"], "bruno",
expiration=(datetime.now(timezone.utc)
- timedelta(days=1)).isoformat())
# Octroi coffre-wide actif : couvre tous les secrets.
resp = client.post(
"/api/coffres/personnel/octrois",
json={"cible_type": "consommateur",
"cible_id": consommateur["id"],
"secret_nom": None},
)
# Si l'API refuse le coffre-wide sans nom, on le crée direct.
if resp.status_code != 200:
create_octroi(coffre, "consommateur", consommateur["id"],
None, "bruno")
refs = {x["reference"]
for x in self._lister(client, jeton).json()}
assert "coffre:personnel/gitea-token" in refs
assert "coffre:personnel/second" in refs
def test_auth_consommateur_uniquement(self, client, env_coffre):
_creer_coffre_secret(client)
_creer_consommateur_octroi(client)
# Sans jeton : 401 (la session utilisateur ne suffit pas).
assert self._lister(client).status_code == 401
# Jeton inconnu : 401, sans révéler d'inventaire.
assert self._lister(client, "jeton-inconnu").status_code == 401
# Consommateur sans octroi : liste vide, pas d'erreur.
resp = client.post("/api/coffres/consommateurs",
json={"nom": "autre-app"})
autre_jeton = resp.json()["jeton"]
assert self._lister(client, autre_jeton).json() == []
def test_consommateur_revoque_et_flag_desactive(
self, client, env_coffre, monkeypatch):
_creer_coffre_secret(client)
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
resp = self._lister(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "consommateur_revoque"
# Flag désactivé : 503 comme les autres routes.
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
assert self._lister(client, jeton).status_code == 503
-85
View File
@@ -1,85 +0,0 @@
"""Tests de la recherche fédérée ObsiGate (fusion #68).
Fonctions pures de backend/recherche_cors.py uniquement : aucune base,
aucun serveur.
"""
from backend.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("og-dev.dracodev.net") is True
assert is_dev_host("og.dracodev.net") is False
assert is_dev_host("x-dev.dracodev.net.evil") is False
assert is_prod_host("og.dracodev.net") is True
assert is_prod_host("og-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("https://og-dev.dracodev.net/chemin") == "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="og-dev.dracodev.net"
)
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og.dracodev.net"
)
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net"
)
def test_cors_headers():
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
assert cors_headers_for("https://og-dev.dracodev.net.evil.example",
RESOLVED_DEV) is None
# Autre environnement autorisé (correctif 2026-10-28).
assert cors_headers_for("https://og.dracodev.net",
RESOLVED_DEV) is not None
def test_preflight_headers():
headers = preflight_headers("https://flowdeck-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None