SPB Git

spb/admin-ka

Public

Analytics: onglet Visiteurs (uniques en direct par site) — beacon /collect + SQLite (hits+rollup quotidien), API summary/series, cartes live (en ligne/aujourd hui/7j/30j/pages vues). Beacon injecte dans ka-agent.js des 12 sites.

SPB committed (Aug 23, 2026) parent 29e9759 Browse files

5 changed files +197 −2

modified public/app.js +38 −1
@@ -51,7 +51,7 @@
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 ----------
54− const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'settings']; 54+ const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'visitors', 'settings'];
55 function switchView(name) { 55 function switchView(name) {
56 curView = name; 56 curView = name;
57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name); 57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);
@@ -67,6 +67,43 @@
67 } 67 }
68 document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); }); 68 document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });
69 69
70+// ===== VISITORS_TAB =====
71+(function(){
72+ const $ = (id) => document.getElementById(id);
73+ let timer = null;
74+ function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); }
75+ function card(s){
76+ const logo = '<img src="/icons/'+s.site+'">';
77+ const bigdot = s.online>0 ? '<span class="vis-dot big on"></span>' : '<span class="vis-dot big"></span>';
78+ const dot = s.online>0 ? '<span class="vis-dot on"></span>' : '<span class="vis-dot"></span>';
79+ return '<div class="vis-card">'
80+ + '<div class="vis-h">'+logo+'<div class="vis-name">'+ (s.label||s.site) +'</div>'+dot+'</div>'
81+ + '<div class="vis-online">'+bigdot+'<span class="vis-onN">'+nf(s.online)+'</span><span class="vis-onL">en ligne maintenant</span></div>'
82+ + '<div class="vis-row">'
83+ + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">aujourd&#39;hui</div></div>'
84+ + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7 jours</div></div>'
85+ + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30 jours</div></div>'
86+ + '</div>'
87+ + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues aujourd&#39;hui</div>'
88+ + '</div>';
89+ }
90+ async function loadVisitors(){
91+ try{
92+ const d = await (await fetch('/api/analytics/summary')).json();
93+ const g = d.global||{};
94+ if($('visLive')) $('visLive').innerHTML = '<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' visiteurs aujourd\'hui';
95+ if($('visGlobal')) $('visGlobal').innerHTML =
96+ '<div class="vg-item"><div class="vg-n">'+nf(g.online)+'</div><div class="vg-l">en ligne</div></div>'
97+ +'<div class="vg-item"><div class="vg-n">'+nf(g.today)+'</div><div class="vg-l">aujourd\'hui</div></div>'
98+ +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>'
99+ +'<div class="vg-item"><div class="vg-n">'+nf(g.pv_today)+'</div><div class="vg-l">pages vues</div></div>';
100+ if($('visGrid')) $('visGrid').innerHTML = (d.sites||[]).map(card).join('');
101+ }catch(e){}
102+ }
103+ window.startVisitors = function(){ loadVisitors(); clearInterval(timer); timer=setInterval(function(){ if(curView==='visitors') loadVisitors(); },15000); };
104+})();
105+
106+
70 // ---------- Écran du nœud (noVNC embarqué) ---------- 107 // ---------- Écran du nœud (noVNC embarqué) ----------
71 let _rfb = null; 108 let _rfb = null;
72 (function bindVNC(){ 109 (function bindVNC(){
modified public/index.html +7 −1
@@ -178,6 +178,12 @@
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> 178 <div id="stGallery" class="st-gallery"><div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div></div>
179 </section> 179 </section>
180 180
181+<section id="view-visitors" class="view hidden">
182+ <div class="view-head"><h2>👥 Visiteurs</h2><span id="visLive" class="soc-sub"></span></div>
183+ <div id="visGlobal" class="vis-global"></div>
184+ <div id="visGrid" class="vis-grid"><div class="soc-sub">Chargement…</div></div>
185+ </section>
186+
181 <!-- ======== Bottom nav ======== --> 187 <!-- ======== Bottom nav ======== -->
182 <nav class="bottomnav"> 188 <nav class="bottomnav">
183 <button data-view="chat" class="nav-btn active"> 189 <button data-view="chat" class="nav-btn active">
@@ -197,7 +203,7 @@
197 <span>Écosystème</span><i class="badge hidden"></i> 203 <span>Écosystème</span><i class="badge hidden"></i>
198 </button> 204 </button>
199 <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> 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>
200− <button data-view="settings" class="nav-btn"> 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">
201 <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> 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>
202 <span>Réglages</span><i class="badge hidden"></i> 208 <span>Réglages</span><i class="badge hidden"></i>
203 </button> 209 </button>
modified public/style.css +24 −0
@@ -689,3 +689,27 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
689 .st-skel .st-media{position:relative} 689 .st-skel .st-media{position:relative}
690 .st-shimmer{position:absolute;inset:0;background:linear-gradient(100deg,#1a1a1a 30%,#2a2a2a 50%,#1a1a1a 70%);background-size:200% 100%;animation:stshim 1.3s linear infinite} 690 .st-shimmer{position:absolute;inset:0;background:linear-gradient(100deg,#1a1a1a 30%,#2a2a2a 50%,#1a1a1a 70%);background-size:200% 100%;animation:stshim 1.3s linear infinite}
691 @keyframes stshim{to{background-position:-200% 0}} 691 @keyframes stshim{to{background-position:-200% 0}}
692+
693+/* ---- Visiteurs ---- */
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}
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)}
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}
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)}
701+.vis-h{display:flex;align-items:center;gap:10px}
702+.vis-h img{width:26px;height:26px;border-radius:6px}
703+.vis-name{font-weight:800;font-size:15px;flex:1}
704+.vis-dot{width:9px;height:9px;border-radius:50%;background:#9aa;display:inline-block}
705+.vis-dot.on{background:#22c55e;box-shadow:0 0 0 0 rgba(34,197,94,.6);animation:vpulse 1.6s infinite}
706+.vis-dot.big{width:12px;height:12px}
707+@keyframes vpulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 10px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
708+.vis-online{display:flex;align-items:center;gap:8px;margin-top:12px}
709+.vis-onN{font-size:34px;font-weight:900;color:var(--accent,#f97316)}
710+.vis-onL{font-size:12px;color:var(--muted,#777);font-weight:700}
711+.vis-row{display:flex;gap:8px;margin-top:12px}
712+.vis-m{flex:1;background:rgba(127,127,127,.08);border-radius:10px;padding:8px;text-align:center}
713+.vm-n{font-size:20px;font-weight:900}
714+.vm-l{font-size:10px;color:var(--muted,#777);font-weight:700;text-transform:uppercase}
715+.vis-pv{font-size:12px;color:var(--muted,#777);margin-top:10px;font-weight:600}
added server/analytics.js +116 −0
@@ -0,0 +1,116 @@
1+// admin-ka — Analytics first-party : visiteurs uniques par site KA.
2+// Un beacon (1px) sur chaque site envoie GET /collect?site=..&vid=..&p=..
3+// On stocke les hits (SQLite), on compte les uniques (vid distincts) en live,
4+// et on fige un rollup quotidien pour l'historique long terme.
5+import fs from 'node:fs';
6+import path from 'node:path';
7+import { fileURLToPath } from 'node:url';
8+import { DatabaseSync } from 'node:sqlite';
9+
10+const __dirname = path.dirname(fileURLToPath(import.meta.url));
11+const DATA_DIR = path.join(__dirname, '..', 'data');
12+const DB_PATH = path.join(DATA_DIR, 'analytics.sqlite');
13+
14+let db = null;
15+const RETENTION_DAYS = 45;
16+
17+// GIF transparent 1x1 renvoyé au beacon
18+export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
19+
20+function dayKey(ts) {
21+ // jour local (Amérique/Montréal ~ heure du serveur)
22+ const d = new Date(ts);
23+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
24+}
25+
26+export function initAnalytics() {
27+ fs.mkdirSync(DATA_DIR, { recursive: true });
28+ db = new DatabaseSync(DB_PATH);
29+ db.exec(`
30+ CREATE TABLE IF NOT EXISTS hits (site TEXT, vid TEXT, ts INTEGER, path TEXT);
31+ CREATE INDEX IF NOT EXISTS idx_hits_site_ts ON hits(site, ts);
32+ CREATE TABLE IF NOT EXISTS daily (site TEXT, day TEXT, uniques INTEGER, pv INTEGER, PRIMARY KEY(site, day));
33+ `);
34+ rollup();
35+ setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes
36+ setInterval(purge, 6 * 60 * 60 * 1000); // purge le brut ancien
37+ purge();
38+}
39+
40+// enregistre un hit (appelé par /collect)
41+export function recordHit(site, vid, p) {
42+ if (!db || !site || !vid) return;
43+ site = String(site).slice(0, 40);
44+ vid = String(vid).slice(0, 64);
45+ p = String(p || '/').slice(0, 200);
46+ try { db.prepare('INSERT INTO hits (site,vid,ts,path) VALUES (?,?,?,?)').run(site, vid, Date.now(), p); } catch {}
47+}
48+
49+function purge() {
50+ if (!db) return;
51+ const cutoff = Date.now() - RETENTION_DAYS * 864e5;
52+ try { db.prepare('DELETE FROM hits WHERE ts < ?').run(cutoff); } catch {}
53+}
54+
55+// fige les uniques/pv par jour dans `daily` (sauvegarde long terme)
56+function rollup() {
57+ if (!db) return;
58+ try {
59+ const rows = db.prepare(`
60+ SELECT site, ts, vid FROM hits WHERE ts >= ?`).all(Date.now() - 3 * 864e5);
61+ const agg = {}; // site|day -> {set, pv}
62+ for (const r of rows) {
63+ const k = r.site + '|' + dayKey(r.ts);
64+ if (!agg[k]) agg[k] = { set: new Set(), pv: 0 };
65+ agg[k].set.add(r.vid); agg[k].pv++;
66+ }
67+ const up = db.prepare('INSERT OR REPLACE INTO daily (site,day,uniques,pv) VALUES (?,?,?,?)');
68+ for (const k in agg) { const [site, day] = k.split('|'); up.run(site, day, agg[k].set.size, agg[k].pv); }
69+ } catch {}
70+}
71+
72+function uniquesSince(site, sinceTs) {
73+ try { return db.prepare('SELECT COUNT(DISTINCT vid) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; }
74+}
75+function pvSince(site, sinceTs) {
76+ try { return db.prepare('SELECT COUNT(*) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; }
77+}
78+
79+export function analyticsSummary(sites) {
80+ const now = Date.now();
81+ const startToday = new Date(); startToday.setHours(0, 0, 0, 0);
82+ const t0 = startToday.getTime();
83+ const out = [];
84+ let g = { online: 0, today: 0, d7: 0, pv_today: 0 };
85+ for (const s of sites) {
86+ const site = s.app || s;
87+ const row = {
88+ site, label: s.label || site, domain: s.domain || '',
89+ online: uniquesSince(site, now - 5 * 60 * 1000), // uniques dans les 5 dernières min
90+ today: uniquesSince(site, t0),
91+ d7: uniquesSince(site, now - 7 * 864e5),
92+ d30: uniquesSince(site, now - 30 * 864e5),
93+ pv_today: pvSince(site, t0),
94+ pv_total: pvSince(site, now - RETENTION_DAYS * 864e5),
95+ };
96+ out.push(row);
97+ g.online += row.online; g.today += row.today; g.d7 += row.d7; g.pv_today += row.pv_today;
98+ }
99+ out.sort((a, b) => b.online - a.online || b.today - a.today);
100+ return { sites: out, global: g, updated: now };
101+}
102+
103+// série quotidienne (uniques) des N derniers jours pour un site
104+export function analyticsSeries(site, days = 30) {
105+ const out = [];
106+ for (let i = days - 1; i >= 0; i--) {
107+ const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - i);
108+ const key = dayKey(d.getTime());
109+ let u = 0;
110+ try { const r = db.prepare('SELECT uniques FROM daily WHERE site=? AND day=?').get(site, key); if (r) u = r.uniques; } catch {}
111+ // aujourd'hui : recalcule en direct (le rollup peut être en retard)
112+ if (i === 0) u = Math.max(u, uniquesSince(site, d.getTime()));
113+ out.push({ day: key, uniques: u });
114+ }
115+ return out;
116+}
modified server/server.js +12 −0
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process';
11 import { fileURLToPath } from 'node:url'; 11 import { fileURLToPath } from 'node:url';
12 import { WebSocketServer } from 'ws'; 12 import { WebSocketServer } from 'ws';
13 import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js'; 13 import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js';
14+import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, PIXEL } from './analytics.js';
14 import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js'; 15 import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js';
15 import { execFile } from 'node:child_process'; 16 import { execFile } from 'node:child_process';
16 17
@@ -488,6 +489,13 @@ const server = http.createServer(async (req, res) => {
488 489
489 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); } 490 if (p === '/healthz') { res.writeHead(200); return res.end('ok'); }
490 491
492+ // --- beacon analytics (public, sans auth) : compte les visiteurs uniques ---
493+ if (p === '/collect') {
494+ try { recordHit(url.searchParams.get('site'), url.searchParams.get('vid'), url.searchParams.get('p')); } catch {}
495+ res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' });
496+ return res.end(PIXEL);
497+ }
498+
491 // logos des sites (favicon/apple-touch-icon en cache) — public, léger 499 // logos des sites (favicon/apple-touch-icon en cache) — public, léger
492 if (p.startsWith('/icons/')) { 500 if (p.startsWith('/icons/')) {
493 const icon = getIcon(p.split('/')[2]); 501 const icon = getIcon(p.split('/')[2]);
@@ -634,6 +642,8 @@ const server = http.createServer(async (req, res) => {
634 return json(res, 502, { error: e.message }); 642 return json(res, 502, { error: e.message });
635 } 643 }
636 } 644 }
645+ if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES));
646+ if (p.startsWith('/api/analytics/series/')) return json(res, 200, { series: analyticsSeries(p.split('/')[4], 30) });
637 if (p === '/api/eco/summary') return json(res, 200, ecoSummary()); 647 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());
638 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() }); 648 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });
639 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes()); 649 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());
@@ -963,6 +973,8 @@ initSocial(
963 (line) => broadcastAll({ type: 'social_log', line }), 973 (line) => broadcastAll({ type: 'social_log', line }),
964 ); 974 );
965 975
976+initAnalytics();
977+
966 // au démarrage, les chats marqués "running" par un ancien processus sont orphelins 978 // au démarrage, les chats marqués "running" par un ancien processus sont orphelins
967 for (const c of chats) if (c.state !== 'idle') c.state = 'idle'; 979 for (const c of chats) if (c.state !== 'idle') c.state = 'idle';
968 saveChats(); 980 saveChats();
969 981
Menu

Theme