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