Files
kanbante/static/js/draco-alertes.js
T
2026-10-28 12:47:06 -04:00

428 lines
17 KiB
JavaScript

/* 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);