From 8b84306f610f26d6b48c932e6228d6f0993e8235 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 13:51:18 -0400 Subject: [PATCH] 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);