SPB Git

spb/admin-ka

Public

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

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

8 changed files +1,777 −435

modified CLAUDE.md +13 −0
@@ -47,3 +47,16 @@ node server/set-password.js '<nouveau>' # changer le mot de passe
47 ``` 47 ```
48 48
49 Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`. 49 Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`.
50+
51+## v3 (2026-08-26) — Control Center : refonte UI + analytics humains
52+
53+**UI** : desktop-first — sidebar gauche (≥1020 px) + topbar (titre, incidents, thème, statut) ; bottom nav mobile réduite à 5 (Vue d'ensemble, Claude Code, Écosystème, Visiteurs, Plus). Nouvelle page **Vue d'ensemble** (KPIs humains+éco+Claude, chart 30 j, chips écosystème, incidents, anomalies). **Dark mode** réel (`ka_theme` auto/clair/sombre, variables `--edge/--shadow/--code-bg/...`). Sessions et Prompts en listes denses filtrables (recherche/app/statut ; favoris ★, duplication, compteur d'usage sur les prompts).
54+
55+**Analytics v2 (`server/analytics.js`) — définitions officielles** :
56+- **Visiteur humain** : vid classé `human` = UA navigateur + JS exécuté + réseau non-datacenter/proxy + pas interne + pas de flood.
57+- **Visiteur unique** : vid humain distinct dans la fenêtre. **Session** : trou > 30 min = nouvelle session (`sessionMin`). **Page vue** : événement `pv` (navigation réelle, SPA incluse) — un heartbeat n'est JAMAIS une page vue. **En ligne** : dernier événement humain < 120 s (`activeSec`) — expire tout seul.
58+- **Classes** : `human / crawler / datacenter / automation / internal` — classées à l'insertion (UA regex + cache `ipinfo` ip-api avec `hosting/proxy/ASN`, reclassement rétroactif quand l'IP se résout, anti-flood `maxIpPerHour`). Les métriques principales n'affichent QUE les humains ; le reste est visible dans la barre de répartition et le toggle « Tout le trafic ».
59+- Tables : `events` (brut, rétention 45 j), `ipinfo` (cache IP-intelligence), `daily2` (rollup quotidien par classe, long terme), `meta.migrated_at` (frontière legacy). Les anciennes `hits/daily` sont CONSERVÉES et affichées en pointillé « ancien comptage v1 (non filtré) » — ne jamais comparer directement les deux séries.
60+- **Beacon v2** : `public/ka-a.js` (vid/sid localStorage, pv SPA via pushState, heartbeat 25 s seulement onglet visible + interaction < 60 s). Le beacon v1 (ping 60 s) reste supporté : le serveur dé-duplique (même page ~60 s = heartbeat) et sessionne en mémoire.
61+- API : `/api/analytics/{summary,series/:site|_all,site/:site,realtime,anomalies,inspect?ip=|vid=,classes,config(GET/PUT)}`. Config runtime dans `config.json → analytics` (fenêtres, IP/UA internes, bots custom, ASNs exclus) — éditable dans Réglages.
62+- Tests : `node server/test-analytics.mjs` (28 scénarios : humain/refresh/SPA, Googlebot, uptime bot, IP AWS, curl, interne, dédup v1, invariants uniques≤sessions≤pv, expiration en ligne) — à lancer depuis une COPIE (écrit dans data/).
modified public/app.js +530 −120
@@ -50,96 +50,392 @@
50 } 50 }
51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; } 51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }
52 52
53− // ---------- navigation ---------- 53+ // ---------- navigation (sidebar desktop + bottom nav mobile) ----------
54− const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'visitors', 'settings']; 54+ const I = {
55+ home: '<svg viewBox="0 0 24 24"><path d="M4 11l8-7 8 7v9a1.5 1.5 0 01-1.5 1.5H15v-6h-6v6H5.5A1.5 1.5 0 014 20z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',
56+ chat: '<svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',
57+ sessions: '<svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>',
58+ prompts: '<svg viewBox="0 0 24 24"><path d="M7 3h10a2 2 0 012 2v16l-7-4-7 4V5a2 2 0 012-2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',
59+ eco: '<svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>',
60+ visitors: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M3.5 19a5.5 5.5 0 0111 0" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/><path d="M16 5.2a3 3 0 010 5.6M18 19a5.5 5.5 0 00-3-4.9" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>',
61+ social: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H4z M8 20h8" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 8l5 2.5-5 2.5z" fill="currentColor"/></svg>',
62+ settings: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
63+ more: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.8" fill="currentColor"/><circle cx="12" cy="12" r="1.8" fill="currentColor"/><circle cx="19" cy="12" r="1.8" fill="currentColor"/></svg>',
64+ };
65+ const NAV = [
66+ { id: 'overview', label: 'Vue d’ensemble', icon: I.home },
67+ { id: 'chat', label: 'Claude Code', icon: I.chat },
68+ { id: 'sessions', label: 'Sessions', icon: I.sessions },
69+ { id: 'prompts', label: 'Prompts', icon: I.prompts },
70+ { id: 'eco', label: 'Écosystème', icon: I.eco },
71+ { id: 'visitors', label: 'Visiteurs', icon: I.visitors },
72+ { id: 'social', label: 'Studio', icon: I.social },
73+ { id: 'settings', label: 'Réglages', icon: I.settings },
74+ ];
75+ const views = NAV.map(n => n.id);
76+ const TITLES = Object.fromEntries(NAV.map(n => [n.id, n.label]));
77+ function navBtnHTML(n) {
78+ return `<button data-view="${n.id}" class="nav-btn">${n.icon}<span>${n.label}</span><i class="badge hidden"></i></button>`;
79+ }
80+ $('sideNav').innerHTML = NAV.map(navBtnHTML).join('');
81+ const MOBILE_NAV = ['overview', 'chat', 'eco', 'visitors'];
82+ $('bottomNav').innerHTML = NAV.filter(n => MOBILE_NAV.includes(n.id)).map(navBtnHTML).join('')
83+ + `<button class="nav-btn" id="navMore">${I.more}<span>Plus</span><i class="badge hidden"></i></button>`;
84+ $('navMore').onclick = () => {
85+ openSheet(`<div class="sheet-h"><h3>Navigation</h3></div><div class="more-nav">${
86+ NAV.filter(n => !MOBILE_NAV.includes(n.id)).map(n => `<button class="more-btn" data-view="${n.id}">${n.icon}<span>${n.label}</span></button>`).join('')
87+ }</div>`);
88+ document.querySelectorAll('.more-btn').forEach(b => { b.onclick = () => { closeSheet(); switchView(b.dataset.view); }; });
89+ };
90+
55 function switchView(name) { 91 function switchView(name) {
56 curView = name; 92 curView = name;
57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name); 93 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);
94+ const pt = $('pageTitle'); if (pt) pt.textContent = TITLES[name] || name;
58 document.querySelectorAll('.nav-btn').forEach((b) => { 95 document.querySelectorAll('.nav-btn').forEach((b) => {
59 b.classList.toggle('active', b.dataset.view === name); 96 b.classList.toggle('active', b.dataset.view === name);
60− if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden'); 97+ if (b.dataset.view === name) { const bd = b.querySelector('.badge'); if (bd) bd.classList.add('hidden'); }
61 }); 98 });
99+ if (name === 'overview' && window.startOverview) window.startOverview();
62 if (name === 'sessions') loadSessions(); 100 if (name === 'sessions') loadSessions();
63 if (name === 'prompts') loadPrompts(); 101 if (name === 'prompts') loadPrompts();
64 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); } 102 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }
65 if (name === 'social' && window.__loadGallery) window.__loadGallery(); 103 if (name === 'social' && window.__loadGallery) window.__loadGallery();
66 if (name === 'visitors' && window.startVisitors) window.startVisitors(); 104 if (name === 'visitors' && window.startVisitors) window.startVisitors();
67− if (name === 'settings') refreshSettingsView(); 105+ if (name === 'settings') { refreshSettingsView(); if (window.loadAnCfg) window.loadAnCfg(); }
68 } 106 }
69− document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); }); 107+ document.addEventListener('click', (e) => {
108+ const nb = e.target.closest('.nav-btn[data-view]');
109+ if (nb) { switchView(nb.dataset.view); return; }
110+ const gl = e.target.closest('[data-goto]');
111+ if (gl) switchView(gl.dataset.goto);
112+ });
70 113
71−// ===== VISITORS_TAB ===== 114+ // ---------- thème (auto / clair / sombre) ----------
72−(function(){ 115+ function applyTheme() {
116+ const t = localStorage.getItem('ka_theme') || 'auto';
117+ const dark = t === 'dark' || (t === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
118+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
119+ const meta = document.querySelector('meta[name="theme-color"]');
120+ if (meta) meta.content = dark ? '#16130e' : '#F2F1EC';
121+ document.querySelectorAll('#segTheme button').forEach(b => b.classList.toggle('on', b.dataset.v === t));
122+ }
123+ document.querySelectorAll('#segTheme button').forEach(b => { b.onclick = () => { localStorage.setItem('ka_theme', b.dataset.v); applyTheme(); }; });
124+ $('themeBtn').onclick = () => {
125+ const cur = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
126+ localStorage.setItem('ka_theme', cur === 'dark' ? 'light' : 'dark');
127+ applyTheme();
128+ };
129+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
130+ applyTheme();
131+
132+// ===== VISITEURS v2 — trafic humain crédible =====
133+(function () {
73 const $ = (id) => document.getElementById(id); 134 const $ = (id) => document.getElementById(id);
74− let timer=null; 135+ let timer = null, mode = 'human', lastSummary = null;
75− function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); } 136+ const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' ');
76− function card(s){ 137+ const DEV_LABEL = { m: '📱 mobile', t: '💻 tablette', d: '🖥️ desktop', '?': '—' };
77− return '<div class="vis-card" data-site="'+s.site+'" data-label="'+(s.label||s.site)+'">' 138+ const CLS_LABEL = { human: 'Humains', crawler: 'Crawlers', datacenter: 'Datacenter', automation: 'Automation', internal: 'Interne' };
78− + '<div class="vis-h"><img src="/icons/'+s.site+'"><div class="vis-name">'+(s.label||s.site)+'</div><span class="vis-dot'+(s.online>0?' on':'')+'"></span></div>' 139+ const CLS_COLOR = { human: 'var(--good)', crawler: '#6a9fd8', datacenter: 'var(--warn)', automation: '#a08cc9', internal: 'var(--muted)' };
79− + '<div class="vis-online"><span class="vis-dot big'+(s.online>0?' on':'')+'"></span><span class="vis-onN">'+nf(s.online)+'</span><span class="vis-onL">en ligne</span></div>' 140+
80− + '<div class="vis-row">' 141+ function delta(cur, prev) {
81− + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">jour</div></div>' 142+ if (!prev) return '';
82− + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7j</div></div>' 143+ const d = Math.round((cur - prev) / prev * 100);
83− + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30j</div></div>' 144+ if (!isFinite(d) || Math.abs(d) < 1) return '<span class="kdelta flat">≈ hier</span>';
84− + '</div>' 145+ return `<span class="kdelta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '+' : ''}${d}&nbsp;% vs hier</span>`;
85− + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues · voir détail →</div></div>'; 146+ }
86− } 147+ function kpi(v, l, extra, accent) {
87− async function loadVisitors(){ 148+ return `<div class="kpi2${accent ? ' accent' : ''}"><div class="kv2">${v}</div><div class="kl2">${l}</div>${extra || ''}</div>`;
88− try{ 149+ }
89− const d=await (await fetch('/api/analytics/summary')).json(); const g=d.global||{}; 150+ function renderGlobal(d) {
90− if($('visLive')) $('visLive').innerHTML='<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' aujourd&#39;hui'; 151+ const g = d.global || {};
91− if($('visGlobal')) $('visGlobal').innerHTML= 152+ $('visLive').innerHTML = '<span class="vis-dot on"></span> ' + nf(g.online) + ' humain' + (g.online > 1 ? 's' : '') + ' en ligne';
92− '<div class="vg-item"><div class="vg-n">'+nf(g.online)+'</div><div class="vg-l">en ligne</div></div>' 153+ const all = mode === 'all';
93− +'<div class="vg-item"><div class="vg-n">'+nf(g.today)+'</div><div class="vg-l">aujourd&#39;hui</div></div>' 154+ const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0);
94− +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>' 155+ $('visGlobal').innerHTML =
95− +'<div class="vg-item"><div class="vg-n">'+nf(g.pv_today)+'</div><div class="vg-l">pages vues</div></div>'; 156+ kpi(nf(g.online), 'en ligne (actifs < 2 min)', '', true)
96− if($('visGrid')){ $('visGrid').innerHTML=(d.sites||[]).map(card).join(''); 157+ + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame))
97− $('visGrid').querySelectorAll('.vis-card').forEach(function(c){ c.onclick=function(){ openDetail(c.dataset.site,c.dataset.label); }; }); } 158+ + kpi(nf(g.sessions), 'sessions')
98− }catch(e){} 159+ + kpi(nf(g.pv), 'pages vues')
160+ + kpi(nf(g.d7), '7 jours')
161+ + kpi(nf(g.d30), '30 jours')
162+ + (all ? kpi(nf(g.total_vids), 'visiteurs (toutes classes)') + kpi(nf(autoHits), 'hits automatisés') : '');
163+ // barre de répartition des classes (événements du jour)
164+ const seg = [['human', g.humanHits], ['crawler', g.crawler], ['datacenter', g.datacenter], ['automation', g.automation], ['internal', g.internal]]
165+ .map(([k, v]) => [k, v || 0]);
166+ const tot = seg.reduce((a, [, v]) => a + v, 0) || 1;
167+ $('visBreak').innerHTML = `
168+ <div class="break-bar">${seg.map(([k, v]) => v ? `<i style="width:${(v / tot * 100).toFixed(2)}%;background:${CLS_COLOR[k]}" title="${CLS_LABEL[k]} : ${nf(v)}"></i>` : '').join('')}</div>
169+ <div class="legend">${seg.map(([k, v]) => `<span><i class="sw" style="background:${CLS_COLOR[k]}"></i>${CLS_LABEL[k]} ${nf(v)}</span>`).join('')}
170+ <span class="panel-note">événements du jour — les métriques principales n’affichent que les humains</span></div>`;
171+ }
172+
173+ function siteCard(s) {
174+ const all = mode === 'all';
175+ const autoHits = (s.crawler || 0) + (s.datacenter || 0) + (s.automation || 0) + (s.internal || 0);
176+ return `<div class="vis-card" data-site="${s.site}" data-label="${s.label || s.site}">
177+ <div class="vis-h"><img src="/icons/${s.site}" onerror="this.style.display='none'"><div class="vis-name">${s.label || s.site}</div>
178+ <span class="vis-on-pill${s.online > 0 ? ' on' : ''}">${nf(s.online)} en ligne</span></div>
179+ <div class="vis-row">
180+ <div class="vis-m"><div class="vm-n">${nf(s.today)}</div><div class="vm-l">humains</div></div>
181+ <div class="vis-m"><div class="vm-n">${nf(s.sessions)}</div><div class="vm-l">sessions</div></div>
182+ <div class="vis-m"><div class="vm-n">${nf(s.pv)}</div><div class="vm-l">pages</div></div>
183+ <div class="vis-m"><div class="vm-n">${nf(s.d7)}</div><div class="vm-l">7 j</div></div>
184+ </div>
185+ ${all ? `<div class="vis-pv">🤖 ${nf(autoHits)} hits automatisés (crawler ${nf(s.crawler)} · dc ${nf(s.datacenter)} · autom. ${nf(s.automation)} · interne ${nf(s.internal)})</div>`
186+ : `<div class="vis-pv">${delta(s.today, s.yesterdaySame) || '&nbsp;'}<span class="vis-more">détail →</span></div>`}
187+ </div>`;
188+ }
189+
190+ async function loadVisitors() {
191+ try {
192+ const d = await (await fetch('/api/analytics/summary')).json();
193+ lastSummary = d;
194+ renderGlobal(d);
195+ $('visGrid').innerHTML = (d.sites || []).map(siteCard).join('');
196+ $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); });
197+ } catch {}
198+ loadRealtime(); loadAnoms();
199+ }
200+
201+ async function loadRealtime() {
202+ try {
203+ const d = await (await fetch('/api/analytics/realtime')).json();
204+ $('rtCount').textContent = d.active.length + ' actif' + (d.active.length > 1 ? 's' : '') + ' · fenêtre ' + d.activeSec + ' s';
205+ if (!d.active.length) { $('visRealtime').innerHTML = '<div class="soc-sub">Personne en ce moment — le compteur expire tout seul (~2 min).</div>'; return; }
206+ $('visRealtime').innerHTML = d.active.slice(0, 30).map(a => {
207+ const dur = Math.max(0, Math.round((Date.now() - (a.since || a.lastTs)) / 60000));
208+ return `<div class="rt-row">
209+ <img src="/icons/${a.site}" onerror="this.style.display='none'">
210+ <span class="rt-site">${a.label}</span>
211+ <span class="rt-path mono" title="${esc(a.path || '/')}">${esc((a.path || '/').slice(0, 42))}</span>
212+ <span class="rt-loc">${esc(a.loc)}${a.cc && a.cc !== 'CA' && a.cc !== 'XX' ? ' · ' + a.cc : ''}</span>
213+ <span class="rt-dev">${DEV_LABEL[a.dev] || ''}</span>
214+ <span class="rt-dur mono">${dur < 1 ? '<1 min' : dur + ' min'}</span>
215+ </div>`;
216+ }).join('');
217+ } catch {}
218+ }
219+
220+ async function loadAnoms() {
221+ try {
222+ const d = await (await fetch('/api/analytics/anomalies')).json();
223+ const bits = [];
224+ if (d.datacenter?.surge) bits.push(`<div class="anom warn">▲ Poussée datacenter : ${nf(d.datacenter.lastHour)} hits/h (moy. ${nf(d.datacenter.hourlyAvg)})</div>`);
225+ for (const s of d.spikes || []) bits.push(`<div class="anom warn">▲ Pic sur <b>${esc(s.site)}</b> : ${nf(s.lastHour)} evt/h (×${s.factor})</div>`);
226+ for (const ip of (d.topIps || []).slice(0, 5)) bits.push(`<div class="anom"><span class="mono">${esc(ip.ip)}</span> · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} · classe <b>${esc(ip.cls)}</b> <button class="linklike" data-ip="${esc(ip.ip)}">inspecter</button></div>`);
227+ $('visAnom').innerHTML = bits.length ? bits.join('') : '<div class="soc-sub">● Rien à signaler — trafic dans les normes.</div>';
228+ $('visAnom').querySelectorAll('[data-ip]').forEach(b => { b.onclick = () => inspect(b.dataset.ip); });
229+ } catch {}
230+ }
231+
232+ async function inspect(q) {
233+ const val = q || prompt('IP ou visitor-id à inspecter :');
234+ if (!val) return;
235+ const isIp = /^[\d.:a-f]+$/i.test(val) && val.includes('.') || val.includes(':');
236+ const r = await fetch('/api/analytics/inspect?' + (isIp ? 'ip=' : 'vid=') + encodeURIComponent(val));
237+ const d = await r.json();
238+ const ipi = d.ipinfo;
239+ openSheet(`<div class="sheet-h"><h3>🔬 Inspecteur de trafic</h3></div>
240+ <div class="sheet-sec">
241+ <div class="kv"><span>Classe stockée</span><b>${esc(d.storedClass || '—')}</b></div>
242+ <div class="kv"><span>IP</span><b>${esc(d.ip || '—')}</b></div>
243+ ${ipi ? `<div class="kv"><span>Réseau</span><b>AS${ipi.asn} · ${esc(ipi.org || ipi.isp || '?')}</b></div>
244+ <div class="kv"><span>Lieu</span><b>${esc([ipi.city, ipi.region, ipi.country].filter(Boolean).join(', ') || '—')}</b></div>
245+ <div class="kv"><span>Drapeaux</span><b>${ipi.hosting ? 'hosting ' : ''}${ipi.proxy ? 'proxy ' : ''}${ipi.mobile ? 'mobile ' : ''}${!ipi.hosting && !ipi.proxy ? 'résidentiel' : ''}</b></div>` : '<div class="soc-sub">IP pas encore résolue (file ip-api)</div>'}
246+ </div>
247+ <div class="sheet-sec"><h4>Raisons de la classification</h4>
248+ ${(d.reasons || []).map(x => `<div class="inc-item"><span>·</span><span>${esc(x)}</span></div>`).join('')}
249+ </div>
250+ <div class="sheet-sec"><h4>Événements récents (${(d.events || []).length})</h4>
251+ ${(d.events || []).slice(0, 20).map(e2 => `<div class="inc-item mono" style="font-size:11px"><span>${e2.ev}</span><span>${new Date(e2.ts).toLocaleTimeString('fr-CA')} · ${esc(e2.site)} · ${esc(e2.path)} · ${esc(e2.cls)}</span></div>`).join('') || '<div class="soc-sub">aucun</div>'}
252+ </div>`);
253+ }
254+ $('visDebugBtn').onclick = () => inspect(null);
255+
256+ document.querySelectorAll('#visMode button').forEach(b => {
257+ b.onclick = () => {
258+ mode = b.dataset.v;
259+ document.querySelectorAll('#visMode button').forEach(x => x.classList.toggle('on', x === b));
260+ if (lastSummary) { renderGlobal(lastSummary); $('visGrid').innerHTML = (lastSummary.sites || []).map(siteCard).join(''); $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); }); }
261+ };
262+ });
263+
264+ // graphique série 30 j : humains (trait plein) + héritage v1 (pointillé gris)
265+ function chart(series, mig) {
266+ const w = 680, h = 220, pl = 42, pb = 26, pt = 10;
267+ const n = series.length; if (n < 2) return '<div class="soc-sub">Pas encore assez de données.</div>';
268+ const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0]));
269+ const X = i => pl + (w - pl - 10) * (i / (n - 1));
270+ const Y = v => h - pb - (h - pb - pt) * (v / max);
271+ let dH = '', dL = '', pen = false;
272+ series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); });
273+ series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; });
274+ const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`;
275+ let lab = '';
276+ for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `<text x="${X(i)}" y="${h - 8}" font-size="11" fill="var(--muted)" text-anchor="middle" font-family="var(--mono)">${series[i].day.slice(5)}</text>`;
277+ const grid = [0.5, 1].map(f => `<line x1="${pl}" y1="${Y(max * f)}" x2="${w - 10}" y2="${Y(max * f)}" stroke="var(--grid)"/><text x="${pl - 6}" y="${Y(max * f) + 4}" font-size="10" fill="var(--muted)" text-anchor="end" font-family="var(--mono)">${nf(Math.round(max * f))}</text>`).join('');
278+ const migDay = mig ? new Date(mig).toISOString().slice(0, 10) : null;
279+ const migIdx = migDay ? series.findIndex(x => x.day >= migDay) : -1;
280+ const migLine = migIdx > 0 ? `<line x1="${X(migIdx)}" y1="${pt}" x2="${X(migIdx)}" y2="${h - pb}" stroke="var(--muted)" stroke-dasharray="3 4"/><text x="${X(migIdx) + 4}" y="${pt + 10}" font-size="9" fill="var(--muted)">migration v2</text>` : '';
281+ return `<svg viewBox="0 0 ${w} ${h}" class="vis-chart">
282+ ${grid}${migLine}
283+ <path d="${area}" fill="var(--orange-deep)" opacity="0.12"/>
284+ <path d="${dL}" fill="none" stroke="var(--muted)" stroke-width="1.6" stroke-dasharray="4 4"/>
285+ <path d="${dH}" fill="none" stroke="var(--orange-deep)" stroke-width="2.4" stroke-linejoin="round"/>
286+ ${lab}
287+ </svg>
288+ <div class="legend"><span><i class="sw" style="background:var(--orange-deep)"></i>humains (v2)</span><span><i class="sw" style="background:var(--muted)"></i>ancien comptage (non filtré)</span></div>`;
289+ }
290+
291+ async function openDetail(site, label) {
292+ openSheet(`<div class="sheet-h"><img class="site-logo big" src="/icons/${site}" onerror="this.style.display='none'"><h3>${esc(label)}</h3></div><div id="vdBody"><div class="soc-sub">Chargement…</div></div>`);
293+ try {
294+ const [sm, se, det] = await Promise.all([
295+ lastSummary ? Promise.resolve(lastSummary) : (await fetch('/api/analytics/summary')).json(),
296+ (await fetch('/api/analytics/series/' + site)).json(),
297+ (await fetch('/api/analytics/site/' + site + '?days=7')).json(),
298+ ]);
299+ const s = (sm.sites || []).find(x => x.site === site) || {};
300+ const bar = (rows, kL, kV, unit) => {
301+ const mx = Math.max(1, ...rows.map(r => r[kV]));
302+ return rows.map(r => `<div class="vd-geo"><span class="vd-geoL" title="${esc(String(r[kL]))}">${esc(String(r[kL]).slice(0, 42))}</span><div class="vd-bar"><div style="width:${Math.round(100 * r[kV] / mx)}%"></div></div><span class="vd-geoN">${nf(r[kV])}${unit || ''}</span></div>`).join('') || '<div class="soc-sub">rien encore</div>';
303+ };
304+ const devTot = det.devs.reduce((a, x) => a + x.uv, 0) || 1;
305+ $('vdBody').innerHTML = `
306+ <div class="sheet-sec">
307+ <div class="vd-kpis">
308+ <div class="vd-k"><div class="vd-kn">${nf(s.online)}</div><div class="vd-kl">en ligne</div></div>
309+ <div class="vd-k"><div class="vd-kn">${nf(s.today)}</div><div class="vd-kl">humains auj.</div></div>
310+ <div class="vd-k"><div class="vd-kn">${nf(s.sessions)}</div><div class="vd-kl">sessions</div></div>
311+ <div class="vd-k"><div class="vd-kn">${nf(s.pv)}</div><div class="vd-kl">pages vues</div></div>
312+ </div>
313+ <div class="vd-kpis" style="margin-top:8px">
314+ <div class="vd-k"><div class="vd-kn">${nf(det.newToday)}</div><div class="vd-kl">nouveaux</div></div>
315+ <div class="vd-k"><div class="vd-kn">${nf(det.returningToday)}</div><div class="vd-kl">récurrents</div></div>
316+ <div class="vd-k"><div class="vd-kn">${det.pagesPerSession || 0}</div><div class="vd-kl">pages/session</div></div>
317+ <div class="vd-k"><div class="vd-kn">${det.avgSessionSec >= 60 ? Math.round(det.avgSessionSec / 60) + ' min' : (det.avgSessionSec || 0) + ' s'}</div><div class="vd-kl">durée moy.</div></div>
318+ </div>
319+ </div>
320+ <div class="sheet-sec"><h4>Visiteurs humains — 30 jours</h4>${chart(se.series || [], se.migratedAt)}</div>
321+ <div class="sheet-sec"><h4>Pages populaires — 7 j (humains)</h4>${bar(det.pages, 'path', 'pv')}</div>
322+ <div class="sheet-sec"><h4>Sources / referrers — 7 j</h4>${det.refs.length ? bar(det.refs.map(r => ({ ...r, ref: r.ref.replace(/^https?:\/\//, '') })), 'ref', 'uv') : '<div class="soc-sub">rien encore (referrer fourni par le beacon v2)</div>'}</div>
323+ <div class="sheet-sec"><h4>Appareils</h4>${bar(det.devs.map(x => ({ l: (DEV_LABEL[x.dev] || 'inconnu (beacon v1)') + ' · ' + Math.round(x.uv / devTot * 100) + ' %', uv: x.uv })), 'l', 'uv')}</div>
324+ <div class="sheet-sec"><h4>D'où viennent les visiteurs — 7 j</h4>
325+ ${bar(det.geo.map(x => ({ l: x.city + (x.region ? ', ' + (x.cc === 'CA' ? x.region : x.country) : ''), uv: x.uv })), 'l', 'uv')}
326+ ${det.countries.length > 1 ? '<h4 style="margin-top:12px">Pays</h4>' + bar(det.countries.map(x => ({ l: x.country, uv: x.uv })), 'l', 'uv') : ''}
327+ </div>
328+ <div class="sheet-sec"><h4>Trafic filtré — 7 j</h4>
329+ ${bar((det.classes || []).filter(c => c.cls !== 'human').map(c => ({ l: (CLS_LABEL[c.cls] || c.cls) + ' (' + nf(c.uv) + ' ids)', n: c.n })), 'l', 'n', ' hits')}
330+ </div>`;
331+ } catch (e) { $('vdBody').innerHTML = '<div class="soc-sub">Erreur : ' + esc(e.message) + '</div>'; }
332+ }
333+
334+ window.startVisitors = function () {
335+ loadVisitors();
336+ clearInterval(timer);
337+ timer = setInterval(() => { if (curView === 'visitors' && !document.hidden) { loadVisitors(); } }, 12000);
338+ };
339+})();
340+
341+// ===== VUE D'ENSEMBLE =====
342+(function () {
343+ const $ = (id) => document.getElementById(id);
344+ let timer = null;
345+ const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' ');
346+ const fmt$$ = (v) => (v || 0).toFixed(2).replace('.', ',') + ' $';
347+ function kpi(v, l, extra, cls) {
348+ return `<div class="kpi2 ${cls || ''}"><div class="kv2">${v}</div><div class="kl2">${l}</div>${extra || ''}</div>`;
349+ }
350+ function delta(cur, prev) {
351+ if (!prev) return '';
352+ const d = Math.round((cur - prev) / prev * 100);
353+ if (!isFinite(d) || Math.abs(d) < 1) return '<span class="kdelta flat">≈ hier</span>';
354+ return `<span class="kdelta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '+' : ''}${d}&nbsp;% vs hier</span>`;
355+ }
356+ async function load() {
357+ try {
358+ const [an, eco, chats, anom, series] = await Promise.all([
359+ (await fetch('/api/analytics/summary')).json(),
360+ (await fetch('/api/eco/summary')).json(),
361+ (await fetch('/api/chats')).json(),
362+ (await fetch('/api/analytics/anomalies')).json(),
363+ (await fetch('/api/analytics/series/_all')).json(),
364+ ]);
365+ const g = an.global || {}, eg = eco.global || {};
366+ const running = (chats.chats || []).filter(c => c.state !== 'idle');
367+ const cost = (chats.chats || []).reduce((a, c) => a + (c.totalCost || 0), 0);
368+ $('ovStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
369+ const ts = $('topStamp'); if (ts) ts.textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' });
370+ $('ovKpis').innerHTML =
371+ kpi(nf(g.online), 'humains en ligne', '', 'accent')
372+ + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame))
373+ + kpi(nf(g.sessions), 'sessions')
374+ + kpi(nf(g.pv), 'pages vues')
375+ + kpi(`${eg.up}<small>/${eg.total}</small>`, 'sites en ligne', '', eg.down ? 'bad' : 'good')
376+ + kpi(eg.openIncidents || 0, 'incidents ouverts', '', eg.openIncidents ? 'bad' : '')
377+ + kpi((eg.uptime24 ?? '—') + '<small>%</small>', 'uptime 24 h')
378+ + kpi(eg.avgLatency + '<small> ms</small>', 'latence moy.')
379+ + kpi(running.length, 'sessions Claude actives', '', running.length ? 'accent' : '')
380+ + kpi(fmt$$(cost), 'coût Claude cumulé');
381+ // graphique 30 j global
382+ const s = series.series || [];
383+ $('ovChart').innerHTML = ovChart(s, series.migratedAt);
384+ const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0);
385+ $('ovBreak').innerHTML = `<div class="panel-note" style="margin-top:8px">Aujourd’hui : <b>${nf(g.today)}</b> humains · trafic filtré : ${nf(g.crawler)} crawler + ${nf(g.datacenter)} datacenter + ${nf(g.automation)} automation + ${nf(g.internal)} interne (${nf(autoHits)} hits exclus des métriques)</div>`;
386+ // écosystème compact
387+ const order = { down: 0, slow: 1, unknown: 2, up: 3 };
388+ const stt = (x) => (x.incident || (x.last && !x.last.ok)) ? 'down' : (x.last && x.last.ms > 4000) ? 'slow' : x.last ? 'up' : 'unknown';
389+ $('ovEco').innerHTML = '<div class="ov-eco-chips">' + [...eco.sites].sort((a, b) => order[stt(a)] - order[stt(b)]).map(x => {
390+ const st = stt(x);
391+ return `<span class="eco-chip ${st}" data-goto="eco" title="${x.label} — ${x.last?.ok ? x.last.ms + ' ms' : 'hors ligne'}">${st === 'up' ? '●' : st === 'slow' ? '▲' : '■'} ${x.label}<b>${x.last?.ok ? x.last.ms + 'ms' : 'OFF'}</b></span>`;
392+ }).join('') + '</div>';
393+ // incidents récents
394+ const inc = await (await fetch('/api/eco/incidents')).json();
395+ $('ovInc').innerHTML = (inc.incidents || []).slice(0, 5).map(i => `
396+ <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>
397+ <span><b>${esc(i.site)}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'}</span></div>`).join('')
398+ || '<div class="soc-sub">Aucun incident récent 🎉</div>';
399+ // Claude Code
400+ $('ovClaude').innerHTML = (chats.chats || []).slice(0, 5).map(c => `
401+ <div class="ov-sess" data-chat="${c.id}">
402+ <span class="sess-badge ${c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>
403+ <span class="ov-sess-t">${esc(c.title)}</span>
404+ <span class="ov-sess-m mono">${esc(c.projectName || '')} · ${fmt$$(c.totalCost)}</span>
405+ </div>`).join('') || '<div class="soc-sub">Aucune session encore.</div>';
406+ $('ovClaude').querySelectorAll('[data-chat]').forEach(x => { x.onclick = () => openChat(x.dataset.chat); });
407+ // anomalies
408+ const bits = [];
409+ if (anom.datacenter?.surge) bits.push(`<div class="anom warn">▲ Poussée datacenter : ${nf(anom.datacenter.lastHour)} hits/h</div>`);
410+ for (const sp of anom.spikes || []) bits.push(`<div class="anom warn">▲ Pic sur <b>${esc(sp.site)}</b> : ×${sp.factor}</div>`);
411+ for (const ip of (anom.topIps || []).slice(0, 3)) bits.push(`<div class="anom"><span class="mono">${esc(ip.ip)}</span> · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} → <b>${esc(ip.cls)}</b></div>`);
412+ $('ovAnom').innerHTML = bits.length ? bits.join('') : '<div class="soc-sub">● Rien à signaler.</div>';
413+ } catch {}
99 } 414 }
100− function chart(series){ 415+ function ovChart(series, mig) {
101− var w=680,h=240,pad=30; var vals=series.map(function(x){return x.uniques;}); 416+ const w = 760, h = 200, pl = 44, pb = 24, pt = 8;
102− var max=Math.max(1,Math.max.apply(null,vals)); var n=series.length; 417+ const n = series.length; if (n < 2) return '<div class="soc-sub">Les données humaines s’accumulent — revenez dans quelques heures.</div>';
103− var X=function(i){return pad+(w-2*pad)*(n<=1?0:i/(n-1));}; 418+ const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0]));
104− var Y=function(v){return h-pad-(h-2*pad)*(v/max);}; 419+ const X = i => pl + (w - pl - 10) * (i / (n - 1));
105− var pts=series.map(function(x,i){return X(i)+','+Y(x.uniques);}).join(' '); 420+ const Y = v => h - pb - (h - pb - pt) * (v / max);
106− var area='M'+X(0)+','+(h-pad)+' L'+pts.replace(/ /g,' L')+' L'+X(n-1)+','+(h-pad)+' Z'; 421+ let dH = '', dL = '', pen = false;
107− var last=series[n-1]||{uniques:0}; 422+ series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); });
108− var dots='<circle cx="'+X(n-1)+'" cy="'+Y(last.uniques)+'" r="6" fill="var(--accent,#f97316)"/>'; 423+ series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; });
109− // libellés d'axe : quelques jours 424+ const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`;
110− var lab=''; for(var i=0;i<n;i+=Math.ceil(n/5)){ var dd=series[i].day.slice(5); lab+='<text x="'+X(i)+'" y="'+(h-8)+'" font-size="14" fill="#888" text-anchor="middle">'+dd+'</text>'; } 425+ let lab = '';
111− return '<svg viewBox="0 0 '+w+' '+h+'" class="vis-chart">' 426+ for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `<text x="${X(i)}" y="${h - 6}" font-size="10" fill="var(--muted)" text-anchor="middle" font-family="var(--mono)">${series[i].day.slice(5)}</text>`;
112− +'<defs><linearGradient id="ga" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--accent,#f97316)" stop-opacity=".45"/><stop offset="1" stop-color="var(--accent,#f97316)" stop-opacity="0"/></linearGradient></defs>' 427+ const grid = [0.5, 1].map(f => `<line x1="${pl}" y1="${Y(max * f)}" x2="${w - 10}" y2="${Y(max * f)}" stroke="var(--grid)"/><text x="${pl - 6}" y="${Y(max * f) + 4}" font-size="10" fill="var(--muted)" text-anchor="end" font-family="var(--mono)">${nf(Math.round(max * f))}</text>`).join('');
113− +'<path d="'+area+'" fill="url(#ga)"/>' 428+ return `<svg viewBox="0 0 ${w} ${h}" class="vis-chart">${grid}
114− +'<polyline points="'+pts+'" fill="none" stroke="var(--accent,#f97316)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>' 429+ <path d="${area}" fill="var(--orange-deep)" opacity="0.10"/>
115− +dots+lab 430+ <path d="${dL}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4 4"/>
116− +'<text x="'+pad+'" y="20" font-size="15" fill="#999">max '+max+'/j</text></svg>'; 431+ <path d="${dH}" fill="none" stroke="var(--orange-deep)" stroke-width="2.4" stroke-linejoin="round"/>${lab}</svg>
117− } 432+ <div class="legend"><span><i class="sw" style="background:var(--orange-deep)"></i>humains (v2, filtré)</span><span><i class="sw" style="background:var(--muted)"></i>ancien comptage v1 (non filtré)</span></div>`;
118− async function openDetail(site,label){ 433+ }
119− var ov=$('visDetail'); 434+ window.startOverview = function () {
120− if(!ov){ ov=document.createElement('div'); ov.id='visDetail'; document.body.appendChild(ov); } 435+ load();
121− ov.innerHTML='<div class="vd-box"><div class="vd-head"><img src="/icons/'+site+'"><h3>'+label+'</h3><button id="vdClose">✕</button></div><div class="vd-body"><div class="soc-sub">Chargement…</div></div></div>'; 436+ clearInterval(timer);
122− ov.classList.add('on'); $('vdClose').onclick=function(){ ov.classList.remove('on'); }; 437+ timer = setInterval(() => { if (curView === 'overview' && !document.hidden) load(); }, 30000);
123− ov.onclick=function(e){ if(e.target===ov) ov.classList.remove('on'); }; 438+ };
124− try{
125− var sm=await (await fetch('/api/analytics/summary')).json();
126− var s=(sm.sites||[]).filter(function(x){return x.site===site;})[0]||{};
127− var se=(await (await fetch('/api/analytics/series/'+site)).json()).series||[];
128− var ge=(await (await fetch('/api/analytics/geo/'+site)).json()).geo||[];
129− var maxg=Math.max(1,Math.max.apply(null,ge.map(function(x){return x.c;})||[1]));
130− var geoHtml = ge.length ? ge.map(function(x){return '<div class="vd-geo"><span class="vd-geoL">'+x.label+'</span><div class="vd-bar"><div style="width:'+Math.round(100*x.c/maxg)+'%"></div></div><span class="vd-geoN">'+nf(x.c)+'</span></div>';}).join('') : '<div class="soc-sub">Emplacements en cours de géolocalisation…</div>';
131− ov.querySelector('.vd-body').innerHTML=
132− '<div class="vd-kpis">'
133− +'<div class="vd-k"><div class="vd-kn">'+nf(s.online)+'</div><div class="vd-kl">en ligne</div></div>'
134− +'<div class="vd-k"><div class="vd-kn">'+nf(s.today)+'</div><div class="vd-kl">aujourd&#39;hui</div></div>'
135− +'<div class="vd-k"><div class="vd-kn">'+nf(s.d7)+'</div><div class="vd-kl">7 jours</div></div>'
136− +'<div class="vd-k"><div class="vd-kn">'+nf(s.d30)+'</div><div class="vd-kl">30 jours</div></div>'
137− +'</div>'
138− +'<div class="vd-title">Visiteurs uniques · 30 jours</div>'+chart(se)
139− +'<div class="vd-title">D&#39;où viennent les visiteurs</div>'+geoHtml;
140− }catch(e){ ov.querySelector('.vd-body').innerHTML='<div class="soc-sub">Erreur : '+e.message+'</div>'; }
141− }
142− window.startVisitors=function(){ loadVisitors(); clearInterval(timer); timer=setInterval(function(){ if(curView==='visitors') loadVisitors(); },15000); };
143 })(); 439 })();
144 440
145 441
@@ -299,8 +595,7 @@
299 bindSocial(); 595 bindSocial();
300 function navBadge(view) { 596 function navBadge(view) {
301 if (curView === view) return; 597 if (curView === view) return;
302− const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`); 598+ document.querySelectorAll(`.nav-btn[data-view="${view}"] .badge`).forEach(b => b.classList.remove('hidden'));
303− if (b) b.classList.remove('hidden');
304 } 599 }
305 600
306 // ---------- bannières ---------- 601 // ---------- bannières ----------
@@ -660,9 +955,13 @@
660 // ---------- WebSocket ---------- 955 // ---------- WebSocket ----------
661 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } 956 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
662 function setConn(state) { 957 function setConn(state) {
663− const pill = $('connPill'); 958+ const txt = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';
664− pill.className = 'conn-pill ' + state; 959+ for (const id of ['connPill', 'connPillSide']) {
665− $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne'; 960+ const pill = $(id);
961+ if (!pill) continue;
962+ pill.className = 'conn-pill ' + state;
963+ const t = pill.querySelector('.conn-txt'); if (t) t.textContent = txt;
964+ }
666 } 965 }
667 function connect() { 966 function connect() {
668 if (!wsWanted) return; 967 if (!wsWanted) return;
@@ -724,7 +1023,7 @@
724 return; 1023 return;
725 } 1024 }
726 if (msg.type === 'eco_alert') { 1025 if (msg.type === 'eco_alert') {
727− if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); 1026+ if (msg.kind === 'down') { banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); const tb = $('topIncBadge'); if (tb) tb.classList.remove('hidden'); }
728 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco')); 1027 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));
729 navBadge('eco'); 1028 navBadge('eco');
730 if (curView === 'eco') loadEco(); 1029 if (curView === 'eco') loadEco();
@@ -918,79 +1217,134 @@
918 } 1217 }
919 1218
920 // ---------- sessions ---------- 1219 // ---------- sessions ----------
921− async function loadSessions() { 1220+ let sessCache = [];
922− const r = await fetch('/api/chats'); 1221+ function sessFilters() {
923− if (!r.ok) return; 1222+ return { q: ($('sessSearch')?.value || '').toLowerCase(), app: $('sessApp')?.value || '', st: $('sessState')?.value || '' };
924− const { chats } = await r.json(); 1223+ }
1224+ function renderSessions() {
1225+ const { q, app, st } = sessFilters();
925 const list = $('sessList'); 1226 const list = $('sessList');
926 list.innerHTML = ''; 1227 list.innerHTML = '';
927− if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; } 1228+ const rows = sessCache.filter(c =>
1229+ (!q || (c.title + ' ' + (c.projectName || '')).toLowerCase().includes(q))
1230+ && (!app || c.projectId === app)
1231+ && (!st || c.state === st));
1232+ if (!rows.length) { list.appendChild(el('div', 'sess-empty', sessCache.length ? 'Aucune session ne correspond aux filtres.' : 'Aucune session. Lance une conversation dans l’onglet Claude Code.')); return; }
928 const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' }; 1233 const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };
929− for (const c of chats) { 1234+ for (const c of rows) {
930− const card = el('div', 'sess-card'); 1235+ const card = el('div', 'sess-card row');
931 card.innerHTML = ` 1236 card.innerHTML = `
932− <div class="sess-top"> 1237+ <span class="sess-badge ${c.state}" title="${stLabel[c.state] || c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>
933− <span class="sess-title">${esc(c.title)}</span> 1238+ <div class="sess-main">
934− <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span> 1239+ <span class="sess-title">${c.batch ? '<span class="batch-tag">🌐</span> ' : ''}${esc(c.title)}</span>
1240+ <span class="sess-info"><span>${esc(c.projectName || '')}</span><span>${MODEL_LABEL[c.model] || c.model}</span><span>${c.contextTokens ? 'ctx ' + fmtTok(c.contextTokens) : ''}</span></span>
935 </div> 1241 </div>
936− <div class="sess-info"> 1242+ <span class="sess-cost mono">${fmt$(c.totalCost)}</span>
937− ${c.batch ? '<span class="batch-tag">🌐 diffusion</span>' : ''} 1243+ <span class="sess-when mono">${timeAgo(c.updated)}</span>
938− <span>${esc(c.projectName || '')}</span> 1244+ <span class="sess-btns"></span>`;
939− <span>${MODEL_LABEL[c.model] || c.model}</span> 1245+ const btns = card.querySelector('.sess-btns');
940− <span>${fmt$(c.totalCost)}</span>
941− <span>${timeAgo(c.updated)}</span>
942− </div>`;
943− const actions = el('div', 'sess-actions');
944 const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir'); 1246 const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');
945 open.onclick = (e) => { e.stopPropagation(); openChat(c.id); }; 1247 open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };
946− actions.appendChild(open); 1248+ btns.appendChild(open);
947 if (c.state === 'idle') { 1249 if (c.state === 'idle') {
948− const del = el('button', 'del', 'Supprimer'); 1250+ const del = el('button', 'del', '✕');
1251+ del.title = 'Supprimer';
949 del.onclick = async (e) => { 1252 del.onclick = async (e) => {
950 e.stopPropagation(); 1253 e.stopPropagation();
951 if (!confirm('Supprimer cette session ?')) return; 1254 if (!confirm('Supprimer cette session ?')) return;
952 await fetch('/api/chats/' + c.id, { method: 'DELETE' }); 1255 await fetch('/api/chats/' + c.id, { method: 'DELETE' });
953− card.remove(); 1256+ sessCache = sessCache.filter(x => x.id !== c.id);
1257+ renderSessions();
954 if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); } 1258 if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }
955 }; 1259 };
956− actions.appendChild(del); 1260+ btns.appendChild(del);
957 } 1261 }
958− card.appendChild(actions);
959 card.onclick = () => openChat(c.id); 1262 card.onclick = () => openChat(c.id);
960 list.appendChild(card); 1263 list.appendChild(card);
961 } 1264 }
962 } 1265 }
1266+ async function loadSessions() {
1267+ const r = await fetch('/api/chats');
1268+ if (!r.ok) return;
1269+ sessCache = (await r.json()).chats || [];
1270+ // remplit le filtre app à partir des sessions existantes
1271+ const sel = $('sessApp');
1272+ if (sel && sel.options.length <= 1) {
1273+ const seen = new Map();
1274+ for (const c of sessCache) if (c.projectId && !seen.has(c.projectId)) seen.set(c.projectId, c.projectName || c.projectId);
1275+ for (const [id2, name] of seen) { const o = document.createElement('option'); o.value = id2; o.textContent = name; sel.appendChild(o); }
1276+ }
1277+ renderSessions();
1278+ }
963 $('newSessBtn').onclick = newChat; 1279 $('newSessBtn').onclick = newChat;
1280+ for (const id2 of ['sessSearch', 'sessApp', 'sessState']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderSessions; }
1281+
1282+ // sessions récentes sur l'écran d'accueil du chat
1283+ async function loadWelcomeRecent() {
1284+ const box = $('welcomeRecent');
1285+ if (!box) return;
1286+ try {
1287+ const { chats } = await (await fetch('/api/chats')).json();
1288+ if (!chats.length) { box.innerHTML = ''; return; }
1289+ box.innerHTML = '<div class="wr-title">Reprendre une session</div>' + chats.slice(0, 4).map(c => `
1290+ <button class="wr-item" data-chat="${c.id}">
1291+ <span class="sess-badge ${c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>
1292+ <span class="wr-t">${esc(c.title)}</span>
1293+ <span class="wr-m mono">${esc(c.projectName || '')}</span>
1294+ </button>`).join('');
1295+ box.querySelectorAll('[data-chat]').forEach(b => { b.onclick = () => openChat(b.dataset.chat); });
1296+ } catch {}
1297+ }
964 1298
965 // ---------- Prompt Bank ---------- 1299 // ---------- Prompt Bank ----------
966 function projectOptionsHTML(selected) { 1300 function projectOptionsHTML(selected) {
967 return [...projectSel.options].map(o => 1301 return [...projectSel.options].map(o =>
968 `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join(''); 1302 `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');
969 } 1303 }
970− async function loadPrompts() { 1304+ let promptCache = [];
971− const r = await fetch('/api/prompts'); 1305+ function renderPrompts() {
972− if (!r.ok) return; 1306+ const q = ($('promptSearch')?.value || '').toLowerCase();
973− const { prompts } = await r.json(); 1307+ const app = $('promptApp')?.value || '';
974 const list = $('promptList'); 1308 const list = $('promptList');
975 list.innerHTML = ''; 1309 list.innerHTML = '';
976− if (!prompts.length) { list.appendChild(el('div', 'sess-empty', 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; } 1310+ const rows = promptCache
977− for (const pr of prompts) { 1311+ .filter(pr => (!q || (pr.title + ' ' + pr.text).toLowerCase().includes(q)) && (!app || pr.project === app))
1312+ .sort((a, b) => (b.fav ? 1 : 0) - (a.fav ? 1 : 0) || (b.updated || 0) - (a.updated || 0));
1313+ if (!rows.length) { list.appendChild(el('div', 'sess-empty', promptCache.length ? 'Aucun prompt ne correspond.' : 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; }
1314+ for (const pr of rows) {
978 const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix'; 1315 const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix';
979− const card = el('div', 'sess-card'); 1316+ const card = el('div', 'sess-card prow');
980 card.innerHTML = ` 1317 card.innerHTML = `
981− <div class="sess-top"><span class="sess-title">${esc(pr.title)}</span></div> 1318+ <div class="sess-top">
982− <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div> 1319+ <button class="fav-btn${pr.fav ? ' on' : ''}" title="Favori">${pr.fav ? '★' : '☆'}</button>
983− <div class="sess-info"><span>🎯 ${esc(projName)}</span><span>${timeAgo(pr.updated)}</span></div>`; 1320+ <span class="sess-title">${esc(pr.title)}</span>
1321+ <span class="sess-info"><span>🎯 ${esc(projName)}</span>${pr.uses ? `<span>${pr.uses}× utilisé</span>` : ''}<span>${timeAgo(pr.updated)}</span></span>
1322+ </div>
1323+ <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>`;
984 const actions = el('div', 'sess-actions'); 1324 const actions = el('div', 'sess-actions');
985− const sendB = el('button', 'p-send', 'Envoyer →'); 1325+ const sendB = el('button', 'p-send', 'Lancer →');
986 const editB = el('button', '', '✎ Éditer'); 1326 const editB = el('button', '', '✎ Éditer');
987 const upB = el('button', '', '✨ Améliorer'); 1327 const upB = el('button', '', '✨ Améliorer');
1328+ const dupB = el('button', '', '⧉ Dupliquer');
988 const delB = el('button', 'del', 'Suppr.'); 1329 const delB = el('button', 'del', 'Suppr.');
989− actions.append(sendB, upB, editB, delB); 1330+ actions.append(sendB, upB, editB, dupB, delB);
990 card.appendChild(actions); 1331 card.appendChild(actions);
991− sendB.onclick = () => openSendSheet(pr); 1332+ card.querySelector('.fav-btn').onclick = async (e) => {
1333+ e.stopPropagation();
1334+ pr.fav = !pr.fav;
1335+ await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fav: pr.fav }) });
1336+ renderPrompts();
1337+ };
1338+ sendB.onclick = () => {
1339+ fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bumpUse: true }) }).catch(() => {});
1340+ openSendSheet(pr);
1341+ };
992 editB.onclick = () => openPromptEditor(pr); 1342 editB.onclick = () => openPromptEditor(pr);
993− delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); card.remove(); }; 1343+ dupB.onclick = async () => {
1344+ await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: pr.title + ' (copie)', text: pr.text, project: pr.project }) });
1345+ loadPrompts();
1346+ };
1347+ delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); promptCache = promptCache.filter(x => x.id !== pr.id); renderPrompts(); };
994 upB.onclick = async () => { 1348 upB.onclick = async () => {
995 upB.textContent = '✨ …'; upB.disabled = true; 1349 upB.textContent = '✨ …'; upB.disabled = true;
996 try { 1350 try {
@@ -1007,7 +1361,23 @@
1007 list.appendChild(card); 1361 list.appendChild(card);
1008 } 1362 }
1009 } 1363 }
1364+ async function loadPrompts() {
1365+ const r = await fetch('/api/prompts');
1366+ if (!r.ok) return;
1367+ promptCache = (await r.json()).prompts || [];
1368+ const sel = $('promptApp');
1369+ if (sel && sel.options.length <= 1) {
1370+ const seen = new Set(promptCache.map(p2 => p2.project).filter(Boolean));
1371+ for (const id2 of seen) {
1372+ const o = document.createElement('option');
1373+ o.value = id2; o.textContent = [...projectSel.options].find(x => x.value === id2)?.textContent || id2;
1374+ sel.appendChild(o);
1375+ }
1376+ }
1377+ renderPrompts();
1378+ }
1010 $('newPromptBtn').onclick = () => openPromptEditor(null); 1379 $('newPromptBtn').onclick = () => openPromptEditor(null);
1380+ for (const id2 of ['promptSearch', 'promptApp']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderPrompts; }
1011 1381
1012 function openPromptEditor(pr) { 1382 function openPromptEditor(pr) {
1013 const isNew = !pr; 1383 const isNew = !pr;
@@ -1079,8 +1449,11 @@
1079 banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000); 1449 banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000);
1080 } 1450 }
1081 1451
1082− // incidents globaux (bouton en tête de l'onglet Écosystème) 1452+ // incidents globaux (bouton Écosystème + cloche topbar)
1083− $('incBtn').onclick = async () => { 1453+ $('topInc').onclick = () => openIncidents();
1454+ $('incBtn').onclick = () => openIncidents();
1455+ async function openIncidents() {
1456+ const tb = $('topIncBadge'); if (tb) tb.classList.add('hidden');
1084 const r = await fetch('/api/eco/incidents'); 1457 const r = await fetch('/api/eco/incidents');
1085 if (!r.ok) return; 1458 if (!r.ok) return;
1086 const { incidents } = await r.json(); 1459 const { incidents } = await r.json();
@@ -1457,6 +1830,40 @@
1457 location.reload(); 1830 location.reload();
1458 }; 1831 };
1459 1832
1833+ // ---------- réglages analytics ----------
1834+ window.loadAnCfg = async function () {
1835+ const box = $('anCfg');
1836+ if (!box) return;
1837+ try {
1838+ const c = await (await fetch('/api/analytics/config')).json();
1839+ box.innerHTML = `
1840+ <div class="set-row"><span>Fenêtre de session</span><span><input id="acSess" class="ac-num" type="number" min="5" max="240" value="${c.sessionMin}"> min</span></div>
1841+ <div class="set-row"><span>Fenêtre « en ligne »</span><span><input id="acAct" class="ac-num" type="number" min="30" max="900" value="${c.activeSec}"> s</span></div>
1842+ <div class="set-row"><span>Rétention des événements</span><span><input id="acRet" class="ac-num" type="number" min="7" max="365" value="${c.retentionDays}"> jours</span></div>
1843+ <div class="set-row"><span>Seuil anti-flood par IP</span><span><input id="acFlood" class="ac-num" type="number" min="100" max="100000" value="${c.maxIpPerHour}"> evt/h</span></div>
1844+ <div class="set-label" style="margin-top:10px">IPs internes exclues (préfixes, une par ligne)</div>
1845+ <textarea id="acIps" class="pe-textarea" style="min-height:56px">${esc((c.internalIps || []).join('\n'))}</textarea>
1846+ <div class="set-label" style="margin-top:10px">User-Agents internes / bots additionnels (fragments)</div>
1847+ <textarea id="acUas" class="pe-textarea" style="min-height:56px" placeholder="UA internes…">${esc((c.internalUAs || []).join('\n'))}</textarea>
1848+ <textarea id="acBots" class="pe-textarea" style="min-height:56px;margin-top:6px" placeholder="UA bots custom…">${esc((c.extraBotUAs || []).join('\n'))}</textarea>
1849+ <div class="set-label" style="margin-top:10px">ASNs exclus (numéros, un par ligne)</div>
1850+ <textarea id="acAsn" class="pe-textarea" style="min-height:44px">${esc((c.blockedASNs || []).join('\n'))}</textarea>
1851+ <div class="set-actions"><button id="acSave" class="btn-cta small">Enregistrer</button><span id="acMsg" class="soc-sub"></span></div>`;
1852+ $('acSave').onclick = async () => {
1853+ const lines = (id2) => $(id2).value.split('\n').map(s => s.trim()).filter(Boolean);
1854+ const body = {
1855+ sessionMin: +$('acSess').value, activeSec: +$('acAct').value,
1856+ retentionDays: +$('acRet').value, maxIpPerHour: +$('acFlood').value,
1857+ internalIps: lines('acIps'), internalUAs: lines('acUas'),
1858+ extraBotUAs: lines('acBots'), blockedASNs: lines('acAsn').map(Number).filter(Boolean),
1859+ };
1860+ const r2 = await fetch('/api/analytics/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1861+ $('acMsg').textContent = r2.ok ? '✓ enregistré et appliqué' : '✗ échec';
1862+ setTimeout(() => { $('acMsg').textContent = ''; }, 3000);
1863+ };
1864+ } catch { box.innerHTML = '<div class="soc-sub">Config indisponible.</div>'; }
1865+ };
1866+
1460 // ---------- login / init ---------- 1867 // ---------- login / init ----------
1461 async function init() { 1868 async function init() {
1462 const me = await fetch('/api/me'); 1869 const me = await fetch('/api/me');
@@ -1489,10 +1896,13 @@
1489 }; 1896 };
1490 refreshChips(); 1897 refreshChips();
1491 refreshSettingsView(); 1898 refreshSettingsView();
1899+ loadWelcomeRecent();
1492 // reprise automatique : on rouvre la dernière conversation (historique + 1900 // reprise automatique : on rouvre la dernière conversation (historique +
1493 // live si une tâche tourne encore) même après fermeture complète de l'app 1901 // live si une tâche tourne encore) même après fermeture complète de l'app
1494 if (settings.lastChatId) currentChat = { id: settings.lastChatId }; 1902 if (settings.lastChatId) currentChat = { id: settings.lastChatId };
1495 connect(); 1903 connect();
1904+ // page d'accueil = Vue d'ensemble (le chat garde sa reprise en arrière-plan)
1905+ switchView('overview');
1496 } 1906 }
1497 $('loginBtn').onclick = doLogin; 1907 $('loginBtn').onclick = doLogin;
1498 $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); 1908 $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
modified public/index.html +247 −158
@@ -7,11 +7,15 @@
7 <meta name="apple-mobile-web-app-capable" content="yes"> 7 <meta name="apple-mobile-web-app-capable" content="yes">
8 <meta name="apple-mobile-web-app-status-bar-style" content="default"> 8 <meta name="apple-mobile-web-app-status-bar-style" content="default">
9 <meta name="apple-mobile-web-app-title" content="Admin-Ka"> 9 <meta name="apple-mobile-web-app-title" content="Admin-Ka">
10−<title>Admin-Ka — Console Groupe KA</title> 10+<title>Admin-Ka — Control Center Groupe KA</title>
11 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='-apple-system,sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E"> 11 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='-apple-system,sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E">
12 <link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E"> 12 <link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E">
13 <link rel="stylesheet" href="/vendor/hljs-github.min.css"> 13 <link rel="stylesheet" href="/vendor/hljs-github.min.css">
14 <link rel="stylesheet" href="/style.css"> 14 <link rel="stylesheet" href="/style.css">
15+<script>/* thème avant peinture (anti-flash) */
16+(function(){try{var t=localStorage.getItem('ka_theme')||'auto';
17+var dark=t==='dark'||(t==='auto'&&matchMedia('(prefers-color-scheme: dark)').matches);
18+document.documentElement.dataset.theme=dark?'dark':'light';}catch(e){}})();</script>
15 </head> 19 </head>
16 <body> 20 <body>
17 21
@@ -20,7 +24,7 @@
20 <div class="login-box"> 24 <div class="login-box">
21 <div class="ka-mark big">Ka</div> 25 <div class="ka-mark big">Ka</div>
22 <h1 class="login-title">Admin<span class="tiret">-</span>Ka</h1> 26 <h1 class="login-title">Admin<span class="tiret">-</span>Ka</h1>
23− <p class="login-sub">Console Claude Code · Groupe KA</p> 27+ <p class="login-sub">Control Center · Groupe KA</p>
24 <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password"> 28 <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password">
25 <button id="loginBtn" class="btn-cta">Se connecter</button> 29 <button id="loginBtn" class="btn-cta">Se connecter</button>
26 <div id="loginError" class="login-error"></div> 30 <div id="loginError" class="login-error"></div>
@@ -31,183 +35,268 @@
31 <!-- ============ App ============ --> 35 <!-- ============ App ============ -->
32 <div id="app" class="app hidden"> 36 <div id="app" class="app hidden">
33 37
34− <header class="header"> 38+ <!-- Sidebar (desktop) -->
35− <div class="brand"> 39+ <aside class="sidebar">
40+ <div class="side-brand">
36 <span class="ka-mark">Ka</span> 41 <span class="ka-mark">Ka</span>
37 <span class="brand-name">Admin<span class="tiret">-</span>Ka</span> 42 <span class="brand-name">Admin<span class="tiret">-</span>Ka</span>
38 </div> 43 </div>
39− <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText">hors ligne</span></div> 44+ <nav class="side-nav" id="sideNav"></nav>
40− </header> 45+ <div class="side-foot">
41− 46+ <div id="connPillSide" class="conn-pill off"><span class="conn-dot"></span><span class="conn-txt">hors ligne</span></div>
42− <!-- Bannière notifications / alertes --> 47+ <div class="side-node mono">M3U96a · v3</div>
43− <div id="banners" class="banners"></div>
44−
45− <!-- ======== Vue CHAT ======== -->
46− <section id="view-chat" class="view">
47− <div class="chatbar">
48− <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select>
49− <button id="chipModel" class="chip" title="Modèle">opus</button>
50− <button id="chipPerm" class="chip" title="Permissions">normal</button>
51− <button id="chipMore" class="chip ghost" title="Options de session">⋯</button>
52− </div>
53− <div id="sessMeta" class="sess-meta hidden">
54− <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span>
55 </div> 48 </div>
49+ </aside>
50+
51+ <div class="main-col">
52+ <!-- Topbar -->
53+ <header class="header">
54+ <div class="brand mobile-only">
55+ <span class="ka-mark">Ka</span>
56+ <span class="brand-name">Admin<span class="tiret">-</span>Ka</span>
57+ </div>
58+ <h1 id="pageTitle" class="page-title desktop-only">Vue d’ensemble</h1>
59+ <div class="head-right">
60+ <span id="topStamp" class="top-stamp desktop-only"></span>
61+ <button id="topInc" class="icon-btn" title="Incidents"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/><path d="M12 10v4M12 16.6v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg><i id="topIncBadge" class="badge hidden"></i></button>
62+ <button id="themeBtn" class="icon-btn" title="Thème clair/sombre"><svg viewBox="0 0 24 24"><path d="M12 4.5a7.5 7.5 0 100 15 7.5 7.5 0 000-15zM12 4.5v15" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 4.5a7.5 7.5 0 010 15z" fill="currentColor"/></svg></button>
63+ <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText" class="conn-txt">hors ligne</span></div>
64+ </div>
65+ </header>
56 66
57− <main id="messages" class="messages"></main> 67+ <!-- Bannière notifications / alertes -->
68+ <div id="banners" class="banners"></div>
58 69
59− <div id="welcome" class="welcome"> 70+ <!-- ======== Vue VUE D'ENSEMBLE ======== -->
60− <div class="ka-mark big">Ka</div> 71+ <section id="view-overview" class="view scroll hidden">
61− <h2>Console Claude Code</h2> 72+ <div class="view-head">
62− <p>Le vrai Claude Code, lancé directement sur le nœud de chaque app du Groupe KA.</p> 73+ <h2>Vue d’ensemble</h2>
63− <div class="hints"> 74+ <span id="ovStamp" class="eco-stamp"></span>
64− <button class="hint">Quel modèle es-tu et dans quel répertoire travailles-tu ?</button>
65− <button class="hint">Montre les 5 derniers commits</button>
66− <button class="hint">État PM2 de l'app</button>
67 </div> 75 </div>
68− </div> 76+ <div id="ovKpis" class="ov-kpis"><div class="eco-loading">Chargement…</div></div>
77+ <div class="ov-cols">
78+ <div class="panel">
79+ <div class="panel-h"><h3>Visiteurs humains — 30 jours</h3><span class="panel-note">tous les sites KA</span></div>
80+ <div id="ovChart"></div>
81+ <div id="ovBreak"></div>
82+ </div>
83+ <div class="panel">
84+ <div class="panel-h"><h3>État de l’écosystème</h3><button class="linklike" data-goto="eco">tout voir →</button></div>
85+ <div id="ovEco"></div>
86+ <div class="panel-h" style="margin-top:14px"><h3>Incidents récents</h3></div>
87+ <div id="ovInc"></div>
88+ </div>
89+ </div>
90+ <div class="ov-cols">
91+ <div class="panel">
92+ <div class="panel-h"><h3>Claude Code</h3><button class="linklike" data-goto="sessions">sessions →</button></div>
93+ <div id="ovClaude"></div>
94+ </div>
95+ <div class="panel">
96+ <div class="panel-h"><h3>Anomalies de trafic</h3><span class="panel-note">dernière heure</span></div>
97+ <div id="ovAnom"></div>
98+ </div>
99+ </div>
100+ </section>
69 101
70− <footer class="composer"> 102+ <!-- ======== Vue CHAT (Claude Code) ======== -->
71− <div class="composer-inner"> 103+ <section id="view-chat" class="view">
72− <button id="upgradeBtn" class="wand-btn" title="Améliorer le prompt (Opus 4.8 + savoir Groupe KA)"><svg viewBox="0 0 24 24"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linejoin="round"/></svg></button> 104+ <div class="chatbar">
73− <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea> 105+ <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select>
74− <button id="stopBtn" class="send-btn stop hidden" title="Arrêter"><svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg></button> 106+ <button id="chipModel" class="chip" title="Modèle">opus</button>
75− <button id="sendBtn" class="send-btn" title="Envoyer"><svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></button> 107+ <button id="chipPerm" class="chip" title="Permissions">normal</button>
76− </div> 108+ <button id="chipMore" class="chip ghost" title="Options de session">⋯</button>
77− <div id="slashHint" class="slash-hint hidden"></div> 109+ </div>
78− </footer> 110+ <div id="sessMeta" class="sess-meta hidden">
79− </section> 111+ <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span>
80− 112+ </div>
81− <!-- ======== Vue SESSIONS ======== -->
82− <section id="view-sessions" class="view hidden">
83− <div class="view-head">
84− <h2>Sessions</h2>
85− <button id="newSessBtn" class="btn-cta small">+ Nouvelle</button>
86− </div>
87− <div id="sessList" class="sess-list"></div>
88− </section>
89−
90− <!-- ======== Vue PROMPTS (bank) ======== -->
91− <section id="view-prompts" class="view hidden">
92− <div class="view-head">
93− <h2>Prompts</h2>
94− <button id="newPromptBtn" class="btn-cta small">+ Nouveau</button>
95− </div>
96− <div id="promptList" class="sess-list"></div>
97− </section>
98 113
99− <!-- ======== Vue ÉCOSYSTÈME ======== --> 114+ <main id="messages" class="messages"></main>
100− <section id="view-eco" class="view hidden"> 115+
101− <div class="view-head"> 116+ <div id="welcome" class="welcome">
102− <h2>Écosystème</h2> 117+ <div class="welcome-inner">
103− <div class="head-actions"> 118+ <div class="ka-mark big">Ka</div>
104− <button id="incBtn" class="chip">Incidents</button> 119+ <h2>Claude Code</h2>
105− <span id="ecoStamp" class="eco-stamp"></span> 120+ <p>Le vrai Claude Code, lancé sur le nœud de chaque app du Groupe KA.</p>
121+ <div class="hints">
122+ <button class="hint">Quel modèle es-tu et dans quel répertoire travailles-tu ?</button>
123+ <button class="hint">Montre les 5 derniers commits</button>
124+ <button class="hint">État PM2 de l'app</button>
125+ </div>
126+ <div id="welcomeRecent" class="welcome-recent"></div>
127+ </div>
106 </div> 128 </div>
107− </div> 129+
108− <div id="ecoOverview" class="eco-overview"></div> 130+ <footer class="composer">
109− <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div> 131+ <div class="composer-inner">
110− <h3 class="eco-subhead">Nœuds du cluster</h3> 132+ <button id="upgradeBtn" class="wand-btn" title="Améliorer le prompt (Opus 4.8 + savoir Groupe KA)"><svg viewBox="0 0 24 24"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linejoin="round"/></svg></button>
111− <div id="ecoNodes" class="node-grid"></div> 133+ <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea>
112− </section> 134+ <button id="stopBtn" class="send-btn stop hidden" title="Arrêter"><svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg></button>
113− 135+ <button id="sendBtn" class="send-btn" title="Envoyer"><svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></button>
114− <!-- ======== Vue RÉGLAGES ======== --> 136+ </div>
115− <section id="view-settings" class="view hidden"> 137+ <div id="slashHint" class="slash-hint hidden"></div>
116− <div class="view-head"><h2>Réglages</h2></div> 138+ </footer>
117− <div class="set-card"> 139+ </section>
118− <div class="set-label">Modèle par défaut</div> 140+
119− <div class="seg" id="segModel"> 141+ <!-- ======== Vue SESSIONS ======== -->
120− <button data-v="fable">Fable 5</button><button data-v="opus">Opus 5</button><button data-v="sonnet">Sonnet 5</button><button data-v="haiku">Haiku</button> 142+ <section id="view-sessions" class="view scroll hidden">
143+ <div class="view-head">
144+ <h2>Sessions</h2>
145+ <button id="newSessBtn" class="btn-cta small">+ Nouvelle</button>
121 </div> 146 </div>
122− </div> 147+ <div class="filterbar">
123− <div class="set-card"> 148+ <input id="sessSearch" class="f-input" type="search" placeholder="Rechercher…">
124− <div class="set-label">Permissions par défaut</div> 149+ <select id="sessApp" class="f-select"><option value="">Toutes les apps</option></select>
125− <div class="seg vert" id="segPerm"> 150+ <select id="sessState" class="f-select">
126− <button data-v="default">Normal — demande dans le chat</button> 151+ <option value="">Tous les statuts</option>
127− <button data-v="acceptEdits">acceptEdits — éditions auto</button> 152+ <option value="running">En cours</option>
128− <button data-v="plan">Plan — proposer avant d'agir</button> 153+ <option value="waiting_perm">Permission</option>
129− <button data-v="bypass">⚠ Tout autoriser (bypass)</button> 154+ <option value="idle">Terminées</option>
155+ </select>
130 </div> 156 </div>
131− </div> 157+ <div id="sessList" class="sess-list"></div>
132− <div class="set-card"> 158+ </section>
133− <div class="set-label">Conversation active</div> 159+
134− <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div> 160+ <!-- ======== Vue PROMPTS ======== -->
135− <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div> 161+ <section id="view-prompts" class="view scroll hidden">
136− <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div> 162+ <div class="view-head">
137− <div class="set-actions"> 163+ <h2>Prompts</h2>
138− <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button> 164+ <button id="newPromptBtn" class="btn-cta small">+ Nouveau</button>
139− <button id="btnCompact" class="btn-ghost">/compact — compacter</button>
140 </div> 165 </div>
141− </div> 166+ <div class="filterbar">
142− <div class="set-card"> 167+ <input id="promptSearch" class="f-input" type="search" placeholder="Rechercher un prompt…">
143− <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div> 168+ <select id="promptApp" class="f-select"><option value="">Toutes les apps</option></select>
144− <div class="set-row"><span>Version</span><span class="mono">2.0.0</span></div> 169+ </div>
145− </div> 170+ <div id="promptList" class="sess-list"></div>
146− <button id="logoutBtn" class="btn-danger">Se déconnecter</button> 171+ </section>
147− </section>
148 172
149− <!-- ======== Vue SOCIAL ======== --> 173+ <!-- ======== Vue ÉCOSYSTÈME ======== -->
150− <section id="view-social" class="view hidden"> 174+ <section id="view-eco" class="view scroll hidden">
151− <div class="view-head"><h2>🎨 Studio</h2><button id="stRefresh" class="btn-ghost small">↻ Galerie</button></div> 175+ <div class="view-head">
152− <p class="soc-sub" style="margin:-4px 0 14px">Génère des visuels prêts à publier — télécharge-les et copie le texte. Rien n'est publié automatiquement.</p> 176+ <h2>Écosystème</h2>
177+ <div class="head-actions">
178+ <button id="incBtn" class="chip">Incidents</button>
179+ <span id="ecoStamp" class="eco-stamp"></span>
180+ </div>
181+ </div>
182+ <div id="ecoOverview" class="eco-overview"></div>
183+ <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div>
184+ <h3 class="eco-subhead">Nœuds du cluster</h3>
185+ <div id="ecoNodes" class="node-grid"></div>
186+ </section>
153 187
154− <div class="st-grid"> 188+ <!-- ======== Vue VISITEURS (analytics humains) ======== -->
155− <div class="soc-card st-gen"> 189+ <section id="view-visitors" class="view scroll hidden">
156− <h3>🖼️ Publication</h3> 190+ <div class="view-head">
157− <textarea id="stPostPrompt" rows="2" placeholder="Sujet (optionnel). Ex : dernière propriété à vendre sur Immo-Ka."></textarea> 191+ <h2>Visiteurs</h2>
158− <div class="soc-actions"> 192+ <div class="head-actions">
159− <select id="stPostSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select> 193+ <div class="seg mini" id="visMode">
160− <button id="stPostGen" class="btn-cta small">✨ Générer une image</button> 194+ <button data-v="human" class="on">Humains</button>
195+ <button data-v="all">Tout le trafic</button>
196+ </div>
197+ <span id="visLive" class="eco-stamp"></span>
161 </div> 198 </div>
162 </div> 199 </div>
163− <div class="soc-card st-gen"> 200+
164− <h3>🎬 Reel vidéo</h3> 201+ <div id="visGlobal" class="ov-kpis compact"></div>
165− <textarea id="stReelPrompt" rows="2" placeholder="Sujet (optionnel). Ex : le dernier logement en annonce sur Lou-Ka."></textarea> 202+ <div id="visBreak" class="panel slim"></div>
166− <div class="soc-actions"> 203+
167− <select id="stReelSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select> 204+ <div class="ov-cols">
168− <button id="stReelGen" class="btn-cta small">🎬 Générer un reel</button> 205+ <div class="panel">
206+ <div class="panel-h"><h3>Temps réel — humains actifs</h3><span id="rtCount" class="panel-note"></span></div>
207+ <div id="visRealtime" class="rt-list"><div class="soc-sub">Chargement…</div></div>
169 </div> 208 </div>
170− <div class="soc-actions" style="margin-top:8px"> 209+ <div class="panel">
171− <button id="stReel10" class="btn-ghost small">🔥 Générer 10 reels</button> 210+ <div class="panel-h"><h3>Anomalies</h3><button class="linklike" id="visDebugBtn">🔬 inspecter une IP / un visiteur</button></div>
172− <span id="stGenStatus" class="soc-sub"></span> 211+ <div id="visAnom"></div>
173 </div> 212 </div>
174 </div> 213 </div>
175− </div>
176 214
177− <div class="view-head" style="margin-top:18px"><h2 style="font-size:17px">Galerie</h2><span id="stCount" class="soc-sub"></span></div> 215+ <div class="view-head" style="margin-top:16px"><h3 class="sub-h">Par application</h3></div>
178− <div id="stGallery" class="st-gallery"><div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div></div> 216+ <div id="visGrid" class="vis-grid"><div class="soc-sub">Chargement…</div></div>
179− </section> 217+ <p class="defs-note">Définitions — <b>humain</b> : navigateur réel, réseau non-datacenter, JS exécuté · <b>en ligne</b> : activité &lt; 2 min (expire seul) · <b>page vue</b> : navigation réelle (jamais un heartbeat) · <b>session</b> : inactivité &gt; 30 min = nouvelle session. Datacenters, crawlers, monitoring et trafic interne sont exclus des métriques principales.</p>
180− 218+ </section>
181−<section id="view-visitors" class="view hidden"> 219+
182− <div class="view-head"><h2>👥 Visiteurs</h2><span id="visLive" class="soc-sub"></span></div> 220+ <!-- ======== Vue RÉGLAGES ======== -->
183− <div id="visGlobal" class="vis-global"></div> 221+ <section id="view-settings" class="view scroll hidden">
184− <div id="visGrid" class="vis-grid"><div class="soc-sub">Chargement…</div></div> 222+ <div class="view-head"><h2>Réglages</h2></div>
185− </section> 223+ <div class="set-card">
186− 224+ <div class="set-label">Apparence</div>
187− <!-- ======== Bottom nav ======== --> 225+ <div class="seg" id="segTheme">
188− <nav class="bottomnav"> 226+ <button data-v="auto">Auto</button><button data-v="light">Clair</button><button data-v="dark">Sombre</button>
189− <button data-view="chat" class="nav-btn active"> 227+ </div>
190− <svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg> 228+ </div>
191− <span>Chat</span><i class="badge hidden"></i> 229+ <div class="set-card">
192− </button> 230+ <div class="set-label">Modèle par défaut</div>
193− <button data-view="sessions" class="nav-btn"> 231+ <div class="seg" id="segModel">
194− <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> 232+ <button data-v="fable">Fable 5</button><button data-v="opus">Opus 5</button><button data-v="sonnet">Sonnet 5</button><button data-v="haiku">Haiku</button>
195− <span>Sessions</span><i class="badge hidden"></i> 233+ </div>
196− </button> 234+ </div>
197− <button data-view="prompts" class="nav-btn"> 235+ <div class="set-card">
198− <svg viewBox="0 0 24 24"><path d="M7 3h10a2 2 0 012 2v16l-7-4-7 4V5a2 2 0 012-2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg> 236+ <div class="set-label">Permissions par défaut</div>
199− <span>Prompts</span><i class="badge hidden"></i> 237+ <div class="seg vert" id="segPerm">
200− </button> 238+ <button data-v="default">Normal — demande dans le chat</button>
201− <button data-view="eco" class="nav-btn"> 239+ <button data-v="acceptEdits">acceptEdits — éditions auto</button>
202− <svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> 240+ <button data-v="plan">Plan — proposer avant d'agir</button>
203− <span>Écosystème</span><i class="badge hidden"></i> 241+ <button data-v="bypass">⚠ Tout autoriser (bypass)</button>
204− </button> 242+ </div>
205− <button data-view="social" class="nav-btn"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H4z M8 20h8" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 8l5 2.5-5 2.5z" fill="currentColor"/></svg><span>Studio</span><i class="badge hidden"></i></button> 243+ </div>
206− <button data-view="visitors" class="nav-btn"><svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M3.5 19a5.5 5.5 0 0111 0" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/><path d="M16 5.2a3 3 0 010 5.6M18 19a5.5 5.5 0 00-3-4.9" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg><span>Visiteurs</span><i class="badge hidden"></i></button><button data-view="settings" class="nav-btn"> 244+ <div class="set-card">
207− <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> 245+ <div class="set-label">Analytics — définitions &amp; filtres</div>
208− <span>Réglages</span><i class="badge hidden"></i> 246+ <div id="anCfg" class="an-cfg"><div class="soc-sub">Chargement…</div></div>
209− </button> 247+ </div>
210− </nav> 248+ <div class="set-card">
249+ <div class="set-label">Conversation active</div>
250+ <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div>
251+ <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div>
252+ <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div>
253+ <div class="set-actions">
254+ <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button>
255+ <button id="btnCompact" class="btn-ghost">/compact — compacter</button>
256+ </div>
257+ </div>
258+ <div class="set-card">
259+ <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div>
260+ <div class="set-row"><span>Version</span><span class="mono">3.0.0</span></div>
261+ </div>
262+ <button id="logoutBtn" class="btn-danger">Se déconnecter</button>
263+ </section>
264+
265+ <!-- ======== Vue STUDIO ======== -->
266+ <section id="view-social" class="view scroll hidden">
267+ <div class="view-head"><h2>🎨 Studio</h2><button id="stRefresh" class="btn-ghost small">↻ Galerie</button></div>
268+ <p class="soc-sub" style="margin:-4px 0 14px">Génère des visuels prêts à publier — télécharge-les et copie le texte. Rien n'est publié automatiquement.</p>
269+
270+ <div class="st-grid">
271+ <div class="soc-card st-gen">
272+ <h3>🖼️ Publication</h3>
273+ <textarea id="stPostPrompt" rows="2" placeholder="Sujet (optionnel). Ex : dernière propriété à vendre sur Immo-Ka."></textarea>
274+ <div class="soc-actions">
275+ <select id="stPostSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select>
276+ <button id="stPostGen" class="btn-cta small">✨ Générer une image</button>
277+ </div>
278+ </div>
279+ <div class="soc-card st-gen">
280+ <h3>🎬 Reel vidéo</h3>
281+ <textarea id="stReelPrompt" rows="2" placeholder="Sujet (optionnel). Ex : le dernier logement en annonce sur Lou-Ka."></textarea>
282+ <div class="soc-actions">
283+ <select id="stReelSite" class="soc-select"><option value="">Choix auto</option><option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option></select>
284+ <button id="stReelGen" class="btn-cta small">🎬 Générer un reel</button>
285+ </div>
286+ <div class="soc-actions" style="margin-top:8px">
287+ <button id="stReel10" class="btn-ghost small">🔥 Générer 10 reels</button>
288+ <span id="stGenStatus" class="soc-sub"></span>
289+ </div>
290+ </div>
291+ </div>
292+
293+ <div class="view-head" style="margin-top:18px"><h2 style="font-size:17px">Galerie</h2><span id="stCount" class="soc-sub"></span></div>
294+ <div id="stGallery" class="st-gallery"><div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div></div>
295+ </section>
296+
297+ <!-- ======== Bottom nav (mobile) ======== -->
298+ <nav class="bottomnav" id="bottomNav"></nav>
299+ </div>
211 300
212 <!-- ======== Feuille détail (éco / options) ======== --> 301 <!-- ======== Feuille détail (éco / options) ======== -->
213 <div id="sheet" class="sheet hidden"> 302 <div id="sheet" class="sheet hidden">
added public/ka-a.js +71 −0
@@ -0,0 +1,71 @@
1+/* ka-a.js v2 — analytics first-party Groupe KA (administration-ka.com)
2+ * Léger, asynchrone, sans cookie tiers, sans fingerprinting, sans contenu saisi.
3+ * - visitor_id (KA_VID, localStorage) : anonyme, persistant, dédup refresh
4+ * - session_id (KA_SID) : nouvelle session après 30 min d'inactivité
5+ * - page view : chargement + navigations SPA (pushState/replaceState/popstate)
6+ * - heartbeat 25 s : SEULEMENT si l'onglet est visible ET activité < 60 s
7+ * (scroll/pointeur/clavier/touch — on note QU'une interaction a eu lieu, jamais son contenu)
8+ * → un onglet fermé ou inactif disparaît du compteur « en ligne » en ~2 min. */
9+(function () {
10+ try {
11+ if (window.__kaAn2) return; window.__kaAn2 = 1;
12+ var HOST = 'https://www.administration-ka.com';
13+ var SITE = window.__kaSite || (document.currentScript && document.currentScript.dataset.site) || '';
14+ if (!SITE) return;
15+ var SESS_MS = 30 * 60 * 1000, HB_MS = 25 * 1000, INT_MS = 60 * 1000;
16+
17+ function rid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
18+ function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
19+ function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
20+
21+ var vid = lsGet('KA_VID'); if (!vid) { vid = rid(); lsSet('KA_VID', vid); }
22+ function sid() {
23+ var now = Date.now(), s = lsGet('KA_SID'), last = +(lsGet('KA_SLAST') || 0);
24+ if (!s || now - last > SESS_MS) { s = rid(); lsSet('KA_SID', s); }
25+ lsSet('KA_SLAST', String(now));
26+ return s;
27+ }
28+ var dev = (function () { var w = Math.min(screen.width, screen.height) || innerWidth; return w <= 500 ? 'm' : w <= 900 ? 't' : 'd'; })();
29+
30+ function send(ev, withRef) {
31+ var u = HOST + '/collect?site=' + encodeURIComponent(SITE)
32+ + '&vid=' + vid + '&sid=' + sid() + '&ev=' + ev
33+ + '&p=' + encodeURIComponent(location.pathname)
34+ + '&d=' + dev
35+ + (withRef && document.referrer && document.referrer.indexOf(location.host) === -1
36+ ? '&r=' + encodeURIComponent(document.referrer.slice(0, 180)) : '')
37+ + '&t=' + Date.now();
38+ if (navigator.sendBeacon) { try { navigator.sendBeacon(u); return; } catch (e) {} }
39+ var i = new Image(); i.src = u;
40+ }
41+
42+ var lastPath = location.pathname;
43+ function pv() { lastPath = location.pathname; send('pv', true); }
44+
45+ // navigation SPA = nouvelle page vue (jamais un heartbeat)
46+ function hookHistory(fn) {
47+ var orig = history[fn];
48+ history[fn] = function () { var r = orig.apply(this, arguments); onNav(); return r; };
49+ }
50+ function onNav() { if (location.pathname !== lastPath) setTimeout(pv, 0); }
51+ hookHistory('pushState'); hookHistory('replaceState');
52+ addEventListener('popstate', onNav);
53+
54+ // présence réelle : interaction récente + onglet visible
55+ var lastInt = Date.now();
56+ function bump() { lastInt = Date.now(); }
57+ ['pointerdown', 'keydown', 'scroll', 'touchstart', 'mousemove'].forEach(function (e) {
58+ addEventListener(e, bump, { passive: true, capture: true });
59+ });
60+ setInterval(function () {
61+ if (document.visibilityState !== 'visible') return;
62+ if (Date.now() - lastInt > INT_MS) return;
63+ send('hb');
64+ }, HB_MS);
65+ document.addEventListener('visibilitychange', function () {
66+ if (document.visibilityState === 'visible') { bump(); send('hb'); }
67+ });
68+
69+ pv();
70+ } catch (e) {}
71+})();
modified public/style.css +303 −61
@@ -20,8 +20,8 @@
20 --r-lg: 20px; 20 --r-lg: 20px;
21 --r-md: 14px; 21 --r-md: 14px;
22 --r-sm: 10px; 22 --r-sm: 10px;
23− --bshadow: 3px 3px 0 var(--ink); 23+ --bshadow: 3px 3px 0 var(--shadow);
24− --bshadow-sm: 2px 2px 0 var(--ink); 24+ --bshadow-sm: 2px 2px 0 var(--shadow);
25 --z-banner: 140; 25 --z-banner: 140;
26 --z-header: 100; 26 --z-header: 100;
27 --z-nav: 110; 27 --z-nav: 110;
@@ -47,7 +47,7 @@ button { font-family: inherit; color: inherit; }
47 display: inline-flex; align-items: center; justify-content: center; 47 display: inline-flex; align-items: center; justify-content: center;
48 width: 34px; height: 34px; border-radius: 11px; 48 width: 34px; height: 34px; border-radius: 11px;
49 background: var(--orange); color: var(--ink); 49 background: var(--orange); color: var(--ink);
50− border: 2px solid var(--ink); box-shadow: var(--bshadow-sm); 50+ border: 2px solid var(--edge); box-shadow: var(--bshadow-sm);
51 font-weight: 800; font-size: 15px; letter-spacing: -0.2px; 51 font-weight: 800; font-size: 15px; letter-spacing: -0.2px;
52 flex-shrink: 0; 52 flex-shrink: 0;
53 } 53 }
@@ -56,15 +56,15 @@ button { font-family: inherit; color: inherit; }
56 56
57 .btn-cta { 57 .btn-cta {
58 background: var(--orange); color: var(--ink); 58 background: var(--orange); color: var(--ink);
59− border: 2px solid var(--ink); border-radius: 999px; 59+ border: 2px solid var(--edge); border-radius: 999px;
60 box-shadow: var(--bshadow); 60 box-shadow: var(--bshadow);
61 font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer; 61 font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer;
62 transition: transform 0.08s, box-shadow 0.08s; 62 transition: transform 0.08s, box-shadow 0.08s;
63 } 63 }
64−.btn-cta:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); } 64+.btn-cta:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--shadow); }
65 .btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); } 65 .btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); }
66 .btn-ghost { 66 .btn-ghost {
67− background: var(--card); border: 1.5px solid var(--ink); border-radius: 999px; 67+ background: var(--card); border: 1.5px solid var(--edge); border-radius: 999px;
68 font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer; 68 font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer;
69 } 69 }
70 .btn-danger { 70 .btn-danger {
@@ -82,15 +82,15 @@ button { font-family: inherit; color: inherit; }
82 } 82 }
83 .login-box { 83 .login-box {
84 width: 100%; max-width: 360px; 84 width: 100%; max-width: 360px;
85− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-xl); 85+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-xl);
86− box-shadow: 6px 6px 0 var(--ink); 86+ box-shadow: 6px 6px 0 var(--shadow);
87 padding: 34px 26px 26px; 87 padding: 34px 26px 26px;
88 display: flex; flex-direction: column; gap: 13px; text-align: center; 88 display: flex; flex-direction: column; gap: 13px; text-align: center;
89 } 89 }
90 .login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; } 90 .login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; }
91 .login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; } 91 .login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; }
92 .login input { 92 .login input {
93− background: var(--bg); border: 2px solid var(--ink); color: var(--ink); 93+ background: var(--bg); border: 2px solid var(--edge); color: var(--ink);
94 border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none; 94 border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none;
95 font-family: inherit; 95 font-family: inherit;
96 } 96 }
@@ -105,14 +105,14 @@ button { font-family: inherit; color: inherit; }
105 position: sticky; top: 0; z-index: var(--z-header); 105 position: sticky; top: 0; z-index: var(--z-header);
106 display: flex; align-items: center; justify-content: space-between; gap: 10px; 106 display: flex; align-items: center; justify-content: space-between; gap: 10px;
107 background: var(--bg); 107 background: var(--bg);
108− border-bottom: 2px solid var(--ink); 108+ border-bottom: 2px solid var(--edge);
109 padding: 8px 14px; 109 padding: 8px 14px;
110 padding-top: calc(8px + env(safe-area-inset-top)); 110 padding-top: calc(8px + env(safe-area-inset-top));
111 } 111 }
112 .brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; } 112 .brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; }
113 .conn-pill { 113 .conn-pill {
114 display: flex; align-items: center; gap: 6px; 114 display: flex; align-items: center; gap: 6px;
115− border: 1.5px solid var(--ink); border-radius: 999px; 115+ border: 1.5px solid var(--edge); border-radius: 999px;
116 background: var(--card); font-size: 11.5px; font-weight: 700; 116 background: var(--card); font-size: 11.5px; font-weight: 700;
117 padding: 4px 10px; 117 padding: 4px 10px;
118 } 118 }
@@ -125,7 +125,7 @@ button { font-family: inherit; color: inherit; }
125 .banners { position: fixed; top: calc(52px + env(safe-area-inset-top)); left: 0; right: 0; z-index: var(--z-banner); display: flex; flex-direction: column; gap: 8px; padding: 8px 14px; pointer-events: none; } 125 .banners { position: fixed; top: calc(52px + env(safe-area-inset-top)); left: 0; right: 0; z-index: var(--z-banner); display: flex; flex-direction: column; gap: 8px; padding: 8px 14px; pointer-events: none; }
126 .banner { 126 .banner {
127 pointer-events: auto; cursor: pointer; 127 pointer-events: auto; cursor: pointer;
128− border: 2px solid var(--ink); border-radius: var(--r-md); 128+ border: 2px solid var(--edge); border-radius: var(--r-md);
129 box-shadow: var(--bshadow); padding: 11px 14px; 129 box-shadow: var(--bshadow); padding: 11px 14px;
130 font-weight: 700; font-size: 13.5px; 130 font-weight: 700; font-size: 13.5px;
131 display: flex; gap: 10px; align-items: center; 131 display: flex; gap: 10px; align-items: center;
@@ -148,7 +148,7 @@ button { font-family: inherit; color: inherit; }
148 position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav); 148 position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav);
149 display: flex; 149 display: flex;
150 background: var(--card); 150 background: var(--card);
151− border-top: 2px solid var(--ink); 151+ border-top: 2px solid var(--edge);
152 padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); 152 padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
153 } 153 }
154 .nav-btn { 154 .nav-btn {
@@ -164,7 +164,7 @@ button { font-family: inherit; color: inherit; }
164 .nav-btn .badge { 164 .nav-btn .badge {
165 position: absolute; top: 3px; right: calc(50% - 17px); 165 position: absolute; top: 3px; right: calc(50% - 17px);
166 width: 9px; height: 9px; border-radius: 50%; 166 width: 9px; height: 9px; border-radius: 50%;
167− background: var(--orange); border: 1.5px solid var(--ink); 167+ background: var(--orange); border: 1.5px solid var(--edge);
168 } 168 }
169 169
170 /* ================= Vue chat ================= */ 170 /* ================= Vue chat ================= */
@@ -178,7 +178,7 @@ button { font-family: inherit; color: inherit; }
178 .pill-select { 178 .pill-select {
179 appearance: none; -webkit-appearance: none; 179 appearance: none; -webkit-appearance: none;
180 background: var(--card); color: var(--ink); 180 background: var(--card); color: var(--ink);
181− border: 2px solid var(--ink); border-radius: 999px; 181+ border: 2px solid var(--edge); border-radius: 999px;
182 font-weight: 800; font-size: 13.5px; font-family: inherit; 182 font-weight: 800; font-size: 13.5px; font-family: inherit;
183 padding: 7px 26px 7px 13px; 183 padding: 7px 26px 7px 13px;
184 box-shadow: var(--bshadow-sm); 184 box-shadow: var(--bshadow-sm);
@@ -187,7 +187,7 @@ button { font-family: inherit; color: inherit; }
187 background-repeat: no-repeat; background-position: right 9px center; background-size: 12px; 187 background-repeat: no-repeat; background-position: right 9px center; background-size: 12px;
188 } 188 }
189 .chip { 189 .chip {
190− border: 1.5px solid var(--ink); border-radius: 999px; 190+ border: 1.5px solid var(--edge); border-radius: 999px;
191 background: var(--card2); font-weight: 700; font-size: 12.5px; 191 background: var(--card2); font-weight: 700; font-size: 12.5px;
192 padding: 7px 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; 192 padding: 7px 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0;
193 } 193 }
@@ -212,14 +212,14 @@ button { font-family: inherit; color: inherit; }
212 .msg-user { 212 .msg-user {
213 align-self: flex-end; max-width: 86%; 213 align-self: flex-end; max-width: 86%;
214 background: var(--orange); color: var(--ink); 214 background: var(--orange); color: var(--ink);
215− border: 2px solid var(--ink); border-radius: var(--r-lg) var(--r-lg) 6px var(--r-lg); 215+ border: 2px solid var(--edge); border-radius: var(--r-lg) var(--r-lg) 6px var(--r-lg);
216 box-shadow: var(--bshadow-sm); 216 box-shadow: var(--bshadow-sm);
217 padding: 10px 15px; font-weight: 600; 217 padding: 10px 15px; font-weight: 600;
218 white-space: pre-wrap; word-break: break-word; 218 white-space: pre-wrap; word-break: break-word;
219 } 219 }
220 .msg-claude { 220 .msg-claude {
221 align-self: stretch; 221 align-self: stretch;
222− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); 222+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
223 box-shadow: var(--bshadow-sm); 223 box-shadow: var(--bshadow-sm);
224 padding: 13px 16px; word-break: break-word; 224 padding: 13px 16px; word-break: break-word;
225 } 225 }
@@ -227,7 +227,7 @@ button { font-family: inherit; color: inherit; }
227 .msg-claude .md > :last-child { margin-bottom: 0; } 227 .msg-claude .md > :last-child { margin-bottom: 0; }
228 .md p { margin: 0.55em 0; } 228 .md p { margin: 0.55em 0; }
229 .md pre { 229 .md pre {
230− background: #f2efe7; border: 1.5px solid var(--line); border-radius: var(--r-sm); 230+ background: var(--code-bg); border: 1.5px solid var(--line); border-radius: var(--r-sm);
231 padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5; 231 padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5;
232 } 232 }
233 .md code { font-family: var(--mono); font-size: 0.88em; } 233 .md code { font-family: var(--mono); font-size: 0.88em; }
@@ -261,7 +261,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
261 261
262 /* --- cartes outils --- */ 262 /* --- cartes outils --- */
263 .tool { 263 .tool {
264− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md); 264+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-md);
265 overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm); 265 overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm);
266 } 266 }
267 .tool summary { 267 .tool summary {
@@ -271,7 +271,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
271 .tool summary::-webkit-details-marker { display: none; } 271 .tool summary::-webkit-details-marker { display: none; }
272 .tool .t-icon { 272 .tool .t-icon {
273 flex-shrink: 0; width: 28px; height: 28px; border-radius: 9px; 273 flex-shrink: 0; width: 28px; height: 28px; border-radius: 9px;
274− border: 1.5px solid var(--ink); 274+ border: 1.5px solid var(--edge);
275 display: flex; align-items: center; justify-content: center; 275 display: flex; align-items: center; justify-content: center;
276 font-size: 13px; background: var(--card2); 276 font-size: 13px; background: var(--card2);
277 } 277 }
@@ -288,7 +288,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
288 .tool .t-state.pending { color: var(--warn); background: var(--warn-soft); } 288 .tool .t-state.pending { color: var(--warn); background: var(--warn-soft); }
289 .tool .t-state.ok { color: var(--good); background: var(--good-soft); } 289 .tool .t-state.ok { color: var(--good); background: var(--good-soft); }
290 .tool .t-state.err { color: var(--bad); background: var(--bad-soft); } 290 .tool .t-state.err { color: var(--bad); background: var(--bad-soft); }
291−.tool-body { border-top: 2px solid var(--ink); padding: 9px 13px 11px; background: var(--card2); } 291+.tool-body { border-top: 2px solid var(--edge); padding: 9px 13px 11px; background: var(--card2); }
292 .tool-body pre { 292 .tool-body pre {
293 margin: 6px 0; background: var(--card); border: 1.5px solid var(--line); 293 margin: 6px 0; background: var(--card); border: 1.5px solid var(--line);
294 border-radius: var(--r-sm); padding: 9px; 294 border-radius: var(--r-sm); padding: 9px;
@@ -300,8 +300,8 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
300 300
301 .diff { font-family: var(--mono); font-size: 12px; margin: 6px 0; border: 1.5px solid var(--line); border-radius: var(--r-sm); overflow: hidden; background: var(--card); } 301 .diff { font-family: var(--mono); font-size: 12px; margin: 6px 0; border: 1.5px solid var(--line); border-radius: var(--r-sm); overflow: hidden; background: var(--card); }
302 .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; } 302 .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; }
303−.diff .del { background: #ffe4e1; color: #8c1622; } 303+.diff .del { background: var(--del-bg); color: var(--del-ink); }
304−.diff .add { background: #ddf3e4; color: #0c5326; } 304+.diff .add { background: var(--add-bg); color: var(--add-ink); }
305 305
306 .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; } 306 .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; }
307 .todos li { padding: 3px 0; } 307 .todos li { padding: 3px 0; }
@@ -310,9 +310,9 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
310 310
311 /* --- permission --- */ 311 /* --- permission --- */
312 .perm { 312 .perm {
313− background: var(--warn-soft); border: 2px solid var(--ink); 313+ background: var(--warn-soft); border: 2px solid var(--edge);
314 border-radius: var(--r-lg); padding: 14px; font-size: 13.5px; 314 border-radius: var(--r-lg); padding: 14px; font-size: 13.5px;
315− box-shadow: 4px 4px 0 var(--ink); 315+ box-shadow: 4px 4px 0 var(--shadow);
316 } 316 }
317 .perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; } 317 .perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; }
318 .perm pre { 318 .perm pre {
@@ -325,7 +325,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
325 .perm-actions button { 325 .perm-actions button {
326 flex: 1; padding: 11px 6px; border-radius: 999px; 326 flex: 1; padding: 11px 6px; border-radius: 999px;
327 font-size: 13.5px; font-weight: 800; cursor: pointer; min-height: 46px; 327 font-size: 13.5px; font-weight: 800; cursor: pointer; min-height: 46px;
328− border: 2px solid var(--ink); 328+ border: 2px solid var(--edge);
329 } 329 }
330 .perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); } 330 .perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); }
331 .perm-always { background: var(--good-soft); color: var(--good); line-height: 1.05; } 331 .perm-always { background: var(--good-soft); color: var(--good); line-height: 1.05; }
@@ -333,7 +333,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
333 .perm-deny { background: var(--card); color: var(--bad); } 333 .perm-deny { background: var(--card); color: var(--bad); }
334 .perm-allall { 334 .perm-allall {
335 width: 100%; margin-top: 8px; padding: 12px; border-radius: 999px; 335 width: 100%; margin-top: 8px; padding: 12px; border-radius: 999px;
336− border: 2px solid var(--ink); background: var(--ink); color: var(--orange); 336+ border: 2px solid var(--edge); background: var(--ink); color: var(--orange);
337 font-weight: 800; font-size: 13.5px; cursor: pointer; box-shadow: var(--bshadow-sm); 337 font-weight: 800; font-size: 13.5px; cursor: pointer; box-shadow: var(--bshadow-sm);
338 } 338 }
339 .perm-allall:active { transform: translate(1px,1px); box-shadow: none; } 339 .perm-allall:active { transform: translate(1px,1px); box-shadow: none; }
@@ -357,11 +357,11 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
357 align-self: flex-start; 357 align-self: flex-start;
358 display: flex; align-items: center; gap: 10px; 358 display: flex; align-items: center; gap: 10px;
359 font-size: 13.5px; font-weight: 700; 359 font-size: 13.5px; font-weight: 700;
360− background: var(--card); border: 2px solid var(--ink); 360+ background: var(--card); border: 2px solid var(--edge);
361 border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm); 361 border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm);
362 } 362 }
363 .dots { display: flex; gap: 4px; } 363 .dots { display: flex; gap: 4px; }
364−.dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1px solid var(--ink); animation: bounce 1.1s ease-in-out infinite; } 364+.dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1px solid var(--edge); animation: bounce 1.1s ease-in-out infinite; }
365 .dots i:nth-child(2) { animation-delay: 0.14s; } 365 .dots i:nth-child(2) { animation-delay: 0.14s; }
366 .dots i:nth-child(3) { animation-delay: 0.28s; } 366 .dots i:nth-child(3) { animation-delay: 0.28s; }
367 @keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-4px); opacity: 1; } } 367 @keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-4px); opacity: 1; } }
@@ -377,7 +377,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
377 .welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; } 377 .welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; }
378 .hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; } 378 .hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; }
379 .hint { 379 .hint {
380− background: var(--card); border: 1.5px solid var(--ink); 380+ background: var(--card); border: 1.5px solid var(--edge);
381 font-size: 13px; font-weight: 700; 381 font-size: 13px; font-weight: 700;
382 border-radius: 999px; padding: 9px 14px; cursor: pointer; 382 border-radius: 999px; padding: 9px 14px; cursor: pointer;
383 box-shadow: var(--bshadow-sm); 383 box-shadow: var(--bshadow-sm);
@@ -393,7 +393,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
393 display: flex; align-items: flex-end; gap: 8px; 393 display: flex; align-items: flex-end; gap: 8px;
394 max-width: 780px; margin: 0 auto; 394 max-width: 780px; margin: 0 auto;
395 background: var(--card); 395 background: var(--card);
396− border: 2px solid var(--ink); border-radius: var(--r-xl); 396+ border: 2px solid var(--edge); border-radius: var(--r-xl);
397 box-shadow: var(--bshadow); 397 box-shadow: var(--bshadow);
398 padding: 6px 6px 6px 6px; gap: 4px; 398 padding: 6px 6px 6px 6px; gap: 4px;
399 } 399 }
@@ -406,7 +406,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
406 #input::placeholder { color: var(--muted); font-weight: 600; } 406 #input::placeholder { color: var(--muted); font-weight: 600; }
407 .send-btn { 407 .send-btn {
408 width: 42px; height: 42px; border-radius: 50%; 408 width: 42px; height: 42px; border-radius: 50%;
409− border: 2px solid var(--ink); 409+ border: 2px solid var(--edge);
410 background: var(--orange); color: var(--ink); 410 background: var(--orange); color: var(--ink);
411 cursor: pointer; flex-shrink: 0; 411 cursor: pointer; flex-shrink: 0;
412 display: flex; align-items: center; justify-content: center; 412 display: flex; align-items: center; justify-content: center;
@@ -419,7 +419,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
419 .send-btn.stop { background: var(--bad); color: #fff; } 419 .send-btn.stop { background: var(--bad); color: #fff; }
420 .wand-btn { 420 .wand-btn {
421 width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0; 421 width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0;
422− border: 2px solid var(--ink); background: var(--card2); color: var(--orange-deep); 422+ border: 2px solid var(--edge); background: var(--card2); color: var(--orange-deep);
423 cursor: pointer; display: flex; align-items: center; justify-content: center; 423 cursor: pointer; display: flex; align-items: center; justify-content: center;
424 align-self: flex-end; margin-bottom: 2px; 424 align-self: flex-end; margin-bottom: 2px;
425 } 425 }
@@ -431,29 +431,29 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
431 max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap; 431 max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap;
432 } 432 }
433 .slash-hint button { 433 .slash-hint button {
434− border: 1.5px solid var(--ink); background: var(--card2); border-radius: 999px; 434+ border: 1.5px solid var(--edge); background: var(--card2); border-radius: 999px;
435 font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer; 435 font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer;
436 } 436 }
437 437
438 /* ================= Sessions ================= */ 438 /* ================= Sessions ================= */
439 .sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; } 439 .sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; }
440 .sess-card { 440 .sess-card {
441− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); 441+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
442 box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer; 442 box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer;
443 display: flex; flex-direction: column; gap: 6px; 443 display: flex; flex-direction: column; gap: 6px;
444 } 444 }
445 .sess-top { display: flex; align-items: center; gap: 8px; } 445 .sess-top { display: flex; align-items: center; gap: 8px; }
446 .sess-title { flex: 1; min-width: 0; font-weight: 800; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 446 .sess-title { flex: 1; min-width: 0; font-weight: 800; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
447−.sess-badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; } 447+.sess-badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--edge); flex-shrink: 0; }
448 .sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; } 448 .sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; }
449 .sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); } 449 .sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); }
450 .sess-badge.idle { background: var(--card2); color: var(--muted); } 450 .sess-badge.idle { background: var(--card2); color: var(--muted); }
451 .batch-tag { background: var(--orange-soft); color: var(--orange-deep); border-radius: 999px; padding: 1px 8px; font-weight: 800; } 451 .batch-tag { background: var(--orange-soft); color: var(--orange-deep); border-radius: 999px; padding: 1px 8px; font-weight: 800; }
452−.pill-select.all-mode { background: var(--orange); box-shadow: 3px 3px 0 var(--ink); } 452+.pill-select.all-mode { background: var(--orange); box-shadow: 3px 3px 0 var(--shadow); }
453 .sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); } 453 .sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); }
454 .sess-actions { display: flex; gap: 8px; margin-top: 4px; } 454 .sess-actions { display: flex; gap: 8px; margin-top: 4px; }
455 .sess-actions button { 455 .sess-actions button {
456− border: 1.5px solid var(--ink); border-radius: 999px; background: var(--card2); 456+ border: 1.5px solid var(--edge); border-radius: 999px; background: var(--card2);
457 font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer; 457 font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer;
458 } 458 }
459 .sess-actions .del { color: var(--bad); } 459 .sess-actions .del { color: var(--bad); }
@@ -467,13 +467,13 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
467 } 467 }
468 .sess-actions .p-send { background: var(--orange); color: var(--ink); border-color: var(--ink); box-shadow: var(--bshadow-sm); } 468 .sess-actions .p-send { background: var(--orange); color: var(--ink); border-color: var(--ink); box-shadow: var(--bshadow-sm); }
469 .pe-input { 469 .pe-input {
470− width: 100%; background: var(--bg); border: 2px solid var(--ink); color: var(--ink); 470+ width: 100%; background: var(--bg); border: 2px solid var(--edge); color: var(--ink);
471 border-radius: var(--r-md); padding: 11px 13px; font-size: 15px; font-family: inherit; font-weight: 600; outline: none; 471 border-radius: var(--r-md); padding: 11px 13px; font-size: 15px; font-family: inherit; font-weight: 600; outline: none;
472 } 472 }
473 .pe-input:focus { border-color: var(--orange); } 473 .pe-input:focus { border-color: var(--orange); }
474 .pe-textarea { 474 .pe-textarea {
475 width: 100%; margin-top: 10px; min-height: 180px; resize: vertical; 475 width: 100%; margin-top: 10px; min-height: 180px; resize: vertical;
476− background: var(--bg); border: 2px solid var(--ink); color: var(--ink); 476+ background: var(--bg); border: 2px solid var(--edge); color: var(--ink);
477 border-radius: var(--r-md); padding: 12px 13px; font-size: 14px; line-height: 1.5; 477 border-radius: var(--r-md); padding: 12px 13px; font-size: 14px; line-height: 1.5;
478 font-family: var(--mono); outline: none; 478 font-family: var(--mono); outline: none;
479 } 479 }
@@ -490,7 +490,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
490 max-width: 780px; margin-left: auto; margin-right: auto; 490 max-width: 780px; margin-left: auto; margin-right: auto;
491 } 491 }
492 .eco-stat { 492 .eco-stat {
493− flex: 1; background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); 493+ flex: 1; background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
494 box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center; 494 box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center;
495 } 495 }
496 .eco-stat .v { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; } 496 .eco-stat .v { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
@@ -504,7 +504,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
504 @media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } } 504 @media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } }
505 .eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; } 505 .eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; }
506 .site-card { 506 .site-card {
507− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); 507+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
508 box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer; 508 box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer;
509 display: flex; flex-direction: column; gap: 8px; 509 display: flex; flex-direction: column; gap: 8px;
510 transition: transform 0.08s; 510 transition: transform 0.08s;
@@ -513,7 +513,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
513 .site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); } 513 .site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); }
514 .site-top { display: flex; align-items: center; gap: 8px; } 514 .site-top { display: flex; align-items: center; gap: 8px; }
515 .site-name { font-weight: 800; font-size: 16px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 515 .site-name { font-weight: 800; font-size: 16px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
516−.st-pill { font-size: 10.5px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; display: flex; gap: 4px; align-items: center; } 516+.st-pill { font-size: 10.5px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--edge); flex-shrink: 0; display: flex; gap: 4px; align-items: center; }
517 .st-pill.up { background: var(--good-soft); color: var(--good); } 517 .st-pill.up { background: var(--good-soft); color: var(--good); }
518 .st-pill.slow { background: var(--warn-soft); color: var(--warn); } 518 .st-pill.slow { background: var(--warn-soft); color: var(--warn); }
519 .st-pill.down { background: var(--bad); color: #fff; } 519 .st-pill.down { background: var(--bad); color: #fff; }
@@ -530,11 +530,11 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
530 .eco-subhead { max-width: 780px; margin: 20px auto 10px; width: 100%; font-size: 15px; font-weight: 800; letter-spacing: -0.3px; } 530 .eco-subhead { max-width: 780px; margin: 20px auto 10px; width: 100%; font-size: 15px; font-weight: 800; letter-spacing: -0.3px; }
531 .node-grid { display: grid; grid-template-columns: 1fr; gap: 10px; max-width: 780px; margin: 0 auto; width: 100%; } 531 .node-grid { display: grid; grid-template-columns: 1fr; gap: 10px; max-width: 780px; margin: 0 auto; width: 100%; }
532 @media (min-width: 560px) { .node-grid { grid-template-columns: 1fr 1fr; } } 532 @media (min-width: 560px) { .node-grid { grid-template-columns: 1fr 1fr; } }
533−.node-card { background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 12px 14px; } 533+.node-card { background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 12px 14px; }
534 .node-card.warn { border-color: var(--warn); } 534 .node-card.warn { border-color: var(--warn); }
535 .node-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; } 535 .node-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
536 .node-name { font-weight: 800; font-size: 15px; font-family: var(--mono); } 536 .node-name { font-weight: 800; font-size: 15px; font-family: var(--mono); }
537−.node-apps { font-size: 11px; font-weight: 800; border: 1.5px solid var(--ink); border-radius: 999px; padding: 2px 9px; background: var(--good-soft); color: var(--good); } 537+.node-apps { font-size: 11px; font-weight: 800; border: 1.5px solid var(--edge); border-radius: 999px; padding: 2px 9px; background: var(--good-soft); color: var(--good); }
538 .node-apps.bad { background: var(--warn-soft); color: var(--warn); } 538 .node-apps.bad { background: var(--warn-soft); color: var(--warn); }
539 .nbar { margin: 5px 0; } 539 .nbar { margin: 5px 0; }
540 .nbar-l { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 2px; } 540 .nbar-l { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 2px; }
@@ -546,7 +546,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
546 .admin-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } 546 .admin-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
547 .admin-actions .btn-ghost { text-align: center; text-decoration: none; } 547 .admin-actions .btn-ghost { text-align: center; text-decoration: none; }
548 .admin-out { margin-top: 10px; } 548 .admin-out { margin-top: 10px; }
549−.admin-out pre { background: var(--ink); color: #e8e6df; border-radius: var(--r-sm); padding: 10px; font-size: 11px; font-family: var(--mono); overflow-x: auto; max-height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; margin: 0; } 549+.admin-out pre { background: var(--term-bg); color: var(--term-ink); border-radius: var(--r-sm); padding: 10px; font-size: 11px; font-family: var(--mono); overflow-x: auto; max-height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
550 .admin-load { color: var(--muted); font-weight: 700; font-size: 13px; } 550 .admin-load { color: var(--muted); font-weight: 700; font-size: 13px; }
551 .admin-ok { color: var(--good); font-weight: 800; } 551 .admin-ok { color: var(--good); font-weight: 800; }
552 .admin-err { color: var(--bad); font-weight: 800; } 552 .admin-err { color: var(--bad); font-weight: 800; }
@@ -558,7 +558,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
558 } 558 }
559 @media (min-width: 560px) { .eco-overview { grid-template-columns: repeat(6, 1fr); } } 559 @media (min-width: 560px) { .eco-overview { grid-template-columns: repeat(6, 1fr); } }
560 .kpi { 560 .kpi {
561− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md); 561+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-md);
562 box-shadow: var(--bshadow-sm); padding: 9px 8px; text-align: center; 562 box-shadow: var(--bshadow-sm); padding: 9px 8px; text-align: center;
563 } 563 }
564 .kpi .v { font-size: 21px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; } 564 .kpi .v { font-size: 21px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; }
@@ -599,7 +599,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
599 .sheet-panel { 599 .sheet-panel {
600 position: absolute; left: 0; right: 0; bottom: 0; 600 position: absolute; left: 0; right: 0; bottom: 0;
601 max-height: 88dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; 601 max-height: 88dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
602− background: var(--bg); border: 2px solid var(--ink); border-bottom: none; 602+ background: var(--bg); border: 2px solid var(--edge); border-bottom: none;
603 border-radius: var(--r-xl) var(--r-xl) 0 0; 603 border-radius: var(--r-xl) var(--r-xl) 0 0;
604 padding: 8px 16px calc(20px + env(safe-area-inset-bottom)); 604 padding: 8px 16px calc(20px + env(safe-area-inset-bottom));
605 animation: sheet-up 0.22s ease-out; 605 animation: sheet-up 0.22s ease-out;
@@ -607,7 +607,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
607 .sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; } 607 .sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; }
608 .sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } 608 .sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
609 .sheet-h h3 { margin: 0; font-size: 21px; font-weight: 800; flex: 1; } 609 .sheet-h h3 { margin: 0; font-size: 21px; font-weight: 800; flex: 1; }
610−.sheet-sec { background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 13px 15px; margin-bottom: 12px; } 610+.sheet-sec { background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 13px 15px; margin-bottom: 12px; }
611 .sheet-sec h4 { margin: 0 0 9px; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); } 611 .sheet-sec h4 { margin: 0 0 9px; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); }
612 .kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; } 612 .kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; }
613 .kv b { font-family: var(--mono); font-weight: 700; } 613 .kv b { font-family: var(--mono); font-weight: 700; }
@@ -629,14 +629,14 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
629 /* ================= Réglages ================= */ 629 /* ================= Réglages ================= */
630 .set-card { 630 .set-card {
631 max-width: 780px; margin: 0 auto 12px; width: 100%; 631 max-width: 780px; margin: 0 auto 12px; width: 100%;
632− background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); 632+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
633 box-shadow: var(--bshadow-sm); padding: 14px 16px; 633 box-shadow: var(--bshadow-sm); padding: 14px 16px;
634 } 634 }
635 .set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; } 635 .set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; }
636 .seg { display: flex; gap: 7px; } 636 .seg { display: flex; gap: 7px; }
637 .seg.vert { flex-direction: column; } 637 .seg.vert { flex-direction: column; }
638 .seg button { 638 .seg button {
639− flex: 1; border: 1.5px solid var(--ink); border-radius: 999px; 639+ flex: 1; border: 1.5px solid var(--edge); border-radius: 999px;
640 background: var(--card2); font-weight: 700; font-size: 13.5px; 640 background: var(--card2); font-weight: 700; font-size: 13.5px;
641 padding: 10px 12px; cursor: pointer; text-align: center; 641 padding: 10px 12px; cursor: pointer; text-align: center;
642 } 642 }
@@ -647,16 +647,16 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
647 647
648 648
649 /* ---- Social ---- */ 649 /* ---- Social ---- */
650−.soc-card{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:16px;padding:14px 16px;margin:0 0 14px;box-shadow:3px 3px 0 var(--ink,#1a1a1a);} 650+.soc-card{background:var(--card,#fff);border:2px solid var(--edge);border-radius:16px;padding:14px 16px;margin:0 0 14px;box-shadow:3px 3px 0 var(--shadow);}
651 .soc-card h3{margin:0 0 10px;font-size:15px;} 651 .soc-card h3{margin:0 0 10px;font-size:15px;}
652 .soc-switch{display:flex;align-items:center;gap:10px;font-weight:700;cursor:pointer;} 652 .soc-switch{display:flex;align-items:center;gap:10px;font-weight:700;cursor:pointer;}
653 .soc-switch input{width:20px;height:20px;accent-color:var(--accent,#f97316);} 653 .soc-switch input{width:20px;height:20px;accent-color:var(--accent,#f97316);}
654 .soc-sub{color:var(--muted,#6b7280);font-size:13px;margin-top:8px;} 654 .soc-sub{color:var(--muted,#6b7280);font-size:13px;margin-top:8px;}
655 .soc-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px;} 655 .soc-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px;}
656−.soc-select{padding:9px 12px;border:2px solid var(--ink,#1a1a1a);border-radius:10px;font:inherit;background:#fff;} 656+.soc-select{padding:9px 12px;border:2px solid var(--edge);border-radius:10px;font:inherit;background:#fff;}
657 .soc-draft{margin-top:14px;display:flex;flex-direction:column;gap:10px;} 657 .soc-draft{margin-top:14px;display:flex;flex-direction:column;gap:10px;}
658−.soc-draft img{width:100%;max-width:340px;border-radius:12px;border:2px solid var(--ink,#1a1a1a);align-self:center;} 658+.soc-draft img{width:100%;max-width:340px;border-radius:12px;border:2px solid var(--edge);align-self:center;}
659−#view-social textarea{width:100%;padding:10px 12px;border:2px solid var(--ink,#1a1a1a);border-radius:10px;font:inherit;resize:vertical;} 659+#view-social textarea{width:100%;padding:10px 12px;border:2px solid var(--edge);border-radius:10px;font:inherit;resize:vertical;}
660 .soc-log{display:flex;flex-direction:column;gap:6px;max-height:340px;overflow-y:auto;} 660 .soc-log{display:flex;flex-direction:column;gap:6px;max-height:340px;overflow-y:auto;}
661 .soc-logrow{font-size:13px;padding:6px 8px;border-radius:8px;background:rgba(0,0,0,.04);} 661 .soc-logrow{font-size:13px;padding:6px 8px;border-radius:8px;background:rgba(0,0,0,.04);}
662 .soc-logt{color:var(--muted,#6b7280);font-variant-numeric:tabular-nums;margin-right:6px;} 662 .soc-logt{color:var(--muted,#6b7280);font-variant-numeric:tabular-nums;margin-right:6px;}
@@ -669,7 +669,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
669 @media(max-width:640px){.st-grid{grid-template-columns:1fr}} 669 @media(max-width:640px){.st-grid{grid-template-columns:1fr}}
670 .st-gen h3{margin:0 0 8px} 670 .st-gen h3{margin:0 0 8px}
671 .st-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:8px} 671 .st-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:8px}
672−.st-item{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:14px;overflow:hidden;box-shadow:3px 3px 0 var(--ink,#1a1a1a);display:flex;flex-direction:column} 672+.st-item{background:var(--card,#fff);border:2px solid var(--edge);border-radius:14px;overflow:hidden;box-shadow:3px 3px 0 var(--shadow);display:flex;flex-direction:column}
673 .st-media{position:relative;aspect-ratio:4/5;background:#0d0d0d;overflow:hidden} 673 .st-media{position:relative;aspect-ratio:4/5;background:#0d0d0d;overflow:hidden}
674 .st-media img,.st-media video{width:100%;height:100%;object-fit:cover;display:block} 674 .st-media img,.st-media video{width:100%;height:100%;object-fit:cover;display:block}
675 .st-badge{position:absolute;left:8px;top:8px;background:rgba(13,13,13,.82);color:#fff;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;letter-spacing:.02em} 675 .st-badge{position:absolute;left:8px;top:8px;background:rgba(13,13,13,.82);color:#fff;font-size:11px;font-weight:800;padding:4px 9px;border-radius:999px;letter-spacing:.02em}
@@ -681,7 +681,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
681 /* ---- Studio loading ---- */ 681 /* ---- Studio loading ---- */
682 #stLoad{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;background:rgba(8,8,10,.72);backdrop-filter:blur(4px)} 682 #stLoad{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;background:rgba(8,8,10,.72);backdrop-filter:blur(4px)}
683 #stLoad.on{display:flex} 683 #stLoad.on{display:flex}
684−.stload-box{background:var(--card,#141414);border:2px solid var(--ink,#1a1a1a);border-radius:18px;padding:26px 30px;text-align:center;box-shadow:4px 4px 0 var(--ink,#1a1a1a);max-width:80vw} 684+.stload-box{background:var(--card,#141414);border:2px solid var(--edge);border-radius:18px;padding:26px 30px;text-align:center;box-shadow:4px 4px 0 var(--shadow);max-width:80vw}
685 .stload-spin{width:44px;height:44px;margin:0 auto 14px;border:5px solid rgba(127,127,127,.25);border-top-color:var(--accent,#f97316);border-radius:50%;animation:stspin .8s linear infinite} 685 .stload-spin{width:44px;height:44px;margin:0 auto 14px;border:5px solid rgba(127,127,127,.25);border-top-color:var(--accent,#f97316);border-radius:50%;animation:stspin .8s linear infinite}
686 @keyframes stspin{to{transform:rotate(360deg)}} 686 @keyframes stspin{to{transform:rotate(360deg)}}
687 .stload-msg{font-weight:800;font-size:15px} 687 .stload-msg{font-weight:800;font-size:15px}
@@ -693,11 +693,11 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
693 /* ---- Visiteurs ---- */ 693 /* ---- Visiteurs ---- */
694 #view-visitors{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:14px 14px calc(86px + env(safe-area-inset-bottom))} 694 #view-visitors{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:14px 14px calc(86px + env(safe-area-inset-bottom))}
695 .vis-global{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px} 695 .vis-global{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}
696−.vg-item{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:14px;padding:14px;text-align:center;box-shadow:3px 3px 0 var(--ink,#1a1a1a)} 696+.vg-item{background:var(--card,#fff);border:2px solid var(--edge);border-radius:14px;padding:14px;text-align:center;box-shadow:3px 3px 0 var(--shadow)}
697 .vg-n{font-size:26px;font-weight:900;color:var(--accent,#f97316)} 697 .vg-n{font-size:26px;font-weight:900;color:var(--accent,#f97316)}
698 .vg-l{font-size:11px;font-weight:700;color:var(--muted,#777);text-transform:uppercase;letter-spacing:.05em;margin-top:2px} 698 .vg-l{font-size:11px;font-weight:700;color:var(--muted,#777);text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
699 .vis-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px} 699 .vis-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
700−.vis-card{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:16px;padding:16px;box-shadow:3px 3px 0 var(--ink,#1a1a1a)} 700+.vis-card{background:var(--card,#fff);border:2px solid var(--edge);border-radius:16px;padding:16px;box-shadow:3px 3px 0 var(--shadow)}
701 .vis-h{display:flex;align-items:center;gap:10px} 701 .vis-h{display:flex;align-items:center;gap:10px}
702 .vis-h img{width:26px;height:26px;border-radius:6px} 702 .vis-h img{width:26px;height:26px;border-radius:6px}
703 .vis-name{font-weight:800;font-size:15px;flex:1} 703 .vis-name{font-weight:800;font-size:15px;flex:1}
@@ -718,7 +718,7 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
718 .vis-card:active{transform:scale(.98)} 718 .vis-card:active{transform:scale(.98)}
719 #visDetail{position:fixed;inset:0;z-index:9998;display:none;align-items:flex-start;justify-content:center;background:rgba(8,8,10,.72);backdrop-filter:blur(4px);overflow-y:auto;padding:40px 14px} 719 #visDetail{position:fixed;inset:0;z-index:9998;display:none;align-items:flex-start;justify-content:center;background:rgba(8,8,10,.72);backdrop-filter:blur(4px);overflow-y:auto;padding:40px 14px}
720 #visDetail.on{display:flex} 720 #visDetail.on{display:flex}
721−.vd-box{background:var(--card,#141414);border:2px solid var(--ink,#1a1a1a);border-radius:18px;width:100%;max-width:720px;box-shadow:5px 5px 0 var(--ink,#1a1a1a)} 721+.vd-box{background:var(--card,#141414);border:2px solid var(--edge);border-radius:18px;width:100%;max-width:720px;box-shadow:5px 5px 0 var(--shadow)}
722 .vd-head{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(127,127,127,.2)} 722 .vd-head{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(127,127,127,.2)}
723 .vd-head img{width:30px;height:30px;border-radius:8px} 723 .vd-head img{width:30px;height:30px;border-radius:8px}
724 .vd-head h3{flex:1;font-size:18px} 724 .vd-head h3{flex:1;font-size:18px}
@@ -735,3 +735,245 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
735 .vd-bar{flex:1;height:14px;background:rgba(127,127,127,.15);border-radius:7px;overflow:hidden} 735 .vd-bar{flex:1;height:14px;background:rgba(127,127,127,.15);border-radius:7px;overflow:hidden}
736 .vd-bar>div{height:100%;background:var(--accent,#f97316);border-radius:7px} 736 .vd-bar>div{height:100%;background:var(--accent,#f97316);border-radius:7px}
737 .vd-geoN{font-size:13px;font-weight:800;width:44px;text-align:right} 737 .vd-geoN{font-size:13px;font-weight:800;width:44px;text-align:right}
738+
739+/* ====================================================================
740+ Admin-Ka v3 — Control Center : layout desktop, dark mode, densité
741+ ==================================================================== */
742+:root {
743+ --shadow: #1a1611;
744+ --edge: #1a1611;
745+ --on-accent: #1a1611;
746+ --code-bg: #f2efe7;
747+ --term-bg: #1a1611;
748+ --term-ink: #e8e6df;
749+ --del-bg: #ffe4e1; --del-ink: #8c1622;
750+ --add-bg: #ddf3e4; --add-ink: #0c5326;
751+ --panel-gap: 14px;
752+}
753+:root[data-theme="dark"] {
754+ --bg: #16130e;
755+ --card: #201c15;
756+ --card2: #2a251c;
757+ --ink: #ede6d8;
758+ --muted: #a39a89;
759+ --line: #3a342a;
760+ --grid: #322d24;
761+ --edge: #4a4335;
762+ --shadow: #0a0806;
763+ --orange-soft: #3d2510;
764+ --good: #3fbf78; --good-soft: #14301f;
765+ --warn: #e0a93e; --warn-soft: #362a10;
766+ --bad: #f0616d; --bad-soft: #3a1518;
767+ --code-bg: #17140f;
768+ --del-bg: #3d1a1d; --del-ink: #f4a9ad;
769+ --add-bg: #12301c; --add-ink: #9fdcb2;
770+}
771+:root[data-theme="dark"] .hljs { background: var(--code-bg); color: #d8d2c4; }
772+:root[data-theme="dark"] .site-logo, :root[data-theme="dark"] .vis-h img, :root[data-theme="dark"] .rt-row img { background: #f4f1ea; }
773+
774+/* texte sombre garanti sur les fonds orange (les deux thèmes) */
775+.btn-cta, .ka-mark, .send-btn:not(.stop), .msg-user, .seg button.on,
776+.pill-select.all-mode, .sess-badge.running, .perm-allall { color: var(--on-accent); }
777+:root[data-theme="dark"] .perm-allall { background: var(--orange); color: var(--on-accent); }
778+
779+/* ---------- coquille : sidebar + colonne principale ---------- */
780+.app { display: flex; flex-direction: row; height: 100dvh; }
781+.main-col { flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100dvh; }
782+.sidebar { display: none; }
783+.desktop-only { display: none !important; }
784+.page-title { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: -0.3px; }
785+.head-right { display: flex; align-items: center; gap: 8px; }
786+.icon-btn {
787+ position: relative; width: 34px; height: 34px; border-radius: 10px;
788+ border: 1.5px solid var(--edge); background: var(--card); cursor: pointer;
789+ display: flex; align-items: center; justify-content: center; color: var(--ink);
790+}
791+.icon-btn svg { width: 17px; height: 17px; }
792+.icon-btn .badge { position: absolute; top: -4px; right: -4px; width: 10px; height: 10px; border-radius: 50%; background: var(--bad); border: 1.5px solid var(--edge); }
793+.top-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); }
794+.view.scroll { overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 14px 14px calc(86px + env(safe-area-inset-bottom)); }
795+
796+@media (min-width: 1020px) {
797+ .desktop-only { display: block !important; }
798+ .mobile-only { display: none !important; }
799+ .bottomnav { display: none; }
800+ .sidebar {
801+ display: flex; flex-direction: column; width: 224px; flex-shrink: 0;
802+ background: var(--card); border-right: 2px solid var(--edge);
803+ padding: 14px 10px 12px;
804+ }
805+ .side-brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 18px; letter-spacing: -0.4px; padding: 2px 8px 14px; }
806+ .side-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; }
807+ .side-nav .nav-btn {
808+ flex-direction: row; justify-content: flex-start; gap: 10px;
809+ font-size: 13.5px; font-weight: 700; color: var(--muted);
810+ padding: 9px 12px; border-radius: 10px; text-align: left;
811+ }
812+ .side-nav .nav-btn svg { width: 18px; height: 18px; flex-shrink: 0; }
813+ .side-nav .nav-btn.active { background: var(--orange-soft); color: var(--ink); border: 1.5px solid var(--edge); padding: 7.5px 10.5px; }
814+ .side-nav .nav-btn.active svg { color: var(--orange-deep); }
815+ .side-nav .nav-btn .badge { position: absolute; top: 8px; right: 10px; left: auto; }
816+ .side-foot { display: flex; flex-direction: column; gap: 8px; padding: 10px 8px 0; border-top: 1.5px solid var(--line); }
817+ .side-node { font-size: 11px; color: var(--muted); }
818+ .header { padding: 10px 20px; }
819+ .head-right #connPill { display: none; }
820+ .view.scroll { padding: 18px 24px 40px; }
821+ .messages, .composer-inner, .slash-hint { max-width: 920px; }
822+ .eco-grid, .eco-overview, .node-grid, .eco-subhead, .sess-list, .set-card { max-width: 1180px; }
823+ .eco-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
824+ .node-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
825+ .eco-overview { grid-template-columns: repeat(6, 1fr); }
826+ html, body { font-size: 14.5px; }
827+}
828+
829+/* ---------- KPI v3 ---------- */
830+.ov-kpis {
831+ display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
832+ gap: 10px; margin-bottom: var(--panel-gap); max-width: 1180px;
833+}
834+.ov-kpis.compact { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
835+.kpi2 {
836+ background: var(--card); border: 1.5px solid var(--edge); border-radius: var(--r-md);
837+ padding: 10px 12px; box-shadow: var(--bshadow-sm);
838+}
839+.kpi2 .kv2 { font-size: 24px; font-weight: 800; letter-spacing: -0.6px; line-height: 1.1; font-variant-numeric: tabular-nums; }
840+.kpi2 .kv2 small { font-size: 12px; color: var(--muted); font-weight: 700; }
841+.kpi2 .kl2 { font-size: 10px; color: var(--muted); font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
842+.kpi2.accent .kv2 { color: var(--orange-deep); }
843+.kpi2.good .kv2 { color: var(--good); }
844+.kpi2.bad .kv2 { color: var(--bad); }
845+.kdelta { display: inline-block; font-size: 11px; font-weight: 800; margin-top: 3px; border-radius: 999px; padding: 1px 8px; }
846+.kdelta.up { background: var(--good-soft); color: var(--good); }
847+.kdelta.down { background: var(--bad-soft); color: var(--bad); }
848+.kdelta.flat { background: var(--card2); color: var(--muted); }
849+
850+/* ---------- panneaux ---------- */
851+.ov-cols { display: grid; grid-template-columns: 1fr; gap: var(--panel-gap); margin-bottom: var(--panel-gap); max-width: 1180px; }
852+@media (min-width: 900px) { .ov-cols { grid-template-columns: 3fr 2fr; } .ov-cols + .ov-cols { grid-template-columns: 1fr 1fr; } }
853+.panel {
854+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-lg);
855+ box-shadow: var(--bshadow-sm); padding: 14px 16px; min-width: 0;
856+}
857+.panel.slim { padding: 10px 14px; margin-bottom: var(--panel-gap); max-width: 1180px; }
858+.panel-h { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
859+.panel-h h3 { margin: 0; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); }
860+.panel-note { font-size: 11px; color: var(--muted); font-weight: 600; }
861+.linklike { background: none; border: none; color: var(--orange-deep); font-weight: 800; font-size: 12px; cursor: pointer; padding: 0; }
862+.sub-h { margin: 0; font-size: 16px; font-weight: 800; letter-spacing: -0.3px; }
863+.defs-note { max-width: 1180px; color: var(--muted); font-size: 11.5px; line-height: 1.6; margin: 14px 2px 0; }
864+
865+/* écosystème compact (overview) */
866+.ov-eco-chips { display: flex; flex-wrap: wrap; gap: 6px; }
867+.eco-chip {
868+ display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
869+ border: 1.5px solid var(--edge); border-radius: 999px; padding: 4px 10px;
870+ font-size: 11.5px; font-weight: 700; background: var(--card2);
871+}
872+.eco-chip b { font-family: var(--mono); font-weight: 700; color: var(--muted); }
873+.eco-chip.up { color: var(--good); }
874+.eco-chip.slow { color: var(--warn); background: var(--warn-soft); }
875+.eco-chip.down { color: #fff; background: var(--bad); border-color: var(--bad); }
876+.eco-chip.down b { color: #fff; }
877+.ov-sess { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1.5px solid var(--line); cursor: pointer; }
878+.ov-sess:last-child { border-bottom: none; }
879+.ov-sess-t { flex: 1; min-width: 0; font-weight: 700; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
880+.ov-sess-m { font-size: 11px; color: var(--muted); flex-shrink: 0; }
881+.anom { font-size: 12.5px; padding: 7px 0; border-bottom: 1.5px solid var(--line); font-weight: 600; }
882+.anom:last-child { border-bottom: none; }
883+.anom.warn { color: var(--warn); font-weight: 800; }
884+
885+/* répartition humains / bots / datacenter */
886+.break-bar { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--card2); border: 1.5px solid var(--line); }
887+.break-bar i { display: block; height: 100%; }
888+
889+/* temps réel */
890+.rt-list { display: flex; flex-direction: column; max-height: 420px; overflow-y: auto; }
891+.rt-row {
892+ display: flex; align-items: center; gap: 8px;
893+ padding: 6px 0; border-bottom: 1.5px solid var(--line); font-size: 12.5px;
894+}
895+.rt-row:last-child { border-bottom: none; }
896+.rt-row img { width: 18px; height: 18px; border-radius: 5px; flex-shrink: 0; }
897+.rt-site { font-weight: 800; flex-shrink: 0; }
898+.rt-path { color: var(--muted); font-size: 11px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
899+.rt-loc { color: var(--ink); font-weight: 600; font-size: 11.5px; flex-shrink: 0; }
900+.rt-dev { font-size: 10.5px; flex-shrink: 0; }
901+.rt-dur { color: var(--muted); font-size: 10.5px; flex-shrink: 0; }
902+
903+/* visiteurs v2 */
904+.vis-grid { max-width: 1180px; }
905+.vis-card { padding: 12px 14px; box-shadow: var(--bshadow-sm); border-width: 2px; }
906+.vis-on-pill {
907+ font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px;
908+ border: 1.5px solid var(--line); color: var(--muted); background: var(--card2); flex-shrink: 0;
909+}
910+.vis-on-pill.on { background: var(--good-soft); color: var(--good); border-color: var(--good); }
911+.vis-row { margin-top: 10px; }
912+.vis-m { background: var(--card2); border: 1.5px solid var(--line); }
913+.vm-n { font-size: 17px; font-variant-numeric: tabular-nums; }
914+.vis-pv { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 11.5px; }
915+.vis-more { color: var(--orange-deep); font-weight: 800; }
916+.seg.mini button { padding: 6px 12px; font-size: 12px; }
917+.seg.mini { gap: 5px; }
918+
919+/* filtres listes */
920+.filterbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; max-width: 1180px; }
921+.f-input, .f-select {
922+ background: var(--card); border: 1.5px solid var(--edge); color: var(--ink);
923+ border-radius: 999px; padding: 8px 14px; font-size: 13px; font-weight: 600; font-family: inherit; outline: none;
924+}
925+.f-input { flex: 1; min-width: 140px; }
926+.f-input:focus { border-color: var(--orange); }
927+
928+/* sessions denses */
929+.sess-card.row {
930+ flex-direction: row; align-items: center; gap: 10px; padding: 10px 14px;
931+ border-width: 1.5px; box-shadow: none;
932+}
933+.sess-card.row:hover { border-color: var(--orange-deep); }
934+.sess-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
935+.sess-cost, .sess-when { flex-shrink: 0; font-size: 11.5px; color: var(--muted); }
936+.sess-btns { display: flex; gap: 6px; flex-shrink: 0; }
937+.sess-btns button { border: 1.5px solid var(--edge); border-radius: 999px; background: var(--card2); font-size: 11.5px; font-weight: 800; padding: 5px 12px; cursor: pointer; }
938+.sess-btns .del { color: var(--bad); padding: 5px 9px; }
939+@media (max-width: 640px) { .sess-when { display: none; } }
940+
941+/* prompts */
942+.sess-card.prow { box-shadow: none; border-width: 1.5px; }
943+.sess-card.prow:hover { border-color: var(--orange-deep); }
944+.fav-btn { background: none; border: none; font-size: 17px; color: var(--muted); cursor: pointer; padding: 0 2px; flex-shrink: 0; }
945+.fav-btn.on { color: var(--orange-deep); }
946+.sess-top .sess-info { flex-shrink: 0; }
947+
948+/* nav mobile « Plus » */
949+.more-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding-bottom: 8px; }
950+.more-btn {
951+ display: flex; align-items: center; gap: 10px;
952+ background: var(--card); border: 2px solid var(--edge); border-radius: var(--r-md);
953+ box-shadow: var(--bshadow-sm); padding: 13px 14px; font-weight: 800; font-size: 14px; cursor: pointer;
954+}
955+.more-btn svg { width: 20px; height: 20px; color: var(--orange-deep); }
956+
957+/* accueil chat */
958+.welcome { position: absolute; inset: 110px 0 150px; overflow-y: auto; }
959+.welcome-inner { display: flex; flex-direction: column; align-items: center; gap: 9px; width: 100%; max-width: 560px; margin: auto; }
960+.welcome-recent { width: 100%; margin-top: 18px; display: flex; flex-direction: column; gap: 6px; }
961+.wr-title { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; color: var(--muted); text-align: left; }
962+.wr-item {
963+ display: flex; align-items: center; gap: 9px; text-align: left;
964+ background: var(--card); border: 1.5px solid var(--edge); border-radius: var(--r-md);
965+ padding: 9px 12px; cursor: pointer; font-family: inherit;
966+}
967+.wr-t { flex: 1; min-width: 0; font-weight: 700; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
968+.wr-m { font-size: 11px; color: var(--muted); flex-shrink: 0; }
969+
970+/* réglages analytics */
971+.an-cfg .ac-num { width: 74px; background: var(--bg); border: 1.5px solid var(--edge); color: var(--ink); border-radius: 8px; padding: 5px 8px; font-family: var(--mono); font-size: 13px; }
972+.an-cfg .pe-textarea { font-size: 12px; }
973+
974+/* studio / vieux composants : bascule douce vers les variables */
975+.soc-select, #view-social textarea { background: var(--card); color: var(--ink); }
976+.vd-box, .stload-box { background: var(--card); }
977+.vd-head h3 { color: var(--ink); }
978+.st-cap { color: var(--muted); }
979+.vis-chart { background: var(--card2); border-radius: 12px; }
modified server/analytics.js +466 −90
Large diff (629 lines) is not loaded by default to keep the page fast.Load diff
modified server/server.js +19 −6
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added server/test-analytics.mjs +128 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme