Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e78ae3c60 | ||
|
|
b72d2607a8 | ||
|
|
2235d0be02 | ||
|
|
699600eda3 |
@@ -5,9 +5,6 @@
|
||||
d'agents sont INJECTÉES par l'hôte depuis app.config
|
||||
(draco_urls / agent_url, défauts = noms officiels) — aucun nom
|
||||
public codé en dur dans ce gabarit ni dans draco-panneau.js.
|
||||
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
|
||||
(data-app="agent", agent_url), après Kanbante ; l'entrée
|
||||
« Console Spark » du menu de la marque est conservée.
|
||||
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
|
||||
Inclus par _header.html (toutes les pages applicatives ; ni le
|
||||
wallboard ni l'écran de connexion, volontaires). #}
|
||||
@@ -40,10 +37,6 @@
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="agent" href="{{ agent_url }}/">
|
||||
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
|
||||
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
|
||||
@@ -15,13 +15,22 @@
|
||||
data-base-obsigate="{{ draco_urls.obsigate }}"
|
||||
data-base-flowdeck="{{ draco_urls.flowdeck }}"
|
||||
data-base-tiktak="{{ draco_urls.tiktak }}"
|
||||
data-base-kanbante="{{ draco_urls.kanbante }}">
|
||||
data-base-kanbante="{{ draco_urls.kanbante }}"
|
||||
data-base-console="{{ agent_url }}">
|
||||
<h1>Rechercher partout</h1>
|
||||
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
|
||||
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
</div>
|
||||
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
|
||||
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
|
||||
</nav>
|
||||
<section class="rp-section" data-rp-section="obsigate">
|
||||
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
@@ -38,6 +47,10 @@
|
||||
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
<section class="rp-section" data-rp-section="console">
|
||||
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
|
||||
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
|
||||
@@ -37,22 +37,3 @@
|
||||
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||
.draco-alertes-empty a { color: inherit; }
|
||||
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
|
||||
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
|
||||
.draco-alertes-tab {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 999px; padding: 5px 11px;
|
||||
cursor: pointer; font-size: 13px; line-height: 1.3;
|
||||
}
|
||||
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
|
||||
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
|
||||
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
|
||||
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
|
||||
.draco-alertes-filtre input {
|
||||
flex: 1; min-width: 0; background: transparent; color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
|
||||
.draco-alertes-compte:empty { display: none; }
|
||||
.draco-alertes-item.is-error { border-color: #c2437e; }
|
||||
|
||||
@@ -192,9 +192,5 @@
|
||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
|
||||
/* Chantier 5 : sur la console elle-même (data-current="agent",
|
||||
programme console #71), c'est l'onglet Console Spark qui reste
|
||||
visible en mobile — les 4 hôtes gardent leur propre courant. */
|
||||
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
||||
}
|
||||
|
||||
@@ -33,3 +33,58 @@
|
||||
.rp-title { display: block; font-weight: 600; }
|
||||
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||
|
||||
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||
.rp-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid #888; margin-bottom: 20px;
|
||||
}
|
||||
.rp-tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
|
||||
color: inherit; opacity: .7; white-space: nowrap;
|
||||
}
|
||||
.rp-tab:hover { opacity: 1; }
|
||||
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
|
||||
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
.rp-tab-count {
|
||||
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
|
||||
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
||||
}
|
||||
.rp-tab-count:empty { display: none; }
|
||||
.rp-section[hidden] { display: none; }
|
||||
.rp-all[hidden] { display: none; }
|
||||
|
||||
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
|
||||
* Cause : recherche.html étend base.html sans layout_class, donc
|
||||
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
|
||||
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
|
||||
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
|
||||
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
|
||||
* la largeur disponible, sans toucher aux autres pages (elles ne
|
||||
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
|
||||
* la grille n'a qu'une colonne : la page la prend entièrement. */
|
||||
.layout > .rp-page {
|
||||
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.layout > .rp-page { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* ---- ObsiGate : navigation statique de la page autonome (chantier
|
||||
* 2+3). La page /recherche est servie hors SPA (route avant le
|
||||
* catch-all) : elle reprend les classes de l'app (style.css :
|
||||
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
|
||||
* .content-area) et cette navigation, calée sur les variables de
|
||||
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
|
||||
* la SPA (voir le commentaire dans recherche.html). */
|
||||
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
|
||||
.rp-og-nav a {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
|
||||
.rp-og-nav a[aria-current="page"] {
|
||||
background: rgba(127, 127, 127, .18); font-weight: 700;
|
||||
}
|
||||
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
|
||||
|
||||
+108
-430
@@ -1,25 +1,14 @@
|
||||
/* 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.
|
||||
/* 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 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.
|
||||
* 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, inchangé) :
|
||||
* 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.
|
||||
@@ -34,55 +23,20 @@
|
||||
* 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
|
||||
* masqués), pas un journal lisible par l'utilisateur. La section
|
||||
* 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) ;
|
||||
* 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, clé "agent"
|
||||
* incluse).
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||
* pour la pastille (repli : mise à jour directe du badge).
|
||||
@@ -96,39 +50,23 @@
|
||||
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/"
|
||||
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/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
kanbante: "https://kanbante-dev.dracodev.net/"
|
||||
};
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
|
||||
tiktak: "TikTak", kanbante: "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 = {
|
||||
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;
|
||||
@@ -160,16 +98,12 @@
|
||||
}
|
||||
function fmtDate(s) {
|
||||
var t = parseDate(s);
|
||||
if (!t) return "";
|
||||
if (!t) return s ? String(s).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 :
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
|
||||
function resolveUrls(env) {
|
||||
env = env || {};
|
||||
var hostname = env.hostname;
|
||||
@@ -178,31 +112,28 @@
|
||||
}
|
||||
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]));
|
||||
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[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
|
||||
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[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
|
||||
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[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
|
||||
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[out] = withSlash(u);
|
||||
if (key === "console") urls.console = withSlash(u);
|
||||
urls[app] = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
@@ -220,23 +151,9 @@
|
||||
} catch (e) { /* rien */ }
|
||||
return {
|
||||
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
|
||||
consoleUrl: null
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || 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) || [];
|
||||
@@ -275,48 +192,7 @@
|
||||
};
|
||||
});
|
||||
}
|
||||
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. */
|
||||
/* 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;
|
||||
@@ -332,74 +208,9 @@
|
||||
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;
|
||||
var panel = null, listEl = null, badgeTimer = null;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var d = document.createElement(tag);
|
||||
@@ -415,203 +226,95 @@
|
||||
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", function () { closePanel(); });
|
||||
close.addEventListener("click", 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);
|
||||
box.appendChild(note);
|
||||
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();
|
||||
if (ev.key === "Escape") 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;
|
||||
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;
|
||||
}
|
||||
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);
|
||||
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 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 + " »"
|
||||
: "";
|
||||
}
|
||||
function render(results, urls) {
|
||||
ensurePanel();
|
||||
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 */ }
|
||||
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) {
|
||||
@@ -639,7 +342,6 @@
|
||||
}
|
||||
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) }; }),
|
||||
@@ -648,11 +350,6 @@
|
||||
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) {
|
||||
@@ -664,7 +361,6 @@
|
||||
})).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 };
|
||||
});
|
||||
}
|
||||
@@ -684,35 +380,28 @@
|
||||
}
|
||||
function refreshBadge() {
|
||||
return loadAll().then(function (r) {
|
||||
var n = countActive(allItems(r.results));
|
||||
setBadge(n);
|
||||
return n;
|
||||
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(initialTab) {
|
||||
function openPanel() {
|
||||
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)));
|
||||
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 () {
|
||||
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
|
||||
renderCurrent();
|
||||
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -721,29 +410,18 @@
|
||||
_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
|
||||
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) ;
|
||||
* un lien partagé #alertes=<onglet> ouvre directement le panneau
|
||||
* sur cet onglet. */
|
||||
/* 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);
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) setTimeout(function () { openPanel(tab); }, 350);
|
||||
});
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -51,13 +51,9 @@
|
||||
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
|
||||
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
|
||||
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
|
||||
* La clé spark ne correspond à aucun onglet de la barre : elle est
|
||||
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
|
||||
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
|
||||
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
|
||||
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
|
||||
* rendu » comme les autres onglets, en plus d'alimenter le menu de
|
||||
* la marque et DRACO_BAR.urls.
|
||||
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
|
||||
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
|
||||
* Spark (panneau-agent), qui suit le même contrat d'injection.
|
||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
@@ -83,10 +79,9 @@
|
||||
* onglets présents dans la barre, puis une entrée « Console Spark »
|
||||
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
|
||||
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
|
||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
|
||||
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
|
||||
* 4) défaut officiel prod https://agent.dracodev.net/.
|
||||
* AUCUN hôte spark-dev n'est inventé :
|
||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) défaut officiel
|
||||
* prod https://agent.dracodev.net/ (il n'y a pas d'onglet agent, donc
|
||||
* pas d'étape « href rendu »). AUCUN hôte spark-dev n'est inventé :
|
||||
* la clé spark, elle, suit toujours son propre contrat (explicite >
|
||||
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
|
||||
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
|
||||
@@ -334,11 +329,6 @@
|
||||
appsMenu.setAttribute("role", "menu");
|
||||
appsMenu.setAttribute("aria-label", "Applications");
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
|
||||
* repris ici — l'entrée dédiée ci-dessous le représente déjà
|
||||
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
|
||||
* listerait « Console Spark » deux fois. */
|
||||
if (t.dataset.app === "agent") return;
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item";
|
||||
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||
|
||||
+543
-65
@@ -1,11 +1,39 @@
|
||||
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
|
||||
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68,
|
||||
* chantier 2+3 : onglets, « Tout voir », focus).
|
||||
*
|
||||
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
|
||||
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
|
||||
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
|
||||
*
|
||||
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
|
||||
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
|
||||
* de cette application (aucune troncature côté client — la requête
|
||||
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
|
||||
* jeu complet). « Tout voir » d'une section ne quitte plus la page :
|
||||
* il bascule vers l'onglet de l'application (son href reste une URL
|
||||
* partageable ?q=&tab= au cas où le JS ne tournerait pas).
|
||||
* L'onglet actif vit dans l'URL (?tab=, absent = Tout) : partageable,
|
||||
* et le retour arrière navigateur le restaure (popstate).
|
||||
*
|
||||
* Compteurs : chaque onglet affiche le nombre TOTAL de résultats de
|
||||
* son application (pas le nombre tronqué affiché dans « Tout »),
|
||||
* « … » pendant le chargement, « 0 » pour un vide honnête, rien pour
|
||||
* une section en erreur/indisponible (pas de faux 0). L'onglet Tout
|
||||
* affiche « … » tant qu'une section charge, sinon la somme des totaux
|
||||
* connus (une section en erreur compte pour 0) ; il est vide si les
|
||||
* 4 sections sont en erreur ou si aucune recherche n'a encore eu lieu.
|
||||
* Une section injoignable ne casse jamais les autres.
|
||||
*
|
||||
* Focus : l'attribut autofocus seul ne survit pas au chargement des
|
||||
* surcouches (barre, palette, app) — la page appelle donc focus()
|
||||
* explicitement en fin d'init (plus un rappel rAF/timeout et un
|
||||
* rappel pageshow). Cliquer la barre « Rechercher partout » (bouton
|
||||
* data-act="palette") sur cette même page replace aussi le curseur
|
||||
* dans la boîte ; « / » et Ctrl+K / ⌘K font de même, comme avant.
|
||||
*
|
||||
* Contrat d'API (audité, rien d'inventé) :
|
||||
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
|
||||
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=
|
||||
* -> { results: [{ vault, path, title, snippet }] }
|
||||
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
||||
* - Flowdeck : GET {base}/api/search?q=
|
||||
@@ -14,10 +42,27 @@
|
||||
* - TikTak : GET {base}/api/search?q=
|
||||
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
||||
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
|
||||
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
|
||||
* - Kanbante : GET {base}/api/search?q=
|
||||
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
|
||||
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
|
||||
* adressable — même limite que la page /search native de Kanbante).
|
||||
* - Console Spark : AUCUN endpoint de recherche mergé au moment de
|
||||
* cette livraison (agent-commun : seule la PR #12 est ouverte, autre
|
||||
* programme — ce dépôt n'est pas touché ici). Le code tente quand
|
||||
* même l'appel conventionnel GET {base-console}/api/search?q= (même
|
||||
* convention que les autres hôtes ; chemin À CONFIRMER avec le
|
||||
* travailleur console dès que son endpoint atterrit) et, en cas
|
||||
* d'échec, de HTTP non-OK ou de format non reconnu, affiche
|
||||
* honnêtement « Console Spark : recherche indisponible pour
|
||||
* l'instant » — jamais de faux résultats, jamais de faux 0. La base
|
||||
* console vient, dans l'ordre, de data-base-console rendu par l'hôte,
|
||||
* de window.DRACO_URLS.agent (ObsiGate) / window.DRACO_AGENT_URL
|
||||
* (TikTak), de la dérivation -dev si l'hôte courant est -dev, du
|
||||
* href d'un onglet de barre data-app="console"/"spark" s'il existe,
|
||||
* sinon du défaut prod agent.dracodev.net. L'onglet console n'est
|
||||
* interrogé que lorsqu'il est affiché (ou quand une recherche a lieu
|
||||
* pendant qu'il est affiché) : « Tout » ne le contient pas, pour ne
|
||||
* pas polluer la vue groupée d'un message d'indisponibilité.
|
||||
*
|
||||
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
||||
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
||||
@@ -31,11 +76,12 @@
|
||||
* credentials:"include". Le navigateur envoie alors les cookies de
|
||||
* chaque origine, dont le cookie SSO partagé draco_session
|
||||
* (Domain=.dracodev.net), accepté par les 4 API de recherche
|
||||
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
|
||||
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
|
||||
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68).
|
||||
* Chaque sœur n'autorise en CORS que les origines sœurs
|
||||
* (GET sur l'endpoint de recherche uniquement, pas de "*").
|
||||
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||
* Le correctif CORS lui-même est chez un autre travailleur.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
@@ -56,12 +102,17 @@
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
};
|
||||
var PROD_AGENT = "https://agent.dracodev.net/";
|
||||
var DEV_AGENT = "https://agent-dev.dracodev.net/";
|
||||
var CONSOLE_SEARCH_PATH = "api/search";
|
||||
var APPS = [
|
||||
{ key: "obsigate", label: "ObsiGate" },
|
||||
{ key: "flowdeck", label: "Flowdeck" },
|
||||
{ key: "tiktak", label: "TikTak" },
|
||||
{ key: "kanbante", label: "Kanbante" },
|
||||
];
|
||||
var CONSOLE_APP = { key: "console", label: "Console Spark" };
|
||||
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var COLUMN_LABELS = {
|
||||
backlog: "Backlog",
|
||||
in_progress: "En cours",
|
||||
@@ -73,6 +124,7 @@
|
||||
var TIMEOUT_MS = 8000;
|
||||
var DEBOUNCE_MS = 250;
|
||||
var PER_APP_LIMIT = 8;
|
||||
var FULL_LIMIT = 100;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
@@ -120,9 +172,91 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
function searchPath(app, q) {
|
||||
/* Base console : explicite > URL agent injectée par l'hôte >
|
||||
* dérivation -dev > href d'onglet de barre console/spark > prod.
|
||||
* opts : { host, explicit, agentUrl, tabHrefs:{} } */
|
||||
function resolveConsoleBase(opts) {
|
||||
opts = opts || {};
|
||||
var u = cleanUrl(opts.explicit);
|
||||
if (!u) u = cleanUrl(opts.agentUrl);
|
||||
if (!u && isDevHost(opts.host)) u = DEV_AGENT;
|
||||
if (!u)
|
||||
u = cleanUrl(
|
||||
opts.tabHrefs && (opts.tabHrefs.console || opts.tabHrefs.spark)
|
||||
);
|
||||
if (!u) u = PROD_AGENT;
|
||||
return withSlash(u);
|
||||
}
|
||||
|
||||
/* Onglet valide dans l'URL, sinon "all". */
|
||||
function parseTab(v) {
|
||||
return TABS.indexOf(v) !== -1 ? v : "all";
|
||||
}
|
||||
|
||||
/* Sections visibles pour un onglet. « Tout » = les 4 sections
|
||||
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
|
||||
function visibleKeys(tab) {
|
||||
tab = parseTab(tab);
|
||||
if (tab === "all") return APPS.map(function (a) { return a.key; });
|
||||
return [tab];
|
||||
}
|
||||
|
||||
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
||||
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
||||
function pageItems(items, full) {
|
||||
items = items || [];
|
||||
return full ? items : items.slice(0, PER_APP_LIMIT);
|
||||
}
|
||||
|
||||
/* ?q=&tab= partageable. tab "all" omis (c'est le défaut). */
|
||||
function buildSearchUrl(q, tab) {
|
||||
var parts = [];
|
||||
if (q) parts.push("q=" + encodeURIComponent(q));
|
||||
tab = parseTab(tab);
|
||||
if (tab !== "all") parts.push("tab=" + encodeURIComponent(tab));
|
||||
return parts.length ? "?" + parts.join("&") : "";
|
||||
}
|
||||
|
||||
/* Libellés des compteurs d'onglets à partir des états :
|
||||
* states : { key: { status, total } }.
|
||||
* Chargement -> "…", ok/vide -> total ("0" honnête),
|
||||
* erreur/indisponible/inactif -> "" (pas de faux 0).
|
||||
* Tout : "…" tant qu'une section charge, sinon somme des totaux
|
||||
* connus, "" si les 4 sections sont en erreur/inactives. */
|
||||
function tabCounts(states) {
|
||||
states = states || {};
|
||||
var out = {};
|
||||
function labelFor(key) {
|
||||
var st = states[key] || {};
|
||||
if (st.status === "loading") return "…";
|
||||
if (st.status === "ok" || st.status === "empty") return String(st.total || 0);
|
||||
return "";
|
||||
}
|
||||
var allLoading = false;
|
||||
var allTotal = 0;
|
||||
var allKnown = false;
|
||||
APPS.forEach(function (app) {
|
||||
var st = states[app.key] || {};
|
||||
out[app.key] = labelFor(app.key);
|
||||
if (st.status === "loading") allLoading = true;
|
||||
if (st.status === "ok" || st.status === "empty") {
|
||||
allKnown = true;
|
||||
allTotal += st.total || 0;
|
||||
}
|
||||
});
|
||||
out.console = labelFor("console");
|
||||
out.all = allLoading ? "…" : allKnown ? String(allTotal) : "";
|
||||
return out;
|
||||
}
|
||||
|
||||
function searchPath(app, q, full) {
|
||||
if (app === "obsigate") {
|
||||
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
|
||||
return (
|
||||
"api/search?q=" +
|
||||
encodeURIComponent(q) +
|
||||
"&vault=all&limit=" +
|
||||
(full ? FULL_LIMIT : PER_APP_LIMIT)
|
||||
);
|
||||
}
|
||||
return "api/search?q=" + encodeURIComponent(q);
|
||||
}
|
||||
@@ -133,11 +267,17 @@
|
||||
}
|
||||
|
||||
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
|
||||
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
|
||||
function normalize(app, data, base, q) {
|
||||
* base : base résolue de l'app (avec / final). q : requête (liens TikTak).
|
||||
* full=true : aucune troncature (stockage complet) ; sinon PER_APP_LIMIT
|
||||
* (comportement historique, conservé par défaut pour les tests). */
|
||||
function normalize(app, data, base, q, full) {
|
||||
data = data || {};
|
||||
var limit = full ? null : PER_APP_LIMIT;
|
||||
function cut(arr) {
|
||||
return limit == null ? arr : arr.slice(0, limit);
|
||||
}
|
||||
if (app === "obsigate") {
|
||||
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
return cut(data.results || []).map(function (r) {
|
||||
return {
|
||||
title: r.title || r.path || "Sans titre",
|
||||
snippet: snippetOf(r.snippet || ""),
|
||||
@@ -153,7 +293,7 @@
|
||||
}
|
||||
if (app === "flowdeck") {
|
||||
var items = (data.pages || []).concat(data.collections || []);
|
||||
return items.slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
return cut(items).map(function (r) {
|
||||
var rel = String(r.url || "").replace(/^\//, "");
|
||||
return {
|
||||
title: r.title || "Sans titre",
|
||||
@@ -164,7 +304,7 @@
|
||||
});
|
||||
}
|
||||
if (app === "tiktak") {
|
||||
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
|
||||
return cut(data.tasks || []).map(function (t) {
|
||||
var ctx = t.status === "done" ? "Terminée" : "Active";
|
||||
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
|
||||
return {
|
||||
@@ -182,7 +322,7 @@
|
||||
rows.push({ group: g.full_name, row: row });
|
||||
});
|
||||
});
|
||||
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
|
||||
return cut(rows).map(function (x) {
|
||||
var issue = x.row.issue || {};
|
||||
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
||||
return {
|
||||
@@ -196,6 +336,34 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Normalisation console, défensive : accepte results/discussions/
|
||||
* items. Un élément sans URL exploitable (http(s), /relatif, #ancre)
|
||||
* garde url=null et sera rendu en <span>, pas en faux lien profond.
|
||||
* Retourne { recognized, items } : recognized=false -> l'appelant
|
||||
* affiche « indisponible », jamais un vide mensonger. */
|
||||
function normalizeConsole(data, base) {
|
||||
data = data || {};
|
||||
var list = null;
|
||||
if (Array.isArray(data.results)) list = data.results;
|
||||
else if (Array.isArray(data.discussions)) list = data.discussions;
|
||||
else if (Array.isArray(data.items)) list = data.items;
|
||||
if (!list) return { recognized: false, items: [] };
|
||||
var items = list.map(function (r) {
|
||||
r = r || {};
|
||||
var raw = r.url || r.href || r.link || "";
|
||||
var url = null;
|
||||
if (/^https?:\/\//.test(raw)) url = raw;
|
||||
else if (raw.charAt(0) === "/" || raw.charAt(0) === "#") url = base + String(raw).replace(/^\//, "");
|
||||
return {
|
||||
title: r.title || r.name || r.subject || "Sans titre",
|
||||
snippet: snippetOf(r.snippet || r.excerpt || r.preview || r.body || ""),
|
||||
context: r.subtitle || r.source || r.type || "",
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
return { recognized: true, items: items };
|
||||
}
|
||||
|
||||
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||
function nativeSearchUrl(app, base, q) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
@@ -214,6 +382,7 @@
|
||||
}
|
||||
|
||||
function appLabel(key) {
|
||||
if (key === "console") return CONSOLE_APP.label;
|
||||
for (var i = 0; i < APPS.length; i++) {
|
||||
if (APPS[i].key === key) return APPS[i].label;
|
||||
}
|
||||
@@ -224,11 +393,23 @@
|
||||
esc: esc,
|
||||
isDevHost: isDevHost,
|
||||
resolveBases: resolveBases,
|
||||
resolveConsoleBase: resolveConsoleBase,
|
||||
parseTab: parseTab,
|
||||
visibleKeys: visibleKeys,
|
||||
pageItems: pageItems,
|
||||
buildSearchUrl: buildSearchUrl,
|
||||
tabCounts: tabCounts,
|
||||
searchPath: searchPath,
|
||||
normalize: normalize,
|
||||
normalizeConsole: normalizeConsole,
|
||||
nativeSearchUrl: nativeSearchUrl,
|
||||
PROD: PROD,
|
||||
DEV: DEV,
|
||||
PROD_AGENT: PROD_AGENT,
|
||||
DEV_AGENT: DEV_AGENT,
|
||||
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
|
||||
PER_APP_LIMIT: PER_APP_LIMIT,
|
||||
FULL_LIMIT: FULL_LIMIT,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.RecherchePartout = api;
|
||||
@@ -268,16 +449,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection(section, state) {
|
||||
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
|
||||
function renderSection(section, state, full) {
|
||||
/* state : { status: "idle"|"loading"|"empty"|"error"|"ok"|"unavailable",
|
||||
* items (complets), message, q, base, app }.
|
||||
* full=true (onglet propre à l'app) : tout afficher ; sinon
|
||||
* PER_APP_LIMIT (vue « Tout »). Le compteur affiche le TOTAL. */
|
||||
var body = section.querySelector(".rp-body");
|
||||
var count = section.querySelector(".rp-count");
|
||||
var toutVoir = section.querySelector(".rp-all");
|
||||
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
|
||||
toutVoir.textContent = "Tout voir";
|
||||
toutVoir.title = nativeSearchLabel(state.app);
|
||||
if (toutVoir) {
|
||||
try {
|
||||
toutVoir.href =
|
||||
global.location.pathname + buildSearchUrl(state.q, state.app);
|
||||
} catch (e) {}
|
||||
toutVoir.textContent = "Tout voir";
|
||||
toutVoir.title = "Afficher tous les résultats de " + appLabel(state.app);
|
||||
}
|
||||
if (state.status === "loading") {
|
||||
count.textContent = "";
|
||||
count.textContent = "…";
|
||||
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
||||
return;
|
||||
}
|
||||
@@ -293,19 +482,31 @@
|
||||
"</a></p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
if (state.status === "unavailable") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Console Spark : recherche indisponible pour l\'instant' +
|
||||
(state.message ? " (" + esc(state.message) + ")" : "") +
|
||||
".</p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "idle") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
count.textContent = "0";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||
return;
|
||||
}
|
||||
var shown = pageItems(state.items, full);
|
||||
count.textContent = String(state.items.length);
|
||||
body.innerHTML = state.items
|
||||
body.innerHTML = shown
|
||||
.map(function (item) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' +
|
||||
esc(item.url) +
|
||||
'">' +
|
||||
var inner =
|
||||
'<span class="rp-title">' +
|
||||
esc(item.title) +
|
||||
"</span>" +
|
||||
@@ -314,9 +515,13 @@
|
||||
: "") +
|
||||
(item.snippet
|
||||
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||
: "") +
|
||||
"</a>"
|
||||
);
|
||||
: "");
|
||||
if (item.url) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' + esc(item.url) + '">' + inner + "</a>"
|
||||
);
|
||||
}
|
||||
return '<span class="rp-item">' + inner + "</span>";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -349,40 +554,205 @@
|
||||
barUrls: barUrls,
|
||||
tabHrefs: tabHrefs,
|
||||
});
|
||||
var consoleBase = resolveConsoleBase({
|
||||
host: global.location.hostname,
|
||||
explicit: root.getAttribute("data-base-console"),
|
||||
agentUrl:
|
||||
(global.DRACO_URLS && global.DRACO_URLS.agent) || global.DRACO_AGENT_URL,
|
||||
tabHrefs: tabHrefs,
|
||||
});
|
||||
var allKeys = APPS.map(function (a) { return a.key; }).concat(["console"]);
|
||||
var sections = {};
|
||||
APPS.forEach(function (app) {
|
||||
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
|
||||
if (el) sections[app.key] = el;
|
||||
allKeys.forEach(function (key) {
|
||||
var el = document.querySelector('[data-rp-section="' + key + '"]');
|
||||
if (el) sections[key] = el;
|
||||
});
|
||||
var tabButtons = {};
|
||||
document.querySelectorAll("[data-rp-tab]").forEach(function (btn) {
|
||||
tabButtons[btn.getAttribute("data-rp-tab")] = btn;
|
||||
});
|
||||
var tablist = document.querySelector(".rp-tabs");
|
||||
|
||||
/* États par application (+ console). total = items.length. */
|
||||
var states = {};
|
||||
allKeys.forEach(function (key) {
|
||||
states[key] = {
|
||||
status: "idle",
|
||||
items: [],
|
||||
message: "",
|
||||
q: "",
|
||||
base: key === "console" ? consoleBase : bases[key],
|
||||
app: key,
|
||||
};
|
||||
});
|
||||
|
||||
var activeTab = "all";
|
||||
var currentQ = "";
|
||||
var runId = 0;
|
||||
var consoleFetchedQ = null;
|
||||
var debounceTimer = null;
|
||||
|
||||
function statesForCounts() {
|
||||
var out = {};
|
||||
allKeys.forEach(function (key) {
|
||||
out[key] = { status: states[key].status, total: states[key].items.length };
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
var counts = tabCounts(statesForCounts());
|
||||
Object.keys(tabButtons).forEach(function (key) {
|
||||
var btn = tabButtons[key];
|
||||
var selected = key === activeTab;
|
||||
btn.classList.toggle("active", selected);
|
||||
btn.setAttribute("aria-selected", selected ? "true" : "false");
|
||||
btn.tabIndex = selected ? 0 : -1;
|
||||
var span = btn.querySelector(".rp-tab-count");
|
||||
if (span) span.textContent = counts[key] || "";
|
||||
});
|
||||
}
|
||||
|
||||
/* Bascule d'onglet : visibilité des sections, re-rendu (plein dans
|
||||
* l'onglet propre à l'app, tronqué dans « Tout »), compteurs.
|
||||
* opts : { push (historique), focusTab }. */
|
||||
function applyTab(tab, opts) {
|
||||
opts = opts || {};
|
||||
activeTab = parseTab(tab);
|
||||
try {
|
||||
root.setAttribute("data-rp-tab", activeTab);
|
||||
} catch (e) {}
|
||||
var visible = visibleKeys(activeTab);
|
||||
allKeys.forEach(function (key) {
|
||||
var sec = sections[key];
|
||||
if (!sec) return;
|
||||
var show = visible.indexOf(key) !== -1;
|
||||
sec.hidden = !show;
|
||||
if (show) renderSection(sec, states[key], activeTab === key);
|
||||
var tv = sec.querySelector(".rp-all");
|
||||
if (tv) tv.hidden = activeTab === key;
|
||||
});
|
||||
renderTabs();
|
||||
if (opts.push) {
|
||||
try {
|
||||
global.history.pushState(
|
||||
null,
|
||||
"",
|
||||
global.location.pathname + buildSearchUrl(currentQ, activeTab)
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
if (opts.focusTab && tabButtons[activeTab]) {
|
||||
try {
|
||||
tabButtons[activeTab].focus();
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(tab, opts) {
|
||||
applyTab(tab, opts);
|
||||
/* La console n'est interrogée qu'une fois affichée. */
|
||||
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
||||
fetchConsole(currentQ, runId);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchConsole(q, myRun) {
|
||||
var sec = sections.console;
|
||||
if (!sec) return;
|
||||
consoleFetchedQ = q;
|
||||
states.console = {
|
||||
status: "loading",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
renderSection(sec, states.console, true);
|
||||
renderTabs();
|
||||
var url = consoleBase + CONSOLE_SEARCH_PATH + "?q=" + encodeURIComponent(q);
|
||||
fetchSection("console", url, function (err, data) {
|
||||
if (myRun !== runId) return;
|
||||
if (err) {
|
||||
var msg;
|
||||
if (err.name === "AbortError") msg = "délai dépassé (8 s)";
|
||||
else if (err.status) msg = "HTTP " + err.status;
|
||||
else msg = "injoignable (CORS ou réseau)";
|
||||
states.console = {
|
||||
status: "unavailable",
|
||||
items: [],
|
||||
message: msg,
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
} else {
|
||||
var norm = normalizeConsole(data, consoleBase);
|
||||
if (!norm.recognized) {
|
||||
states.console = {
|
||||
status: "unavailable",
|
||||
items: [],
|
||||
message: "format inattendu",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
} else {
|
||||
states.console = {
|
||||
status: norm.items.length ? "ok" : "empty",
|
||||
items: norm.items,
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
}
|
||||
}
|
||||
renderSection(sec, states.console, true);
|
||||
renderTabs();
|
||||
});
|
||||
}
|
||||
|
||||
function runSearch(q) {
|
||||
currentQ = q;
|
||||
var myRun = ++runId;
|
||||
if (!q) {
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
allKeys.forEach(function (key) {
|
||||
var sec = sections[key];
|
||||
if (!sec) return;
|
||||
sec.querySelector(".rp-body").innerHTML =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
sec.querySelector(".rp-count").textContent = "";
|
||||
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
|
||||
states[key] = {
|
||||
status: "idle",
|
||||
items: [],
|
||||
message: "",
|
||||
q: "",
|
||||
base: key === "console" ? consoleBase : bases[key],
|
||||
app: key,
|
||||
};
|
||||
renderSection(sec, states[key], activeTab === key);
|
||||
});
|
||||
consoleFetchedQ = null;
|
||||
renderTabs();
|
||||
return;
|
||||
}
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
|
||||
states[app.key] = {
|
||||
status: "loading",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
renderSection(sec, states[app.key], activeTab === app.key);
|
||||
var url;
|
||||
if (app.key === hostApp) {
|
||||
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
||||
url = "/" + searchPath(app.key, q);
|
||||
url = "/" + searchPath(app.key, q, true);
|
||||
} else {
|
||||
url = bases[app.key] + searchPath(app.key, q);
|
||||
url = bases[app.key] + searchPath(app.key, q, true);
|
||||
}
|
||||
fetchSection(app.key, url, function (err, data) {
|
||||
if (myRun !== runId) return;
|
||||
@@ -393,25 +763,45 @@
|
||||
msg = "Connexion requise sur " + app.label;
|
||||
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
|
||||
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
|
||||
renderSection(sec, {
|
||||
states[app.key] = {
|
||||
status: "error",
|
||||
items: [],
|
||||
message: msg,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
return;
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
} else {
|
||||
var items = normalize(app.key, data, bases[app.key], q, true);
|
||||
states[app.key] = {
|
||||
status: items.length ? "ok" : "empty",
|
||||
items: items,
|
||||
message: "",
|
||||
q: q,
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
}
|
||||
var items = normalize(app.key, data, bases[app.key], q);
|
||||
renderSection(sec, {
|
||||
status: items.length ? "ok" : "empty",
|
||||
items: items,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
renderSection(sec, states[app.key], activeTab === app.key);
|
||||
renderTabs();
|
||||
});
|
||||
});
|
||||
/* Console : seulement si son onglet est affiché ; sinon son
|
||||
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
||||
if (activeTab === "console") {
|
||||
fetchConsole(q, myRun);
|
||||
} else {
|
||||
states.console = {
|
||||
status: "idle",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
consoleFetchedQ = null;
|
||||
renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch(q, immediate) {
|
||||
@@ -427,13 +817,33 @@
|
||||
|
||||
function syncUrl(q) {
|
||||
try {
|
||||
var url = new URL(global.location.href);
|
||||
if (q) url.searchParams.set("q", q);
|
||||
else url.searchParams.delete("q");
|
||||
global.history.replaceState(null, "", url.toString());
|
||||
global.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
global.location.pathname + buildSearchUrl(q, activeTab)
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
try {
|
||||
input.focus({ preventScroll: true });
|
||||
} catch (e) {
|
||||
try {
|
||||
input.focus();
|
||||
} catch (e2) {}
|
||||
}
|
||||
}
|
||||
|
||||
function visibleLinks() {
|
||||
return Array.prototype.slice
|
||||
.call(document.querySelectorAll("[data-rp-link]"))
|
||||
.filter(function (a) {
|
||||
var s = a.closest ? a.closest("[data-rp-section]") : null;
|
||||
return !s || !s.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
var q = input.value.trim();
|
||||
syncUrl(q);
|
||||
@@ -441,7 +851,7 @@
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
var first = document.querySelector("[data-rp-link]");
|
||||
var first = visibleLinks()[0];
|
||||
if (first) {
|
||||
e.preventDefault();
|
||||
first.click();
|
||||
@@ -471,6 +881,9 @@
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
/* Sur un onglet, ↑/↓ ne déplacent pas les résultats : les
|
||||
* flèches ←/→ du tablist s'en chargent (voir ci-dessous). */
|
||||
if (target && target.closest && target.closest(".rp-tab")) return;
|
||||
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
@@ -478,8 +891,62 @@
|
||||
true
|
||||
);
|
||||
|
||||
/* « Tout voir » reste sur la page : bascule vers l'onglet. */
|
||||
root.addEventListener("click", function (e) {
|
||||
var all = e.target && e.target.closest ? e.target.closest(".rp-all") : null;
|
||||
if (!all) return;
|
||||
var sec = all.closest ? all.closest("[data-rp-section]") : null;
|
||||
if (!sec) return;
|
||||
e.preventDefault();
|
||||
setTab(sec.getAttribute("data-rp-section"), { push: true, focusTab: true });
|
||||
});
|
||||
|
||||
/* Clic sur la barre « Rechercher partout » (bouton
|
||||
* data-act="palette") pendant qu'on est déjà sur la page :
|
||||
* replacer le curseur dans la boîte au lieu de recharger. */
|
||||
document.addEventListener("click", function (e) {
|
||||
var p = e.target && e.target.closest ? e.target.closest('[data-act="palette"]') : null;
|
||||
if (!p) return;
|
||||
e.preventDefault();
|
||||
focusSearch();
|
||||
try {
|
||||
input.select();
|
||||
} catch (err) {}
|
||||
});
|
||||
|
||||
/* Onglets : clic + clavier (←/→, Début/Fin). */
|
||||
Object.keys(tabButtons).forEach(function (key) {
|
||||
tabButtons[key].addEventListener("click", function () {
|
||||
setTab(key, { push: true });
|
||||
});
|
||||
});
|
||||
if (tablist) {
|
||||
tablist.addEventListener("keydown", function (e) {
|
||||
var order = TABS;
|
||||
var idx = order.indexOf(activeTab);
|
||||
var next = null;
|
||||
if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
|
||||
else if (e.key === "ArrowLeft")
|
||||
next = order[(idx - 1 + order.length) % order.length];
|
||||
else if (e.key === "Home") next = order[0];
|
||||
else if (e.key === "End") next = order[order.length - 1];
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
setTab(next, { push: true, focusTab: true });
|
||||
});
|
||||
}
|
||||
|
||||
/* Retour arrière navigateur : restaurer q + onglet. */
|
||||
global.addEventListener("popstate", function () {
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
var q = (params.get("q") || "").trim();
|
||||
input.value = q;
|
||||
applyTab(params.get("tab"));
|
||||
runSearch(q);
|
||||
});
|
||||
|
||||
function moveFocus(delta, e) {
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
|
||||
var links = visibleLinks();
|
||||
if (!links.length) return;
|
||||
var active = document.activeElement;
|
||||
var idx = links.indexOf(active);
|
||||
@@ -492,12 +959,23 @@
|
||||
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
var initialQ = (params.get("q") || "").trim();
|
||||
if (initialQ) {
|
||||
input.value = initialQ;
|
||||
runSearch(initialQ);
|
||||
} else {
|
||||
runSearch("");
|
||||
}
|
||||
var initialTab = parseTab(params.get("tab"));
|
||||
if (initialQ) input.value = initialQ;
|
||||
applyTab(initialTab);
|
||||
runSearch(initialQ);
|
||||
|
||||
/* Focus explicite (voir en-tête) : l'attribut autofocus seul ne
|
||||
* survit pas au chargement des surcouches. */
|
||||
focusSearch();
|
||||
try {
|
||||
if (global.requestAnimationFrame) global.requestAnimationFrame(focusSearch);
|
||||
} catch (e) {}
|
||||
try {
|
||||
setTimeout(focusSearch, 0);
|
||||
} catch (e) {}
|
||||
global.addEventListener("pageshow", function (e) {
|
||||
if (e.persisted) focusSearch();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
+5
-157
@@ -1,61 +1,18 @@
|
||||
// Tests unitaires (node:test) du module partagé draco-alertes.js.
|
||||
// Exécutés dans la sandbox : node --test draco-alertes.test.js
|
||||
// Copie vendangée byte-identique de Projets/dracodev-commons
|
||||
// ui/alertes/ (chantier 5) — seul le require change selon l'hôte.
|
||||
// Hôte : Kanbante
|
||||
// Aucune donnée inventée : les payloads reprennent la forme exacte
|
||||
// des API réelles (Flowdeck /api/notifications, TikTak
|
||||
// /api/notifications, Kanbante /api/events). Le payload console
|
||||
// n'est qu'un EXEMPLE DE FORMAT (voir README commons) : aucun
|
||||
// endpoint console n'est publié sur agent-commun main au moment
|
||||
// du chantier.
|
||||
// /api/notifications, Kanbante /api/events).
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const t = require("../static/js/draco-alertes.js");
|
||||
|
||||
function sampleResults() {
|
||||
return {
|
||||
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
}) },
|
||||
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]) },
|
||||
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
}) },
|
||||
obsigate: { items: [] },
|
||||
console: { items: [], sourceMissing: true },
|
||||
};
|
||||
}
|
||||
function allOf(results) {
|
||||
const all = [];
|
||||
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
|
||||
(results[app] && results[app].items || []).forEach((a) => all.push(a));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
|
||||
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
|
||||
assert.equal(t.TAB_LABELS.console, "Console Spark");
|
||||
});
|
||||
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
|
||||
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(prod.agent, "https://agent.dracodev.net/");
|
||||
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
|
||||
const explicit = t.resolveUrls({
|
||||
hostname: "tiktak-dev.dracodev.net",
|
||||
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||
@@ -63,10 +20,9 @@ test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent incl
|
||||
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||
const href = t.resolveUrls({
|
||||
hostname: "example.org",
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/" },
|
||||
});
|
||||
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||
assert.equal(href.agent, "https://agent.dracodev.net/");
|
||||
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||
@@ -78,7 +34,8 @@ test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
],
|
||||
unread: 1,
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].active, true);
|
||||
@@ -125,112 +82,3 @@ test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||
assert.equal(sorted[3].date, "");
|
||||
assert.equal(t.countActive(list), 2);
|
||||
});
|
||||
|
||||
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r);
|
||||
assert.equal(tout.items.length, 2);
|
||||
assert.ok(tout.items.every((a) => a.active));
|
||||
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
|
||||
const fd = t.scopeAlerts(all, "flowdeck", r);
|
||||
assert.equal(fd.items.length, 1);
|
||||
assert.equal(fd.items[0].app, "flowdeck");
|
||||
const hist = t.scopeAlerts(all, "historique", r);
|
||||
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
|
||||
assert.ok(hist.items.every((a) => !a.active));
|
||||
const og = t.scopeAlerts(all, "obsigate", r);
|
||||
assert.equal(og.items.length, 0);
|
||||
const cs = t.scopeAlerts(all, "console", r);
|
||||
assert.equal(cs.items.length, 0);
|
||||
assert.equal(cs.consoleMissing, true);
|
||||
});
|
||||
|
||||
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
|
||||
const r = sampleResults();
|
||||
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
|
||||
const hist = t.scopeAlerts(allOf(r), "historique", r);
|
||||
assert.equal(hist.errors.length, 1);
|
||||
assert.equal(hist.errors[0].app, "flowdeck");
|
||||
const tout = t.scopeAlerts(allOf(r), "tout", r);
|
||||
assert.equal(tout.errors.length, 1);
|
||||
});
|
||||
|
||||
test("countByTab: compteurs par onglet sur l'echantillon", () => {
|
||||
const r = sampleResults();
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.deepEqual(c, {
|
||||
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
|
||||
kanbante: 0, console: 0, historique: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r).items;
|
||||
assert.equal(t.filterAlerts(tout, "budget").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
|
||||
assert.equal(t.filterAlerts(tout, "tache").length, 1);
|
||||
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
|
||||
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
|
||||
const hist = t.scopeAlerts(all, "historique", r).items;
|
||||
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "").length, tout.length);
|
||||
});
|
||||
|
||||
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
|
||||
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
|
||||
})[0];
|
||||
assert.ok(!t.metaLine(kb).includes("moved"));
|
||||
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
|
||||
assert.equal(t.displayState({ active: true }), "Active");
|
||||
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
|
||||
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
|
||||
assert.match(t.metaLine(sansDate), /sans date/);
|
||||
});
|
||||
|
||||
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
|
||||
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
|
||||
assert.equal(t.parseAlertTab("#alertes=console"), "console");
|
||||
assert.equal(t.parseAlertTab("#alertes"), "tout");
|
||||
assert.equal(t.parseAlertTab("#autre"), null);
|
||||
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
|
||||
assert.equal(t.parseAlertTab(""), null);
|
||||
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
|
||||
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
|
||||
});
|
||||
|
||||
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
|
||||
const items = t.normalizeConsole("https://agent.dracodev.net/", {
|
||||
alerts: [
|
||||
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
|
||||
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
|
||||
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
|
||||
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
|
||||
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 5);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[1].active, true);
|
||||
assert.equal(items[2].active, true);
|
||||
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
|
||||
assert.equal(items[3].active, false);
|
||||
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
|
||||
assert.equal(items[0].url, "https://agent.dracodev.net/");
|
||||
const r = sampleResults();
|
||||
r.console = { items };
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.equal(c.console, 4);
|
||||
assert.equal(c.tout, 6);
|
||||
assert.equal(c.historique, 5);
|
||||
});
|
||||
|
||||
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
|
||||
assert.equal(t.consoleSourceUrl({}), null);
|
||||
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
|
||||
"https://agent-dev.dracodev.net/api/alertes");
|
||||
});
|
||||
|
||||
@@ -65,4 +65,86 @@ ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
|
||||
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
|
||||
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
|
||||
|
||||
|
||||
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
|
||||
|
||||
/* parseTab */
|
||||
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
|
||||
ok(rp.parseTab("console") === "console", "onglet console valide");
|
||||
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
|
||||
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
|
||||
|
||||
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
|
||||
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
|
||||
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
|
||||
|
||||
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
|
||||
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
|
||||
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
|
||||
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
|
||||
|
||||
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
|
||||
let c = rp.tabCounts({
|
||||
obsigate: { status: "loading", total: 0 },
|
||||
flowdeck: { status: "ok", total: 3 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.obsigate === "…", "compteur … pendant le chargement");
|
||||
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
|
||||
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
|
||||
ok(c.all === "…", "Tout = … tant qu'une section charge");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "ok", total: 5 },
|
||||
flowdeck: { status: "ok", total: 2 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "ok", total: 4 },
|
||||
});
|
||||
ok(c.all === "7", "Tout = somme des totaux connus");
|
||||
ok(c.console === "4", "compteur console");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "error", total: 0 },
|
||||
flowdeck: { status: "error", total: 0 },
|
||||
tiktak: { status: "error", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
|
||||
|
||||
/* buildSearchUrl : état onglet partageable */
|
||||
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
|
||||
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
|
||||
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
|
||||
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
|
||||
|
||||
/* searchPath complet (onglet) vs tronqué (Tout) */
|
||||
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
|
||||
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
|
||||
|
||||
/* normalize complet vs tronqué */
|
||||
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
|
||||
|
||||
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
|
||||
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
|
||||
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
|
||||
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
|
||||
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
|
||||
|
||||
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
|
||||
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
|
||||
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
|
||||
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
|
||||
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
|
||||
|
||||
console.log("recherche.test.js : " + n + " assertions OK");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Kanbante).
|
||||
"""Fusion #69 — Alertes unifiées (chantier 2, Kanbante).
|
||||
|
||||
Kanbante n'a pas de table de notifications : sa source réelle est le
|
||||
journal d'événements, déjà exposé par GET /api/events (aucun nouvel
|
||||
@@ -44,20 +44,3 @@ def test_cors_events_soeur_preflight_get_et_tiers(client):
|
||||
headers={"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert "access-control-allow-origin" not in r3.headers
|
||||
|
||||
|
||||
def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
|
||||
"""Chantier 5 : onglet Console Spark en dernier dans la barre, et
|
||||
vue Alertes en onglets (module + styles vendangés depuis commons)."""
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
nav = r.text.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
|
||||
assert "Console Spark" in nav
|
||||
js = client.get("/static/js/draco-alertes.js").text
|
||||
assert "draco-alertes-tab" in js
|
||||
assert "historique" in js
|
||||
assert "source indisponible" in js
|
||||
css = client.get("/static/css/draco-alertes.css").text
|
||||
assert "draco-alertes-tab" in css
|
||||
assert "draco-alertes-filtre" in css
|
||||
|
||||
Reference in New Issue
Block a user