[object Object]

← back to Ga Allsites

Multi-view app: left icon-tab sidebar (Sites/Visuals/Country/Live) + pie/pyramid/mindmap charts + country ETL + persistent field selector

997f94a4c39c6ed849a17ce56d8fcd38a38ee4e2 · 2026-08-04 14:16:36 -0700 · Steve Abrams

Files touched

Diff

commit 997f94a4c39c6ed849a17ce56d8fcd38a38ee4e2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 4 14:16:36 2026 -0700

    Multi-view app: left icon-tab sidebar (Sites/Visuals/Country/Live) + pie/pyramid/mindmap charts + country ETL + persistent field selector
---
 etl.py                |  44 ++++++
 preview-countries.png | Bin 0 -> 86321 bytes
 preview-visuals.png   | Bin 0 -> 159988 bytes
 server.py             | 364 +++++++++++++++++++++++++++++++++++++-------------
 server.py.bak         | 326 ++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 641 insertions(+), 93 deletions(-)

diff --git a/etl.py b/etl.py
index 82bedd7..76b1f94 100644
--- a/etl.py
+++ b/etl.py
@@ -116,6 +116,50 @@ def main() -> int:
     CACHE.write_text(json.dumps(payload, indent=2))
     errs = sum(1 for r in rows if r["error"])
     print(f"Wrote {CACHE} · {len(rows)} props · {errs} errors · {payload['elapsed_s']}s")
+
+    # ---- Country pass: fleet-wide user geography (30d) ----
+    from google.analytics.data_v1beta.types import Dimension
+    countries: dict[str, dict] = {}
+
+    def pull_country(p: dict):
+        try:
+            resp = data.run_report(RunReportRequest(
+                property=f"properties/{p['property_id']}",
+                date_ranges=[DateRange(start_date="30daysAgo", end_date="yesterday")],
+                dimensions=[Dimension(name="country")],
+                metrics=[Metric(name="sessions"), Metric(name="activeUsers")],
+                limit=250,
+            ))
+            out = []
+            for row in resp.rows:
+                c = row.dimension_values[0].value or "(not set)"
+                out.append((c, float(row.metric_values[0].value or 0),
+                            float(row.metric_values[1].value or 0)))
+            return out
+        except Exception:
+            return []
+
+    with ThreadPoolExecutor(max_workers=WORKERS) as ex:
+        for res in ex.map(pull_country, props):
+            for c, s, u in res:
+                d = countries.setdefault(c, {"sessions": 0, "users": 0})
+                d["sessions"] += s
+                d["users"] += u
+    crows = [{"country": c, "sessions": v["sessions"], "users": v["users"]}
+             for c, v in countries.items()]
+    crows.sort(key=lambda r: r["sessions"], reverse=True)
+    ctot = sum(r["sessions"] for r in crows) or 1
+    for r in crows:
+        r["share"] = round(100 * r["sessions"] / ctot, 1)
+    cpath = CACHE.parent / "countries.json"
+    cpath.write_text(json.dumps({
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "window": "30d",
+        "total_sessions": sum(r["sessions"] for r in crows),
+        "country_count": len(crows),
+        "rows": crows,
+    }, indent=2))
+    print(f"Wrote {cpath} · {len(crows)} countries")
     return 0
 
 
diff --git a/preview-countries.png b/preview-countries.png
new file mode 100644
index 0000000..f521873
Binary files /dev/null and b/preview-countries.png differ
diff --git a/preview-visuals.png b/preview-visuals.png
new file mode 100644
index 0000000..1e800d1
Binary files /dev/null and b/preview-visuals.png differ
diff --git a/server.py b/server.py
index 426e606..943c51e 100644
--- a/server.py
+++ b/server.py
@@ -1,27 +1,35 @@
 #!/usr/bin/env python3
 """GA All-Sites dashboard — standing local server (DTD verdict 2026-08-04, Option 1).
-Reads cache/data.json ONLY (never calls the GA API on page load). A scheduled
-etl.py run refreshes the cache; a manual Refresh button can trigger it too.
-
-Basic-auth admin/DW2024! (house standard). Zero dependencies (stdlib only).
+Multi-view app: left icon-tab sidebar → Sites table / Countries / Visuals / Live.
+Reads cache/*.json (never calls the GA Data API on page load); realtime is on-demand.
+Basic-auth admin/DW2024!. Zero external deps (stdlib + google libs for realtime).
 """
 from __future__ import annotations
 
 import base64
 import json
+import os
 import subprocess
 import sys
 import threading
+import time as _time
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timezone
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from pathlib import Path
 
 HERE = Path(__file__).parent
 CACHE = HERE / "cache" / "data.json"
+COUNTRIES = HERE / "cache" / "countries.json"
+KEY = Path.home() / ".config" / "ga-analytics-agent" / "service-account.json"
 PORT = int((HERE / ".port").read_text().strip()) if (HERE / ".port").exists() else 9780
 USER, PW = "admin", "DW2024!"
 
 _refresh_lock = threading.Lock()
 _refreshing = {"on": False}
+_live_cache = {"ts": 0, "data": None}
+_live_lock = threading.Lock()
+_LIVE_TTL = 20
 
 
 def run_etl():
@@ -36,141 +44,305 @@ def run_etl():
         _refreshing["on"] = False
 
 
+def fetch_live():
+    now = _time.time()
+    with _live_lock:
+        if _live_cache["data"] and now - _live_cache["ts"] < _LIVE_TTL:
+            return _live_cache["data"]
+    os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(KEY)
+    from google.analytics.data_v1beta import BetaAnalyticsDataClient
+    from google.analytics.data_v1beta.types import Metric, RunRealtimeReportRequest
+    client = BetaAnalyticsDataClient()
+    props = []
+    if CACHE.exists():
+        d = json.loads(CACHE.read_text())
+        props = [{"property": r["property"], "property_id": r["property_id"],
+                  "account": r.get("account", "")} for r in d.get("rows", [])]
+
+    def one(p):
+        try:
+            resp = client.run_realtime_report(RunRealtimeReportRequest(
+                property=f"properties/{p['property_id']}",
+                metrics=[Metric(name="activeUsers")]))
+            active = int(resp.rows[0].metric_values[0].value) if resp.rows else 0
+        except Exception:
+            active = 0
+        return {**p, "active": active}
+
+    with ThreadPoolExecutor(max_workers=12) as ex:
+        rows = list(ex.map(one, props))
+    rows.sort(key=lambda r: r["active"], reverse=True)
+    data = {"generated_at": datetime.now(timezone.utc).isoformat(),
+            "total_active": sum(r["active"] for r in rows),
+            "active_sites": sum(1 for r in rows if r["active"] > 0),
+            "property_count": len(rows), "rows": rows}
+    with _live_lock:
+        _live_cache["ts"] = _time.time()
+        _live_cache["data"] = data
+    return data
+
+
 PAGE = r"""<!DOCTYPE html><html><head><meta charset="utf-8">
 <meta name="viewport" content="width=device-width,initial-scale=1">
-<title>GA · All Sites At Once</title>
+<title>GA · All Sites</title>
 <style>
  :root{--fs:13px;--pad:6px}
  *{box-sizing:border-box}
- body{font:var(--fs)/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;color:#1a1a1a;background:#f6f7f9}
+ body{margin:0;font:var(--fs)/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1a1a1a;background:#f6f7f9;display:flex;min-height:100vh}
+ /* sidebar */
+ .side{width:78px;background:#0f1115;color:#aeb6c2;display:flex;flex-direction:column;align-items:stretch;padding:8px 0;position:sticky;top:0;height:100vh;flex:0 0 78px}
+ .side .brand{color:#fff;font-weight:700;font-size:11px;text-align:center;padding:8px 4px 14px;letter-spacing:.5px;border-bottom:1px solid #23262d;margin-bottom:6px}
+ .side button{background:none;border:0;color:inherit;cursor:pointer;padding:12px 4px;display:flex;flex-direction:column;align-items:center;gap:5px;font-size:10px;border-left:3px solid transparent}
+ .side button .ic{font-size:20px;line-height:1}
+ .side button:hover{background:#1a1d24;color:#fff}
+ .side button.on{background:#171a20;color:#fff;border-left-color:#e74c3c}
+ .main{flex:1;min-width:0;display:flex;flex-direction:column}
  header{position:sticky;top:0;z-index:5;background:#111;color:#fff;padding:12px 18px;display:flex;flex-wrap:wrap;gap:14px;align-items:center}
- header h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.2px}
+ header h1{font-size:15px;margin:0;font-weight:600}
  header .stat{font-variant-numeric:tabular-nums;color:#cfe;font-size:13px}
  header .muted{color:#9aa}
- .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:10px 18px;background:#fff;border-bottom:1px solid #e6e8ec;position:sticky;top:44px;z-index:4}
- .bar input[type=search]{padding:6px 10px;border:1px solid #cbd0d6;border-radius:6px;min-width:220px;font-size:13px}
+ .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:10px 18px;background:#fff;border-bottom:1px solid #e6e8ec}
+ .bar input[type=search]{padding:6px 10px;border:1px solid #cbd0d6;border-radius:6px;min-width:200px;font-size:13px}
  .bar label{font-size:12px;color:#555;display:inline-flex;gap:4px;align-items:center;cursor:pointer}
  .bar button{padding:6px 12px;border:1px solid #cbd0d6;background:#fafbfc;border-radius:6px;cursor:pointer;font-size:12px}
  .bar button:hover{background:#eef1f4}
  .cols{display:flex;flex-wrap:wrap;gap:2px 10px;padding:6px 18px;background:#fbfcfd;border-bottom:1px solid #eef0f3;font-size:11px;color:#667}
- .wrap{padding:0 0 40px}
+ .cols b{color:#333;margin-right:4px}
+ .view{padding:0 0 40px}
+ .view[hidden]{display:none}
  table{border-collapse:collapse;width:100%;background:#fff}
- thead th{position:sticky;top:96px;background:#f2f4f7;text-align:right;padding:var(--pad) 12px;border-bottom:2px solid #dde1e6;font-weight:600;cursor:pointer;white-space:nowrap;user-select:none}
+ thead th{position:sticky;top:44px;background:#f2f4f7;text-align:right;padding:var(--pad) 12px;border-bottom:2px solid #dde1e6;font-weight:600;cursor:pointer;white-space:nowrap;user-select:none}
  thead th.l{text-align:left}
  thead th:hover{background:#e9edf2}
  tbody td{padding:var(--pad) 12px;text-align:right;border-bottom:1px solid #f0f1f4;font-variant-numeric:tabular-nums;white-space:nowrap}
  tbody td.l{text-align:left}
  tbody tr:hover{background:#f8fafc}
- a{color:#1558d6;text-decoration:none}
- a:hover{text-decoration:underline}
- .z{color:#b8bec6}
- .acct{font-size:11px;color:#7a828c}
+ a{color:#1558d6;text-decoration:none}a:hover{text-decoration:underline}
+ .z{color:#b8bec6}.acct{font-size:11px;color:#7a828c}
  .badge{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:600}
- .fresh{background:#e4f5e9;color:#1a7a3c}
- .stale{background:#fdecc8;color:#8a5a00}
+ .fresh{background:#e4f5e9;color:#1a7a3c}.stale{background:#fdecc8;color:#8a5a00}
  tfoot td{padding:8px 12px;font-weight:700;border-top:2px solid #dde1e6;text-align:right;background:#f7f9fb}
  tfoot td.l{text-align:left}
  .err{color:#c0392b;font-size:11px}
+ /* charts */
+ .panel{padding:18px}
+ .card{background:#fff;border:1px solid #e6e8ec;border-radius:10px;padding:18px;margin-bottom:18px}
+ .card h2{margin:0 0 12px;font-size:14px}
+ .piewrap{display:flex;gap:26px;align-items:center;flex-wrap:wrap}
+ .pie{width:220px;height:220px;border-radius:50%;flex:0 0 auto}
+ .legend{list-style:none;margin:0;padding:0;font-size:13px}
+ .legend li{display:flex;align-items:center;gap:8px;padding:3px 0}
+ .legend .sw{width:12px;height:12px;border-radius:3px;flex:0 0 auto}
+ .legend .v{margin-left:auto;font-variant-numeric:tabular-nums;color:#555;padding-left:18px}
+ /* pyramid */
+ .pyr{display:flex;flex-direction:column;align-items:center;gap:4px}
+ .pyr .row{display:flex;align-items:center;justify-content:center;gap:8px;width:100%}
+ .pyr .bar{background:linear-gradient(90deg,#5b8def,#8a5bef);color:#fff;text-align:center;border-radius:4px;padding:5px 8px;font-size:12px;white-space:nowrap;overflow:hidden}
+ .pyr .lbl{font-size:12px;color:#333;min-width:150px;text-align:right}
+ .pyr .val{font-size:12px;color:#666;min-width:60px;text-align:left;font-variant-numeric:tabular-nums}
+ svg text{font:12px -apple-system,sans-serif}
 </style></head><body>
-<header>
- <h1>GA · All Sites At Once</h1>
- <span class="stat" id="hstat">loading…</span>
- <span id="hbadge"></span>
- <span class="stat muted" style="margin-left:auto" id="hgen"></span>
-</header>
-<div class="bar">
- <input type="search" id="q" placeholder="filter sites…" autocomplete="off">
- <label>density <input type="range" id="dens" min="10" max="18" value="13"></label>
- <label><input type="checkbox" id="onlyactive"> only sites with traffic</label>
- <button id="refresh">↻ Refresh data</button>
- <span class="muted" id="rmsg"></span>
+<nav class="side" id="side">
+ <div class="brand">GA<br>FLEET</div>
+ <button data-tab="sites" class="on"><span class="ic">📊</span>Sites</button>
+ <button data-tab="visuals"><span class="ic">🔺</span>Visuals</button>
+ <button data-tab="countries"><span class="ic">🌍</span>Country</button>
+ <button data-tab="live"><span class="ic">🔴</span>Live</button>
+</nav>
+<div class="main">
+ <header>
+  <h1 id="htitle">All Sites</h1>
+  <span class="stat" id="hstat">loading…</span>
+  <span id="hbadge"></span>
+  <span class="stat muted" style="margin-left:auto" id="hgen"></span>
+ </header>
+
+ <!-- SITES -->
+ <div class="view" id="v-sites">
+  <div class="bar">
+   <input type="search" id="q" placeholder="filter sites…" autocomplete="off">
+   <label>density <input type="range" id="dens" min="10" max="18" value="13"></label>
+   <label><input type="checkbox" id="onlyactive"> only sites with traffic</label>
+   <button id="refresh">↻ Refresh data</button>
+   <span class="muted" id="rmsg"></span>
+  </div>
+  <div class="cols" id="cols"></div>
+  <table id="t"><thead><tr id="hr"></tr></thead><tbody id="tb"></tbody><tfoot><tr id="tf"></tr></tfoot></table>
+ </div>
+
+ <!-- VISUALS -->
+ <div class="view panel" id="v-visuals" hidden>
+  <div class="card"><h2>Fleet mind map — top sites by 30-day sessions</h2><div id="mindmap"></div></div>
+  <div class="card"><h2>Traffic pyramid — top 10 sites (30d sessions)</h2><div class="pyr" id="pyr"></div></div>
+ </div>
+
+ <!-- COUNTRIES -->
+ <div class="view panel" id="v-countries" hidden>
+  <div class="card"><h2>Where your users are — fleet-wide, last 30 days</h2>
+   <div class="piewrap"><div class="pie" id="pie"></div><ul class="legend" id="pielegend"></ul></div></div>
+  <div class="card"><h2>All countries</h2>
+   <table><thead><tr><th class="l">Country</th><th>Sessions</th><th>Users</th><th>Share</th></tr></thead>
+    <tbody id="ctb"></tbody></table></div>
+ </div>
+
+ <!-- LIVE -->
+ <div class="view" id="v-live" hidden>
+  <div style="padding:16px 18px;background:#1a0f14;color:#fff;display:flex;align-items:baseline;gap:16px;flex-wrap:wrap">
+   <span style="font-size:13px;color:#f5b7b1;font-weight:600">🔴 LIVE · last 30 min</span>
+   <span id="livetot" style="font-size:28px;font-weight:700;font-variant-numeric:tabular-nums">—</span>
+   <span style="font-size:13px;color:#e8a">active users on the fleet right now</span>
+   <span id="livesites" class="muted" style="margin-left:auto;font-size:12px"></span>
+  </div>
+  <table><thead><tr><th class="l">Site</th><th class="l">Account</th><th>Active users (30 min)</th></tr></thead>
+   <tbody id="ltb"></tbody><tfoot><tr id="ltf"></tr></tfoot></table>
+ </div>
 </div>
-<div class="cols" id="cols"></div>
-<div class="wrap"><table id="t">
- <thead><tr id="hr"></tr></thead>
- <tbody id="tb"></tbody>
- <tfoot><tr id="tf"></tr></tfoot>
-</table></div>
+
 <script>window.__BOOT__=/*BOOTDATA*/null/*/BOOTDATA*/;</script>
 <script>
+const PAL=['#5b8def','#e74c3c','#27ae60','#f39c12','#9b59b6','#16a085','#e67e22','#2c3e50','#c0392b','#7f8c8d'];
 const COLS=[
- {k:'property',l:'Site',t:'l',fmt:(r)=>`<a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/reports/intelligenthome" target="_blank" rel="noopener noreferrer">${esc(r.property||'?')}</a>${r.error?` <span class="err" title="${esc(r.error)}">⚠</span>`:''}`},
- {k:'account',l:'Account',t:'l',cls:'acct',fmt:(r)=>r.account.includes('APPS')?'APPS':esc(r.account)},
+ {k:'property',l:'Site',t:'l',def:1,fmt:r=>`<a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/reports/intelligenthome" target="_blank" rel="noopener noreferrer">${esc(r.property||'?')}</a>${r.error?` <span class="err" title="${esc(r.error)}">⚠</span>`:''}`},
+ {k:'account',l:'Account',t:'l',def:1,cls:'acct',fmt:r=>r.account.includes('APPS')?'APPS':esc(r.account)},
  {k:'d7.sessions',l:'7d sess',def:1},
- {k:'d7.activeUsers',l:'7d users'},
- {k:'d7.screenPageViews',l:'7d views'},
- {k:'d7.conversions',l:'7d conv'},
+ {k:'d7.activeUsers',l:'7d users',def:0},
+ {k:'d7.screenPageViews',l:'7d views',def:0},
+ {k:'d7.conversions',l:'7d conv',def:0},
  {k:'d30.sessions',l:'30d sess',def:1},
  {k:'d30.activeUsers',l:'30d users',def:1},
- {k:'d30.screenPageViews',l:'30d views'},
- {k:'d30.conversions',l:'30d conv'},
+ {k:'d30.screenPageViews',l:'30d views',def:0},
+ {k:'d30.conversions',l:'30d conv',def:0},
 ];
 const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 const dig=(o,k)=>k.split('.').reduce((a,x)=>a?.[x],o);
 const num=x=>{x=+x||0;return x?Math.round(x).toLocaleString():'<span class=z>0</span>'};
-let DATA={rows:[]}, sortK=localStorage.gaSortK||'d30.sessions', sortDir=+(localStorage.gaSortDir||-1);
-let hidden=new Set(JSON.parse(localStorage.gaHidden||'[]'));
+let DATA={rows:[]},CDATA=null;
+let sortK=localStorage.gaSortK||'d30.sessions',sortDir=+(localStorage.gaSortDir||-1);
+// field selector: default from COLS[].def, then user override persists
+let hidden=new Set(JSON.parse(localStorage.gaHidden||'null')||COLS.filter(c=>!c.def).map(c=>c.k));
 
+/* ---- field selector (persists on load) ---- */
 function renderCols(){
- document.getElementById('cols').innerHTML='columns: '+COLS.map(c=>
-   `<label><input type=checkbox data-c="${c.k}" ${hidden.has(c.k)?'':'checked'}>${c.l}</label>`).join(' ');
+ document.getElementById('cols').innerHTML='<b>Fields on load:</b>'+COLS.map(c=>
+   `<label><input type=checkbox data-c="${c.k}" ${hidden.has(c.k)?'':'checked'}>${c.l}</label>`).join(' ')+
+   ' <a href="#" id="resetcols" style="margin-left:8px">reset</a>';
  document.querySelectorAll('#cols input').forEach(cb=>cb.onchange=()=>{
    cb.checked?hidden.delete(cb.dataset.c):hidden.add(cb.dataset.c);
-   localStorage.gaHidden=JSON.stringify([...hidden]);render();});
+   localStorage.gaHidden=JSON.stringify([...hidden]);renderTable();});
+ document.getElementById('resetcols').onclick=e=>{e.preventDefault();hidden=new Set(COLS.filter(c=>!c.def).map(c=>c.k));localStorage.removeItem('gaHidden');renderCols();renderTable();};
 }
-function vis(){return COLS.filter(c=>!hidden.has(c.k))}
-function render(){
+const vis=()=>COLS.filter(c=>!hidden.has(c.k));
+
+/* ---- SITES table ---- */
+function renderTable(){
  const q=document.getElementById('q').value.toLowerCase();
  const onlyA=document.getElementById('onlyactive').checked;
  let rows=DATA.rows.filter(r=>(r.property||'').toLowerCase().includes(q));
  if(onlyA)rows=rows.filter(r=>dig(r,'d30.sessions')>0);
  rows.sort((a,b)=>{const x=dig(a,sortK),y=dig(b,sortK);
-   if(typeof x==='string')return sortDir*String(x).localeCompare(String(y));
-   return sortDir*((+x||0)-(+y||0));});
- // header
- document.getElementById('hr').innerHTML=vis().map(c=>
-   `<th class="${c.t==='l'?'l':''}" data-k="${c.k}">${c.l}${sortK===c.k?(sortDir<0?' ▼':' ▲'):''}</th>`).join('');
- document.querySelectorAll('#hr th').forEach(th=>th.onclick=()=>{
-   const k=th.dataset.k; if(sortK===k)sortDir*=-1; else{sortK=k;sortDir=k.startsWith('property')||k.startsWith('account')?1:-1;}
-   localStorage.gaSortK=sortK;localStorage.gaSortDir=sortDir;render();});
- // body
+   return typeof x==='string'?sortDir*String(x).localeCompare(String(y)):sortDir*((+x||0)-(+y||0));});
+ document.getElementById('hr').innerHTML=vis().map(c=>`<th class="${c.t==='l'?'l':''}" data-k="${c.k}">${c.l}${sortK===c.k?(sortDir<0?' ▼':' ▲'):''}</th>`).join('');
+ document.querySelectorAll('#hr th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;
+   if(sortK===k)sortDir*=-1;else{sortK=k;sortDir=(k==='property'||k==='account')?1:-1;}
+   localStorage.gaSortK=sortK;localStorage.gaSortDir=sortDir;renderTable();});
  document.getElementById('tb').innerHTML=rows.map(r=>'<tr>'+vis().map(c=>{
-   const v=c.fmt?c.fmt(r):num(dig(r,c.k));
-   return `<td class="${c.t==='l'?'l':''} ${c.cls||''}">${v}</td>`;}).join('')+'</tr>').join('');
- // totals
+   const v=c.fmt?c.fmt(r):num(dig(r,c.k));return `<td class="${c.t==='l'?'l':''} ${c.cls||''}">${v}</td>`;}).join('')+'</tr>').join('');
  const tot=k=>rows.reduce((s,r)=>s+(+dig(r,k)||0),0);
- document.getElementById('tf').innerHTML=vis().map((c,i)=>{
-   if(i===0)return `<td class="l">Σ ${rows.length} sites</td>`;
-   if(c.t==='l')return '<td class="l"></td>';
-   return `<td>${num(tot(c.k))}</td>`;}).join('');
+ document.getElementById('tf').innerHTML=vis().map((c,i)=>i===0?`<td class="l">Σ ${rows.length} sites</td>`:(c.t==='l'?'<td class="l"></td>':`<td>${num(tot(c.k))}</td>`)).join('');
 }
-function fmtAge(iso){const s=(Date.now()-new Date(iso))/1000;
- if(s<90)return 'just now';if(s<3600)return Math.round(s/60)+'m ago';return Math.round(s/3600)+'h ago';}
-async function load(){
- if(window.__BOOT__){DATA=window.__BOOT__;window.__BOOT__=null;}
- else{const r=await fetch(location.origin+'/api/data');DATA=await r.json();}
+
+/* ---- VISUALS ---- */
+function renderVisuals(){
+ const top=DATA.rows.filter(r=>dig(r,'d30.sessions')>0).slice(0,10);
+ const max=top.length?dig(top[0],'d30.sessions'):1;
+ document.getElementById('pyr').innerHTML=top.map(r=>{
+   const s=dig(r,'d30.sessions');const w=Math.max(6,Math.round(100*s/max));
+   return `<div class="row"><span class="lbl">${esc(r.property)}</span><span class="bar" style="width:${w}%">${Math.round(s)}</span><span class="val"></span></div>`;}).join('')||'<div class="z">no traffic yet</div>';
+ // mind map (SVG radial): center → top 8 sites
+ const nodes=DATA.rows.filter(r=>dig(r,'d30.sessions')>0).slice(0,8);
+ const W=820,H=460,cx=W/2,cy=H/2,R=170;
+ const t30=DATA.rows.reduce((s,r)=>s+dig(r,'d30.sessions'),0);
+ let svg=`<svg viewBox="0 0 ${W} ${H}" width="100%" style="max-height:480px">`;
+ nodes.forEach((n,i)=>{const a=-Math.PI/2+2*Math.PI*i/nodes.length;const x=cx+R*Math.cos(a),y=cy+R*Math.sin(a);
+   svg+=`<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="#cdd4dd" stroke-width="2"/>`;});
+ nodes.forEach((n,i)=>{const a=-Math.PI/2+2*Math.PI*i/nodes.length;const x=cx+R*Math.cos(a),y=cy+R*Math.sin(a);
+   const c=PAL[i%PAL.length];const lbl=(n.property||'').slice(0,16);const s=Math.round(dig(n,'d30.sessions'));
+   svg+=`<g><rect x="${x-64}" y="${y-16}" width="128" height="32" rx="8" fill="#fff" stroke="${c}" stroke-width="2"/>`+
+        `<text x="${x}" y="${y-1}" text-anchor="middle" fill="#1a1a1a">${esc(lbl)}</text>`+
+        `<text x="${x}" y="${y+12}" text-anchor="middle" fill="${c}" font-weight="700">${s} sess</text></g>`;});
+ svg+=`<circle cx="${cx}" cy="${cy}" r="52" fill="#0f1115"/>`+
+      `<text x="${cx}" y="${cy-6}" text-anchor="middle" fill="#fff" font-weight="700">FLEET</text>`+
+      `<text x="${cx}" y="${cy+12}" text-anchor="middle" fill="#8fa">${DATA.rows.length} sites</text>`+
+      `<text x="${cx}" y="${cy+28}" text-anchor="middle" fill="#8fa">${Math.round(t30)} sess</text></svg>`;
+ document.getElementById('mindmap').innerHTML=svg;
+}
+
+/* ---- COUNTRIES ---- */
+async function ensureCountries(){if(CDATA)return;try{const r=await fetch(location.origin+'/api/countries');CDATA=await r.json();}catch(e){CDATA={rows:[]};}}
+async function renderCountries(){
+ await ensureCountries();
+ const rows=(CDATA.rows||[]);const tot=CDATA.total_sessions||rows.reduce((s,r)=>s+r.sessions,0)||1;
+ const top=rows.slice(0,8);const otherS=rows.slice(8).reduce((s,r)=>s+r.sessions,0);
+ const segs=top.map((r,i)=>({name:r.country,v:r.sessions,c:PAL[i%PAL.length]}));
+ if(otherS>0)segs.push({name:'Other',v:otherS,c:'#bdc3c7'});
+ let acc=0;const stops=segs.map(s=>{const a=100*acc/tot,b=100*(acc+s.v)/tot;acc+=s.v;return `${s.c} ${a.toFixed(2)}% ${b.toFixed(2)}%`;}).join(',');
+ document.getElementById('pie').style.background=`conic-gradient(${stops})`;
+ document.getElementById('pielegend').innerHTML=segs.map(s=>`<li><span class="sw" style="background:${s.c}"></span>${esc(s.name)}<span class="v">${Math.round(s.v)} · ${(100*s.v/tot).toFixed(1)}%</span></li>`).join('');
+ document.getElementById('ctb').innerHTML=rows.map(r=>`<tr><td class="l">${esc(r.country)}</td><td>${Math.round(r.sessions).toLocaleString()}</td><td>${Math.round(r.users).toLocaleString()}</td><td>${r.share}%</td></tr>`).join('');
+}
+
+/* ---- LIVE ---- */
+let liveTimer=null;
+async function pollLive(){
+ try{const r=await fetch(location.origin+'/api/live');const j=await r.json();
+  if(j.error){document.getElementById('livetot').textContent='ERR';document.getElementById('livesites').textContent=j.error;return;}
+  document.getElementById('livetot').textContent=j.total_active.toLocaleString();
+  document.getElementById('livesites').textContent=`${j.active_sites} site(s) active · ${j.property_count} polled · ${new Date(j.generated_at).toLocaleTimeString()}`;
+  const a=j.rows.filter(r=>r.active>0);
+  document.getElementById('ltb').innerHTML=(a.length?a:[{property:'— no active users on any site right now —',property_id:'',account:'',active:''}]).map(r=>
+    `<tr><td class="l">${r.property_id?`<a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/realtime/overview" target="_blank" rel="noopener noreferrer">${esc(r.property)}</a>`:esc(r.property)}</td><td class="l acct">${r.account&&r.account.includes('APPS')?'APPS':esc(r.account||'')}</td><td style="font-weight:700;color:#c0392b">${r.active}</td></tr>`).join('');
+  document.getElementById('ltf').innerHTML=`<td class="l">Σ fleet live</td><td class="l"></td><td>${j.total_active}</td>`;
+ }catch(e){document.getElementById('livesites').textContent='fetch failed: '+e.message;}
+}
+
+/* ---- tabs ---- */
+const TITLES={sites:'All Sites',visuals:'Visuals',countries:'User Country',live:'Live'};
+function showTab(tab){
+ document.querySelectorAll('.side button').forEach(b=>b.classList.toggle('on',b.dataset.tab===tab));
+ ['sites','visuals','countries','live'].forEach(t=>document.getElementById('v-'+t).hidden=(t!==tab));
+ document.getElementById('htitle').textContent=TITLES[tab];
+ clearInterval(liveTimer);liveTimer=null;
+ if(tab==='visuals')renderVisuals();
+ if(tab==='countries')renderCountries();
+ if(tab==='live'){pollLive();liveTimer=setInterval(pollLive,20000);}
+}
+document.querySelectorAll('.side button').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));
+
+/* ---- header + load ---- */
+function fmtAge(iso){const s=(Date.now()-new Date(iso))/1000;return s<90?'just now':s<3600?Math.round(s/60)+'m ago':Math.round(s/3600)+'h ago';}
+function header(){
  const active=DATA.rows.filter(x=>dig(x,'d30.sessions')>0).length;
- const t7=DATA.rows.reduce((s,x)=>s+dig(x,'d7.sessions'),0);
- const t30=DATA.rows.reduce((s,x)=>s+dig(x,'d30.sessions'),0);
+ const t7=DATA.rows.reduce((s,x)=>s+dig(x,'d7.sessions'),0),t30=DATA.rows.reduce((s,x)=>s+dig(x,'d30.sessions'),0);
  document.getElementById('hstat').textContent=`${DATA.property_count} sites · ${active} with traffic · ${Math.round(t7).toLocaleString()} sess/7d · ${Math.round(t30).toLocaleString()} sess/30d`;
  const age=(Date.now()-new Date(DATA.generated_at))/60000;
- const b=document.getElementById('hbadge');
- b.innerHTML=`<span class="badge ${age>45?'stale':'fresh'}">${age>45?'STALE':'fresh'} · ${fmtAge(DATA.generated_at)}</span>`;
- document.getElementById('hgen').textContent='cache built '+DATA.generated_at.slice(0,16).replace('T',' ')+'Z ('+DATA.elapsed_s+'s)';
- render();
+ document.getElementById('hbadge').innerHTML=`<span class="badge ${age>45?'stale':'fresh'}">${age>45?'STALE':'fresh'} · ${fmtAge(DATA.generated_at)}</span>`;
+ document.getElementById('hgen').textContent='cache '+DATA.generated_at.slice(0,16).replace('T',' ')+'Z';
 }
-document.getElementById('q').oninput=render;
-document.getElementById('onlyactive').onchange=render;
-document.getElementById('dens').oninput=e=>{document.documentElement.style.setProperty('--fs',e.target.value+'px');
- document.documentElement.style.setProperty('--pad',Math.max(3,e.target.value-7)+'px');localStorage.gaDens=e.target.value;};
-if(localStorage.gaDens){document.getElementById('dens').value=localStorage.gaDens;document.getElementById('dens').oninput({target:{value:localStorage.gaDens}});}
-document.getElementById('refresh').onclick=async()=>{
- const m=document.getElementById('rmsg');m.textContent='refreshing… (~70s, pulls all sites)';
+async function load(){
+ if(window.__BOOT__){DATA=window.__BOOT__;window.__BOOT__=null;}
+ else{const r=await fetch(location.origin+'/api/data');DATA=await r.json();}
+ header();renderTable();
+ const cur=document.querySelector('.side button.on').dataset.tab;
+ if(cur==='visuals')renderVisuals();if(cur==='countries'){CDATA=null;renderCountries();}
+}
+document.getElementById('q').oninput=renderTable;
+document.getElementById('onlyactive').onchange=renderTable;
+document.getElementById('dens').oninput=e=>{document.documentElement.style.setProperty('--fs',e.target.value+'px');document.documentElement.style.setProperty('--pad',Math.max(3,e.target.value-7)+'px');localStorage.gaDens=e.target.value;};
+if(localStorage.gaDens){const el=document.getElementById('dens');el.value=localStorage.gaDens;el.dispatchEvent(new Event('input'));}
+document.getElementById('refresh').onclick=async()=>{const m=document.getElementById('rmsg');m.textContent='refreshing… (~50s)';
  await fetch(location.origin+'/api/refresh',{method:'POST'});
- let tries=0;const iv=setInterval(async()=>{tries++;const r=await fetch(location.origin+'/api/status');const j=await r.json();
-   if(!j.refreshing){clearInterval(iv);m.textContent='updated ✓';load();setTimeout(()=>m.textContent='',3000);}
-   else m.textContent=`refreshing… ${tries*3}s`;},3000);
-};
+ let n=0;const iv=setInterval(async()=>{n++;const j=await(await fetch(location.origin+'/api/status')).json();
+   if(!j.refreshing){clearInterval(iv);m.textContent='updated ✓';CDATA=null;load();setTimeout(()=>m.textContent='',3000);}else m.textContent=`refreshing… ${n*3}s`;},3000);};
 renderCols();load();
 </script></body></html>"""
 
@@ -198,14 +370,20 @@ class H(BaseHTTPRequestHandler):
             return
         if self.path == "/" or self.path.startswith("/index"):
             boot = CACHE.read_text() if CACHE.exists() else "null"
-            html = PAGE.replace("/*BOOTDATA*/null/*/BOOTDATA*/", boot)
-            self._send(200, "text/html", html.encode())
+            self._send(200, "text/html", PAGE.replace("/*BOOTDATA*/null/*/BOOTDATA*/", boot).encode())
         elif self.path == "/api/data":
-            body = CACHE.read_bytes() if CACHE.exists() else b'{"rows":[],"property_count":0,"generated_at":"1970-01-01T00:00:00","elapsed_s":0}'
-            self._send(200, "application/json", body)
-        elif self.path == "/api/status":
             self._send(200, "application/json",
-                       json.dumps({"refreshing": _refreshing["on"]}).encode())
+                       CACHE.read_bytes() if CACHE.exists() else b'{"rows":[],"property_count":0,"generated_at":"1970-01-01T00:00:00"}')
+        elif self.path == "/api/countries":
+            self._send(200, "application/json",
+                       COUNTRIES.read_bytes() if COUNTRIES.exists() else b'{"rows":[],"total_sessions":0}')
+        elif self.path == "/api/status":
+            self._send(200, "application/json", json.dumps({"refreshing": _refreshing["on"]}).encode())
+        elif self.path == "/api/live":
+            try:
+                self._send(200, "application/json", json.dumps(fetch_live()).encode())
+            except Exception as e:
+                self._send(500, "application/json", json.dumps({"error": str(e)[:200]}).encode())
         else:
             self._send(404, "text/plain", b"not found")
 
diff --git a/server.py.bak b/server.py.bak
new file mode 100644
index 0000000..d67bb77
--- /dev/null
+++ b/server.py.bak
@@ -0,0 +1,326 @@
+#!/usr/bin/env python3
+"""GA All-Sites dashboard — standing local server (DTD verdict 2026-08-04, Option 1).
+Reads cache/data.json ONLY (never calls the GA API on page load). A scheduled
+etl.py run refreshes the cache; a manual Refresh button can trigger it too.
+
+Basic-auth admin/DW2024! (house standard). Zero dependencies (stdlib only).
+"""
+from __future__ import annotations
+
+import base64
+import json
+import subprocess
+import sys
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+HERE = Path(__file__).parent
+CACHE = HERE / "cache" / "data.json"
+PORT = int((HERE / ".port").read_text().strip()) if (HERE / ".port").exists() else 9780
+USER, PW = "admin", "DW2024!"
+
+_refresh_lock = threading.Lock()
+_refreshing = {"on": False}
+
+
+def run_etl():
+    with _refresh_lock:
+        if _refreshing["on"]:
+            return
+        _refreshing["on"] = True
+    try:
+        subprocess.run([sys.executable, str(HERE / "etl.py")], cwd=str(HERE),
+                       capture_output=True, timeout=300)
+    finally:
+        _refreshing["on"] = False
+
+
+# ---- LIVE (GA4 Realtime API: active users in the last 30 min) ----
+import os
+import time as _time
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timezone
+
+_KEY = Path.home() / ".config" / "ga-analytics-agent" / "service-account.json"
+_live_cache = {"ts": 0, "data": None}
+_live_lock = threading.Lock()
+_LIVE_TTL = 20  # seconds
+
+
+def _properties_from_cache():
+    if not CACHE.exists():
+        return []
+    d = json.loads(CACHE.read_text())
+    return [{"property": r["property"], "property_id": r["property_id"],
+             "account": r.get("account", "")} for r in d.get("rows", [])]
+
+
+def fetch_live():
+    now = _time.time()
+    with _live_lock:
+        if _live_cache["data"] and now - _live_cache["ts"] < _LIVE_TTL:
+            return _live_cache["data"]
+    os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(_KEY)
+    from google.analytics.data_v1beta import BetaAnalyticsDataClient
+    from google.analytics.data_v1beta.types import Metric, RunRealtimeReportRequest
+    client = BetaAnalyticsDataClient()
+    props = _properties_from_cache()
+
+    def one(p):
+        try:
+            resp = client.run_realtime_report(RunRealtimeReportRequest(
+                property=f"properties/{p['property_id']}",
+                metrics=[Metric(name="activeUsers")]))
+            active = int(resp.rows[0].metric_values[0].value) if resp.rows else 0
+        except Exception:
+            active = 0
+        return {**p, "active": active}
+
+    rows = []
+    with ThreadPoolExecutor(max_workers=12) as ex:
+        rows = list(ex.map(one, props))
+    rows.sort(key=lambda r: r["active"], reverse=True)
+    data = {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "total_active": sum(r["active"] for r in rows),
+        "active_sites": sum(1 for r in rows if r["active"] > 0),
+        "property_count": len(rows),
+        "rows": rows,
+    }
+    with _live_lock:
+        _live_cache["ts"] = _time.time()
+        _live_cache["data"] = data
+    return data
+
+
+PAGE = r"""<!DOCTYPE html><html><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>GA · All Sites At Once</title>
+<style>
+ :root{--fs:13px;--pad:6px}
+ *{box-sizing:border-box}
+ body{font:var(--fs)/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;color:#1a1a1a;background:#f6f7f9}
+ header{position:sticky;top:0;z-index:5;background:#111;color:#fff;padding:12px 18px;display:flex;flex-wrap:wrap;gap:14px;align-items:center}
+ header h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.2px}
+ header .stat{font-variant-numeric:tabular-nums;color:#cfe;font-size:13px}
+ header .muted{color:#9aa}
+ .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:10px 18px;background:#fff;border-bottom:1px solid #e6e8ec;position:sticky;top:44px;z-index:4}
+ .bar input[type=search]{padding:6px 10px;border:1px solid #cbd0d6;border-radius:6px;min-width:220px;font-size:13px}
+ .bar label{font-size:12px;color:#555;display:inline-flex;gap:4px;align-items:center;cursor:pointer}
+ .bar button{padding:6px 12px;border:1px solid #cbd0d6;background:#fafbfc;border-radius:6px;cursor:pointer;font-size:12px}
+ .bar button:hover{background:#eef1f4}
+ .cols{display:flex;flex-wrap:wrap;gap:2px 10px;padding:6px 18px;background:#fbfcfd;border-bottom:1px solid #eef0f3;font-size:11px;color:#667}
+ .wrap{padding:0 0 40px}
+ table{border-collapse:collapse;width:100%;background:#fff}
+ thead th{position:sticky;top:96px;background:#f2f4f7;text-align:right;padding:var(--pad) 12px;border-bottom:2px solid #dde1e6;font-weight:600;cursor:pointer;white-space:nowrap;user-select:none}
+ thead th.l{text-align:left}
+ thead th:hover{background:#e9edf2}
+ tbody td{padding:var(--pad) 12px;text-align:right;border-bottom:1px solid #f0f1f4;font-variant-numeric:tabular-nums;white-space:nowrap}
+ tbody td.l{text-align:left}
+ tbody tr:hover{background:#f8fafc}
+ a{color:#1558d6;text-decoration:none}
+ a:hover{text-decoration:underline}
+ .z{color:#b8bec6}
+ .acct{font-size:11px;color:#7a828c}
+ .badge{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:600}
+ .fresh{background:#e4f5e9;color:#1a7a3c}
+ .stale{background:#fdecc8;color:#8a5a00}
+ tfoot td{padding:8px 12px;font-weight:700;border-top:2px solid #dde1e6;text-align:right;background:#f7f9fb}
+ tfoot td.l{text-align:left}
+ .err{color:#c0392b;font-size:11px}
+</style></head><body>
+<header>
+ <h1>GA · All Sites At Once</h1>
+ <span class="stat" id="hstat">loading…</span>
+ <span id="hbadge"></span>
+ <span class="stat muted" style="margin-left:auto" id="hgen"></span>
+</header>
+<div class="bar">
+ <input type="search" id="q" placeholder="filter sites…" autocomplete="off">
+ <label>density <input type="range" id="dens" min="10" max="18" value="13"></label>
+ <label><input type="checkbox" id="onlyactive"> only sites with traffic</label>
+ <button id="live">🔴 Live view</button>
+ <button id="refresh">↻ Refresh data</button>
+ <span class="muted" id="rmsg"></span>
+</div>
+<div id="livebar" style="display:none;padding:14px 18px;background:#1a0f14;color:#fff;border-bottom:2px solid #c0392b;align-items:baseline;gap:16px">
+ <span style="font-size:13px;color:#f5b7b1;font-weight:600">🔴 LIVE · last 30 min</span>
+ <span id="livetot" style="font-size:26px;font-weight:700;font-variant-numeric:tabular-nums">—</span>
+ <span style="font-size:13px;color:#e8a">active users on the fleet right now</span>
+ <span id="livesites" class="muted" style="margin-left:auto;font-size:12px"></span>
+</div>
+<div class="cols" id="cols"></div>
+<div class="wrap"><table id="t">
+ <thead><tr id="hr"></tr></thead>
+ <tbody id="tb"></tbody>
+ <tfoot><tr id="tf"></tr></tfoot>
+</table></div>
+<script>window.__BOOT__=/*BOOTDATA*/null/*/BOOTDATA*/;</script>
+<script>
+const COLS=[
+ {k:'property',l:'Site',t:'l',fmt:(r)=>`<a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/reports/intelligenthome" target="_blank" rel="noopener noreferrer">${esc(r.property||'?')}</a>${r.error?` <span class="err" title="${esc(r.error)}">⚠</span>`:''}`},
+ {k:'account',l:'Account',t:'l',cls:'acct',fmt:(r)=>r.account.includes('APPS')?'APPS':esc(r.account)},
+ {k:'d7.sessions',l:'7d sess',def:1},
+ {k:'d7.activeUsers',l:'7d users'},
+ {k:'d7.screenPageViews',l:'7d views'},
+ {k:'d7.conversions',l:'7d conv'},
+ {k:'d30.sessions',l:'30d sess',def:1},
+ {k:'d30.activeUsers',l:'30d users',def:1},
+ {k:'d30.screenPageViews',l:'30d views'},
+ {k:'d30.conversions',l:'30d conv'},
+];
+const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const dig=(o,k)=>k.split('.').reduce((a,x)=>a?.[x],o);
+const num=x=>{x=+x||0;return x?Math.round(x).toLocaleString():'<span class=z>0</span>'};
+let DATA={rows:[]}, sortK=localStorage.gaSortK||'d30.sessions', sortDir=+(localStorage.gaSortDir||-1);
+let hidden=new Set(JSON.parse(localStorage.gaHidden||'[]'));
+
+function renderCols(){
+ document.getElementById('cols').innerHTML='columns: '+COLS.map(c=>
+   `<label><input type=checkbox data-c="${c.k}" ${hidden.has(c.k)?'':'checked'}>${c.l}</label>`).join(' ');
+ document.querySelectorAll('#cols input').forEach(cb=>cb.onchange=()=>{
+   cb.checked?hidden.delete(cb.dataset.c):hidden.add(cb.dataset.c);
+   localStorage.gaHidden=JSON.stringify([...hidden]);render();});
+}
+function vis(){return COLS.filter(c=>!hidden.has(c.k))}
+function render(){
+ const q=document.getElementById('q').value.toLowerCase();
+ const onlyA=document.getElementById('onlyactive').checked;
+ let rows=DATA.rows.filter(r=>(r.property||'').toLowerCase().includes(q));
+ if(onlyA)rows=rows.filter(r=>dig(r,'d30.sessions')>0);
+ rows.sort((a,b)=>{const x=dig(a,sortK),y=dig(b,sortK);
+   if(typeof x==='string')return sortDir*String(x).localeCompare(String(y));
+   return sortDir*((+x||0)-(+y||0));});
+ // header
+ document.getElementById('hr').innerHTML=vis().map(c=>
+   `<th class="${c.t==='l'?'l':''}" data-k="${c.k}">${c.l}${sortK===c.k?(sortDir<0?' ▼':' ▲'):''}</th>`).join('');
+ document.querySelectorAll('#hr th').forEach(th=>th.onclick=()=>{
+   const k=th.dataset.k; if(sortK===k)sortDir*=-1; else{sortK=k;sortDir=k.startsWith('property')||k.startsWith('account')?1:-1;}
+   localStorage.gaSortK=sortK;localStorage.gaSortDir=sortDir;render();});
+ // body
+ document.getElementById('tb').innerHTML=rows.map(r=>'<tr>'+vis().map(c=>{
+   const v=c.fmt?c.fmt(r):num(dig(r,c.k));
+   return `<td class="${c.t==='l'?'l':''} ${c.cls||''}">${v}</td>`;}).join('')+'</tr>').join('');
+ // totals
+ const tot=k=>rows.reduce((s,r)=>s+(+dig(r,k)||0),0);
+ document.getElementById('tf').innerHTML=vis().map((c,i)=>{
+   if(i===0)return `<td class="l">Σ ${rows.length} sites</td>`;
+   if(c.t==='l')return '<td class="l"></td>';
+   return `<td>${num(tot(c.k))}</td>`;}).join('');
+}
+function fmtAge(iso){const s=(Date.now()-new Date(iso))/1000;
+ if(s<90)return 'just now';if(s<3600)return Math.round(s/60)+'m ago';return Math.round(s/3600)+'h ago';}
+async function load(){
+ if(window.__BOOT__){DATA=window.__BOOT__;window.__BOOT__=null;}
+ else{const r=await fetch(location.origin+'/api/data');DATA=await r.json();}
+ const active=DATA.rows.filter(x=>dig(x,'d30.sessions')>0).length;
+ const t7=DATA.rows.reduce((s,x)=>s+dig(x,'d7.sessions'),0);
+ const t30=DATA.rows.reduce((s,x)=>s+dig(x,'d30.sessions'),0);
+ document.getElementById('hstat').textContent=`${DATA.property_count} sites · ${active} with traffic · ${Math.round(t7).toLocaleString()} sess/7d · ${Math.round(t30).toLocaleString()} sess/30d`;
+ const age=(Date.now()-new Date(DATA.generated_at))/60000;
+ const b=document.getElementById('hbadge');
+ b.innerHTML=`<span class="badge ${age>45?'stale':'fresh'}">${age>45?'STALE':'fresh'} · ${fmtAge(DATA.generated_at)}</span>`;
+ document.getElementById('hgen').textContent='cache built '+DATA.generated_at.slice(0,16).replace('T',' ')+'Z ('+DATA.elapsed_s+'s)';
+ render();
+}
+document.getElementById('q').oninput=render;
+document.getElementById('onlyactive').onchange=render;
+document.getElementById('dens').oninput=e=>{document.documentElement.style.setProperty('--fs',e.target.value+'px');
+ document.documentElement.style.setProperty('--pad',Math.max(3,e.target.value-7)+'px');localStorage.gaDens=e.target.value;};
+if(localStorage.gaDens){document.getElementById('dens').value=localStorage.gaDens;document.getElementById('dens').oninput({target:{value:localStorage.gaDens}});}
+document.getElementById('refresh').onclick=async()=>{
+ const m=document.getElementById('rmsg');m.textContent='refreshing… (~70s, pulls all sites)';
+ await fetch(location.origin+'/api/refresh',{method:'POST'});
+ let tries=0;const iv=setInterval(async()=>{tries++;const r=await fetch(location.origin+'/api/status');const j=await r.json();
+   if(!j.refreshing){clearInterval(iv);m.textContent='updated ✓';load();setTimeout(()=>m.textContent='',3000);}
+   else m.textContent=`refreshing… ${tries*3}s`;},3000);
+};
+// ---- LIVE view ----
+let liveMode=false, liveTimer=null;
+async function pollLive(){
+ try{
+  const r=await fetch(location.origin+'/api/live');const j=await r.json();
+  if(j.error){document.getElementById('livetot').textContent='ERR';document.getElementById('livesites').textContent=j.error;return;}
+  document.getElementById('livetot').textContent=j.total_active.toLocaleString();
+  document.getElementById('livesites').textContent=`${j.active_sites} site(s) active · ${j.property_count} polled · updated ${new Date(j.generated_at).toLocaleTimeString()}`;
+  const active=j.rows.filter(r=>r.active>0);
+  document.getElementById('hr').innerHTML='<th class="l">Site</th><th class="l">Account</th><th>Active users (30 min)</th>';
+  document.getElementById('tb').innerHTML=(active.length?active:[{property:'— no active users on any site right now —',property_id:'',account:'',active:''}]).map(r=>
+    `<tr><td class="l">${r.property_id?`<a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/realtime/overview" target="_blank" rel="noopener noreferrer">${esc(r.property)}</a>`:esc(r.property)}</td>`+
+    `<td class="l acct">${r.account&&r.account.includes('APPS')?'APPS':esc(r.account||'')}</td>`+
+    `<td style="font-weight:700;color:#c0392b">${r.active===''?'':r.active}</td></tr>`).join('');
+  document.getElementById('tf').innerHTML=`<td class="l">Σ fleet live</td><td class="l"></td><td>${j.total_active}</td>`;
+ }catch(e){document.getElementById('livesites').textContent='fetch failed: '+e.message;}
+}
+document.getElementById('live').onclick=()=>{
+ liveMode=!liveMode;
+ const btn=document.getElementById('live');
+ document.getElementById('livebar').style.display=liveMode?'flex':'none';
+ if(liveMode){btn.textContent='◼ Exit live';btn.style.background='#c0392b';btn.style.color='#fff';pollLive();liveTimer=setInterval(pollLive,20000);}
+ else{btn.textContent='🔴 Live view';btn.style.background='';btn.style.color='';clearInterval(liveTimer);load();}
+};
+renderCols();load();
+</script></body></html>"""
+
+
+class H(BaseHTTPRequestHandler):
+    def _auth(self) -> bool:
+        h = self.headers.get("Authorization", "")
+        if h.startswith("Basic "):
+            try:
+                u, p = base64.b64decode(h[6:]).decode().split(":", 1)
+                if u == USER and p == PW:
+                    return True
+            except Exception:
+                pass
+        self.send_response(401)
+        self.send_header("WWW-Authenticate", 'Basic realm="GA All Sites"')
+        self.end_headers()
+        return False
+
+    def log_message(self, *a):
+        pass
+
+    def do_GET(self):
+        if not self._auth():
+            return
+        if self.path == "/" or self.path.startswith("/index"):
+            boot = CACHE.read_text() if CACHE.exists() else "null"
+            html = PAGE.replace("/*BOOTDATA*/null/*/BOOTDATA*/", boot)
+            self._send(200, "text/html", html.encode())
+        elif self.path == "/api/data":
+            body = CACHE.read_bytes() if CACHE.exists() else b'{"rows":[],"property_count":0,"generated_at":"1970-01-01T00:00:00","elapsed_s":0}'
+            self._send(200, "application/json", body)
+        elif self.path == "/api/status":
+            self._send(200, "application/json",
+                       json.dumps({"refreshing": _refreshing["on"]}).encode())
+        elif self.path == "/api/live":
+            try:
+                self._send(200, "application/json", json.dumps(fetch_live()).encode())
+            except Exception as e:
+                self._send(500, "application/json",
+                           json.dumps({"error": str(e)[:200]}).encode())
+        else:
+            self._send(404, "text/plain", b"not found")
+
+    def do_POST(self):
+        if not self._auth():
+            return
+        if self.path == "/api/refresh":
+            threading.Thread(target=run_etl, daemon=True).start()
+            self._send(202, "application/json", b'{"started":true}')
+        else:
+            self._send(404, "text/plain", b"not found")
+
+    def _send(self, code, ctype, body):
+        self.send_response(code)
+        self.send_header("Content-Type", ctype)
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+
+if __name__ == "__main__":
+    print(f"GA All-Sites dashboard → http://127.0.0.1:{PORT}  (admin/DW2024!)")
+    ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()

← fa60089 Add launchd ETL scheduler (30min auto-refresh); use homebrew  ·  back to Ga Allsites  ·  Housekeeping: drop server.py.bak, gitignore *.bak + preview 265e278 →