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