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%

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

SPB committed 1 mo ago (Aug 23, 2026) parent 61f3df0

4 changed files +134 −24

modified public/app.js +59 −19
@@ -71,40 +71,80 @@
71 71 // ===== VISITORS_TAB =====
72 72 (function(){
73 73 const $ = (id) => document.getElementById(id);
74 let timer = null;
74 + let timer=null;
75 75 function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); }
76 76 function card(s){
77 const logo = '<img src="/icons/'+s.site+'">';
78 const bigdot = s.online>0 ? '<span class="vis-dot big on"></span>' : '<span class="vis-dot big"></span>';
79 const dot = s.online>0 ? '<span class="vis-dot on"></span>' : '<span class="vis-dot"></span>';
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>'
77 + return '<div class="vis-card" data-site="'+s.site+'" data-label="'+(s.label||s.site)+'">'
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 + + '<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>'
83 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>'
85 + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7 jours</div></div>'
86 + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30 jours</div></div>'
81 + + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">jour</div></div>'
82 + + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7j</div></div>'
83 + + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30j</div></div>'
87 84 + '</div>'
88 + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues aujourd&#39;hui</div>'
89 + '</div>';
85 + + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues · voir détail →</div></div>';
90 86 }
91 87 async function loadVisitors(){
92 88 try{
93 const d = await (await fetch('/api/analytics/summary')).json();
94 const g = d.global||{};
95 if($('visLive')) $('visLive').innerHTML = '<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' visiteurs aujourd\'hui';
96 if($('visGlobal')) $('visGlobal').innerHTML =
89 + const d=await (await fetch('/api/analytics/summary')).json(); 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';
91 + if($('visGlobal')) $('visGlobal').innerHTML=
97 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 94 +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>'
100 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 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 148 // ---------- Écran du nœud (noVNC embarqué) ----------
109 149 let _rfb = null;
110 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 713 .vm-n{font-size:20px;font-weight:900}
714 714 .vm-l{font-size:10px;color:var(--muted,#777);font-weight:700;text-transform:uppercase}
715 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 30 CREATE TABLE IF NOT EXISTS hits (site TEXT, vid TEXT, ts INTEGER, path TEXT);
31 31 CREATE INDEX IF NOT EXISTS idx_hits_site_ts ON hits(site, ts);
32 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 36 rollup();
35 37 setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes
36 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 43 // enregistre un hit (appelé par /collect)
41 export function recordHit(site, vid, p) {
44 +export function recordHit(site, vid, p, ip) {
42 45 if (!db || !site || !vid) return;
43 46 site = String(site).slice(0, 40);
44 47 vid = String(vid).slice(0, 64);
45 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 96 function purge() {
modified server/server.js +3 −2
@@ -11,7 +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 +import { initAnalytics, recordHit, analyticsSummary, analyticsSeries, analyticsGeo, PIXEL } from './analytics.js';
15 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 16 import { execFile } from 'node:child_process';
17 17
@@ -491,7 +491,7 @@ const server = http.createServer(async (req, res) => {
491 491
492 492 // --- beacon analytics (public, sans auth) : compte les visiteurs uniques ---
493 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 495 res.writeHead(200, { 'Content-Type': 'image/gif', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Access-Control-Allow-Origin': '*' });
496 496 return res.end(PIXEL);
497 497 }
@@ -644,6 +644,7 @@ const server = http.createServer(async (req, res) => {
644 644 }
645 645 if (p === '/api/analytics/summary') return json(res, 200, analyticsSummary(SITES));
646 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 648 if (p === '/api/eco/summary') return json(res, 200, ecoSummary());
648 649 if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() });
649 650 if (p === '/api/eco/nodes') return json(res, 200, ecoNodes());
650 651