diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html
index 98b2995..da164c7 100644
--- a/app/templates/_draco_bar.html
+++ b/app/templates/_draco_bar.html
@@ -5,6 +5,9 @@
d'agents sont INJECTÉES par l'hôte depuis app.config
(draco_urls / agent_url, défauts = noms officiels) — aucun nom
public codé en dur dans ce gabarit ni dans draco-panneau.js.
+ Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
+ (data-app="agent", agent_url), après Kanbante ; l'entrée
+ « Console Spark » du menu de la marque est conservée.
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #}
@@ -37,6 +40,10 @@
KanbanteKanban0
+
+ ✦
+ Console SparkAgent commun
+
diff --git a/static/css/draco-alertes.css b/static/css/draco-alertes.css
index 0d0582e..b509743 100644
--- a/static/css/draco-alertes.css
+++ b/static/css/draco-alertes.css
@@ -37,3 +37,22 @@
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
.draco-alertes-empty a { color: inherit; }
+/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
+.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
+.draco-alertes-tab {
+ border: 1px solid var(--border, #333); background: transparent;
+ color: inherit; border-radius: 999px; padding: 5px 11px;
+ cursor: pointer; font-size: 13px; line-height: 1.3;
+}
+.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
+.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
+.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
+.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
+.draco-alertes-filtre input {
+ flex: 1; min-width: 0; background: transparent; color: inherit;
+ border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
+ font-size: 13px;
+}
+.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
+.draco-alertes-compte:empty { display: none; }
+.draco-alertes-item.is-error { border-color: #c2437e; }
diff --git a/static/css/draco-barre.css b/static/css/draco-barre.css
index bcb79eb..ad654dc 100644
--- a/static/css/draco-barre.css
+++ b/static/css/draco-barre.css
@@ -192,5 +192,9 @@
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
- .draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
+ .draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
+ /* Chantier 5 : sur la console elle-même (data-current="agent",
+ programme console #71), c'est l'onglet Console Spark qui reste
+ visible en mobile — les 4 hôtes gardent leur propre courant. */
+ .draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
}
diff --git a/static/js/draco-alertes.js b/static/js/draco-alertes.js
index b2aed2c..a89988b 100644
--- a/static/js/draco-alertes.js
+++ b/static/js/draco-alertes.js
@@ -1,14 +1,25 @@
-/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
+/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
+ * (onglets + composant partagé + onglet Console Spark).
+ *
+ * SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
+ * dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
+ * vendoring byte-identiques de ce fichier — ne pas les modifier sur
+ * place, toute évolution passe par commons puis re-vendoring.
*
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
* ouverture du panneau (et au chargement, pour la pastille), le module
* interroge les API de notifications réellement exposées par chaque
* application, avec les identifiants du navigateur (credentials:
* "include"). Aucune alerte n'est inventée : une source injoignable
- * ou non authentifiée affiche son état réel (« Connexion requise »)
- * avec un lien profond vers sa page native, jamais du vide déguisé.
+ * ou non authentifiée affiche son état réel par section (une section
+ * en échec n'empêche jamais de consulter les autres), jamais du vide
+ * déguisé. En attendant le correctif CORS des allowlists (autre
+ * chantier), une section inter-origines peut afficher cet état
+ * d'erreur : c'est un état d'erreur transitoire, pas un comportement
+ * normal — aucun état « Injoignable » systématique n'est présenté
+ * comme normal.
*
- * Sources réelles (audit #69, 2026-10-28) :
+ * Sources réelles (audit #69, 2026-10-28, inchangé) :
* - Flowdeck : GET {base}/api/notifications?limit=50
* -> { notifications: [{id, ntype, title, message, url, is_read,
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
@@ -23,20 +34,55 @@
* est compté comme actif ; lien = base + "/board/" + repo.
* - ObsiGate : AUCUNE notification entrante consultable. Ses
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
- * masqués), pas un journal lisible par l'utilisateur. La section
+ * masqués), pas un journal lisible par l'utilisateur. L'onglet
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
+ * - Console Spark : AUCUN endpoint d'alertes console n'est publié
+ * sur agent-commun main au moment de ce chantier (autre chantier
+ * en cours). L'onglet Console Spark affiche donc un état vide
+ * HONNÊTE (« source indisponible », lien vers la console) et ne
+ * fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
+ * poser window.DRACO_ALERTES_CONSOLE_URL (ou
+ * DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
+ * l'autre chantier ; forme attendue : tableau, ou
+ * {alerts:[...]} / {items:[...]}, chaque alerte portant title,
+ * detail|message|context, date|created_at, active (booléen) ou
+ * status/state (pause budget, accès requis, en attente, fermée…),
+ * url|link. Les missions en pause budget / accès requis et les
+ * approbations en attente y apparaîtront avec leurs états réels.
*
* Résolution des bases (même ordre que barre-apps v0.1.6) :
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
- * 3. href rendu de l'onglet .draco-apptab[data-app] ;
+ * 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
+ * « Console Spark » ajouté par le chantier 5 porte data-app
+ * "agent", donc la base agent suit le même ordre) ;
* 4. défaut officiel (production).
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
- * lit aussi window.DRACO_URLS (injecté par son backend).
+ * lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
+ * incluse).
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
* que sur un hôte -dev, exactement comme la barre.
*
+ * Présentation (identique partout, imposée par ce seul fichier) :
+ * champs par alerte = application, titre, date, état, lien profond
+ * (environnement -dev respecté via la résolution ci-dessus) ;
+ * ordre = date décroissante dans chaque onglet (sans date en
+ * dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
+ * si absente ou illisible ; libellés d'état affichés = « Active »,
+ * « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
+ * ou l'état réel console quand la source existe. Le type brut
+ * Kanbante (« moved »…) n'est plus affiché : il dupliquait le
+ * libellé d'état en anglais.
+ * Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
+ * Console Spark, Historique — dans cet ordre. Tout et chaque onglet
+ * par application ne montrent QUE les alertes actives ; Historique
+ * ne montre QUE les alertes fermées (lues / événements Kanbante /
+ * console fermées). Le filtre texte (titre + contexte) s'applique
+ * APRÈS le périmètre de l'onglet courant : il ne mélange jamais
+ * actifs et historique. L'onglet courant est dans l'URL
+ * (#alertes=, partageable, retour arrière via pushState).
+ *
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
* pour la pastille (repli : mise à jour directe du badge).
@@ -50,23 +96,39 @@
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
- kanbante: "https://kanbante.dracodev.net/"
+ kanbante: "https://kanbante.dracodev.net/",
+ agent: "https://agent.dracodev.net/"
};
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
- kanbante: "https://kanbante-dev.dracodev.net/"
+ kanbante: "https://kanbante-dev.dracodev.net/",
+ agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
- tiktak: "TikTak", kanbante: "Kanbante"
+ tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
};
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
+ var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
+ var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
+ var TAB_LABELS = {
+ tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
+ tiktak: "TikTak", kanbante: "Kanbante",
+ console: "Console Spark", historique: "Historique"
+ };
var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commentée"
};
+ var CONSOLE_ACTIVE_STATUSES = [
+ "active", "pending", "pause", "pause_budget", "acces_requis",
+ "en_attente", "en attente", "requise", "awaiting"
+ ];
+ var CONSOLE_CLOSED_STATUSES = [
+ "closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
+ ];
function isDevHost(hostname) {
if (!hostname) return false;
@@ -98,12 +160,16 @@
}
function fmtDate(s) {
var t = parseDate(s);
- if (!t) return s ? String(s).slice(0, 16) : "";
+ if (!t) return "";
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
}
+ function capitalize(s) {
+ s = String(s || "");
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
+ }
/* Résolution des bases. `env` permet les tests sans navigateur :
- * { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
+ * { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
@@ -112,28 +178,31 @@
}
var dev = isDevHost(hostname);
var urls = {};
- APP_ORDER.forEach(function (app) {
- var u = cleanUrl(env.barUrls && env.barUrls[app])
- || cleanUrl(env.configUrls && env.configUrls[app]);
+ ALL_APPS.forEach(function (key) {
+ var app = key === "console" ? "agent" : key;
+ var out = key === "console" ? "agent" : key;
+ var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
+ || cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
if (!u && dev) u = DEV_URLS[app] || null;
if (!u) {
var href = null;
- if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app];
+ if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
else if (typeof document !== "undefined" && document.querySelector) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
href = tab ? tab.getAttribute("href") : null;
}
u = cleanUrl(href);
}
- if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]);
+ if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
try {
- if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
+ if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
- urls[app] = withSlash(u);
+ urls[out] = withSlash(u);
+ if (key === "console") urls.console = withSlash(u);
});
return urls;
}
@@ -151,9 +220,23 @@
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
- tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null
+ tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
+ consoleUrl: null
};
}
+ /* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
+ * n'est devinée : sans configuration, la source est indisponible
+ * (état vide honnête, voir renderCurrent). */
+ function consoleSourceUrl(env) {
+ env = env || {};
+ if (env.consoleUrl) return env.consoleUrl;
+ try {
+ if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
+ var cfg = global.DRACO_BAR_CONFIG || {};
+ if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
+ } catch (e) { /* rien */ }
+ return null;
+ }
function normalizeFlowdeck(base, payload) {
var list = (payload && payload.notifications) || [];
@@ -192,7 +275,48 @@
};
});
}
- /* Actives d'abord, puis date décroissante (sans date en dernier). */
+ function normalizeConsole(base, payload) {
+ var list = Array.isArray(payload) ? payload
+ : (payload && (payload.alerts || payload.items)) || [];
+ return list.map(function (n) {
+ var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
+ var stateRaw = String(n.state || status || "");
+ var active;
+ if (typeof n.active === "boolean") active = n.active;
+ else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
+ else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
+ else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
+ else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
+ else active = true; /* source réelle, statut inconnu : on l'expose en active */
+ return {
+ app: "console", id: n.id, title: n.title || "(sans titre)",
+ detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
+ active: active, state: stateRaw || (active ? "en attente" : "fermée"),
+ kind: n.kind || n.type || "",
+ url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
+ };
+ });
+ }
+ /* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
+ * normalize* sont inchangées, pour compatibilité des tests #69). */
+ function displayState(a) {
+ if (a.active) return "Active";
+ if (a.app === "kanbante") return capitalize(a.state);
+ if (a.app === "console") return capitalize(a.state);
+ return "Lue";
+ }
+ function metaLine(a) {
+ var date = fmtDate(a.date) || "sans date";
+ /* Kanbante : le type brut (« moved ») dupliquait le libellé
+ * d'état ; il n'est plus affiché. Les autres apps gardent leur
+ * type quand il apporte une information. */
+ if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
+ var kind = a.kind ? " · " + a.kind : "";
+ return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
+ }
+ /* Actives d'abord, puis date décroissante (sans date en dernier).
+ * Dans un onglet, le périmètre est déjà homogène (que des actives
+ * ou que des fermées) : l'ordre effectif est donc la date décroissante. */
function sortAlerts(list) {
return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1;
@@ -208,9 +332,74 @@
list.forEach(function (a) { if (a.active) n++; });
return n;
}
+ function allItems(results) {
+ var all = [];
+ ALL_APPS.forEach(function (app) {
+ (results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
+ });
+ return all;
+ }
+ /* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
+ * Tout et onglets par application = actives seulement ; Historique =
+ * fermées seulement. Les erreurs de section sont remontées dans
+ * chaque onglet concerné (jamais silencieusement avalées). */
+ function scopeAlerts(all, tab, results) {
+ results = results || {};
+ var items = [], errors = [];
+ function collect(app, pred) {
+ if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
+ all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
+ }
+ if (tab === "historique") {
+ ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
+ } else if (tab === "tout") {
+ ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
+ } else if (ALL_APPS.indexOf(tab) !== -1) {
+ collect(tab, function (a) { return a.active; });
+ } else {
+ ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
+ }
+ return {
+ items: sortAlerts(items), errors: errors,
+ consoleMissing: !!(results.console && results.console.sourceMissing)
+ };
+ }
+ function countByTab(all, results) {
+ var counts = {};
+ TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
+ return counts;
+ }
+ /* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
+ * contexte, insensible à la casse, sans jamais mélanger actifs et
+ * historique (le périmètre est déjà fixé par scopeAlerts). */
+ function filterAlerts(list, q) {
+ q = String(q || "").trim().toLowerCase();
+ if (!q) return list.slice();
+ return list.filter(function (a) {
+ return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
+ || (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
+ });
+ }
+ /* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
+ * "#alertes" -> "tout", autre -> null. */
+ function parseAlertTab(hash) {
+ if (!hash) return null;
+ var h = String(hash).replace(/^#/, "");
+ if (h === "alertes") return "tout";
+ var m = /^alertes=([a-z]+)$/.exec(h);
+ if (m && TABS.indexOf(m[1]) !== -1) return m[1];
+ return null;
+ }
+ function hashForTab(tab) {
+ if (TABS.indexOf(tab) === -1) tab = "tout";
+ return "#alertes=" + tab;
+ }
/* ---- Rendu (navigateur seulement) ---- */
- var panel = null, listEl = null, badgeTimer = null;
+ var panel = null, listEl = null, tabsEl = null, filterEl = null,
+ countEl = null, badgeTimer = null;
+ var currentTab = "tout", currentData = null, currentFilter = "";
+ var panelPushed = false, closingViaBack = false, suppressHash = false;
function el(tag, cls, text) {
var d = document.createElement(tag);
@@ -226,96 +415,204 @@
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
- var note = el("p", "draco-alertes-note",
- "Agrégation à la demande, pas une fédération temps réel : " +
- "les alertes sont relues dans chaque application à l'ouverture.");
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
- close.addEventListener("click", closePanel);
+ close.addEventListener("click", function () { closePanel(); });
head.appendChild(close);
box.appendChild(head);
- box.appendChild(note);
+ box.appendChild(el("p", "draco-alertes-note",
+ "Agrégation à la demande, pas une fédération temps réel : " +
+ "les alertes sont relues dans chaque application à l'ouverture. " +
+ "Une section en échec n'empêche pas de consulter les autres."));
+ tabsEl = el("div", "draco-alertes-tabs");
+ tabsEl.setAttribute("role", "tablist");
+ tabsEl.setAttribute("aria-label", "Alertes par application");
+ tabsEl.addEventListener("keydown", onTabsKeydown);
+ box.appendChild(tabsEl);
+ var filtre = el("div", "draco-alertes-filtre");
+ var lbl = el("label", "", "Filtrer");
+ lbl.setAttribute("for", "draco-alertes-filtre-input");
+ filtre.appendChild(lbl);
+ filterEl = el("input", "");
+ filterEl.type = "search";
+ filterEl.id = "draco-alertes-filtre-input";
+ filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
+ filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
+ filterEl.addEventListener("input", function () {
+ currentFilter = filterEl.value;
+ renderCurrent();
+ });
+ filtre.appendChild(filterEl);
+ box.appendChild(filtre);
+ countEl = el("p", "draco-alertes-compte", "");
+ box.appendChild(countEl);
listEl = el("div", "draco-alertes-list");
+ listEl.setAttribute("role", "tabpanel");
box.appendChild(listEl);
ov.appendChild(box);
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
document.addEventListener("keydown", function (ev) {
- if (ev.key === "Escape") closePanel();
+ if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
- function closePanel() { if (panel) panel.style.display = "none"; }
-
- function renderSection(title, items, fallback) {
- var sec = el("section", "draco-alertes-section");
- sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
- if (!items.length) {
- sec.appendChild(el("p", "draco-alertes-empty", fallback));
- return sec;
+ function closePanel() {
+ if (!panel) return;
+ panel.style.display = "none";
+ if (panelPushed) {
+ panelPushed = false;
+ closingViaBack = true;
+ try { history.back(); } catch (e) { closingViaBack = false; }
+ return;
}
- items.forEach(function (a) {
- var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
- var main = el("div", "draco-alertes-main");
- var link = el("a", "draco-alertes-title", a.title);
- link.href = a.url;
- main.appendChild(link);
- if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
- var meta = el("div", "draco-alertes-meta",
- APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
- " · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
- main.appendChild(meta);
- row.appendChild(main);
- sec.appendChild(row);
- });
- return sec;
+ try {
+ var h = parseAlertTab(window.location.hash);
+ if (h) {
+ suppressHash = true;
+ history.replaceState(null, "", window.location.pathname + window.location.search);
+ suppressHash = false;
+ }
+ } catch (e) { /* rien */ }
}
- function render(results, urls) {
- ensurePanel();
- listEl.textContent = "";
- var all = [];
- APP_ORDER.forEach(function (app) {
- (results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
- });
- var actives = sortAlerts(all.filter(function (a) { return a.active; }));
- var history = sortAlerts(all.filter(function (a) { return !a.active; }));
- listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
- APP_ORDER.forEach(function (app) {
- var items = actives.filter(function (a) { return a.app === app; });
- if (results[app] && results[app].error) {
- var sec = renderSection(APP_LABELS[app], [], results[app].error);
- listEl.appendChild(sec);
- } else if (app === "obsigate") {
- listEl.appendChild(renderSection(APP_LABELS[app], [],
- "ObsiGate n'expose aucune notification entrante consultable " +
- "(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
- } else if (items.length) {
- listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
- }
- });
- if (!actives.length) {
- listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
+ function onHashChange() {
+ if (suppressHash) return;
+ if (closingViaBack) { closingViaBack = false; return; }
+ var tab = parseAlertTab(window.location.hash);
+ if (tab) {
+ if (!panel || panel.style.display === "none") openPanel(tab);
+ else setTab(tab, false);
+ } else if (panel && panel.style.display !== "none") {
+ panelPushed = false;
+ panel.style.display = "none";
}
- listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
- APP_ORDER.forEach(function (app) {
- if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
- if (app === "obsigate") {
- var link = el("div", "draco-alertes-empty");
- var a = el("a", "", "Ouvrir ObsiGate");
- a.href = urls.obsigate;
- link.appendChild(a);
- var sec = el("section", "draco-alertes-section");
- sec.appendChild(el("h3", "", "ObsiGate (0)"));
- sec.appendChild(link);
- listEl.appendChild(sec);
- return;
- }
- var items = history.filter(function (x) { return x.app === app; });
- listEl.appendChild(renderSection(APP_LABELS[app], items,
- "Aucun historique disponible pour cette application."));
+ }
+ function onTabsKeydown(ev) {
+ var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
+ if (keys.indexOf(ev.key) === -1) return;
+ var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
+ var idx = TABS.indexOf(currentTab);
+ if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
+ else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
+ else if (ev.key === "Home") idx = 0;
+ else if (ev.key === "End") idx = TABS.length - 1;
+ ev.preventDefault();
+ setTab(TABS[idx], true);
+ if (btns[idx] && btns[idx].focus) btns[idx].focus();
+ }
+ function renderTabs() {
+ if (!tabsEl || !currentData) return;
+ tabsEl.textContent = "";
+ var all = allItems(currentData.results);
+ var counts = countByTab(all, currentData.results);
+ TABS.forEach(function (tab) {
+ var b = el("button", "draco-alertes-tab",
+ TAB_LABELS[tab] + " (" + counts[tab] + ")");
+ b.type = "button";
+ b.setAttribute("role", "tab");
+ b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
+ b.tabIndex = tab === currentTab ? 0 : -1;
+ b.addEventListener("click", function () { setTab(tab, true); });
+ tabsEl.appendChild(b);
});
}
+ function renderRow(a) {
+ var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
+ var main = el("div", "draco-alertes-main");
+ var link = el("a", "draco-alertes-title", a.title);
+ link.href = a.url;
+ main.appendChild(link);
+ if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
+ main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
+ row.appendChild(main);
+ return row;
+ }
+ function renderErrorRow(app, msg) {
+ var row = el("div", "draco-alertes-item is-error");
+ var main = el("div", "draco-alertes-main");
+ main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
+ main.appendChild(el("div", "draco-alertes-detail", msg));
+ main.appendChild(el("div", "draco-alertes-meta",
+ APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
+ row.appendChild(main);
+ return row;
+ }
+ function renderCurrent() {
+ if (!listEl || !currentData) return;
+ renderTabs();
+ var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
+ var filtered = filterAlerts(scope.items, currentFilter);
+ var q = String(currentFilter || "").trim();
+ if (countEl) {
+ countEl.textContent = q
+ ? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
+ : "";
+ }
+ listEl.textContent = "";
+ /* Onglet Console sans source : état vide honnête, jamais d'alertes
+ * inventées, avec le lien profond vers la console. */
+ if (currentTab === "console" && scope.consoleMissing) {
+ var sec = el("section", "draco-alertes-section");
+ sec.appendChild(el("h3", "", "Console Spark (0)"));
+ sec.appendChild(el("p", "draco-alertes-empty",
+ "Aucune alerte console — source indisponible : l'endpoint " +
+ "d'alertes console (agent-commun, autre chantier) n'est pas " +
+ "encore publié. Les missions en pause budget / accès requis " +
+ "et les approbations en attente apparaîtront ici, avec leurs " +
+ "états réels, dès sa livraison."));
+ var link = el("div", "draco-alertes-empty");
+ var a = el("a", "", "Ouvrir la Console Spark");
+ a.href = currentData.urls.agent || currentData.urls.console || "#";
+ link.appendChild(a);
+ sec.appendChild(link);
+ listEl.appendChild(sec);
+ scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
+ return;
+ }
+ if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
+ var secOg = el("section", "draco-alertes-section");
+ secOg.appendChild(el("h3", "", "ObsiGate (0)"));
+ secOg.appendChild(el("p", "draco-alertes-empty",
+ "ObsiGate n'expose aucune notification entrante consultable " +
+ "(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
+ var linkOg = el("div", "draco-alertes-empty");
+ var aOg = el("a", "", "Ouvrir ObsiGate");
+ aOg.href = currentData.urls.obsigate;
+ linkOg.appendChild(aOg);
+ secOg.appendChild(linkOg);
+ listEl.appendChild(secOg);
+ return;
+ }
+ scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
+ if (!scope.items.length && !scope.errors.length) {
+ var empty = currentTab === "historique"
+ ? "Aucune alerte fermée dans les applications joignables."
+ : currentTab === "tout"
+ ? "Aucune alerte active dans les applications joignables."
+ : "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
+ if (currentTab === "console") empty = "Aucune alerte console.";
+ listEl.appendChild(el("p", "draco-alertes-empty", empty));
+ return;
+ }
+ if (q && !filtered.length) {
+ listEl.appendChild(el("p", "draco-alertes-empty",
+ "Aucune alerte dans cet onglet pour ce filtre."));
+ return;
+ }
+ filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
+ }
+ function setTab(tab, push) {
+ if (TABS.indexOf(tab) === -1) tab = "tout";
+ currentTab = tab;
+ renderCurrent();
+ if (push) {
+ try {
+ history.pushState(null, "", hashForTab(tab));
+ panelPushed = true;
+ } catch (e) { /* URL partageable : repli silencieux */ }
+ }
+ }
function fetchJson(url, timeoutMs) {
var ctrl = null, timer = null;
@@ -342,6 +639,7 @@
}
function loadAll() {
var urls = resolveUrls(browserEnv());
+ var consoleUrl = consoleSourceUrl(browserEnv());
var jobs = {
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
@@ -350,6 +648,11 @@
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
};
+ if (consoleUrl) {
+ var agentBase = urls.agent || urls.console;
+ jobs.console = fetchJson(consoleUrl)
+ .then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
+ }
var keys = Object.keys(jobs);
return Promise.all(keys.map(function (k) {
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
@@ -361,6 +664,7 @@
})).then(function (pairs) {
var results = { obsigate: { items: [] } };
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
+ if (!consoleUrl) results.console = { items: [], sourceMissing: true };
return { urls: urls, results: results };
});
}
@@ -380,28 +684,35 @@
}
function refreshBadge() {
return loadAll().then(function (r) {
- var all = [];
- APP_ORDER.forEach(function (app) {
- (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
- });
- setBadge(countActive(all));
- return countActive(all);
+ var n = countActive(allItems(r.results));
+ setBadge(n);
+ return n;
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
- function openPanel() {
+ function openPanel(initialTab) {
ensurePanel();
panel.style.display = "flex";
+ var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
+ currentTab = tab;
+ currentFilter = "";
+ if (filterEl) filterEl.value = "";
listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
+ if (tabsEl) tabsEl.textContent = "";
loadAll().then(function (r) {
- render(r.results, r.urls);
- var all = [];
- APP_ORDER.forEach(function (app) {
- (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
- });
- setBadge(countActive(all));
+ currentData = r;
+ /* Ouverture par le bouton (pas de hash) : l'onglet entre dans
+ * l'URL pour être partageable ; ouverture par lien partagé : le
+ * hash est déjà le bon, on ne pousse pas d'entrée en double. */
+ var fromHash = parseAlertTab(window.location.hash);
+ if (!fromHash) {
+ try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
+ }
+ renderCurrent();
+ setBadge(countActive(allItems(r.results)));
}).catch(function () {
- render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
+ currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
+ renderCurrent();
});
}
@@ -410,18 +721,29 @@
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
+ normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
sortAlerts: sortAlerts, countActive: countActive,
- isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
+ filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
+ countByTab: countByTab, parseAlertTab: parseAlertTab,
+ hashForTab: hashForTab, displayState: displayState,
+ metaLine: metaLine, fmtDate: fmtDate,
+ isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
+ TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
}
};
global.DRACO_ALERTES = api;
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
- /* Pastille au chargement (après les scripts defer de la barre). */
+ /* Pastille au chargement (après les scripts defer de la barre) ;
+ * un lien partagé #alertes= ouvre directement le panneau
+ * sur cet onglet. */
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300);
+ var tab = parseAlertTab(window.location.hash);
+ if (tab) setTimeout(function () { openPanel(tab); }, 350);
});
+ window.addEventListener("hashchange", onHashChange);
}
})(typeof window !== "undefined" ? window : globalThis);
diff --git a/static/js/draco-barre.js b/static/js/draco-barre.js
index d5a5261..eeaa965 100644
--- a/static/js/draco-barre.js
+++ b/static/js/draco-barre.js
@@ -51,9 +51,13 @@
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
- * Les clés agent et spark ne correspondent à aucun onglet de la barre :
- * elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
- * Spark (panneau-agent), qui suit le même contrat d'injection.
+ * La clé spark ne correspond à aucun onglet de la barre : elle est
+ * résolue et exposée via DRACO_BAR.urls pour le panneau Spark
+ * (panneau-agent), qui suit le même contrat d'injection. La clé agent,
+ * elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
+ * TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
+ * rendu » comme les autres onglets, en plus d'alimenter le menu de
+ * la marque et DRACO_BAR.urls.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
@@ -79,9 +83,10 @@
* 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) défaut officiel
- * prod https://agent.dracodev.net/ (il n'y a pas d'onglet agent, donc
- * pas d'étape « href rendu »). AUCUN hôte spark-dev n'est inventé :
+ * 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
@@ -329,6 +334,11 @@
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
+ /* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
+ * repris ici — l'entrée dédiée ci-dessous le représente déjà
+ * (après Kanbante, avec séparateur) ; sans ce saut, le menu
+ * listerait « Console Spark » deux fois. */
+ if (t.dataset.app === "agent") return;
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
diff --git a/tests/draco-alertes.test.js b/tests/draco-alertes.test.js
index dc5371f..2fa125f 100644
--- a/tests/draco-alertes.test.js
+++ b/tests/draco-alertes.test.js
@@ -1,18 +1,61 @@
// Tests unitaires (node:test) du module partagé draco-alertes.js.
// Exécutés dans la sandbox : node --test draco-alertes.test.js
+// Copie vendangée byte-identique de Projets/dracodev-commons
+// ui/alertes/ (chantier 5) — seul le require change selon l'hôte.
+// Hôte : Kanbante
// Aucune donnée inventée : les payloads reprennent la forme exacte
// des API réelles (Flowdeck /api/notifications, TikTak
-// /api/notifications, Kanbante /api/events).
+// /api/notifications, Kanbante /api/events). Le payload console
+// n'est qu'un EXEMPLE DE FORMAT (voir README commons) : aucun
+// endpoint console n'est publié sur agent-commun main au moment
+// du chantier.
const test = require("node:test");
const assert = require("node:assert/strict");
const t = require("../static/js/draco-alertes.js");
-test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
+function sampleResults() {
+ return {
+ flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
+ notifications: [
+ { id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
+ { id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
+ ], unread: 1,
+ }) },
+ tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
+ { id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
+ { id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
+ ]) },
+ kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
+ events: [
+ { id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
+ { id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
+ ],
+ }) },
+ obsigate: { items: [] },
+ console: { items: [], sourceMissing: true },
+ };
+}
+function allOf(results) {
+ const all = [];
+ for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
+ (results[app] && results[app].items || []).forEach((a) => all.push(a));
+ }
+ return all;
+}
+
+test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
+ assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
+ assert.equal(t.TAB_LABELS.console, "Console Spark");
+});
+
+test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
+ assert.equal(prod.agent, "https://agent.dracodev.net/");
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
+ assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
const explicit = t.resolveUrls({
hostname: "tiktak-dev.dracodev.net",
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
@@ -20,9 +63,10 @@ test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
const href = t.resolveUrls({
hostname: "example.org",
- tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/" },
+ tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
});
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
+ assert.equal(href.agent, "https://agent.dracodev.net/");
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
@@ -34,8 +78,7 @@ test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
notifications: [
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
- ],
- unread: 1,
+ ], unread: 1,
});
assert.equal(items.length, 2);
assert.equal(items[0].active, true);
@@ -82,3 +125,112 @@ test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
assert.equal(sorted[3].date, "");
assert.equal(t.countActive(list), 2);
});
+
+test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
+ const r = sampleResults();
+ const all = allOf(r);
+ const tout = t.scopeAlerts(all, "tout", r);
+ assert.equal(tout.items.length, 2);
+ assert.ok(tout.items.every((a) => a.active));
+ assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
+ const fd = t.scopeAlerts(all, "flowdeck", r);
+ assert.equal(fd.items.length, 1);
+ assert.equal(fd.items[0].app, "flowdeck");
+ const hist = t.scopeAlerts(all, "historique", r);
+ assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
+ assert.ok(hist.items.every((a) => !a.active));
+ const og = t.scopeAlerts(all, "obsigate", r);
+ assert.equal(og.items.length, 0);
+ const cs = t.scopeAlerts(all, "console", r);
+ assert.equal(cs.items.length, 0);
+ assert.equal(cs.consoleMissing, true);
+});
+
+test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
+ const r = sampleResults();
+ r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
+ const hist = t.scopeAlerts(allOf(r), "historique", r);
+ assert.equal(hist.errors.length, 1);
+ assert.equal(hist.errors[0].app, "flowdeck");
+ const tout = t.scopeAlerts(allOf(r), "tout", r);
+ assert.equal(tout.errors.length, 1);
+});
+
+test("countByTab: compteurs par onglet sur l'echantillon", () => {
+ const r = sampleResults();
+ const c = t.countByTab(allOf(r), r);
+ assert.deepEqual(c, {
+ tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
+ kanbante: 0, console: 0, historique: 4,
+ });
+});
+
+test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
+ const r = sampleResults();
+ const all = allOf(r);
+ const tout = t.scopeAlerts(all, "tout", r).items;
+ assert.equal(t.filterAlerts(tout, "budget").length, 1);
+ assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
+ assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
+ assert.equal(t.filterAlerts(tout, "tache").length, 1);
+ // « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
+ assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
+ const hist = t.scopeAlerts(all, "historique", r).items;
+ assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
+ assert.equal(t.filterAlerts(tout, "").length, tout.length);
+});
+
+test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
+ const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
+ events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
+ })[0];
+ assert.ok(!t.metaLine(kb).includes("moved"));
+ assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
+ assert.equal(t.displayState({ active: true }), "Active");
+ assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
+ const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
+ assert.match(t.metaLine(sansDate), /sans date/);
+});
+
+test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
+ assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
+ assert.equal(t.parseAlertTab("#alertes=console"), "console");
+ assert.equal(t.parseAlertTab("#alertes"), "tout");
+ assert.equal(t.parseAlertTab("#autre"), null);
+ assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
+ assert.equal(t.parseAlertTab(""), null);
+ for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
+ assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
+});
+
+test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
+ const items = t.normalizeConsole("https://agent.dracodev.net/", {
+ alerts: [
+ { id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
+ { id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
+ { id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
+ { id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
+ { id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
+ ],
+ });
+ assert.equal(items.length, 5);
+ assert.equal(items[0].active, true);
+ assert.equal(items[1].active, true);
+ assert.equal(items[2].active, true);
+ assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
+ assert.equal(items[3].active, false);
+ assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
+ assert.equal(items[0].url, "https://agent.dracodev.net/");
+ const r = sampleResults();
+ r.console = { items };
+ const c = t.countByTab(allOf(r), r);
+ assert.equal(c.console, 4);
+ assert.equal(c.tout, 6);
+ assert.equal(c.historique, 5);
+});
+
+test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
+ assert.equal(t.consoleSourceUrl({}), null);
+ assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
+ "https://agent-dev.dracodev.net/api/alertes");
+});
diff --git a/tests/test_draco_alertes.py b/tests/test_draco_alertes.py
index 15ad7e4..11f609f 100644
--- a/tests/test_draco_alertes.py
+++ b/tests/test_draco_alertes.py
@@ -1,4 +1,4 @@
-"""Fusion #69 — Alertes unifiées (chantier 2, Kanbante).
+"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Kanbante).
Kanbante n'a pas de table de notifications : sa source réelle est le
journal d'événements, déjà exposé par GET /api/events (aucun nouvel
@@ -44,3 +44,20 @@ def test_cors_events_soeur_preflight_get_et_tiers(client):
headers={"Origin": "https://evil.example",
"Access-Control-Request-Method": "GET"})
assert "access-control-allow-origin" not in r3.headers
+
+
+def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
+ """Chantier 5 : onglet Console Spark en dernier dans la barre, et
+ vue Alertes en onglets (module + styles vendangés depuis commons)."""
+ r = client.get("/")
+ assert r.status_code == 200
+ nav = r.text.split('