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>
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 | |