Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : module alertes vendange ui/alertes

This commit is contained in:
2026-10-28 13:51:54 -04:00
parent 183fd316ef
commit 3e042eed2d
+431 -109
View File
@@ -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 * Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
* ouverture du panneau (et au chargement, pour la pastille), le module * ouverture du panneau (et au chargement, pour la pastille), le module
* interroge les API de notifications réellement exposées par chaque * interroge les API de notifications réellement exposées par chaque
* application, avec les identifiants du navigateur (credentials: * application, avec les identifiants du navigateur (credentials:
* "include"). Aucune alerte n'est inventée : une source injoignable * "include"). Aucune alerte n'est inventée : une source injoignable
* ou non authentifiée affiche son état réel (« Connexion requise ») * ou non authentifiée affiche son état réel par section (une section
* avec un lien profond vers sa page native, jamais du vide déguisé. * 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 * - Flowdeck : GET {base}/api/notifications?limit=50
* -> { notifications: [{id, ntype, title, message, url, is_read, * -> { notifications: [{id, ntype, title, message, url, is_read,
* created_at}], unread } ; active = is_read = 0 ; lien = base + url. * created_at}], unread } ; active = is_read = 0 ; lien = base + url.
@@ -23,20 +34,55 @@
* est compté comme actif ; lien = base + "/board/" + repo. * est compté comme actif ; lien = base + "/board/" + repo.
* - ObsiGate : AUCUNE notification entrante consultable. Ses * - ObsiGate : AUCUNE notification entrante consultable. Ses
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets * /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. * 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) : * 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 * 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ; * 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) ; * 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). * 4. défaut officiel (production).
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y * 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 * 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. * 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=<onglet>, partageable, retour arrière via pushState).
*
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme * 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 * seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
* pour la pastille (repli : mise à jour directe du badge). * pour la pastille (repli : mise à jour directe du badge).
@@ -50,23 +96,39 @@
obsigate: "https://og.dracodev.net/", obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/", flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.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 = { var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/", obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/", flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-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 = { var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck", obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante" tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
}; };
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"]; 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 = { var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée", created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commenté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) { function isDevHost(hostname) {
if (!hostname) return false; if (!hostname) return false;
@@ -98,12 +160,16 @@
} }
function fmtDate(s) { function fmtDate(s) {
var t = parseDate(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); } 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 : /* Résolution des bases. `env` permet les tests sans navigateur :
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */ * { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
function resolveUrls(env) { function resolveUrls(env) {
env = env || {}; env = env || {};
var hostname = env.hostname; var hostname = env.hostname;
@@ -112,28 +178,31 @@
} }
var dev = isDevHost(hostname); var dev = isDevHost(hostname);
var urls = {}; var urls = {};
APP_ORDER.forEach(function (app) { ALL_APPS.forEach(function (key) {
var u = cleanUrl(env.barUrls && env.barUrls[app]) var app = key === "console" ? "agent" : key;
|| cleanUrl(env.configUrls && env.configUrls[app]); 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 && dev) u = DEV_URLS[app] || null;
if (!u) { if (!u) {
var href = null; 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) { else if (typeof document !== "undefined" && document.querySelector) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]'); var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
href = tab ? tab.getAttribute("href") : null; href = tab ? tab.getAttribute("href") : null;
} }
u = cleanUrl(href); 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) { if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */ /* ObsiGate : window.DRACO_URLS injecté par le backend. */
try { 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 */ } } catch (e) { /* rien */ }
} }
if (!u) u = DEFAULT_URLS[app]; if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u); urls[out] = withSlash(u);
if (key === "console") urls.console = withSlash(u);
}); });
return urls; return urls;
} }
@@ -151,9 +220,23 @@
} catch (e) { /* rien */ } } catch (e) { /* rien */ }
return { return {
barUrls: bar.urls || null, configUrls: cfg.urls || null, 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) { function normalizeFlowdeck(base, payload) {
var list = (payload && payload.notifications) || []; 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) { function sortAlerts(list) {
return list.slice().sort(function (a, b) { return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1; if (a.active !== b.active) return a.active ? -1 : 1;
@@ -208,9 +332,74 @@
list.forEach(function (a) { if (a.active) n++; }); list.forEach(function (a) { if (a.active) n++; });
return 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) ---- */ /* ---- 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) { function el(tag, cls, text) {
var d = document.createElement(tag); var d = document.createElement(tag);
@@ -226,96 +415,204 @@
var box = el("div", "draco-alertes-box"); var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head"); var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées")); 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", "✕"); var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer"); close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", closePanel); close.addEventListener("click", function () { closePanel(); });
head.appendChild(close); head.appendChild(close);
box.appendChild(head); 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 = el("div", "draco-alertes-list");
listEl.setAttribute("role", "tabpanel");
box.appendChild(listEl); box.appendChild(listEl);
ov.appendChild(box); ov.appendChild(box);
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); }); ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
document.addEventListener("keydown", function (ev) { document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closePanel(); if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
}); });
document.body.appendChild(ov); document.body.appendChild(ov);
panel = ov; panel = ov;
return ov; return ov;
} }
function closePanel() { if (panel) panel.style.display = "none"; } function closePanel() {
if (!panel) return;
function renderSection(title, items, fallback) { panel.style.display = "none";
var sec = el("section", "draco-alertes-section"); if (panelPushed) {
sec.appendChild(el("h3", "", title + " (" + items.length + ")")); panelPushed = false;
if (!items.length) { closingViaBack = true;
sec.appendChild(el("p", "draco-alertes-empty", fallback)); try { history.back(); } catch (e) { closingViaBack = false; }
return sec; return;
} }
items.forEach(function (a) { try {
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : "")); var h = parseAlertTab(window.location.hash);
var main = el("div", "draco-alertes-main"); if (h) {
var link = el("a", "draco-alertes-title", a.title); suppressHash = true;
link.href = a.url; history.replaceState(null, "", window.location.pathname + window.location.search);
main.appendChild(link); suppressHash = false;
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail)); }
var meta = el("div", "draco-alertes-meta", } catch (e) { /* rien */ }
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) { function onHashChange() {
ensurePanel(); if (suppressHash) return;
listEl.textContent = ""; if (closingViaBack) { closingViaBack = false; return; }
var all = []; var tab = parseAlertTab(window.location.hash);
APP_ORDER.forEach(function (app) { if (tab) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); }); if (!panel || panel.style.display === "none") openPanel(tab);
}); else setTab(tab, false);
var actives = sortAlerts(all.filter(function (a) { return a.active; })); } else if (panel && panel.style.display !== "none") {
var history = sortAlerts(all.filter(function (a) { return !a.active; })); panelPushed = false;
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives")); panel.style.display = "none";
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) { function onTabsKeydown(ev) {
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */ var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
if (app === "obsigate") { if (keys.indexOf(ev.key) === -1) return;
var link = el("div", "draco-alertes-empty"); var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
var a = el("a", "", "Ouvrir ObsiGate"); var idx = TABS.indexOf(currentTab);
a.href = urls.obsigate; if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
link.appendChild(a); else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
var sec = el("section", "draco-alertes-section"); else if (ev.key === "Home") idx = 0;
sec.appendChild(el("h3", "", "ObsiGate (0)")); else if (ev.key === "End") idx = TABS.length - 1;
sec.appendChild(link); ev.preventDefault();
listEl.appendChild(sec); setTab(TABS[idx], true);
return; if (btns[idx] && btns[idx].focus) btns[idx].focus();
} }
var items = history.filter(function (x) { return x.app === app; }); function renderTabs() {
listEl.appendChild(renderSection(APP_LABELS[app], items, if (!tabsEl || !currentData) return;
"Aucun historique disponible pour cette application.")); 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) { function fetchJson(url, timeoutMs) {
var ctrl = null, timer = null; var ctrl = null, timer = null;
@@ -342,6 +639,7 @@
} }
function loadAll() { function loadAll() {
var urls = resolveUrls(browserEnv()); var urls = resolveUrls(browserEnv());
var consoleUrl = consoleSourceUrl(browserEnv());
var jobs = { var jobs = {
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50") flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }), .then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
@@ -350,6 +648,11 @@
kanbante: fetchJson(urls.kanbante + "api/events?limit=100") kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; }) .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); var keys = Object.keys(jobs);
return Promise.all(keys.map(function (k) { return Promise.all(keys.map(function (k) {
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) { return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
@@ -361,6 +664,7 @@
})).then(function (pairs) { })).then(function (pairs) {
var results = { obsigate: { items: [] } }; var results = { obsigate: { items: [] } };
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; }); pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
return { urls: urls, results: results }; return { urls: urls, results: results };
}); });
} }
@@ -380,28 +684,35 @@
} }
function refreshBadge() { function refreshBadge() {
return loadAll().then(function (r) { return loadAll().then(function (r) {
var all = []; var n = countActive(allItems(r.results));
APP_ORDER.forEach(function (app) { setBadge(n);
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); return n;
});
setBadge(countActive(all));
return countActive(all);
}).catch(function () { /* pastille optionnelle : la barre vit sans */ }); }).catch(function () { /* pastille optionnelle : la barre vit sans */ });
} }
function openPanel() { function openPanel(initialTab) {
ensurePanel(); ensurePanel();
panel.style.display = "flex"; panel.style.display = "flex";
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
currentTab = tab;
currentFilter = "";
if (filterEl) filterEl.value = "";
listEl.textContent = ""; listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…")); listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
if (tabsEl) tabsEl.textContent = "";
loadAll().then(function (r) { loadAll().then(function (r) {
render(r.results, r.urls); currentData = r;
var all = []; /* Ouverture par le bouton (pas de hash) : l'onglet entre dans
APP_ORDER.forEach(function (app) { * l'URL pour être partageable ; ouverture par lien partagé : le
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); * hash est déjà le bon, on ne pousse pas d'entrée en double. */
}); var fromHash = parseAlertTab(window.location.hash);
setBadge(countActive(all)); if (!fromHash) {
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
}
renderCurrent();
setBadge(countActive(allItems(r.results)));
}).catch(function () { }).catch(function () {
render({ obsigate: { items: [] } }, resolveUrls(browserEnv())); currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
renderCurrent();
}); });
} }
@@ -410,18 +721,29 @@
_test: { _test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck, resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante, normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
sortAlerts: sortAlerts, countActive: countActive, 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; global.DRACO_ALERTES = api;
if (typeof module !== "undefined" && module.exports) module.exports = api._test; 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=<onglet> ouvre directement le panneau
* sur cet onglet. */
if (typeof document !== "undefined" && document.addEventListener) { if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer); if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300); 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); })(typeof window !== "undefined" ? window : globalThis);