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%

v3 Control Center — refonte UI desktop-first (sidebar+topbar+dark mode+Vue d'ensemble) + analytics v2 trafic humain (classification UA/IP datacenter-bots-interne, sessions, heartbeat, temps réel, anomalies, inspecteur, config)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 28 days ago (Aug 26, 2026) parent 34a4eff

8 changed files +1,777 −435

modified CLAUDE.md +13 −0
@@ -47,3 +47,16 @@ node server/set-password.js '<nouveau>' # changer le mot de passe
47 47 ```
48 48
49 49 Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`.
50 +
51 +## v3 (2026-08-26) — Control Center : refonte UI + analytics humains
52 +
53 +**UI** : desktop-first — sidebar gauche (≥1020 px) + topbar (titre, incidents, thème, statut) ; bottom nav mobile réduite à 5 (Vue d'ensemble, Claude Code, Écosystème, Visiteurs, Plus). Nouvelle page **Vue d'ensemble** (KPIs humains+éco+Claude, chart 30 j, chips écosystème, incidents, anomalies). **Dark mode** réel (`ka_theme` auto/clair/sombre, variables `--edge/--shadow/--code-bg/...`). Sessions et Prompts en listes denses filtrables (recherche/app/statut ; favoris ★, duplication, compteur d'usage sur les prompts).
54 +
55 +**Analytics v2 (`server/analytics.js`) — définitions officielles** :
56 +- **Visiteur humain** : vid classé `human` = UA navigateur + JS exécuté + réseau non-datacenter/proxy + pas interne + pas de flood.
57 +- **Visiteur unique** : vid humain distinct dans la fenêtre. **Session** : trou > 30 min = nouvelle session (`sessionMin`). **Page vue** : événement `pv` (navigation réelle, SPA incluse) — un heartbeat n'est JAMAIS une page vue. **En ligne** : dernier événement humain < 120 s (`activeSec`) — expire tout seul.
58 +- **Classes** : `human / crawler / datacenter / automation / internal` — classées à l'insertion (UA regex + cache `ipinfo` ip-api avec `hosting/proxy/ASN`, reclassement rétroactif quand l'IP se résout, anti-flood `maxIpPerHour`). Les métriques principales n'affichent QUE les humains ; le reste est visible dans la barre de répartition et le toggle « Tout le trafic ».
59 +- Tables : `events` (brut, rétention 45 j), `ipinfo` (cache IP-intelligence), `daily2` (rollup quotidien par classe, long terme), `meta.migrated_at` (frontière legacy). Les anciennes `hits/daily` sont CONSERVÉES et affichées en pointillé « ancien comptage v1 (non filtré) » — ne jamais comparer directement les deux séries.
60 +- **Beacon v2** : `public/ka-a.js` (vid/sid localStorage, pv SPA via pushState, heartbeat 25 s seulement onglet visible + interaction < 60 s). Le beacon v1 (ping 60 s) reste supporté : le serveur dé-duplique (même page ~60 s = heartbeat) et sessionne en mémoire.
61 +- API : `/api/analytics/{summary,series/:site|_all,site/:site,realtime,anomalies,inspect?ip=|vid=,classes,config(GET/PUT)}`. Config runtime dans `config.json → analytics` (fenêtres, IP/UA internes, bots custom, ASNs exclus) — éditable dans Réglages.
62 +- Tests : `node server/test-analytics.mjs` (28 scénarios : humain/refresh/SPA, Googlebot, uptime bot, IP AWS, curl, interne, dédup v1, invariants uniques≤sessions≤pv, expiration en ligne) — à lancer depuis une COPIE (écrit dans data/).
modified public/app.js +530 −120
@@ -50,96 +50,392 @@
50 50 }
51 51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }
52 52
53 // ---------- navigation ----------
54 const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'visitors', 'settings'];
53 + // ---------- navigation (sidebar desktop + bottom nav mobile) ----------
54 + const I = {
55 + home: '<svg viewBox="0 0 24 24"><path d="M4 11l8-7 8 7v9a1.5 1.5 0 01-1.5 1.5H15v-6h-6v6H5.5A1.5 1.5 0 014 20z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',
56 + chat: '<svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',
57 + sessions: '<svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>',
58 + prompts: '<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>',
59 + eco: '<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>',
60 + visitors: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M3.5 19a5.5 5.5 0 0111 0" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/><path d="M16 5.2a3 3 0 010 5.6M18 19a5.5 5.5 0 00-3-4.9" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>',
61 + social: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H4z M8 20h8" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 8l5 2.5-5 2.5z" fill="currentColor"/></svg>',
62 + settings: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
63 + more: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.8" fill="currentColor"/><circle cx="12" cy="12" r="1.8" fill="currentColor"/><circle cx="19" cy="12" r="1.8" fill="currentColor"/></svg>',
64 + };
65 + const NAV = [
66 + { id: 'overview', label: 'Vue d’ensemble', icon: I.home },
67 + { id: 'chat', label: 'Claude Code', icon: I.chat },
68 + { id: 'sessions', label: 'Sessions', icon: I.sessions },
69 + { id: 'prompts', label: 'Prompts', icon: I.prompts },
70 + { id: 'eco', label: 'Écosystème', icon: I.eco },
71 + { id: 'visitors', label: 'Visiteurs', icon: I.visitors },
72 + { id: 'social', label: 'Studio', icon: I.social },
73 + { id: 'settings', label: 'Réglages', icon: I.settings },
74 + ];
75 + const views = NAV.map(n => n.id);
76 + const TITLES = Object.fromEntries(NAV.map(n => [n.id, n.label]));
77 + function navBtnHTML(n) {
78 + return `<button data-view="${n.id}" class="nav-btn">${n.icon}<span>${n.label}</span><i class="badge hidden"></i></button>`;
79 + }
80 + $('sideNav').innerHTML = NAV.map(navBtnHTML).join('');
81 + const MOBILE_NAV = ['overview', 'chat', 'eco', 'visitors'];
82 + $('bottomNav').innerHTML = NAV.filter(n => MOBILE_NAV.includes(n.id)).map(navBtnHTML).join('')
83 + + `<button class="nav-btn" id="navMore">${I.more}<span>Plus</span><i class="badge hidden"></i></button>`;
84 + $('navMore').onclick = () => {
85 + openSheet(`<div class="sheet-h"><h3>Navigation</h3></div><div class="more-nav">${
86 + NAV.filter(n => !MOBILE_NAV.includes(n.id)).map(n => `<button class="more-btn" data-view="${n.id}">${n.icon}<span>${n.label}</span></button>`).join('')
87 + }</div>`);
88 + document.querySelectorAll('.more-btn').forEach(b => { b.onclick = () => { closeSheet(); switchView(b.dataset.view); }; });
89 + };
90 +
55 91 function switchView(name) {
56 92 curView = name;
57 93 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);
94 + const pt = $('pageTitle'); if (pt) pt.textContent = TITLES[name] || name;
58 95 document.querySelectorAll('.nav-btn').forEach((b) => {
59 96 b.classList.toggle('active', b.dataset.view === name);
60 if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden');
97 + if (b.dataset.view === name) { const bd = b.querySelector('.badge'); if (bd) bd.classList.add('hidden'); }
61 98 });
99 + if (name === 'overview' && window.startOverview) window.startOverview();
62 100 if (name === 'sessions') loadSessions();
63 101 if (name === 'prompts') loadPrompts();
64 102 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }
65 103 if (name === 'social' && window.__loadGallery) window.__loadGallery();
66 104 if (name === 'visitors' && window.startVisitors) window.startVisitors();
67 if (name === 'settings') refreshSettingsView();
105 + if (name === 'settings') { refreshSettingsView(); if (window.loadAnCfg) window.loadAnCfg(); }
68 106 }
69 document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });
107 + document.addEventListener('click', (e) => {
108 + const nb = e.target.closest('.nav-btn[data-view]');
109 + if (nb) { switchView(nb.dataset.view); return; }
110 + const gl = e.target.closest('[data-goto]');
111 + if (gl) switchView(gl.dataset.goto);
112 + });
70 113
71 // ===== VISITORS_TAB =====
72 (function(){
114 + // ---------- thème (auto / clair / sombre) ----------
115 + function applyTheme() {
116 + const t = localStorage.getItem('ka_theme') || 'auto';
117 + const dark = t === 'dark' || (t === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
118 + document.documentElement.dataset.theme = dark ? 'dark' : 'light';
119 + const meta = document.querySelector('meta[name="theme-color"]');
120 + if (meta) meta.content = dark ? '#16130e' : '#F2F1EC';
121 + document.querySelectorAll('#segTheme button').forEach(b => b.classList.toggle('on', b.dataset.v === t));
122 + }
123 + document.querySelectorAll('#segTheme button').forEach(b => { b.onclick = () => { localStorage.setItem('ka_theme', b.dataset.v); applyTheme(); }; });
124 + $('themeBtn').onclick = () => {
125 + const cur = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
126 + localStorage.setItem('ka_theme', cur === 'dark' ? 'light' : 'dark');
127 + applyTheme();
128 + };
129 + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
130 + applyTheme();
131 +
132 +// ===== VISITEURS v2 — trafic humain crédible =====
133 +(function () {
73 134 const $ = (id) => document.getElementById(id);
74 let timer=null;
75 function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); }
76 function card(s){
77 return '<div class="vis-card" data-site="'+s.site+'" data-label="'+(s.label||s.site)+'">'
78 + '<div class="vis-h"><img src="/icons/'+s.site+'"><div class="vis-name">'+(s.label||s.site)+'</div><span class="vis-dot'+(s.online>0?' on':'')+'"></span></div>'
79 + '<div class="vis-online"><span class="vis-dot big'+(s.online>0?' on':'')+'"></span><span class="vis-onN">'+nf(s.online)+'</span><span class="vis-onL">en ligne</span></div>'
80 + '<div class="vis-row">'
81 + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">jour</div></div>'
82 + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7j</div></div>'
83 + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30j</div></div>'
84 + '</div>'
85 + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues · voir détail →</div></div>';
86 }
87 async function loadVisitors(){
88 try{
89 const d=await (await fetch('/api/analytics/summary')).json(); const g=d.global||{};
90 if($('visLive')) $('visLive').innerHTML='<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' aujourd&#39;hui';
91 if($('visGlobal')) $('visGlobal').innerHTML=
92 '<div class="vg-item"><div class="vg-n">'+nf(g.online)+'</div><div class="vg-l">en ligne</div></div>'
93 +'<div class="vg-item"><div class="vg-n">'+nf(g.today)+'</div><div class="vg-l">aujourd&#39;hui</div></div>'
94 +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>'
95 +'<div class="vg-item"><div class="vg-n">'+nf(g.pv_today)+'</div><div class="vg-l">pages vues</div></div>';
96 if($('visGrid')){ $('visGrid').innerHTML=(d.sites||[]).map(card).join('');
97 $('visGrid').querySelectorAll('.vis-card').forEach(function(c){ c.onclick=function(){ openDetail(c.dataset.site,c.dataset.label); }; }); }
98 }catch(e){}
135 + let timer = null, mode = 'human', lastSummary = null;
136 + const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' ');
137 + const DEV_LABEL = { m: '📱 mobile', t: '💻 tablette', d: '🖥️ desktop', '?': '—' };
138 + const CLS_LABEL = { human: 'Humains', crawler: 'Crawlers', datacenter: 'Datacenter', automation: 'Automation', internal: 'Interne' };
139 + const CLS_COLOR = { human: 'var(--good)', crawler: '#6a9fd8', datacenter: 'var(--warn)', automation: '#a08cc9', internal: 'var(--muted)' };
140 +
141 + function delta(cur, prev) {
142 + if (!prev) return '';
143 + const d = Math.round((cur - prev) / prev * 100);
144 + if (!isFinite(d) || Math.abs(d) < 1) return '<span class="kdelta flat">≈ hier</span>';
145 + return `<span class="kdelta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '+' : ''}${d}&nbsp;% vs hier</span>`;
146 + }
147 + function kpi(v, l, extra, accent) {
148 + return `<div class="kpi2${accent ? ' accent' : ''}"><div class="kv2">${v}</div><div class="kl2">${l}</div>${extra || ''}</div>`;
149 + }
150 + function renderGlobal(d) {
151 + const g = d.global || {};
152 + $('visLive').innerHTML = '<span class="vis-dot on"></span> ' + nf(g.online) + ' humain' + (g.online > 1 ? 's' : '') + ' en ligne';
153 + const all = mode === 'all';
154 + const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0);
155 + $('visGlobal').innerHTML =
156 + kpi(nf(g.online), 'en ligne (actifs < 2 min)', '', true)
157 + + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame))
158 + + kpi(nf(g.sessions), 'sessions')
159 + + kpi(nf(g.pv), 'pages vues')
160 + + kpi(nf(g.d7), '7 jours')
161 + + kpi(nf(g.d30), '30 jours')
162 + + (all ? kpi(nf(g.total_vids), 'visiteurs (toutes classes)') + kpi(nf(autoHits), 'hits automatisés') : '');
163 + // barre de répartition des classes (événements du jour)
164 + const seg = [['human', g.humanHits], ['crawler', g.crawler], ['datacenter', g.datacenter], ['automation', g.automation], ['internal', g.internal]]
165 + .map(([k, v]) => [k, v || 0]);
166 + const tot = seg.reduce((a, [, v]) => a + v, 0) || 1;
167 + $('visBreak').innerHTML = `
168 + <div class="break-bar">${seg.map(([k, v]) => v ? `<i style="width:${(v / tot * 100).toFixed(2)}%;background:${CLS_COLOR[k]}" title="${CLS_LABEL[k]} : ${nf(v)}"></i>` : '').join('')}</div>
169 + <div class="legend">${seg.map(([k, v]) => `<span><i class="sw" style="background:${CLS_COLOR[k]}"></i>${CLS_LABEL[k]} ${nf(v)}</span>`).join('')}
170 + <span class="panel-note">événements du jour — les métriques principales n’affichent que les humains</span></div>`;
171 + }
172 +
173 + function siteCard(s) {
174 + const all = mode === 'all';
175 + const autoHits = (s.crawler || 0) + (s.datacenter || 0) + (s.automation || 0) + (s.internal || 0);
176 + return `<div class="vis-card" data-site="${s.site}" data-label="${s.label || s.site}">
177 + <div class="vis-h"><img src="/icons/${s.site}" onerror="this.style.display='none'"><div class="vis-name">${s.label || s.site}</div>
178 + <span class="vis-on-pill${s.online > 0 ? ' on' : ''}">${nf(s.online)} en ligne</span></div>
179 + <div class="vis-row">
180 + <div class="vis-m"><div class="vm-n">${nf(s.today)}</div><div class="vm-l">humains</div></div>
181 + <div class="vis-m"><div class="vm-n">${nf(s.sessions)}</div><div class="vm-l">sessions</div></div>
182 + <div class="vis-m"><div class="vm-n">${nf(s.pv)}</div><div class="vm-l">pages</div></div>
183 + <div class="vis-m"><div class="vm-n">${nf(s.d7)}</div><div class="vm-l">7 j</div></div>
184 + </div>
185 + ${all ? `<div class="vis-pv">🤖 ${nf(autoHits)} hits automatisés (crawler ${nf(s.crawler)} · dc ${nf(s.datacenter)} · autom. ${nf(s.automation)} · interne ${nf(s.internal)})</div>`
186 + : `<div class="vis-pv">${delta(s.today, s.yesterdaySame) || '&nbsp;'}<span class="vis-more">détail →</span></div>`}
187 + </div>`;
188 + }
189 +
190 + async function loadVisitors() {
191 + try {
192 + const d = await (await fetch('/api/analytics/summary')).json();
193 + lastSummary = d;
194 + renderGlobal(d);
195 + $('visGrid').innerHTML = (d.sites || []).map(siteCard).join('');
196 + $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); });
197 + } catch {}
198 + loadRealtime(); loadAnoms();
199 + }
200 +
201 + async function loadRealtime() {
202 + try {
203 + const d = await (await fetch('/api/analytics/realtime')).json();
204 + $('rtCount').textContent = d.active.length + ' actif' + (d.active.length > 1 ? 's' : '') + ' · fenêtre ' + d.activeSec + ' s';
205 + if (!d.active.length) { $('visRealtime').innerHTML = '<div class="soc-sub">Personne en ce moment — le compteur expire tout seul (~2 min).</div>'; return; }
206 + $('visRealtime').innerHTML = d.active.slice(0, 30).map(a => {
207 + const dur = Math.max(0, Math.round((Date.now() - (a.since || a.lastTs)) / 60000));
208 + return `<div class="rt-row">
209 + <img src="/icons/${a.site}" onerror="this.style.display='none'">
210 + <span class="rt-site">${a.label}</span>
211 + <span class="rt-path mono" title="${esc(a.path || '/')}">${esc((a.path || '/').slice(0, 42))}</span>
212 + <span class="rt-loc">${esc(a.loc)}${a.cc && a.cc !== 'CA' && a.cc !== 'XX' ? ' · ' + a.cc : ''}</span>
213 + <span class="rt-dev">${DEV_LABEL[a.dev] || ''}</span>
214 + <span class="rt-dur mono">${dur < 1 ? '<1 min' : dur + ' min'}</span>
215 + </div>`;
216 + }).join('');
217 + } catch {}
218 + }
219 +
220 + async function loadAnoms() {
221 + try {
222 + const d = await (await fetch('/api/analytics/anomalies')).json();
223 + const bits = [];
224 + if (d.datacenter?.surge) bits.push(`<div class="anom warn">▲ Poussée datacenter : ${nf(d.datacenter.lastHour)} hits/h (moy. ${nf(d.datacenter.hourlyAvg)})</div>`);
225 + for (const s of d.spikes || []) bits.push(`<div class="anom warn">▲ Pic sur <b>${esc(s.site)}</b> : ${nf(s.lastHour)} evt/h (×${s.factor})</div>`);
226 + for (const ip of (d.topIps || []).slice(0, 5)) bits.push(`<div class="anom"><span class="mono">${esc(ip.ip)}</span> · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} · classe <b>${esc(ip.cls)}</b> <button class="linklike" data-ip="${esc(ip.ip)}">inspecter</button></div>`);
227 + $('visAnom').innerHTML = bits.length ? bits.join('') : '<div class="soc-sub">● Rien à signaler — trafic dans les normes.</div>';
228 + $('visAnom').querySelectorAll('[data-ip]').forEach(b => { b.onclick = () => inspect(b.dataset.ip); });
229 + } catch {}
230 + }
231 +
232 + async function inspect(q) {
233 + const val = q || prompt('IP ou visitor-id à inspecter :');
234 + if (!val) return;
235 + const isIp = /^[\d.:a-f]+$/i.test(val) && val.includes('.') || val.includes(':');
236 + const r = await fetch('/api/analytics/inspect?' + (isIp ? 'ip=' : 'vid=') + encodeURIComponent(val));
237 + const d = await r.json();
238 + const ipi = d.ipinfo;
239 + openSheet(`<div class="sheet-h"><h3>🔬 Inspecteur de trafic</h3></div>
240 + <div class="sheet-sec">
241 + <div class="kv"><span>Classe stockée</span><b>${esc(d.storedClass || '—')}</b></div>
242 + <div class="kv"><span>IP</span><b>${esc(d.ip || '—')}</b></div>
243 + ${ipi ? `<div class="kv"><span>Réseau</span><b>AS${ipi.asn} · ${esc(ipi.org || ipi.isp || '?')}</b></div>
244 + <div class="kv"><span>Lieu</span><b>${esc([ipi.city, ipi.region, ipi.country].filter(Boolean).join(', ') || '—')}</b></div>
245 + <div class="kv"><span>Drapeaux</span><b>${ipi.hosting ? 'hosting ' : ''}${ipi.proxy ? 'proxy ' : ''}${ipi.mobile ? 'mobile ' : ''}${!ipi.hosting && !ipi.proxy ? 'résidentiel' : ''}</b></div>` : '<div class="soc-sub">IP pas encore résolue (file ip-api)</div>'}
246 + </div>
247 + <div class="sheet-sec"><h4>Raisons de la classification</h4>
248 + ${(d.reasons || []).map(x => `<div class="inc-item"><span>·</span><span>${esc(x)}</span></div>`).join('')}
249 + </div>
250 + <div class="sheet-sec"><h4>Événements récents (${(d.events || []).length})</h4>
251 + ${(d.events || []).slice(0, 20).map(e2 => `<div class="inc-item mono" style="font-size:11px"><span>${e2.ev}</span><span>${new Date(e2.ts).toLocaleTimeString('fr-CA')} · ${esc(e2.site)} · ${esc(e2.path)} · ${esc(e2.cls)}</span></div>`).join('') || '<div class="soc-sub">aucun</div>'}
252 + </div>`);
253 + }
254 + $('visDebugBtn').onclick = () => inspect(null);
255 +
256 + document.querySelectorAll('#visMode button').forEach(b => {
257 + b.onclick = () => {
258 + mode = b.dataset.v;
259 + document.querySelectorAll('#visMode button').forEach(x => x.classList.toggle('on', x === b));
260 + if (lastSummary) { renderGlobal(lastSummary); $('visGrid').innerHTML = (lastSummary.sites || []).map(siteCard).join(''); $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); }); }
261 + };
262 + });
263 +
264 + // graphique série 30 j : humains (trait plein) + héritage v1 (pointillé gris)
265 + function chart(series, mig) {
266 + const w = 680, h = 220, pl = 42, pb = 26, pt = 10;
267 + const n = series.length; if (n < 2) return '<div class="soc-sub">Pas encore assez de données.</div>';
268 + const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0]));
269 + const X = i => pl + (w - pl - 10) * (i / (n - 1));
270 + const Y = v => h - pb - (h - pb - pt) * (v / max);
271 + let dH = '', dL = '', pen = false;
272 + series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); });
273 + series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; });
274 + const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`;
275 + let lab = '';
276 + for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `<text x="${X(i)}" y="${h - 8}" font-size="11" fill="var(--muted)" text-anchor="middle" font-family="var(--mono)">${series[i].day.slice(5)}</text>`;
277 + const grid = [0.5, 1].map(f => `<line x1="${pl}" y1="${Y(max * f)}" x2="${w - 10}" y2="${Y(max * f)}" stroke="var(--grid)"/><text x="${pl - 6}" y="${Y(max * f) + 4}" font-size="10" fill="var(--muted)" text-anchor="end" font-family="var(--mono)">${nf(Math.round(max * f))}</text>`).join('');
278 + const migDay = mig ? new Date(mig).toISOString().slice(0, 10) : null;
279 + const migIdx = migDay ? series.findIndex(x => x.day >= migDay) : -1;
280 + const migLine = migIdx > 0 ? `<line x1="${X(migIdx)}" y1="${pt}" x2="${X(migIdx)}" y2="${h - pb}" stroke="var(--muted)" stroke-dasharray="3 4"/><text x="${X(migIdx) + 4}" y="${pt + 10}" font-size="9" fill="var(--muted)">migration v2</text>` : '';
281 + return `<svg viewBox="0 0 ${w} ${h}" class="vis-chart">
282 + ${grid}${migLine}
283 + <path d="${area}" fill="var(--orange-deep)" opacity="0.12"/>
284 + <path d="${dL}" fill="none" stroke="var(--muted)" stroke-width="1.6" stroke-dasharray="4 4"/>
285 + <path d="${dH}" fill="none" stroke="var(--orange-deep)" stroke-width="2.4" stroke-linejoin="round"/>
286 + ${lab}
287 + </svg>
288 + <div class="legend"><span><i class="sw" style="background:var(--orange-deep)"></i>humains (v2)</span><span><i class="sw" style="background:var(--muted)"></i>ancien comptage (non filtré)</span></div>`;
289 + }
290 +
291 + async function openDetail(site, label) {
292 + openSheet(`<div class="sheet-h"><img class="site-logo big" src="/icons/${site}" onerror="this.style.display='none'"><h3>${esc(label)}</h3></div><div id="vdBody"><div class="soc-sub">Chargement…</div></div>`);
293 + try {
294 + const [sm, se, det] = await Promise.all([
295 + lastSummary ? Promise.resolve(lastSummary) : (await fetch('/api/analytics/summary')).json(),
296 + (await fetch('/api/analytics/series/' + site)).json(),
297 + (await fetch('/api/analytics/site/' + site + '?days=7')).json(),
298 + ]);
299 + const s = (sm.sites || []).find(x => x.site === site) || {};
300 + const bar = (rows, kL, kV, unit) => {
301 + const mx = Math.max(1, ...rows.map(r => r[kV]));
302 + return rows.map(r => `<div class="vd-geo"><span class="vd-geoL" title="${esc(String(r[kL]))}">${esc(String(r[kL]).slice(0, 42))}</span><div class="vd-bar"><div style="width:${Math.round(100 * r[kV] / mx)}%"></div></div><span class="vd-geoN">${nf(r[kV])}${unit || ''}</span></div>`).join('') || '<div class="soc-sub">rien encore</div>';
303 + };
304 + const devTot = det.devs.reduce((a, x) => a + x.uv, 0) || 1;
305 + $('vdBody').innerHTML = `
306 + <div class="sheet-sec">
307 + <div class="vd-kpis">
308 + <div class="vd-k"><div class="vd-kn">${nf(s.online)}</div><div class="vd-kl">en ligne</div></div>
309 + <div class="vd-k"><div class="vd-kn">${nf(s.today)}</div><div class="vd-kl">humains auj.</div></div>
310 + <div class="vd-k"><div class="vd-kn">${nf(s.sessions)}</div><div class="vd-kl">sessions</div></div>
311 + <div class="vd-k"><div class="vd-kn">${nf(s.pv)}</div><div class="vd-kl">pages vues</div></div>
312 + </div>
313 + <div class="vd-kpis" style="margin-top:8px">
314 + <div class="vd-k"><div class="vd-kn">${nf(det.newToday)}</div><div class="vd-kl">nouveaux</div></div>
315 + <div class="vd-k"><div class="vd-kn">${nf(det.returningToday)}</div><div class="vd-kl">récurrents</div></div>
316 + <div class="vd-k"><div class="vd-kn">${det.pagesPerSession || 0}</div><div class="vd-kl">pages/session</div></div>
317 + <div class="vd-k"><div class="vd-kn">${det.avgSessionSec >= 60 ? Math.round(det.avgSessionSec / 60) + ' min' : (det.avgSessionSec || 0) + ' s'}</div><div class="vd-kl">durée moy.</div></div>
318 + </div>
319 + </div>
320 + <div class="sheet-sec"><h4>Visiteurs humains — 30 jours</h4>${chart(se.series || [], se.migratedAt)}</div>
321 + <div class="sheet-sec"><h4>Pages populaires — 7 j (humains)</h4>${bar(det.pages, 'path', 'pv')}</div>
322 + <div class="sheet-sec"><h4>Sources / referrers — 7 j</h4>${det.refs.length ? bar(det.refs.map(r => ({ ...r, ref: r.ref.replace(/^https?:\/\//, '') })), 'ref', 'uv') : '<div class="soc-sub">rien encore (referrer fourni par le beacon v2)</div>'}</div>
323 + <div class="sheet-sec"><h4>Appareils</h4>${bar(det.devs.map(x => ({ l: (DEV_LABEL[x.dev] || 'inconnu (beacon v1)') + ' · ' + Math.round(x.uv / devTot * 100) + ' %', uv: x.uv })), 'l', 'uv')}</div>
324 + <div class="sheet-sec"><h4>D'où viennent les visiteurs — 7 j</h4>
325 + ${bar(det.geo.map(x => ({ l: x.city + (x.region ? ', ' + (x.cc === 'CA' ? x.region : x.country) : ''), uv: x.uv })), 'l', 'uv')}
326 + ${det.countries.length > 1 ? '<h4 style="margin-top:12px">Pays</h4>' + bar(det.countries.map(x => ({ l: x.country, uv: x.uv })), 'l', 'uv') : ''}
327 + </div>
328 + <div class="sheet-sec"><h4>Trafic filtré — 7 j</h4>
329 + ${bar((det.classes || []).filter(c => c.cls !== 'human').map(c => ({ l: (CLS_LABEL[c.cls] || c.cls) + ' (' + nf(c.uv) + ' ids)', n: c.n })), 'l', 'n', ' hits')}
330 + </div>`;
331 + } catch (e) { $('vdBody').innerHTML = '<div class="soc-sub">Erreur : ' + esc(e.message) + '</div>'; }
332 + }
333 +
334 + window.startVisitors = function () {
335 + loadVisitors();
336 + clearInterval(timer);
337 + timer = setInterval(() => { if (curView === 'visitors' && !document.hidden) { loadVisitors(); } }, 12000);
338 + };
339 +})();
340 +
341 +// ===== VUE D'ENSEMBLE =====
342 +(function () {
343 + const $ = (id) => document.getElementById(id);
344 + let timer = null;
345 + const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' ');
346 + const fmt$$ = (v) => (v || 0).toFixed(2).replace('.', ',') + ' $';
347 + function kpi(v, l, extra, cls) {
348 + return `<div class="kpi2 ${cls || ''}"><div class="kv2">${v}</div><div class="kl2">${l}</div>${extra || ''}</div>`;
349 + }
350 + function delta(cur, prev) {
351 + if (!prev) return '';
352 + const d = Math.round((cur - prev) / prev * 100);
353 + if (!isFinite(d) || Math.abs(d) < 1) return '<span class="kdelta flat">≈ hier</span>';
354 + return `<span class="kdelta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '+' : ''}${d}&nbsp;% vs hier</span>`;
355 + }
356 + async function load() {
357 + try {
358 + const [an, eco, chats, anom, series] = await Promise.all([
359 + (await fetch('/api/analytics/summary')).json(),
360 + (await fetch('/api/eco/summary')).json(),
361 + (await fetch('/api/chats')).json(),
362 + (await fetch('/api/analytics/anomalies')).json(),
363 + (await fetch('/api/analytics/series/_all')).json(),
364 + ]);
365 + const g = an.global || {}, eg = eco.global || {};
366 + const running = (chats.chats || []).filter(c => c.state !== 'idle');
367 + const cost = (chats.chats || []).reduce((a, c) => a + (c.totalCost || 0), 0);
368 + $('ovStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
369 + const ts = $('topStamp'); if (ts) ts.textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' });
370 + $('ovKpis').innerHTML =
371 + kpi(nf(g.online), 'humains en ligne', '', 'accent')
372 + + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame))
373 + + kpi(nf(g.sessions), 'sessions')
374 + + kpi(nf(g.pv), 'pages vues')
375 + + kpi(`${eg.up}<small>/${eg.total}</small>`, 'sites en ligne', '', eg.down ? 'bad' : 'good')
376 + + kpi(eg.openIncidents || 0, 'incidents ouverts', '', eg.openIncidents ? 'bad' : '')
377 + + kpi((eg.uptime24 ?? '—') + '<small>%</small>', 'uptime 24 h')
378 + + kpi(eg.avgLatency + '<small> ms</small>', 'latence moy.')
379 + + kpi(running.length, 'sessions Claude actives', '', running.length ? 'accent' : '')
380 + + kpi(fmt$$(cost), 'coût Claude cumulé');
381 + // graphique 30 j global
382 + const s = series.series || [];
383 + $('ovChart').innerHTML = ovChart(s, series.migratedAt);
384 + const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0);
385 + $('ovBreak').innerHTML = `<div class="panel-note" style="margin-top:8px">Aujourd’hui : <b>${nf(g.today)}</b> humains · trafic filtré : ${nf(g.crawler)} crawler + ${nf(g.datacenter)} datacenter + ${nf(g.automation)} automation + ${nf(g.internal)} interne (${nf(autoHits)} hits exclus des métriques)</div>`;
386 + // écosystème compact
387 + const order = { down: 0, slow: 1, unknown: 2, up: 3 };
388 + const stt = (x) => (x.incident || (x.last && !x.last.ok)) ? 'down' : (x.last && x.last.ms > 4000) ? 'slow' : x.last ? 'up' : 'unknown';
389 + $('ovEco').innerHTML = '<div class="ov-eco-chips">' + [...eco.sites].sort((a, b) => order[stt(a)] - order[stt(b)]).map(x => {
390 + const st = stt(x);
391 + return `<span class="eco-chip ${st}" data-goto="eco" title="${x.label} — ${x.last?.ok ? x.last.ms + ' ms' : 'hors ligne'}">${st === 'up' ? '●' : st === 'slow' ? '▲' : '■'} ${x.label}<b>${x.last?.ok ? x.last.ms + 'ms' : 'OFF'}</b></span>`;
392 + }).join('') + '</div>';
393 + // incidents récents
394 + const inc = await (await fetch('/api/eco/incidents')).json();
395 + $('ovInc').innerHTML = (inc.incidents || []).slice(0, 5).map(i => `
396 + <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>
397 + <span><b>${esc(i.site)}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'}</span></div>`).join('')
398 + || '<div class="soc-sub">Aucun incident récent 🎉</div>';
399 + // Claude Code
400 + $('ovClaude').innerHTML = (chats.chats || []).slice(0, 5).map(c => `
401 + <div class="ov-sess" data-chat="${c.id}">
402 + <span class="sess-badge ${c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>
403 + <span class="ov-sess-t">${esc(c.title)}</span>
404 + <span class="ov-sess-m mono">${esc(c.projectName || '')} · ${fmt$$(c.totalCost)}</span>
405 + </div>`).join('') || '<div class="soc-sub">Aucune session encore.</div>';
406 + $('ovClaude').querySelectorAll('[data-chat]').forEach(x => { x.onclick = () => openChat(x.dataset.chat); });
407 + // anomalies
408 + const bits = [];
409 + if (anom.datacenter?.surge) bits.push(`<div class="anom warn">▲ Poussée datacenter : ${nf(anom.datacenter.lastHour)} hits/h</div>`);
410 + for (const sp of anom.spikes || []) bits.push(`<div class="anom warn">▲ Pic sur <b>${esc(sp.site)}</b> : ×${sp.factor}</div>`);
411 + for (const ip of (anom.topIps || []).slice(0, 3)) bits.push(`<div class="anom"><span class="mono">${esc(ip.ip)}</span> · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} → <b>${esc(ip.cls)}</b></div>`);
412 + $('ovAnom').innerHTML = bits.length ? bits.join('') : '<div class="soc-sub">● Rien à signaler.</div>';
413 + } catch {}
99 414 }
100 function chart(series){
101 var w=680,h=240,pad=30; var vals=series.map(function(x){return x.uniques;});
102 var max=Math.max(1,Math.max.apply(null,vals)); var n=series.length;
103 var X=function(i){return pad+(w-2*pad)*(n<=1?0:i/(n-1));};
104 var Y=function(v){return h-pad-(h-2*pad)*(v/max);};
105 var pts=series.map(function(x,i){return X(i)+','+Y(x.uniques);}).join(' ');
106 var area='M'+X(0)+','+(h-pad)+' L'+pts.replace(/ /g,' L')+' L'+X(n-1)+','+(h-pad)+' Z';
107 var last=series[n-1]||{uniques:0};
108 var dots='<circle cx="'+X(n-1)+'" cy="'+Y(last.uniques)+'" r="6" fill="var(--accent,#f97316)"/>';
109 // libellés d'axe : quelques jours
110 var lab=''; for(var i=0;i<n;i+=Math.ceil(n/5)){ var dd=series[i].day.slice(5); lab+='<text x="'+X(i)+'" y="'+(h-8)+'" font-size="14" fill="#888" text-anchor="middle">'+dd+'</text>'; }
111 return '<svg viewBox="0 0 '+w+' '+h+'" class="vis-chart">'
112 +'<defs><linearGradient id="ga" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--accent,#f97316)" stop-opacity=".45"/><stop offset="1" stop-color="var(--accent,#f97316)" stop-opacity="0"/></linearGradient></defs>'
113 +'<path d="'+area+'" fill="url(#ga)"/>'
114 +'<polyline points="'+pts+'" fill="none" stroke="var(--accent,#f97316)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>'
115 +dots+lab
116 +'<text x="'+pad+'" y="20" font-size="15" fill="#999">max '+max+'/j</text></svg>';
117 }
118 async function openDetail(site,label){
119 var ov=$('visDetail');
120 if(!ov){ ov=document.createElement('div'); ov.id='visDetail'; document.body.appendChild(ov); }
121 ov.innerHTML='<div class="vd-box"><div class="vd-head"><img src="/icons/'+site+'"><h3>'+label+'</h3><button id="vdClose">✕</button></div><div class="vd-body"><div class="soc-sub">Chargement…</div></div></div>';
122 ov.classList.add('on'); $('vdClose').onclick=function(){ ov.classList.remove('on'); };
123 ov.onclick=function(e){ if(e.target===ov) ov.classList.remove('on'); };
124 try{
125 var sm=await (await fetch('/api/analytics/summary')).json();
126 var s=(sm.sites||[]).filter(function(x){return x.site===site;})[0]||{};
127 var se=(await (await fetch('/api/analytics/series/'+site)).json()).series||[];
128 var ge=(await (await fetch('/api/analytics/geo/'+site)).json()).geo||[];
129 var maxg=Math.max(1,Math.max.apply(null,ge.map(function(x){return x.c;})||[1]));
130 var geoHtml = ge.length ? ge.map(function(x){return '<div class="vd-geo"><span class="vd-geoL">'+x.label+'</span><div class="vd-bar"><div style="width:'+Math.round(100*x.c/maxg)+'%"></div></div><span class="vd-geoN">'+nf(x.c)+'</span></div>';}).join('') : '<div class="soc-sub">Emplacements en cours de géolocalisation…</div>';
131 ov.querySelector('.vd-body').innerHTML=
132 '<div class="vd-kpis">'
133 +'<div class="vd-k"><div class="vd-kn">'+nf(s.online)+'</div><div class="vd-kl">en ligne</div></div>'
134 +'<div class="vd-k"><div class="vd-kn">'+nf(s.today)+'</div><div class="vd-kl">aujourd&#39;hui</div></div>'
135 +'<div class="vd-k"><div class="vd-kn">'+nf(s.d7)+'</div><div class="vd-kl">7 jours</div></div>'
136 +'<div class="vd-k"><div class="vd-kn">'+nf(s.d30)+'</div><div class="vd-kl">30 jours</div></div>'
137 +'</div>'
138 +'<div class="vd-title">Visiteurs uniques · 30 jours</div>'+chart(se)
139 +'<div class="vd-title">D&#39;où viennent les visiteurs</div>'+geoHtml;
140 }catch(e){ ov.querySelector('.vd-body').innerHTML='<div class="soc-sub">Erreur : '+e.message+'</div>'; }
141 }
142 window.startVisitors=function(){ loadVisitors(); clearInterval(timer); timer=setInterval(function(){ if(curView==='visitors') loadVisitors(); },15000); };
415 + function ovChart(series, mig) {
416 + const w = 760, h = 200, pl = 44, pb = 24, pt = 8;
417 + const n = series.length; if (n < 2) return '<div class="soc-sub">Les données humaines s’accumulent — revenez dans quelques heures.</div>';
418 + const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0]));
419 + const X = i => pl + (w - pl - 10) * (i / (n - 1));
420 + const Y = v => h - pb - (h - pb - pt) * (v / max);
421 + let dH = '', dL = '', pen = false;
422 + series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); });
423 + series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; });
424 + const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`;
425 + let lab = '';
426 + for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `<text x="${X(i)}" y="${h - 6}" font-size="10" fill="var(--muted)" text-anchor="middle" font-family="var(--mono)">${series[i].day.slice(5)}</text>`;
427 + const grid = [0.5, 1].map(f => `<line x1="${pl}" y1="${Y(max * f)}" x2="${w - 10}" y2="${Y(max * f)}" stroke="var(--grid)"/><text x="${pl - 6}" y="${Y(max * f) + 4}" font-size="10" fill="var(--muted)" text-anchor="end" font-family="var(--mono)">${nf(Math.round(max * f))}</text>`).join('');
428 + return `<svg viewBox="0 0 ${w} ${h}" class="vis-chart">${grid}
429 + <path d="${area}" fill="var(--orange-deep)" opacity="0.10"/>
430 + <path d="${dL}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4 4"/>
431 + <path d="${dH}" fill="none" stroke="var(--orange-deep)" stroke-width="2.4" stroke-linejoin="round"/>${lab}</svg>
432 + <div class="legend"><span><i class="sw" style="background:var(--orange-deep)"></i>humains (v2, filtré)</span><span><i class="sw" style="background:var(--muted)"></i>ancien comptage v1 (non filtré)</span></div>`;
433 + }
434 + window.startOverview = function () {
435 + load();
436 + clearInterval(timer);
437 + timer = setInterval(() => { if (curView === 'overview' && !document.hidden) load(); }, 30000);
438 + };
143 439 })();
144 440
145 441
@@ -299,8 +595,7 @@
299 595 bindSocial();
300 596 function navBadge(view) {
301 597 if (curView === view) return;
302 const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`);
303 if (b) b.classList.remove('hidden');
598 + document.querySelectorAll(`.nav-btn[data-view="${view}"] .badge`).forEach(b => b.classList.remove('hidden'));
304 599 }
305 600
306 601 // ---------- bannières ----------
@@ -660,9 +955,13 @@
660 955 // ---------- WebSocket ----------
661 956 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
662 957 function setConn(state) {
663 const pill = $('connPill');
664 pill.className = 'conn-pill ' + state;
665 $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';
958 + const txt = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';
959 + for (const id of ['connPill', 'connPillSide']) {
960 + const pill = $(id);
961 + if (!pill) continue;
962 + pill.className = 'conn-pill ' + state;
963 + const t = pill.querySelector('.conn-txt'); if (t) t.textContent = txt;
964 + }
666 965 }
667 966 function connect() {
668 967 if (!wsWanted) return;
@@ -724,7 +1023,7 @@
724 1023 return;
725 1024 }
726 1025 if (msg.type === 'eco_alert') {
727 if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000);
1026 + if (msg.kind === 'down') { banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); const tb = $('topIncBadge'); if (tb) tb.classList.remove('hidden'); }
728 1027 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));
729 1028 navBadge('eco');
730 1029 if (curView === 'eco') loadEco();
@@ -918,79 +1217,134 @@
918 1217 }
919 1218
920 1219 // ---------- sessions ----------
921 async function loadSessions() {
922 const r = await fetch('/api/chats');
923 if (!r.ok) return;
924 const { chats } = await r.json();
1220 + let sessCache = [];
1221 + function sessFilters() {
1222 + return { q: ($('sessSearch')?.value || '').toLowerCase(), app: $('sessApp')?.value || '', st: $('sessState')?.value || '' };
1223 + }
1224 + function renderSessions() {
1225 + const { q, app, st } = sessFilters();
925 1226 const list = $('sessList');
926 1227 list.innerHTML = '';
927 if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; }
1228 + const rows = sessCache.filter(c =>
1229 + (!q || (c.title + ' ' + (c.projectName || '')).toLowerCase().includes(q))
1230 + && (!app || c.projectId === app)
1231 + && (!st || c.state === st));
1232 + if (!rows.length) { list.appendChild(el('div', 'sess-empty', sessCache.length ? 'Aucune session ne correspond aux filtres.' : 'Aucune session. Lance une conversation dans l’onglet Claude Code.')); return; }
928 1233 const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };
929 for (const c of chats) {
930 const card = el('div', 'sess-card');
1234 + for (const c of rows) {
1235 + const card = el('div', 'sess-card row');
931 1236 card.innerHTML = `
932 <div class="sess-top">
933 <span class="sess-title">${esc(c.title)}</span>
934 <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span>
1237 + <span class="sess-badge ${c.state}" title="${stLabel[c.state] || c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>
1238 + <div class="sess-main">
1239 + <span class="sess-title">${c.batch ? '<span class="batch-tag">🌐</span> ' : ''}${esc(c.title)}</span>
1240 + <span class="sess-info"><span>${esc(c.projectName || '')}</span><span>${MODEL_LABEL[c.model] || c.model}</span><span>${c.contextTokens ? 'ctx ' + fmtTok(c.contextTokens) : ''}</span></span>
935 1241 </div>
936 <div class="sess-info">
937 ${c.batch ? '<span class="batch-tag">🌐 diffusion</span>' : ''}
938 <span>${esc(c.projectName || '')}</span>
939 <span>${MODEL_LABEL[c.model] || c.model}</span>
940 <span>${fmt$(c.totalCost)}</span>
941 <span>${timeAgo(c.updated)}</span>
942 </div>`;
943 const actions = el('div', 'sess-actions');
1242 + <span class="sess-cost mono">${fmt$(c.totalCost)}</span>
1243 + <span class="sess-when mono">${timeAgo(c.updated)}</span>
1244 + <span class="sess-btns"></span>`;
1245 + const btns = card.querySelector('.sess-btns');
944 1246 const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');
945 1247 open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };
946 actions.appendChild(open);
1248 + btns.appendChild(open);
947 1249 if (c.state === 'idle') {
948 const del = el('button', 'del', 'Supprimer');
1250 + const del = el('button', 'del', '✕');
1251 + del.title = 'Supprimer';
949 1252 del.onclick = async (e) => {
950 1253 e.stopPropagation();
951 1254 if (!confirm('Supprimer cette session ?')) return;
952 1255 await fetch('/api/chats/' + c.id, { method: 'DELETE' });
953 card.remove();
1256 + sessCache = sessCache.filter(x => x.id !== c.id);
1257 + renderSessions();
954 1258 if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }
955 1259 };
956 actions.appendChild(del);
1260 + btns.appendChild(del);
957 1261 }
958 card.appendChild(actions);
959 1262 card.onclick = () => openChat(c.id);
960 1263 list.appendChild(card);
961 1264 }
962 1265 }
1266 + async function loadSessions() {
1267 + const r = await fetch('/api/chats');
1268 + if (!r.ok) return;
1269 + sessCache = (await r.json()).chats || [];
1270 + // remplit le filtre app à partir des sessions existantes
1271 + const sel = $('sessApp');
1272 + if (sel && sel.options.length <= 1) {
1273 + const seen = new Map();
1274 + for (const c of sessCache) if (c.projectId && !seen.has(c.projectId)) seen.set(c.projectId, c.projectName || c.projectId);
1275 + for (const [id2, name] of seen) { const o = document.createElement('option'); o.value = id2; o.textContent = name; sel.appendChild(o); }
1276 + }
1277 + renderSessions();
1278 + }
963 1279 $('newSessBtn').onclick = newChat;
1280 + for (const id2 of ['sessSearch', 'sessApp', 'sessState']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderSessions; }
1281 +
1282 + // sessions récentes sur l'écran d'accueil du chat
1283 + async function loadWelcomeRecent() {
1284 + const box = $('welcomeRecent');
1285 + if (!box) return;
1286 + try {
1287 + const { chats } = await (await fetch('/api/chats')).json();
1288 + if (!chats.length) { box.innerHTML = ''; return; }
1289 + box.innerHTML = '<div class="wr-title">Reprendre une session</div>' + chats.slice(0, 4).map(c => `
1290 + <button class="wr-item" data-chat="${c.id}">
1291 + <span class="sess-badge ${c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>
1292 + <span class="wr-t">${esc(c.title)}</span>
1293 + <span class="wr-m mono">${esc(c.projectName || '')}</span>
1294 + </button>`).join('');
1295 + box.querySelectorAll('[data-chat]').forEach(b => { b.onclick = () => openChat(b.dataset.chat); });
1296 + } catch {}
1297 + }
964 1298
965 1299 // ---------- Prompt Bank ----------
966 1300 function projectOptionsHTML(selected) {
967 1301 return [...projectSel.options].map(o =>
968 1302 `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');
969 1303 }
970 async function loadPrompts() {
971 const r = await fetch('/api/prompts');
972 if (!r.ok) return;
973 const { prompts } = await r.json();
1304 + let promptCache = [];
1305 + function renderPrompts() {
1306 + const q = ($('promptSearch')?.value || '').toLowerCase();
1307 + const app = $('promptApp')?.value || '';
974 1308 const list = $('promptList');
975 1309 list.innerHTML = '';
976 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; }
977 for (const pr of prompts) {
1310 + const rows = promptCache
1311 + .filter(pr => (!q || (pr.title + ' ' + pr.text).toLowerCase().includes(q)) && (!app || pr.project === app))
1312 + .sort((a, b) => (b.fav ? 1 : 0) - (a.fav ? 1 : 0) || (b.updated || 0) - (a.updated || 0));
1313 + if (!rows.length) { list.appendChild(el('div', 'sess-empty', promptCache.length ? 'Aucun prompt ne correspond.' : 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; }
1314 + for (const pr of rows) {
978 1315 const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix';
979 const card = el('div', 'sess-card');
1316 + const card = el('div', 'sess-card prow');
980 1317 card.innerHTML = `
981 <div class="sess-top"><span class="sess-title">${esc(pr.title)}</span></div>
982 <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>
983 <div class="sess-info"><span>🎯 ${esc(projName)}</span><span>${timeAgo(pr.updated)}</span></div>`;
1318 + <div class="sess-top">
1319 + <button class="fav-btn${pr.fav ? ' on' : ''}" title="Favori">${pr.fav ? '★' : '☆'}</button>
1320 + <span class="sess-title">${esc(pr.title)}</span>
1321 + <span class="sess-info"><span>🎯 ${esc(projName)}</span>${pr.uses ? `<span>${pr.uses}× utilisé</span>` : ''}<span>${timeAgo(pr.updated)}</span></span>
1322 + </div>
1323 + <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>`;
984 1324 const actions = el('div', 'sess-actions');
985 const sendB = el('button', 'p-send', 'Envoyer →');
1325 + const sendB = el('button', 'p-send', 'Lancer →');
986 1326 const editB = el('button', '', '✎ Éditer');
987 1327 const upB = el('button', '', '✨ Améliorer');
1328 + const dupB = el('button', '', '⧉ Dupliquer');
988 1329 const delB = el('button', 'del', 'Suppr.');
989 actions.append(sendB, upB, editB, delB);
1330 + actions.append(sendB, upB, editB, dupB, delB);
990 1331 card.appendChild(actions);
991 sendB.onclick = () => openSendSheet(pr);
1332 + card.querySelector('.fav-btn').onclick = async (e) => {
1333 + e.stopPropagation();
1334 + pr.fav = !pr.fav;
1335 + await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fav: pr.fav }) });
1336 + renderPrompts();
1337 + };
1338 + sendB.onclick = () => {
1339 + fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bumpUse: true }) }).catch(() => {});
1340 + openSendSheet(pr);
1341 + };
992 1342 editB.onclick = () => openPromptEditor(pr);
993 delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); card.remove(); };
1343 + dupB.onclick = async () => {
1344 + await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: pr.title + ' (copie)', text: pr.text, project: pr.project }) });
1345 + loadPrompts();
1346 + };
1347 + delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); promptCache = promptCache.filter(x => x.id !== pr.id); renderPrompts(); };
994 1348 upB.onclick = async () => {
995 1349 upB.textContent = '✨ …'; upB.disabled = true;
996 1350 try {
@@ -1007,7 +1361,23 @@
1007 1361 list.appendChild(card);
1008 1362 }
1009 1363 }
1364 + async function loadPrompts() {
1365 + const r = await fetch('/api/prompts');
1366 + if (!r.ok) return;
1367 + promptCache = (await r.json()).prompts || [];
1368 + const sel = $('promptApp');
1369 + if (sel && sel.options.length <= 1) {
1370 + const seen = new Set(promptCache.map(p2 => p2.project).filter(Boolean));
1371 + for (const id2 of seen) {
1372 + const o = document.createElement('option');
1373 + o.value = id2; o.textContent = [...projectSel.options].find(x => x.value === id2)?.textContent || id2;
1374 + sel.appendChild(o);
1375 + }
1376 + }
1377 + renderPrompts();
1378 + }
1010 1379 $('newPromptBtn').onclick = () => openPromptEditor(null);
1380 + for (const id2 of ['promptSearch', 'promptApp']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderPrompts; }
1011 1381
1012 1382 function openPromptEditor(pr) {
1013 1383 const isNew = !pr;
@@ -1079,8 +1449,11 @@
1079 1449 banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000);
1080 1450 }
1081 1451
1082 // incidents globaux (bouton en tête de l'onglet Écosystème)
1083 $('incBtn').onclick = async () => {
1452 + // incidents globaux (bouton Écosystème + cloche topbar)
1453 + $('topInc').onclick = () => openIncidents();
1454 + $('incBtn').onclick = () => openIncidents();
1455 + async function openIncidents() {
1456 + const tb = $('topIncBadge'); if (tb) tb.classList.add('hidden');
1084 1457 const r = await fetch('/api/eco/incidents');
1085 1458 if (!r.ok) return;
1086 1459 const { incidents } = await r.json();
@@ -1457,6 +1830,40 @@
1457 1830 location.reload();
1458 1831 };
1459 1832
1833 + // ---------- réglages analytics ----------
1834 + window.loadAnCfg = async function () {
1835 + const box = $('anCfg');
1836 + if (!box) return;
1837 + try {
1838 + const c = await (await fetch('/api/analytics/config')).json();
1839 + box.innerHTML = `
1840 + <div class="set-row"><span>Fenêtre de session</span><span><input id="acSess" class="ac-num" type="number" min="5" max="240" value="${c.sessionMin}"> min</span></div>
1841 + <div class="set-row"><span>Fenêtre « en ligne »</span><span><input id="acAct" class="ac-num" type="number" min="30" max="900" value="${c.activeSec}"> s</span></div>
1842 + <div class="set-row"><span>Rétention des événements</span><span><input id="acRet" class="ac-num" type="number" min="7" max="365" value="${c.retentionDays}"> jours</span></div>
1843 + <div class="set-row"><span>Seuil anti-flood par IP</span><span><input id="acFlood" class="ac-num" type="number" min="100" max="100000" value="${c.maxIpPerHour}"> evt/h</span></div>
1844 + <div class="set-label" style="margin-top:10px">IPs internes exclues (préfixes, une par ligne)</div>
1845 + <textarea id="acIps" class="pe-textarea" style="min-height:56px">${esc((c.internalIps || []).join('\n'))}</textarea>
1846 + <div class="set-label" style="margin-top:10px">User-Agents internes / bots additionnels (fragments)</div>
1847 + <textarea id="acUas" class="pe-textarea" style="min-height:56px" placeholder="UA internes…">${esc((c.internalUAs || []).join('\n'))}</textarea>
1848 + <textarea id="acBots" class="pe-textarea" style="min-height:56px;margin-top:6px" placeholder="UA bots custom…">${esc((c.extraBotUAs || []).join('\n'))}</textarea>
1849 + <div class="set-label" style="margin-top:10px">ASNs exclus (numéros, un par ligne)</div>
1850 + <textarea id="acAsn" class="pe-textarea" style="min-height:44px">${esc((c.blockedASNs || []).join('\n'))}</textarea>
1851 + <div class="set-actions"><button id="acSave" class="btn-cta small">Enregistrer</button><span id="acMsg" class="soc-sub"></span></div>`;
1852 + $('acSave').onclick = async () => {
1853 + const lines = (id2) => $(id2).value.split('\n').map(s => s.trim()).filter(Boolean);
1854 + const body = {
1855 + sessionMin: +$('acSess').value, activeSec: +$('acAct').value,
1856 + retentionDays: +$('acRet').value, maxIpPerHour: +$('acFlood').value,
1857 + internalIps: lines('acIps'), internalUAs: lines('acUas'),
1858 + extraBotUAs: lines('acBots'), blockedASNs: lines('acAsn').map(Number).filter(Boolean),
1859 + };
1860 + const r2 = await fetch('/api/analytics/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1861 + $('acMsg').textContent = r2.ok ? '✓ enregistré et appliqué' : '✗ échec';
1862 + setTimeout(() => { $('acMsg').textContent = ''; }, 3000);
1863 + };
1864 + } catch { box.innerHTML = '<div class="soc-sub">Config indisponible.</div>'; }
1865 + };
1866 +
1460 1867 // ---------- login / init ----------
1461 1868 async function init() {
1462 1869 const me = await fetch('/api/me');
@@ -1489,10 +1896,13 @@
1489 1896 };
1490 1897 refreshChips();
1491 1898 refreshSettingsView();
1899 + loadWelcomeRecent();
1492 1900 // reprise automatique : on rouvre la dernière conversation (historique +
1493 1901 // live si une tâche tourne encore) même après fermeture complète de l'app
1494 1902 if (settings.lastChatId) currentChat = { id: settings.lastChatId };
1495 1903 connect();
1904 + // page d'accueil = Vue d'ensemble (le chat garde sa reprise en arrière-plan)
1905 + switchView('overview');
1496 1906 }
1497 1907 $('loginBtn').onclick = doLogin;
1498 1908 $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
modified public/index.html +247 −158
@@ -7,11 +7,15 @@
7 7 <meta name="apple-mobile-web-app-capable" content="yes">
8 8 <meta name="apple-mobile-web-app-status-bar-style" content="default">
9 9 <meta name="apple-mobile-web-app-title" content="Admin-Ka">
10 <title>Admin-Ka — Console Groupe KA</title>
10 +<title>Admin-Ka — Control Center Groupe KA</title>
11 11 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='-apple-system,sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E">
12 12 <link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E">
13 13 <link rel="stylesheet" href="/vendor/hljs-github.min.css">
14 14 <link rel="stylesheet" href="/style.css">
15 +<script>/* thème avant peinture (anti-flash) */
16 +(function(){try{var t=localStorage.getItem('ka_theme')||'auto';
17 +var dark=t==='dark'||(t==='auto'&&matchMedia('(prefers-color-scheme: dark)').matches);
18 +document.documentElement.dataset.theme=dark?'dark':'light';}catch(e){}})();</script>
15 19 </head>
16 20 <body>
17 21
@@ -20,7 +24,7 @@
20 24 <div class="login-box">
21 25 <div class="ka-mark big">Ka</div>
22 26 <h1 class="login-title">Admin<span class="tiret">-</span>Ka</h1>
23 <p class="login-sub">Console Claude Code · Groupe KA</p>
27 + <p class="login-sub">Control Center · Groupe KA</p>
24 28 <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password">
25 29 <button id="loginBtn" class="btn-cta">Se connecter</button>
26 30 <div id="loginError" class="login-error"></div>
@@ -31,183 +35,268 @@
31 35 <!-- ============ App ============ -->
32 36 <div id="app" class="app hidden">
33 37
34 <header class="header">
35 <div class="brand">
38 + <!-- Sidebar (desktop) -->
39 + <aside class="sidebar">
40 + <div class="side-brand">
36 41 <span class="ka-mark">Ka</span>
37 42 <span class="brand-name">Admin<span class="tiret">-</span>Ka</span>
38 43 </div>
39 <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText">hors ligne</span></div>
40 </header>
41
42 <!-- Bannière notifications / alertes -->
43 <div id="banners" class="banners"></div>
44
45 <!-- ======== Vue CHAT ======== -->
46 <section id="view-chat" class="view">
47 <div class="chatbar">
48 <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select>
49 <button id="chipModel" class="chip" title="Modèle">opus</button>
50 <button id="chipPerm" class="chip" title="Permissions">normal</button>
51 <button id="chipMore" class="chip ghost" title="Options de session">⋯</button>
52 </div>
53 <div id="sessMeta" class="sess-meta hidden">
54 <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span>
44 + <nav class="side-nav" id="sideNav"></nav>
45 + <div class="side-foot">
46 + <div id="connPillSide" class="conn-pill off"><span class="conn-dot"></span><span class="conn-txt">hors ligne</span></div>
47 + <div class="side-node mono">M3U96a · v3</div>
55 48 </div>
49 + </aside>
50 +
51 + <div class="main-col">
52 + <!-- Topbar -->
53 + <header class="header">
54 + <div class="brand mobile-only">
55 + <span class="ka-mark">Ka</span>
56 + <span class="brand-name">Admin<span class="tiret">-</span>Ka</span>
57 + </div>
58 + <h1 id="pageTitle" class="page-title desktop-only">Vue d’ensemble</h1>
59 + <div class="head-right">
60 + <span id="topStamp" class="top-stamp desktop-only"></span>
61 + <button id="topInc" class="icon-btn" title="Incidents"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/><path d="M12 10v4M12 16.6v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg><i id="topIncBadge" class="badge hidden"></i></button>
62 + <button id="themeBtn" class="icon-btn" title="Thème clair/sombre"><svg viewBox="0 0 24 24"><path d="M12 4.5a7.5 7.5 0 100 15 7.5 7.5 0 000-15zM12 4.5v15" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 4.5a7.5 7.5 0 010 15z" fill="currentColor"/></svg></button>
63 + <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText" class="conn-txt">hors ligne</span></div>
64 + </div>
65 + </header>
56 66
57 <main id="messages" class="messages"></main>
67 + <!-- Bannière notifications / alertes -->
68 + <div id="banners" class="banners"></div>
58 69
59 <div id="welcome" class="welcome">
60 <div class="ka-mark big">Ka</div>
61 <h2>Console Claude Code</h2>
62 <p>Le vrai Claude Code, lancé directement sur le nœud de chaque app du Groupe KA.</p>
63 <div class="hints">
64 <button class="hint">Quel modèle es-tu et dans quel répertoire travailles-tu ?</button>
65 <button class="hint">Montre les 5 derniers commits</button>
66 <button class="hint">État PM2 de l'app</button>
70 + <!-- ======== Vue VUE D'ENSEMBLE ======== -->
71 + <section id="view-overview" class="view scroll hidden">
72 + <div class="view-head">
73 + <h2>Vue d’ensemble</h2>
74 + <span id="ovStamp" class="eco-stamp"></span>
67 75 </div>
68 </div>
76 + <div id="ovKpis" class="ov-kpis"><div class="eco-loading">Chargement…</div></div>
77 + <div class="ov-cols">
78 + <div class="panel">
79 + <div class="panel-h"><h3>Visiteurs humains — 30 jours</h3><span class="panel-note">tous les sites KA</span></div>
80 + <div id="ovChart"></div>
81 + <div id="ovBreak"></div>
82 + </div>
83 + <div class="panel">
84 + <div class="panel-h"><h3>État de l’écosystème</h3><button class="linklike" data-goto="eco">tout voir →</button></div>
85 + <div id="ovEco"></div>
86 + <div class="panel-h" style="margin-top:14px"><h3>Incidents récents</h3></div>
87 + <div id="ovInc"></div>
88 + </div>
89 + </div>
90 + <div class="ov-cols">
91 + <div class="panel">
92 + <div class="panel-h"><h3>Claude Code</h3><button class="linklike" data-goto="sessions">sessions →</button></div>
93 + <div id="ovClaude"></div>
94 + </div>
95 + <div class="panel">
96 + <div class="panel-h"><h3>Anomalies de trafic</h3><span class="panel-note">dernière heure</span></div>
97 + <div id="ovAnom"></div>
98 + </div>
99 + </div>
100 + </section>
69 101
70 <footer class="composer">
71 <div class="composer-inner">
72 <button id="upgradeBtn" class="wand-btn" title="Améliorer le prompt (Opus 4.8 + savoir Groupe KA)"><svg viewBox="0 0 24 24"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linejoin="round"/></svg></button>
73 <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea>
74 <button id="stopBtn" class="send-btn stop hidden" title="Arrêter"><svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg></button>
75 <button id="sendBtn" class="send-btn" title="Envoyer"><svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></button>
76 </div>
77 <div id="slashHint" class="slash-hint hidden"></div>
78 </footer>
79 </section>
80
81 <!-- ======== Vue SESSIONS ======== -->
82 <section id="view-sessions" class="view hidden">
83 <div class="view-head">
84 <h2>Sessions</h2>
85 <button id="newSessBtn" class="btn-cta small">+ Nouvelle</button>
86 </div>
87 <div id="sessList" class="sess-list"></div>
88 </section>
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>
102 + <!-- ======== Vue CHAT (Claude Code) ======== -->
103 + <section id="view-chat" class="view">
104 + <div class="chatbar">
105 + <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select>
106 + <button id="chipModel" class="chip" title="Modèle">opus</button>
107 + <button id="chipPerm" class="chip" title="Permissions">normal</button>
108 + <button id="chipMore" class="chip ghost" title="Options de session">⋯</button>
109 + </div>
110 + <div id="sessMeta" class="sess-meta hidden">
111 + <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span>
112 + </div>
98 113
99 <!-- ======== Vue ÉCOSYSTÈME ======== -->
100 <section id="view-eco" class="view hidden">
101 <div class="view-head">
102 <h2>Écosystème</h2>
103 <div class="head-actions">
104 <button id="incBtn" class="chip">Incidents</button>
105 <span id="ecoStamp" class="eco-stamp"></span>
114 + <main id="messages" class="messages"></main>
115 +
116 + <div id="welcome" class="welcome">
117 + <div class="welcome-inner">
118 + <div class="ka-mark big">Ka</div>
119 + <h2>Claude Code</h2>
120 + <p>Le vrai Claude Code, lancé sur le nœud de chaque app du Groupe KA.</p>
121 + <div class="hints">
122 + <button class="hint">Quel modèle es-tu et dans quel répertoire travailles-tu ?</button>
123 + <button class="hint">Montre les 5 derniers commits</button>
124 + <button class="hint">État PM2 de l'app</button>
125 + </div>
126 + <div id="welcomeRecent" class="welcome-recent"></div>
127 + </div>
106 128 </div>
107 </div>
108 <div id="ecoOverview" class="eco-overview"></div>
109 <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div>
110 <h3 class="eco-subhead">Nœuds du cluster</h3>
111 <div id="ecoNodes" class="node-grid"></div>
112 </section>
113
114 <!-- ======== Vue RÉGLAGES ======== -->
115 <section id="view-settings" class="view hidden">
116 <div class="view-head"><h2>Réglages</h2></div>
117 <div class="set-card">
118 <div class="set-label">Modèle par défaut</div>
119 <div class="seg" id="segModel">
120 <button data-v="fable">Fable 5</button><button data-v="opus">Opus 5</button><button data-v="sonnet">Sonnet 5</button><button data-v="haiku">Haiku</button>
129 +
130 + <footer class="composer">
131 + <div class="composer-inner">
132 + <button id="upgradeBtn" class="wand-btn" title="Améliorer le prompt (Opus 4.8 + savoir Groupe KA)"><svg viewBox="0 0 24 24"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linejoin="round"/></svg></button>
133 + <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea>
134 + <button id="stopBtn" class="send-btn stop hidden" title="Arrêter"><svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg></button>
135 + <button id="sendBtn" class="send-btn" title="Envoyer"><svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></button>
136 + </div>
137 + <div id="slashHint" class="slash-hint hidden"></div>
138 + </footer>
139 + </section>
140 +
141 + <!-- ======== Vue SESSIONS ======== -->
142 + <section id="view-sessions" class="view scroll hidden">
143 + <div class="view-head">
144 + <h2>Sessions</h2>
145 + <button id="newSessBtn" class="btn-cta small">+ Nouvelle</button>
121 146 </div>
122 </div>
123 <div class="set-card">
124 <div class="set-label">Permissions par défaut</div>
125 <div class="seg vert" id="segPerm">
126 <button data-v="default">Normal — demande dans le chat</button>
127 <button data-v="acceptEdits">acceptEdits — éditions auto</button>
128 <button data-v="plan">Plan — proposer avant d'agir</button>
129 <button data-v="bypass">⚠ Tout autoriser (bypass)</button>
147 + <div class="filterbar">
148 + <input id="sessSearch" class="f-input" type="search" placeholder="Rechercher…">
149 + <select id="sessApp" class="f-select"><option value="">Toutes les apps</option></select>
150 + <select id="sessState" class="f-select">
151 + <option value="">Tous les statuts</option>
152 + <option value="running">En cours</option>
153 + <option value="waiting_perm">Permission</option>
154 + <option value="idle">Terminées</option>
155 + </select>
130 156 </div>
131 </div>
132 <div class="set-card">
133 <div class="set-label">Conversation active</div>
134 <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div>
135 <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div>
136 <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div>
137 <div class="set-actions">
138 <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button>
139 <button id="btnCompact" class="btn-ghost">/compact — compacter</button>
157 + <div id="sessList" class="sess-list"></div>
158 + </section>
159 +
160 + <!-- ======== Vue PROMPTS ======== -->
161 + <section id="view-prompts" class="view scroll hidden">
162 + <div class="view-head">
163 + <h2>Prompts</h2>
164 + <button id="newPromptBtn" class="btn-cta small">+ Nouveau</button>
140 165 </div>
141 </div>
142 <div class="set-card">
143 <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div>
144 <div class="set-row"><span>Version</span><span class="mono">2.0.0</span></div>
145 </div>
146 <button id="logoutBtn" class="btn-danger">Se déconnecter</button>
147 </section>
166 + <div class="filterbar">
167 + <input id="promptSearch" class="f-input" type="search" placeholder="Rechercher un prompt…">
168 + <select id="promptApp" class="f-select"><option value="">Toutes les apps</option></select>
169 + </div>
170 + <div id="promptList" class="sess-list"></div>
171 + </section>
148 172
149 <!-- ======== Vue SOCIAL ======== -->
150 <section id="view-social" class="view hidden">
151 <div class="view-head"><h2>🎨 Studio</h2><button id="stRefresh" class="btn-ghost small">↻ Galerie</button></div>
152 <p class="soc-sub" style="margin:-4px 0 14px">Génère des visuels prêts à publier — télécharge-les et copie le texte. Rien n'est publié automatiquement.</p>
173 + <!-- ======== Vue ÉCOSYSTÈME ======== -->
174 + <section id="view-eco" class="view scroll hidden">
175 + <div class="view-head">
176 + <h2>Écosystème</h2>
177 + <div class="head-actions">
178 + <button id="incBtn" class="chip">Incidents</button>
179 + <span id="ecoStamp" class="eco-stamp"></span>
180 + </div>
181 + </div>
182 + <div id="ecoOverview" class="eco-overview"></div>
183 + <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div>
184 + <h3 class="eco-subhead">Nœuds du cluster</h3>
185 + <div id="ecoNodes" class="node-grid"></div>
186 + </section>
153 187
154 <div class="st-grid">
155 <div class="soc-card st-gen">
156 <h3>🖼️ Publication</h3>
157 <textarea id="stPostPrompt" rows="2" placeholder="Sujet (optionnel). Ex : dernière propriété à vendre sur Immo-Ka."></textarea>
158 <div class="soc-actions">
159 <select id="stPostSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select>
160 <button id="stPostGen" class="btn-cta small">✨ Générer une image</button>
188 + <!-- ======== Vue VISITEURS (analytics humains) ======== -->
189 + <section id="view-visitors" class="view scroll hidden">
190 + <div class="view-head">
191 + <h2>Visiteurs</h2>
192 + <div class="head-actions">
193 + <div class="seg mini" id="visMode">
194 + <button data-v="human" class="on">Humains</button>
195 + <button data-v="all">Tout le trafic</button>
196 + </div>
197 + <span id="visLive" class="eco-stamp"></span>
161 198 </div>
162 199 </div>
163 <div class="soc-card st-gen">
164 <h3>🎬 Reel vidéo</h3>
165 <textarea id="stReelPrompt" rows="2" placeholder="Sujet (optionnel). Ex : le dernier logement en annonce sur Lou-Ka."></textarea>
166 <div class="soc-actions">
167 <select id="stReelSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select>
168 <button id="stReelGen" class="btn-cta small">🎬 Générer un reel</button>
200 +
201 + <div id="visGlobal" class="ov-kpis compact"></div>
202 + <div id="visBreak" class="panel slim"></div>
203 +
204 + <div class="ov-cols">
205 + <div class="panel">
206 + <div class="panel-h"><h3>Temps réel — humains actifs</h3><span id="rtCount" class="panel-note"></span></div>
207 + <div id="visRealtime" class="rt-list"><div class="soc-sub">Chargement…</div></div>
169 208 </div>
170 <div class="soc-actions" style="margin-top:8px">
171 <button id="stReel10" class="btn-ghost small">🔥 Générer 10 reels</button>
172 <span id="stGenStatus" class="soc-sub"></span>
209 + <div class="panel">
210 + <div class="panel-h"><h3>Anomalies</h3><button class="linklike" id="visDebugBtn">🔬 inspecter une IP / un visiteur</button></div>
211 + <div id="visAnom"></div>
173 212 </div>
174 213 </div>
175 </div>
176 214
177 <div class="view-head" style="margin-top:18px"><h2 style="font-size:17px">Galerie</h2><span id="stCount" class="soc-sub"></span></div>
178 <div id="stGallery" class="st-gallery"><div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div></div>
179 </section>
180
181 <section id="view-visitors" class="view hidden">
182 <div class="view-head"><h2>👥 Visiteurs</h2><span id="visLive" class="soc-sub"></span></div>
183 <div id="visGlobal" class="vis-global"></div>
184 <div id="visGrid" class="vis-grid"><div class="soc-sub">Chargement…</div></div>
185 </section>
186
187 <!-- ======== Bottom nav ======== -->
188 <nav class="bottomnav">
189 <button data-view="chat" class="nav-btn active">
190 <svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>
191 <span>Chat</span><i class="badge hidden"></i>
192 </button>
193 <button data-view="sessions" class="nav-btn">
194 <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
195 <span>Sessions</span><i class="badge hidden"></i>
196 </button>
197 <button data-view="prompts" class="nav-btn">
198 <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>
199 <span>Prompts</span><i class="badge hidden"></i>
200 </button>
201 <button data-view="eco" class="nav-btn">
202 <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>
203 <span>Écosystème</span><i class="badge hidden"></i>
204 </button>
205 <button data-view="social" class="nav-btn"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H4z M8 20h8" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 8l5 2.5-5 2.5z" fill="currentColor"/></svg><span>Studio</span><i class="badge hidden"></i></button>
206 <button data-view="visitors" class="nav-btn"><svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M3.5 19a5.5 5.5 0 0111 0" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/><path d="M16 5.2a3 3 0 010 5.6M18 19a5.5 5.5 0 00-3-4.9" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg><span>Visiteurs</span><i class="badge hidden"></i></button><button data-view="settings" class="nav-btn">
207 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
208 <span>Réglages</span><i class="badge hidden"></i>
209 </button>
210 </nav>
215 + <div class="view-head" style="margin-top:16px"><h3 class="sub-h">Par application</h3></div>
216 + <div id="visGrid" class="vis-grid"><div class="soc-sub">Chargement…</div></div>
217 + <p class="defs-note">Définitions — <b>humain</b> : navigateur réel, réseau non-datacenter, JS exécuté · <b>en ligne</b> : activité &lt; 2 min (expire seul) · <b>page vue</b> : navigation réelle (jamais un heartbeat) · <b>session</b> : inactivité &gt; 30 min = nouvelle session. Datacenters, crawlers, monitoring et trafic interne sont exclus des métriques principales.</p>
218 + </section>
219 +
220 + <!-- ======== Vue RÉGLAGES ======== -->
221 + <section id="view-settings" class="view scroll hidden">
222 + <div class="view-head"><h2>Réglages</h2></div>
223 + <div class="set-card">
224 + <div class="set-label">Apparence</div>
225 + <div class="seg" id="segTheme">
226 + <button data-v="auto">Auto</button><button data-v="light">Clair</button><button data-v="dark">Sombre</button>
227 + </div>
228 + </div>
229 + <div class="set-card">
230 + <div class="set-label">Modèle par défaut</div>
231 + <div class="seg" id="segModel">
232 + <button data-v="fable">Fable 5</button><button data-v="opus">Opus 5</button><button data-v="sonnet">Sonnet 5</button><button data-v="haiku">Haiku</button>
233 + </div>
234 + </div>
235 + <div class="set-card">
236 + <div class="set-label">Permissions par défaut</div>
237 + <div class="seg vert" id="segPerm">
238 + <button data-v="default">Normal — demande dans le chat</button>
239 + <button data-v="acceptEdits">acceptEdits — éditions auto</button>
240 + <button data-v="plan">Plan — proposer avant d'agir</button>
241 + <button data-v="bypass">⚠ Tout autoriser (bypass)</button>
242 + </div>
243 + </div>
244 + <div class="set-card">
245 + <div class="set-label">Analytics — définitions &amp; filtres</div>
246 + <div id="anCfg" class="an-cfg"><div class="soc-sub">Chargement…</div></div>
247 + </div>
248 + <div class="set-card">
249 + <div class="set-label">Conversation active</div>
250 + <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div>
251 + <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div>
252 + <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div>
253 + <div class="set-actions">
254 + <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button>
255 + <button id="btnCompact" class="btn-ghost">/compact — compacter</button>
256 + </div>
257 + </div>
258 + <div class="set-card">
259 + <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div>
260 + <div class="set-row"><span>Version</span><span class="mono">3.0.0</span></div>
261 + </div>
262 + <button id="logoutBtn" class="btn-danger">Se déconnecter</button>
263 + </section>
264 +
265 + <!-- ======== Vue STUDIO ======== -->
266 + <section id="view-social" class="view scroll hidden">
267 + <div class="view-head"><h2>🎨 Studio</h2><button id="stRefresh" class="btn-ghost small">↻ Galerie</button></div>
268 + <p class="soc-sub" style="margin:-4px 0 14px">Génère des visuels prêts à publier — télécharge-les et copie le texte. Rien n'est publié automatiquement.</p>
269 +
270 + <div class="st-grid">
271 + <div class="soc-card st-gen">
272 + <h3>🖼️ Publication</h3>
273 + <textarea id="stPostPrompt" rows="2" placeholder="Sujet (optionnel). Ex : dernière propriété à vendre sur Immo-Ka."></textarea>
274 + <div class="soc-actions">
275 + <select id="stPostSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select>
276 + <button id="stPostGen" class="btn-cta small">✨ Générer une image</button>
277 + </div>
278 + </div>
279 + <div class="soc-card st-gen">
280 + <h3>🎬 Reel vidéo</h3>
281 + <textarea id="stReelPrompt" rows="2" placeholder="Sujet (optionnel). Ex : le dernier logement en annonce sur Lou-Ka."></textarea>
282 + <div class="soc-actions">
283 + <select id="stReelSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select>
284 + <button id="stReelGen" class="btn-cta small">🎬 Générer un reel</button>
285 + </div>
286 + <div class="soc-actions" style="margin-top:8px">
287 + <button id="stReel10" class="btn-ghost small">🔥 Générer 10 reels</button>
288 + <span id="stGenStatus" class="soc-sub"></span>
289 + </div>
290 + </div>
291 + </div>
292 +
293 + <div class="view-head" style="margin-top:18px"><h2 style="font-size:17px">Galerie</h2><span id="stCount" class="soc-sub"></span></div>
294 + <div id="stGallery" class="st-gallery"><div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div></div>
295 + </section>
296 +
297 + <!-- ======== Bottom nav (mobile) ======== -->
298 + <nav class="bottomnav" id="bottomNav"></nav>
299 + </div>
211 300
212 301 <!-- ======== Feuille détail (éco / options) ======== -->
213 302 <div id="sheet" class="sheet hidden">
added public/ka-a.js +71 −0
@@ -0,0 +1,71 @@
1 +/* ka-a.js v2 — analytics first-party Groupe KA (administration-ka.com)
2 + * Léger, asynchrone, sans cookie tiers, sans fingerprinting, sans contenu saisi.
3 + * - visitor_id (KA_VID, localStorage) : anonyme, persistant, dédup refresh
4 + * - session_id (KA_SID) : nouvelle session après 30 min d'inactivité
5 + * - page view : chargement + navigations SPA (pushState/replaceState/popstate)
6 + * - heartbeat 25 s : SEULEMENT si l'onglet est visible ET activité < 60 s
7 + * (scroll/pointeur/clavier/touch — on note QU'une interaction a eu lieu, jamais son contenu)
8 + * → un onglet fermé ou inactif disparaît du compteur « en ligne » en ~2 min. */
9 +(function () {
10 + try {
11 + if (window.__kaAn2) return; window.__kaAn2 = 1;
12 + var HOST = 'https://www.administration-ka.com';
13 + var SITE = window.__kaSite || (document.currentScript && document.currentScript.dataset.site) || '';
14 + if (!SITE) return;
15 + var SESS_MS = 30 * 60 * 1000, HB_MS = 25 * 1000, INT_MS = 60 * 1000;
16 +
17 + function rid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
18 + function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
19 + function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
20 +
21 + var vid = lsGet('KA_VID'); if (!vid) { vid = rid(); lsSet('KA_VID', vid); }
22 + function sid() {
23 + var now = Date.now(), s = lsGet('KA_SID'), last = +(lsGet('KA_SLAST') || 0);
24 + if (!s || now - last > SESS_MS) { s = rid(); lsSet('KA_SID', s); }
25 + lsSet('KA_SLAST', String(now));
26 + return s;
27 + }
28 + var dev = (function () { var w = Math.min(screen.width, screen.height) || innerWidth; return w <= 500 ? 'm' : w <= 900 ? 't' : 'd'; })();
29 +
30 + function send(ev, withRef) {
31 + var u = HOST + '/collect?site=' + encodeURIComponent(SITE)
32 + + '&vid=' + vid + '&sid=' + sid() + '&ev=' + ev
33 + + '&p=' + encodeURIComponent(location.pathname)
34 + + '&d=' + dev
35 + + (withRef && document.referrer && document.referrer.indexOf(location.host) === -1
36 + ? '&r=' + encodeURIComponent(document.referrer.slice(0, 180)) : '')
37 + + '&t=' + Date.now();
38 + if (navigator.sendBeacon) { try { navigator.sendBeacon(u); return; } catch (e) {} }
39 + var i = new Image(); i.src = u;
40 + }
41 +
42 + var lastPath = location.pathname;
43 + function pv() { lastPath = location.pathname; send('pv', true); }
44 +
45 + // navigation SPA = nouvelle page vue (jamais un heartbeat)
46 + function hookHistory(fn) {
47 + var orig = history[fn];
48 + history[fn] = function () { var r = orig.apply(this, arguments); onNav(); return r; };
49 + }
50 + function onNav() { if (location.pathname !== lastPath) setTimeout(pv, 0); }
51 + hookHistory('pushState'); hookHistory('replaceState');
52 + addEventListener('popstate', onNav);
53 +
54 + // présence réelle : interaction récente + onglet visible
55 + var lastInt = Date.now();
56 + function bump() { lastInt = Date.now(); }
57 + ['pointerdown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(function (e) {
58 + addEventListener(e, bump, { passive: true, capture: true });
59 + });
60 + setInterval(function () {
61 + if (document.visibilityState !== 'visible') return;
62 + if (Date.now() - lastInt > INT_MS) return;
63 + send('hb');
64 + }, HB_MS);
65 + document.addEventListener('visibilitychange', function () {
66 + if (document.visibilityState === 'visible') { bump(); send('hb'); }
67 + });
68 +
69 + pv();
70 + } catch (e) {}
71 +})();
modified public/style.css +303 −61
@@ -20,8 +20,8 @@
20 20 --r-lg: 20px;
21 21 --r-md: 14px;
22 22 --r-sm: 10px;
23 --bshadow: 3px 3px 0 var(--ink);
24 --bshadow-sm: 2px 2px 0 var(--ink);
23 + --bshadow: 3px 3px 0 var(--shadow);
24 + --bshadow-sm: 2px 2px 0 var(--shadow);
25 25 --z-banner: 140;
26 26 --z-header: 100;
27 27 --z-nav: 110;
@@ -47,7 +47,7 @@ button { font-family: inherit; color: inherit; }
47 47 display: inline-flex; align-items: center; justify-content: center;
48 48 width: 34px; height: 34px; border-radius: 11px;
49 49 background: var(--orange); color: var(--ink);
50 border: 2px solid var(--ink); box-shadow: var(--bshadow-sm);
50 + border: 2px solid var(--edge); box-shadow: var(--bshadow-sm);
51 51 font-weight: 800; font-size: 15px; letter-spacing: -0.2px;
52 52 flex-shrink: 0;
53 53 }
@@ -56,15 +56,15 @@ button { font-family: inherit; color: inherit; }
56 56
57 57 .btn-cta {
58 58 background: var(--orange); color: var(--ink);
59 border: 2px solid var(--ink); border-radius: 999px;
59 + border: 2px solid var(--edge); border-radius: 999px;
60 60 box-shadow: var(--bshadow);
61 61 font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer;
62 62 transition: transform 0.08s, box-shadow 0.08s;
63 63 }
64 .btn-cta:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); }
64 +.btn-cta:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--shadow); }
65 65 .btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); }
66 66 .btn-ghost {
67 background: var(--card); border: 1.5px solid var(--ink); border-radius: 999px;
67 + background: var(--card); border: 1.5px solid var(--edge); border-radius: 999px;
68 68 font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer;
69 69 }
70 70 .btn-danger {
@@ -82,15 +82,15 @@ button { font-family: inherit; color: inherit; }
82 82 }
83 83 .login-box {
84 84 width: 100%; max-width: 360px;
85 background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-xl);
86 box-shadow: 6px 6px 0 var(--ink);
85 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-xl);
86 + box-shadow: 6px 6px 0 var(--shadow);
87 87 padding: 34px 26px 26px;
88 88 display: flex; flex-direction: column; gap: 13px; text-align: center;
89 89 }
90 90 .login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; }
91 91 .login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; }
92 92 .login input {
93 background: var(--bg); border: 2px solid var(--ink); color: var(--ink);
93 + background: var(--bg); border: 2px solid var(--edge); color: var(--ink);
94 94 border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none;
95 95 font-family: inherit;
96 96 }
@@ -105,14 +105,14 @@ button { font-family: inherit; color: inherit; }
105 105 position: sticky; top: 0; z-index: var(--z-header);
106 106 display: flex; align-items: center; justify-content: space-between; gap: 10px;
107 107 background: var(--bg);
108 border-bottom: 2px solid var(--ink);
108 + border-bottom: 2px solid var(--edge);
109 109 padding: 8px 14px;
110 110 padding-top: calc(8px + env(safe-area-inset-top));
111 111 }
112 112 .brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; }
113 113 .conn-pill {
114 114 display: flex; align-items: center; gap: 6px;
115 border: 1.5px solid var(--ink); border-radius: 999px;
115 + border: 1.5px solid var(--edge); border-radius: 999px;
116 116 background: var(--card); font-size: 11.5px; font-weight: 700;
117 117 padding: 4px 10px;
118 118 }
@@ -125,7 +125,7 @@ button { font-family: inherit; color: inherit; }
125 125 .banners { position: fixed; top: calc(52px + env(safe-area-inset-top)); left: 0; right: 0; z-index: var(--z-banner); display: flex; flex-direction: column; gap: 8px; padding: 8px 14px; pointer-events: none; }
126 126 .banner {
127 127 pointer-events: auto; cursor: pointer;
128 border: 2px solid var(--ink); border-radius: var(--r-md);
128 + border: 2px solid var(--edge); border-radius: var(--r-md);
129 129 box-shadow: var(--bshadow); padding: 11px 14px;
130 130 font-weight: 700; font-size: 13.5px;
131 131 display: flex; gap: 10px; align-items: center;
@@ -148,7 +148,7 @@ button { font-family: inherit; color: inherit; }
148 148 position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav);
149 149 display: flex;
150 150 background: var(--card);
151 border-top: 2px solid var(--ink);
151 + border-top: 2px solid var(--edge);
152 152 padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
153 153 }
154 154 .nav-btn {
@@ -164,7 +164,7 @@ button { font-family: inherit; color: inherit; }
164 164 .nav-btn .badge {
165 165 position: absolute; top: 3px; right: calc(50% - 17px);
166 166 width: 9px; height: 9px; border-radius: 50%;
167 background: var(--orange); border: 1.5px solid var(--ink);
167 + background: var(--orange); border: 1.5px solid var(--edge);
168 168 }
169 169
170 170 /* ================= Vue chat ================= */
@@ -178,7 +178,7 @@ button { font-family: inherit; color: inherit; }
178 178 .pill-select {
179 179 appearance: none; -webkit-appearance: none;
180 180 background: var(--card); color: var(--ink);
181 border: 2px solid var(--ink); border-radius: 999px;
181 + border: 2px solid var(--edge); border-radius: 999px;
182 182 font-weight: 800; font-size: 13.5px; font-family: inherit;
183 183 padding: 7px 26px 7px 13px;
184 184 box-shadow: var(--bshadow-sm);
@@ -187,7 +187,7 @@ button { font-family: inherit; color: inherit; }
187 187 background-repeat: no-repeat; background-position: right 9px center; background-size: 12px;
188 188 }
189 189 .chip {
190 border: 1.5px solid var(--ink); border-radius: 999px;
190 + border: 1.5px solid var(--edge); border-radius: 999px;
191 191 background: var(--card2); font-weight: 700; font-size: 12.5px;
192 192 padding: 7px 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0;
193 193 }
@@ -212,14 +212,14 @@ button { font-family: inherit; color: inherit; }
212 212 .msg-user {
213 213 align-self: flex-end; max-width: 86%;
214 214 background: var(--orange); color: var(--ink);
215 border: 2px solid var(--ink); border-radius: var(--r-lg) var(--r-lg) 6px var(--r-lg);
215 + border: 2px solid var(--edge); border-radius: var(--r-lg) var(--r-lg) 6px var(--r-lg);
216 216 box-shadow: var(--bshadow-sm);
217 217 padding: 10px 15px; font-weight: 600;
218 218 white-space: pre-wrap; word-break: break-word;
219 219 }
220 220 .msg-claude {
221 221 align-self: stretch;
222 background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
222 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
223 223 box-shadow: var(--bshadow-sm);
224 224 padding: 13px 16px; word-break: break-word;
225 225 }
@@ -227,7 +227,7 @@ button { font-family: inherit; color: inherit; }
227 227 .msg-claude .md > :last-child { margin-bottom: 0; }
228 228 .md p { margin: 0.55em 0; }
229 229 .md pre {
230 background: #f2efe7; border: 1.5px solid var(--line); border-radius: var(--r-sm);
230 + background: var(--code-bg); border: 1.5px solid var(--line); border-radius: var(--r-sm);
231 231 padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5;
232 232 }
233 233 .md code { font-family: var(--mono); font-size: 0.88em; }
@@ -261,7 +261,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
261 261
262 262 /* --- cartes outils --- */
263 263 .tool {
264 background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md);
264 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-md);
265 265 overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm);
266 266 }
267 267 .tool summary {
@@ -271,7 +271,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
271 271 .tool summary::-webkit-details-marker { display: none; }
272 272 .tool .t-icon {
273 273 flex-shrink: 0; width: 28px; height: 28px; border-radius: 9px;
274 border: 1.5px solid var(--ink);
274 + border: 1.5px solid var(--edge);
275 275 display: flex; align-items: center; justify-content: center;
276 276 font-size: 13px; background: var(--card2);
277 277 }
@@ -288,7 +288,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
288 288 .tool .t-state.pending { color: var(--warn); background: var(--warn-soft); }
289 289 .tool .t-state.ok { color: var(--good); background: var(--good-soft); }
290 290 .tool .t-state.err { color: var(--bad); background: var(--bad-soft); }
291 .tool-body { border-top: 2px solid var(--ink); padding: 9px 13px 11px; background: var(--card2); }
291 +.tool-body { border-top: 2px solid var(--edge); padding: 9px 13px 11px; background: var(--card2); }
292 292 .tool-body pre {
293 293 margin: 6px 0; background: var(--card); border: 1.5px solid var(--line);
294 294 border-radius: var(--r-sm); padding: 9px;
@@ -300,8 +300,8 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
300 300
301 301 .diff { font-family: var(--mono); font-size: 12px; margin: 6px 0; border: 1.5px solid var(--line); border-radius: var(--r-sm); overflow: hidden; background: var(--card); }
302 302 .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; }
303 .diff .del { background: #ffe4e1; color: #8c1622; }
304 .diff .add { background: #ddf3e4; color: #0c5326; }
303 +.diff .del { background: var(--del-bg); color: var(--del-ink); }
304 +.diff .add { background: var(--add-bg); color: var(--add-ink); }
305 305
306 306 .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; }
307 307 .todos li { padding: 3px 0; }
@@ -310,9 +310,9 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
310 310
311 311 /* --- permission --- */
312 312 .perm {
313 background: var(--warn-soft); border: 2px solid var(--ink);
313 + background: var(--warn-soft); border: 2px solid var(--edge);
314 314 border-radius: var(--r-lg); padding: 14px; font-size: 13.5px;
315 box-shadow: 4px 4px 0 var(--ink);
315 + box-shadow: 4px 4px 0 var(--shadow);
316 316 }
317 317 .perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; }
318 318 .perm pre {
@@ -325,7 +325,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
325 325 .perm-actions button {
326 326 flex: 1; padding: 11px 6px; border-radius: 999px;
327 327 font-size: 13.5px; font-weight: 800; cursor: pointer; min-height: 46px;
328 border: 2px solid var(--ink);
328 + border: 2px solid var(--edge);
329 329 }
330 330 .perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); }
331 331 .perm-always { background: var(--good-soft); color: var(--good); line-height: 1.05; }
@@ -333,7 +333,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
333 333 .perm-deny { background: var(--card); color: var(--bad); }
334 334 .perm-allall {
335 335 width: 100%; margin-top: 8px; padding: 12px; border-radius: 999px;
336 border: 2px solid var(--ink); background: var(--ink); color: var(--orange);
336 + border: 2px solid var(--edge); background: var(--ink); color: var(--orange);
337 337 font-weight: 800; font-size: 13.5px; cursor: pointer; box-shadow: var(--bshadow-sm);
338 338 }
339 339 .perm-allall:active { transform: translate(1px,1px); box-shadow: none; }
@@ -357,11 +357,11 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
357 357 align-self: flex-start;
358 358 display: flex; align-items: center; gap: 10px;
359 359 font-size: 13.5px; font-weight: 700;
360 background: var(--card); border: 2px solid var(--ink);
360 + background: var(--card); border: 2px solid var(--edge);
361 361 border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm);
362 362 }
363 363 .dots { display: flex; gap: 4px; }
364 .dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1px solid var(--ink); animation: bounce 1.1s ease-in-out infinite; }
364 +.dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1px solid var(--edge); animation: bounce 1.1s ease-in-out infinite; }
365 365 .dots i:nth-child(2) { animation-delay: 0.14s; }
366 366 .dots i:nth-child(3) { animation-delay: 0.28s; }
367 367 @keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-4px); opacity: 1; } }
@@ -377,7 +377,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
377 377 .welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; }
378 378 .hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; }
379 379 .hint {
380 background: var(--card); border: 1.5px solid var(--ink);
380 + background: var(--card); border: 1.5px solid var(--edge);
381 381 font-size: 13px; font-weight: 700;
382 382 border-radius: 999px; padding: 9px 14px; cursor: pointer;
383 383 box-shadow: var(--bshadow-sm);
@@ -393,7 +393,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
393 393 display: flex; align-items: flex-end; gap: 8px;
394 394 max-width: 780px; margin: 0 auto;
395 395 background: var(--card);
396 border: 2px solid var(--ink); border-radius: var(--r-xl);
396 + border: 2px solid var(--edge); border-radius: var(--r-xl);
397 397 box-shadow: var(--bshadow);
398 398 padding: 6px 6px 6px 6px; gap: 4px;
399 399 }
@@ -406,7 +406,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
406 406 #input::placeholder { color: var(--muted); font-weight: 600; }
407 407 .send-btn {
408 408 width: 42px; height: 42px; border-radius: 50%;
409 border: 2px solid var(--ink);
409 + border: 2px solid var(--edge);
410 410 background: var(--orange); color: var(--ink);
411 411 cursor: pointer; flex-shrink: 0;
412 412 display: flex; align-items: center; justify-content: center;
@@ -419,7 +419,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
419 419 .send-btn.stop { background: var(--bad); color: #fff; }
420 420 .wand-btn {
421 421 width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0;
422 border: 2px solid var(--ink); background: var(--card2); color: var(--orange-deep);
422 + border: 2px solid var(--edge); background: var(--card2); color: var(--orange-deep);
423 423 cursor: pointer; display: flex; align-items: center; justify-content: center;
424 424 align-self: flex-end; margin-bottom: 2px;
425 425 }
@@ -431,29 +431,29 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
431 431 max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap;
432 432 }
433 433 .slash-hint button {
434 border: 1.5px solid var(--ink); background: var(--card2); border-radius: 999px;
434 + border: 1.5px solid var(--edge); background: var(--card2); border-radius: 999px;
435 435 font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer;
436 436 }
437 437
438 438 /* ================= Sessions ================= */
439 439 .sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; }
440 440 .sess-card {
441 background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
441 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
442 442 box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer;
443 443 display: flex; flex-direction: column; gap: 6px;
444 444 }
445 445 .sess-top { display: flex; align-items: center; gap: 8px; }
446 446 .sess-title { flex: 1; min-width: 0; font-weight: 800; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
447 .sess-badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; }
447 +.sess-badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--edge); flex-shrink: 0; }
448 448 .sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; }
449 449 .sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); }
450 450 .sess-badge.idle { background: var(--card2); color: var(--muted); }
451 451 .batch-tag { background: var(--orange-soft); color: var(--orange-deep); border-radius: 999px; padding: 1px 8px; font-weight: 800; }
452 .pill-select.all-mode { background: var(--orange); box-shadow: 3px 3px 0 var(--ink); }
452 +.pill-select.all-mode { background: var(--orange); box-shadow: 3px 3px 0 var(--shadow); }
453 453 .sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); }
454 454 .sess-actions { display: flex; gap: 8px; margin-top: 4px; }
455 455 .sess-actions button {
456 border: 1.5px solid var(--ink); border-radius: 999px; background: var(--card2);
456 + border: 1.5px solid var(--edge); border-radius: 999px; background: var(--card2);
457 457 font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer;
458 458 }
459 459 .sess-actions .del { color: var(--bad); }
@@ -467,13 +467,13 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
467 467 }
468 468 .sess-actions .p-send { background: var(--orange); color: var(--ink); border-color: var(--ink); box-shadow: var(--bshadow-sm); }
469 469 .pe-input {
470 width: 100%; background: var(--bg); border: 2px solid var(--ink); color: var(--ink);
470 + width: 100%; background: var(--bg); border: 2px solid var(--edge); color: var(--ink);
471 471 border-radius: var(--r-md); padding: 11px 13px; font-size: 15px; font-family: inherit; font-weight: 600; outline: none;
472 472 }
473 473 .pe-input:focus { border-color: var(--orange); }
474 474 .pe-textarea {
475 475 width: 100%; margin-top: 10px; min-height: 180px; resize: vertical;
476 background: var(--bg); border: 2px solid var(--ink); color: var(--ink);
476 + background: var(--bg); border: 2px solid var(--edge); color: var(--ink);
477 477 border-radius: var(--r-md); padding: 12px 13px; font-size: 14px; line-height: 1.5;
478 478 font-family: var(--mono); outline: none;
479 479 }
@@ -490,7 +490,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
490 490 max-width: 780px; margin-left: auto; margin-right: auto;
491 491 }
492 492 .eco-stat {
493 flex: 1; background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
493 + flex: 1; background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
494 494 box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center;
495 495 }
496 496 .eco-stat .v { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
@@ -504,7 +504,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
504 504 @media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } }
505 505 .eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; }
506 506 .site-card {
507 background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
507 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
508 508 box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer;
509 509 display: flex; flex-direction: column; gap: 8px;
510 510 transition: transform 0.08s;
@@ -513,7 +513,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
513 513 .site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); }
514 514 .site-top { display: flex; align-items: center; gap: 8px; }
515 515 .site-name { font-weight: 800; font-size: 16px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
516 .st-pill { font-size: 10.5px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; display: flex; gap: 4px; align-items: center; }
516 +.st-pill { font-size: 10.5px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--edge); flex-shrink: 0; display: flex; gap: 4px; align-items: center; }
517 517 .st-pill.up { background: var(--good-soft); color: var(--good); }
518 518 .st-pill.slow { background: var(--warn-soft); color: var(--warn); }
519 519 .st-pill.down { background: var(--bad); color: #fff; }
@@ -530,11 +530,11 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
530 530 .eco-subhead { max-width: 780px; margin: 20px auto 10px; width: 100%; font-size: 15px; font-weight: 800; letter-spacing: -0.3px; }
531 531 .node-grid { display: grid; grid-template-columns: 1fr; gap: 10px; max-width: 780px; margin: 0 auto; width: 100%; }
532 532 @media (min-width: 560px) { .node-grid { grid-template-columns: 1fr 1fr; } }
533 .node-card { background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 12px 14px; }
533 +.node-card { background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 12px 14px; }
534 534 .node-card.warn { border-color: var(--warn); }
535 535 .node-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
536 536 .node-name { font-weight: 800; font-size: 15px; font-family: var(--mono); }
537 .node-apps { font-size: 11px; font-weight: 800; border: 1.5px solid var(--ink); border-radius: 999px; padding: 2px 9px; background: var(--good-soft); color: var(--good); }
537 +.node-apps { font-size: 11px; font-weight: 800; border: 1.5px solid var(--edge); border-radius: 999px; padding: 2px 9px; background: var(--good-soft); color: var(--good); }
538 538 .node-apps.bad { background: var(--warn-soft); color: var(--warn); }
539 539 .nbar { margin: 5px 0; }
540 540 .nbar-l { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 2px; }
@@ -546,7 +546,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
546 546 .admin-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
547 547 .admin-actions .btn-ghost { text-align: center; text-decoration: none; }
548 548 .admin-out { margin-top: 10px; }
549 .admin-out pre { background: var(--ink); color: #e8e6df; border-radius: var(--r-sm); padding: 10px; font-size: 11px; font-family: var(--mono); overflow-x: auto; max-height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
549 +.admin-out pre { background: var(--term-bg); color: var(--term-ink); border-radius: var(--r-sm); padding: 10px; font-size: 11px; font-family: var(--mono); overflow-x: auto; max-height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
550 550 .admin-load { color: var(--muted); font-weight: 700; font-size: 13px; }
551 551 .admin-ok { color: var(--good); font-weight: 800; }
552 552 .admin-err { color: var(--bad); font-weight: 800; }
@@ -558,7 +558,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
558 558 }
559 559 @media (min-width: 560px) { .eco-overview { grid-template-columns: repeat(6, 1fr); } }
560 560 .kpi {
561 background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md);
561 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-md);
562 562 box-shadow: var(--bshadow-sm); padding: 9px 8px; text-align: center;
563 563 }
564 564 .kpi .v { font-size: 21px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; }
@@ -599,7 +599,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
599 599 .sheet-panel {
600 600 position: absolute; left: 0; right: 0; bottom: 0;
601 601 max-height: 88dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
602 background: var(--bg); border: 2px solid var(--ink); border-bottom: none;
602 + background: var(--bg); border: 2px solid var(--edge); border-bottom: none;
603 603 border-radius: var(--r-xl) var(--r-xl) 0 0;
604 604 padding: 8px 16px calc(20px + env(safe-area-inset-bottom));
605 605 animation: sheet-up 0.22s ease-out;
@@ -607,7 +607,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
607 607 .sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; }
608 608 .sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
609 609 .sheet-h h3 { margin: 0; font-size: 21px; font-weight: 800; flex: 1; }
610 .sheet-sec { background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 13px 15px; margin-bottom: 12px; }
610 +.sheet-sec { background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 13px 15px; margin-bottom: 12px; }
611 611 .sheet-sec h4 { margin: 0 0 9px; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); }
612 612 .kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; }
613 613 .kv b { font-family: var(--mono); font-weight: 700; }
@@ -629,14 +629,14 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
629 629 /* ================= Réglages ================= */
630 630 .set-card {
631 631 max-width: 780px; margin: 0 auto 12px; width: 100%;
632 background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg);
632 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
633 633 box-shadow: var(--bshadow-sm); padding: 14px 16px;
634 634 }
635 635 .set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; }
636 636 .seg { display: flex; gap: 7px; }
637 637 .seg.vert { flex-direction: column; }
638 638 .seg button {
639 flex: 1; border: 1.5px solid var(--ink); border-radius: 999px;
639 + flex: 1; border: 1.5px solid var(--edge); border-radius: 999px;
640 640 background: var(--card2); font-weight: 700; font-size: 13.5px;
641 641 padding: 10px 12px; cursor: pointer; text-align: center;
642 642 }
@@ -647,16 +647,16 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
647 647
648 648
649 649 /* ---- Social ---- */
650 .soc-card{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:16px;padding:14px 16px;margin:0 0 14px;box-shadow:3px 3px 0 var(--ink,#1a1a1a);}
650 +.soc-card{background:var(--card,#fff);border:2px solid var(--edge);border-radius:16px;padding:14px 16px;margin:0 0 14px;box-shadow:3px 3px 0 var(--shadow);}
651 651 .soc-card h3{margin:0 0 10px;font-size:15px;}
652 652 .soc-switch{display:flex;align-items:center;gap:10px;font-weight:700;cursor:pointer;}
653 653 .soc-switch input{width:20px;height:20px;accent-color:var(--accent,#f97316);}
654 654 .soc-sub{color:var(--muted,#6b7280);font-size:13px;margin-top:8px;}
655 655 .soc-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px;}
656 .soc-select{padding:9px 12px;border:2px solid var(--ink,#1a1a1a);border-radius:10px;font:inherit;background:#fff;}
656 +.soc-select{padding:9px 12px;border:2px solid var(--edge);border-radius:10px;font:inherit;background:#fff;}
657 657 .soc-draft{margin-top:14px;display:flex;flex-direction:column;gap:10px;}
658 .soc-draft img{width:100%;max-width:340px;border-radius:12px;border:2px solid var(--ink,#1a1a1a);align-self:center;}
659 #view-social textarea{width:100%;padding:10px 12px;border:2px solid var(--ink,#1a1a1a);border-radius:10px;font:inherit;resize:vertical;}
658 +.soc-draft img{width:100%;max-width:340px;border-radius:12px;border:2px solid var(--edge);align-self:center;}
659 +#view-social textarea{width:100%;padding:10px 12px;border:2px solid var(--edge);border-radius:10px;font:inherit;resize:vertical;}
660 660 .soc-log{display:flex;flex-direction:column;gap:6px;max-height:340px;overflow-y:auto;}
661 661 .soc-logrow{font-size:13px;padding:6px 8px;border-radius:8px;background:rgba(0,0,0,.04);}
662 662 .soc-logt{color:var(--muted,#6b7280);font-variant-numeric:tabular-nums;margin-right:6px;}
@@ -669,7 +669,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
669 669 @media(max-width:640px){.st-grid{grid-template-columns:1fr}}
670 670 .st-gen h3{margin:0 0 8px}
671 671 .st-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:8px}
672 .st-item{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:14px;overflow:hidden;box-shadow:3px 3px 0 var(--ink,#1a1a1a);display:flex;flex-direction:column}
672 +.st-item{background:var(--card,#fff);border:2px solid var(--edge);border-radius:14px;overflow:hidden;box-shadow:3px 3px 0 var(--shadow);display:flex;flex-direction:column}
673 673 .st-media{position:relative;aspect-ratio:4/5;background:#0d0d0d;overflow:hidden}
674 674 .st-media img,.st-media video{width:100%;height:100%;object-fit:cover;display:block}
675 675 .st-badge{position:absolute;left:8px;top:8px;background:rgba(13,13,13,.82);color:#fff;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;letter-spacing:.02em}
@@ -681,7 +681,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
681 681 /* ---- Studio loading ---- */
682 682 #stLoad{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;background:rgba(8,8,10,.72);backdrop-filter:blur(4px)}
683 683 #stLoad.on{display:flex}
684 .stload-box{background:var(--card,#141414);border:2px solid var(--ink,#1a1a1a);border-radius:18px;padding:26px 30px;text-align:center;box-shadow:4px 4px 0 var(--ink,#1a1a1a);max-width:80vw}
684 +.stload-box{background:var(--card,#141414);border:2px solid var(--edge);border-radius:18px;padding:26px 30px;text-align:center;box-shadow:4px 4px 0 var(--shadow);max-width:80vw}
685 685 .stload-spin{width:44px;height:44px;margin:0 auto 14px;border:5px solid rgba(127,127,127,.25);border-top-color:var(--accent,#f97316);border-radius:50%;animation:stspin .8s linear infinite}
686 686 @keyframes stspin{to{transform:rotate(360deg)}}
687 687 .stload-msg{font-weight:800;font-size:15px}
@@ -693,11 +693,11 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
693 693 /* ---- Visiteurs ---- */
694 694 #view-visitors{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:14px 14px calc(86px + env(safe-area-inset-bottom))}
695 695 .vis-global{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}
696 .vg-item{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:14px;padding:14px;text-align:center;box-shadow:3px 3px 0 var(--ink,#1a1a1a)}
696 +.vg-item{background:var(--card,#fff);border:2px solid var(--edge);border-radius:14px;padding:14px;text-align:center;box-shadow:3px 3px 0 var(--shadow)}
697 697 .vg-n{font-size:26px;font-weight:900;color:var(--accent,#f97316)}
698 698 .vg-l{font-size:11px;font-weight:700;color:var(--muted,#777);text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
699 699 .vis-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
700 .vis-card{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:16px;padding:16px;box-shadow:3px 3px 0 var(--ink,#1a1a1a)}
700 +.vis-card{background:var(--card,#fff);border:2px solid var(--edge);border-radius:16px;padding:16px;box-shadow:3px 3px 0 var(--shadow)}
701 701 .vis-h{display:flex;align-items:center;gap:10px}
702 702 .vis-h img{width:26px;height:26px;border-radius:6px}
703 703 .vis-name{font-weight:800;font-size:15px;flex:1}
@@ -718,7 +718,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
718 718 .vis-card:active{transform:scale(.98)}
719 719 #visDetail{position:fixed;inset:0;z-index:9998;display:none;align-items:flex-start;justify-content:center;background:rgba(8,8,10,.72);backdrop-filter:blur(4px);overflow-y:auto;padding:40px 14px}
720 720 #visDetail.on{display:flex}
721 .vd-box{background:var(--card,#141414);border:2px solid var(--ink,#1a1a1a);border-radius:18px;width:100%;max-width:720px;box-shadow:5px 5px 0 var(--ink,#1a1a1a)}
721 +.vd-box{background:var(--card,#141414);border:2px solid var(--edge);border-radius:18px;width:100%;max-width:720px;box-shadow:5px 5px 0 var(--shadow)}
722 722 .vd-head{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(127,127,127,.2)}
723 723 .vd-head img{width:30px;height:30px;border-radius:8px}
724 724 .vd-head h3{flex:1;font-size:18px}
@@ -735,3 +735,245 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
735 735 .vd-bar{flex:1;height:14px;background:rgba(127,127,127,.15);border-radius:7px;overflow:hidden}
736 736 .vd-bar>div{height:100%;background:var(--accent,#f97316);border-radius:7px}
737 737 .vd-geoN{font-size:13px;font-weight:800;width:44px;text-align:right}
738 +
739 +/* ====================================================================
740 + Admin-Ka v3 — Control Center : layout desktop, dark mode, densité
741 + ==================================================================== */
742 +:root {
743 + --shadow: #1a1611;
744 + --edge: #1a1611;
745 + --on-accent: #1a1611;
746 + --code-bg: #f2efe7;
747 + --term-bg: #1a1611;
748 + --term-ink: #e8e6df;
749 + --del-bg: #ffe4e1; --del-ink: #8c1622;
750 + --add-bg: #ddf3e4; --add-ink: #0c5326;
751 + --panel-gap: 14px;
752 +}
753 +:root[data-theme="dark"] {
754 + --bg: #16130e;
755 + --card: #201c15;
756 + --card2: #2a251c;
757 + --ink: #ede6d8;
758 + --muted: #a39a89;
759 + --line: #3a342a;
760 + --grid: #322d24;
761 + --edge: #4a4335;
762 + --shadow: #0a0806;
763 + --orange-soft: #3d2510;
764 + --good: #3fbf78; --good-soft: #14301f;
765 + --warn: #e0a93e; --warn-soft: #362a10;
766 + --bad: #f0616d; --bad-soft: #3a1518;
767 + --code-bg: #17140f;
768 + --del-bg: #3d1a1d; --del-ink: #f4a9ad;
769 + --add-bg: #12301c; --add-ink: #9fdcb2;
770 +}
771 +:root[data-theme="dark"] .hljs { background: var(--code-bg); color: #d8d2c4; }
772 +:root[data-theme="dark"] .site-logo, :root[data-theme="dark"] .vis-h img, :root[data-theme="dark"] .rt-row img { background: #f4f1ea; }
773 +
774 +/* texte sombre garanti sur les fonds orange (les deux thèmes) */
775 +.btn-cta, .ka-mark, .send-btn:not(.stop), .msg-user, .seg button.on,
776 +.pill-select.all-mode, .sess-badge.running, .perm-allall { color: var(--on-accent); }
777 +:root[data-theme="dark"] .perm-allall { background: var(--orange); color: var(--on-accent); }
778 +
779 +/* ---------- coquille : sidebar + colonne principale ---------- */
780 +.app { display: flex; flex-direction: row; height: 100dvh; }
781 +.main-col { flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100dvh; }
782 +.sidebar { display: none; }
783 +.desktop-only { display: none !important; }
784 +.page-title { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: -0.3px; }
785 +.head-right { display: flex; align-items: center; gap: 8px; }
786 +.icon-btn {
787 + position: relative; width: 34px; height: 34px; border-radius: 10px;
788 + border: 1.5px solid var(--edge); background: var(--card); cursor: pointer;
789 + display: flex; align-items: center; justify-content: center; color: var(--ink);
790 +}
791 +.icon-btn svg { width: 17px; height: 17px; }
792 +.icon-btn .badge { position: absolute; top: -4px; right: -4px; width: 10px; height: 10px; border-radius: 50%; background: var(--bad); border: 1.5px solid var(--edge); }
793 +.top-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); }
794 +.view.scroll { overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 14px 14px calc(86px + env(safe-area-inset-bottom)); }
795 +
796 +@media (min-width: 1020px) {
797 + .desktop-only { display: block !important; }
798 + .mobile-only { display: none !important; }
799 + .bottomnav { display: none; }
800 + .sidebar {
801 + display: flex; flex-direction: column; width: 224px; flex-shrink: 0;
802 + background: var(--card); border-right: 2px solid var(--edge);
803 + padding: 14px 10px 12px;
804 + }
805 + .side-brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 18px; letter-spacing: -0.4px; padding: 2px 8px 14px; }
806 + .side-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; }
807 + .side-nav .nav-btn {
808 + flex-direction: row; justify-content: flex-start; gap: 10px;
809 + font-size: 13.5px; font-weight: 700; color: var(--muted);
810 + padding: 9px 12px; border-radius: 10px; text-align: left;
811 + }
812 + .side-nav .nav-btn svg { width: 18px; height: 18px; flex-shrink: 0; }
813 + .side-nav .nav-btn.active { background: var(--orange-soft); color: var(--ink); border: 1.5px solid var(--edge); padding: 7.5px 10.5px; }
814 + .side-nav .nav-btn.active svg { color: var(--orange-deep); }
815 + .side-nav .nav-btn .badge { position: absolute; top: 8px; right: 10px; left: auto; }
816 + .side-foot { display: flex; flex-direction: column; gap: 8px; padding: 10px 8px 0; border-top: 1.5px solid var(--line); }
817 + .side-node { font-size: 11px; color: var(--muted); }
818 + .header { padding: 10px 20px; }
819 + .head-right #connPill { display: none; }
820 + .view.scroll { padding: 18px 24px 40px; }
821 + .messages, .composer-inner, .slash-hint { max-width: 920px; }
822 + .eco-grid, .eco-overview, .node-grid, .eco-subhead, .sess-list, .set-card { max-width: 1180px; }
823 + .eco-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
824 + .node-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
825 + .eco-overview { grid-template-columns: repeat(6, 1fr); }
826 + html, body { font-size: 14.5px; }
827 +}
828 +
829 +/* ---------- KPI v3 ---------- */
830 +.ov-kpis {
831 + display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
832 + gap: 10px; margin-bottom: var(--panel-gap); max-width: 1180px;
833 +}
834 +.ov-kpis.compact { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
835 +.kpi2 {
836 + background: var(--card); border: 1.5px solid var(--edge); border-radius: var(--r-md);
837 + padding: 10px 12px; box-shadow: var(--bshadow-sm);
838 +}
839 +.kpi2 .kv2 { font-size: 24px; font-weight: 800; letter-spacing: -0.6px; line-height: 1.1; font-variant-numeric: tabular-nums; }
840 +.kpi2 .kv2 small { font-size: 12px; color: var(--muted); font-weight: 700; }
841 +.kpi2 .kl2 { font-size: 10px; color: var(--muted); font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
842 +.kpi2.accent .kv2 { color: var(--orange-deep); }
843 +.kpi2.good .kv2 { color: var(--good); }
844 +.kpi2.bad .kv2 { color: var(--bad); }
845 +.kdelta { display: inline-block; font-size: 11px; font-weight: 800; margin-top: 3px; border-radius: 999px; padding: 1px 8px; }
846 +.kdelta.up { background: var(--good-soft); color: var(--good); }
847 +.kdelta.down { background: var(--bad-soft); color: var(--bad); }
848 +.kdelta.flat { background: var(--card2); color: var(--muted); }
849 +
850 +/* ---------- panneaux ---------- */
851 +.ov-cols { display: grid; grid-template-columns: 1fr; gap: var(--panel-gap); margin-bottom: var(--panel-gap); max-width: 1180px; }
852 +@media (min-width: 900px) { .ov-cols { grid-template-columns: 3fr 2fr; } .ov-cols + .ov-cols { grid-template-columns: 1fr 1fr; } }
853 +.panel {
854 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
855 + box-shadow: var(--bshadow-sm); padding: 14px 16px; min-width: 0;
856 +}
857 +.panel.slim { padding: 10px 14px; margin-bottom: var(--panel-gap); max-width: 1180px; }
858 +.panel-h { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
859 +.panel-h h3 { margin: 0; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); }
860 +.panel-note { font-size: 11px; color: var(--muted); font-weight: 600; }
861 +.linklike { background: none; border: none; color: var(--orange-deep); font-weight: 800; font-size: 12px; cursor: pointer; padding: 0; }
862 +.sub-h { margin: 0; font-size: 16px; font-weight: 800; letter-spacing: -0.3px; }
863 +.defs-note { max-width: 1180px; color: var(--muted); font-size: 11.5px; line-height: 1.6; margin: 14px 2px 0; }
864 +
865 +/* écosystème compact (overview) */
866 +.ov-eco-chips { display: flex; flex-wrap: wrap; gap: 6px; }
867 +.eco-chip {
868 + display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
869 + border: 1.5px solid var(--edge); border-radius: 999px; padding: 4px 10px;
870 + font-size: 11.5px; font-weight: 700; background: var(--card2);
871 +}
872 +.eco-chip b { font-family: var(--mono); font-weight: 700; color: var(--muted); }
873 +.eco-chip.up { color: var(--good); }
874 +.eco-chip.slow { color: var(--warn); background: var(--warn-soft); }
875 +.eco-chip.down { color: #fff; background: var(--bad); border-color: var(--bad); }
876 +.eco-chip.down b { color: #fff; }
877 +.ov-sess { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1.5px solid var(--line); cursor: pointer; }
878 +.ov-sess:last-child { border-bottom: none; }
879 +.ov-sess-t { flex: 1; min-width: 0; font-weight: 700; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
880 +.ov-sess-m { font-size: 11px; color: var(--muted); flex-shrink: 0; }
881 +.anom { font-size: 12.5px; padding: 7px 0; border-bottom: 1.5px solid var(--line); font-weight: 600; }
882 +.anom:last-child { border-bottom: none; }
883 +.anom.warn { color: var(--warn); font-weight: 800; }
884 +
885 +/* répartition humains / bots / datacenter */
886 +.break-bar { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--card2); border: 1.5px solid var(--line); }
887 +.break-bar i { display: block; height: 100%; }
888 +
889 +/* temps réel */
890 +.rt-list { display: flex; flex-direction: column; max-height: 420px; overflow-y: auto; }
891 +.rt-row {
892 + display: flex; align-items: center; gap: 8px;
893 + padding: 6px 0; border-bottom: 1.5px solid var(--line); font-size: 12.5px;
894 +}
895 +.rt-row:last-child { border-bottom: none; }
896 +.rt-row img { width: 18px; height: 18px; border-radius: 5px; flex-shrink: 0; }
897 +.rt-site { font-weight: 800; flex-shrink: 0; }
898 +.rt-path { color: var(--muted); font-size: 11px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
899 +.rt-loc { color: var(--ink); font-weight: 600; font-size: 11.5px; flex-shrink: 0; }
900 +.rt-dev { font-size: 10.5px; flex-shrink: 0; }
901 +.rt-dur { color: var(--muted); font-size: 10.5px; flex-shrink: 0; }
902 +
903 +/* visiteurs v2 */
904 +.vis-grid { max-width: 1180px; }
905 +.vis-card { padding: 12px 14px; box-shadow: var(--bshadow-sm); border-width: 2px; }
906 +.vis-on-pill {
907 + font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px;
908 + border: 1.5px solid var(--line); color: var(--muted); background: var(--card2); flex-shrink: 0;
909 +}
910 +.vis-on-pill.on { background: var(--good-soft); color: var(--good); border-color: var(--good); }
911 +.vis-row { margin-top: 10px; }
912 +.vis-m { background: var(--card2); border: 1.5px solid var(--line); }
913 +.vm-n { font-size: 17px; font-variant-numeric: tabular-nums; }
914 +.vis-pv { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 11.5px; }
915 +.vis-more { color: var(--orange-deep); font-weight: 800; }
916 +.seg.mini button { padding: 6px 12px; font-size: 12px; }
917 +.seg.mini { gap: 5px; }
918 +
919 +/* filtres listes */
920 +.filterbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; max-width: 1180px; }
921 +.f-input, .f-select {
922 + background: var(--card); border: 1.5px solid var(--edge); color: var(--ink);
923 + border-radius: 999px; padding: 8px 14px; font-size: 13px; font-weight: 600; font-family: inherit; outline: none;
924 +}
925 +.f-input { flex: 1; min-width: 140px; }
926 +.f-input:focus { border-color: var(--orange); }
927 +
928 +/* sessions denses */
929 +.sess-card.row {
930 + flex-direction: row; align-items: center; gap: 10px; padding: 10px 14px;
931 + border-width: 1.5px; box-shadow: none;
932 +}
933 +.sess-card.row:hover { border-color: var(--orange-deep); }
934 +.sess-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
935 +.sess-cost, .sess-when { flex-shrink: 0; font-size: 11.5px; color: var(--muted); }
936 +.sess-btns { display: flex; gap: 6px; flex-shrink: 0; }
937 +.sess-btns button { border: 1.5px solid var(--edge); border-radius: 999px; background: var(--card2); font-size: 11.5px; font-weight: 800; padding: 5px 12px; cursor: pointer; }
938 +.sess-btns .del { color: var(--bad); padding: 5px 9px; }
939 +@media (max-width: 640px) { .sess-when { display: none; } }
940 +
941 +/* prompts */
942 +.sess-card.prow { box-shadow: none; border-width: 1.5px; }
943 +.sess-card.prow:hover { border-color: var(--orange-deep); }
944 +.fav-btn { background: none; border: none; font-size: 17px; color: var(--muted); cursor: pointer; padding: 0 2px; flex-shrink: 0; }
945 +.fav-btn.on { color: var(--orange-deep); }
946 +.sess-top .sess-info { flex-shrink: 0; }
947 +
948 +/* nav mobile « Plus » */
949 +.more-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding-bottom: 8px; }
950 +.more-btn {
951 + display: flex; align-items: center; gap: 10px;
952 + background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-md);
953 + box-shadow: var(--bshadow-sm); padding: 13px 14px; font-weight: 800; font-size: 14px; cursor: pointer;
954 +}
955 +.more-btn svg { width: 20px; height: 20px; color: var(--orange-deep); }
956 +
957 +/* accueil chat */
958 +.welcome { position: absolute; inset: 110px 0 150px; overflow-y: auto; }
959 +.welcome-inner { display: flex; flex-direction: column; align-items: center; gap: 9px; width: 100%; max-width: 560px; margin: auto; }
960 +.welcome-recent { width: 100%; margin-top: 18px; display: flex; flex-direction: column; gap: 6px; }
961 +.wr-title { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); text-align: left; }
962 +.wr-item {
963 + display: flex; align-items: center; gap: 9px; text-align: left;
964 + background: var(--card); border: 1.5px solid var(--edge); border-radius: var(--r-md);
965 + padding: 9px 12px; cursor: pointer; font-family: inherit;
966 +}
967 +.wr-t { flex: 1; min-width: 0; font-weight: 700; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
968 +.wr-m { font-size: 11px; color: var(--muted); flex-shrink: 0; }
969 +
970 +/* réglages analytics */
971 +.an-cfg .ac-num { width: 74px; background: var(--bg); border: 1.5px solid var(--edge); color: var(--ink); border-radius: 8px; padding: 5px 8px; font-family: var(--mono); font-size: 13px; }
972 +.an-cfg .pe-textarea { font-size: 12px; }
973 +
974 +/* studio / vieux composants : bascule douce vers les variables */
975 +.soc-select, #view-social textarea { background: var(--card); color: var(--ink); }
976 +.vd-box, .stload-box { background: var(--card); }
977 +.vd-head h3 { color: var(--ink); }
978 +.st-cap { color: var(--muted); }
979 +.vis-chart { background: var(--card2); border-radius: 12px; }
modified server/analytics.js +466 −90
@@ -1,163 +1,539 @@
1 // admin-ka — Analytics first-party : visiteurs uniques par site KA.
2 // Un beacon (1px) sur chaque site envoie GET /collect?site=..&vid=..&p=..
3 // On stocke les hits (SQLite), on compte les uniques (vid distincts) en live,
4 // et on fige un rollup quotidien pour l'historique long terme.
1 +// admin-ka — Analytics first-party v2 : trafic HUMAIN crédible par site KA.
2 +//
3 +// Pipeline : beacon (v1 legacy 60 s, ou v2 ka-a.js pv/heartbeat) → /collect →
4 +// classification à l'insertion (humain / crawler / datacenter / automation /
5 +// interne) via User-Agent + IP-intelligence (ip-api, cache SQLite, hosting/
6 +// proxy/ASN) → événements typés (page_view / heartbeat) + sessions → agrégats
7 +// quotidiens par classe. Les métriques principales n'affichent QUE les humains.
8 +//
9 +// Définitions (documentées dans CLAUDE.md) :
10 +// - visiteur humain : vid dont les événements sont classés `human`
11 +// - visiteur unique : vid humain distinct dans la fenêtre
12 +// - session : suite d'événements d'un même vid sans trou > sessionMin (30 min)
13 +// - page vue : événement `pv` (navigation réelle, SPA incluse) — jamais un heartbeat
14 +// - en ligne / actif : humain dont le dernier événement < activeSec (120 s) ;
15 +// expire tout seul, un onglet fermé disparaît en ~2 min
5 16 import fs from 'node:fs';
6 17 import path from 'node:path';
18 +import crypto from 'node:crypto';
7 19 import { fileURLToPath } from 'node:url';
8 20 import { DatabaseSync } from 'node:sqlite';
9 21
10 22 const __dirname = path.dirname(fileURLToPath(import.meta.url));
11 23 const DATA_DIR = path.join(__dirname, '..', 'data');
12 24 const DB_PATH = path.join(DATA_DIR, 'analytics.sqlite');
25 +const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
13 26
14 27 let db = null;
15 const RETENTION_DAYS = 45;
16 28
17 29 // GIF transparent 1x1 renvoyé au beacon
18 30 export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
19 31
20 function dayKey(ts) {
21 // jour local (Amérique/Montréal ~ heure du serveur)
22 const d = new Date(ts);
23 return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
32 +// ---------- configuration (surchageable via config.json → "analytics") ----------
33 +export const DEFAULTS = {
34 + sessionMin: 30, // minutes d'inactivité avant nouvelle session
35 + activeSec: 120, // fenêtre « en ligne » (dernier événement < N s)
36 + retentionDays: 45, // rétention des événements bruts
37 + maxIpPerHour: 1200, // au-delà : trafic reclassé automation (flood)
38 + internalIps: [], // IPs/préfixes internes additionnels
39 + internalUAs: [], // fragments UA internes additionnels
40 + extraBotUAs: [], // fragments UA bots additionnels
41 + blockedASNs: [], // ASNs (numéros) forcés en datacenter
42 + debug: false,
43 +};
44 +let cfg = { ...DEFAULTS };
45 +function loadCfg() {
46 + try {
47 + const j = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
48 + cfg = { ...DEFAULTS, ...(j.analytics || {}) };
49 + } catch { cfg = { ...DEFAULTS }; }
24 50 }
51 +export function analyticsConfig() { return { ...cfg }; }
52 +export function setAnalyticsConfig(patch) {
53 + const j = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
54 + j.analytics = { ...(j.analytics || {}) };
55 + for (const k of Object.keys(DEFAULTS)) {
56 + if (!(k in patch)) continue;
57 + if (Array.isArray(DEFAULTS[k])) j.analytics[k] = Array.isArray(patch[k]) ? patch[k].map(String).filter(Boolean).slice(0, 200) : [];
58 + else if (typeof DEFAULTS[k] === 'number') { const n = Number(patch[k]); if (Number.isFinite(n) && n > 0) j.analytics[k] = n; }
59 + else if (typeof DEFAULTS[k] === 'boolean') j.analytics[k] = !!patch[k];
60 + }
61 + fs.writeFileSync(CONFIG_PATH, JSON.stringify(j, null, 2));
62 + loadCfg();
63 + return analyticsConfig();
64 +}
65 +
66 +// ---------- classification ----------
67 +// Crawlers connus (moteurs, IA, SEO, réseaux sociaux, uptime)
68 +const BOT_RE = /googlebot|bingbot|slurp|duckduckbot|baiduspider|yandex(bot)?|applebot|gptbot|oai-searchbot|chatgpt-user|claudebot|claude-web|anthropic-ai|perplexitybot|youbot|cohere-ai|ccbot|bytespider|petalbot|amazonbot|ahrefsbot|semrushbot|mj12bot|dotbot|rogerbot|screaming frog|dataforseo|serpstatbot|blexbot|facebookexternalhit|meta-externalagent|facebookcatalog|twitterbot|linkedinbot|pinterestbot|slackbot|discordbot|telegrambot|whatsapp|skypeuripreview|redditbot|uptimerobot|uptime-kuma|pingdom|statuscake|site24x7|betteruptime|checkly|freshping|hetrixtools|gtmetrix|lighthouse|chrome-lighthouse|pagespeed|ptst|crawler|spider|scrapy|feedfetcher|bitlybot|embedly|quora link preview|ia_archiver|archive\.org_bot|wayback|semanticscholar|turnitin|dataminr/i;
69 +// Outils / scripts / navigateurs headless (pas un navigateur humain)
70 +const TOOL_RE = /^$|curl|wget|python-requests|python-urllib|python-httpx|aiohttp|httpx\/|go-http-client|node-fetch|undici|axios\/|libwww|okhttp|java\/|apache-httpclient|guzzlehttp|ruby|perl|php\/|headlesschrome|phantomjs|puppeteer|playwright|selenium|splash|cypress|jsdom|wkhtmltopdf|nutch|masscan|zgrab|nmap|dirbuster|nikto|sqlmap|censys|shodan|paloaltonetworks|expanse|internet-measurement|probe|monitor(ing)?\b|synthetic|datadog|newrelic|grafana/i;
71 +// Hébergeurs / clouds — filet quand ip-api n'a pas le drapeau hosting
72 +const DC_ORG_RE = /amazon|aws |a100 row|google (cloud|llc)|gcp|microsoft|azure|digitalocean|hetzner|ovh|linode|akamai|vultr|oracle|cloudflare|fastly|fly\.io|render|railway|vercel|netlify|scaleway|leaseweb|tencent|alibaba|aliyun|huawei cloud|choopa|contabo|m247|datacamp|packethub|gcore|stackpath|upcloud|kamatera|ionos|godaddy|hostinger|namecheap|dreamhost|liquid web|colocrossing|psychz|quadranet|serverius|worldstream|datapacket|cdn77|edgecast|limelight|zenlayer|host(ing|ed|s)\b|data ?cent(er|re)|serveur|dedibox|kimsufi|so-?you-?start/i;
73 +// UA interne Groupe KA (monitoring, gardiens, scripts maison)
74 +const INTERNAL_UA_RE = /admin-ka|ka-guardian|maclustr|adminka/i;
75 +// IP locales / cluster / tailscale
76 +const INTERNAL_IP_RE = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.|::1$|fe80:|fc|fd|::ffff:127\.|::ffff:10\.|::ffff:192\.168\.)/;
77 +const BROWSER_RE = /mozilla\/5\.0/i;
25 78
79 +export const CLASSES = ['human', 'crawler', 'datacenter', 'automation', 'internal', 'unknown'];
80 +
81 +function classify(ua, ip, ipRow, internalHeader) {
82 + const reasons = [];
83 + ua = String(ua || '');
84 + // 1) interne (header dédié, IP cluster/LAN, UA maison, listes config)
85 + if (internalHeader) return { cls: 'internal', reasons: ['en-tête X-KA-Internal'] };
86 + if (INTERNAL_IP_RE.test(ip)) return { cls: 'internal', reasons: ['IP locale/cluster (' + ip + ')'] };
87 + if (cfg.internalIps.some(p => p && ip.startsWith(p))) return { cls: 'internal', reasons: ['IP interne (réglages)'] };
88 + if (INTERNAL_UA_RE.test(ua) || cfg.internalUAs.some(p => p && ua.toLowerCase().includes(p.toLowerCase())))
89 + return { cls: 'internal', reasons: ['User-Agent interne'] };
90 + // 2) crawlers connus
91 + if (BOT_RE.test(ua) || cfg.extraBotUAs.some(p => p && ua.toLowerCase().includes(p.toLowerCase()))) {
92 + const m = ua.match(BOT_RE);
93 + return { cls: 'crawler', reasons: ['User-Agent crawler connu' + (m ? ' (' + m[0] + ')' : '')] };
94 + }
95 + // 3) outils / headless / pas un navigateur
96 + if (TOOL_RE.test(ua)) return { cls: 'automation', reasons: ['User-Agent outil/headless : ' + (ua.slice(0, 60) || '(vide)')] };
97 + if (!BROWSER_RE.test(ua)) return { cls: 'automation', reasons: ['User-Agent non-navigateur : ' + ua.slice(0, 60)] };
98 + // 4) réseau datacenter / hosting / proxy / ASN bloqué
99 + if (ipRow) {
100 + if (cfg.blockedASNs.includes(ipRow.asn)) { reasons.push('ASN ' + ipRow.asn + ' exclu (réglages)'); return { cls: 'datacenter', reasons }; }
101 + if (ipRow.hosting) { reasons.push('réseau hosting/datacenter (' + (ipRow.org || ipRow.isp || 'AS' + ipRow.asn) + ')'); return { cls: 'datacenter', reasons }; }
102 + if (ipRow.proxy) { reasons.push('proxy/VPN détecté (' + (ipRow.org || ipRow.isp || '') + ')'); return { cls: 'datacenter', reasons }; }
103 + if (DC_ORG_RE.test((ipRow.org || '') + ' ' + (ipRow.isp || ''))) { reasons.push('organisation hébergeur : ' + (ipRow.org || ipRow.isp)); return { cls: 'datacenter', reasons }; }
104 + reasons.push('réseau résidentiel/mobile (' + (ipRow.isp || ipRow.org || 'AS' + ipRow.asn) + ')');
105 + } else {
106 + reasons.push('IP pas encore classifiée (reclassement auto dès résolution)');
107 + }
108 + reasons.push('navigateur + JS exécuté');
109 + return { cls: 'human', reasons };
110 +}
111 +
112 +// ---------- init ----------
26 113 export function initAnalytics() {
27 114 fs.mkdirSync(DATA_DIR, { recursive: true });
115 + loadCfg();
28 116 db = new DatabaseSync(DB_PATH);
29 117 db.exec(`
118 + PRAGMA journal_mode = WAL;
119 + -- v2 : événements typés + classe de trafic
120 + CREATE TABLE IF NOT EXISTS events (
121 + site TEXT, vid TEXT, sid TEXT, ts INTEGER, ev TEXT, path TEXT, ref TEXT,
122 + ip TEXT, cls TEXT, dev TEXT, v INTEGER
123 + );
124 + CREATE INDEX IF NOT EXISTS idx_ev_site_ts ON events(site, ts);
125 + CREATE INDEX IF NOT EXISTS idx_ev_ts ON events(ts);
126 + CREATE INDEX IF NOT EXISTS idx_ev_ip ON events(ip);
127 + CREATE INDEX IF NOT EXISTS idx_ev_sid ON events(sid);
128 + CREATE INDEX IF NOT EXISTS idx_ev_vid ON events(vid);
129 + -- IP intelligence (cache — un lookup par IP, jamais par hit)
130 + CREATE TABLE IF NOT EXISTS ipinfo (
131 + ip TEXT PRIMARY KEY, asn INTEGER, org TEXT, isp TEXT,
132 + cc TEXT, country TEXT, region TEXT, city TEXT,
133 + hosting INTEGER, proxy INTEGER, mobile INTEGER, ts INTEGER
134 + );
135 + -- agrégats quotidiens v2 (long terme, par classe)
136 + CREATE TABLE IF NOT EXISTS daily2 (
137 + site TEXT, day TEXT, humans INTEGER, sessions INTEGER, pv INTEGER,
138 + crawler INTEGER, datacenter INTEGER, automation INTEGER, internal INTEGER,
139 + total_vids INTEGER, PRIMARY KEY(site, day)
140 + );
141 + CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
142 + -- héritées de v1 (historique conservé, plus alimentées) : hits, daily, geo
30 143 CREATE TABLE IF NOT EXISTS hits (site TEXT, vid TEXT, ts INTEGER, path TEXT);
31 CREATE INDEX IF NOT EXISTS idx_hits_site_ts ON hits(site, ts);
32 144 CREATE TABLE IF NOT EXISTS daily (site TEXT, day TEXT, uniques INTEGER, pv INTEGER, PRIMARY KEY(site, day));
33 CREATE TABLE IF NOT EXISTS geo (ip TEXT PRIMARY KEY, label TEXT, country TEXT, ts INTEGER);
34 145 `);
35 try { db.exec('ALTER TABLE hits ADD COLUMN ip TEXT'); } catch {}
146 + if (!db.prepare('SELECT value FROM meta WHERE key=?').get('migrated_at')) {
147 + db.prepare('INSERT INTO meta (key,value) VALUES (?,?)').run('migrated_at', String(Date.now()));
148 + }
36 149 rollup();
37 setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes
38 setInterval(purge, 6 * 60 * 60 * 1000); // purge le brut ancien
39 setInterval(resolveGeo, 60 * 1000); // géolocalise les nouvelles IP
40 purge(); resolveGeo();
41 }
42
43 // enregistre un hit (appelé par /collect)
44 export function recordHit(site, vid, p, ip) {
45 if (!db || !site || !vid) return;
46 site = String(site).slice(0, 40);
47 vid = String(vid).slice(0, 64);
48 p = String(p || '/').slice(0, 200);
49 ip = (String(ip || '').split(',')[0] || '').trim().slice(0, 45);
50 try { db.prepare('INSERT INTO hits (site,vid,ts,path,ip) VALUES (?,?,?,?,?)').run(site, vid, Date.now(), p, ip); } catch {}
51 }
52
53 // --- géolocalisation des IP (ip-api.com, gratuit, mis en cache) ---
54 let geoBusy = false;
55 async function resolveGeo() {
56 if (!db || geoBusy) return;
57 geoBusy = true;
150 + setInterval(rollup, 10 * 60 * 1000);
151 + setInterval(purge, 6 * 60 * 60 * 1000);
152 + setInterval(resolveIps, 30 * 1000);
153 + setInterval(() => { ipHourly.clear(); }, 60 * 60 * 1000); // fenêtre anti-flood glissante grossière
154 + purge(); resolveIps();
155 +}
156 +
157 +export function migratedAt() {
158 + const r = db.prepare('SELECT value FROM meta WHERE key=?').get('migrated_at');
159 + return r ? Number(r.value) : null;
160 +}
161 +
162 +// ---------- ingestion ----------
163 +// état de session en mémoire pour le beacon v1 (pas de sid côté client)
164 +const sessState = new Map(); // site|vid -> { sid, lastTs, lastPath }
165 +const ipHourly = new Map(); // ip -> compteur d'événements (fenêtre ~1 h)
166 +let insEv = null;
167 +
168 +function getIpRow(ip) {
169 + try { return db.prepare('SELECT * FROM ipinfo WHERE ip=?').get(ip) || null; } catch { return null; }
170 +}
171 +
172 +export function recordHit(q, req) {
173 + if (!db) return;
174 + const site = String(q.get('site') || '').slice(0, 40);
175 + const vid = String(q.get('vid') || '').slice(0, 64);
176 + if (!site || !vid) return;
177 + const now = Date.now();
178 + const pathP = String(q.get('p') || '/').slice(0, 200);
179 + const ref = String(q.get('r') || '').slice(0, 200);
180 + const dev = ['m', 't', 'd'].includes(q.get('d')) ? q.get('d') : null;
181 + const v = q.get('sid') ? 2 : 1;
182 + const ua = String(req.headers['user-agent'] || '');
183 + const xf = req.headers['x-forwarded-for'];
184 + const ip = ((xf ? String(xf).split(',')[0] : req.socket.remoteAddress) || '').trim().slice(0, 45);
185 + const internalHeader = req.headers['x-ka-internal'] === '1';
186 +
187 + // classe de trafic (IP-intelligence si déjà en cache, sinon provisoire)
188 + let { cls } = classify(ua, ip, getIpRow(ip), internalHeader);
189 + // anti-flood : une IP qui dépasse maxIpPerHour passe en automation
190 + const cnt = (ipHourly.get(ip) || 0) + 1;
191 + ipHourly.set(ip, cnt);
192 + if (cls === 'human' && cnt > cfg.maxIpPerHour) cls = 'automation';
193 +
194 + // session + type d'événement
195 + let sid, ev;
196 + if (v === 2) {
197 + sid = String(q.get('sid')).slice(0, 64);
198 + ev = q.get('ev') === 'hb' ? 'hb' : 'pv';
199 + } else {
200 + // beacon v1 : ping au chargement + toutes les 60 s → on sessionne et on
201 + // dé-duplique côté serveur (un ping d'intervalle N'EST PAS une page vue)
202 + const key = site + '|' + vid;
203 + const st = sessState.get(key);
204 + if (!st || now - st.lastTs > cfg.sessionMin * 60_000) {
205 + sid = vid + '.' + now.toString(36);
206 + ev = 'pv';
207 + } else {
208 + sid = st.sid;
209 + // nouvelle page = pv ; re-ping rapide (<50 s) même page = reload = pv ;
210 + // ping d'intervalle (~60 s+) même page = heartbeat
211 + ev = (pathP !== st.lastPath || now - st.lastTs < 50_000) ? 'pv' : 'hb';
212 + }
213 + sessState.set(key, { sid, lastTs: now, lastPath: pathP });
214 + if (sessState.size > 50_000) sessState.clear();
215 + }
216 +
217 + try {
218 + if (!insEv) insEv = db.prepare('INSERT INTO events (site,vid,sid,ts,ev,path,ref,ip,cls,dev,v) VALUES (?,?,?,?,?,?,?,?,?,?,?)');
219 + insEv.run(site, vid, sid, now, ev, pathP, ref || null, ip, cls, dev, v);
220 + } catch {}
221 +}
222 +
223 +// ---------- IP intelligence (ip-api.com, gratuit ≤45 req/min, cache) ----------
224 +let ipBusy = false;
225 +async function resolveIps() {
226 + if (!db || ipBusy) return;
227 + ipBusy = true;
58 228 try {
59 const rows = db.prepare(`SELECT DISTINCT ip FROM hits WHERE ip IS NOT NULL AND ip<>'' AND ip NOT IN (SELECT ip FROM geo) LIMIT 12`).all();
229 + const rows = db.prepare(`
230 + SELECT DISTINCT e.ip FROM events e
231 + WHERE e.ip <> '' AND e.ip NOT IN (SELECT ip FROM ipinfo)
232 + ORDER BY e.ts DESC LIMIT 18`).all();
60 233 for (const r of rows) {
61 234 const ip = r.ip;
62 if (/^(127\.|10\.|192\.168\.|::1|172\.(1[6-9]|2\d|3[01])\.)/.test(ip) || ip === 'localhost') {
63 db.prepare('INSERT OR REPLACE INTO geo (ip,label,country,ts) VALUES (?,?,?,?)').run(ip, 'Local', 'Local', Date.now());
235 + if (INTERNAL_IP_RE.test(ip)) {
236 + db.prepare('INSERT OR REPLACE INTO ipinfo (ip,asn,org,isp,cc,country,region,city,hosting,proxy,mobile,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
237 + .run(ip, 0, 'Réseau local', 'Local', 'XX', 'Local', '', '', 0, 0, 0, Date.now());
64 238 continue;
65 239 }
66 240 try {
67 const res = await fetch('http://ip-api.com/json/' + encodeURIComponent(ip) + '?fields=status,country,countryCode,regionName,city', { signal: AbortSignal.timeout(8000) });
241 + const res = await fetch('http://ip-api.com/json/' + encodeURIComponent(ip)
242 + + '?fields=status,country,countryCode,regionName,city,isp,org,as,hosting,proxy,mobile',
243 + { signal: AbortSignal.timeout(8000) });
68 244 const d = await res.json();
69 let label = 'Inconnu', country = '';
70 245 if (d && d.status === 'success') {
71 country = d.country || '';
72 label = [d.city, d.regionName].filter(Boolean).join(', ') || d.country || 'Inconnu';
73 if (d.countryCode && d.countryCode !== 'CA') label = (d.city ? d.city + ', ' : '') + (d.country || '');
246 + const asn = Number((String(d.as || '').match(/^AS(\d+)/) || [])[1] || 0);
247 + db.prepare('INSERT OR REPLACE INTO ipinfo (ip,asn,org,isp,cc,country,region,city,hosting,proxy,mobile,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
248 + .run(ip, asn, d.org || '', d.isp || '', d.countryCode || '', d.country || '', d.regionName || '', d.city || '',
249 + d.hosting ? 1 : 0, d.proxy ? 1 : 0, d.mobile ? 1 : 0, Date.now());
250 + reclassifyIp(ip);
251 + } else {
252 + db.prepare('INSERT OR REPLACE INTO ipinfo (ip,asn,org,isp,cc,country,region,city,hosting,proxy,mobile,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
253 + .run(ip, 0, '', '', '', '', '', '', 0, 0, 0, Date.now());
74 254 }
75 db.prepare('INSERT OR REPLACE INTO geo (ip,label,country,ts) VALUES (?,?,?,?)').run(ip, label, country, Date.now());
76 } catch {
77 db.prepare('INSERT OR REPLACE INTO geo (ip,label,country,ts) VALUES (?,?,?,?)').run(ip, 'Inconnu', '', Date.now());
78 }
79 await new Promise(r => setTimeout(r, 1500)); // respecter la limite ip-api
255 + } catch { break; } // réseau indisponible — on réessaiera au prochain cycle
256 + await new Promise(r2 => setTimeout(r2, 1500)); // respecter la limite ip-api
80 257 }
81 258 } catch {}
82 geoBusy = false;
259 + ipBusy = false;
83 260 }
84 261
85 // top emplacements (visiteurs uniques) d'un site
86 export function analyticsGeo(site, days = 30) {
87 try {
88 return db.prepare(`
89 SELECT g.label label, COUNT(DISTINCT h.vid) c
90 FROM hits h JOIN geo g ON g.ip=h.ip
91 WHERE h.site=? AND h.ts>=? AND g.label IS NOT NULL AND g.label<>'Local'
92 GROUP BY g.label ORDER BY c DESC LIMIT 8`).all(site, Date.now() - days * 864e5);
93 } catch { return []; }
262 +// une IP vient d'être résolue : reclasse rétroactivement ses événements
263 +// provisoirement « human » si le réseau est un datacenter/proxy/ASN exclu
264 +function reclassifyIp(ip) {
265 + const row = getIpRow(ip);
266 + if (!row) return;
267 + const isDc = row.hosting || row.proxy || cfg.blockedASNs.includes(row.asn) || DC_ORG_RE.test((row.org || '') + ' ' + (row.isp || ''));
268 + if (isDc) {
269 + try { db.prepare(`UPDATE events SET cls='datacenter' WHERE ip=? AND cls='human'`).run(ip); } catch {}
270 + }
94 271 }
95 272
273 +// ---------- entretien ----------
96 274 function purge() {
97 275 if (!db) return;
98 const cutoff = Date.now() - RETENTION_DAYS * 864e5;
99 try { db.prepare('DELETE FROM hits WHERE ts < ?').run(cutoff); } catch {}
276 + const cutoff = Date.now() - cfg.retentionDays * 864e5;
277 + try {
278 + db.prepare('DELETE FROM events WHERE ts < ?').run(cutoff);
279 + db.prepare('DELETE FROM hits WHERE ts < ?').run(cutoff);
280 + db.prepare('DELETE FROM ipinfo WHERE ts < ?').run(Date.now() - 90 * 864e5); // re-résolution périodique
281 + } catch {}
100 282 }
101 283
102 // fige les uniques/pv par jour dans `daily` (sauvegarde long terme)
284 +function dayKey(ts) {
285 + const d = new Date(ts);
286 + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
287 +}
288 +function dayStart(offsetDays = 0) {
289 + const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - offsetDays);
290 + return d.getTime();
291 +}
292 +
293 +// fige les agrégats quotidiens par classe (3 derniers jours recalculés)
103 294 function rollup() {
104 295 if (!db) return;
105 296 try {
106 const rows = db.prepare(`
107 SELECT site, ts, vid FROM hits WHERE ts >= ?`).all(Date.now() - 3 * 864e5);
108 const agg = {}; // site|day -> {set, pv}
297 + const since = dayStart(2);
298 + const rows = db.prepare('SELECT site, ts, vid, sid, ev, cls FROM events WHERE ts >= ?').all(since);
299 + const agg = {}; // site|day -> accumulateurs
109 300 for (const r of rows) {
110 301 const k = r.site + '|' + dayKey(r.ts);
111 if (!agg[k]) agg[k] = { set: new Set(), pv: 0 };
112 agg[k].set.add(r.vid); agg[k].pv++;
302 + let a = agg[k];
303 + if (!a) a = agg[k] = { hv: new Set(), hs: new Set(), pv: 0, crawler: 0, datacenter: 0, automation: 0, internal: 0, av: new Set() };
304 + a.av.add(r.vid);
305 + if (r.cls === 'human') {
306 + a.hv.add(r.vid); a.hs.add(r.sid);
307 + if (r.ev === 'pv') a.pv++;
308 + } else if (a[r.cls] !== undefined) a[r.cls]++;
309 + }
310 + const up = db.prepare('INSERT OR REPLACE INTO daily2 (site,day,humans,sessions,pv,crawler,datacenter,automation,internal,total_vids) VALUES (?,?,?,?,?,?,?,?,?,?)');
311 + for (const k in agg) {
312 + const [site, day] = k.split('|');
313 + const a = agg[k];
314 + up.run(site, day, a.hv.size, a.hs.size, a.pv, a.crawler, a.datacenter, a.automation, a.internal, a.av.size);
113 315 }
114 const up = db.prepare('INSERT OR REPLACE INTO daily (site,day,uniques,pv) VALUES (?,?,?,?)');
115 for (const k in agg) { const [site, day] = k.split('|'); up.run(site, day, agg[k].set.size, agg[k].pv); }
116 316 } catch {}
117 317 }
118 318
119 function uniquesSince(site, sinceTs) {
120 try { return db.prepare('SELECT COUNT(DISTINCT vid) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; }
121 }
122 function pvSince(site, sinceTs) {
123 try { return db.prepare('SELECT COUNT(*) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; }
319 +// ---------- requêtes ----------
320 +const H = `cls='human'`;
321 +
322 +function q1(sql, ...args) { try { return db.prepare(sql).get(...args); } catch { return null; } }
323 +function qa(sql, ...args) { try { return db.prepare(sql).all(...args); } catch { return []; } }
324 +
325 +function humanUniques(site, a, b) {
326 + return q1(`SELECT COUNT(DISTINCT vid) c FROM events WHERE site=? AND ${H} AND ts>=? AND ts<?`, site, a, b)?.c || 0;
124 327 }
125 328
329 +// résumé global + par site (métriques HUMAINES + répartition des classes)
126 330 export function analyticsSummary(sites) {
127 331 const now = Date.now();
128 const startToday = new Date(); startToday.setHours(0, 0, 0, 0);
129 const t0 = startToday.getTime();
332 + const t0 = dayStart(0);
333 + const y0 = dayStart(1);
334 + const activeCut = now - cfg.activeSec * 1000;
130 335 const out = [];
131 let g = { online: 0, today: 0, d7: 0, pv_today: 0 };
336 + const g = { online: 0, today: 0, yesterdaySame: 0, sessions: 0, pv: 0, d7: 0, d30: 0, crawler: 0, datacenter: 0, automation: 0, internal: 0, total_vids: 0 };
132 337 for (const s of sites) {
133 338 const site = s.app || s;
339 + const t = q1(`SELECT COUNT(DISTINCT vid) uv, COUNT(DISTINCT sid) ss, SUM(CASE WHEN ev='pv' THEN 1 ELSE 0 END) pv, COUNT(*) hits
340 + FROM events WHERE site=? AND ${H} AND ts>=?`, site, t0) || {};
341 + const other = q1(`SELECT
342 + SUM(CASE WHEN cls='crawler' THEN 1 ELSE 0 END) crawler,
343 + SUM(CASE WHEN cls='datacenter' THEN 1 ELSE 0 END) datacenter,
344 + SUM(CASE WHEN cls IN ('automation','unknown') THEN 1 ELSE 0 END) automation,
345 + SUM(CASE WHEN cls='internal' THEN 1 ELSE 0 END) internal,
346 + COUNT(DISTINCT vid) total_vids
347 + FROM events WHERE site=? AND ts>=?`, site, t0) || {};
134 348 const row = {
135 349 site, label: s.label || site, domain: s.domain || '',
136 online: uniquesSince(site, now - 5 * 60 * 1000), // uniques dans les 5 dernières min
137 today: uniquesSince(site, t0),
138 d7: uniquesSince(site, now - 7 * 864e5),
139 d30: uniquesSince(site, now - 30 * 864e5),
140 pv_today: pvSince(site, t0),
141 pv_total: pvSince(site, now - RETENTION_DAYS * 864e5),
350 + online: q1(`SELECT COUNT(DISTINCT vid) c FROM events WHERE site=? AND ${H} AND ts>=?`, site, activeCut)?.c || 0,
351 + today: t.uv || 0,
352 + yesterdaySame: humanUniques(site, y0, y0 + (now - t0)), // hier à la même heure (delta honnête)
353 + sessions: t.ss || 0,
354 + pv: t.pv || 0,
355 + humanHits: t.hits || 0,
356 + d7: humanUniques(site, now - 7 * 864e5, now + 1),
357 + d30: humanUniques(site, now - 30 * 864e5, now + 1),
358 + crawler: other.crawler || 0, datacenter: other.datacenter || 0,
359 + automation: other.automation || 0, internal: other.internal || 0,
360 + total_vids: other.total_vids || 0,
142 361 };
143 362 out.push(row);
144 g.online += row.online; g.today += row.today; g.d7 += row.d7; g.pv_today += row.pv_today;
363 + for (const k of ['online', 'today', 'yesterdaySame', 'sessions', 'pv', 'humanHits', 'd7', 'd30', 'crawler', 'datacenter', 'automation', 'internal', 'total_vids']) g[k] = (g[k] || 0) + row[k];
145 364 }
146 365 out.sort((a, b) => b.online - a.online || b.today - a.today);
147 return { sites: out, global: g, updated: now };
366 + return { sites: out, global: g, updated: now, activeSec: cfg.activeSec, migratedAt: migratedAt() };
148 367 }
149 368
150 // série quotidienne (uniques) des N derniers jours pour un site
369 +// série quotidienne 30 j (v2 par classe + héritage v1 avant migration)
151 370 export function analyticsSeries(site, days = 30) {
152 371 const out = [];
372 + const mig = migratedAt();
373 + const migDay = mig ? dayKey(mig) : null;
374 + const isAll = site === '_all';
153 375 for (let i = days - 1; i >= 0; i--) {
154 const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - i);
155 const key = dayKey(d.getTime());
156 let u = 0;
157 try { const r = db.prepare('SELECT uniques FROM daily WHERE site=? AND day=?').get(site, key); if (r) u = r.uniques; } catch {}
158 // aujourd'hui : recalcule en direct (le rollup peut être en retard)
159 if (i === 0) u = Math.max(u, uniquesSince(site, d.getTime()));
160 out.push({ day: key, uniques: u });
376 + const start = dayStart(i);
377 + const key = dayKey(start);
378 + let r;
379 + if (isAll) {
380 + r = q1('SELECT SUM(humans) humans, SUM(sessions) sessions, SUM(pv) pv, SUM(crawler+datacenter+automation+internal) other FROM daily2 WHERE day=?', key);
381 + } else {
382 + r = q1('SELECT humans, sessions, pv, (crawler+datacenter+automation+internal) other FROM daily2 WHERE site=? AND day=?', site, key);
383 + }
384 + let legacy = null;
385 + if (migDay && key <= migDay) {
386 + legacy = isAll
387 + ? (q1('SELECT SUM(uniques) u FROM daily WHERE day=?', key)?.u ?? null)
388 + : (q1('SELECT uniques u FROM daily WHERE site=? AND day=?', site, key)?.u ?? null);
389 + }
390 + // aujourd'hui : recalcul en direct (le rollup 10 min peut être en retard)
391 + if (i === 0) {
392 + const live = isAll
393 + ? q1(`SELECT COUNT(DISTINCT vid) c FROM events WHERE ${H} AND ts>=?`, start)
394 + : q1(`SELECT COUNT(DISTINCT vid) c FROM events WHERE site=? AND ${H} AND ts>=?`, site, start);
395 + r = { ...(r || {}), humans: Math.max(r?.humans || 0, live?.c || 0) };
396 + }
397 + out.push({ day: key, humans: r?.humans || 0, sessions: r?.sessions || 0, pv: r?.pv || 0, other: r?.other || 0, legacy });
398 + }
399 + return { series: out, migratedAt: mig };
400 +}
401 +
402 +// fiche détaillée d'un site : top pages, referrers, devices, géo, horaires, sessions
403 +export function analyticsSite(site, days = 7) {
404 + const now = Date.now();
405 + const since = now - days * 864e5;
406 + const t0 = dayStart(0);
407 + const pages = qa(`SELECT path, COUNT(*) pv, COUNT(DISTINCT vid) uv FROM events
408 + WHERE site=? AND ${H} AND ev='pv' AND ts>=? GROUP BY path ORDER BY pv DESC LIMIT 12`, site, since);
409 + const refs = qa(`SELECT ref, COUNT(DISTINCT vid) uv FROM events
410 + WHERE site=? AND ${H} AND ev='pv' AND ts>=? AND ref IS NOT NULL AND ref<>'' GROUP BY ref ORDER BY uv DESC LIMIT 10`, site, since);
411 + const devs = qa(`SELECT COALESCE(dev,'?') dev, COUNT(DISTINCT vid) uv FROM events
412 + WHERE site=? AND ${H} AND ts>=? GROUP BY dev ORDER BY uv DESC`, site, since);
413 + const geo = qa(`SELECT i.cc, i.country, i.region, i.city, COUNT(DISTINCT e.vid) uv FROM events e JOIN ipinfo i ON i.ip=e.ip
414 + WHERE e.site=? AND e.${H} AND e.ts>=? AND i.city<>'' GROUP BY i.cc,i.region,i.city ORDER BY uv DESC LIMIT 12`, site, since);
415 + const countries = qa(`SELECT i.cc, i.country, COUNT(DISTINCT e.vid) uv FROM events e JOIN ipinfo i ON i.ip=e.ip
416 + WHERE e.site=? AND e.${H} AND e.ts>=? AND i.country<>'' GROUP BY i.cc ORDER BY uv DESC LIMIT 8`, site, since);
417 + // 24 dernières heures (humains) : uniques + pv par heure
418 + const hourly = qa(`SELECT (ts/3600000)*3600000 h, COUNT(DISTINCT vid) uv, SUM(CASE WHEN ev='pv' THEN 1 ELSE 0 END) pv
419 + FROM events WHERE site=? AND ${H} AND ts>=? GROUP BY h ORDER BY h`, site, now - 24 * 3600_000);
420 + // sessions du jour : durée moyenne + pages/session + rebond
421 + const sess = qa(`SELECT sid, COUNT(CASE WHEN ev='pv' THEN 1 END) pv, MAX(ts)-MIN(ts) dur
422 + FROM events WHERE site=? AND ${H} AND ts>=? GROUP BY sid`, site, t0);
423 + let durSum = 0, pvSum = 0, bounce = 0;
424 + for (const s of sess) { durSum += s.dur; pvSum += s.pv; if (s.pv <= 1) bounce++; }
425 + const nS = sess.length || 1;
426 + // répartition des classes sur la fenêtre
427 + const classes = qa(`SELECT cls, COUNT(*) n, COUNT(DISTINCT vid) uv FROM events WHERE site=? AND ts>=? GROUP BY cls`, site, since);
428 + // nouveaux vs récurrents (parmi les humains du jour : vid vu avant aujourd'hui ?)
429 + const newRet = q1(`SELECT
430 + SUM(CASE WHEN first_ts >= ? THEN 1 ELSE 0 END) new_v,
431 + SUM(CASE WHEN first_ts < ? THEN 1 ELSE 0 END) ret_v
432 + FROM (SELECT vid, MIN(ts) first_ts FROM events WHERE site=? AND ${H} GROUP BY vid HAVING MAX(ts) >= ?)`, t0, t0, site, t0) || {};
433 + return {
434 + site, days, pages, refs, devs, geo, countries, hourly, classes,
435 + sessionsToday: sess.length,
436 + avgSessionSec: Math.round(durSum / nS / 1000),
437 + pagesPerSession: Math.round(pvSum / nS * 100) / 100,
438 + bouncePct: Math.round(bounce / nS * 100),
439 + newToday: newRet.new_v || 0, returningToday: newRet.ret_v || 0,
440 + };
441 +}
442 +
443 +// temps réel : humains actifs (dernier événement < activeSec)
444 +export function analyticsRealtime(sites) {
445 + const cut = Date.now() - cfg.activeSec * 1000;
446 + const labels = {}; for (const s of sites) labels[s.app] = s.label || s.app;
447 + const rows = qa(`
448 + SELECT e.site, e.vid, e.dev, e.sid, e.ip, MAX(e.ts) last_ts,
449 + (SELECT MIN(ts) FROM events WHERE sid=e.sid) since,
450 + (SELECT path FROM events WHERE vid=e.vid AND site=e.site AND ts>=? ORDER BY ts DESC LIMIT 1) path,
451 + (SELECT i.city || CASE WHEN i.region<>'' THEN ', ' || i.region ELSE '' END FROM ipinfo i WHERE i.ip=e.ip AND i.city<>'') loc,
452 + (SELECT i.cc FROM ipinfo i WHERE i.ip=e.ip) cc
453 + FROM events e
454 + WHERE e.${H} AND e.ts >= ?
455 + GROUP BY e.site, e.vid ORDER BY last_ts DESC LIMIT 120`, cut, cut);
456 + return {
457 + active: rows.map(r => ({
458 + site: r.site, label: labels[r.site] || r.site, vid: r.vid.slice(0, 6),
459 + path: r.path, loc: r.loc || '—', cc: r.cc || '', dev: r.dev || '?',
460 + since: r.since, lastTs: r.last_ts,
461 + })),
462 + activeSec: cfg.activeSec, ts: Date.now(),
463 + };
464 +}
465 +
466 +// anomalies simples : IP bavardes, pics de trafic, poussée datacenter
467 +export function analyticsAnomalies() {
468 + const now = Date.now();
469 + const h1 = now - 3600_000;
470 + const topIps = qa(`SELECT e.ip, COUNT(*) n, COUNT(DISTINCT e.site) sites, MAX(e.cls) cls,
471 + (SELECT org FROM ipinfo WHERE ip=e.ip) org, (SELECT city FROM ipinfo WHERE ip=e.ip) city
472 + FROM events e WHERE e.ts>=? GROUP BY e.ip HAVING n > 120 ORDER BY n DESC LIMIT 10`, h1);
473 + // pic : événements de la dernière heure vs moyenne horaire des 24 h précédentes
474 + const spikes = [];
475 + const sitesH = qa(`SELECT site, COUNT(*) n FROM events WHERE ts>=? GROUP BY site`, h1);
476 + for (const s of sitesH) {
477 + const avg = q1(`SELECT COUNT(*)/24.0 a FROM events WHERE site=? AND ts>=? AND ts<?`, s.site, now - 25 * 3600_000, h1)?.a || 0;
478 + if (avg >= 2 && s.n > avg * 5) spikes.push({ site: s.site, lastHour: s.n, hourlyAvg: Math.round(avg), factor: Math.round(s.n / avg * 10) / 10 });
479 + }
480 + const dcSurge = q1(`SELECT COUNT(*) n FROM events WHERE ts>=? AND cls='datacenter'`, h1)?.n || 0;
481 + const dcAvg = q1(`SELECT COUNT(*)/24.0 a FROM events WHERE ts>=? AND ts<? AND cls='datacenter'`, now - 25 * 3600_000, h1)?.a || 0;
482 + return {
483 + topIps, spikes,
484 + datacenter: { lastHour: dcSurge, hourlyAvg: Math.round(dcAvg), surge: dcAvg >= 2 && dcSurge > dcAvg * 5 },
485 + ts: now,
486 + };
487 +}
488 +
489 +// mode debug : pourquoi une visite est classée comme elle l'est
490 +export function analyticsInspect(q) {
491 + const byIp = q.ip ? String(q.ip) : null;
492 + const byVid = q.vid ? String(q.vid) : null;
493 + if (!byIp && !byVid) return { error: 'fournir ip= ou vid=' };
494 + const where = byIp ? 'ip=?' : 'vid LIKE ?';
495 + const arg = byIp || (byVid + '%');
496 + const events = qa(`SELECT site, vid, sid, ts, ev, path, ref, ip, cls, dev, v FROM events WHERE ${where} ORDER BY ts DESC LIMIT 40`, arg);
497 + if (!events.length) return { events: [], reasons: ['aucun événement trouvé'] };
498 + const ip = events[0].ip;
499 + const ipRow = getIpRow(ip);
500 + const cnt1h = q1('SELECT COUNT(*) c FROM events WHERE ip=? AND ts>=?', ip, Date.now() - 3600_000)?.c || 0;
501 + const stored = events[0].cls;
502 + const reasons = [];
503 + // raisons dérivées de l'état réel (l'UA n'est pas conservé par événement)
504 + if (q.ua) {
505 + const replay = classify(q.ua, ip, ipRow, false);
506 + reasons.push('rejeu avec UA fourni → ' + replay.cls, ...replay.reasons);
507 + }
508 + if (INTERNAL_IP_RE.test(ip)) reasons.push('IP locale/cluster');
509 + if (ipRow) {
510 + reasons.push(`réseau : AS${ipRow.asn} ${ipRow.org || ipRow.isp || '?'} (${[ipRow.city, ipRow.country].filter(Boolean).join(', ') || 'lieu inconnu'})`);
511 + if (ipRow.hosting) reasons.push('drapeau hosting/datacenter → exclu des humains');
512 + if (ipRow.proxy) reasons.push('drapeau proxy/VPN → exclu des humains');
513 + if (cfg.blockedASNs.includes(ipRow.asn)) reasons.push('ASN exclu (réglages)');
514 + if (DC_ORG_RE.test((ipRow.org || '') + ' ' + (ipRow.isp || ''))) reasons.push('organisation de type hébergeur');
515 + if (!ipRow.hosting && !ipRow.proxy) reasons.push('réseau résidentiel/mobile');
516 + } else reasons.push('IP pas encore résolue (file ip-api — reclassement auto ensuite)');
517 + if (stored === 'crawler') reasons.push('User-Agent de crawler connu au moment du hit');
518 + if (stored === 'automation') reasons.push('User-Agent outil/headless (ou flood) au moment du hit');
519 + if (stored === 'internal') reasons.push('trafic interne Groupe KA (IP/UA/en-tête)');
520 + if (cnt1h > cfg.maxIpPerHour) reasons.push(`${cnt1h} événements dans l'heure (> ${cfg.maxIpPerHour}) → automation`);
521 + const sids = new Set(events.map(e => e.sid));
522 + return {
523 + ip, ipinfo: ipRow, storedClass: stored,
524 + reasons: [...reasons, `${cnt1h} evt/h pour cette IP`, `${sids.size} session(s) dans les événements récents`],
525 + events,
526 + };
527 +}
528 +
529 +// répartition des classes (période paramétrable) pour le toggle Humains|Tout
530 +export function analyticsClasses(sinceMs) {
531 + const since = Date.now() - sinceMs;
532 + const rows = qa(`SELECT cls, COUNT(*) hits, COUNT(DISTINCT vid) vids FROM events WHERE ts>=? GROUP BY cls`, since);
533 + const out = { human: { hits: 0, vids: 0 }, crawler: { hits: 0, vids: 0 }, datacenter: { hits: 0, vids: 0 }, automation: { hits: 0, vids: 0 }, internal: { hits: 0, vids: 0 } };
534 + for (const r of rows) {
535 + const k = r.cls === 'unknown' ? 'automation' : r.cls;
536 + if (out[k]) { out[k].hits += r.hits; out[k].vids += r.vids; }
161 537 }
162 538 return out;
163 539 }
modified server/server.js +19 −6
@@ -11,7 +11,7 @@ import { spawn } from 'node:child_process';
11 11 import { fileURLToPath } from 'node:url';
12 12 import { WebSocketServer } from 'ws';
13 13 import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js';
14 import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsGeo, PIXEL } from './analytics.js';
14 +import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsSite, analyticsRealtime, analyticsAnomalies, analyticsInspect, analyticsClasses, analyticsConfig, setAnalyticsConfig, PIXEL } from './analytics.js';
15 15 import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js';
16 16 import { execFile } from 'node:child_process';
17 17
@@ -494,9 +494,9 @@ const server = http.createServer(async (req, res) => {
494 494
495 495 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); }
496 496
497 // --- beacon analytics (public, sans auth) : compte les visiteurs uniques ---
497 + // --- beacon analytics (public, sans auth) : v1 (pings 60 s) et v2 (pv/hb) ---
498 498 if (p === '/collect') {
499 try { recordHit(url.searchParams.get('site'), url.searchParams.get('vid'), url.searchParams.get('p'), (req.headers['x-forwarded-for']||req.socket.remoteAddress||'')); } catch {}
499 + try { recordHit(url.searchParams, req); } catch {}
500 500 res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' });
501 501 return res.end(PIXEL);
502 502 }
@@ -581,7 +581,7 @@ const server = http.createServer(async (req, res) => {
581 581 // --- API authentifiée ---
582 582 if (p.startsWith('/api/')) {
583 583 if (!getSession(req)) return json(res, 401, { error: 'auth' });
584 if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '2.0.0' });
584 + if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '3.0.0' });
585 585 if (p === '/api/projects') return json(res, 200, { projects: listProjects() });
586 586 if (p === '/api/chats' && req.method === 'GET') {
587 587 const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat);
@@ -611,6 +611,7 @@ const server = http.createServer(async (req, res) => {
611 611 title: (String(body.title || '').trim() || text.slice(0, 48)),
612 612 text,
613 613 project: findProject(body.project) ? body.project : null,
614 + fav: !!body.fav, uses: 0,
614 615 created: Date.now(), updated: Date.now(),
615 616 };
616 617 prompts.push(pr); savePrompts();
@@ -625,6 +626,8 @@ const server = http.createServer(async (req, res) => {
625 626 if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim();
626 627 if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48);
627 628 if ('project' in body) pr.project = findProject(body.project) ? body.project : null;
629 + if (typeof body.fav === 'boolean') pr.fav = body.fav;
630 + if (body.bumpUse) pr.uses = (pr.uses || 0) + 1;
628 631 pr.updated = Date.now(); savePrompts();
629 632 return json(res, 200, { prompt: pr });
630 633 }
@@ -648,8 +651,18 @@ const server = http.createServer(async (req, res) => {
648 651 }
649 652 }
650 653 if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES));
651 if (p.startsWith('/api/analytics/series/')) return json(res, 200, { series: analyticsSeries(p.split('/')[4], 30) });
652 if (p.startsWith('/api/analytics/geo/')) return json(res, 200, { geo: analyticsGeo(p.split('/')[4], 30) });
654 + if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30));
655 + if (p.startsWith('/api/analytics/site/')) return json(res, 200, analyticsSite(p.split('/')[4], Number(url.searchParams.get('days')) || 7));
656 + if (p === '/api/analytics/realtime') return json(res, 200, analyticsRealtime(SITES));
657 + if (p === '/api/analytics/anomalies') return json(res, 200, analyticsAnomalies());
658 + if (p === '/api/analytics/inspect') return json(res, 200, analyticsInspect({ ip: url.searchParams.get('ip'), vid: url.searchParams.get('vid'), ua: url.searchParams.get('ua') }));
659 + if (p === '/api/analytics/classes') return json(res, 200, analyticsClasses(Number(url.searchParams.get('ms')) || 864e5));
660 + if (p === '/api/analytics/config' && req.method === 'GET') return json(res, 200, analyticsConfig());
661 + if (p === '/api/analytics/config' && req.method === 'PUT') {
662 + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); }
663 + audit({ kind: 'analytics_config', ip: clientIp(req) });
664 + return json(res, 200, setAnalyticsConfig(body));
665 + }
653 666 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());
654 667 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });
655 668 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());
added server/test-analytics.mjs +128 −0
@@ -0,0 +1,128 @@
1 +// Banc d'essai analytics v2 — valide les scénarios de classification et les
2 +// invariants des métriques. À lancer depuis une COPIE du repo (il écrit dans
3 +// data/analytics.sqlite) : `node server/test-analytics.mjs`
4 +import fs from 'node:fs';
5 +import path from 'node:path';
6 +import { fileURLToPath } from 'node:url';
7 +import { DatabaseSync } from 'node:sqlite';
8 +
9 +const __dirname = path.dirname(fileURLToPath(import.meta.url));
10 +const DATA = path.join(__dirname, '..', 'data');
11 +const DB = path.join(DATA, 'analytics.sqlite');
12 +fs.mkdirSync(DATA, { recursive: true });
13 +try { fs.unlinkSync(DB); } catch {}
14 +try { fs.unlinkSync(DB + '-wal'); } catch {}
15 +try { fs.unlinkSync(DB + '-shm'); } catch {}
16 +if (!fs.existsSync(path.join(DATA, 'config.json'))) {
17 + fs.writeFileSync(path.join(DATA, 'config.json'), JSON.stringify({ salt: '00', passwordHash: '00' }));
18 +}
19 +
20 +const { initAnalytics, recordHit, analyticsSummary, analyticsRealtime, analyticsInspect, setAnalyticsConfig } =
21 + await import('./analytics.js');
22 +
23 +initAnalytics();
24 +setAnalyticsConfig({ activeSec: 2 }); // fenêtre « en ligne » courte pour tester l'expiration
25 +
26 +const CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
27 +const GOOGLEBOT = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
28 +const UPTIME = 'Mozilla/5.0 (compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)';
29 +const CURL = 'curl/8.6.0';
30 +
31 +function hit(params, ua, ip) {
32 + const q = new URLSearchParams(params);
33 + recordHit(q, { headers: { 'user-agent': ua, 'x-forwarded-for': ip }, socket: { remoteAddress: ip } });
34 +}
35 +const SITES = [{ app: 'lou-ka', label: 'Lou·Ka' }];
36 +const sum = () => analyticsSummary(SITES).sites[0];
37 +
38 +let pass = 0, fail = 0;
39 +function check(name, cond, detail) {
40 + if (cond) { pass++; console.log(' ✓ ' + name); }
41 + else { fail++; console.log(' ✗ ' + name + (detail !== undefined ? ' — obtenu : ' + JSON.stringify(detail) : '')); }
42 +}
43 +
44 +// IP AWS pré-classifiée hosting (simule le cache ip-api)
45 +const db = new DatabaseSync(DB);
46 +db.prepare('INSERT OR REPLACE INTO ipinfo (ip,asn,org,isp,cc,country,region,city,hosting,proxy,mobile,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
47 + .run('54.230.1.9', 16509, 'Amazon.com, Inc.', 'Amazon Technologies', 'US', 'United States', 'Virginia', 'Ashburn', 1, 0, 0, Date.now());
48 +
49 +console.log('\nCas 1 — humain ouvre Lou-KA (beacon v2)');
50 +hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'pv', p: '/', d: 'd' }, CHROME, '24.48.100.10');
51 +let s = sum();
52 +check('+1 visiteur humain', s.today === 1, s.today);
53 +check('+1 session', s.sessions === 1, s.sessions);
54 +check('+1 page vue', s.pv === 1, s.pv);
55 +check('+1 en ligne', s.online === 1, s.online);
56 +
57 +console.log('\nCas 2 — même humain rafraîchit la page');
58 +hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'pv', p: '/', d: 'd' }, CHROME, '24.48.100.10');
59 +s = sum();
60 +check('même visiteur', s.today === 1, s.today);
61 +check('même session', s.sessions === 1, s.sessions);
62 +check('+1 page vue (2)', s.pv === 2, s.pv);
63 +
64 +console.log('\nCas 3 — même humain navigue (SPA) + heartbeat');
65 +hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'pv', p: '/logement/abc', d: 'd' }, CHROME, '24.48.100.10');
66 +hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'hb', p: '/logement/abc', d: 'd' }, CHROME, '24.48.100.10');
67 +s = sum();
68 +check('toujours 1 visiteur / 1 session', s.today === 1 && s.sessions === 1, [s.today, s.sessions]);
69 +check('+1 page vue (3) — heartbeat non compté', s.pv === 3, s.pv);
70 +
71 +console.log('\nCas 4 — Googlebot rend 300 URLs (JS exécuté, beacon v1, vid changeant)');
72 +for (let i = 0; i < 300; i++) hit({ site: 'lou-ka', vid: 'gb' + i, p: '/page' + i }, GOOGLEBOT, '66.249.66.' + (i % 250));
73 +s = sum();
74 +check('0 visiteur humain ajouté', s.today === 1, s.today);
75 +check('300 hits crawler', s.crawler === 300, s.crawler);
76 +check('0 page vue humaine ajoutée', s.pv === 3, s.pv);
77 +
78 +console.log('\nCas 5 — uptime monitor ping (UptimeRobot)');
79 +for (let i = 0; i < 20; i++) hit({ site: 'lou-ka', vid: 'up1', p: '/' }, UPTIME, '69.162.124.1');
80 +s = sum();
81 +check('0 humain ajouté, visible en crawler', s.today === 1 && s.crawler === 320, [s.today, s.crawler]);
82 +
83 +console.log('\nCas 6 — IP AWS (headless Chrome, UA navigateur) fait 100 requêtes');
84 +for (let i = 0; i < 100; i++) hit({ site: 'lou-ka', vid: 'aws' + i, sid: 'as' + i, ev: 'pv', p: '/x' + i, d: 'd' }, CHROME, '54.230.1.9');
85 +s = sum();
86 +check('0 humain ajouté (datacenter)', s.today === 1, s.today);
87 +check('100 hits datacenter', s.datacenter === 100, s.datacenter);
88 +
89 +console.log('\nCas 6b — script curl → automation ; trafic interne → internal');
90 +hit({ site: 'lou-ka', vid: 'cu1', p: '/' }, CURL, '203.0.113.5');
91 +hit({ site: 'lou-ka', vid: 'in1', p: '/' }, CHROME, '192.168.2.40');
92 +s = sum();
93 +check('1 hit automation', s.automation === 1, s.automation);
94 +check('1 hit interne', s.internal === 1, s.internal);
95 +check('toujours 1 seul humain', s.today === 1, s.today);
96 +
97 +console.log('\nCas 7 — beacon v1 : dédup des pings 60 s côté serveur');
98 +const now = Date.now();
99 +hit({ site: 'lou-ka', vid: 'v1user', p: '/appartement' }, CHROME, '70.80.1.2'); // pv (nouvelle session)
100 +// simule un ping d'intervalle : on triche sur l'horloge de sessState en réinsérant à ~60 s
101 +// (ici on vérifie surtout : 2e ping immédiat même page = reload = pv, page différente = pv)
102 +hit({ site: 'lou-ka', vid: 'v1user', p: '/appartement' }, CHROME, '70.80.1.2'); // reload rapide → pv
103 +hit({ site: 'lou-ka', vid: 'v1user', p: '/contact' }, CHROME, '70.80.1.2'); // nouvelle page → pv
104 +s = sum();
105 +check('v1 : 1 visiteur, 1 session', s.today === 2 && s.sessions === 2, [s.today, s.sessions]);
106 +check('v1 : 3 pv (pas de hb en rafale)', s.pv === 6, s.pv);
107 +
108 +console.log('\nInspecteur — raisons de classification');
109 +const insp = analyticsInspect({ ip: '54.230.1.9' });
110 +check('classe stockée datacenter', insp.storedClass === 'datacenter', insp.storedClass);
111 +check('raison mentionne le hosting/Amazon', (insp.reasons || []).join(' ').toLowerCase().includes('hosting') || (insp.reasons || []).join(' ').includes('Amazon'), insp.reasons);
112 +
113 +console.log('\nInvariants');
114 +s = sum();
115 +check('uniques <= sessions', s.today <= s.sessions, [s.today, s.sessions]);
116 +check('sessions <= pages vues', s.sessions <= s.pv, [s.sessions, s.pv]);
117 +check('en ligne <= uniques du jour', s.online <= s.today, [s.online, s.today]);
118 +
119 +console.log('\nTemps réel + expiration du « en ligne » (fenêtre 2 s)');
120 +let rt = analyticsRealtime(SITES);
121 +check('humains actifs présents', rt.active.length >= 1, rt.active.length);
122 +check('bots absents du temps réel', rt.active.every(a => !a.vid.startsWith('gb') && !a.vid.startsWith('aws')), rt.active.map(a => a.vid));
123 +await new Promise(r => setTimeout(r, 2600));
124 +s = sum(); rt = analyticsRealtime(SITES);
125 +check('en ligne retombe à 0 (expiration auto)', s.online === 0 && rt.active.length === 0, [s.online, rt.active.length]);
126 +
127 +console.log(`\n${pass} réussis, ${fail} échoués`);
128 +process.exit(fail ? 1 : 0);
129