SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%

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 1 mo ago (Aug 21, 2026) parent 27fcf70

10 changed files +525 −34

modified data/audit.jsonl +4 −0
@@ -748,3 +748,7 @@
748 748 {"ts":"2026-08-21T15:50:24.028Z","kind":"result","chatId":"5a12c5fe36395cf0","subtype":"success","cost":2.6318587499999997,"duration_ms":923393,"turns":70}
749 749 {"ts":"2026-08-21T16:21:32.346Z","kind":"upgrade_prompt","project":"lou-ka@M3U96b","len_in":47,"len_out":2419}
750 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 16 admin-ka v2 sur port 3300 (dual-stack)
17 17 admin-ka v2 sur port 3300 (dual-stack)
18 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 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 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 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 51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }
52 52
53 53 // ---------- navigation ----------
54 const views = ['chat', 'sessions', 'eco', 'settings'];
54 + const views = ['chat', 'sessions', 'prompts', 'eco', 'settings'];
55 55 function switchView(name) {
56 56 curView = name;
57 57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);
@@ -60,6 +60,7 @@
60 60 if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden');
61 61 });
62 62 if (name === 'sessions') loadSessions();
63 + if (name === 'prompts') loadPrompts();
63 64 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }
64 65 if (name === 'settings') refreshSettingsView();
65 66 }
@@ -716,6 +717,122 @@
716 717 }
717 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 836 // incidents globaux (bouton en tête de l'onglet Écosystème)
720 837 $('incBtn').onclick = async () => {
721 838 const r = await fetch('/api/eco/incidents');
@@ -803,37 +920,56 @@
803 920 }
804 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 934 function renderEco() {
807 935 const sites = ecoData.sites;
808 const up = sites.filter(s => siteStatus(s) === 'up').length;
809 const down = sites.filter(s => siteStatus(s) === 'down').length;
810 const lat = sites.filter(s => s.last?.ok).map(s => s.last.ms);
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' });
936 + const g = ecoData.global || {};
937 + $('ecoStamp').textContent = 'live · maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
938 + // bandeau KPI du command center
813 939 $('ecoOverview').innerHTML = `
814 <div class="eco-stat ok"><div class="v">${up}/${sites.length}</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>
816 <div class="eco-stat"><div class="v">${avg}<small style="font-size:12px"> ms</small></div><div class="l">latence moy.</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>
941 + <div class="kpi"><div class="v">${g.uptime24 ?? '—'}<small>%</small></div><div class="l">uptime 24h</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 946 const grid = $('ecoGrid');
818 947 grid.innerHTML = '';
819 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 950 const st = ST[siteStatus(s)];
822 951 const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));
823 952 card.innerHTML = `
824 953 <div class="site-top">
954 + <img class="site-logo" src="/icons/${s.app}" alt="" loading="lazy" onerror="this.style.display='none'">
825 955 <span class="site-name">${esc(s.label)}</span>
826 956 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>
827 957 </div>
828 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 960 ${sparkline(s.spark)}
831 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 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 970 <span>${esc(s.node)}</span>
836 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 973 ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}
838 974 </div>`;
839 975 card.onclick = () => openSiteSheet(s.app);
@@ -848,8 +984,10 @@
848 984 const s = ecoData.sites.find(x => x.app === app) || {};
849 985 const st = ST[siteStatus(s)];
850 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 988 openSheet(`
852 989 <div class="sheet-h">
990 + <img class="site-logo big" src="/icons/${d.app}" alt="" onerror="this.style.display='none'">
853 991 <h3>${esc(d.label)}</h3>
854 992 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>
855 993 </div>
@@ -863,12 +1001,39 @@
863 1001 <div style="margin-top:10px">${uptimeDays(d.days)}</div>
864 1002 </div>
865 1003 <div class="sheet-sec">
866 <h4>Latence — 24 h</h4>
1004 + <h4>Latence — percentiles 24 h ${trendBadge(d.trend)}</h4>
867 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 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>
870 <div class="tile"><div class="v">${s.p95_24 ?? '—'}</div><div class="l">p95 ms</div></div>
871 <div class="tile"><div class="v">${s.last?.code || '—'}</div><div class="l">HTTP</div></div>
1026 + <div class="tile"><div class="v">${C.total ?? '—'}</div><div class="l">checks</div></div>
1027 + <div class="tile"><div class="v ${C.fail ? 'red' : ''}">${C.fail ?? '—'}</div><div class="l">échecs</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 1037 </div>
873 1038 </div>
874 1039 <div class="sheet-sec">
modified public/index.html +13 −0
@@ -87,6 +87,15 @@
87 87 <div id="sessList" class="sess-list"></div>
88 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 99 <!-- ======== Vue ÉCOSYSTÈME ======== -->
91 100 <section id="view-eco" class="view hidden">
92 101 <div class="view-head">
@@ -147,6 +156,10 @@
147 156 <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
148 157 <span>Sessions</span><i class="badge hidden"></i>
149 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 163 <button data-view="eco" class="nav-btn">
151 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 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 457 .sess-actions .del { color: var(--bad); }
458 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 484 /* ================= Écosystème ================= */
461 485 .eco-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); }
462 486 .eco-overview {
@@ -525,6 +549,47 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
525 549 .admin-ok { color: var(--good); font-weight: 800; }
526 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 593 /* feuille détail */
529 594 .sheet { position: fixed; inset: 0; z-index: var(--z-sheet); }
530 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 34 let db, emit;
35 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 81 export function startMonitor({ dataDir, onEvent }) {
38 82 emit = onEvent || (() => {});
39 83 db = new DatabaseSync(path.join(dataDir, 'eco.sqlite'));
40 84 db.exec(`
41 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 89 CREATE TABLE IF NOT EXISTS incidents(id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, started INTEGER, ended INTEGER, reason TEXT);
44 90 CREATE TABLE IF NOT EXISTS cert(site TEXT PRIMARY KEY, expires INTEGER, issuer TEXT, checked INTEGER);
45 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 96 checkAll();
51 97 sweepNodes();
98 + fetchAllIcons();
52 99 setInterval(checkAll, CHECK_MS);
53 100 setInterval(sweepNodes, SWEEP_MS);
101 + setInterval(fetchAllIcons, 7 * 24 * 3600_000);
54 102 setInterval(cleanup, 6 * 3600_000);
55 103 }
56 104
@@ -59,25 +107,26 @@ function checkOne(site) {
59 107 return new Promise((resolve) => {
60 108 const t0 = Date.now();
61 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 111 const cert = res.socket.getPeerCertificate?.();
64 112 if (cert && cert.valid_to) {
65 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 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 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 125 async function checkAll() {
77 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 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 130 handleIncident(r);
82 131 }
83 132 emit({ type: 'eco', kind: 'checks', items: results });
@@ -179,10 +228,53 @@ function uptime(site, sinceMs) {
179 228 const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs);
180 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 235 function p95(site, sinceMs) {
183 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;
185 return rows[Math.min(rows.length - 1, Math.floor(rows.length * 0.95))].ms;
237 + return pct(rows.map(r => r.ms), 0.95);
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 280 export function ecoSummary() {
@@ -195,21 +287,43 @@ export function ecoSummary() {
195 287 const inc = db.prepare('SELECT started,reason FROM incidents WHERE site=? AND ended IS NULL').get(s.app);
196 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 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 292 out.push({
199 293 app: s.app, label: s.label, domain: s.domain, node: s.node,
200 294 last, spark,
201 295 uptime24: uptime(s.app, 24 * 3600_000),
202 296 uptime7: uptime(s.app, 7 * 24 * 3600_000),
203 297 uptime30: uptime(s.app, 30 * 24 * 3600_000),
204 avg24: avg?.a ? Math.round(avg.a) : null,
205 p95_24: p95(s.app, 24 * 3600_000),
298 + avg24: lat.avg,
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 304 certExpires: cert?.expires || null,
207 305 incident: inc || null,
208 306 proc: stat || null,
209 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 329 export function ecoSite(app) {
@@ -224,15 +338,25 @@ export function ecoSite(app) {
224 338 const incidents = db.prepare('SELECT started,ended,reason FROM incidents WHERE site=? AND started>? ORDER BY started DESC LIMIT 30')
225 339 .all(app, Date.now() - 30 * 24 * 3600_000);
226 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 342 const days = [];
229 343 for (let i = 13; i >= 0; i--) {
230 344 const d0 = new Date(); d0.setHours(0, 0, 0, 0);
231 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);
233 days.push({ day: start, uptime: r.n ? Math.round((r.up / r.n) * 1000) / 10 : null });
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);
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 362 export function ecoIncidents() {
modified server/server.js +51 −1
@@ -9,7 +9,7 @@ import os from 'node:os';
9 9 import { spawn } from 'node:child_process';
10 10 import { fileURLToPath } from 'node:url';
11 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 13 import { execFile } from 'node:child_process';
14 14
15 15 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -194,6 +194,12 @@ function readTranscript(chatId) {
194 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 203 // ---------- projets (12 apps Groupe KA) ----------
198 204 const SELF_NODE = 'M3U96a';
199 205 const PROJECTS = [
@@ -467,6 +473,14 @@ const server = http.createServer(async (req, res) => {
467 473
468 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 484 // --- endpoint interne de permission (long-poll par perm-mcp.js) ---
471 485 if (p === '/internal/perm' && req.method === 'POST') {
472 486 let body;
@@ -555,6 +569,42 @@ const server = http.createServer(async (req, res) => {
555 569 }
556 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 608 if (p === '/api/upgrade-prompt' && req.method === 'POST') {
559 609 let body;
560 610 try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
561 611