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.
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'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'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 | |