From c50e0e5d0e75e8744d7c45fcd6d6a75f6459cfa3 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:50:25 -0400 Subject: [PATCH] =?UTF-8?q?Fusion=20#69=20:=20alertes=20unifi=C3=A9es=20(r?= =?UTF-8?q?ebase=20sur=20main=20post-#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/draco-alertes.js | 427 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 frontend/draco-alertes.js diff --git a/frontend/draco-alertes.js b/frontend/draco-alertes.js new file mode 100644 index 0000000..b2aed2c --- /dev/null +++ b/frontend/draco-alertes.js @@ -0,0 +1,427 @@ +/* Alertes unifiées dracodev — Fusion #69 (chantier 2). + * + * Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque + * ouverture du panneau (et au chargement, pour la pastille), le module + * interroge les API de notifications réellement exposées par chaque + * application, avec les identifiants du navigateur (credentials: + * "include"). Aucune alerte n'est inventée : une source injoignable + * ou non authentifiée affiche son état réel (« Connexion requise ») + * avec un lien profond vers sa page native, jamais du vide déguisé. + * + * Sources réelles (audit #69, 2026-10-28) : + * - Flowdeck : GET {base}/api/notifications?limit=50 + * -> { notifications: [{id, ntype, title, message, url, is_read, + * created_at}], unread } ; active = is_read = 0 ; lien = base + url. + * - 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. La section + * ObsiGate le dit et renvoie vers l'accueil d'ObsiGate. + * + * 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] ; + * 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). + * 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. + * + * 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/" + }; + 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/" + }; + var APP_LABELS = { + obsigate: "ObsiGate", flowdeck: "Flowdeck", + tiktak: "TikTak", kanbante: "Kanbante" + }; + var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"]; + var EVENT_LABELS = { + created: "créée", moved: "déplacée", closed: "fermée", + reopened: "rouverte", commented: "commentée" + }; + + 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 s ? String(s).slice(0, 16) : ""; + try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); } + } + + /* Résolution des bases. `env` permet les tests sans navigateur : + * { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */ + 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 = {}; + APP_ORDER.forEach(function (app) { + var u = cleanUrl(env.barUrls && env.barUrls[app]) + || cleanUrl(env.configUrls && env.configUrls[app]); + if (!u && dev) u = DEV_URLS[app] || null; + if (!u) { + var href = null; + if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app]; + else if (typeof document !== "undefined" && document.querySelector) { + var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]'); + href = tab ? tab.getAttribute("href") : null; + } + u = cleanUrl(href); + } + if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(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]); + } catch (e) { /* rien */ } + } + if (!u) u = DEFAULT_URLS[app]; + urls[app] = 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 + }; + } + + 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 || "")) + }; + }); + } + /* Actives d'abord, puis date décroissante (sans date en dernier). */ + function sortAlerts(list) { + return list.slice().sort(function (a, b) { + if (a.active !== b.active) return a.active ? -1 : 1; + 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; + } + + /* ---- Rendu (navigateur seulement) ---- */ + var panel = null, listEl = null, badgeTimer = null; + + 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 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); + head.appendChild(close); + box.appendChild(head); + box.appendChild(note); + listEl = el("div", "draco-alertes-list"); + 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(); + }); + 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; + } + items.forEach(function (a) { + var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : "")); + var main = el("div", "draco-alertes-main"); + var link = el("a", "draco-alertes-title", a.title); + link.href = a.url; + main.appendChild(link); + if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail)); + var meta = el("div", "draco-alertes-meta", + APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") + + " · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state); + main.appendChild(meta); + row.appendChild(main); + sec.appendChild(row); + }); + return sec; + } + function 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.")); + } + listEl.appendChild(el("h3", "draco-alertes-group", "Historique")); + APP_ORDER.forEach(function (app) { + if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */ + if (app === "obsigate") { + var link = el("div", "draco-alertes-empty"); + var a = el("a", "", "Ouvrir ObsiGate"); + a.href = urls.obsigate; + link.appendChild(a); + var sec = el("section", "draco-alertes-section"); + sec.appendChild(el("h3", "", "ObsiGate (0)")); + sec.appendChild(link); + listEl.appendChild(sec); + return; + } + var items = history.filter(function (x) { return x.app === app; }); + listEl.appendChild(renderSection(APP_LABELS[app], items, + "Aucun historique disponible pour cette application.")); + }); + } + + function fetchJson(url, timeoutMs) { + 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 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) }; }) + }; + 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]; }); + 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 all = []; + APP_ORDER.forEach(function (app) { + (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); + }); + setBadge(countActive(all)); + return countActive(all); + }).catch(function () { /* pastille optionnelle : la barre vit sans */ }); + } + function openPanel() { + ensurePanel(); + panel.style.display = "flex"; + listEl.textContent = ""; + listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…")); + 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)); + }).catch(function () { + render({ obsigate: { items: [] } }, resolveUrls(browserEnv())); + }); + } + + var api = { + open: openPanel, close: closePanel, refreshBadge: refreshBadge, + _test: { + resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck, + normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante, + sortAlerts: sortAlerts, countActive: countActive, + isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS + } + }; + global.DRACO_ALERTES = api; + if (typeof module !== "undefined" && module.exports) module.exports = api._test; + + /* Pastille au chargement (après les scripts defer de la barre). */ + if (typeof document !== "undefined" && document.addEventListener) { + document.addEventListener("DOMContentLoaded", function () { + if (badgeTimer) clearTimeout(badgeTimer); + badgeTimer = setTimeout(refreshBadge, 300); + }); + } +})(typeof window !== "undefined" ? window : globalThis);