From e18cbcdc69c6acd3d08c392a2e0fbae9f1da27f6 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:40 -0400 Subject: [PATCH 1/7] Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : onglet agent --- app/templates/_draco_bar.html | 7 +++++++ 1 file changed, 7 insertions(+) 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 @@ KanbanteKanban + + ✦ + Console SparkAgent commun +
-- 2.54.0 From 0adf10a38aff17e507e23078b344ca69f3242f62 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:43 -0400 Subject: [PATCH 2/7] Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : barre vendangee commons --- static/js/draco-barre.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) 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"); -- 2.54.0 From 9eb47cfa8c6a6572ac5c5badb940698277cb8f6d Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:45 -0400 Subject: [PATCH 3/7] Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : css barre vendangee commons --- static/css/draco-barre.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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; } } -- 2.54.0 From f2b3f95baecfe31724ead34b178577ada75cc145 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:48 -0400 Subject: [PATCH 4/7] Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : module alertes vendange ui/alertes --- static/js/draco-alertes.js | 540 +++++++++++++++++++++++++++++-------- 1 file changed, 431 insertions(+), 109 deletions(-) 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); -- 2.54.0 From 539a28114ede6f8b047bd9b13be69cb3e05c5b30 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:52 -0400 Subject: [PATCH 5/7] Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : styles alertes vendanges --- static/css/draco-alertes.css | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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; } -- 2.54.0 From 5b5e7f2f946025504e29c7204aa25ea0105bec13 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:50:56 -0400 Subject: [PATCH 6/7] Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : tests node 14 --- tests/draco-alertes.test.js | 162 ++++++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 5 deletions(-) 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"); +}); -- 2.54.0 From 883871244ff16ad109de2437b451ba153e9b6f63 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:51:00 -0400 Subject: [PATCH 7/7] Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : test pytest chantier 5 --- tests/test_draco_alertes.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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('