1<!doctype html> 2<html lang="it"> 3<head> 4 <meta charset="utf-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 <title>Admin Attività ⢠GuardaMI</title> 7 <style> 8 body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; background: #f8fafc; color: #0f172a; margin: 0; } 9 header { display:flex; align-items:center; justify-content:space-between; padding: 12px 16px; background:#ffffff; border-bottom:1px solid #e2e8f0; position:sticky; top:0; } 10 h1 { font-size: 18px; margin: 0; } 11 main { padding: 12px; } 12 .panel { background:#ffffff; border:1px solid #e2e8f0; border-radius:12px; padding:12px; } 13 table { width:100%; border-collapse: collapse; font-size: 13px; } 14 th, td { border-bottom:1px solid #f1f5f9; padding: 8px; text-align:left; } 15 th { background:#f8fafc; } 16 .pill { display:inline-block; padding:2px 8px; border:1px solid #e2e8f0; border-radius:999px; background:#f8fafc; font-size:12px; } 17 .error { color:#b91c1c; font-weight:700; } 18 .ok { color:#16a34a; font-weight:700; } 19 .controls { display:flex; gap:8px; align-items:center; } 20 input[type="number"]{width:72px;} 21 .hint { color:#64748b; font-size:12px; } 22 .kpis { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 10px; } 23 .kpi { background:#ffffff; border:1px solid #e2e8f0; border-radius:12px; padding:10px; } 24 .kpi .label { color:#64748b; font-size:12px; } 25 .kpi .value { font-size:20px; font-weight:800; margin-top:4px; } 26 .kpi .sub { margin-top:6px; color:#64748b; font-size:12px; line-height: 1.25; } 27 @media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } } 28 @media (max-width: 520px) { .kpis { grid-template-columns: 1fr; } } 29 </style> 30</head> 31<body> 32 <header> 33 <h1>Admin Attività </h1> 34 <div class="controls"> 35 <label>Giorni <input id="days" type="number" min="1" max="60" value="7"/></label> 36 <label>Cerca <input id="q" type="text" placeholder="es. lanzoni" style="width:160px;"/></label> 37 <label>Auth <input id="authEmail" type="email" placeholder="email utente" style="width:190px;"/></label> 38 <button id="authInspect">Auth inspect</button> 39 <button id="refresh">Aggiorna</button> 40 <button id="scan" title="Cerca incoerenze punteggi e specie sconosciute">Scan integrità </button> 41 <button id="fix" title="Ripara automaticamente: specie sconosciute Anfibi/Rettili + punti">Scan + Auto-fix</button> 42 <a class='pill' href='/'>â Torna alla App</a> 43 </div> 44 </header> 45 <main> 46 <div id="status" class="hint">Caricamentoâ¦</div> 47 <div id="kpis" class="kpis"></div> 48 <div class="panel" style="margin-top:10px; overflow:auto;"> 49 <table id="tbl"> 50 <thead> 51 <tr> 52 <th>Utente</th> 53 <th>Email</th> 54 <th>Ultimo login</th> 55 <th>OS</th> 56 <th>PWA</th> 57 <th>Punti Citt.</th> 58 <th>Punti Ricerc.</th> 59 <th>Tot. invii</th> 60 <th>Invii ultimi giorni</th> 61 <th>Moduli</th> 62 <th>Audit</th> 63 </tr> 64 </thead> 65 <tbody></tbody> 66 </table> 67 </div> 68 </main> 69
69<script type="module"> 70 import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js"; 71 import { getAuth, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js"; 72 73 const firebaseConfig = { 74 apiKey: "AIzaSyDLwAgTCyoRXIWTyWPWgjUqD_9qfObAE0k", 75 authDomain: "guardamiwebapp.firebaseapp.com", 76 projectId: "guardamiwebapp", 77 storageBucket: "guardamiwebapp.firebasestorage.app", 78 messagingSenderId: "105692008925", 79 appId: "1:105692008925:web:fc61d601f308b28e0c6634", 80 measurementId: "G-N3S20SDTWV" 81 }; 82 83 const app = initializeApp(firebaseConfig); 84 const auth = getAuth(app); 85 86 const statusEl = document.getElementById("status"); 87 const kpisEl = document.getElementById("kpis"); 88 const daysEl = document.getElementById("days"); 89 const qEl = document.getElementById("q"); 90 const tbl = document.getElementById("tbl").querySelector("tbody"); 91 const refreshBtn = document.getElementById("refresh"); 92 const scanBtn = document.getElementById("scan"); 93 const fixBtn = document.getElementById("fix"); 94 const authEmailEl = document.getElementById("authEmail"); 95 const authInspectBtn = document.getElementById("authInspect"); 96 97 refreshBtn.addEventListener("click", () => load()); 98 qEl.addEventListener("input", () => renderLast()); 99 scanBtn.addEventListener("click", () => scanIntegrity()); 100 fixBtn.addEventListener("click", () => scanAndFix()); 101 if (authInspectBtn) authInspectBtn.addEventListener("click", () => authInspect()); 102 103 function escapeHtml(s) { 104 return String(s || "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); 105 } 106 107 async function fetchJson(url, options) { 108 const r = await fetch(url, options); 109 const ct = String(r.headers.get("content-type") || "").toLowerCase(); 110 if (!ct.includes("application/json")) { 111 const text = await r.text().catch(() => ""); 112 const snippet = text ? text.slice(0, 240) : ""; 113 throw new Error(`Risposta non JSON (${r.status}). ${snippet}`.trim()); 114 } 115 const data = await r.json(); 116 return { r, data }; 117 } 118 119 async function waitForUser() { 120 if (auth.currentUser) return auth.currentUser; 121 return new Promise((resolve) => { 122 const unsub = onAuthStateChanged(auth, (u) => { 123 unsub(); 124 resolve(u); 125 }); 126 }); 127 } 128 129 async function authInspect() { 130 const email = String(authEmailEl && authEmailEl.value || "").trim(); 131 if (!email) return; 132 try { 133 const u = await waitForUser(); 134 if (!u) throw new Error("Devi accedere"); 135 const t = await u.getIdToken(); 136 const { data } = await fetchJson(`/.netlify/functions/admin-auth-inspect?email=${encodeURIComponent(email)}`, { 137 headers: { Authorization: `Bearer ${t}` } 138 }); 139 if (!data || data.ok !== true) throw new Error((data && data.error) || "Errore"); 140 if (data.found === false) { 141 alert(`Utente non trovato in Firebase Auth: ${email}`); 142 return; 143 } 144 const lines = [ 145 `Email: ${data.email || email}`, 146 `UID: ${data.uid || "â"}`, 147 `Disabilitato: ${data.disabled ? "SI" : "no"}`, 148 `Email verificata: ${data.emailVerified ? "SI" : "no"}`, 149 `Providers: ${(Array.isArray(data.providerIds) && data.providerIds.length) ? data.providerIds.join(", ") : "â"}`, 150 `Creato: ${data.createdAt || "â"}`, 151 `Ultimo accesso: ${data.lastSignInAt || "â"}` 152 ]; 153 alert(lines.join("\n")); 154 } catch (e) { 155 alert(e && e.message ? e.message : String(e)); 156 } 157 } 158 159 async function load() { 160 statusEl.textContent = "Caricamentoâ¦"; 161 kpisEl.innerHTML = ""; 162 tbl.innerHTML = ""; 163 164 const u = await waitForUser(); 165 if (!u) { 166 statusEl.innerHTML = '<span class="error">Devi accedere nella WebApp prima di usare la pagina Admin.</span>'; 167 return; 168 } 169 170 try { 171 const t = await u.getIdToken(); 172 try { 173 window.__webappMetrics = await loadWebappMetrics(t); 174 } catch (e) { 175 window.__webappMetrics = null; 176 } 177 const days = Math.max(1, Math.min(60, parseInt(daysEl.value || "7", 10) || 7)); 178 const { r, data } = await fetchJson(`/.netlify/functions/admin-activity-summary?days=${days}&limit=120&debug=1`, { 179 headers: { Authorization: `Bearer ${t}` } 180 }); 181 if (!data.ok) { 182 statusEl.innerHTML = `<span class="error">Errore: ${escapeHtml(data.error || String(r.status))}</span>`;
183 return; 184 } 185 const cacheKey = "admin_activity_cache_v1"; 186 const saveCache = (payload) => { 187 try { localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), payload })); } catch (e) {} 188 }; 189 const loadCache = () => { 190 try { 191 const raw = localStorage.getItem(cacheKey); 192 if (!raw) return null; 193 const j = JSON.parse(raw); 194 if (!j || !j.payload) return null; 195 return j; 196 } catch (e) { 197 return null; 198 } 199 }; 200 201 let effective = data; 202 const hasWarning = Boolean(data && data.warning); 203 const emptyRows = !(data && Array.isArray(data.rows) && data.rows.length); 204 if (hasWarning && emptyRows) { 205 const cached = loadCache(); 206 if (cached && cached.payload && Array.isArray(cached.payload.rows) && cached.payload.rows.length) { 207 effective = cached.payload; 208 const when = new Date(cached.savedAt || Date.now()).toLocaleString(); 209 statusEl.innerHTML = `<span class="error">${escapeHtml(String(data.warning))}</span> <span class="hint">Mostro ultimi dati salvati (${escapeHtml(when)}).</span>${data && data.debug && data.debug.projectId ? ` <span class="hint">projectId: ${escapeHtml(String(data.debug.projectId))}</span>` : ""}`; 210 } else { 211 statusEl.innerHTML = `<span class="error">${escapeHtml(String(data.warning))}</span>${data && data.debug && data.debug.projectId ? ` <span class="hint">projectId: ${escapeHtml(String(data.debug.projectId))}</span>` : ""}`; 212 } 213 } else { 214 if (data && Array.isArray(data.rows) && data.rows.length) saveCache(data); 215 statusEl.innerHTML = `<span class="ok">OK</span> dal ${new Date(data.since).toLocaleString()} â Utenti: ${data.totalUsers}${data && data.debug && data.debug.projectId ? ` <span class="hint">projectId: ${escapeHtml(String(data.debug.projectId))}</span>` : ""}`; 216 } 217 218 window.__adminRows = Array.isArray(effective.rows) ? effective.rows.slice() : []; 219 window.__adminSummary = effective.summary || null; 220 renderKpis(effective); 221 renderLast(); 222 } catch (e) { 223 statusEl.innerHTML = `<span class="error">Errore: ${escapeHtml(e && e.message ? e.message : String(e))}</span>`; 224 } 225 } 226 227 async function loadWebappMetrics(token) { 228 const cacheKey = "admin_webapp_metrics_cache_v1"; 229 const loadCache = () => { 230 try { 231 const raw = localStorage.getItem(cacheKey); 232 if (!raw) return null; 233 const j = JSON.parse(raw); 234 if (!j || !j.payload) return null; 235 return j; 236 } catch (e) { 237 return null; 238 } 239 }; 240 const saveCache = (payload) => { 241 try { localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), payload })); } catch (e) {} 242 }; 243 244 const cached = loadCache(); 245 if (cached && cached.payload && Number.isFinite(cached.savedAt) && Date.now() - cached.savedAt < 24 * 60 * 60 * 1000) { 246 return cached.payload; 247 } 248 249 const { data } = await fetchJson(`/.netlify/functions/admin-webapp-metrics`, { 250 headers: { Authorization: `Bearer ${token}` } 251 }); 252 if (!data || data.ok !== true) throw new Error((data && data.error) || "Errore KPI"); 253 saveCache(data); 254 return data; 255 } 256 257 async function scanIntegrity() { 258 scanBtn.disabled = true; 259 const prev = scanBtn.textContent; 260 scanBtn.textContent = "â¦"; 261 try { 262 const u = await waitForUser(); 263 if (!u) throw new Error("Devi accedere"); 264 const t = await u.getIdToken(); 265 const { data } = await fetchJson(`/.netlify/functions/admin-integrity-scan?limitUsers=200`, { 266 headers: { Authorization: `Bearer ${t}` } 267 }); 268 if (!data.ok) throw new Error(data.error || "Errore scan"); 269 const msg = 270 `Scan completato\n\n` + 271 `Utenti scansionati: ${data.scannedUsers}\n` + 272 `Problemi trovati: ${data.issuesCount}\n` + 273 `Punti non coerenti: ${data.usersWithMismatchedPoints}\n` + 274 `Anfibi/Rettili sconosciuti (tot): ${data.totalUnknownAnfibiRettili}\n\n` + 275 (Array.isArray(data.issues) && data.issues.length 276 ? `Dettagli (prime 12):\n` + data.issues.slice(0, 12).map((it) => { 277 const who = (it.displayName || it.email || it.uid || "").trim(); 278 const unk = Number(it.unknown_anfibi_rettili || 0); 279 const mismatch = (it.doc && it.recomputed) ? ((it.doc.points_total !== it.recomputed.points_total) ? " punti!" : "") : ""; 280 return `- ${who}: unk anfibi=${unk}${mismatch}`; 281 }).join("\n") 282 : "Nessun problema trovato â "); 283 alert(msg); 284 } catch (e) {
285 alert(e && e.message ? e.message : String(e)); 286 } finally { 287 scanBtn.textContent = prev; 288 scanBtn.disabled = false; 289 } 290 } 291 292 async function scanAndFix() { 293 const ok = confirm( 294 "Vuoi eseguire lo Scan + Auto-fix?\n\n" + 295 "- Ripara specie Anfibi/Rettili solo se può dedurle dai dati salvati (altrimenti usa 'Specie non specificata')\n" + 296 "- Aggiorna i punti ricalcolandoli dalla history\n\n" + 297 "Procedo?" 298 ); 299 if (!ok) return; 300 301 fixBtn.disabled = true; 302 const prev = fixBtn.textContent; 303 fixBtn.textContent = "â¦"; 304 try { 305 const u = await waitForUser(); 306 if (!u) throw new Error("Devi accedere"); 307 const t = await u.getIdToken(); 308 const { data } = await fetchJson(`/.netlify/functions/admin-integrity-fix?dryRun=0&limitUsers=200`, { 309 method: "POST", 310 headers: { Authorization: `Bearer ${t}` } 311 }); 312 if (!data.ok) throw new Error(data.error || "Errore auto-fix"); 313 const msg = 314 `Auto-fix completato\n\n` + 315 `Utenti scansionati: ${data.scannedUsers}\n` + 316 `Utenti con fix applicati: ${data.usersFixed}\n` + 317 `Utenti con punti aggiornati: ${data.usersPointsUpdated}\n` + 318 `Entry fixate: ${data.entriesFixed}\n` + 319 `Entry ancora 'Specie non specificata' (non deducibili): ${data.entriesStillUnknown}\n\n` + 320 "Ora rifai Scan integrità per verifica finale."; 321 alert(msg); 322 await load(); 323 } catch (e) { 324 alert(e && e.message ? e.message : String(e)); 325 } finally { 326 fixBtn.textContent = prev; 327 fixBtn.disabled = false; 328 } 329 } 330 331 function renderKpis(data) { 332 const rows = Array.isArray(data && data.rows) ? data.rows : []; 333 const since = data && data.since ? new Date(data.since) : null; 334 const days = rows.length ? Number(rows[0].lastDays || 0) : Number(daysEl.value || 7); 335 336 const activeBySubmissions = rows.filter(r => Number(r.submissions_last_days || 0) > 0).length; 337 const activeByLogin = since ? rows.filter(r => { 338 if (!r.lastLogin) return false; 339 const d = new Date(r.lastLogin); 340 return !Number.isNaN(d.getTime()) && d >= since; 341 }).length : 0; 342 const pwaInstalled = rows.filter(r => r.pwa_installed || r.pwa_standalone).length; 343 const os = rows.reduce((acc, r) => { 344 const k = r.device_os || "Altro"; 345 acc[k] = (acc[k] || 0) + 1; 346 return acc; 347 }, {}); 348 const osText = Object.entries(os).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `${k}: ${v}`).join(" · "); 349 350 const m = window.__webappMetrics; 351 let metricsCards = ""; 352 if (m && m.ok === true) { 353 const when = m.updatedAtIso ? new Date(m.updatedAtIso).toLocaleString() : "â"; 354 metricsCards = 355 `<div class="kpi"><div class="label">WebApp installate (totale)</div><div class="value">${Number(m.pwa_installed || 0)}</div><div class="sub">Proxy PWA ⢠Aggiornato: ${escapeHtml(when)}</div></div>` + 356 `<div class="kpi"><div class="label">WebApp attive (totale)</div><div class="value">${Number(m.active_last_30d_login || 0)}</div><div class="sub">Login ultimi ${Number(m.windowDays || 30)} giorni</div></div>`; 357 } else { 358 metricsCards = 359 `<div class="kpi"><div class="label">WebApp installate (totale)</div><div class="value">â¦</div><div class="sub">Aggiorna 1 volta al giorno</div></div>` + 360 `<div class="kpi"><div class="label">WebApp attive (totale)</div><div class="value">â¦</div><div class="sub">Login ultimi 30 giorni</div></div>`; 361 } 362 363 kpisEl.innerHTML = 364 metricsCards + 365 `<div class="kpi"><div class="label">Utenti in elenco</div><div class="value">${rows.length}</div><div class="sub">Campione (max 120)</div></div>` + 366 `<div class="kpi"><div class="label">Attivi (invii)</div><div class="value">${activeBySubmissions}</div><div class="sub">Invii negli ultimi ${days} giorni</div></div>` + 367 `<div class="kpi"><div class="label">Attivi (login)</div><div class="value">${activeByLogin}</div><div class="sub">Login negli ultimi ${days} giorni</div></div>` + 368 `<div class="kpi"><div class="label">WebApp installata (elenco)</div><div class="value">${pwaInstalled}</div><div class="sub">${osText || "OS: N/D"}</div></div>`; 369 } 370 371 function renderLast() { 372 tbl.innerHTML = ""; 373 const q = String(qEl.value || "").trim().toLowerCase(); 374 const rows = Array.isArray(window.__adminRows) ? window.__adminRows.slice() : []; 375 rows.sort((a, b) => (b.submissions_last_days || 0) - (a.submissions_last_days || 0)); 376 const filtered = q ? rows.filter((r) => { 377 const name = String(r.displayName || "").toLowerCase(); 378 const email = String(r.email || "").toLowerCase(); 379 return name.includes(q) || email.includes(q); 380 }) : rows; 381 382 filtered.forEach((row) => { 383 const mods = Object.entries(row.modules || {}).map(([k, v]) => `${k}: ${v}`).join(", "); 384 const os = row.device_os || ""; 385 const pwa = (row.pwa_installed || row.pwa_standalone) ? "Sì" : "No"; 386 const tr = document.createElement("tr"); 387 tr.innerHTML = 388 `<td>${escapeHtml(row.displayName || "")}</td>` + 389 `<td>${escapeHtml(row.email || "")}</td>` + 390 `<td>${escapeHtml(row.lastLogin || "")}</td>` + 391 `<td>${escapeHtml(os)}</td>` + 392 `<td>${escapeHtml(pwa)}</td>` + 393 `<td>${Number(row.points_citizen || 0)}</td>` + 394 `<td>${Number(row.points_expert || 0)}</td>` + 395 `<td>${Number(row.submissions_total || 0)}</td>` + 396 `<td>${Number(row.submissions_last_days || 0)}</td>` + 397 `<td>${escapeHtml(mods)}</td>` + 398 `<td><button type="button" data-email="${escapeHtml(row.email || "")}" style="padding:6px 10px;border-radius:10px;border:1px solid #e2e8f0;background:#ffffff;cursor:pointer;">Verifica</button></td>`; 399 tbl.appendChild(tr); 400 }); 401 } 402 403 tbl.addEventListener("click", async (ev) => { 404 const btn = ev.target && ev.target.closest ? ev.target.closest("button[data-email]") : null; 405 if (!btn) return; 406 const email = btn.getAttribute("data-email") || ""; 407 if (!email) return; 408 btn.disabled = true; 409 const prev = btn.textContent; 410 btn.textContent = "â¦";
411 try { 412 const u = await waitForUser(); 413 if (!u) throw new Error("Devi accedere"); 414 const t = await u.getIdToken(); 415 const r = await fetch(`/.netlify/functions/admin-user-audit?email=${encodeURIComponent(email)}`, { 416 headers: { Authorization: `Bearer ${t}` } 417 }); 418 const data = await r.json(); 419 if (!data.ok) throw new Error(data.error || "Errore audit"); 420 const ok = data.matches && data.matches.total; 421 const unknownAnfibi = Number((data.recomputed && data.recomputed.unknown_anfibi_rettili) || 0) || 0; 422 const msg = 423 `Audit ${data.displayName || data.email}\n\n` + 424 `Doc punti (tot): ${data.doc.points_total} | Ricalcolati: ${data.recomputed.points_total}\n` + 425 `Citizen: ${data.doc.points_citizen} vs ${data.recomputed.points_citizen}\n` + 426 `Expert: ${data.doc.points_expert} vs ${data.recomputed.points_expert}\n` + 427 `History entries: ${data.recomputed.history_docs}\n` + 428 `Ultima osservazione: ${data.recomputed.lastObservationTimestamp || "N/D"}\n` + 429 `Anfibi/Rettili sconosciuti: ${unknownAnfibi}\n\n` + 430 (ok ? "â Punti coerenti" : "â ï¸ Punti NON coerenti"); 431 432 if (unknownAnfibi > 0) { 433 const doFixSpecies = confirm( 434 msg + 435 "\n\nVuoi riparare le specie Anfibi/Rettili sconosciute?\n" + 436 "Default: Lucertola muraiola (Podarcis muralis)" 437 ); 438 if (doFixSpecies) { 439 const fixResp = await fetch( 440 `/.netlify/functions/admin-fix-anfibi-unknowns?email=${encodeURIComponent(email)}&species=${encodeURIComponent("Lucertola muraiola (Podarcis muralis)")}`, 441 { headers: { Authorization: `Bearer ${t}` }, method: "POST" } 442 ); 443 const fixData = await fixResp.json(); 444 if (!fixData.ok) throw new Error(fixData.error || "Errore riparazione specie"); 445 alert(`Specie riparate: ${fixData.fixed}\nPunti totali: ${fixData.updatedPoints.points_total}`); 446 await load(); 447 return; 448 } 449 } 450 451 if (ok) { 452 alert(msg); 453 return; 454 } 455 456 const doFix = confirm(msg + "\n\nVuoi riparare i punti (ricalcolo da history)?"); 457 if (!doFix) return; 458 459 const fixResp = await fetch(`/.netlify/functions/admin-user-reconcile?email=${encodeURIComponent(email)}`, { 460 headers: { Authorization: `Bearer ${t}` } 461 }); 462 const fixData = await fixResp.json(); 463 if (!fixData.ok) throw new Error(fixData.error || "Errore riparazione"); 464 alert( 465 `Riparazione completata\n\n` + 466 `${fixData.displayName || fixData.email}\n` + 467 `Nuovi punti (tot): ${fixData.updated.points_total}\n` + 468 `Citizen: ${fixData.updated.points_citizen}\n` + 469 `Expert: ${fixData.updated.points_expert}\n` + 470 `Invii: ${fixData.updated.submissions_total}` 471 ); 472 await load(); 473 } catch (e) { 474 alert(e && e.message ? e.message : String(e)); 475 } finally { 476 btn.textContent = prev; 477 btn.disabled = false; 478 } 479 }); 480 481 load(); 482 </script>
482 483</body> 484</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.