SPB Git

spb/admin-ka

Public

Visiteurs: page detail par site (graphique 30j visiteurs uniques + top emplacements via geo-IP ip-api) au clic sur une carte

SPB committed (Aug 23, 2026) parent 61f3df0 Browse files

4 changed files +134 −24

modified public/app.js +59 −19
@@ -71,40 +71,80 @@
71 // ===== VISITORS_TAB ===== 71 // ===== VISITORS_TAB =====
72 (function(){ 72 (function(){
73 const $ = (id) => document.getElementById(id); 73 const $ = (id) => document.getElementById(id);
74− let timer = null; 74+ let timer=null;
75 function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); } 75 function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); }
76 function card(s){ 76 function card(s){
77− const logo = '<img src="/icons/'+s.site+'">'; 77+ return '<div class="vis-card" data-site="'+s.site+'" data-label="'+(s.label||s.site)+'">'
78− const bigdot = s.online>0 ? '<span class="vis-dot big on"></span>' : '<span class="vis-dot big"></span>'; 78+ + '<div class="vis-h"><img src="/icons/'+s.site+'"><div class="vis-name">'+(s.label||s.site)+'</div><span class="vis-dot'+(s.online>0?' on':'')+'"></span></div>'
79− const dot = s.online>0 ? '<span class="vis-dot on"></span>' : '<span class="vis-dot"></span>'; 79+ + '<div class="vis-online"><span class="vis-dot big'+(s.online>0?' on':'')+'"></span><span class="vis-onN">'+nf(s.online)+'</span><span class="vis-onL">en ligne</span></div>'
80− return '<div class="vis-card">'
81− + '<div class="vis-h">'+logo+'<div class="vis-name">'+ (s.label||s.site) +'</div>'+dot+'</div>'
82− + '<div class="vis-online">'+bigdot+'<span class="vis-onN">'+nf(s.online)+'</span><span class="vis-onL">en ligne maintenant</span></div>'
83 + '<div class="vis-row">' 80 + '<div class="vis-row">'
84− + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">aujourd&#39;hui</div></div>' 81+ + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">jour</div></div>'
85− + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7 jours</div></div>' 82+ + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7j</div></div>'
86− + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30 jours</div></div>' 83+ + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30j</div></div>'
87 + '</div>' 84 + '</div>'
88− + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues aujourd&#39;hui</div>' 85+ + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues · voir détail →</div></div>';
89− + '</div>';
90 } 86 }
91 async function loadVisitors(){ 87 async function loadVisitors(){
92 try{ 88 try{
93− const d = await (await fetch('/api/analytics/summary')).json(); 89+ const d=await (await fetch('/api/analytics/summary')).json(); const g=d.global||{};
94− const g = d.global||{}; 90+ if($('visLive')) $('visLive').innerHTML='<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' aujourd&#39;hui';
95− if($('visLive')) $('visLive').innerHTML = '<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' visiteurs aujourd\'hui'; 91+ if($('visGlobal')) $('visGlobal').innerHTML=
96− if($('visGlobal')) $('visGlobal').innerHTML =
97 '<div class="vg-item"><div class="vg-n">'+nf(g.online)+'</div><div class="vg-l">en ligne</div></div>' 92 '<div class="vg-item"><div class="vg-n">'+nf(g.online)+'</div><div class="vg-l">en ligne</div></div>'
98− +'<div class="vg-item"><div class="vg-n">'+nf(g.today)+'</div><div class="vg-l">aujourd\'hui</div></div>' 93+ +'<div class="vg-item"><div class="vg-n">'+nf(g.today)+'</div><div class="vg-l">aujourd&#39;hui</div></div>'
99 +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>' 94 +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>'
100 +'<div class="vg-item"><div class="vg-n">'+nf(g.pv_today)+'</div><div class="vg-l">pages vues</div></div>'; 95 +'<div class="vg-item"><div class="vg-n">'+nf(g.pv_today)+'</div><div class="vg-l">pages vues</div></div>';
101− if($('visGrid')) $('visGrid').innerHTML = (d.sites||[]).map(card).join(''); 96+ if($('visGrid')){ $('visGrid').innerHTML=(d.sites||[]).map(card).join('');
97+ $('visGrid').querySelectorAll('.vis-card').forEach(function(c){ c.onclick=function(){ openDetail(c.dataset.site,c.dataset.label); }; }); }
102 }catch(e){} 98 }catch(e){}
103 } 99 }
104− window.startVisitors = function(){ loadVisitors(); clearInterval(timer); timer=setInterval(function(){ if(curView==='visitors') loadVisitors(); },15000); }; 100+ function chart(series){
101+ var w=680,h=240,pad=30; var vals=series.map(function(x){return x.uniques;});
102+ var max=Math.max(1,Math.max.apply(null,vals)); var n=series.length;
103+ var X=function(i){return pad+(w-2*pad)*(n<=1?0:i/(n-1));};
104+ var Y=function(v){return h-pad-(h-2*pad)*(v/max);};
105+ var pts=series.map(function(x,i){return X(i)+','+Y(x.uniques);}).join(' ');
106+ var area='M'+X(0)+','+(h-pad)+' L'+pts.replace(/ /g,' L')+' L'+X(n-1)+','+(h-pad)+' Z';
107+ var last=series[n-1]||{uniques:0};
108+ var dots='<circle cx="'+X(n-1)+'" cy="'+Y(last.uniques)+'" r="6" fill="var(--accent,#f97316)"/>';
109+ // libellés d'axe : quelques jours
110+ var lab=''; for(var i=0;i<n;i+=Math.ceil(n/5)){ var dd=series[i].day.slice(5); lab+='<text x="'+X(i)+'" y="'+(h-8)+'" font-size="14" fill="#888" text-anchor="middle">'+dd+'</text>'; }
111+ return '<svg viewBox="0 0 '+w+' '+h+'" class="vis-chart">'
112+ +'<defs><linearGradient id="ga" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--accent,#f97316)" stop-opacity=".45"/><stop offset="1" stop-color="var(--accent,#f97316)" stop-opacity="0"/></linearGradient></defs>'
113+ +'<path d="'+area+'" fill="url(#ga)"/>'
114+ +'<polyline points="'+pts+'" fill="none" stroke="var(--accent,#f97316)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>'
115+ +dots+lab
116+ +'<text x="'+pad+'" y="20" font-size="15" fill="#999">max '+max+'/j</text></svg>';
117+ }
118+ async function openDetail(site,label){
119+ var ov=$('visDetail');
120+ if(!ov){ ov=document.createElement('div'); ov.id='visDetail'; document.body.appendChild(ov); }
121+ ov.innerHTML='<div class="vd-box"><div class="vd-head"><img src="/icons/'+site+'"><h3>'+label+'</h3><button id="vdClose">✕</button></div><div class="vd-body"><div class="soc-sub">Chargement…</div></div></div>';
122+ ov.classList.add('on'); $('vdClose').onclick=function(){ ov.classList.remove('on'); };
123+ ov.onclick=function(e){ if(e.target===ov) ov.classList.remove('on'); };
124+ try{
125+ var sm=await (await fetch('/api/analytics/summary')).json();
126+ var s=(sm.sites||[]).filter(function(x){return x.site===site;})[0]||{};
127+ var se=(await (await fetch('/api/analytics/series/'+site)).json()).series||[];
128+ var ge=(await (await fetch('/api/analytics/geo/'+site)).json()).geo||[];
129+ var maxg=Math.max(1,Math.max.apply(null,ge.map(function(x){return x.c;})||[1]));
130+ var geoHtml = ge.length ? ge.map(function(x){return '<div class="vd-geo"><span class="vd-geoL">'+x.label+'</span><div class="vd-bar"><div style="width:'+Math.round(100*x.c/maxg)+'%"></div></div><span class="vd-geoN">'+nf(x.c)+'</span></div>';}).join('') : '<div class="soc-sub">Emplacements en cours de géolocalisation…</div>';
131+ ov.querySelector('.vd-body').innerHTML=
132+ '<div class="vd-kpis">'
133+ +'<div class="vd-k"><div class="vd-kn">'+nf(s.online)+'</div><div class="vd-kl">en ligne</div></div>'
134+ +'<div class="vd-k"><div class="vd-kn">'+nf(s.today)+'</div><div class="vd-kl">aujourd&#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); };
105 })(); 143 })();
106 144
107 145
146+
147+
108 // ---------- Écran du nœud (noVNC embarqué) ---------- 148 // ---------- Écran du nœud (noVNC embarqué) ----------
109 let _rfb = null; 149 let _rfb = null;
110 (function bindVNC(){ 150 (function bindVNC(){
modified public/style.css +22 −0
@@ -713,3 +713,25 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p
713 .vm-n{font-size:20px;font-weight:900} 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} 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} 715 .vis-pv{font-size:12px;color:var(--muted,#777);margin-top:10px;font-weight:600}
716+
717+.vis-card{cursor:pointer;transition:transform .1s}
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}
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)}
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}
724+.vd-head h3{flex:1;font-size:18px}
725+.vd-head #vdClose{background:none;border:0;font-size:22px;cursor:pointer;color:var(--muted,#888)}
726+.vd-body{padding:18px}
727+.vd-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
728+.vd-k{background:rgba(127,127,127,.08);border-radius:12px;padding:12px;text-align:center}
729+.vd-kn{font-size:24px;font-weight:900;color:var(--accent,#f97316)}
730+.vd-kl{font-size:10px;color:var(--muted,#888);font-weight:700;text-transform:uppercase}
731+.vd-title{font-size:13px;font-weight:800;color:var(--muted,#888);text-transform:uppercase;letter-spacing:.06em;margin:20px 0 8px}
732+.vis-chart{width:100%;height:auto;background:rgba(127,127,127,.05);border-radius:12px}
733+.vd-geo{display:flex;align-items:center;gap:10px;margin-bottom:8px}
734+.vd-geoL{width:180px;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
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}
737+.vd-geoN{font-size:13px;font-weight:800;width:44px;text-align:right}
modified server/analytics.js +50 −3
@@ -30,20 +30,67 @@ export function initAnalytics() {
30 CREATE TABLE IF NOT EXISTS hits (site TEXT, vid TEXT, ts INTEGER, path TEXT); 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); 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)); 32 CREATE TABLE IF NOT EXISTS daily (site TEXT, day TEXT, uniques INTEGER, pv INTEGER, PRIMARY KEY(site, day));
33+ CREATE TABLE IF NOT EXISTS geo (ip TEXT PRIMARY KEY, label TEXT, country TEXT, ts INTEGER);
33 `); 34 `);
35+ try { db.exec('ALTER TABLE hits ADD COLUMN ip TEXT'); } catch {}
34 rollup(); 36 rollup();
35 setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes 37 setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes
36 setInterval(purge, 6 * 60 * 60 * 1000); // purge le brut ancien 38 setInterval(purge, 6 * 60 * 60 * 1000); // purge le brut ancien
37− purge(); 39+ setInterval(resolveGeo, 60 * 1000); // géolocalise les nouvelles IP
40+ purge(); resolveGeo();
38 } 41 }
39 42
40 // enregistre un hit (appelé par /collect) 43 // enregistre un hit (appelé par /collect)
41−export function recordHit(site, vid, p) { 44+export function recordHit(site, vid, p, ip) {
42 if (!db || !site || !vid) return; 45 if (!db || !site || !vid) return;
43 site = String(site).slice(0, 40); 46 site = String(site).slice(0, 40);
44 vid = String(vid).slice(0, 64); 47 vid = String(vid).slice(0, 64);
45 p = String(p || '/').slice(0, 200); 48 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 {} 49+ ip = (String(ip || '').split(',')[0] || '').trim().slice(0, 45);
50+ try { db.prepare('INSERT INTO hits (site,vid,ts,path,ip) VALUES (?,?,?,?,?)').run(site, vid, Date.now(), p, ip); } catch {}
51+}
52+
53+// --- géolocalisation des IP (ip-api.com, gratuit, mis en cache) ---
54+let geoBusy = false;
55+async function resolveGeo() {
56+ if (!db || geoBusy) return;
57+ geoBusy = true;
58+ try {
59+ const rows = db.prepare(`SELECT DISTINCT ip FROM hits WHERE ip IS NOT NULL AND ip<>'' AND ip NOT IN (SELECT ip FROM geo) LIMIT 12`).all();
60+ for (const r of rows) {
61+ const ip = r.ip;
62+ if (/^(127\.|10\.|192\.168\.|::1|172\.(1[6-9]|2\d|3[01])\.)/.test(ip) || ip === 'localhost') {
63+ db.prepare('INSERT OR REPLACE INTO geo (ip,label,country,ts) VALUES (?,?,?,?)').run(ip, 'Local', 'Local', Date.now());
64+ continue;
65+ }
66+ try {
67+ const res = await fetch('http://ip-api.com/json/' + encodeURIComponent(ip) + '?fields=status,country,countryCode,regionName,city', { signal: AbortSignal.timeout(8000) });
68+ const d = await res.json();
69+ let label = 'Inconnu', country = '';
70+ if (d && d.status === 'success') {
71+ country = d.country || '';
72+ label = [d.city, d.regionName].filter(Boolean).join(', ') || d.country || 'Inconnu';
73+ if (d.countryCode && d.countryCode !== 'CA') label = (d.city ? d.city + ', ' : '') + (d.country || '');
74+ }
75+ db.prepare('INSERT OR REPLACE INTO geo (ip,label,country,ts) VALUES (?,?,?,?)').run(ip, label, country, Date.now());
76+ } catch {
77+ db.prepare('INSERT OR REPLACE INTO geo (ip,label,country,ts) VALUES (?,?,?,?)').run(ip, 'Inconnu', '', Date.now());
78+ }
79+ await new Promise(r => setTimeout(r, 1500)); // respecter la limite ip-api
80+ }
81+ } catch {}
82+ geoBusy = false;
83+}
84+
85+// top emplacements (visiteurs uniques) d'un site
86+export function analyticsGeo(site, days = 30) {
87+ try {
88+ return db.prepare(`
89+ SELECT g.label label, COUNT(DISTINCT h.vid) c
90+ FROM hits h JOIN geo g ON g.ip=h.ip
91+ WHERE h.site=? AND h.ts>=? AND g.label IS NOT NULL AND g.label<>'Local'
92+ GROUP BY g.label ORDER BY c DESC LIMIT 8`).all(site, Date.now() - days * 864e5);
93+ } catch { return []; }
47 } 94 }
48 95
49 function purge() { 96 function purge() {
modified server/server.js +3 −2
@@ -11,7 +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 { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsGeo, PIXEL } from './analytics.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 { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState, generateReelDraft, publishReelDraft, runReelCycle, reelPath, startReelBatch, batchStatus, REEL_BATCH_DEFAULT, getGallery, startGenBatch, genBatchStatus } from './social.js';
16 import { execFile } from 'node:child_process'; 16 import { execFile } from 'node:child_process';
17 17
@@ -491,7 +491,7 @@ const server = http.createServer(async (req, res) => {
491 491
492 // --- beacon analytics (public, sans auth) : compte les visiteurs uniques --- 492 // --- beacon analytics (public, sans auth) : compte les visiteurs uniques ---
493 if (p === '/collect') { 493 if (p === '/collect') {
494− try { recordHit(url.searchParams.get('site'), url.searchParams.get('vid'), url.searchParams.get('p')); } catch {} 494+ try { recordHit(url.searchParams.get('site'), url.searchParams.get('vid'), url.searchParams.get('p'), (req.headers['x-forwarded-for']||req.socket.remoteAddress||'')); } catch {}
495 res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' }); 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); 496 return res.end(PIXEL);
497 } 497 }
@@ -644,6 +644,7 @@ const server = http.createServer(async (req, res) => {
644 } 644 }
645 if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES)); 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) }); 646 if (p.startsWith('/api/analytics/series/')) return json(res, 200, { series: analyticsSeries(p.split('/')[4], 30) });
647+ if (p.startsWith('/api/analytics/geo/')) return json(res, 200, { geo: analyticsGeo(p.split('/')[4], 30) });
647 if (p === '/api/eco/summary') return json(res, 200, ecoSummary()); 648 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());
648 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() }); 649 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });
649 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes()); 650 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());
650 651
Menu

Theme