/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5 * (onglets + composant partagé + onglet Console Spark). * * SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies * dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des * vendoring byte-identiques de ce fichier — ne pas les modifier sur * place, toute évolution passe par commons puis re-vendoring. * * Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque * ouverture du panneau (et au chargement, pour la pastille), le module * interroge les API de notifications réellement exposées par chaque * application, avec les identifiants du navigateur (credentials: * "include"). Aucune alerte n'est inventée : une source injoignable * ou non authentifiée affiche son état réel par section (une section * en échec n'empêche jamais de consulter les autres), jamais du vide * déguisé. En attendant le correctif CORS des allowlists (autre * chantier), une section inter-origines peut afficher cet état * d'erreur : c'est un état d'erreur transitoire, pas un comportement * normal — aucun état « Injoignable » systématique n'est présenté * comme normal. * * Sources réelles (audit #69, 2026-10-28, inchangé) : * - Flowdeck : GET {base}/api/notifications?limit=50 * -> { notifications: [{id, ntype, title, message, url, is_read, * created_at}], unread } ; active = is_read = 0 ; lien = base + url. * - TikTak : GET {base}/api/notifications * -> [{id, kind, title, body, task_id, is_read, created_at}] ; * active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon * base + "/notifications". * - Kanbante : GET {base}/api/events?limit=100 * -> { events: [{repo_full_name, issue_number, event_type, * from_column, to_column, created_at}] } ; AUCUN état lu/non lu * n'existe dans Kanbante, donc tout y est historique et rien n'y * est compté comme actif ; lien = base + "/board/" + repo. * - ObsiGate : AUCUNE notification entrante consultable. Ses * /api/notify/* sont des canaux d'envoi sortants (admin, secrets * masqués), pas un journal lisible par l'utilisateur. L'onglet * ObsiGate le dit et renvoie vers l'accueil d'ObsiGate. * - Console Spark : AUCUN endpoint d'alertes console n'est publié * sur agent-commun main au moment de ce chantier (autre chantier * en cours). L'onglet Console Spark affiche donc un état vide * HONNÊTE (« source indisponible », lien vers la console) et ne * fabrique aucune alerte. Branchement prévu, sans deviner d'URL : * poser window.DRACO_ALERTES_CONSOLE_URL (ou * DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par * l'autre chantier ; forme attendue : tableau, ou * {alerts:[...]} / {items:[...]}, chaque alerte portant title, * detail|message|context, date|created_at, active (booléen) ou * status/state (pause budget, accès requis, en attente, fermée…), * url|link. Les missions en pause budget / accès requis et les * approbations en attente y apparaîtront avec leurs états réels. * * Résolution des bases (même ordre que barre-apps v0.1.6) : * 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon * window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ; * 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ; * 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet * « Console Spark » ajouté par le chantier 5 porte data-app * "agent", donc la base agent suit le même ordre) ; * 4. défaut officiel (production). * ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y * lit aussi window.DRACO_URLS (injecté par son backend, clé "agent" * incluse). * Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique * que sur un hôte -dev, exactement comme la barre. * * Présentation (identique partout, imposée par ce seul fichier) : * champs par alerte = application, titre, date, état, lien profond * (environnement -dev respecté via la résolution ci-dessus) ; * ordre = date décroissante dans chaque onglet (sans date en * dernier) ; format de date = toLocaleString("fr-FR"), « sans date » * si absente ou illisible ; libellés d'état affichés = « Active », * « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »), * ou l'état réel console quand la source existe. Le type brut * Kanbante (« moved »…) n'est plus affiché : il dupliquait le * libellé d'état en anglais. * Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante, * Console Spark, Historique — dans cet ordre. Tout et chaque onglet * par application ne montrent QUE les alertes actives ; Historique * ne montre QUE les alertes fermées (lues / événements Kanbante / * console fermées). Le filtre texte (titre + contexte) s'applique * APRÈS le périmètre de l'onglet courant : il ne mélange jamais * actifs et historique. L'onglet courant est dans l'URL * (#alertes=, partageable, retour arrière via pushState). * * Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme * seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts * pour la pastille (repli : mise à jour directe du badge). * Insertion des données : textContent uniquement, jamais innerHTML * avec une valeur venue du serveur. */ (function (global) { "use strict"; var DEFAULT_URLS = { obsigate: "https://og.dracodev.net/", flowdeck: "https://flowdeck.dracodev.net/", tiktak: "https://tiktak.dracodev.net/", kanbante: "https://kanbante.dracodev.net/", agent: "https://agent.dracodev.net/" }; var DEV_URLS = { obsigate: "https://og-dev.dracodev.net/", flowdeck: "https://flowdeck-dev.dracodev.net/", tiktak: "https://tiktak-dev.dracodev.net/", kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent-dev.dracodev.net/" }; var APP_LABELS = { obsigate: "ObsiGate", flowdeck: "Flowdeck", tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark" }; var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"]; var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]; var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]; var TAB_LABELS = { tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck", tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark", historique: "Historique" }; var EVENT_LABELS = { created: "créée", moved: "déplacée", closed: "fermée", reopened: "rouverte", commented: "commentée" }; var CONSOLE_ACTIVE_STATUSES = [ "active", "pending", "pause", "pause_budget", "acces_requis", "en_attente", "en attente", "requise", "awaiting" ]; var CONSOLE_CLOSED_STATUSES = [ "closed", "resolved", "resolue", "lue", "terminee", "done", "fermee" ]; function isDevHost(hostname) { if (!hostname) return false; var h = String(hostname).toLowerCase(); if (h.slice(-13) !== ".dracodev.net") return false; var first = h.split(".")[0]; return first.length > 4 && first.slice(-4) === "-dev"; } function cleanUrl(u) { if (!u || typeof u !== "string") return null; if (u.indexOf("{{") !== -1) return null; if (!u.replace(/\s/g, "")) return null; return u; } function withSlash(u) { return u.charAt(u.length - 1) !== "/" ? u + "/" : u; } function joinUrl(base, path) { var b = withSlash(base); if (!path) return b; if (/^https?:\/\//.test(path)) return path; return b + String(path).replace(/^\//, ""); } function parseDate(s) { if (!s) return null; var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1 ? String(s).replace(" ", "T") + "Z" : s); return isNaN(t.getTime()) ? null : t; } function fmtDate(s) { var t = parseDate(s); if (!t) return ""; try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); } } function capitalize(s) { s = String(s || ""); return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; } /* Résolution des bases. `env` permet les tests sans navigateur : * { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */ function resolveUrls(env) { env = env || {}; var hostname = env.hostname; if (hostname === undefined) { try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; } } var dev = isDevHost(hostname); var urls = {}; ALL_APPS.forEach(function (key) { var app = key === "console" ? "agent" : key; var out = key === "console" ? "agent" : key; var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app])) || cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app])); if (!u && dev) u = DEV_URLS[app] || null; if (!u) { var href = null; if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app]; else if (typeof document !== "undefined" && document.querySelector) { var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]'); href = tab ? tab.getAttribute("href") : null; } u = cleanUrl(href); } if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]); if (!u) { /* ObsiGate : window.DRACO_URLS injecté par le backend. */ try { if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]); } catch (e) { /* rien */ } } if (!u) u = DEFAULT_URLS[app]; urls[out] = withSlash(u); if (key === "console") urls.console = withSlash(u); }); return urls; } function browserEnv() { var cfg = global.DRACO_BAR_CONFIG || {}; var bar = global.DRACO_BAR || {}; var tabs = {}; try { if (typeof document !== "undefined" && document.querySelectorAll) { var nodes = document.querySelectorAll(".draco-apptab[data-app]"); for (var i = 0; i < nodes.length; i++) { tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href"); } } } catch (e) { /* rien */ } return { barUrls: bar.urls || null, configUrls: cfg.urls || null, tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null, consoleUrl: null }; } /* URL de la source console, si l'hôte l'a configurée. AUCUNE URL * n'est devinée : sans configuration, la source est indisponible * (état vide honnête, voir renderCurrent). */ function consoleSourceUrl(env) { env = env || {}; if (env.consoleUrl) return env.consoleUrl; try { if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL; var cfg = global.DRACO_BAR_CONFIG || {}; if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl; } catch (e) { /* rien */ } return null; } function normalizeFlowdeck(base, payload) { var list = (payload && payload.notifications) || []; return list.map(function (n) { return { app: "flowdeck", id: n.id, title: n.title || "(sans titre)", detail: n.message || "", date: n.created_at || "", active: !n.is_read, state: n.is_read ? "lue" : "active", kind: n.ntype || "", url: joinUrl(base, n.url || "") }; }); } function normalizeTiktak(base, payload) { var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || []; return list.map(function (n) { return { app: "tiktak", id: n.id, title: n.title || "(sans titre)", detail: n.body || "", date: n.created_at || "", active: !n.is_read, state: n.is_read ? "lue" : "active", kind: n.kind || "", url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications") }; }); } function normalizeKanbante(base, payload) { var list = (payload && payload.events) || []; return list.map(function (e) { var label = EVENT_LABELS[e.event_type] || e.event_type || "événement"; var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || ""); return { app: "kanbante", id: e.id, title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label, detail: detail, date: e.created_at || "", active: false, state: label, kind: e.event_type || "", url: joinUrl(base, "board/" + (e.repo_full_name || "")) }; }); } function normalizeConsole(base, payload) { var list = Array.isArray(payload) ? payload : (payload && (payload.alerts || payload.items)) || []; return list.map(function (n) { var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim(); var stateRaw = String(n.state || status || ""); var active; if (typeof n.active === "boolean") active = n.active; else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true; else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false; else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true; else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false; else active = true; /* source réelle, statut inconnu : on l'expose en active */ return { app: "console", id: n.id, title: n.title || "(sans titre)", detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "", active: active, state: stateRaw || (active ? "en attente" : "fermée"), kind: n.kind || n.type || "", url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base) }; }); } /* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de * normalize* sont inchangées, pour compatibilité des tests #69). */ function displayState(a) { if (a.active) return "Active"; if (a.app === "kanbante") return capitalize(a.state); if (a.app === "console") return capitalize(a.state); return "Lue"; } function metaLine(a) { var date = fmtDate(a.date) || "sans date"; /* Kanbante : le type brut (« moved ») dupliquait le libellé * d'état ; il n'est plus affiché. Les autres apps gardent leur * type quand il apporte une information. */ if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a); var kind = a.kind ? " · " + a.kind : ""; return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a); } /* Actives d'abord, puis date décroissante (sans date en dernier). * Dans un onglet, le périmètre est déjà homogène (que des actives * ou que des fermées) : l'ordre effectif est donc la date décroissante. */ function sortAlerts(list) { return list.slice().sort(function (a, b) { if (a.active !== b.active) return a.active ? -1 : 1; var da = parseDate(a.date), db = parseDate(b.date); if (da && db) return db - da; if (da) return -1; if (db) return 1; return 0; }); } function countActive(list) { var n = 0; list.forEach(function (a) { if (a.active) n++; }); return n; } function allItems(results) { var all = []; ALL_APPS.forEach(function (app) { (results[app] && results[app].items || []).forEach(function (a) { all.push(a); }); }); return all; } /* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }. * Tout et onglets par application = actives seulement ; Historique = * fermées seulement. Les erreurs de section sont remontées dans * chaque onglet concerné (jamais silencieusement avalées). */ function scopeAlerts(all, tab, results) { results = results || {}; var items = [], errors = []; function collect(app, pred) { if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error }); all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); }); } if (tab === "historique") { ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); }); } else if (tab === "tout") { ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); }); } else if (ALL_APPS.indexOf(tab) !== -1) { collect(tab, function (a) { return a.active; }); } else { ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); }); } return { items: sortAlerts(items), errors: errors, consoleMissing: !!(results.console && results.console.sourceMissing) }; } function countByTab(all, results) { var counts = {}; TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; }); return counts; } /* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou * contexte, insensible à la casse, sans jamais mélanger actifs et * historique (le périmètre est déjà fixé par scopeAlerts). */ function filterAlerts(list, q) { q = String(q || "").trim().toLowerCase(); if (!q) return list.slice(); return list.filter(function (a) { return (String(a.title || "").toLowerCase().indexOf(q) !== -1) || (String(a.detail || "").toLowerCase().indexOf(q) !== -1); }); } /* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique", * "#alertes" -> "tout", autre -> null. */ function parseAlertTab(hash) { if (!hash) return null; var h = String(hash).replace(/^#/, ""); if (h === "alertes") return "tout"; var m = /^alertes=([a-z]+)$/.exec(h); if (m && TABS.indexOf(m[1]) !== -1) return m[1]; return null; } function hashForTab(tab) { if (TABS.indexOf(tab) === -1) tab = "tout"; return "#alertes=" + tab; } /* ---- Rendu (navigateur seulement) ---- */ var panel = null, listEl = null, tabsEl = null, filterEl = null, countEl = null, badgeTimer = null; var currentTab = "tout", currentData = null, currentFilter = ""; var panelPushed = false, closingViaBack = false, suppressHash = false; function el(tag, cls, text) { var d = document.createElement(tag); if (cls) d.className = cls; if (text !== undefined && text !== null) d.textContent = text; return d; } function ensurePanel() { if (panel) return panel; var ov = el("div", "draco-alertes-overlay"); ov.setAttribute("role", "dialog"); ov.setAttribute("aria-label", "Alertes unifiées"); var box = el("div", "draco-alertes-box"); var head = el("div", "draco-alertes-head"); head.appendChild(el("h2", "", "Alertes unifiées")); var close = el("button", "draco-alertes-close", "✕"); close.setAttribute("aria-label", "Fermer"); close.addEventListener("click", function () { closePanel(); }); head.appendChild(close); box.appendChild(head); box.appendChild(el("p", "draco-alertes-note", "Agrégation à la demande, pas une fédération temps réel : " + "les alertes sont relues dans chaque application à l'ouverture. " + "Une section en échec n'empêche pas de consulter les autres.")); tabsEl = el("div", "draco-alertes-tabs"); tabsEl.setAttribute("role", "tablist"); tabsEl.setAttribute("aria-label", "Alertes par application"); tabsEl.addEventListener("keydown", onTabsKeydown); box.appendChild(tabsEl); var filtre = el("div", "draco-alertes-filtre"); var lbl = el("label", "", "Filtrer"); lbl.setAttribute("for", "draco-alertes-filtre-input"); filtre.appendChild(lbl); filterEl = el("input", ""); filterEl.type = "search"; filterEl.id = "draco-alertes-filtre-input"; filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…"); filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant"); filterEl.addEventListener("input", function () { currentFilter = filterEl.value; renderCurrent(); }); filtre.appendChild(filterEl); box.appendChild(filtre); countEl = el("p", "draco-alertes-compte", ""); box.appendChild(countEl); listEl = el("div", "draco-alertes-list"); listEl.setAttribute("role", "tabpanel"); box.appendChild(listEl); ov.appendChild(box); ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel(); }); document.body.appendChild(ov); panel = ov; return ov; } function closePanel() { if (!panel) return; panel.style.display = "none"; if (panelPushed) { panelPushed = false; closingViaBack = true; try { history.back(); } catch (e) { closingViaBack = false; } return; } try { var h = parseAlertTab(window.location.hash); if (h) { suppressHash = true; history.replaceState(null, "", window.location.pathname + window.location.search); suppressHash = false; } } catch (e) { /* rien */ } } function onHashChange() { if (suppressHash) return; if (closingViaBack) { closingViaBack = false; return; } var tab = parseAlertTab(window.location.hash); if (tab) { if (!panel || panel.style.display === "none") openPanel(tab); else setTab(tab, false); } else if (panel && panel.style.display !== "none") { panelPushed = false; panel.style.display = "none"; } } function onTabsKeydown(ev) { var keys = ["ArrowLeft", "ArrowRight", "Home", "End"]; if (keys.indexOf(ev.key) === -1) return; var btns = tabsEl.querySelectorAll(".draco-alertes-tab"); var idx = TABS.indexOf(currentTab); if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length; else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length; else if (ev.key === "Home") idx = 0; else if (ev.key === "End") idx = TABS.length - 1; ev.preventDefault(); setTab(TABS[idx], true); if (btns[idx] && btns[idx].focus) btns[idx].focus(); } function renderTabs() { if (!tabsEl || !currentData) return; tabsEl.textContent = ""; var all = allItems(currentData.results); var counts = countByTab(all, currentData.results); TABS.forEach(function (tab) { var b = el("button", "draco-alertes-tab", TAB_LABELS[tab] + " (" + counts[tab] + ")"); b.type = "button"; b.setAttribute("role", "tab"); b.setAttribute("aria-selected", tab === currentTab ? "true" : "false"); b.tabIndex = tab === currentTab ? 0 : -1; b.addEventListener("click", function () { setTab(tab, true); }); tabsEl.appendChild(b); }); } function renderRow(a) { var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : "")); var main = el("div", "draco-alertes-main"); var link = el("a", "draco-alertes-title", a.title); link.href = a.url; main.appendChild(link); if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail)); main.appendChild(el("div", "draco-alertes-meta", metaLine(a))); row.appendChild(main); return row; } function renderErrorRow(app, msg) { var row = el("div", "draco-alertes-item is-error"); var main = el("div", "draco-alertes-main"); main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec")); main.appendChild(el("div", "draco-alertes-detail", msg)); main.appendChild(el("div", "draco-alertes-meta", APP_LABELS[app] + " · section en échec — les autres sections restent consultables")); row.appendChild(main); return row; } function renderCurrent() { if (!listEl || !currentData) return; renderTabs(); var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results); var filtered = filterAlerts(scope.items, currentFilter); var q = String(currentFilter || "").trim(); if (countEl) { countEl.textContent = q ? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »" : ""; } listEl.textContent = ""; /* Onglet Console sans source : état vide honnête, jamais d'alertes * inventées, avec le lien profond vers la console. */ if (currentTab === "console" && scope.consoleMissing) { var sec = el("section", "draco-alertes-section"); sec.appendChild(el("h3", "", "Console Spark (0)")); sec.appendChild(el("p", "draco-alertes-empty", "Aucune alerte console — source indisponible : l'endpoint " + "d'alertes console (agent-commun, autre chantier) n'est pas " + "encore publié. Les missions en pause budget / accès requis " + "et les approbations en attente apparaîtront ici, avec leurs " + "états réels, dès sa livraison.")); var link = el("div", "draco-alertes-empty"); var a = el("a", "", "Ouvrir la Console Spark"); a.href = currentData.urls.agent || currentData.urls.console || "#"; link.appendChild(a); sec.appendChild(link); listEl.appendChild(sec); scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); }); return; } if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) { var secOg = el("section", "draco-alertes-section"); secOg.appendChild(el("h3", "", "ObsiGate (0)")); secOg.appendChild(el("p", "draco-alertes-empty", "ObsiGate n'expose aucune notification entrante consultable " + "(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs).")); var linkOg = el("div", "draco-alertes-empty"); var aOg = el("a", "", "Ouvrir ObsiGate"); aOg.href = currentData.urls.obsigate; linkOg.appendChild(aOg); secOg.appendChild(linkOg); listEl.appendChild(secOg); return; } scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); }); if (!scope.items.length && !scope.errors.length) { var empty = currentTab === "historique" ? "Aucune alerte fermée dans les applications joignables." : currentTab === "tout" ? "Aucune alerte active dans les applications joignables." : "Aucune alerte active pour " + TAB_LABELS[currentTab] + "."; if (currentTab === "console") empty = "Aucune alerte console."; listEl.appendChild(el("p", "draco-alertes-empty", empty)); return; } if (q && !filtered.length) { listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte dans cet onglet pour ce filtre.")); return; } filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); }); } function setTab(tab, push) { if (TABS.indexOf(tab) === -1) tab = "tout"; currentTab = tab; renderCurrent(); if (push) { try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* URL partageable : repli silencieux */ } } } function fetchJson(url, timeoutMs) { var ctrl = null, timer = null; try { if (typeof AbortController !== "undefined") { ctrl = new AbortController(); timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000); } } catch (e) { ctrl = null; } var opts = { credentials: "include" }; if (ctrl) opts.signal = ctrl.signal; return fetch(url, opts).then(function (r) { if (timer) clearTimeout(timer); if (!r.ok) { var err = new Error("HTTP " + r.status); err.status = r.status; throw err; } return r.json(); }).catch(function (e) { if (timer) clearTimeout(timer); throw e; }); } function loadAll() { var urls = resolveUrls(browserEnv()); var consoleUrl = consoleSourceUrl(browserEnv()); var jobs = { flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50") .then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }), tiktak: fetchJson(urls.tiktak + "api/notifications") .then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }), kanbante: fetchJson(urls.kanbante + "api/events?limit=100") .then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; }) }; if (consoleUrl) { var agentBase = urls.agent || urls.console; jobs.console = fetchJson(consoleUrl) .then(function (p) { return { items: normalizeConsole(agentBase, p) }; }); } var keys = Object.keys(jobs); return Promise.all(keys.map(function (k) { return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) { var msg = (e && e.status === 401) ? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau." : "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ")."; return [k, { items: [], error: msg }]; }); })).then(function (pairs) { var results = { obsigate: { items: [] } }; pairs.forEach(function (kv) { results[kv[0]] = kv[1]; }); if (!consoleUrl) results.console = { items: [], sourceMissing: true }; return { urls: urls, results: results }; }); } function setBadge(n) { try { if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") { global.DRACO_BAR.setCounts({ alertes: n }); return; } } catch (e) { /* repli ci-dessous */ } try { document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) { b.textContent = n; b.hidden = !(n > 0); }); } catch (e) { /* rien */ } } function refreshBadge() { return loadAll().then(function (r) { var n = countActive(allItems(r.results)); setBadge(n); return n; }).catch(function () { /* pastille optionnelle : la barre vit sans */ }); } function openPanel(initialTab) { ensurePanel(); panel.style.display = "flex"; var tab = initialTab || parseAlertTab(window.location.hash) || "tout"; currentTab = tab; currentFilter = ""; if (filterEl) filterEl.value = ""; listEl.textContent = ""; listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…")); if (tabsEl) tabsEl.textContent = ""; loadAll().then(function (r) { currentData = r; /* Ouverture par le bouton (pas de hash) : l'onglet entre dans * l'URL pour être partageable ; ouverture par lien partagé : le * hash est déjà le bon, on ne pousse pas d'entrée en double. */ var fromHash = parseAlertTab(window.location.hash); if (!fromHash) { try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ } } renderCurrent(); setBadge(countActive(allItems(r.results))); }).catch(function () { currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } }; renderCurrent(); }); } var api = { open: openPanel, close: closePanel, refreshBadge: refreshBadge, _test: { resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck, normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante, normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl, sortAlerts: sortAlerts, countActive: countActive, filterAlerts: filterAlerts, scopeAlerts: scopeAlerts, countByTab: countByTab, parseAlertTab: parseAlertTab, hashForTab: hashForTab, displayState: displayState, metaLine: metaLine, fmtDate: fmtDate, isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS, TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS } }; global.DRACO_ALERTES = api; if (typeof module !== "undefined" && module.exports) module.exports = api._test; /* Pastille au chargement (après les scripts defer de la barre) ; * un lien partagé #alertes= 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);