SPB Git

spb/admin-ka

Public

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 (Aug 26, 2026) parent 34a4eff Browse files

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 Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`. 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 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; } 51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }
52 52
53− // ---------- navigation ---------- 53+ // ---------- navigation (sidebar desktop + bottom nav mobile) ----------
54− const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'visitors', 'settings']; 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 function switchView(name) { 91 function switchView(name) {
56 curView = name; 92 curView = name;
57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name); 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 document.querySelectorAll('.nav-btn').forEach((b) => { 95 document.querySelectorAll('.nav-btn').forEach((b) => {
59 b.classList.toggle('active', b.dataset.view === name); 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 if (name === 'sessions') loadSessions(); 100 if (name === 'sessions') loadSessions();
63 if (name === 'prompts') loadPrompts(); 101 if (name === 'prompts') loadPrompts();
64 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); } 102 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }
65 if (name === 'social' && window.__loadGallery) window.__loadGallery(); 103 if (name === 'social' && window.__loadGallery) window.__loadGallery();
66 if (name === 'visitors' && window.startVisitors) window.startVisitors(); 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 ===== 114+ // ---------- thème (auto / clair / sombre) ----------
72−(function(){ 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 const $ = (id) => document.getElementById(id); 134 const $ = (id) => document.getElementById(id);
74− let timer=null; 135+ let timer = null, mode = 'human', lastSummary = null;
75− function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); } 136+ const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' ');
76− function card(s){ 137+ const DEV_LABEL = { m: '📱 mobile', t: '💻 tablette', d: '🖥️ desktop', '?': '—' };
77− return '<div class="vis-card" data-site="'+s.site+'" data-label="'+(s.label||s.site)+'">' 138+ const CLS_LABEL = { human: 'Humains', crawler: 'Crawlers', datacenter: 'Datacenter', automation: 'Automation', internal: 'Interne' };
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>' 139+ const CLS_COLOR = { human: 'var(--good)', crawler: '#6a9fd8', datacenter: 'var(--warn)', automation: '#a08cc9', internal: 'var(--muted)' };
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>' 140+
80− + '<div class="vis-row">' 141+ function delta(cur, prev) {
81− + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">jour</div></div>' 142+ if (!prev) return '';
82− + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7j</div></div>' 143+ const d = Math.round((cur - prev) / prev * 100);
83− + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30j</div></div>' 144+ if (!isFinite(d) || Math.abs(d) < 1) return '<span class="kdelta flat">≈ hier</span>';
84− + '</div>' 145+ return `<span class="kdelta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '+' : ''}${d}&nbsp;% vs hier</span>`;
85− + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues · voir détail →</div></div>'; 146+ }
86− } 147+ function kpi(v, l, extra, accent) {
87− async function loadVisitors(){ 148+ return `<div class="kpi2${accent ? ' accent' : ''}"><div class="kv2">${v}</div><div class="kl2">${l}</div>${extra || ''}</div>`;
88− try{ 149+ }
89− const d=await (await fetch('/api/analytics/summary')).json(); const g=d.global||{}; 150+ function renderGlobal(d) {
90− if($('visLive')) $('visLive').innerHTML='<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' aujourd&#39;hui'; 151+ const g = d.global || {};
91− if($('visGlobal')) $('visGlobal').innerHTML= 152+ $('visLive').innerHTML = '<span class="vis-dot on"></span> ' + nf(g.online) + ' humain' + (g.online > 1 ? 's' : '') + ' en ligne';
92− '<div class="vg-item"><div class="vg-n">'+nf(g.online)+'</div><div class="vg-l">en ligne</div></div>' 153+ const all = mode === 'all';
93− +'<div class="vg-item"><div class="vg-n">'+nf(g.today)+'</div><div class="vg-l">aujourd&#39;hui</div></div>' 154+ const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0);
94− +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>' 155+ $('visGlobal').innerHTML =
95− +'<div class="vg-item"><div class="vg-n">'+nf(g.pv_today)+'</div><div class="vg-l">pages vues</div></div>'; 156+ kpi(nf(g.online), 'en ligne (actifs < 2 min)', '', true)
96− if($('visGrid')){ $('visGrid').innerHTML=(d.sites||[]).map(card).join(''); 157+ + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame))
97− $('visGrid').querySelectorAll('.vis-card').forEach(function(c){ c.onclick=function(){ openDetail(c.dataset.site,c.dataset.label); }; }); } 158+ + kpi(nf(g.sessions), 'sessions')
98− }catch(e){} 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){ 415+ function ovChart(series, mig) {
101− var w=680,h=240,pad=30; var vals=series.map(function(x){return x.uniques;}); 416+ const w = 760, h = 200, pl = 44, pb = 24, pt = 8;
102− var max=Math.max(1,Math.max.apply(null,vals)); var n=series.length; 417+ const n = series.length; if (n < 2) return '<div class="soc-sub">Les données humaines s’accumulent — revenez dans quelques heures.</div>';
103− var X=function(i){return pad+(w-2*pad)*(n<=1?0:i/(n-1));}; 418+ const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0]));
104− var Y=function(v){return h-pad-(h-2*pad)*(v/max);}; 419+ const X = i => pl + (w - pl - 10) * (i / (n - 1));
105− var pts=series.map(function(x,i){return X(i)+','+Y(x.uniques);}).join(' '); 420+ const Y = v => h - pb - (h - pb - pt) * (v / max);
106− var area='M'+X(0)+','+(h-pad)+' L'+pts.replace(/ /g,' L')+' L'+X(n-1)+','+(h-pad)+' Z'; 421+ let dH = '', dL = '', pen = false;
107− var last=series[n-1]||{uniques:0}; 422+ series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); });
108− var dots='<circle cx="'+X(n-1)+'" cy="'+Y(last.uniques)+'" r="6" fill="var(--accent,#f97316)"/>'; 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; });
109− // libellés d'axe : quelques jours 424+ const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`;
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>'; } 425+ let lab = '';
111− return '<svg viewBox="0 0 '+w+' '+h+'" class="vis-chart">' 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>`;
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>' 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('');
113− +'<path d="'+area+'" fill="url(#ga)"/>' 428+ return `<svg viewBox="0 0 ${w} ${h}" class="vis-chart">${grid}
114− +'<polyline points="'+pts+'" fill="none" stroke="var(--accent,#f97316)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>' 429+ <path d="${area}" fill="var(--orange-deep)" opacity="0.10"/>
115− +dots+lab 430+ <path d="${dL}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4 4"/>
116− +'<text x="'+pad+'" y="20" font-size="15" fill="#999">max '+max+'/j</text></svg>'; 431+ <path d="${dH}" fill="none" stroke="var(--orange-deep)" stroke-width="2.4" stroke-linejoin="round"/>${lab}</svg>
117− } 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>`;
118− async function openDetail(site,label){ 433+ }
119− var ov=$('visDetail'); 434+ window.startOverview = function () {
120− if(!ov){ ov=document.createElement('div'); ov.id='visDetail'; document.body.appendChild(ov); } 435+ load();
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>'; 436+ clearInterval(timer);
122− ov.classList.add('on'); $('vdClose').onclick=function(){ ov.classList.remove('on'); }; 437+ timer = setInterval(() => { if (curView === 'overview' && !document.hidden) load(); }, 30000);
123− ov.onclick=function(e){ if(e.target===ov) ov.classList.remove('on'); }; 438+ };
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); };
143 })(); 439 })();
144 440
145 441
@@ -299,8 +595,7 @@
299 bindSocial(); 595 bindSocial();
300 function navBadge(view) { 596 function navBadge(view) {
301 if (curView === view) return; 597 if (curView === view) return;
302− const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`); 598+ document.querySelectorAll(`.nav-btn[data-view="${view}"] .badge`).forEach(b => b.classList.remove('hidden'));
303− if (b) b.classList.remove('hidden');
304 } 599 }
305 600
306 // ---------- bannières ---------- 601 // ---------- bannières ----------
@@ -660,9 +955,13 @@
660 // ---------- WebSocket ---------- 955 // ---------- WebSocket ----------
661 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } 956 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
662 function setConn(state) { 957 function setConn(state) {
663− const pill = $('connPill'); 958+ const txt = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';
664− pill.className = 'conn-pill ' + state; 959+ for (const id of ['connPill', 'connPillSide']) {
665− $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne'; 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 function connect() { 966 function connect() {
668 if (!wsWanted) return; 967 if (!wsWanted) return;
@@ -724,7 +1023,7 @@
724 return; 1023 return;
725 } 1024 }
726 if (msg.type === 'eco_alert') { 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 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco')); 1027 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));
729 navBadge('eco'); 1028 navBadge('eco');
730 if (curView === 'eco') loadEco(); 1029 if (curView === 'eco') loadEco();
@@ -918,79 +1217,134 @@
918 } 1217 }
919 1218
920 // ---------- sessions ---------- 1219 // ---------- sessions ----------
921− async function loadSessions() { 1220+ let sessCache = [];
922− const r = await fetch('/api/chats'); 1221+ function sessFilters() {
923− if (!r.ok) return; 1222+ return { q: ($('sessSearch')?.value || '').toLowerCase(), app: $('sessApp')?.value || '', st: $('sessState')?.value || '' };
924− const { chats } = await r.json(); 1223+ }
1224+ function renderSessions() {
1225+ const { q, app, st } = sessFilters();
925 const list = $('sessList'); 1226 const list = $('sessList');
926 list.innerHTML = ''; 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 const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' }; 1233 const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };
929− for (const c of chats) { 1234+ for (const c of rows) {
930− const card = el('div', 'sess-card'); 1235+ const card = el('div', 'sess-card row');
931 card.innerHTML = ` 1236 card.innerHTML = `
932− <div class="sess-top"> 1237+ <span class="sess-badge ${c.state}" title="${stLabel[c.state] || c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>
933− <span class="sess-title">${esc(c.title)}</span> 1238+ <div class="sess-main">
934− <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span> 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 </div> 1241 </div>
936− <div class="sess-info"> 1242+ <span class="sess-cost mono">${fmt$(c.totalCost)}</span>
937− ${c.batch ? '<span class="batch-tag">🌐 diffusion</span>' : ''} 1243+ <span class="sess-when mono">${timeAgo(c.updated)}</span>
938− <span>${esc(c.projectName || '')}</span> 1244+ <span class="sess-btns"></span>`;
939− <span>${MODEL_LABEL[c.model] || c.model}</span> 1245+ const btns = card.querySelector('.sess-btns');
940− <span>${fmt$(c.totalCost)}</span>
941− <span>${timeAgo(c.updated)}</span>
942− </div>`;
943− const actions = el('div', 'sess-actions');
944 const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir'); 1246 const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');
945 open.onclick = (e) => { e.stopPropagation(); openChat(c.id); }; 1247 open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };
946− actions.appendChild(open); 1248+ btns.appendChild(open);
947 if (c.state === 'idle') { 1249 if (c.state === 'idle') {
948− const del = el('button', 'del', 'Supprimer'); 1250+ const del = el('button', 'del', '✕');
1251+ del.title = 'Supprimer';
949 del.onclick = async (e) => { 1252 del.onclick = async (e) => {
950 e.stopPropagation(); 1253 e.stopPropagation();
951 if (!confirm('Supprimer cette session ?')) return; 1254 if (!confirm('Supprimer cette session ?')) return;
952 await fetch('/api/chats/' + c.id, { method: 'DELETE' }); 1255 await fetch('/api/chats/' + c.id, { method: 'DELETE' });
953− card.remove(); 1256+ sessCache = sessCache.filter(x => x.id !== c.id);
1257+ renderSessions();
954 if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); } 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 card.onclick = () => openChat(c.id); 1262 card.onclick = () => openChat(c.id);
960 list.appendChild(card); 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 $('newSessBtn').onclick = newChat; 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 // ---------- Prompt Bank ---------- 1299 // ---------- Prompt Bank ----------
966 function projectOptionsHTML(selected) { 1300 function projectOptionsHTML(selected) {
967 return [...projectSel.options].map(o => 1301 return [...projectSel.options].map(o =>
968 `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join(''); 1302 `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');
969 } 1303 }
970− async function loadPrompts() { 1304+ let promptCache = [];
971− const r = await fetch('/api/prompts'); 1305+ function renderPrompts() {
972− if (!r.ok) return; 1306+ const q = ($('promptSearch')?.value || '').toLowerCase();
973− const { prompts } = await r.json(); 1307+ const app = $('promptApp')?.value || '';
974 const list = $('promptList'); 1308 const list = $('promptList');
975 list.innerHTML = ''; 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; } 1310+ const rows = promptCache
977− for (const pr of prompts) { 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 const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix'; 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 card.innerHTML = ` 1317 card.innerHTML = `
981− <div class="sess-top"><span class="sess-title">${esc(pr.title)}</span></div> 1318+ <div class="sess-top">
982− <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div> 1319+ <button class="fav-btn${pr.fav ? ' on' : ''}" title="Favori">${pr.fav ? '★' : '☆'}</button>
983− <div class="sess-info"><span>🎯 ${esc(projName)}</span><span>${timeAgo(pr.updated)}</span></div>`; 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 const actions = el('div', 'sess-actions'); 1324 const actions = el('div', 'sess-actions');
985− const sendB = el('button', 'p-send', 'Envoyer →'); 1325+ const sendB = el('button', 'p-send', 'Lancer →');
986 const editB = el('button', '', '✎ Éditer'); 1326 const editB = el('button', '', '✎ Éditer');
987 const upB = el('button', '', '✨ Améliorer'); 1327 const upB = el('button', '', '✨ Améliorer');
1328+ const dupB = el('button', '', '⧉ Dupliquer');
988 const delB = el('button', 'del', 'Suppr.'); 1329 const delB = el('button', 'del', 'Suppr.');
989− actions.append(sendB, upB, editB, delB); 1330+ actions.append(sendB, upB, editB, dupB, delB);
990 card.appendChild(actions); 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 editB.onclick = () => openPromptEditor(pr); 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 upB.onclick = async () => { 1348 upB.onclick = async () => {
995 upB.textContent = '✨ …'; upB.disabled = true; 1349 upB.textContent = '✨ …'; upB.disabled = true;
996 try { 1350 try {
@@ -1007,7 +1361,23 @@
1007 list.appendChild(card); 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 $('newPromptBtn').onclick = () => openPromptEditor(null); 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 function openPromptEditor(pr) { 1382 function openPromptEditor(pr) {
1013 const isNew = !pr; 1383 const isNew = !pr;
@@ -1079,8 +1449,11 @@
1079 banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000); 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) 1452+ // incidents globaux (bouton Écosystème + cloche topbar)
1083− $('incBtn').onclick = async () => { 1453+ $('topInc').onclick = () => openIncidents();
1454+ $('incBtn').onclick = () => openIncidents();
1455+ async function openIncidents() {
1456+ const tb = $('topIncBadge'); if (tb) tb.classList.add('hidden');
1084 const r = await fetch('/api/eco/incidents'); 1457 const r = await fetch('/api/eco/incidents');
1085 if (!r.ok) return; 1458 if (!r.ok) return;
1086 const { incidents } = await r.json(); 1459 const { incidents } = await r.json();
@@ -1457,6 +1830,40 @@
1457 location.reload(); 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 // ---------- login / init ---------- 1867 // ---------- login / init ----------
1461 async function init() { 1868 async function init() {
1462 const me = await fetch('/api/me'); 1869 const me = await fetch('/api/me');
@@ -1489,10 +1896,13 @@
1489 }; 1896 };
1490 refreshChips(); 1897 refreshChips();
1491 refreshSettingsView(); 1898 refreshSettingsView();
1899+ loadWelcomeRecent();
1492 // reprise automatique : on rouvre la dernière conversation (historique + 1900 // reprise automatique : on rouvre la dernière conversation (historique +
1493 // live si une tâche tourne encore) même après fermeture complète de l'app 1901 // live si une tâche tourne encore) même après fermeture complète de l'app
1494 if (settings.lastChatId) currentChat = { id: settings.lastChatId }; 1902 if (settings.lastChatId) currentChat = { id: settings.lastChatId };
1495 connect(); 1903 connect();
1904+ // page d'accueil = Vue d'ensemble (le chat garde sa reprise en arrière-plan)
1905+ switchView('overview');
1496 } 1906 }
1497 $('loginBtn').onclick = doLogin; 1907 $('loginBtn').onclick = doLogin;
1498 $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); 1908 $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
modified public/index.html +247 −158
@@ -7,11 +7,15 @@
7 <meta name="apple-mobile-web-app-capable" content="yes"> 7 <meta name="apple-mobile-web-app-capable" content="yes">
8 <meta name="apple-mobile-web-app-status-bar-style" content="default"> 8 <meta name="apple-mobile-web-app-status-bar-style" content="default">
9 <meta name="apple-mobile-web-app-title" content="Admin-Ka"> 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 <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"> 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 <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"> 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 <link rel="stylesheet" href="/vendor/hljs-github.min.css"> 13 <link rel="stylesheet" href="/vendor/hljs-github.min.css">
14 <link rel="stylesheet" href="/style.css"> 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 </head> 19 </head>
16 <body> 20 <body>
17 21
@@ -20,7 +24,7 @@
20 <div class="login-box"> 24 <div class="login-box">
21 <div class="ka-mark big">Ka</div> 25 <div class="ka-mark big">Ka</div>
22 <h1 class="login-title">Admin<span class="tiret">-</span>Ka</h1> 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 <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password"> 28 <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password">
25 <button id="loginBtn" class="btn-cta">Se connecter</button> 29 <button id="loginBtn" class="btn-cta">Se connecter</button>
26 <div id="loginError" class="login-error"></div> 30 <div id="loginError" class="login-error"></div>
@@ -31,183 +35,268 @@
31 <!-- ============ App ============ --> 35 <!-- ============ App ============ -->
32 <div id="app" class="app hidden"> 36 <div id="app" class="app hidden">
33 37
34− <header class="header"> 38+ <!-- Sidebar (desktop) -->
35− <div class="brand"> 39+ <aside class="sidebar">
40+ <div class="side-brand">
36 <span class="ka-mark">Ka</span> 41 <span class="ka-mark">Ka</span>
37 <span class="brand-name">Admin<span class="tiret">-</span>Ka</span> 42 <span class="brand-name">Admin<span class="tiret">-</span>Ka</span>
38 </div> 43 </div>
39− <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText">hors ligne</span></div> 44+ <nav class="side-nav" id="sideNav"></nav>
40− </header> 45+ <div class="side-foot">
41− 46+ <div id="connPillSide" class="conn-pill off"><span class="conn-dot"></span><span class="conn-txt">hors ligne</span></div>
42− <!-- Bannière notifications / alertes --> 47+ <div class="side-node mono">M3U96a · v3</div>
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>
55 </div> 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"> 70+ <!-- ======== Vue VUE D'ENSEMBLE ======== -->
60− <div class="ka-mark big">Ka</div> 71+ <section id="view-overview" class="view scroll hidden">
61− <h2>Console Claude Code</h2> 72+ <div class="view-head">
62− <p>Le vrai Claude Code, lancé directement sur le nœud de chaque app du Groupe KA.</p> 73+ <h2>Vue d’ensemble</h2>
63− <div class="hints"> 74+ <span id="ovStamp" class="eco-stamp"></span>
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>
67 </div> 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"> 102+ <!-- ======== Vue CHAT (Claude Code) ======== -->
71− <div class="composer-inner"> 103+ <section id="view-chat" class="view">
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> 104+ <div class="chatbar">
73− <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea> 105+ <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select>
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> 106+ <button id="chipModel" class="chip" title="Modèle">opus</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> 107+ <button id="chipPerm" class="chip" title="Permissions">normal</button>
76− </div> 108+ <button id="chipMore" class="chip ghost" title="Options de session">⋯</button>
77− <div id="slashHint" class="slash-hint hidden"></div> 109+ </div>
78− </footer> 110+ <div id="sessMeta" class="sess-meta hidden">
79− </section> 111+ <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span>
80− 112+ </div>
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>
98 113
99− <!-- ======== Vue ÉCOSYSTÈME ======== --> 114+ <main id="messages" class="messages"></main>
100− <section id="view-eco" class="view hidden"> 115+
101− <div class="view-head"> 116+ <div id="welcome" class="welcome">
102− <h2>Écosystème</h2> 117+ <div class="welcome-inner">
103− <div class="head-actions"> 118+ <div class="ka-mark big">Ka</div>
104− <button id="incBtn" class="chip">Incidents</button> 119+ <h2>Claude Code</h2>
105− <span id="ecoStamp" class="eco-stamp"></span> 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 </div> 128 </div>
107− </div> 129+
108− <div id="ecoOverview" class="eco-overview"></div> 130+ <footer class="composer">
109− <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div> 131+ <div class="composer-inner">
110− <h3 class="eco-subhead">Nœuds du cluster</h3> 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>
111− <div id="ecoNodes" class="node-grid"></div> 133+ <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea>
112− </section> 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>
113− 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>
114− <!-- ======== Vue RÉGLAGES ======== --> 136+ </div>
115− <section id="view-settings" class="view hidden"> 137+ <div id="slashHint" class="slash-hint hidden"></div>
116− <div class="view-head"><h2>Réglages</h2></div> 138+ </footer>
117− <div class="set-card"> 139+ </section>
118− <div class="set-label">Modèle par défaut</div> 140+
119− <div class="seg" id="segModel"> 141+ <!-- ======== Vue SESSIONS ======== -->
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> 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 </div> 146 </div>
122− </div> 147+ <div class="filterbar">
123− <div class="set-card"> 148+ <input id="sessSearch" class="f-input" type="search" placeholder="Rechercher…">
124− <div class="set-label">Permissions par défaut</div> 149+ <select id="sessApp" class="f-select"><option value="">Toutes les apps</option></select>
125− <div class="seg vert" id="segPerm"> 150+ <select id="sessState" class="f-select">
126− <button data-v="default">Normal — demande dans le chat</button> 151+ <option value="">Tous les statuts</option>
127− <button data-v="acceptEdits">acceptEdits — éditions auto</button> 152+ <option value="running">En cours</option>
128− <button data-v="plan">Plan — proposer avant d'agir</button> 153+ <option value="waiting_perm">Permission</option>
129− <button data-v="bypass">⚠ Tout autoriser (bypass)</button> 154+ <option value="idle">Terminées</option>
155+ </select>
130 </div> 156 </div>
131− </div> 157+ <div id="sessList" class="sess-list"></div>
132− <div class="set-card"> 158+ </section>
133− <div class="set-label">Conversation active</div> 159+
134− <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div> 160+ <!-- ======== Vue PROMPTS ======== -->
135− <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div> 161+ <section id="view-prompts" class="view scroll hidden">
136− <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div> 162+ <div class="view-head">
137− <div class="set-actions"> 163+ <h2>Prompts</h2>
138− <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button> 164+ <button id="newPromptBtn" class="btn-cta small">+ Nouveau</button>
139− <button id="btnCompact" class="btn-ghost">/compact — compacter</button>
140 </div> 165 </div>
141− </div> 166+ <div class="filterbar">
142− <div class="set-card"> 167+ <input id="promptSearch" class="f-input" type="search" placeholder="Rechercher un prompt…">
143− <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div> 168+ <select id="promptApp" class="f-select"><option value="">Toutes les apps</option></select>
144− <div class="set-row"><span>Version</span><span class="mono">2.0.0</span></div> 169+ </div>
145− </div> 170+ <div id="promptList" class="sess-list"></div>
146− <button id="logoutBtn" class="btn-danger">Se déconnecter</button> 171+ </section>
147− </section>
148 172
149− <!-- ======== Vue SOCIAL ======== --> 173+ <!-- ======== Vue ÉCOSYSTÈME ======== -->
150− <section id="view-social" class="view hidden"> 174+ <section id="view-eco" class="view scroll hidden">
151− <div class="view-head"><h2>🎨 Studio</h2><button id="stRefresh" class="btn-ghost small">↻ Galerie</button></div> 175+ <div class="view-head">
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> 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"> 188+ <!-- ======== Vue VISITEURS (analytics humains) ======== -->
155− <div class="soc-card st-gen"> 189+ <section id="view-visitors" class="view scroll hidden">
156− <h3>🖼️ Publication</h3> 190+ <div class="view-head">
157− <textarea id="stPostPrompt" rows="2" placeholder="Sujet (optionnel). Ex : dernière propriété à vendre sur Immo-Ka."></textarea> 191+ <h2>Visiteurs</h2>
158− <div class="soc-actions"> 192+ <div class="head-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> 193+ <div class="seg mini" id="visMode">
160− <button id="stPostGen" class="btn-cta small">✨ Générer une image</button> 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 </div> 198 </div>
162 </div> 199 </div>
163− <div class="soc-card st-gen"> 200+
164− <h3>🎬 Reel vidéo</h3> 201+ <div id="visGlobal" class="ov-kpis compact"></div>
165− <textarea id="stReelPrompt" rows="2" placeholder="Sujet (optionnel). Ex : le dernier logement en annonce sur Lou-Ka."></textarea> 202+ <div id="visBreak" class="panel slim"></div>
166− <div class="soc-actions"> 203+
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> 204+ <div class="ov-cols">
168− <button id="stReelGen" class="btn-cta small">🎬 Générer un reel</button> 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 </div> 208 </div>
170− <div class="soc-actions" style="margin-top:8px"> 209+ <div class="panel">
171− <button id="stReel10" class="btn-ghost small">🔥 Générer 10 reels</button> 210+ <div class="panel-h"><h3>Anomalies</h3><button class="linklike" id="visDebugBtn">🔬 inspecter une IP / un visiteur</button></div>
172− <span id="stGenStatus" class="soc-sub"></span> 211+ <div id="visAnom"></div>
173 </div> 212 </div>
174 </div> 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> 215+ <div class="view-head" style="margin-top:16px"><h3 class="sub-h">Par application</h3></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> 216+ <div id="visGrid" class="vis-grid"><div class="soc-sub">Chargement…</div></div>
179− </section> 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>
180− 218+ </section>
181−<section id="view-visitors" class="view hidden"> 219+
182− <div class="view-head"><h2>👥 Visiteurs</h2><span id="visLive" class="soc-sub"></span></div> 220+ <!-- ======== Vue RÉGLAGES ======== -->
183− <div id="visGlobal" class="vis-global"></div> 221+ <section id="view-settings" class="view scroll hidden">
184− <div id="visGrid" class="vis-grid"><div class="soc-sub">Chargement…</div></div> 222+ <div class="view-head"><h2>Réglages</h2></div>
185− </section> 223+ <div class="set-card">
186− 224+ <div class="set-label">Apparence</div>
187− <!-- ======== Bottom nav ======== --> 225+ <div class="seg" id="segTheme">
188− <nav class="bottomnav"> 226+ <button data-v="auto">Auto</button><button data-v="light">Clair</button><button data-v="dark">Sombre</button>
189− <button data-view="chat" class="nav-btn active"> 227+ </div>
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> 228+ </div>
191− <span>Chat</span><i class="badge hidden"></i> 229+ <div class="set-card">
192− </button> 230+ <div class="set-label">Modèle par défaut</div>
193− <button data-view="sessions" class="nav-btn"> 231+ <div class="seg" id="segModel">
194− <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> 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>
195− <span>Sessions</span><i class="badge hidden"></i> 233+ </div>
196− </button> 234+ </div>
197− <button data-view="prompts" class="nav-btn"> 235+ <div class="set-card">
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> 236+ <div class="set-label">Permissions par défaut</div>
199− <span>Prompts</span><i class="badge hidden"></i> 237+ <div class="seg vert" id="segPerm">
200− </button> 238+ <button data-v="default">Normal — demande dans le chat</button>
201− <button data-view="eco" class="nav-btn"> 239+ <button data-v="acceptEdits">acceptEdits — éditions auto</button>
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> 240+ <button data-v="plan">Plan — proposer avant d'agir</button>
203− <span>Écosystème</span><i class="badge hidden"></i> 241+ <button data-v="bypass">⚠ Tout autoriser (bypass)</button>
204− </button> 242+ </div>
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> 243+ </div>
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"> 244+ <div class="set-card">
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> 245+ <div class="set-label">Analytics — définitions &amp; filtres</div>
208− <span>Réglages</span><i class="badge hidden"></i> 246+ <div id="anCfg" class="an-cfg"><div class="soc-sub">Chargement…</div></div>
209− </button> 247+ </div>
210− </nav> 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 <!-- ======== Feuille détail (éco / options) ======== --> 301 <!-- ======== Feuille détail (éco / options) ======== -->
213 <div id="sheet" class="sheet hidden"> 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 --r-lg: 20px; 20 --r-lg: 20px;
21 --r-md: 14px; 21 --r-md: 14px;
22 --r-sm: 10px; 22 --r-sm: 10px;
23− --bshadow: 3px 3px 0 var(--ink); 23+ --bshadow: 3px 3px 0 var(--shadow);
24− --bshadow-sm: 2px 2px 0 var(--ink); 24+ --bshadow-sm: 2px 2px 0 var(--shadow);
25 --z-banner: 140; 25 --z-banner: 140;
26 --z-header: 100; 26 --z-header: 100;
27 --z-nav: 110; 27 --z-nav: 110;
@@ -47,7 +47,7 @@ button { font-family: inherit; color: inherit; }
47 display: inline-flex; align-items: center; justify-content: center; 47 display: inline-flex; align-items: center; justify-content: center;
48 width: 34px; height: 34px; border-radius: 11px; 48 width: 34px; height: 34px; border-radius: 11px;
49 background: var(--orange); color: var(--ink); 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 font-weight: 800; font-size: 15px; letter-spacing: -0.2px; 51 font-weight: 800; font-size: 15px; letter-spacing: -0.2px;
52 flex-shrink: 0; 52 flex-shrink: 0;
53 } 53 }
@@ -56,15 +56,15 @@ button { font-family: inherit; color: inherit; }
56 56
57 .btn-cta { 57 .btn-cta {
58 background: var(--orange); color: var(--ink); 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 box-shadow: var(--bshadow); 60 box-shadow: var(--bshadow);
61 font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer; 61 font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer;
62 transition: transform 0.08s, box-shadow 0.08s; 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 .btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); } 65 .btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); }
66 .btn-ghost { 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 font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer; 68 font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer;
69 } 69 }
70 .btn-danger { 70 .btn-danger {
@@ -82,15 +82,15 @@ button { font-family: inherit; color: inherit; }
82 } 82 }
83 .login-box { 83 .login-box {
84 width: 100%; max-width: 360px; 84 width: 100%; max-width: 360px;
85− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-xl); 85+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-xl);
86− box-shadow: 6px 6px 0 var(--ink); 86+ box-shadow: 6px 6px 0 var(--shadow);
87 padding: 34px 26px 26px; 87 padding: 34px 26px 26px;
88 display: flex; flex-direction: column; gap: 13px; text-align: center; 88 display: flex; flex-direction: column; gap: 13px; text-align: center;
89 } 89 }
90 .login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; } 90 .login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; }
91 .login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; } 91 .login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; }
92 .login input { 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 border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none; 94 border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none;
95 font-family: inherit; 95 font-family: inherit;
96 } 96 }
@@ -105,14 +105,14 @@ button { font-family: inherit; color: inherit; }
105 position: sticky; top: 0; z-index: var(--z-header); 105 position: sticky; top: 0; z-index: var(--z-header);
106 display: flex; align-items: center; justify-content: space-between; gap: 10px; 106 display: flex; align-items: center; justify-content: space-between; gap: 10px;
107 background: var(--bg); 107 background: var(--bg);
108− border-bottom: 2px solid var(--ink); 108+ border-bottom: 2px solid var(--edge);
109 padding: 8px 14px; 109 padding: 8px 14px;
110 padding-top: calc(8px + env(safe-area-inset-top)); 110 padding-top: calc(8px + env(safe-area-inset-top));
111 } 111 }
112 .brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; } 112 .brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; }
113 .conn-pill { 113 .conn-pill {
114 display: flex; align-items: center; gap: 6px; 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 background: var(--card); font-size: 11.5px; font-weight: 700; 116 background: var(--card); font-size: 11.5px; font-weight: 700;
117 padding: 4px 10px; 117 padding: 4px 10px;
118 } 118 }
@@ -125,7 +125,7 @@ button { font-family: inherit; color: inherit; }
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; } 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 .banner { 126 .banner {
127 pointer-events: auto; cursor: pointer; 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 box-shadow: var(--bshadow); padding: 11px 14px; 129 box-shadow: var(--bshadow); padding: 11px 14px;
130 font-weight: 700; font-size: 13.5px; 130 font-weight: 700; font-size: 13.5px;
131 display: flex; gap: 10px; align-items: center; 131 display: flex; gap: 10px; align-items: center;
@@ -148,7 +148,7 @@ button { font-family: inherit; color: inherit; }
148 position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav); 148 position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav);
149 display: flex; 149 display: flex;
150 background: var(--card); 150 background: var(--card);
151− border-top: 2px solid var(--ink); 151+ border-top: 2px solid var(--edge);
152 padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); 152 padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
153 } 153 }
154 .nav-btn { 154 .nav-btn {
@@ -164,7 +164,7 @@ button { font-family: inherit; color: inherit; }
164 .nav-btn .badge { 164 .nav-btn .badge {
165 position: absolute; top: 3px; right: calc(50% - 17px); 165 position: absolute; top: 3px; right: calc(50% - 17px);
166 width: 9px; height: 9px; border-radius: 50%; 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 /* ================= Vue chat ================= */ 170 /* ================= Vue chat ================= */
@@ -178,7 +178,7 @@ button { font-family: inherit; color: inherit; }
178 .pill-select { 178 .pill-select {
179 appearance: none; -webkit-appearance: none; 179 appearance: none; -webkit-appearance: none;
180 background: var(--card); color: var(--ink); 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 font-weight: 800; font-size: 13.5px; font-family: inherit; 182 font-weight: 800; font-size: 13.5px; font-family: inherit;
183 padding: 7px 26px 7px 13px; 183 padding: 7px 26px 7px 13px;
184 box-shadow: var(--bshadow-sm); 184 box-shadow: var(--bshadow-sm);
@@ -187,7 +187,7 @@ button { font-family: inherit; color: inherit; }
187 background-repeat: no-repeat; background-position: right 9px center; background-size: 12px; 187 background-repeat: no-repeat; background-position: right 9px center; background-size: 12px;
188 } 188 }
189 .chip { 189 .chip {
190− border: 1.5px solid var(--ink); border-radius: 999px; 190+ border: 1.5px solid var(--edge); border-radius: 999px;
191 background: var(--card2); font-weight: 700; font-size: 12.5px; 191 background: var(--card2); font-weight: 700; font-size: 12.5px;
192 padding: 7px 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; 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 .msg-user { 212 .msg-user {
213 align-self: flex-end; max-width: 86%; 213 align-self: flex-end; max-width: 86%;
214 background: var(--orange); color: var(--ink); 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 box-shadow: var(--bshadow-sm); 216 box-shadow: var(--bshadow-sm);
217 padding: 10px 15px; font-weight: 600; 217 padding: 10px 15px; font-weight: 600;
218 white-space: pre-wrap; word-break: break-word; 218 white-space: pre-wrap; word-break: break-word;
219 } 219 }
220 .msg-claude { 220 .msg-claude {
221 align-self: stretch; 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 box-shadow: var(--bshadow-sm); 223 box-shadow: var(--bshadow-sm);
224 padding: 13px 16px; word-break: break-word; 224 padding: 13px 16px; word-break: break-word;
225 } 225 }
@@ -227,7 +227,7 @@ button { font-family: inherit; color: inherit; }
227 .msg-claude .md > :last-child { margin-bottom: 0; } 227 .msg-claude .md > :last-child { margin-bottom: 0; }
228 .md p { margin: 0.55em 0; } 228 .md p { margin: 0.55em 0; }
229 .md pre { 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 padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5; 231 padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5;
232 } 232 }
233 .md code { font-family: var(--mono); font-size: 0.88em; } 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 /* --- cartes outils --- */ 262 /* --- cartes outils --- */
263 .tool { 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 overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm); 265 overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm);
266 } 266 }
267 .tool summary { 267 .tool summary {
@@ -271,7 +271,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
271 .tool summary::-webkit-details-marker { display: none; } 271 .tool summary::-webkit-details-marker { display: none; }
272 .tool .t-icon { 272 .tool .t-icon {
273 flex-shrink: 0; width: 28px; height: 28px; border-radius: 9px; 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 display: flex; align-items: center; justify-content: center; 275 display: flex; align-items: center; justify-content: center;
276 font-size: 13px; background: var(--card2); 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 .tool .t-state.pending { color: var(--warn); background: var(--warn-soft); } 288 .tool .t-state.pending { color: var(--warn); background: var(--warn-soft); }
289 .tool .t-state.ok { color: var(--good); background: var(--good-soft); } 289 .tool .t-state.ok { color: var(--good); background: var(--good-soft); }
290 .tool .t-state.err { color: var(--bad); background: var(--bad-soft); } 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 .tool-body pre { 292 .tool-body pre {
293 margin: 6px 0; background: var(--card); border: 1.5px solid var(--line); 293 margin: 6px 0; background: var(--card); border: 1.5px solid var(--line);
294 border-radius: var(--r-sm); padding: 9px; 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 .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); } 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 .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; } 302 .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; }
303−.diff .del { background: #ffe4e1; color: #8c1622; } 303+.diff .del { background: var(--del-bg); color: var(--del-ink); }
304−.diff .add { background: #ddf3e4; color: #0c5326; } 304+.diff .add { background: var(--add-bg); color: var(--add-ink); }
305 305
306 .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; } 306 .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; }
307 .todos li { padding: 3px 0; } 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 /* --- permission --- */ 311 /* --- permission --- */
312 .perm { 312 .perm {
313− background: var(--warn-soft); border: 2px solid var(--ink); 313+ background: var(--warn-soft); border: 2px solid var(--edge);
314 border-radius: var(--r-lg); padding: 14px; font-size: 13.5px; 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 .perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; } 317 .perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; }
318 .perm pre { 318 .perm pre {
@@ -325,7 +325,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
325 .perm-actions button { 325 .perm-actions button {
326 flex: 1; padding: 11px 6px; border-radius: 999px; 326 flex: 1; padding: 11px 6px; border-radius: 999px;
327 font-size: 13.5px; font-weight: 800; cursor: pointer; min-height: 46px; 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 .perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); } 330 .perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); }
331 .perm-always { background: var(--good-soft); color: var(--good); line-height: 1.05; } 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 .perm-deny { background: var(--card); color: var(--bad); } 333 .perm-deny { background: var(--card); color: var(--bad); }
334 .perm-allall { 334 .perm-allall {
335 width: 100%; margin-top: 8px; padding: 12px; border-radius: 999px; 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 font-weight: 800; font-size: 13.5px; cursor: pointer; box-shadow: var(--bshadow-sm); 337 font-weight: 800; font-size: 13.5px; cursor: pointer; box-shadow: var(--bshadow-sm);
338 } 338 }
339 .perm-allall:active { transform: translate(1px,1px); box-shadow: none; } 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 align-self: flex-start; 357 align-self: flex-start;
358 display: flex; align-items: center; gap: 10px; 358 display: flex; align-items: center; gap: 10px;
359 font-size: 13.5px; font-weight: 700; 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 border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm); 361 border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm);
362 } 362 }
363 .dots { display: flex; gap: 4px; } 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 .dots i:nth-child(2) { animation-delay: 0.14s; } 365 .dots i:nth-child(2) { animation-delay: 0.14s; }
366 .dots i:nth-child(3) { animation-delay: 0.28s; } 366 .dots i:nth-child(3) { animation-delay: 0.28s; }
367 @keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-4px); opacity: 1; } } 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 .welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; } 377 .welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; }
378 .hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; } 378 .hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; }
379 .hint { 379 .hint {
380− background: var(--card); border: 1.5px solid var(--ink); 380+ background: var(--card); border: 1.5px solid var(--edge);
381 font-size: 13px; font-weight: 700; 381 font-size: 13px; font-weight: 700;
382 border-radius: 999px; padding: 9px 14px; cursor: pointer; 382 border-radius: 999px; padding: 9px 14px; cursor: pointer;
383 box-shadow: var(--bshadow-sm); 383 box-shadow: var(--bshadow-sm);
@@ -393,7 +393,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
393 display: flex; align-items: flex-end; gap: 8px; 393 display: flex; align-items: flex-end; gap: 8px;
394 max-width: 780px; margin: 0 auto; 394 max-width: 780px; margin: 0 auto;
395 background: var(--card); 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 box-shadow: var(--bshadow); 397 box-shadow: var(--bshadow);
398 padding: 6px 6px 6px 6px; gap: 4px; 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 #input::placeholder { color: var(--muted); font-weight: 600; } 406 #input::placeholder { color: var(--muted); font-weight: 600; }
407 .send-btn { 407 .send-btn {
408 width: 42px; height: 42px; border-radius: 50%; 408 width: 42px; height: 42px; border-radius: 50%;
409− border: 2px solid var(--ink); 409+ border: 2px solid var(--edge);
410 background: var(--orange); color: var(--ink); 410 background: var(--orange); color: var(--ink);
411 cursor: pointer; flex-shrink: 0; 411 cursor: pointer; flex-shrink: 0;
412 display: flex; align-items: center; justify-content: center; 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 .send-btn.stop { background: var(--bad); color: #fff; } 419 .send-btn.stop { background: var(--bad); color: #fff; }
420 .wand-btn { 420 .wand-btn {
421 width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0; 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 cursor: pointer; display: flex; align-items: center; justify-content: center; 423 cursor: pointer; display: flex; align-items: center; justify-content: center;
424 align-self: flex-end; margin-bottom: 2px; 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 max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap; 431 max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap;
432 } 432 }
433 .slash-hint button { 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 font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer; 435 font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer;
436 } 436 }
437 437
438 /* ================= Sessions ================= */ 438 /* ================= Sessions ================= */
439 .sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; } 439 .sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; }
440 .sess-card { 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 box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer; 442 box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer;
443 display: flex; flex-direction: column; gap: 6px; 443 display: flex; flex-direction: column; gap: 6px;
444 } 444 }
445 .sess-top { display: flex; align-items: center; gap: 8px; } 445 .sess-top { display: flex; align-items: center; gap: 8px; }
446 .sess-title { flex: 1; min-width: 0; font-weight: 800; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 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 .sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; } 448 .sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; }
449 .sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); } 449 .sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); }
450 .sess-badge.idle { background: var(--card2); color: var(--muted); } 450 .sess-badge.idle { background: var(--card2); color: var(--muted); }
451 .batch-tag { background: var(--orange-soft); color: var(--orange-deep); border-radius: 999px; padding: 1px 8px; font-weight: 800; } 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 .sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); } 453 .sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); }
454 .sess-actions { display: flex; gap: 8px; margin-top: 4px; } 454 .sess-actions { display: flex; gap: 8px; margin-top: 4px; }
455 .sess-actions button { 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 font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer; 457 font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer;
458 } 458 }
459 .sess-actions .del { color: var(--bad); } 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 .sess-actions .p-send { background: var(--orange); color: var(--ink); border-color: var(--ink); box-shadow: var(--bshadow-sm); } 468 .sess-actions .p-send { background: var(--orange); color: var(--ink); border-color: var(--ink); box-shadow: var(--bshadow-sm); }
469 .pe-input { 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 border-radius: var(--r-md); padding: 11px 13px; font-size: 15px; font-family: inherit; font-weight: 600; outline: none; 471 border-radius: var(--r-md); padding: 11px 13px; font-size: 15px; font-family: inherit; font-weight: 600; outline: none;
472 } 472 }
473 .pe-input:focus { border-color: var(--orange); } 473 .pe-input:focus { border-color: var(--orange); }
474 .pe-textarea { 474 .pe-textarea {
475 width: 100%; margin-top: 10px; min-height: 180px; resize: vertical; 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 border-radius: var(--r-md); padding: 12px 13px; font-size: 14px; line-height: 1.5; 477 border-radius: var(--r-md); padding: 12px 13px; font-size: 14px; line-height: 1.5;
478 font-family: var(--mono); outline: none; 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 max-width: 780px; margin-left: auto; margin-right: auto; 490 max-width: 780px; margin-left: auto; margin-right: auto;
491 } 491 }
492 .eco-stat { 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 box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center; 494 box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center;
495 } 495 }
496 .eco-stat .v { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; } 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 @media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } } 504 @media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } }
505 .eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; } 505 .eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; }
506 .site-card { 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 box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer; 508 box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer;
509 display: flex; flex-direction: column; gap: 8px; 509 display: flex; flex-direction: column; gap: 8px;
510 transition: transform 0.08s; 510 transition: transform 0.08s;
@@ -513,7 +513,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
513 .site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); } 513 .site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); }
514 .site-top { display: flex; align-items: center; gap: 8px; } 514 .site-top { display: flex; align-items: center; gap: 8px; }
515 .site-name { font-weight: 800; font-size: 16px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 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 .st-pill.up { background: var(--good-soft); color: var(--good); } 517 .st-pill.up { background: var(--good-soft); color: var(--good); }
518 .st-pill.slow { background: var(--warn-soft); color: var(--warn); } 518 .st-pill.slow { background: var(--warn-soft); color: var(--warn); }
519 .st-pill.down { background: var(--bad); color: #fff; } 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 .eco-subhead { max-width: 780px; margin: 20px auto 10px; width: 100%; font-size: 15px; font-weight: 800; letter-spacing: -0.3px; } 530 .eco-subhead { max-width: 780px; margin: 20px auto 10px; width: 100%; font-size: 15px; font-weight: 800; letter-spacing: -0.3px; }
531 .node-grid { display: grid; grid-template-columns: 1fr; gap: 10px; max-width: 780px; margin: 0 auto; width: 100%; } 531 .node-grid { display: grid; grid-template-columns: 1fr; gap: 10px; max-width: 780px; margin: 0 auto; width: 100%; }
532 @media (min-width: 560px) { .node-grid { grid-template-columns: 1fr 1fr; } } 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 .node-card.warn { border-color: var(--warn); } 534 .node-card.warn { border-color: var(--warn); }
535 .node-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; } 535 .node-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
536 .node-name { font-weight: 800; font-size: 15px; font-family: var(--mono); } 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 .node-apps.bad { background: var(--warn-soft); color: var(--warn); } 538 .node-apps.bad { background: var(--warn-soft); color: var(--warn); }
539 .nbar { margin: 5px 0; } 539 .nbar { margin: 5px 0; }
540 .nbar-l { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 2px; } 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 .admin-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } 546 .admin-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
547 .admin-actions .btn-ghost { text-align: center; text-decoration: none; } 547 .admin-actions .btn-ghost { text-align: center; text-decoration: none; }
548 .admin-out { margin-top: 10px; } 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 .admin-load { color: var(--muted); font-weight: 700; font-size: 13px; } 550 .admin-load { color: var(--muted); font-weight: 700; font-size: 13px; }
551 .admin-ok { color: var(--good); font-weight: 800; } 551 .admin-ok { color: var(--good); font-weight: 800; }
552 .admin-err { color: var(--bad); font-weight: 800; } 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 @media (min-width: 560px) { .eco-overview { grid-template-columns: repeat(6, 1fr); } } 559 @media (min-width: 560px) { .eco-overview { grid-template-columns: repeat(6, 1fr); } }
560 .kpi { 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 box-shadow: var(--bshadow-sm); padding: 9px 8px; text-align: center; 562 box-shadow: var(--bshadow-sm); padding: 9px 8px; text-align: center;
563 } 563 }
564 .kpi .v { font-size: 21px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; } 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 .sheet-panel { 599 .sheet-panel {
600 position: absolute; left: 0; right: 0; bottom: 0; 600 position: absolute; left: 0; right: 0; bottom: 0;
601 max-height: 88dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; 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 border-radius: var(--r-xl) var(--r-xl) 0 0; 603 border-radius: var(--r-xl) var(--r-xl) 0 0;
604 padding: 8px 16px calc(20px + env(safe-area-inset-bottom)); 604 padding: 8px 16px calc(20px + env(safe-area-inset-bottom));
605 animation: sheet-up 0.22s ease-out; 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 .sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; } 607 .sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; }
608 .sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } 608 .sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
609 .sheet-h h3 { margin: 0; font-size: 21px; font-weight: 800; flex: 1; } 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 .sheet-sec h4 { margin: 0 0 9px; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); } 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 .kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; } 612 .kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; }
613 .kv b { font-family: var(--mono); font-weight: 700; } 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 /* ================= Réglages ================= */ 629 /* ================= Réglages ================= */
630 .set-card { 630 .set-card {
631 max-width: 780px; margin: 0 auto 12px; width: 100%; 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 box-shadow: var(--bshadow-sm); padding: 14px 16px; 633 box-shadow: var(--bshadow-sm); padding: 14px 16px;
634 } 634 }
635 .set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; } 635 .set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; }
636 .seg { display: flex; gap: 7px; } 636 .seg { display: flex; gap: 7px; }
637 .seg.vert { flex-direction: column; } 637 .seg.vert { flex-direction: column; }
638 .seg button { 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 background: var(--card2); font-weight: 700; font-size: 13.5px; 640 background: var(--card2); font-weight: 700; font-size: 13.5px;
641 padding: 10px 12px; cursor: pointer; text-align: center; 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 /* ---- Social ---- */ 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 .soc-card h3{margin:0 0 10px;font-size:15px;} 651 .soc-card h3{margin:0 0 10px;font-size:15px;}
652 .soc-switch{display:flex;align-items:center;gap:10px;font-weight:700;cursor:pointer;} 652 .soc-switch{display:flex;align-items:center;gap:10px;font-weight:700;cursor:pointer;}
653 .soc-switch input{width:20px;height:20px;accent-color:var(--accent,#f97316);} 653 .soc-switch input{width:20px;height:20px;accent-color:var(--accent,#f97316);}
654 .soc-sub{color:var(--muted,#6b7280);font-size:13px;margin-top:8px;} 654 .soc-sub{color:var(--muted,#6b7280);font-size:13px;margin-top:8px;}
655 .soc-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px;} 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 .soc-draft{margin-top:14px;display:flex;flex-direction:column;gap:10px;} 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;} 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(--ink,#1a1a1a);border-radius:10px;font:inherit;resize:vertical;} 659+#view-social textarea{width:100%;padding:10px 12px;border:2px solid var(--edge);border-radius:10px;font:inherit;resize:vertical;}
660 .soc-log{display:flex;flex-direction:column;gap:6px;max-height:340px;overflow-y:auto;} 660 .soc-log{display:flex;flex-direction:column;gap:6px;max-height:340px;overflow-y:auto;}
661 .soc-logrow{font-size:13px;padding:6px 8px;border-radius:8px;background:rgba(0,0,0,.04);} 661 .soc-logrow{font-size:13px;padding:6px 8px;border-radius:8px;background:rgba(0,0,0,.04);}
662 .soc-logt{color:var(--muted,#6b7280);font-variant-numeric:tabular-nums;margin-right:6px;} 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 @media(max-width:640px){.st-grid{grid-template-columns:1fr}} 669 @media(max-width:640px){.st-grid{grid-template-columns:1fr}}
670 .st-gen h3{margin:0 0 8px} 670 .st-gen h3{margin:0 0 8px}
671 .st-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:8px} 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 .st-media{position:relative;aspect-ratio:4/5;background:#0d0d0d;overflow:hidden} 673 .st-media{position:relative;aspect-ratio:4/5;background:#0d0d0d;overflow:hidden}
674 .st-media img,.st-media video{width:100%;height:100%;object-fit:cover;display:block} 674 .st-media img,.st-media video{width:100%;height:100%;object-fit:cover;display:block}
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} 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 /* ---- Studio loading ---- */ 681 /* ---- Studio loading ---- */
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)} 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 #stLoad.on{display:flex} 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 .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} 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 @keyframes stspin{to{transform:rotate(360deg)}} 686 @keyframes stspin{to{transform:rotate(360deg)}}
687 .stload-msg{font-weight:800;font-size:15px} 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 /* ---- Visiteurs ---- */ 693 /* ---- Visiteurs ---- */
694 #view-visitors{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:14px 14px calc(86px + env(safe-area-inset-bottom))} 694 #view-visitors{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:14px 14px calc(86px + env(safe-area-inset-bottom))}
695 .vis-global{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px} 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 .vg-n{font-size:26px;font-weight:900;color:var(--accent,#f97316)} 697 .vg-n{font-size:26px;font-weight:900;color:var(--accent,#f97316)}
698 .vg-l{font-size:11px;font-weight:700;color:var(--muted,#777);text-transform:uppercase;letter-spacing:.05em;margin-top:2px} 698 .vg-l{font-size:11px;font-weight:700;color:var(--muted,#777);text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
699 .vis-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px} 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 .vis-h{display:flex;align-items:center;gap:10px} 701 .vis-h{display:flex;align-items:center;gap:10px}
702 .vis-h img{width:26px;height:26px;border-radius:6px} 702 .vis-h img{width:26px;height:26px;border-radius:6px}
703 .vis-name{font-weight:800;font-size:15px;flex:1} 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 .vis-card:active{transform:scale(.98)} 718 .vis-card:active{transform:scale(.98)}
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} 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 #visDetail.on{display:flex} 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 .vd-head{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(127,127,127,.2)} 722 .vd-head{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(127,127,127,.2)}
723 .vd-head img{width:30px;height:30px;border-radius:8px} 723 .vd-head img{width:30px;height:30px;border-radius:8px}
724 .vd-head h3{flex:1;font-size:18px} 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 .vd-bar{flex:1;height:14px;background:rgba(127,127,127,.15);border-radius:7px;overflow:hidden} 735 .vd-bar{flex:1;height:14px;background:rgba(127,127,127,.15);border-radius:7px;overflow:hidden}
736 .vd-bar>div{height:100%;background:var(--accent,#f97316);border-radius:7px} 736 .vd-bar>div{height:100%;background:var(--accent,#f97316);border-radius:7px}
737 .vd-geoN{font-size:13px;font-weight:800;width:44px;text-align:right} 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. 1+// admin-ka — Analytics first-party v2 : trafic HUMAIN crédible par site KA.
2−// Un beacon (1px) sur chaque site envoie GET /collect?site=..&vid=..&p=.. 2+//
3−// On stocke les hits (SQLite), on compte les uniques (vid distincts) en live, 3+// Pipeline : beacon (v1 legacy 60 s, ou v2 ka-a.js pv/heartbeat) → /collect →
4−// et on fige un rollup quotidien pour l'historique long terme. 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 import fs from 'node:fs'; 16 import fs from 'node:fs';
6 import path from 'node:path'; 17 import path from 'node:path';
18+import crypto from 'node:crypto';
7 import { fileURLToPath } from 'node:url'; 19 import { fileURLToPath } from 'node:url';
8 import { DatabaseSync } from 'node:sqlite'; 20 import { DatabaseSync } from 'node:sqlite';
9 21
10 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 22 const __dirname = path.dirname(fileURLToPath(import.meta.url));
11 const DATA_DIR = path.join(__dirname, '..', 'data'); 23 const DATA_DIR = path.join(__dirname, '..', 'data');
12 const DB_PATH = path.join(DATA_DIR, 'analytics.sqlite'); 24 const DB_PATH = path.join(DATA_DIR, 'analytics.sqlite');
25+const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
13 26
14 let db = null; 27 let db = null;
15−const RETENTION_DAYS = 45;
16 28
17 // GIF transparent 1x1 renvoyé au beacon 29 // GIF transparent 1x1 renvoyé au beacon
18 export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); 30 export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
19 31
20−function dayKey(ts) { 32+// ---------- configuration (surchageable via config.json → "analytics") ----------
21− // jour local (Amérique/Montréal ~ heure du serveur) 33+export const DEFAULTS = {
22− const d = new Date(ts); 34+ sessionMin: 30, // minutes d'inactivité avant nouvelle session
23− return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); 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 export function initAnalytics() { 113 export function initAnalytics() {
27 fs.mkdirSync(DATA_DIR, { recursive: true }); 114 fs.mkdirSync(DATA_DIR, { recursive: true });
115+ loadCfg();
28 db = new DatabaseSync(DB_PATH); 116 db = new DatabaseSync(DB_PATH);
29 db.exec(` 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 CREATE TABLE IF NOT EXISTS hits (site TEXT, vid TEXT, ts INTEGER, path TEXT); 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 CREATE TABLE IF NOT EXISTS daily (site TEXT, day TEXT, uniques INTEGER, pv INTEGER, PRIMARY KEY(site, day)); 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 rollup(); 149 rollup();
37− setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes 150+ setInterval(rollup, 10 * 60 * 1000);
38− setInterval(purge, 6 * 60 * 60 * 1000); // purge le brut ancien 151+ setInterval(purge, 6 * 60 * 60 * 1000);
39− setInterval(resolveGeo, 60 * 1000); // géolocalise les nouvelles IP 152+ setInterval(resolveIps, 30 * 1000);
40− purge(); resolveGeo(); 153+ setInterval(() => { ipHourly.clear(); }, 60 * 60 * 1000); // fenêtre anti-flood glissante grossière
41−} 154+ purge(); resolveIps();
42− 155+}
43−// enregistre un hit (appelé par /collect) 156+
44−export function recordHit(site, vid, p, ip) { 157+export function migratedAt() {
45− if (!db || !site || !vid) return; 158+ const r = db.prepare('SELECT value FROM meta WHERE key=?').get('migrated_at');
46− site = String(site).slice(0, 40); 159+ return r ? Number(r.value) : null;
47− vid = String(vid).slice(0, 64); 160+}
48− p = String(p || '/').slice(0, 200); 161+
49− ip = (String(ip || '').split(',')[0] || '').trim().slice(0, 45); 162+// ---------- ingestion ----------
50− try { db.prepare('INSERT INTO hits (site,vid,ts,path,ip) VALUES (?,?,?,?,?)').run(site, vid, Date.now(), p, ip); } catch {} 163+// état de session en mémoire pour le beacon v1 (pas de sid côté client)
51−} 164+const sessState = new Map(); // site|vid -> { sid, lastTs, lastPath }
52− 165+const ipHourly = new Map(); // ip -> compteur d'événements (fenêtre ~1 h)
53−// --- géolocalisation des IP (ip-api.com, gratuit, mis en cache) --- 166+let insEv = null;
54−let geoBusy = false; 167+
55−async function resolveGeo() { 168+function getIpRow(ip) {
56− if (!db || geoBusy) return; 169+ try { return db.prepare('SELECT * FROM ipinfo WHERE ip=?').get(ip) || null; } catch { return null; }
57− geoBusy = true; 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 try { 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 for (const r of rows) { 233 for (const r of rows) {
61 const ip = r.ip; 234 const ip = r.ip;
62− if (/^(127\.|10\.|192\.168\.|::1|172\.(1[6-9]|2\d|3[01])\.)/.test(ip) || ip === 'localhost') { 235+ if (INTERNAL_IP_RE.test(ip)) {
63− db.prepare('INSERT OR REPLACE INTO geo (ip,label,country,ts) VALUES (?,?,?,?)').run(ip, 'Local', 'Local', Date.now()); 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 continue; 238 continue;
65 } 239 }
66 try { 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 const d = await res.json(); 244 const d = await res.json();
69− let label = 'Inconnu', country = '';
70 if (d && d.status === 'success') { 245 if (d && d.status === 'success') {
71− country = d.country || ''; 246+ const asn = Number((String(d.as || '').match(/^AS(\d+)/) || [])[1] || 0);
72− label = [d.city, d.regionName].filter(Boolean).join(', ') || d.country || 'Inconnu'; 247+ db.prepare('INSERT OR REPLACE INTO ipinfo (ip,asn,org,isp,cc,country,region,city,hosting,proxy,mobile,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
73− if (d.countryCode && d.countryCode !== 'CA') label = (d.city ? d.city + ', ' : '') + (d.country || ''); 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()); 255+ } catch { break; } // réseau indisponible — on réessaiera au prochain cycle
76− } catch { 256+ await new Promise(r2 => setTimeout(r2, 1500)); // respecter la limite ip-api
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
80 } 257 }
81 } catch {} 258 } catch {}
82− geoBusy = false; 259+ ipBusy = false;
83 } 260 }
84 261
85−// top emplacements (visiteurs uniques) d'un site 262+// une IP vient d'être résolue : reclasse rétroactivement ses événements
86−export function analyticsGeo(site, days = 30) { 263+// provisoirement « human » si le réseau est un datacenter/proxy/ASN exclu
87− try { 264+function reclassifyIp(ip) {
88− return db.prepare(` 265+ const row = getIpRow(ip);
89− SELECT g.label label, COUNT(DISTINCT h.vid) c 266+ if (!row) return;
90− FROM hits h JOIN geo g ON g.ip=h.ip 267+ const isDc = row.hosting || row.proxy || cfg.blockedASNs.includes(row.asn) || DC_ORG_RE.test((row.org || '') + ' ' + (row.isp || ''));
91− WHERE h.site=? AND h.ts>=? AND g.label IS NOT NULL AND g.label<>'Local' 268+ if (isDc) {
92− GROUP BY g.label ORDER BY c DESC LIMIT 8`).all(site, Date.now() - days * 864e5); 269+ try { db.prepare(`UPDATE events SET cls='datacenter' WHERE ip=? AND cls='human'`).run(ip); } catch {}
93− } catch { return []; } 270+ }
94 } 271 }
95 272
273+// ---------- entretien ----------
96 function purge() { 274 function purge() {
97 if (!db) return; 275 if (!db) return;
98− const cutoff = Date.now() - RETENTION_DAYS * 864e5; 276+ const cutoff = Date.now() - cfg.retentionDays * 864e5;
99− try { db.prepare('DELETE FROM hits WHERE ts < ?').run(cutoff); } catch {} 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 function rollup() { 294 function rollup() {
104 if (!db) return; 295 if (!db) return;
105 try { 296 try {
106− const rows = db.prepare(` 297+ const since = dayStart(2);
107− SELECT site, ts, vid FROM hits WHERE ts >= ?`).all(Date.now() - 3 * 864e5); 298+ const rows = db.prepare('SELECT site, ts, vid, sid, ev, cls FROM events WHERE ts >= ?').all(since);
108− const agg = {}; // site|day -> {set, pv} 299+ const agg = {}; // site|day -> accumulateurs
109 for (const r of rows) { 300 for (const r of rows) {
110 const k = r.site + '|' + dayKey(r.ts); 301 const k = r.site + '|' + dayKey(r.ts);
111− if (!agg[k]) agg[k] = { set: new Set(), pv: 0 }; 302+ let a = agg[k];
112− agg[k].set.add(r.vid); agg[k].pv++; 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 } catch {} 316 } catch {}
117 } 317 }
118 318
119−function uniquesSince(site, sinceTs) { 319+// ---------- requêtes ----------
120− try { return db.prepare('SELECT COUNT(DISTINCT vid) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; } 320+const H = `cls='human'`;
121−} 321+
122−function pvSince(site, sinceTs) { 322+function q1(sql, ...args) { try { return db.prepare(sql).get(...args); } catch { return null; } }
123− try { return db.prepare('SELECT COUNT(*) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; } 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 export function analyticsSummary(sites) { 330 export function analyticsSummary(sites) {
127 const now = Date.now(); 331 const now = Date.now();
128− const startToday = new Date(); startToday.setHours(0, 0, 0, 0); 332+ const t0 = dayStart(0);
129− const t0 = startToday.getTime(); 333+ const y0 = dayStart(1);
334+ const activeCut = now - cfg.activeSec * 1000;
130 const out = []; 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 for (const s of sites) { 337 for (const s of sites) {
133 const site = s.app || s; 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 const row = { 348 const row = {
135 site, label: s.label || site, domain: s.domain || '', 349 site, label: s.label || site, domain: s.domain || '',
136− online: uniquesSince(site, now - 5 * 60 * 1000), // uniques dans les 5 dernières min 350+ online: q1(`SELECT COUNT(DISTINCT vid) c FROM events WHERE site=? AND ${H} AND ts>=?`, site, activeCut)?.c || 0,
137− today: uniquesSince(site, t0), 351+ today: t.uv || 0,
138− d7: uniquesSince(site, now - 7 * 864e5), 352+ yesterdaySame: humanUniques(site, y0, y0 + (now - t0)), // hier à la même heure (delta honnête)
139− d30: uniquesSince(site, now - 30 * 864e5), 353+ sessions: t.ss || 0,
140− pv_today: pvSince(site, t0), 354+ pv: t.pv || 0,
141− pv_total: pvSince(site, now - RETENTION_DAYS * 864e5), 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 out.push(row); 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 out.sort((a, b) => b.online - a.online || b.today - a.today); 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 export function analyticsSeries(site, days = 30) { 370 export function analyticsSeries(site, days = 30) {
152 const out = []; 371 const out = [];
372+ const mig = migratedAt();
373+ const migDay = mig ? dayKey(mig) : null;
374+ const isAll = site === '_all';
153 for (let i = days - 1; i >= 0; i--) { 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); 376+ const start = dayStart(i);
155− const key = dayKey(d.getTime()); 377+ const key = dayKey(start);
156− let u = 0; 378+ let r;
157− try { const r = db.prepare('SELECT uniques FROM daily WHERE site=? AND day=?').get(site, key); if (r) u = r.uniques; } catch {} 379+ if (isAll) {
158− // aujourd'hui : recalcule en direct (le rollup peut être en retard) 380+ r = q1('SELECT SUM(humans) humans, SUM(sessions) sessions, SUM(pv) pv, SUM(crawler+datacenter+automation+internal) other FROM daily2 WHERE day=?', key);
159− if (i === 0) u = Math.max(u, uniquesSince(site, d.getTime())); 381+ } else {
160− out.push({ day: key, uniques: u }); 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 return out; 538 return out;
163 } 539 }
modified server/server.js +19 −6
@@ -11,7 +11,7 @@ import { spawn } from 'node:child_process';
11 import { fileURLToPath } from 'node:url'; 11 import { fileURLToPath } from 'node:url';
12 import { WebSocketServer } from 'ws'; 12 import { WebSocketServer } from 'ws';
13 import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js'; 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 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'; 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 import { execFile } from 'node:child_process'; 16 import { execFile } from 'node:child_process';
17 17
@@ -494,9 +494,9 @@ const server = http.createServer(async (req, res) => {
494 494
495 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); } 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 if (p === '/collect') { 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 res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' }); 500 res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' });
501 return res.end(PIXEL); 501 return res.end(PIXEL);
502 } 502 }
@@ -581,7 +581,7 @@ const server = http.createServer(async (req, res) => {
581 // --- API authentifiée --- 581 // --- API authentifiée ---
582 if (p.startsWith('/api/')) { 582 if (p.startsWith('/api/')) {
583 if (!getSession(req)) return json(res, 401, { error: 'auth' }); 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 if (p === '/api/projects') return json(res, 200, { projects: listProjects() }); 585 if (p === '/api/projects') return json(res, 200, { projects: listProjects() });
586 if (p === '/api/chats' && req.method === 'GET') { 586 if (p === '/api/chats' && req.method === 'GET') {
587 const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat); 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 title: (String(body.title || '').trim() || text.slice(0, 48)), 611 title: (String(body.title || '').trim() || text.slice(0, 48)),
612 text, 612 text,
613 project: findProject(body.project) ? body.project : null, 613 project: findProject(body.project) ? body.project : null,
614+ fav: !!body.fav, uses: 0,
614 created: Date.now(), updated: Date.now(), 615 created: Date.now(), updated: Date.now(),
615 }; 616 };
616 prompts.push(pr); savePrompts(); 617 prompts.push(pr); savePrompts();
@@ -625,6 +626,8 @@ const server = http.createServer(async (req, res) => {
625 if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim(); 626 if (typeof body.text === 'string' && body.text.trim()) pr.text = body.text.trim();
626 if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48); 627 if (typeof body.title === 'string') pr.title = body.title.trim() || pr.text.slice(0, 48);
627 if ('project' in body) pr.project = findProject(body.project) ? body.project : null; 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 pr.updated = Date.now(); savePrompts(); 631 pr.updated = Date.now(); savePrompts();
629 return json(res, 200, { prompt: pr }); 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 if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES)); 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) }); 654+ if (p.startsWith('/api/analytics/series/')) return json(res, 200, analyticsSeries(p.split('/')[4], 30));
652− if (p.startsWith('/api/analytics/geo/')) return json(res, 200, { geo: analyticsGeo(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 if (p === '/api/eco/summary') return json(res, 200, ecoSummary()); 666 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());
654 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() }); 667 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });
655 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes()); 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
Menu

Theme