SPB Git

spb/admin-ka

Public

Command Center + Prompt Bank + logos des sites

- Prompt Bank : onglet Prompts (5e), CRUD /api/prompts, éditeur avec amélioration Opus 4.8, envoi à Claude Code en choisissant l app
- Command Center Écosystème : bandeau KPI global (up, uptime24, latence moy, checks24, échecs, incidents), cartes avec LOGO du site + tendance latence + barre de codes HTTP + p95/p99/checks ; fiche détail = percentiles min/p50/p90/p95/p99/max, distribution 2xx/3xx/4xx/5xx/err, MTTR + incidents 30j + pire arrêt, débit moyen, uptime 14j
- Logos : monitor récupère favicon/apple-touch-icon de chaque site, servis en cache via /icons/<app>
- monitor : capture bytes, percentiles, statusDist, trend, incidentStats, agrégats globaux

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 21, 2026) parent 27fcf70 Browse files

10 changed files +525 −34

modified data/audit.jsonl +4 −0
@@ -748,3 +748,7 @@
748 {"ts":"2026-08-21T15:50:24.028Z","kind":"result","chatId":"5a12c5fe36395cf0","subtype":"success","cost":2.6318587499999997,"duration_ms":923393,"turns":70} 748 {"ts":"2026-08-21T15:50:24.028Z","kind":"result","chatId":"5a12c5fe36395cf0","subtype":"success","cost":2.6318587499999997,"duration_ms":923393,"turns":70}
749 {"ts":"2026-08-21T16:21:32.346Z","kind":"upgrade_prompt","project":"lou-ka@M3U96b","len_in":47,"len_out":2419} 749 {"ts":"2026-08-21T16:21:32.346Z","kind":"upgrade_prompt","project":"lou-ka@M3U96b","len_in":47,"len_out":2419}
750 {"ts":"2026-08-21T16:24:30.430Z","kind":"login_ok","ip":"2605:b100:149:21ca:b509:761e:2e8d:6cfc"} 750 {"ts":"2026-08-21T16:24:30.430Z","kind":"login_ok","ip":"2605:b100:149:21ca:b509:761e:2e8d:6cfc"}
751+{"ts":"2026-08-21T16:31:00.793Z","kind":"prompt_save","id":"2794eeec9f2e"}
752+{"ts":"2026-08-21T16:31:16.427Z","kind":"prompt_save","id":"b29eaf4d80ca"}
753+{"ts":"2026-08-21T16:31:16.702Z","kind":"prompt_save","id":"598dcb1a5017"}
754+{"ts":"2026-08-21T16:31:16.947Z","kind":"prompt_save","id":"1ce415b8ef40"}
modified data/backend.log +3 −0
@@ -16,3 +16,6 @@ admin-ka v2 sur port 3300 (dual-stack)
16 admin-ka v2 sur port 3300 (dual-stack) 16 admin-ka v2 sur port 3300 (dual-stack)
17 admin-ka v2 sur port 3300 (dual-stack) 17 admin-ka v2 sur port 3300 (dual-stack)
18 admin-ka v2 sur port 3300 (dual-stack) 18 admin-ka v2 sur port 3300 (dual-stack)
19+admin-ka v2 sur port 3300 (dual-stack)
20+admin-ka v2 sur port 3300 (dual-stack)
21+admin-ka v2 sur port 3300 (dual-stack)
modified data/eco.sqlite +0 −0

Binary file not shown.

modified data/ngrok.log +41 −0
@@ -1152,3 +1152,44 @@ t=2026-08-21T12:24:30-0400 lvl=info msg="join connections" obj=join id=869a7b060
1152 t=2026-08-21T12:25:42-0400 lvl=info msg="join connections" obj=join id=6718b45187d5 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60788 1152 t=2026-08-21T12:25:42-0400 lvl=info msg="join connections" obj=join id=6718b45187d5 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60788
1153 t=2026-08-21T12:25:42-0400 lvl=info msg="join connections" obj=join id=bd941ab4f33b l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60788 1153 t=2026-08-21T12:25:42-0400 lvl=info msg="join connections" obj=join id=bd941ab4f33b l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60788
1154 t=2026-08-21T12:25:42-0400 lvl=info msg="join connections" obj=join id=a1181215d8ce l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60822 1154 t=2026-08-21T12:25:42-0400 lvl=info msg="join connections" obj=join id=a1181215d8ce l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60822
1155+t=2026-08-21T12:30:15-0400 lvl=info msg="join connections" obj=join id=99515eeff693 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1156+t=2026-08-21T12:30:15-0400 lvl=info msg="join connections" obj=join id=95fdf8d6a836 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1157+t=2026-08-21T12:30:15-0400 lvl=info msg="join connections" obj=join id=8bf92cc7a0ca l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60840
1158+t=2026-08-21T12:30:28-0400 lvl=info msg="join connections" obj=join id=a5f21c72e45b l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1159+t=2026-08-21T12:30:28-0400 lvl=info msg="join connections" obj=join id=076cfd7bede3 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1160+t=2026-08-21T12:30:59-0400 lvl=info msg="join connections" obj=join id=0bbd00922682 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60845
1161+t=2026-08-21T12:31:00-0400 lvl=info msg="join connections" obj=join id=ddc3e8181efd l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63505
1162+t=2026-08-21T12:31:01-0400 lvl=info msg="join connections" obj=join id=15e942dfaf03 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63506
1163+t=2026-08-21T12:31:01-0400 lvl=info msg="join connections" obj=join id=75cafff793ed l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63507
1164+t=2026-08-21T12:31:01-0400 lvl=info msg="join connections" obj=join id=8f2dfaf26d16 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63508
1165+t=2026-08-21T12:31:15-0400 lvl=info msg="join connections" obj=join id=0f7060cd0a2f l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1166+t=2026-08-21T12:31:15-0400 lvl=info msg="join connections" obj=join id=db32a66f879f l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1167+t=2026-08-21T12:31:16-0400 lvl=info msg="join connections" obj=join id=977fb0e80843 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63520
1168+t=2026-08-21T12:31:16-0400 lvl=info msg="join connections" obj=join id=911a3e94efde l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63521
1169+t=2026-08-21T12:31:16-0400 lvl=info msg="join connections" obj=join id=b22049a6ff12 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63522
1170+t=2026-08-21T12:31:17-0400 lvl=info msg="join connections" obj=join id=81c88508f28d l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63523
1171+t=2026-08-21T12:31:33-0400 lvl=info msg="join connections" obj=join id=fc3dffbb05d9 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1172+t=2026-08-21T12:31:53-0400 lvl=info msg="join connections" obj=join id=a12942b7d1c7 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60838
1173+t=2026-08-21T12:33:01-0400 lvl=info msg="join connections" obj=join id=ae2c31f7e6e8 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60848
1174+t=2026-08-21T12:33:07-0400 lvl=info msg="join connections" obj=join id=24b6b183975b l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63609
1175+t=2026-08-21T12:33:08-0400 lvl=info msg="join connections" obj=join id=0cbed13e3301 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63611
1176+t=2026-08-21T12:33:08-0400 lvl=info msg="join connections" obj=join id=5923eca8b4f5 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63612
1177+t=2026-08-21T12:33:08-0400 lvl=info msg="join connections" obj=join id=3b4cbad50e80 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63613
1178+t=2026-08-21T12:33:08-0400 lvl=info msg="join connections" obj=join id=3f71f7566f7c l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63614
1179+t=2026-08-21T12:33:08-0400 lvl=info msg="join connections" obj=join id=56293901a963 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:63615
1180+t=2026-08-21T12:34:19-0400 lvl=info msg="join connections" obj=join id=92df30fec9c7 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60861
1181+t=2026-08-21T12:34:34-0400 lvl=info msg="join connections" obj=join id=d93094bf89b6 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1182+t=2026-08-21T12:34:34-0400 lvl=info msg="join connections" obj=join id=3d494f5cfeba l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1183+t=2026-08-21T12:34:35-0400 lvl=info msg="join connections" obj=join id=0c0af5690d2b l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60872
1184+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=48b0ec2750d3 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1185+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=c2d604749470 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1186+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=839251fcf008 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1187+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=5dfcf33d3ed2 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1188+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=8ea6ed5b45a9 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1189+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=4d58dcc49e32 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1190+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=c945de6a9cb4 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1191+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=1d0b4928e283 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1192+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=982f23d6ad2a l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1193+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=1a7f2dca9bb5 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1194+t=2026-08-21T12:34:39-0400 lvl=info msg="join connections" obj=join id=cc9e2f69631c l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
1195+t=2026-08-21T12:34:53-0400 lvl=info msg="join connections" obj=join id=0c6155816ad9 l=[::1]:3300 r=[2605:b100:149:21ca:b509:761e:2e8d:6cfc]:60862
added data/prompts.json +26 −0
@@ -0,0 +1,26 @@
1+[
2+ {
3+ "id": "b29eaf4d80ca",
4+ "title": "Audit mobile complet",
5+ "text": "Fais un audit mobile complet de l app : menus tactiles, barres basses (dvh + safe-area), z-index (--z-*), anti-zoom iOS (inputs 16px), contenu jamais masqué par le clavier. Corrige les problèmes trouvés, valide au simulateur, puis commit+push spbgit.",
6+ "project": null,
7+ "created": 1787329876426,
8+ "updated": 1787329876426
9+ },
10+ {
11+ "id": "598dcb1a5017",
12+ "title": "Nouveaux connecteurs (découverte)",
13+ "text": "Propose et implémente de nouveaux connecteurs de sources pour cette app. Utilise Serper et Tavily pour découvrir des annuaires/sitemaps québécois, puis Scrapfly/Bright Data/Oxylabs si anti-bot. Ajoute la couche qualité (published/quarantaine), teste, commit+push spbgit.",
14+ "project": null,
15+ "created": 1787329876701,
16+ "updated": 1787329876701
17+ },
18+ {
19+ "id": "1ce415b8ef40",
20+ "title": "Vérifier état & santé",
21+ "text": "Donne l état de l app : git log -5, statut PM2, derniers logs d erreur, et vérifie que le site répond (healthcheck). Ne modifie rien, rapporte seulement.",
22+ "project": null,
23+ "created": 1787329876946,
24+ "updated": 1787329876946
25+ }
26+]
\ No newline at end of file
modified public/app.js +182 −17
@@ -51,7 +51,7 @@
51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; } 51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }
52 52
53 // ---------- navigation ---------- 53 // ---------- navigation ----------
54− const views = ['chat', 'sessions', 'eco', 'settings']; 54+ const views = ['chat', 'sessions', 'prompts', 'eco', 'settings'];
55 function switchView(name) { 55 function switchView(name) {
56 curView = name; 56 curView = name;
57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name); 57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);
@@ -60,6 +60,7 @@
60 if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden'); 60 if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden');
61 }); 61 });
62 if (name === 'sessions') loadSessions(); 62 if (name === 'sessions') loadSessions();
63+ if (name === 'prompts') loadPrompts();
63 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); } 64 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }
64 if (name === 'settings') refreshSettingsView(); 65 if (name === 'settings') refreshSettingsView();
65 } 66 }
@@ -716,6 +717,122 @@
716 } 717 }
717 $('newSessBtn').onclick = newChat; 718 $('newSessBtn').onclick = newChat;
718 719
720+ // ---------- Prompt Bank ----------
721+ function projectOptionsHTML(selected) {
722+ return [...projectSel.options].map(o =>
723+ `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');
724+ }
725+ async function loadPrompts() {
726+ const r = await fetch('/api/prompts');
727+ if (!r.ok) return;
728+ const { prompts } = await r.json();
729+ const list = $('promptList');
730+ list.innerHTML = '';
731+ if (!prompts.length) { list.appendChild(el('div', 'sess-empty', 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; }
732+ for (const pr of prompts) {
733+ const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix';
734+ const card = el('div', 'sess-card');
735+ card.innerHTML = `
736+ <div class="sess-top"><span class="sess-title">${esc(pr.title)}</span></div>
737+ <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>
738+ <div class="sess-info"><span>🎯 ${esc(projName)}</span><span>${timeAgo(pr.updated)}</span></div>`;
739+ const actions = el('div', 'sess-actions');
740+ const sendB = el('button', 'p-send', 'Envoyer →');
741+ const editB = el('button', '', '✎ Éditer');
742+ const upB = el('button', '', '✨ Améliorer');
743+ const delB = el('button', 'del', 'Suppr.');
744+ actions.append(sendB, upB, editB, delB);
745+ card.appendChild(actions);
746+ sendB.onclick = () => openSendSheet(pr);
747+ editB.onclick = () => openPromptEditor(pr);
748+ delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); card.remove(); };
749+ upB.onclick = async () => {
750+ upB.textContent = '✨ …'; upB.disabled = true;
751+ try {
752+ const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: pr.text, project: pr.project }) });
753+ const d = await rr.json();
754+ if (rr.ok && d.upgraded) {
755+ await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: d.upgraded }) });
756+ banner('good', '✨ Prompt amélioré et enregistré', null, 3000);
757+ loadPrompts();
758+ } else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);
759+ } catch { banner('bad', '✗ réseau', null, 4000); }
760+ upB.textContent = '✨ Améliorer'; upB.disabled = false;
761+ };
762+ list.appendChild(card);
763+ }
764+ }
765+ $('newPromptBtn').onclick = () => openPromptEditor(null);
766+
767+ function openPromptEditor(pr) {
768+ const isNew = !pr;
769+ openSheet(`
770+ <div class="sheet-h"><h3>${isNew ? 'Nouveau prompt' : 'Éditer le prompt'}</h3></div>
771+ <div class="sheet-sec">
772+ <input id="peTitle" class="pe-input" placeholder="Titre (optionnel)" value="${esc(pr?.title || '')}">
773+ <textarea id="peText" class="pe-textarea" placeholder="Écris ton prompt ici…">${esc(pr?.text || '')}</textarea>
774+ <div class="set-label" style="margin-top:10px">App par défaut</div>
775+ <select id="peProject" class="pill-select" style="max-width:100%;width:100%">
776+ <option value="">— app au choix (demandé à l’envoi) —</option>
777+ ${projectOptionsHTML(pr?.project || '')}
778+ </select>
779+ <div class="set-actions" style="margin-top:12px">
780+ <button class="btn-ghost" id="peUpgrade">✨ Améliorer (Opus 4.8)</button>
781+ <button class="btn-cta small" id="peSave">Enregistrer</button>
782+ </div>
783+ </div>`);
784+ $('peUpgrade').onclick = async () => {
785+ const t = $('peText').value.trim(); if (!t) return;
786+ const b = $('peUpgrade'); b.textContent = '✨ amélioration…'; b.disabled = true;
787+ try {
788+ const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: t, project: $('peProject').value }) });
789+ const d = await rr.json();
790+ if (rr.ok && d.upgraded) { $('peText').value = d.upgraded; if (navigator.vibrate) navigator.vibrate(40); }
791+ else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);
792+ } catch { banner('bad', '✗ réseau', null, 4000); }
793+ b.textContent = '✨ Améliorer (Opus 4.8)'; b.disabled = false;
794+ };
795+ $('peSave').onclick = async () => {
796+ const text = $('peText').value.trim(); if (!text) { banner('bad', 'Le prompt est vide', null, 3000); return; }
797+ const payload = { title: $('peTitle').value, text, project: $('peProject').value || null };
798+ if (isNew) await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
799+ else await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
800+ closeSheet(); loadPrompts();
801+ };
802+ }
803+
804+ function openSendSheet(pr) {
805+ openSheet(`
806+ <div class="sheet-h"><h3>Envoyer à Claude Code</h3></div>
807+ <div class="sheet-sec">
808+ <div class="prompt-preview" style="max-height:180px;overflow:auto">${esc(pr.text)}</div>
809+ </div>
810+ <div class="sheet-sec">
811+ <div class="set-label">App cible (nœud)</div>
812+ <select id="sendProject" class="pill-select" style="max-width:100%;width:100%">${projectOptionsHTML(pr.project || projectSel.value)}</select>
813+ <div class="set-label" style="margin-top:10px">Modèle · permissions</div>
814+ <div class="kv"><span>${MODEL_LABEL[settings.model] || settings.model} · ${PERM_LABEL[settings.permMode] || settings.permMode}</span><span class="mono">(réglages par défaut)</span></div>
815+ <button class="btn-cta" style="width:100%;margin-top:12px" id="sendGo">🚀 Lancer sur Claude Code</button>
816+ </div>`);
817+ $('sendGo').onclick = () => {
818+ const proj = $('sendProject').value;
819+ closeSheet();
820+ sendPromptToApp(pr.text, proj);
821+ };
822+ }
823+
824+ function sendPromptToApp(text, projectId) {
825+ currentChat = null;
826+ queuedPrompt = null;
827+ clearConversation();
828+ setRunning(false);
829+ for (const o of projectSel.options) if (o.value === projectId) projectSel.value = o.value;
830+ switchView('chat');
831+ refreshChips();
832+ send({ type: 'start', chatId: null, project: projectId, model: settings.model, permMode: settings.permMode, prompt: text });
833+ banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000);
834+ }
835+
719 // incidents globaux (bouton en tête de l'onglet Écosystème) 836 // incidents globaux (bouton en tête de l'onglet Écosystème)
720 $('incBtn').onclick = async () => { 837 $('incBtn').onclick = async () => {
721 const r = await fetch('/api/eco/incidents'); 838 const r = await fetch('/api/eco/incidents');
@@ -803,37 +920,56 @@
803 } 920 }
804 renderEco(); 921 renderEco();
805 } 922 }
923+ function trendBadge(t) {
924+ if (t == null) return '';
925+ if (t <= -8) return `<span class="trend down" title="latence en baisse">▼ ${Math.abs(t)}%</span>`;
926+ if (t >= 8) return `<span class="trend up" title="latence en hausse">▲ ${t}%</span>`;
927+ return `<span class="trend flat">≈</span>`;
928+ }
929+ function statusBar(sd) {
930+ const total = (sd['2xx'] + sd['3xx'] + sd['4xx'] + sd['5xx'] + sd.err) || 1;
931+ const seg = (n, cls) => n ? `<i class="${cls}" style="width:${(n / total * 100).toFixed(1)}%" title="${cls} : ${n}"></i>` : '';
932+ return `<div class="statusbar">${seg(sd['2xx'], 's2')}${seg(sd['3xx'], 's3')}${seg(sd['4xx'], 's4')}${seg(sd['5xx'], 's5')}${seg(sd.err, 'se')}</div>`;
933+ }
806 function renderEco() { 934 function renderEco() {
807 const sites = ecoData.sites; 935 const sites = ecoData.sites;
808− const up = sites.filter(s => siteStatus(s) === 'up').length; 936+ const g = ecoData.global || {};
809− const down = sites.filter(s => siteStatus(s) === 'down').length; 937+ $('ecoStamp').textContent = 'live · maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
810− const lat = sites.filter(s => s.last?.ok).map(s => s.last.ms); 938+ // bandeau KPI du command center
811− const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : 0;
812− $('ecoStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
813 $('ecoOverview').innerHTML = ` 939 $('ecoOverview').innerHTML = `
814− <div class="eco-stat ok"><div class="v">${up}/${sites.length}</div><div class="l">en ligne</div></div> 940+ <div class="kpi ${g.down ? 'bad' : 'ok'}"><div class="v">${g.up}<span class="tot">/${g.total}</span></div><div class="l">en ligne</div></div>
815− <div class="eco-stat ${down ? 'bad' : ''}"><div class="v">${down}</div><div class="l">hors ligne</div></div> 941+ <div class="kpi"><div class="v">${g.uptime24 ?? '—'}<small>%</small></div><div class="l">uptime 24h</div></div>
816− <div class="eco-stat"><div class="v">${avg}<small style="font-size:12px"> ms</small></div><div class="l">latence moy.</div></div>`; 942+ <div class="kpi"><div class="v">${g.avgLatency}<small>ms</small></div><div class="l">latence moy.</div></div>
943+ <div class="kpi"><div class="v">${(g.checks24 / 1000).toFixed(1)}<small>k</small></div><div class="l">checks 24h</div></div>
944+ <div class="kpi ${g.fails24 ? 'bad' : ''}"><div class="v">${g.fails24}</div><div class="l">échecs 24h</div></div>
945+ <div class="kpi ${g.openIncidents ? 'bad' : ''}"><div class="v">${g.openIncidents}</div><div class="l">incidents</div></div>`;
817 const grid = $('ecoGrid'); 946 const grid = $('ecoGrid');
818 grid.innerHTML = ''; 947 grid.innerHTML = '';
819 const order = { down: 0, slow: 1, unknown: 2, up: 3 }; 948 const order = { down: 0, slow: 1, unknown: 2, up: 3 };
820− for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)])) { 949+ for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)] || (b.last?.ms || 0) - (a.last?.ms || 0))) {
821 const st = ST[siteStatus(s)]; 950 const st = ST[siteStatus(s)];
822 const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : '')); 951 const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));
823 card.innerHTML = ` 952 card.innerHTML = `
824 <div class="site-top"> 953 <div class="site-top">
954+ <img class="site-logo" src="/icons/${s.app}" alt="" loading="lazy" onerror="this.style.display='none'">
825 <span class="site-name">${esc(s.label)}</span> 955 <span class="site-name">${esc(s.label)}</span>
826 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span> 956 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>
827 </div> 957 </div>
828 <div class="site-mid"> 958 <div class="site-mid">
829− <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small></div> 959+ <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small> ${trendBadge(s.trend)}</div>
830 ${sparkline(s.spark)} 960 ${sparkline(s.spark)}
831 </div> 961 </div>
962+ ${statusBar(s.status24 || { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 })}
963+ <div class="site-metrics">
964+ <div class="m"><b>${s.uptime24 ?? '—'}%</b><span>uptime</span></div>
965+ <div class="m"><b>${s.p95_24 ?? '—'}</b><span>p95 ms</span></div>
966+ <div class="m"><b>${s.p99_24 ?? '—'}</b><span>p99 ms</span></div>
967+ <div class="m"><b>${((s.checks24 || 0) / 1000).toFixed(1)}k</b><span>checks</span></div>
968+ </div>
832 <div class="site-foot"> 969 <div class="site-foot">
833− <span>up 24h <b>${s.uptime24 ?? '—'}%</b></span>
834− <span>p95 <b>${s.p95_24 ?? '—'} ms</b></span>
835 <span>${esc(s.node)}</span> 970 <span>${esc(s.node)}</span>
836 ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">pm2 <b>${esc(s.proc.status)}</b></span>` : ''} 971 ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">pm2 <b>${esc(s.proc.status)}</b></span>` : ''}
972+ ${s.proc?.last_commit ? `<span>commit <b>${timeAgo(s.proc.last_commit)}</b></span>` : ''}
837 ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''} 973 ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}
838 </div>`; 974 </div>`;
839 card.onclick = () => openSiteSheet(s.app); 975 card.onclick = () => openSiteSheet(s.app);
@@ -848,8 +984,10 @@
848 const s = ecoData.sites.find(x => x.app === app) || {}; 984 const s = ecoData.sites.find(x => x.app === app) || {};
849 const st = ST[siteStatus(s)]; 985 const st = ST[siteStatus(s)];
850 const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null; 986 const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;
987+ const L = d.lat24 || {}; const C = d.counts24 || {}; const IS = d.incidentStats || {}; const SD = d.status24 || {};
851 openSheet(` 988 openSheet(`
852 <div class="sheet-h"> 989 <div class="sheet-h">
990+ <img class="site-logo big" src="/icons/${d.app}" alt="" onerror="this.style.display='none'">
853 <h3>${esc(d.label)}</h3> 991 <h3>${esc(d.label)}</h3>
854 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span> 992 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>
855 </div> 993 </div>
@@ -863,12 +1001,39 @@
863 <div style="margin-top:10px">${uptimeDays(d.days)}</div> 1001 <div style="margin-top:10px">${uptimeDays(d.days)}</div>
864 </div> 1002 </div>
865 <div class="sheet-sec"> 1003 <div class="sheet-sec">
866− <h4>Latence — 24 h</h4> 1004+ <h4>Latence — percentiles 24 h ${trendBadge(d.trend)}</h4>
867 ${latencyChart(d.series)} 1005 ${latencyChart(d.series)}
1006+ <div class="pgrid">
1007+ <div class="pc"><b>${L.min ?? '—'}</b><span>min</span></div>
1008+ <div class="pc"><b>${L.p50 ?? '—'}</b><span>p50</span></div>
1009+ <div class="pc"><b>${L.p90 ?? '—'}</b><span>p90</span></div>
1010+ <div class="pc hot"><b>${L.p95 ?? '—'}</b><span>p95</span></div>
1011+ <div class="pc hot"><b>${L.p99 ?? '—'}</b><span>p99</span></div>
1012+ <div class="pc"><b>${L.max ?? '—'}</b><span>max</span></div>
1013+ </div>
1014+ </div>
1015+ <div class="sheet-sec">
1016+ <h4>Codes HTTP & fiabilité — 24 h</h4>
1017+ ${statusBar(SD)}
1018+ <div class="legend">
1019+ <span><i class="sw s2"></i>2xx ${SD['2xx'] || 0}</span>
1020+ <span><i class="sw s3"></i>3xx ${SD['3xx'] || 0}</span>
1021+ <span><i class="sw s4"></i>4xx ${SD['4xx'] || 0}</span>
1022+ <span><i class="sw s5"></i>5xx ${SD['5xx'] || 0}</span>
1023+ <span><i class="sw se"></i>err ${SD.err || 0}</span>
1024+ </div>
868 <div class="tile-row" style="margin-top:10px"> 1025 <div class="tile-row" style="margin-top:10px">
869− <div class="tile"><div class="v">${s.avg24 ?? '—'}</div><div class="l">moy ms</div></div> 1026+ <div class="tile"><div class="v">${C.total ?? '—'}</div><div class="l">checks</div></div>
870− <div class="tile"><div class="v">${s.p95_24 ?? '—'}</div><div class="l">p95 ms</div></div> 1027+ <div class="tile"><div class="v ${C.fail ? 'red' : ''}">${C.fail ?? '—'}</div><div class="l">échecs</div></div>
871− <div class="tile"><div class="v">${s.last?.code || '—'}</div><div class="l">HTTP</div></div> 1028+ <div class="tile"><div class="v">${C.avgBytes ? Math.round(C.avgBytes / 1024) + 'k' : '—'}</div><div class="l">taille moy</div></div>
1029+ </div>
1030+ </div>
1031+ <div class="sheet-sec">
1032+ <h4>Incidents — 30 j</h4>
1033+ <div class="tile-row">
1034+ <div class="tile"><div class="v ${IS.count30 ? 'red' : ''}">${IS.count30 ?? 0}</div><div class="l">incidents</div></div>
1035+ <div class="tile"><div class="v">${IS.mttrMin != null ? IS.mttrMin + ' min' : '—'}</div><div class="l">MTTR</div></div>
1036+ <div class="tile"><div class="v">${IS.longestMin ? IS.longestMin + ' min' : '—'}</div><div class="l">pire arrêt</div></div>
872 </div> 1037 </div>
873 </div> 1038 </div>
874 <div class="sheet-sec"> 1039 <div class="sheet-sec">
modified public/index.html +13 −0
@@ -87,6 +87,15 @@
87 <div id="sessList" class="sess-list"></div> 87 <div id="sessList" class="sess-list"></div>
88 </section> 88 </section>
89 89
90+ <!-- ======== Vue PROMPTS (bank) ======== -->
91+ <section id="view-prompts" class="view hidden">
92+ <div class="view-head">
93+ <h2>Prompts</h2>
94+ <button id="newPromptBtn" class="btn-cta small">+ Nouveau</button>
95+ </div>
96+ <div id="promptList" class="sess-list"></div>
97+ </section>
98+
90 <!-- ======== Vue ÉCOSYSTÈME ======== --> 99 <!-- ======== Vue ÉCOSYSTÈME ======== -->
91 <section id="view-eco" class="view hidden"> 100 <section id="view-eco" class="view hidden">
92 <div class="view-head"> 101 <div class="view-head">
@@ -147,6 +156,10 @@
147 <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> 156 <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
148 <span>Sessions</span><i class="badge hidden"></i> 157 <span>Sessions</span><i class="badge hidden"></i>
149 </button> 158 </button>
159+ <button data-view="prompts" class="nav-btn">
160+ <svg viewBox="0 0 24 24"><path d="M7 3h10a2 2 0 012 2v16l-7-4-7 4V5a2 2 0 012-2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>
161+ <span>Prompts</span><i class="badge hidden"></i>
162+ </button>
150 <button data-view="eco" class="nav-btn"> 163 <button data-view="eco" class="nav-btn">
151 <svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> 164 <svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
152 <span>Écosystème</span><i class="badge hidden"></i> 165 <span>Écosystème</span><i class="badge hidden"></i>
modified public/style.css +65 −0
@@ -457,6 +457,30 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
457 .sess-actions .del { color: var(--bad); } 457 .sess-actions .del { color: var(--bad); }
458 .sess-empty { color: var(--muted); text-align: center; padding: 40px 16px; font-weight: 600; } 458 .sess-empty { color: var(--muted); text-align: center; padding: 40px 16px; font-weight: 600; }
459 459
460+/* Prompt Bank */
461+.prompt-preview {
462+ font-family: var(--mono); font-size: 12px; color: var(--ink);
463+ background: var(--card2); border: 1.5px solid var(--line); border-radius: var(--r-sm);
464+ padding: 8px 10px; white-space: pre-wrap; word-break: break-word; line-height: 1.45;
465+}
466+.sess-actions .p-send { background: var(--orange); color: var(--ink); border-color: var(--ink); box-shadow: var(--bshadow-sm); }
467+.pe-input {
468+ width: 100%; background: var(--bg); border: 2px solid var(--ink); color: var(--ink);
469+ border-radius: var(--r-md); padding: 11px 13px; font-size: 15px; font-family: inherit; font-weight: 600; outline: none;
470+}
471+.pe-input:focus { border-color: var(--orange); }
472+.pe-textarea {
473+ width: 100%; margin-top: 10px; min-height: 180px; resize: vertical;
474+ background: var(--bg); border: 2px solid var(--ink); color: var(--ink);
475+ border-radius: var(--r-md); padding: 12px 13px; font-size: 14px; line-height: 1.5;
476+ font-family: var(--mono); outline: none;
477+}
478+.pe-textarea:focus { border-color: var(--orange); }
479+
480+/* bottom nav resserrée pour 5 onglets */
481+.nav-btn span { font-size: 10px; }
482+.nav-btn svg { width: 22px; height: 22px; }
483+
460 /* ================= Écosystème ================= */ 484 /* ================= Écosystème ================= */
461 .eco-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); } 485 .eco-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); }
462 .eco-overview { 486 .eco-overview {
@@ -525,6 +549,47 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
525 .admin-ok { color: var(--good); font-weight: 800; } 549 .admin-ok { color: var(--good); font-weight: 800; }
526 .admin-err { color: var(--bad); font-weight: 800; } 550 .admin-err { color: var(--bad); font-weight: 800; }
527 551
552+/* ===== Command Center ===== */
553+.eco-overview {
554+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
555+ max-width: 900px; margin: 0 auto 14px; width: 100%;
556+}
557+@media (min-width: 560px) { .eco-overview { grid-template-columns: repeat(6, 1fr); } }
558+.kpi {
559+ background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md);
560+ box-shadow: var(--bshadow-sm); padding: 9px 8px; text-align: center;
561+}
562+.kpi .v { font-size: 21px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; }
563+.kpi .v small { font-size: 11px; font-weight: 700; color: var(--muted); }
564+.kpi .v .tot { font-size: 13px; color: var(--muted); font-weight: 700; }
565+.kpi .l { font-size: 9.5px; color: var(--muted); font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 3px; }
566+.kpi.ok .v { color: var(--good); } .kpi.bad .v { color: var(--bad); }
567+.eco-grid { max-width: 900px; }
568+@media (min-width: 900px) { .eco-grid { grid-template-columns: 1fr 1fr 1fr; } }
569+
570+.site-logo { width: 22px; height: 22px; border-radius: 6px; flex-shrink: 0; object-fit: cover; background: var(--card2); border: 1.5px solid var(--line); }
571+.site-logo.big { width: 34px; height: 34px; border-radius: 9px; }
572+.trend { font-size: 11px; font-weight: 800; font-family: var(--round); margin-left: 4px; }
573+.trend.up { color: var(--bad); } .trend.down { color: var(--good); } .trend.flat { color: var(--muted); }
574+.statusbar { display: flex; height: 6px; border-radius: 999px; overflow: hidden; background: var(--card2); border: 1.5px solid var(--line); margin: 4px 0 2px; }
575+.statusbar i { display: block; height: 100%; }
576+.statusbar .s2 { background: var(--good); } .statusbar .s3 { background: #6a9fd8; }
577+.statusbar .s4 { background: var(--warn); } .statusbar .s5, .statusbar .se { background: var(--bad); }
578+.site-metrics { display: flex; gap: 6px; margin: 4px 0 2px; }
579+.site-metrics .m { flex: 1; text-align: center; background: var(--card2); border: 1.5px solid var(--line); border-radius: 8px; padding: 5px 2px; }
580+.site-metrics .m b { display: block; font-size: 14px; font-weight: 800; letter-spacing: -0.3px; }
581+.site-metrics .m span { font-size: 9px; color: var(--muted); font-weight: 700; text-transform: uppercase; }
582+.pgrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-top: 10px; }
583+.pc { text-align: center; background: var(--card2); border: 1.5px solid var(--line); border-radius: 9px; padding: 7px 2px; }
584+.pc b { display: block; font-size: 15px; font-weight: 800; letter-spacing: -0.4px; }
585+.pc span { font-size: 9.5px; color: var(--muted); font-weight: 800; text-transform: uppercase; }
586+.pc.hot { background: var(--warn-soft); border-color: #f0c36b; }
587+.legend { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; font-size: 11px; font-weight: 700; color: var(--muted); }
588+.legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 3px; vertical-align: -1px; border: 1px solid var(--line); }
589+.legend .sw.s2 { background: var(--good); } .legend .sw.s3 { background: #6a9fd8; }
590+.legend .sw.s4 { background: var(--warn); } .legend .sw.s5 { background: var(--bad); } .legend .sw.se { background: var(--bad); opacity: 0.5; }
591+.tile .v.red { color: var(--bad); }
592+
528 /* feuille détail */ 593 /* feuille détail */
529 .sheet { position: fixed; inset: 0; z-index: var(--z-sheet); } 594 .sheet { position: fixed; inset: 0; z-index: var(--z-sheet); }
530 .sheet-back { position: absolute; inset: 0; background: rgba(26, 22, 17, 0.45); } 595 .sheet-back { position: absolute; inset: 0; background: rgba(26, 22, 17, 0.45); }
modified server/monitor.js +140 −16
@@ -34,12 +34,58 @@ const FAILS_FOR_INCIDENT = 2; // échecs consécutifs avant incident
34 let db, emit; 34 let db, emit;
35 const consecFails = {}; 35 const consecFails = {};
36 36
37+// ---------- logos des sites (favicon/apple-touch-icon, mis en cache) ----------
38+const ICONS = {}; // app -> { buf: Buffer, type: string }
39+const ICON_CANDIDATES = ['/apple-touch-icon.png', '/apple-touch-icon-precomposed.png', '/favicon.svg', '/icon.svg', '/favicon.png', '/favicon.ico'];
40+function fetchUrl(host, pathname) {
41+ return new Promise((resolve) => {
42+ const req = https.get({ host, path: pathname, timeout: 10_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => {
43+ if (res.statusCode !== 200) { res.resume(); return resolve(null); }
44+ const ct = res.headers['content-type'] || '';
45+ if (!/image|svg/.test(ct)) { res.resume(); return resolve(null); }
46+ const chunks = [];
47+ let size = 0;
48+ res.on('data', (c) => { chunks.push(c); size += c.length; if (size > 500_000) { req.destroy(); resolve(null); } });
49+ res.on('end', () => resolve({ buf: Buffer.concat(chunks), type: ct.split(';')[0] }));
50+ });
51+ req.on('timeout', () => req.destroy());
52+ req.on('error', () => resolve(null));
53+ });
54+}
55+async function fetchIconFor(site) {
56+ // 1) essaie les chemins standards ; 2) sinon parse le HTML pour <link rel=icon>
57+ for (const cand of ICON_CANDIDATES) {
58+ const r = await fetchUrl(site.domain, cand);
59+ if (r) { ICONS[site.app] = r; return; }
60+ }
61+ const html = await new Promise((resolve) => {
62+ https.get({ host: site.domain, path: '/', timeout: 10_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => {
63+ let d = ''; res.on('data', (c) => { d += c; if (d.length > 200_000) res.destroy(); }); res.on('end', () => resolve(d));
64+ }).on('error', () => resolve('')).on('timeout', function () { this.destroy(); resolve(''); });
65+ });
66+ const m = html.match(/<link[^>]+rel=["'][^"']*(?:apple-touch-icon|icon)[^"']*["'][^>]*href=["']([^"']+)["']/i)
67+ || html.match(/<link[^>]+href=["']([^"']+)["'][^>]*rel=["'][^"']*icon[^"']*["']/i);
68+ if (m) {
69+ let href = m[1];
70+ let pathname = href.startsWith('http') ? new URL(href).pathname : (href.startsWith('/') ? href : '/' + href);
71+ const r = await fetchUrl(site.domain, pathname);
72+ if (r) ICONS[site.app] = r;
73+ }
74+}
75+async function fetchAllIcons() {
76+ await Promise.all(SITES.map(s => fetchIconFor(s).catch(() => {})));
77+ emit({ type: 'eco', kind: 'icons', ts: Date.now() });
78+}
79+export function getIcon(app) { return ICONS[app] || null; }
80+
37 export function startMonitor({ dataDir, onEvent }) { 81 export function startMonitor({ dataDir, onEvent }) {
38 emit = onEvent || (() => {}); 82 emit = onEvent || (() => {});
39 db = new DatabaseSync(path.join(dataDir, 'eco.sqlite')); 83 db = new DatabaseSync(path.join(dataDir, 'eco.sqlite'));
40 db.exec(` 84 db.exec(`
41 CREATE TABLE IF NOT EXISTS checks(site TEXT, ts INTEGER, ms INTEGER, code INTEGER, ok INTEGER, err TEXT); 85 CREATE TABLE IF NOT EXISTS checks(site TEXT, ts INTEGER, ms INTEGER, code INTEGER, ok INTEGER, err TEXT);
42− CREATE INDEX IF NOT EXISTS idx_checks ON checks(site, ts); 86+ CREATE INDEX IF NOT EXISTS idx_checks ON checks(site, ts);`);
87+ try { db.exec('ALTER TABLE checks ADD COLUMN bytes INTEGER DEFAULT 0'); } catch {}
88+ db.exec(`
43 CREATE TABLE IF NOT EXISTS incidents(id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, started INTEGER, ended INTEGER, reason TEXT); 89 CREATE TABLE IF NOT EXISTS incidents(id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, started INTEGER, ended INTEGER, reason TEXT);
44 CREATE TABLE IF NOT EXISTS cert(site TEXT PRIMARY KEY, expires INTEGER, issuer TEXT, checked INTEGER); 90 CREATE TABLE IF NOT EXISTS cert(site TEXT PRIMARY KEY, expires INTEGER, issuer TEXT, checked INTEGER);
45 CREATE TABLE IF NOT EXISTS nodestats(node TEXT, ts INTEGER, load1 REAL, mem_used INTEGER, mem_total INTEGER, disk_pct INTEGER); 91 CREATE TABLE IF NOT EXISTS nodestats(node TEXT, ts INTEGER, load1 REAL, mem_used INTEGER, mem_total INTEGER, disk_pct INTEGER);
@@ -49,8 +95,10 @@ export function startMonitor({ dataDir, onEvent }) {
49 `); 95 `);
50 checkAll(); 96 checkAll();
51 sweepNodes(); 97 sweepNodes();
98+ fetchAllIcons();
52 setInterval(checkAll, CHECK_MS); 99 setInterval(checkAll, CHECK_MS);
53 setInterval(sweepNodes, SWEEP_MS); 100 setInterval(sweepNodes, SWEEP_MS);
101+ setInterval(fetchAllIcons, 7 * 24 * 3600_000);
54 setInterval(cleanup, 6 * 3600_000); 102 setInterval(cleanup, 6 * 3600_000);
55 } 103 }
56 104
@@ -59,25 +107,26 @@ function checkOne(site) {
59 return new Promise((resolve) => { 107 return new Promise((resolve) => {
60 const t0 = Date.now(); 108 const t0 = Date.now();
61 const req = https.get({ host: site.domain, path: '/', timeout: 12_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => { 109 const req = https.get({ host: site.domain, path: '/', timeout: 12_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => {
62− const ms = Date.now() - t0; 110+ const ms = Date.now() - t0; // ≈ TTFB (réponse reçue avant le corps)
63 const cert = res.socket.getPeerCertificate?.(); 111 const cert = res.socket.getPeerCertificate?.();
64 if (cert && cert.valid_to) { 112 if (cert && cert.valid_to) {
65 db.prepare('INSERT INTO cert(site,expires,issuer,checked) VALUES(?,?,?,?) ON CONFLICT(site) DO UPDATE SET expires=excluded.expires,issuer=excluded.issuer,checked=excluded.checked') 113 db.prepare('INSERT INTO cert(site,expires,issuer,checked) VALUES(?,?,?,?) ON CONFLICT(site) DO UPDATE SET expires=excluded.expires,issuer=excluded.issuer,checked=excluded.checked')
66− .run(site.app, Date.parse(cert.valid_to), cert.issuer?.O || '', Date.now()); 114+ .run(site.app, Date.parse(cert.valid_to), cert.issuer?.O || cert.issuer?.CN || '', Date.now());
67 } 115 }
116+ const bytes = Number(res.headers['content-length'] || 0);
68 res.resume(); 117 res.resume();
69− resolve({ site: site.app, ts: Date.now(), ms, code: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 400 ? 1 : 0, err: null }); 118+ resolve({ site: site.app, ts: Date.now(), ms, code: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 400 ? 1 : 0, err: null, bytes });
70 }); 119 });
71 req.on('timeout', () => { req.destroy(new Error('timeout')); }); 120 req.on('timeout', () => { req.destroy(new Error('timeout')); });
72− req.on('error', (e) => resolve({ site: site.app, ts: Date.now(), ms: Date.now() - t0, code: 0, ok: 0, err: e.message.slice(0, 120) })); 121+ req.on('error', (e) => resolve({ site: site.app, ts: Date.now(), ms: Date.now() - t0, code: 0, ok: 0, err: e.message.slice(0, 120), bytes: 0 }));
73 }); 122 });
74 } 123 }
75 124
76 async function checkAll() { 125 async function checkAll() {
77 const results = await Promise.all(SITES.map(checkOne)); 126 const results = await Promise.all(SITES.map(checkOne));
78− const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err) VALUES(?,?,?,?,?,?)'); 127+ const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err,bytes) VALUES(?,?,?,?,?,?,?)');
79 for (const r of results) { 128 for (const r of results) {
80− ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err); 129+ ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err, r.bytes || 0);
81 handleIncident(r); 130 handleIncident(r);
82 } 131 }
83 emit({ type: 'eco', kind: 'checks', items: results }); 132 emit({ type: 'eco', kind: 'checks', items: results });
@@ -179,10 +228,53 @@ function uptime(site, sinceMs) {
179 const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs); 228 const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs);
180 return r.n ? Math.round((r.up / r.n) * 1000) / 10 : null; 229 return r.n ? Math.round((r.up / r.n) * 1000) / 10 : null;
181 } 230 }
231+function pct(sorted, p) {
232+ if (!sorted.length) return null;
233+ return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
234+}
182 function p95(site, sinceMs) { 235 function p95(site, sinceMs) {
183 const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs); 236 const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs);
184− if (!rows.length) return null; 237+ return pct(rows.map(r => r.ms), 0.95);
185− return rows[Math.min(rows.length - 1, Math.floor(rows.length * 0.95))].ms; 238+}
239+// paquet de latence : min/p50/p90/p95/p99/max/avg sur une fenêtre
240+function latencyStats(site, sinceMs) {
241+ const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs).map(r => r.ms);
242+ if (!rows.length) return { min: null, p50: null, p90: null, p95: null, p99: null, max: null, avg: null, n: 0 };
243+ const sum = rows.reduce((a, b) => a + b, 0);
244+ return { min: rows[0], p50: pct(rows, 0.5), p90: pct(rows, 0.9), p95: pct(rows, 0.95), p99: pct(rows, 0.99), max: rows[rows.length - 1], avg: Math.round(sum / rows.length), n: rows.length };
245+}
246+// distribution des codes HTTP sur 24h
247+function statusDist(site, sinceMs) {
248+ const rows = db.prepare('SELECT code, COUNT(*) n FROM checks WHERE site=? AND ts>? GROUP BY code').all(site, Date.now() - sinceMs);
249+ const d = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 };
250+ for (const r of rows) {
251+ if (!r.code) d.err += r.n;
252+ else if (r.code < 300) d['2xx'] += r.n;
253+ else if (r.code < 400) d['3xx'] += r.n;
254+ else if (r.code < 500) d['4xx'] += r.n;
255+ else d['5xx'] += r.n;
256+ }
257+ return d;
258+}
259+// tendance latence : moyenne dernière heure vs 24h (pour flèche ↑/↓)
260+function latencyTrend(site) {
261+ const now = Date.now();
262+ const h = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(site, now - 3600_000)?.a;
263+ const d = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(site, now - 24 * 3600_000)?.a;
264+ if (!h || !d) return null;
265+ return Math.round((h - d) / d * 100); // % (négatif = plus rapide)
266+}
267+function checkCounts(site, sinceMs) {
268+ const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up, AVG(bytes) b FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs);
269+ return { total: r.n || 0, ok: r.up || 0, fail: (r.n || 0) - (r.up || 0), avgBytes: r.b ? Math.round(r.b) : 0 };
270+}
271+// incidents + MTTR (temps moyen de rétablissement) sur 30 j
272+function incidentStats(site) {
273+ const rows = db.prepare('SELECT started, ended FROM incidents WHERE site=? AND started>?').all(site, Date.now() - 30 * 24 * 3600_000);
274+ const closed = rows.filter(r => r.ended);
275+ const mttr = closed.length ? Math.round(closed.reduce((a, r) => a + (r.ended - r.started), 0) / closed.length / 60000) : null;
276+ const longest = rows.length ? Math.round(Math.max(...rows.map(r => (r.ended || Date.now()) - r.started)) / 60000) : 0;
277+ return { count30: rows.length, open: rows.some(r => !r.ended), mttrMin: mttr, longestMin: longest };
186 } 278 }
187 279
188 export function ecoSummary() { 280 export function ecoSummary() {
@@ -195,21 +287,43 @@ export function ecoSummary() {
195 const inc = db.prepare('SELECT started,reason FROM incidents WHERE site=? AND ended IS NULL').get(s.app); 287 const inc = db.prepare('SELECT started,reason FROM incidents WHERE site=? AND ended IS NULL').get(s.app);
196 const stat = db.prepare('SELECT cpu,mem,status,restarts,last_commit,ts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app); 288 const stat = db.prepare('SELECT cpu,mem,status,restarts,last_commit,ts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app);
197 const nodeStat = db.prepare('SELECT load1,mem_used,mem_total,disk_pct,ts FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(s.node); 289 const nodeStat = db.prepare('SELECT load1,mem_used,mem_total,disk_pct,ts FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(s.node);
290+ const lat = latencyStats(s.app, 24 * 3600_000);
291+ const cnt = checkCounts(s.app, 24 * 3600_000);
198 out.push({ 292 out.push({
199 app: s.app, label: s.label, domain: s.domain, node: s.node, 293 app: s.app, label: s.label, domain: s.domain, node: s.node,
200 last, spark, 294 last, spark,
201 uptime24: uptime(s.app, 24 * 3600_000), 295 uptime24: uptime(s.app, 24 * 3600_000),
202 uptime7: uptime(s.app, 7 * 24 * 3600_000), 296 uptime7: uptime(s.app, 7 * 24 * 3600_000),
203 uptime30: uptime(s.app, 30 * 24 * 3600_000), 297 uptime30: uptime(s.app, 30 * 24 * 3600_000),
204− avg24: avg?.a ? Math.round(avg.a) : null, 298+ avg24: lat.avg,
205− p95_24: p95(s.app, 24 * 3600_000), 299+ p50_24: lat.p50, p95_24: lat.p95, p99_24: lat.p99, min24: lat.min, max24: lat.max,
300+ checks24: cnt.total, fails24: cnt.fail, avgBytes24: cnt.avgBytes,
301+ trend: latencyTrend(s.app),
302+ status24: statusDist(s.app, 24 * 3600_000),
303+ incidents30: incidentStats(s.app),
206 certExpires: cert?.expires || null, 304 certExpires: cert?.expires || null,
207 incident: inc || null, 305 incident: inc || null,
208 proc: stat || null, 306 proc: stat || null,
209 nodeStat: nodeStat || null, 307 nodeStat: nodeStat || null,
210 }); 308 });
211 } 309 }
212− return { sites: out, ts: Date.now() }; 310+ // agrégats globaux pour le command center
311+ const upNow = out.filter(x => x.last && x.last.ok).length;
312+ const okLat = out.filter(x => x.last?.ok).map(x => x.last.ms);
313+ const totalChecks = out.reduce((a, x) => a + (x.checks24 || 0), 0);
314+ const totalFails = out.reduce((a, x) => a + (x.fails24 || 0), 0);
315+ const upts = out.map(x => x.uptime24).filter(v => v != null);
316+ return {
317+ sites: out, ts: Date.now(),
318+ global: {
319+ up: upNow, total: out.length, down: out.length - upNow,
320+ avgLatency: okLat.length ? Math.round(okLat.reduce((a, b) => a + b, 0) / okLat.length) : 0,
321+ worstLatency: okLat.length ? Math.max(...okLat) : 0,
322+ uptime24: upts.length ? Math.round(upts.reduce((a, b) => a + b, 0) / upts.length * 10) / 10 : null,
323+ checks24: totalChecks, fails24: totalFails,
324+ openIncidents: out.filter(x => x.incident).length,
325+ },
326+ };
213 } 327 }
214 328
215 export function ecoSite(app) { 329 export function ecoSite(app) {
@@ -224,15 +338,25 @@ export function ecoSite(app) {
224 const incidents = db.prepare('SELECT started,ended,reason FROM incidents WHERE site=? AND started>? ORDER BY started DESC LIMIT 30') 338 const incidents = db.prepare('SELECT started,ended,reason FROM incidents WHERE site=? AND started>? ORDER BY started DESC LIMIT 30')
225 .all(app, Date.now() - 30 * 24 * 3600_000); 339 .all(app, Date.now() - 30 * 24 * 3600_000);
226 const errors = db.prepare('SELECT ts,code,err,ms FROM checks WHERE site=? AND ok=0 ORDER BY ts DESC LIMIT 20').all(app); 340 const errors = db.prepare('SELECT ts,code,err,ms FROM checks WHERE site=? AND ok=0 ORDER BY ts DESC LIMIT 20').all(app);
227− // uptime quotidien 14 jours pour la barre 341+ // uptime + latence quotidiens 14 jours
228 const days = []; 342 const days = [];
229 for (let i = 13; i >= 0; i--) { 343 for (let i = 13; i >= 0; i--) {
230 const d0 = new Date(); d0.setHours(0, 0, 0, 0); 344 const d0 = new Date(); d0.setHours(0, 0, 0, 0);
231 const start = d0.getTime() - i * 24 * 3600_000; 345 const start = d0.getTime() - i * 24 * 3600_000;
232− const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>=? AND ts<?').get(app, start, start + 24 * 3600_000); 346+ const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up, AVG(CASE WHEN ok=1 THEN ms END) a FROM checks WHERE site=? AND ts>=? AND ts<?').get(app, start, start + 24 * 3600_000);
233− days.push({ day: start, uptime: r.n ? Math.round((r.up / r.n) * 1000) / 10 : null }); 347+ days.push({ day: start, uptime: r.n ? Math.round((r.up / r.n) * 1000) / 10 : null, avgMs: r.a ? Math.round(r.a) : null });
234 } 348 }
235− return { app, label: s.label, domain: s.domain, node: s.node, dir: s.dir, series, incidents, errors, days }; 349+ return {
350+ app, label: s.label, domain: s.domain, node: s.node, dir: s.dir,
351+ series, incidents, errors, days,
352+ lat24: latencyStats(app, 24 * 3600_000),
353+ lat7: latencyStats(app, 7 * 24 * 3600_000),
354+ status24: statusDist(app, 24 * 3600_000),
355+ counts24: checkCounts(app, 24 * 3600_000),
356+ counts7: checkCounts(app, 7 * 24 * 3600_000),
357+ incidentStats: incidentStats(app),
358+ trend: latencyTrend(app),
359+ };
236 } 360 }
237 361
238 export function ecoIncidents() { 362 export function ecoIncidents() {
modified server/server.js +51 −1
@@ -9,7 +9,7 @@ import os from 'node:os';
9 import { spawn } from 'node:child_process'; 9 import { spawn } from 'node:child_process';
10 import { fileURLToPath } from 'node:url'; 10 import { fileURLToPath } from 'node:url';
11 import { WebSocketServer } from 'ws'; 11 import { WebSocketServer } from 'ws';
12−import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, SITES } from './monitor.js'; 12+import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js';
13 import { execFile } from 'node:child_process'; 13 import { execFile } from 'node:child_process';
14 14
15 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -194,6 +194,12 @@ function readTranscript(chatId) {
194 } catch { return []; } 194 } catch { return []; }
195 } 195 }
196 196
197+// ---------- prompt bank ----------
198+const PROMPTS_PATH = path.join(DATA_DIR, 'prompts.json');
199+let prompts = [];
200+try { prompts = JSON.parse(fs.readFileSync(PROMPTS_PATH, 'utf8')); } catch {}
201+function savePrompts() { fs.writeFileSync(PROMPTS_PATH, JSON.stringify(prompts, null, 1)); }
202+
197 // ---------- projets (12 apps Groupe KA) ---------- 203 // ---------- projets (12 apps Groupe KA) ----------
198 const SELF_NODE = 'M3U96a'; 204 const SELF_NODE = 'M3U96a';
199 const PROJECTS = [ 205 const PROJECTS = [
@@ -467,6 +473,14 @@ const server = http.createServer(async (req, res) => {
467 473
468 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); } 474 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); }
469 475
476+ // logos des sites (favicon/apple-touch-icon en cache) — public, léger
477+ if (p.startsWith('/icons/')) {
478+ const icon = getIcon(p.split('/')[2]);
479+ if (!icon) { res.writeHead(404); return res.end(); }
480+ res.writeHead(200, { 'Content-Type': icon.type, 'Cache-Control': 'public, max-age=86400' });
481+ return res.end(icon.buf);
482+ }
483+
470 // --- endpoint interne de permission (long-poll par perm-mcp.js) --- 484 // --- endpoint interne de permission (long-poll par perm-mcp.js) ---
471 if (p === '/internal/perm' && req.method === 'POST') { 485 if (p === '/internal/perm' && req.method === 'POST') {
472 let body; 486 let body;
@@ -555,6 +569,42 @@ const server = http.createServer(async (req, res) => {
555 } 569 }
556 return json(res, 400, { error: 'introuvable ou en cours' }); 570 return json(res, 400, { error: 'introuvable ou en cours' });
557 } 571 }
572+ // --- prompt bank ---
573+ if (p === '/api/prompts' && req.method === 'GET') {
574+ const list = [...prompts].sort((a, b) => (b.updated || 0) - (a.updated || 0));
575+ return json(res, 200, { prompts: list });
576+ }
577+ if (p === '/api/prompts' && req.method === 'POST') {
578+ let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
579+ const text = String(body.text || '').trim();
580+ if (!text) return json(res, 400, { error: 'texte vide' });
581+ const pr = {
582+ id: crypto.randomBytes(6).toString('hex'),
583+ title: (String(body.title || '').trim() || text.slice(0, 48)),
584+ text,
585+ project: findProject(body.project) ? body.project : null,
586+ created: Date.now(), updated: Date.now(),
587+ };
588+ prompts.push(pr); savePrompts();
589+ audit({ kind: 'prompt_save', id: pr.id });
590+ return json(res, 200, { prompt: pr });
591+ }
592+ if (p.startsWith('/api/prompts/') && req.method === 'PUT') {
593+ const id = p.split('/')[3];
594+ const pr = prompts.find(x => x.id === id);
595+ if (!pr) return json(res, 404, { error: 'introuvable' });
596+ let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
597+ if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim();
598+ if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48);
599+ if ('project' in body) pr.project = findProject(body.project) ? body.project : null;
600+ pr.updated = Date.now(); savePrompts();
601+ return json(res, 200, { prompt: pr });
602+ }
603+ if (p.startsWith('/api/prompts/') && req.method === 'DELETE') {
604+ const id = p.split('/')[3];
605+ prompts = prompts.filter(x => x.id !== id); savePrompts();
606+ return json(res, 200, { ok: true });
607+ }
558 if (p === '/api/upgrade-prompt' && req.method === 'POST') { 608 if (p === '/api/upgrade-prompt' && req.method === 'POST') {
559 let body; 609 let body;
560 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } 610 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
561 611
Menu

Theme