← back to Ga Allsites
server.py
713 lines
#!/usr/bin/env python3
"""GA All-Sites dashboard — standing local server (DTD verdict 2026-08-04, Option 1).
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"
KEYWORDS = HERE / "cache" / "keywords.json"
KEY = Path.home() / ".config" / "ga-analytics-agent" / "service-account.json"
# Default 9782 (not 9780): a fresh checkout with no .port must NOT land on 9780,
# which ventura-corridor owns on Kamatera — that collision caused the 2026-08 crash-loop.
PORT = int((HERE / ".port").read_text().strip()) if (HERE / ".port").exists() else 9782
USER, PW = "admin", "DW2024!"
_refresh_lock = threading.Lock()
_refreshing = {"on": False}
_live_cache = {"ts": 0, "data": None}
_live_lock = threading.Lock()
_LIVE_TTL = 12
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
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</title>
<style>
:root{--fs:13px;--pad:6px}
*{box-sizing:border-box}
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:15px;margin:0;font-weight:600}
header .stat{font-variant-numeric:tabular-nums;color:#cfe;font-size:13px}
header .muted{color:#9aa}
.winsel{display:inline-flex;border:1px solid #3a3f4a;border-radius:8px;overflow:hidden}
.winsel button{background:#1a1d24;color:#aeb6c2;border:0;padding:5px 13px;font-size:12px;cursor:pointer}
.winsel button:hover{color:#fff}
.winsel button.on{background:#e74c3c;color:#fff;font-weight:700}
.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}
.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: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}
.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}
/* 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}
/* KPI cards */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:18px}
.kpi{background:#fff;border:1px solid #e6e8ec;border-radius:10px;padding:16px}
.kpi .kv{font-size:25px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
.kpi .kl{font-size:12px;color:#667;margin-top:5px}
/* liquid gauge orbs */
.orbs{display:flex;flex-wrap:wrap;gap:24px;justify-content:center;padding:10px 0 6px}
.orb{width:190px;text-align:center}
.orb svg{width:170px;height:170px;display:block;margin:0 auto}
.orb .olabel{font-size:13px;color:#555;margin-top:8px;font-weight:600}
.orb .osub{font-size:11px;color:#8a8f98}
.wave{animation:wavemove linear infinite}
@keyframes wavemove{from{transform:translateX(0)}to{transform:translateX(-160px)}}
.orb .ocenter{font-size:26px;font-weight:800;fill:#12324f;font-variant-numeric:tabular-nums}
/* live moving graphics */
@keyframes pulse{0%{transform:scale(1);opacity:.9}70%{transform:scale(2.6);opacity:0}100%{opacity:0}}
.livehero{position:relative;padding:22px 26px;background:linear-gradient(120deg,#180d12,#2a1019);color:#fff;display:flex;align-items:center;gap:22px;flex-wrap:wrap}
.livedot{width:13px;height:13px;border-radius:50%;background:#e74c3c;position:relative;flex:0 0 auto}
.livedot::after{content:'';position:absolute;inset:0;border-radius:50%;background:#e74c3c;animation:pulse 1.6s ease-out infinite}
.bignum{font-size:54px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
.livelbl{font-size:13px;color:#f0a0b0;margin-top:5px}
.lrow{display:grid;grid-template-columns:180px 1fr;align-items:center;gap:12px;margin:6px 0;transition:transform .5s}
.lrow .nm{font-size:12px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#333}
.lbar{height:26px;border-radius:6px;background:linear-gradient(90deg,#e74c3c,#ff9a6b);color:#fff;display:flex;align-items:center;padding:0 9px;font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;min-width:26px;transition:width .9s cubic-bezier(.34,1.4,.5,1)}
</style></head><body>
<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="global"><span class="ic">🌊</span>Global</button>
<button data-tab="charts"><span class="ic">📈</span>Charts</button>
<button data-tab="keywords"><span class="ic">🔎</span>Keywords</button>
<button data-tab="visuals"><span class="ic">🔺</span>Visuals</button>
<button data-tab="countries"><span class="ic">🌍</span>Country</button>
<button data-tab="map"><span class="ic">🗺️</span>Map</button>
<button data-tab="live"><span class="ic">🔴</span>Live</button>
</nav>
<div class="main">
<header>
<h1 id="htitle">All Sites</h1>
<span class="winsel" id="winsel" title="Stats window — applies to Sites, Global, Charts, Visuals and Keywords"></span>
<span class="stat" id="hstat">loading…</span>
<span id="hbadge"></span>
<label style="margin-left:auto;color:#bcd;font-size:12px;display:inline-flex;gap:5px;align-items:center;cursor:pointer" title="Hide the main DW store (bot-inflated) so the sister-site fleet is visible"><input type="checkbox" id="exstore"> exclude main store</label>
<span class="stat muted" 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>
<!-- GLOBAL liquid gauges -->
<div class="view panel" id="v-global" hidden>
<div class="card"><h2>Fleet at a glance — live liquid gauges</h2>
<div class="orbs" id="orbs"></div>
<div class="kpis" id="gkpis" style="margin-top:18px"></div>
</div>
</div>
<!-- CHARTS overview -->
<div class="view panel" id="v-charts" hidden>
<div class="kpis" id="kpis"></div>
<div class="card"><h2 id="dbtitle">Top domains by sessions</h2><div id="domainbars"></div></div>
<div class="card"><h2>Sessions by country — 30 days</h2><div class="piewrap"><div class="pie" id="cpie"></div><ul class="legend" id="cpielegend"></ul></div></div>
</div>
<!-- KEYWORDS -->
<div class="view panel" id="v-keywords" hidden>
<div class="kpis" id="kwkpis"></div>
<div class="card" id="kwsetup" hidden></div>
<div class="card"><h2 id="kwqtitle">Top search queries</h2>
<table><thead><tr><th class="l">Query</th><th>Clicks</th><th>Impressions</th><th>CTR</th><th>Avg pos</th></tr></thead>
<tbody id="kwtb"></tbody></table></div>
<div class="card"><h2 id="kwgptitle">GSC grant priority — ranked by organic search (365d)</h2>
<p class="acct" id="kwgpsub"></p>
<table id="kwgptbl"><thead><tr>
<th class="l" data-k="property">Site</th><th data-k="organic_365d">Organic 365d</th>
<th data-k="total_365d">Total 365d</th><th data-k="organic_share">Organic %</th>
<th class="l" data-k="status">Status</th></tr></thead>
<tbody id="kwgptb"></tbody></table></div>
<div class="card"><h2 id="kwchtitle">Traffic by channel</h2>
<div class="piewrap"><div class="pie" id="kwpie"></div><ul class="legend" id="kwpielegend"></ul></div></div>
<div class="card"><h2 id="kworgtitle">Top sites by Organic Search sessions</h2><div id="kworg"></div></div>
</div>
<!-- VISUALS -->
<div class="view panel" id="v-visuals" hidden>
<div class="card"><h2 id="mmtitle">Fleet mind map — top sites by sessions</h2><div id="mindmap"></div></div>
<div class="card"><h2 id="pyrtitle">Traffic pyramid — top 10 sites</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>
<!-- MAP -->
<div class="view panel" id="v-map" hidden>
<div class="card"><h2>User world map — sessions by country, last 30 days</h2>
<div id="mapwrap" style="position:relative"><div id="map"></div>
<div id="maptip" style="position:absolute;pointer-events:none;background:#111;color:#fff;padding:3px 8px;border-radius:5px;font-size:12px;display:none;white-space:nowrap"></div></div>
<div id="maplegend" style="margin-top:10px;font-size:12px;color:#667"></div></div>
</div>
<!-- LIVE (moving graphics) -->
<div class="view" id="v-live" hidden>
<div class="livehero">
<span class="livedot"></span>
<div><div class="bignum" id="livetot">—</div><div class="livelbl">active users on the fleet · last 30 min</div></div>
<span id="livesites" style="margin-left:auto;font-size:12px;color:#c9a">connecting…</span>
</div>
<div class="panel">
<div class="card"><h2>Live pulse — active users over this session</h2><div id="livespark"></div></div>
<div class="card"><h2>Active right now — by site</h2><div id="livebars"></div></div>
</div>
</div>
</div>
<script>window.__BOOT__=/*BOOTDATA*/null/*/BOOTDATA*/;</script>
<script>
const PAL=['#5b8def','#e74c3c','#27ae60','#f39c12','#9b59b6','#16a085','#e67e22','#2c3e50','#c0392b','#7f8c8d'];
const fmtDurP=s=>{s=Math.round(+s||0);return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`;};
const fmtDur=s=>{s=Math.round(+s||0);return s?fmtDurP(s):'<span class=z>0:00</span>';};
/* Time window: Day / Week / Month / Year → d1 / d7 / d30 / d365 buckets.
Metric columns use 'm.<metric>' keys resolved against the selected window,
so one column set serves all four windows. */
const WINS=[['d1','Day'],['d7','Week'],['d30','Month'],['d365','Year']];
const WLBL=k=>(WINS.find(w=>w[0]===k)||['','?'])[1];
let WIN=localStorage.gaWin||'d30';if(!WINS.some(w=>w[0]===WIN))WIN='d30';
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const dig=(o,k)=>k.split('.').reduce((a,x)=>a?.[x],o);
const val=(r,k)=>k.startsWith('m.')?dig(r,WIN+'.'+k.slice(2)):dig(r,k);
const COLS=[
{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:'top_country',l:'Top country',t:'l',def:1,cls:'acct',fmt:r=>esc(r.top_country||'—')},
{k:'m.sessions',l:'Sessions',def:1},
{k:'m.activeUsers',l:'Users',def:1},
{k:'m.screenPageViews',l:'Views',def:1},
{k:'m.conversions',l:'Conv',def:0},
{k:'m.averageSessionDuration',l:'Time',def:1,notot:1,fmt:r=>fmtDur(val(r,'m.averageSessionDuration'))},
];
const num=x=>{x=+x||0;return x?Math.round(x).toLocaleString():'<span class=z>0</span>'};
let DATA={rows:[]},CDATA=null,KDATA=null;
const STORE_ID='294178030';
let exStore=localStorage.gaExStore==='1';
function activeRows(){return exStore?DATA.rows.filter(r=>r.property_id!==STORE_ID):DATA.rows;}
let sortK=localStorage.gaSortK2||'m.sessions',sortDir=+(localStorage.gaSortDir||-1);
// field selector: default from COLS[].def, then user override persists
let hidden=new Set(JSON.parse(localStorage.gaHidden2||'null')||COLS.filter(c=>!c.def).map(c=>c.k));
function renderWinSel(){
document.getElementById('winsel').innerHTML=WINS.map(([k,l])=>`<button data-w="${k}" class="${k===WIN?'on':''}">${l}</button>`).join('');
document.querySelectorAll('#winsel button').forEach(b=>b.onclick=()=>{
WIN=b.dataset.w;localStorage.gaWin=WIN;renderWinSel();header();
const cur=document.querySelector('.side button.on').dataset.tab;
({sites:renderTable,global:renderGlobal,charts:renderCharts,visuals:renderVisuals,keywords:renderKeywords}[cur]||renderTable)();});
}
/* ---- field selector (persists on load) ---- */
function renderCols(){
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]);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();};
}
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=activeRows().filter(r=>(r.property||'').toLowerCase().includes(q));
if(onlyA)rows=rows.filter(r=>val(r,'m.sessions')>0);
rows.sort((a,b)=>{const x=val(a,sortK),y=val(b,sortK);
return typeof x==='string'?sortDir*String(x).localeCompare(String(y)):sortDir*((+x||0)-(+y||0));});
const wl=WLBL(WIN);
document.getElementById('hr').innerHTML=vis().map(c=>`<th class="${c.t==='l'?'l':''}" data-k="${c.k}">${c.k.startsWith('m.')?wl+' '+c.l: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.gaSortK2=sortK;localStorage.gaSortDir=sortDir;renderTable();});
document.getElementById('tb').innerHTML=rows.map(r=>'<tr>'+vis().map(c=>{
const v=c.fmt?c.fmt(r):num(val(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+(+val(r,k)||0),0);
document.getElementById('tf').innerHTML=vis().map((c,i)=>i===0?`<td class="l">Σ ${rows.length} sites</td>`:(c.t==='l'?'<td class="l"></td>':c.notot?'<td></td>':`<td>${num(tot(c.k))}</td>`)).join('');
}
/* ---- GLOBAL: liquid-fill gauge orbs ---- */
function liquidOrb(level,center,label,sub,color,i){
level=Math.max(0,Math.min(1,+level||0));
const R=80,cx=85,cy=85,top=(cy-R)+(2*R)*(1-level),amp=5,wl=80;
let d='M0 '+top.toFixed(1);
for(let x=0;x<=320;x+=8){d+=' L'+x+' '+(top+amp*Math.sin((x/wl)*Math.PI*2)).toFixed(1);}
d+=' L320 170 L0 170 Z';
const id='clip'+i,dur=(3+i*0.6).toFixed(1);
return `<div class="orb"><svg viewBox="0 0 170 170">
<defs><clipPath id="${id}"><circle cx="${cx}" cy="${cy}" r="${R}"/></clipPath></defs>
<circle cx="${cx}" cy="${cy}" r="${R+3}" fill="#f2f6fb" stroke="#dbe3ec" stroke-width="2"/>
<g clip-path="url(#${id})">
<path class="wave" d="${d}" fill="${color}" opacity=".85" style="animation-duration:${dur}s"/>
<path class="wave" d="${d}" fill="${color}" opacity=".4" style="animation-duration:${(+dur+1.6).toFixed(1)}s;animation-direction:reverse"/>
</g>
<circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="#c3d0de" stroke-width="1"/>
<text class="ocenter" x="${cx}" y="${cy+9}" text-anchor="middle">${center}</text>
</svg><div class="olabel">${esc(label)}</div><div class="osub">${esc(sub)}</div></div>`;
}
async function renderGlobal(){
await ensureCountries();
const wl=WLBL(WIN);
const rows=activeRows(),total=rows.length,active=rows.filter(r=>val(r,'m.sessions')>0).length;
const tw=rows.reduce((s,r)=>s+(val(r,'m.sessions')||0),0),ty=rows.reduce((s,r)=>s+(dig(r,'d365.sessions')||0),0);
const pv=rows.reduce((s,r)=>s+(val(r,'m.screenPageViews')||0),0);
const wsum=rows.reduce((s,r)=>s+(val(r,'m.averageSessionDuration')||0)*(val(r,'m.sessions')||0),0),wavg=tw?wsum/tw:0;
const topC=((CDATA&&CDATA.rows)||[])[0]||{country:'—',share:0};
document.getElementById('orbs').innerHTML=[
liquidOrb(active/Math.max(1,total),active,'Sites with traffic','of '+total+' total · '+wl.toLowerCase(),'#27ae60',0),
liquidOrb((topC.share||0)/100,(topC.share||0)+'%','Top country',topC.country+' · 30d','#5b8def',1),
liquidOrb(ty?tw/ty:0,Math.round(tw).toLocaleString(),wl+' sessions','of year ('+Math.round(ty).toLocaleString()+')','#8a5bef',2),
liquidOrb(Math.min(1,wavg/90),fmtDurP(wavg),'Avg time on site',wl.toLowerCase()+' · vs 1:30 benchmark','#e67e22',3),
].join('');
const kpi=[['Domains',total],['Sessions · '+wl,Math.round(tw).toLocaleString()],['Sessions · Year',Math.round(ty).toLocaleString()],['Pageviews · '+wl,Math.round(pv).toLocaleString()],['Top country',topC.country]];
document.getElementById('gkpis').innerHTML=kpi.map((k,i)=>`<div class="kpi"><div class="kv" style="color:${PAL[i%PAL.length]}">${k[1]}</div><div class="kl">${k[0]}</div></div>`).join('');
}
/* ---- VISUALS ---- */
function renderVisuals(){
const sorted=[...DATA.rows].sort((a,b)=>(val(b,'m.sessions')||0)-(val(a,'m.sessions')||0));
const top=sorted.filter(r=>val(r,'m.sessions')>0).slice(0,10);
const max=top.length?val(top[0],'m.sessions'):1;
document.getElementById('pyrtitle').textContent=`Traffic pyramid — top 10 sites (${WLBL(WIN)} sessions)`;
document.getElementById('mmtitle').textContent=`Fleet mind map — top sites by ${WLBL(WIN)} sessions`;
document.getElementById('pyr').innerHTML=top.map(r=>{
const s=val(r,'m.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=sorted.filter(r=>val(r,'m.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+(val(r,'m.sessions')||0),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(val(n,'m.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 + shared pie ---- */
async function ensureCountries(){if(CDATA)return;try{const r=await fetch(location.origin+'/api/countries');CDATA=await r.json();}catch(e){CDATA={rows:[]};}}
function renderPie(pieId,legendId){
const rows=(CDATA&&CDATA.rows)||[];const tot=(CDATA&&CDATA.total_sessions)||rows.reduce((s,r)=>s+r.sessions,0)||1;
const top=rows.slice(0,8),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(',');
const pie=document.getElementById(pieId);if(pie)pie.style.background=`conic-gradient(${stops})`;
const lg=document.getElementById(legendId);if(lg)lg.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('');
}
async function renderCountries(){
await ensureCountries();
renderPie('pie','pielegend');
document.getElementById('ctb').innerHTML=(CDATA.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('');
}
/* ---- CHARTS overview (all domains on one page) ---- */
async function renderCharts(){
await ensureCountries();
const wl=WLBL(WIN);
const rows=activeRows();
const active=rows.filter(r=>val(r,'m.sessions')>0).length;
const tw=rows.reduce((s,r)=>s+(val(r,'m.sessions')||0),0),ty=rows.reduce((s,r)=>s+(dig(r,'d365.sessions')||0),0);
const pv=rows.reduce((s,r)=>s+(val(r,'m.screenPageViews')||0),0);
const wsum=rows.reduce((s,r)=>s+(val(r,'m.averageSessionDuration')||0)*(val(r,'m.sessions')||0),0);
const wavg=tw?wsum/tw:0;
const topC=((CDATA&&CDATA.rows)||[])[0]||{country:'—',share:0};
const kpi=[['Domains',rows.length],['Sessions · '+wl,Math.round(tw).toLocaleString()],['Sessions · Year',Math.round(ty).toLocaleString()],
['Pageviews · '+wl,Math.round(pv).toLocaleString()],['Avg time on site',fmtDurP(wavg)],['Top country',topC.country+(topC.share?' · '+topC.share+'%':'')],['Sites w/ traffic',active+' / '+rows.length]];
document.getElementById('kpis').innerHTML=kpi.map((k,i)=>`<div class="kpi"><div class="kv" style="color:${PAL[i%PAL.length]}">${k[1]}</div><div class="kl">${k[0]}</div></div>`).join('');
document.getElementById('dbtitle').textContent=`Top domains by sessions — ${wl}`;
const sorted=[...rows].sort((a,b)=>(val(b,'m.sessions')||0)-(val(a,'m.sessions')||0));
const top=sorted.filter(r=>val(r,'m.sessions')>0).slice(0,15),mx=Math.max(1,...top.map(r=>val(r,'m.sessions')));
document.getElementById('domainbars').innerHTML=top.map(r=>{const v=Math.round(val(r,'m.sessions'));
return `<div class="lrow"><span class="nm"><a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/reports/intelligenthome" target="_blank" rel="noopener noreferrer">${esc(r.property)}</a></span><div class="lbar" style="width:${Math.max(6,Math.round(100*v/mx))}%;background:linear-gradient(90deg,#5b8def,#8a5bef)">${v}</div></div>`;}).join('');
renderPie('cpie','cpielegend');
}
/* ---- KEYWORDS (GSC queries + GA4 channels, per window) ---- */
async function ensureKeywords(){if(KDATA)return;try{KDATA=await(await fetch(location.origin+'/api/keywords')).json();}catch(e){KDATA={gsc_site_count:0,windows:{},channels:{},organic_sites:{}};}}
async function renderKeywords(){
await ensureKeywords();
const wl=WLBL(WIN);
const wd=(KDATA.windows||{})[WIN]||{queries:[],totals:{}};
const t=wd.totals||{};
const ch=(KDATA.channels||{})[WIN]||{};
const org=(KDATA.organic_sites||{})[WIN]||[];
const orgTot=Math.round(ch['Organic Search']||0);
const kpi=[['Search queries',(t.query_count||0).toLocaleString()],['Clicks · '+wl,(t.clicks||0).toLocaleString()],
['Impressions · '+wl,(t.impressions||0).toLocaleString()],['Organic sessions · '+wl,orgTot.toLocaleString()],
['GSC sites',KDATA.gsc_site_count||0]];
document.getElementById('kwkpis').innerHTML=kpi.map((k,i)=>`<div class="kpi"><div class="kv" style="color:${PAL[i%PAL.length]}">${k[1]}</div><div class="kl">${k[0]}</div></div>`).join('');
const setup=document.getElementById('kwsetup');
if(!(KDATA.gsc_site_count>0)){
setup.hidden=false;
setup.innerHTML=`<h2>Unlock real search keywords (one-time, ~2 min)</h2>
<p>Google only exposes organic search <b>queries</b> through Search Console — GA4 alone can't see them. The dashboard is fully wired for them; it just needs to be let in:</p>
<ol style="line-height:1.9">
<li>Open <a href="https://search.google.com/search-console" target="_blank" rel="noopener noreferrer">Search Console</a> with the Google account that owns your sites.</li>
<li>Pick a property → ⚙ Settings → <b>Users and permissions</b> → <b>Add user</b>.</li>
<li>Add <code>${esc(KDATA.sa_email||'the dashboard service account')}</code> (Restricted access is enough).</li>
<li>Repeat per property — designerwallcoverings.com first. Queries appear on the next auto-refresh (≤30 min).</li>
</ol>
<p class="acct">Meanwhile the channel + organic-sessions stats below are live from GA4 — real search-traffic volume, just without the query strings.</p>`;
} else setup.hidden=true;
document.getElementById('kwqtitle').textContent=`Top search queries — ${wl}`;
document.getElementById('kwtb').innerHTML=(wd.queries||[]).slice(0,200).map(r=>
`<tr><td class="l">${esc(r.query)}</td><td>${(+r.clicks).toLocaleString()}</td><td>${(+r.impressions).toLocaleString()}</td><td>${r.ctr}%</td><td>${r.position}</td></tr>`).join('')
||`<tr><td class="l z" colspan="5" style="text-align:left;padding:14px">${KDATA.gsc_site_count>0?'no query data for this window yet':'no Search Console access yet — see the card above'}</td></tr>`;
document.getElementById('kwchtitle').textContent=`Traffic by channel — ${wl} (GA4, fleet-wide)`;
const segs=Object.entries(ch).sort((a,b)=>b[1]-a[1]);
const tot=segs.reduce((s,x)=>s+x[1],0)||1;
let acc=0;const col=(n,i)=>n==='Organic Search'?'#27ae60':PAL[i%PAL.length];
const stops=segs.map((s,i)=>{const a=100*acc/tot,b=100*(acc+s[1])/tot;acc+=s[1];return `${col(s[0],i)} ${a.toFixed(2)}% ${b.toFixed(2)}%`;}).join(',');
const pie=document.getElementById('kwpie');
pie.style.background=segs.length?`conic-gradient(${stops})`:'#eef1f4';
document.getElementById('kwpielegend').innerHTML=segs.map((s,i)=>
`<li><span class="sw" style="background:${col(s[0],i)}"></span>${esc(s[0])}<span class="v">${Math.round(s[1]).toLocaleString()} · ${(100*s[1]/tot).toFixed(1)}%</span></li>`).join('')
||'<li class="z">channel data arrives on the next data refresh</li>';
renderGrant();
document.getElementById('kworgtitle').textContent=`Top sites by Organic Search sessions — ${wl}`;
const mx=Math.max(1,...org.map(r=>r.sessions));
document.getElementById('kworg').innerHTML=org.map(r=>
`<div class="lrow"><span class="nm">${esc(r.property)}</span><div class="lbar" style="width:${Math.max(6,Math.round(100*r.sessions/mx))}%;background:linear-gradient(90deg,#27ae60,#7bd88a)">${Math.round(r.sessions)}</div></div>`).join('')
||'<div class="z" style="padding:14px">no organic-search sessions in this window yet</div>';
}
/* grant-priority table — organic-search-first, sortable, organic desc by default */
let KWGP={k:'organic_365d',dir:-1};
function grankOrder(r){return r.granted?0:(r.recommend?1:(!r.verifiable?2:3));}
function grankStatus(r){
if(r.granted)return '<span style="color:#27ae60">✅ granted</span>';
if(!r.verifiable)return '<span class="z">? verify domain</span>';
if(!r.meets_floor)return '<span class="z">— below floor</span>';
return '<span style="color:#2d7a34">▷ grant</span>';}
function renderGrant(){
const gp=(KDATA.grant_priority||[]).slice();
const q=gp.filter(r=>r.recommend).length;
document.getElementById('kwgpsub').textContent=
`GSC value tracks organic search, not total traffic — ranked by 365d organic sessions (floor ≥10). ${q} site${q===1?'':'s'} worth granting.`;
const k=KWGP.k,dir=KWGP.dir;
gp.sort((a,b)=>{if(k==='property')return dir*String(a.property).localeCompare(String(b.property));
const x=k==='status'?grankOrder(a):(a[k]||0),y=k==='status'?grankOrder(b):(b[k]||0);return dir*(x-y);});
document.querySelectorAll('#kwgptbl th').forEach(th=>{th.style.cursor='pointer';
th.style.opacity=th.dataset.k===k?'1':'.7';
th.onclick=()=>{KWGP.dir=(KWGP.k===th.dataset.k)?-KWGP.dir:-1;KWGP.k=th.dataset.k;renderGrant();};});
document.getElementById('kwgptb').innerHTML=gp.map(r=>
`<tr${r.granted?' style="opacity:.6"':''}><td class="l">${esc(r.property)}`+
`${r.domain?` <span class="z" style="font-size:11px">${esc(r.domain)}</span>`:''}</td>`+
`<td>${(r.organic_365d||0).toLocaleString()}</td><td>${(r.total_365d||0).toLocaleString()}</td>`+
`<td>${r.organic_share}%</td><td class="l">${grankStatus(r)}</td></tr>`).join('')
||'<tr><td class="l z" colspan="5" style="padding:14px">organic-search ranking arrives on the next data refresh</td></tr>';
}
/* ---- MAP (choropleth, zero-dep SVG) ---- */
let WORLD=null;
async function renderMap(){
await ensureCountries();
if(!WORLD){try{WORLD=await(await fetch(location.origin+'/api/world')).json();}catch(e){document.getElementById('map').textContent='map data unavailable';return;}}
const ALIAS={'USA':'United States','England':'United Kingdom','Turkey':'Türkiye','Czech Republic':'Czechia','Republic of Serbia':'Serbia'};
const sess={};(CDATA.rows||[]).forEach(r=>sess[r.country]=r.sessions);
const max=Math.max(1,...Object.values(sess));
const W=960,H=480;
const proj=([lo,la])=>[(lo+180)/360*W,(90-la)/180*H];
const ring=r=>'M'+r.map(p=>{const[x,y]=proj(p);return x.toFixed(1)+','+y.toFixed(1);}).join('L')+'Z';
const col=v=>{if(!v)return '#e9edf2';const t=0.15+0.85*Math.sqrt(v/max);
return `rgb(${Math.round(220-200*t)},${Math.round(233-160*t)},${Math.round(251-120*t)})`;};
let paths='';
WORLD.features.forEach(f=>{
const gn=f.properties.name,ga=ALIAS[gn]||gn,v=sess[ga]||0,g=f.geometry;
const polys=g.type==='Polygon'?[g.coordinates]:g.type==='MultiPolygon'?g.coordinates:[];
const d=polys.map(poly=>poly.map(ring).join('')).join('');
if(d)paths+=`<path d="${d}" fill="${col(v)}" stroke="#fff" stroke-width="0.4" data-n="${esc(ga)}" data-v="${Math.round(v)}"/>`;
});
document.getElementById('map').innerHTML=`<svg viewBox="0 0 ${W} ${H}" width="100%" style="background:#eaf2fb;border-radius:8px">${paths}</svg>`;
const tip=document.getElementById('maptip'),wrap=document.getElementById('mapwrap');
document.querySelectorAll('#map path').forEach(p=>{
p.onmousemove=e=>{tip.style.display='block';tip.textContent=`${p.dataset.n} — ${(+p.dataset.v).toLocaleString()} sess`;
const b=wrap.getBoundingClientRect();tip.style.left=(e.clientX-b.left+12)+'px';tip.style.top=(e.clientY-b.top+12)+'px';};
p.onmouseleave=()=>tip.style.display='none';
if(+p.dataset.v>0)p.style.cursor='pointer';});
document.getElementById('maplegend').innerHTML=`Shaded by 30-day sessions (darkest = ${esc((CDATA.rows[0]||{}).country||'')}, ${Math.round(max).toLocaleString()}). Hover any country for its total. Tiny territories (Hong Kong, Singapore) aren't drawn on this low-res map but are listed in the Country tab.`;
}
/* ---- LIVE (moving graphics) ---- */
let liveTimer=null,LIVEHIST=[],liveNumCur=0;
function animNum(el,to){const from=liveNumCur,t0=performance.now();
(function step(t){const k=Math.min(1,(t-t0)/600);el.textContent=Math.round(from+(to-from)*k).toLocaleString();if(k<1)requestAnimationFrame(step);})(t0);
liveNumCur=to;}
function sparkline(h){const W=760,H=130,p=8;
if(h.length<2)return '<div class="z" style="padding:24px">collecting live signal… this fills in as polls arrive (every ~15s)</div>';
const max=Math.max(1,...h.map(d=>d.v)),n=h.length;
const X=i=>p+(W-2*p)*i/(n-1),Y=v=>H-p-(H-2*p)*v/max;
const pts=h.map((d,i)=>`${X(i).toFixed(1)},${Y(d.v).toFixed(1)}`);
const lx=X(n-1),ly=Y(h[n-1].v);
return `<svg viewBox="0 0 ${W} ${H}" width="100%" style="max-height:160px">
<defs><linearGradient id="lg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e74c3c" stop-opacity=".35"/><stop offset="1" stop-color="#e74c3c" stop-opacity="0"/></linearGradient></defs>
<path d="M${X(0)},${H-p} L${pts.join(' L')} L${lx},${H-p} Z" fill="url(#lg)"/>
<path d="M${pts.join(' L')}" fill="none" stroke="#e74c3c" stroke-width="2.5"/>
<circle cx="${lx}" cy="${ly}" r="4" fill="#e74c3c"><animate attributeName="r" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></circle>
<text x="${lx-6}" y="${Math.max(16,ly-12)}" text-anchor="end" fill="#c0392b" font-weight="700">${h[n-1].v}</text></svg>`;}
async function pollLive(){
try{const r=await fetch(location.origin+'/api/live');const j=await r.json();
if(j.error){document.getElementById('livesites').textContent=j.error;return;}
animNum(document.getElementById('livetot'),j.total_active);
document.getElementById('livesites').textContent=`${j.active_sites} site(s) active · ${j.property_count} polled · ${new Date(j.generated_at).toLocaleTimeString()}`;
LIVEHIST.push({v:j.total_active});if(LIVEHIST.length>40)LIVEHIST.shift();
document.getElementById('livespark').innerHTML=sparkline(LIVEHIST);
const a=j.rows.filter(x=>x.active>0).slice(0,15),mx=Math.max(1,...a.map(x=>x.active));
document.getElementById('livebars').innerHTML=a.length?a.map(x=>
`<div class="lrow"><span class="nm">${esc(x.property)}</span><div class="lbar" style="width:${Math.max(8,Math.round(100*x.active/mx))}%">${x.active}</div></div>`).join('')
:'<div class="z" style="padding:16px">no active users right now — this lights up the moment someone lands on a site</div>';
}catch(e){document.getElementById('livesites').textContent='fetch failed: '+e.message;}
}
/* ---- tabs ---- */
const TITLES={sites:'All Sites',global:'Global',charts:'Charts — all domains',keywords:'Keywords',visuals:'Visuals',countries:'User Country',map:'User Map',live:'Live'};
function showTab(tab){
document.querySelectorAll('.side button').forEach(b=>b.classList.toggle('on',b.dataset.tab===tab));
['sites','global','charts','keywords','visuals','countries','map','live'].forEach(t=>document.getElementById('v-'+t).hidden=(t!==tab));
document.getElementById('htitle').textContent=TITLES[tab];
clearInterval(liveTimer);liveTimer=null;
if(tab==='keywords')renderKeywords();
if(tab==='global')renderGlobal();
if(tab==='charts')renderCharts();
if(tab==='visuals')renderVisuals();
if(tab==='countries')renderCountries();
if(tab==='map')renderMap();
if(tab==='live'){pollLive();liveTimer=setInterval(pollLive,15000);}
}
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 R=activeRows(),wl=WLBL(WIN).toLowerCase();
const active=R.filter(x=>val(x,'m.sessions')>0).length;
const tw=R.reduce((s,x)=>s+(val(x,'m.sessions')||0),0);
const pv=R.reduce((s,x)=>s+(val(x,'m.screenPageViews')||0),0);
const wsum=R.reduce((s,x)=>s+(val(x,'m.averageSessionDuration')||0)*(val(x,'m.sessions')||0),0);
const wavg=tw?wsum/tw:0;
document.getElementById('hstat').textContent=`${R.length} sites${exStore?' (store hidden)':''} · ${active} with traffic this ${wl} · ${Math.round(tw).toLocaleString()} sessions · ${Math.round(pv).toLocaleString()} views · ⌀ ${fmtDurP(wavg)} on site`;
const age=(Date.now()-new Date(DATA.generated_at))/60000;
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';
}
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();}
if(cur==='keywords'){KDATA=null;renderKeywords();}
}
document.getElementById('q').oninput=renderTable;
document.getElementById('onlyactive').onchange=renderTable;
(function(){const e=document.getElementById('exstore');e.checked=exStore;e.onchange=()=>{exStore=e.checked;localStorage.gaExStore=exStore?'1':'0';header();CDATA=CDATA;const cur=document.querySelector('.side button.on').dataset.tab;({sites:renderTable,global:renderGlobal,charts:renderCharts}[cur]||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 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;KDATA=null;load();setTimeout(()=>m.textContent='',3000);}else m.textContent=`refreshing… ${n*3}s`;},3000);};
renderWinSel();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"
self._send(200, "text/html", PAGE.replace("/*BOOTDATA*/null/*/BOOTDATA*/", boot).encode())
elif self.path == "/api/data":
self._send(200, "application/json",
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/keywords":
self._send(200, "application/json",
KEYWORDS.read_bytes() if KEYWORDS.exists()
else b'{"gsc_site_count":0,"windows":{},"channels":{},"organic_sites":{}}')
elif self.path == "/api/world":
wp = HERE / "cache" / "world.geojson"
self._send(200, "application/json",
wp.read_bytes() if wp.exists() else b'{"type":"FeatureCollection","features":[]}')
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()