SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%

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 1 mo ago (Aug 23, 2026) parent 29e9759

5 changed files +197 −2

modified public/app.js +38 −1
@@ -51,7 +51,7 @@
51 51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }
52 52
53 53 // ---------- navigation ----------
54 const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'settings'];
54 + const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'visitors', 'settings'];
55 55 function switchView(name) {
56 56 curView = name;
57 57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);
@@ -67,6 +67,43 @@
67 67 }
68 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 107 // ---------- Écran du nœud (noVNC embarqué) ----------
71 108 let _rfb = null;
72 109 (function bindVNC(){
modified public/index.html +7 −1
@@ -178,6 +178,12 @@
178 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 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 187 <!-- ======== Bottom nav ======== -->
182 188 <nav class="bottomnav">
183 189 <button data-view="chat" class="nav-btn active">
@@ -197,7 +203,7 @@
197 203 <span>Écosystème</span><i class="badge hidden"></i>
198 204 </button>
199 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 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 208 <span>Réglages</span><i class="badge hidden"></i>
203 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 689 .st-skel .st-media{position:relative}
690 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 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 11 import { fileURLToPath } from 'node:url';
12 12 import { WebSocketServer } from 'ws';
13 13 import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js';
14 +import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, PIXEL } from './analytics.js';
14 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 16 import { execFile } from 'node:child_process';
16 17
@@ -488,6 +489,13 @@ const server = http.createServer(async (req, res) => {
488 489
489 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 499 // logos des sites (favicon/apple-touch-icon en cache) — public, léger
492 500 if (p.startsWith('/icons/')) {
493 501 const icon = getIcon(p.split('/')[2]);
@@ -634,6 +642,8 @@ const server = http.createServer(async (req, res) => {
634 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 647 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());
638 648 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });
639 649 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());
@@ -963,6 +973,8 @@ initSocial(
963 973 (line) => broadcastAll({ type: 'social_log', line }),
964 974 );
965 975
976 +initAnalytics();
977 +
966 978 // au démarrage, les chats marqués "running" par un ancien processus sont orphelins
967 979 for (const c of chats) if (c.state !== 'idle') c.state = 'idle';
968 980 saveChats();
969 981