Visiteurs: page detail par site (graphique 30j visiteurs uniques + top emplacements via geo-IP ip-api) au clic sur une carte
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'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'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'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'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'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'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 | |