← back to Ga Global Viewer
public/index.html
1097 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GA Global Viewer — Steve Abrams Fleet</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--border: #2a2f45;
--accent: #6c8cff;
--accent2: #4fffb0;
--accent3: #ff7c5c;
--ink: #e8ecf4;
--muted: #7a829a;
--success: #3fd68c;
--warn: #f5a623;
--danger: #ff4d4f;
--radius: 12px;
--card-shadow: 0 2px 16px rgba(0,0,0,0.35);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
min-height: 100vh;
}
/* ── header ── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
header h1 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
color: var(--accent);
}
header .subtitle { font-size: 12px; color: var(--muted); margin-top: 2px; }
.header-actions { display: flex; gap: 10px; align-items: center; }
button {
padding: 7px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--ink);
cursor: pointer;
font-size: 13px;
transition: background 0.15s, border-color 0.15s;
}
button:hover { background: var(--border); border-color: var(--accent); }
button.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
button.primary:hover { background: #5577ee; }
/* ── auth banner ── */
#auth-banner {
display: none;
margin: 24px 32px 0;
background: linear-gradient(135deg, #2a1a0e, #1e1a2a);
border: 1px solid var(--warn);
border-radius: var(--radius);
padding: 20px 24px;
}
#auth-banner h2 { color: var(--warn); font-size: 16px; margin-bottom: 8px; }
#auth-banner p { color: var(--ink); line-height: 1.6; margin-bottom: 6px; }
#auth-banner code {
background: rgba(255,255,255,0.08);
padding: 3px 8px;
border-radius: 5px;
font-family: 'Fira Code', 'Menlo', monospace;
font-size: 12px;
color: var(--accent2);
}
#auth-banner ul { margin: 8px 0 0 18px; line-height: 1.8; color: var(--muted); }
/* ── main layout ── */
main { padding: 24px 32px; max-width: 1600px; margin: 0 auto; }
/* ── summary cards ── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
margin-bottom: 28px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--card-shadow);
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: var(--accent-bar, var(--accent));
}
.stat-card .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; }
.stat-card .value { font-size: 28px; font-weight: 700; color: var(--ink); line-height: 1; }
.stat-card .sub { font-size: 11px; color: var(--muted); margin-top: 4px; }
/* ── section headers ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.section-header h2 {
font-size: 15px;
font-weight: 600;
color: var(--ink);
display: flex;
align-items: center;
gap: 8px;
}
.section-header h2 .badge {
font-size: 10px;
background: var(--surface2);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 20px;
color: var(--muted);
font-weight: 500;
}
.range-pill {
font-size: 11px;
background: var(--surface2);
border: 1px solid var(--border);
padding: 4px 12px;
border-radius: 20px;
color: var(--accent);
}
/* ── chart cards ── */
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
box-shadow: var(--card-shadow);
margin-bottom: 20px;
}
.chart-container { position: relative; height: 280px; }
.chart-container.tall { height: 360px; }
/* ── two-column layout ── */
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
@media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }
/* ── property table ── */
.prop-section { margin-bottom: 20px; }
.prop-table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--card-shadow);
}
table { width: 100%; border-collapse: collapse; }
th {
background: var(--surface2);
padding: 10px 16px;
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.7px;
color: var(--muted);
font-weight: 600;
position: sticky;
top: 0;
}
td {
padding: 9px 16px;
font-size: 13px;
border-top: 1px solid var(--border);
vertical-align: middle;
}
tr:hover td { background: rgba(108,140,255,0.04); }
.domain-cell { font-weight: 500; color: var(--ink); }
.mid-cell { font-family: monospace; font-size: 11px; color: var(--muted); }
.num { font-variant-numeric: tabular-nums; }
.bar-cell { width: 120px; }
.mini-bar-wrap { background: var(--border); border-radius: 3px; height: 6px; overflow: hidden; }
.mini-bar { height: 6px; background: var(--accent); border-radius: 3px; transition: width 0.3s; }
.status-ok { color: var(--success); font-size: 11px; }
.status-err { color: var(--danger); font-size: 11px; }
.status-no-data { color: var(--muted); font-size: 11px; }
/* ── sort + density controls (per Steve's CLAUDE.md requirement) ── */
.table-controls {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
}
.table-controls label { font-size: 12px; color: var(--muted); }
.table-controls select, .table-controls input[type=range] {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--ink);
padding: 4px 8px;
font-size: 12px;
}
.table-controls input[type=range] { width: 100px; cursor: pointer; }
/* ── spinner ── */
.spinner {
display: inline-block;
width: 18px; height: 18px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── loading overlay ── */
#loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 16px;
color: var(--muted);
}
#loading .big-spinner {
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
/* ── tabs ── */
.tabs { display: flex; gap: 0; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
.tab {
padding: 10px 20px;
cursor: pointer;
font-size: 13px;
color: var(--muted);
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
background: none;
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
}
.tab:hover { color: var(--ink); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ── footer ── */
footer {
text-align: center;
padding: 24px;
color: var(--muted);
font-size: 11px;
border-top: 1px solid var(--border);
margin-top: 20px;
}
/* ── placeholder state ── */
.placeholder-chart {
display: flex;
align-items: center;
justify-content: center;
height: 280px;
color: var(--muted);
font-size: 13px;
flex-direction: column;
gap: 10px;
}
.placeholder-chart .icon { font-size: 32px; opacity: 0.3; }
/* ── refresh log ── */
#refresh-log {
display: none;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
font-family: monospace;
font-size: 11px;
color: var(--accent2);
max-height: 160px;
overflow-y: auto;
margin-top: 12px;
line-height: 1.6;
}
/* ── numbers format ── */
.big-num { font-size: 28px; font-weight: 700; }
</style>
</head>
<body>
<header>
<div>
<h1>GA Global Viewer</h1>
<div class="subtitle">Steve Abrams Fleet — Google Analytics 4</div>
</div>
<div class="header-actions">
<span id="cache-age" style="font-size:11px;color:var(--muted)"></span>
<button id="btn-refresh" class="primary" onclick="triggerRefresh()">Refresh Data</button>
</div>
</header>
<!-- Auth banner (shown when creds are missing) -->
<div id="auth-banner">
<h2>Credentials Required</h2>
<p id="auth-error-msg">The service account key is not valid or has been revoked.</p>
<p style="margin-top:12px;font-weight:600;">To fix this, Steve needs to:</p>
<ul>
<li>Go to <strong>console.cloud.google.com</strong> → IAM & Admin → Service Accounts</li>
<li>Project: <code>sheets-updater-485823</code></li>
<li>Service account: <code>sheets-updater@sheets-updater-485823.iam.gserviceaccount.com</code></li>
<li>Create a new JSON key and save it to: <code>~/.config/ga-analytics-agent/service-account.json</code></li>
<li>Or run <code>bash ~/.claude/skills/analytics/scripts/setup.sh</code> to create a fresh service account</li>
<li>Then click <strong>Refresh Data</strong> above</li>
</ul>
<p style="margin-top:12px;color:var(--muted);font-size:12px;">
The dashboard is fully built and will populate with real data once a valid key is in place.
All 110 GA4 properties are already mapped (from the cached properties.json) and ready to query.
</p>
</div>
<main>
<!-- Loading state -->
<div id="loading">
<div class="big-spinner"></div>
<div>Loading analytics data…</div>
</div>
<!-- Dashboard (hidden until data loads) -->
<div id="dashboard" style="display:none">
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="switchTab('global')">Global Overview</button>
<button class="tab" onclick="switchTab('properties')">Per-Property</button>
<button class="tab" onclick="switchTab('audit')">Fleet Audit</button>
</div>
<!-- ── GLOBAL TAB ── -->
<div id="tab-global" class="tab-content active">
<!-- Summary stat cards -->
<div class="stat-grid" id="stat-cards">
<div class="stat-card" style="--accent-bar: var(--accent)">
<div class="label">Total Sessions</div>
<div class="value big-num" id="card-sessions">—</div>
<div class="sub">Last 30 days, all properties</div>
</div>
<div class="stat-card" style="--accent-bar: var(--accent2)">
<div class="label">Active Users</div>
<div class="value big-num" id="card-users">—</div>
<div class="sub">Last 30 days, all properties</div>
</div>
<div class="stat-card" style="--accent-bar: var(--accent3)">
<div class="label">Page Views</div>
<div class="value big-num" id="card-pageviews">—</div>
<div class="sub">Last 30 days, all properties</div>
</div>
<div class="stat-card" style="--accent-bar: #b06aff">
<div class="label">Properties Tracked</div>
<div class="value big-num" id="card-props">—</div>
<div class="sub" id="card-props-sub">With GA4 installed</div>
</div>
<div class="stat-card" style="--accent-bar: var(--warn)">
<div class="label">Avg Sessions/Site</div>
<div class="value big-num" id="card-avg">—</div>
<div class="sub">Per property (last 30d)</div>
</div>
<div class="stat-card" style="--accent-bar: var(--success)">
<div class="label">Pages / Session</div>
<div class="value big-num" id="card-depth">—</div>
<div class="sub">Global average</div>
</div>
</div>
<!-- 30-day line chart -->
<div class="chart-card">
<div class="section-header">
<h2>30-Day Traffic Trend <span class="badge">All Properties Combined</span></h2>
<span class="range-pill" id="date-range-label">30 days</span>
</div>
<div class="chart-container tall">
<canvas id="chart-timeseries"></canvas>
<div class="placeholder-chart" id="ph-timeseries" style="display:none">
<div class="icon">📈</div>
<div>No time-series data available</div>
</div>
</div>
</div>
<!-- Bar charts: top pages + top sources -->
<div class="two-col">
<div class="chart-card">
<div class="section-header">
<h2>Top Pages <span class="badge">Global</span></h2>
</div>
<div class="chart-container">
<canvas id="chart-pages"></canvas>
<div class="placeholder-chart" id="ph-pages" style="display:none">
<div class="icon">📄</div>
<div>No page data available</div>
</div>
</div>
</div>
<div class="chart-card">
<div class="section-header">
<h2>Top Traffic Sources <span class="badge">Global</span></h2>
</div>
<div class="chart-container">
<canvas id="chart-sources"></canvas>
<div class="placeholder-chart" id="ph-sources" style="display:none">
<div class="icon">🌐</div>
<div>No source data available</div>
</div>
</div>
</div>
</div>
</div><!-- /tab-global -->
<!-- ── PER-PROPERTY TAB ── -->
<div id="tab-properties" class="tab-content">
<div class="prop-section">
<div class="section-header">
<h2>Per-Property Breakdown <span class="badge" id="prop-count-badge">0 properties</span></h2>
</div>
<div class="prop-table-wrap">
<!-- Sort + density controls per CLAUDE.md requirement -->
<div class="table-controls">
<label>Sort:</label>
<select id="prop-sort" onchange="renderPropertyTable()">
<option value="sessions-desc">Sessions (high first)</option>
<option value="sessions-asc">Sessions (low first)</option>
<option value="domain-az">Domain A→Z</option>
<option value="pageviews-desc">Pageviews (high first)</option>
<option value="users-desc">Users (high first)</option>
</select>
<label style="margin-left:12px">Rows:</label>
<input type="range" id="prop-density" min="10" max="150" step="10" value="50"
oninput="document.getElementById('prop-density-val').textContent=this.value; renderPropertyTable()">
<span id="prop-density-val" style="font-size:12px;color:var(--muted);min-width:28px">50</span>
</div>
<div style="max-height:600px;overflow-y:auto">
<table>
<thead>
<tr>
<th>#</th>
<th>Domain</th>
<th>Measurement ID</th>
<th>Sessions</th>
<th>Users</th>
<th>Pageviews</th>
<th>Relative</th>
<th>Status</th>
</tr>
</thead>
<tbody id="prop-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- Per-property bar chart: top 20 by sessions -->
<div class="chart-card">
<div class="section-header">
<h2>Sessions by Property <span class="badge">Top 20</span></h2>
</div>
<div class="chart-container tall">
<canvas id="chart-prop-sessions"></canvas>
<div class="placeholder-chart" id="ph-prop-sessions" style="display:none">
<div class="icon">📊</div>
<div>No data</div>
</div>
</div>
</div>
</div><!-- /tab-properties -->
<!-- ── FLEET AUDIT TAB ── -->
<div id="tab-audit" class="tab-content">
<div class="section-header">
<h2>GA4 Fleet Audit <span class="badge" id="audit-badge"></span></h2>
</div>
<div class="two-col" style="margin-bottom:20px">
<div class="chart-card">
<div class="section-header"><h2>Coverage</h2></div>
<div class="chart-container" style="height:220px">
<canvas id="chart-coverage"></canvas>
</div>
</div>
<div class="chart-card">
<div class="section-header"><h2>Data Availability</h2></div>
<div class="chart-container" style="height:220px">
<canvas id="chart-data-avail"></canvas>
</div>
</div>
</div>
<div class="prop-table-wrap">
<div style="max-height:500px;overflow-y:auto">
<table>
<thead>
<tr>
<th>#</th>
<th>Domain</th>
<th>G-ID</th>
<th>Numeric ID</th>
<th>Status</th>
<th>Note</th>
</tr>
</thead>
<tbody id="audit-tbody"></tbody>
</table>
</div>
</div>
</div><!-- /tab-audit -->
<!-- Refresh log -->
<div id="refresh-log"></div>
</div><!-- /dashboard -->
</main>
<footer>
ga-global-viewer — Steve Abrams APPS —
<span id="footer-generated"></span> —
<a href="/api/stats" style="color:var(--accent);text-decoration:none">JSON API</a> —
<a href="/api/properties" style="color:var(--accent);text-decoration:none">Properties</a>
</footer>
<script>
// ── state ────────────────────────────────────────────────────────────────────
let STATE = null;
let PROPERTIES_MAP = {};
let charts = {};
// Persist sort + density
const PREFS = {
get sort() { return localStorage.getItem('ga_prop_sort') || 'sessions-desc'; },
set sort(v) { localStorage.setItem('ga_prop_sort', v); },
get density() { return parseInt(localStorage.getItem('ga_prop_density') || '50'); },
set density(v) { localStorage.setItem('ga_prop_density', String(v)); },
};
// ── init ─────────────────────────────────────────────────────────────────────
async function init() {
// Restore prefs
document.getElementById('prop-sort').value = PREFS.sort;
document.getElementById('prop-density').value = PREFS.density;
document.getElementById('prop-density-val').textContent = PREFS.density;
try {
// Load properties map for audit tab
const pResp = await fetch('/api/properties');
const pData = await pResp.json();
PROPERTIES_MAP = pData.properties || {};
} catch (e) { /* ignore */ }
await loadStats();
}
async function loadStats() {
document.getElementById('loading').style.display = 'flex';
document.getElementById('dashboard').style.display = 'none';
document.getElementById('auth-banner').style.display = 'none';
try {
const resp = await fetch('/api/stats');
STATE = await resp.json();
renderDashboard(STATE);
} catch (e) {
document.getElementById('loading').innerHTML =
`<div style="color:var(--danger)">Failed to load stats: ${e.message}</div>`;
}
}
// ── render ────────────────────────────────────────────────────────────────────
function renderDashboard(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
// Auth banner
if (!data.auth_ok) {
document.getElementById('auth-banner').style.display = 'block';
document.getElementById('auth-error-msg').textContent = data.auth_error || 'Authentication failed.';
}
// Cache age
if (data._cache_age_s != null) {
const age = formatAge(data._cache_age_s);
document.getElementById('cache-age').textContent = `Cache: ${age} old`;
}
// Footer
if (data.generated_at) {
document.getElementById('footer-generated').textContent =
'Generated ' + new Date(data.generated_at).toLocaleString(undefined, {
year:'numeric', month:'short', day:'numeric',
hour:'numeric', minute:'2-digit'
});
}
const g = data.global || {};
const totals = g.totals || { sessions:0, users:0, pageviews:0 };
const propsWithData = data.properties_with_data || 0;
// Stat cards
document.getElementById('card-sessions').textContent = fmt(totals.sessions);
document.getElementById('card-users').textContent = fmt(totals.users);
document.getElementById('card-pageviews').textContent = fmt(totals.pageviews);
document.getElementById('card-props').textContent = fmt(propsWithData);
document.getElementById('card-props-sub').textContent =
`of ${data.properties_count || 0} properties tracked`;
document.getElementById('card-avg').textContent =
propsWithData > 0 ? fmt(Math.round(totals.sessions / propsWithData)) : '—';
document.getElementById('card-depth').textContent =
totals.sessions > 0 ? (totals.pageviews / totals.sessions).toFixed(1) : '—';
// Date range label
document.getElementById('date-range-label').textContent =
`${data.date_range?.start || '30daysAgo'} — ${data.date_range?.end || 'yesterday'}`;
// Charts — global
renderTimeseriesChart(g.timeseries || []);
renderBarChart('chart-pages', 'ph-pages', (g.top_pages || []).slice(0,12),
'path', 'pageviews', 'Page Views', '#6c8cff');
renderBarChart('chart-sources', 'ph-sources', (g.top_sources || []).slice(0,12),
'source', 'sessions', 'Sessions', '#4fffb0');
// Per-property badge
document.getElementById('prop-count-badge').textContent =
`${propsWithData} with data / ${data.properties_count} total`;
renderPropertyTable();
renderPropertyBarChart();
renderAuditTab();
}
// ── timeseries chart ──────────────────────────────────────────────────────────
function renderTimeseriesChart(timeseries) {
if (!timeseries.length) {
document.getElementById('ph-timeseries').style.display = 'flex';
return;
}
const labels = timeseries.map(r => {
const d = r.date; // YYYYMMDD
return `${d.slice(4,6)}/${d.slice(6,8)}`;
});
const sessions = timeseries.map(r => r.sessions);
const users = timeseries.map(r => r.users);
const pageviews = timeseries.map(r => r.pageviews);
destroyChart('chart-timeseries');
const ctx = document.getElementById('chart-timeseries').getContext('2d');
charts['chart-timeseries'] = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Sessions',
data: sessions,
borderColor: '#6c8cff',
backgroundColor: 'rgba(108,140,255,0.12)',
fill: true,
tension: 0.3,
pointRadius: 2,
borderWidth: 2,
},
{
label: 'Users',
data: users,
borderColor: '#4fffb0',
backgroundColor: 'rgba(79,255,176,0.08)',
fill: false,
tension: 0.3,
pointRadius: 2,
borderWidth: 2,
},
{
label: 'Pageviews',
data: pageviews,
borderColor: '#ff7c5c',
backgroundColor: 'rgba(255,124,92,0.08)',
fill: false,
tension: 0.3,
pointRadius: 2,
borderWidth: 1.5,
},
],
},
options: darkChartOptions({
plugins: {
legend: { position: 'top', labels: { color: '#7a829a', usePointStyle: true, pointStyleWidth: 10, boxHeight: 6 } },
tooltip: { mode: 'index', intersect: false },
},
scales: {
x: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', maxTicksLimit: 15 } },
y: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', callback: v => fmtK(v) } },
},
}),
});
}
// ── bar chart ─────────────────────────────────────────────────────────────────
function renderBarChart(canvasId, phId, items, labelKey, valueKey, metricLabel, color) {
if (!items.length) {
document.getElementById(phId).style.display = 'flex';
return;
}
document.getElementById(phId).style.display = 'none';
const labels = items.map(r => truncate(r[labelKey], 35));
const values = items.map(r => r[valueKey]);
destroyChart(canvasId);
const ctx = document.getElementById(canvasId).getContext('2d');
charts[canvasId] = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: metricLabel,
data: values,
backgroundColor: color + 'cc',
borderColor: color,
borderWidth: 1,
borderRadius: 4,
}],
},
options: darkChartOptions({
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => ` ${fmt(ctx.raw)}` } },
},
scales: {
x: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', callback: v => fmtK(v) } },
y: { grid: { color: 'transparent' }, ticks: { color: '#e8ecf4', font: { size: 11 } } },
},
}),
});
}
// ── property table ─────────────────────────────────────────────────────────────
function renderPropertyTable() {
const sort = document.getElementById('prop-sort').value;
const limit = parseInt(document.getElementById('prop-density').value);
PREFS.sort = sort;
PREFS.density = limit;
if (!STATE) return;
const perProp = (STATE.per_property || []).filter(p => p.totals);
const sorted = [...perProp].sort((a, b) => {
switch (sort) {
case 'sessions-desc': return (b.totals?.sessions||0) - (a.totals?.sessions||0);
case 'sessions-asc': return (a.totals?.sessions||0) - (b.totals?.sessions||0);
case 'domain-az': return a.domain.localeCompare(b.domain);
case 'pageviews-desc': return (b.totals?.pageviews||0) - (a.totals?.pageviews||0);
case 'users-desc': return (b.totals?.users||0) - (a.totals?.users||0);
default: return 0;
}
});
const maxSessions = Math.max(1, ...sorted.map(p => p.totals?.sessions||0));
const rows = sorted.slice(0, limit);
const tbody = document.getElementById('prop-tbody');
tbody.innerHTML = rows.map((p, i) => {
const s = p.totals?.sessions || 0;
const u = p.totals?.users || 0;
const pv = p.totals?.pageviews || 0;
const pct = Math.round((s / maxSessions) * 100);
return `<tr>
<td class="num" style="color:var(--muted)">${i+1}</td>
<td class="domain-cell">${escHtml(p.domain)}</td>
<td class="mid-cell">${escHtml(p.measurement_id)}</td>
<td class="num">${fmt(s)}</td>
<td class="num">${fmt(u)}</td>
<td class="num">${fmt(pv)}</td>
<td class="bar-cell">
<div class="mini-bar-wrap"><div class="mini-bar" style="width:${pct}%"></div></div>
</td>
<td><span class="status-ok">live</span></td>
</tr>`;
}).join('');
// Rows with errors
const errorRows = (STATE.per_property || []).filter(p => p.error);
if (errorRows.length && rows.length < limit) {
tbody.innerHTML += errorRows.slice(0, limit - rows.length).map((p, i) => `<tr>
<td class="num" style="color:var(--muted)">${rows.length+i+1}</td>
<td class="domain-cell" style="color:var(--muted)">${escHtml(p.domain)}</td>
<td class="mid-cell">${escHtml(p.measurement_id)}</td>
<td class="num" style="color:var(--muted)">—</td>
<td class="num" style="color:var(--muted)">—</td>
<td class="num" style="color:var(--muted)">—</td>
<td class="bar-cell"></td>
<td><span class="status-${p.error === 'permission_denied' ? 'err' : 'no-data'}">${escHtml(p.error)}</span></td>
</tr>`).join('');
}
}
// ── property bar chart ────────────────────────────────────────────────────────
function renderPropertyBarChart() {
if (!STATE) return;
const perProp = (STATE.per_property || []).filter(p => p.totals);
const top20 = [...perProp]
.sort((a,b) => (b.totals?.sessions||0) - (a.totals?.sessions||0))
.slice(0, 20);
if (!top20.length) {
document.getElementById('ph-prop-sessions').style.display = 'flex';
return;
}
const labels = top20.map(p => truncate(p.domain, 25));
const values = top20.map(p => p.totals?.sessions || 0);
destroyChart('chart-prop-sessions');
const ctx = document.getElementById('chart-prop-sessions').getContext('2d');
charts['chart-prop-sessions'] = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Sessions',
data: values,
backgroundColor: values.map((_, i) =>
`hsl(${220 + i * 8}, 70%, 55%)`
),
borderRadius: 4,
borderWidth: 0,
}],
},
options: darkChartOptions({
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => ` ${fmt(ctx.raw)} sessions` } },
},
scales: {
x: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', callback: v => fmtK(v) } },
y: { grid: { color: 'transparent' }, ticks: { color: '#e8ecf4', font: { size: 11 } } },
},
}),
});
}
// ── audit tab ─────────────────────────────────────────────────────────────────
function renderAuditTab() {
const allDomains = Object.keys(PROPERTIES_MAP);
const queriedMap = {};
if (STATE) {
for (const p of (STATE.per_property || [])) {
queriedMap[p.domain] = p;
}
}
document.getElementById('audit-badge').textContent =
`${allDomains.length} properties in fleet`;
const withData = (STATE?.per_property||[]).filter(p=>p.totals).length;
const withError = (STATE?.per_property||[]).filter(p=>p.error).length;
const notQueried = allDomains.filter(d=>!queriedMap[d]).length;
// Coverage doughnut
destroyChart('chart-coverage');
const cCtx = document.getElementById('chart-coverage').getContext('2d');
charts['chart-coverage'] = new Chart(cCtx, {
type: 'doughnut',
data: {
labels: ['Has Data', 'API Error', 'Not Queried'],
datasets: [{
data: [withData, withError, notQueried],
backgroundColor: ['#3fd68c', '#ff4d4f', '#2a2f45'],
borderColor: '#1a1d27',
borderWidth: 3,
}],
},
options: darkChartOptions({
cutout: '65%',
plugins: {
legend: { position: 'bottom', labels: { color: '#7a829a', padding: 16 } },
},
}),
});
// Data availability doughnut
const pidMap = {};
// Build from per_property
if (STATE) {
for (const p of (STATE.per_property||[])) {
pidMap[p.measurement_id] = p.numeric_id ? 'mapped' : 'no_pid';
}
}
const mapped = (STATE?.per_property||[]).filter(p=>p.numeric_id).length;
const noId = (STATE?.per_property||[]).filter(p=>!p.numeric_id && !p.error).length;
destroyChart('chart-data-avail');
const dCtx = document.getElementById('chart-data-avail').getContext('2d');
charts['chart-data-avail'] = new Chart(dCtx, {
type: 'doughnut',
data: {
labels: ['Has Data', 'Permission Denied', 'No Numeric ID', 'Not Queried'],
datasets: [{
data: [
withData,
(STATE?.per_property||[]).filter(p=>p.error==='permission_denied').length,
(STATE?.per_property||[]).filter(p=>p.error==='no_numeric_id').length,
notQueried,
],
backgroundColor: ['#6c8cff', '#ff7c5c', '#f5a623', '#2a2f45'],
borderColor: '#1a1d27',
borderWidth: 3,
}],
},
options: darkChartOptions({
cutout: '65%',
plugins: {
legend: { position: 'bottom', labels: { color: '#7a829a', padding: 16, boxWidth: 12 } },
},
}),
});
// Audit table
const tbody = document.getElementById('audit-tbody');
const rows = allDomains.map((domain, i) => {
const mid = PROPERTIES_MAP[domain];
const entry = queriedMap[domain];
let status, note;
if (!entry) {
status = '<span class="status-no-data">not queried</span>';
note = 'Run refresh to pull data';
} else if (entry.totals) {
status = '<span class="status-ok">data available</span>';
note = `${fmt(entry.totals.sessions)} sessions`;
} else if (entry.error === 'permission_denied') {
status = '<span class="status-err">permission denied</span>';
note = 'Service account lacks viewer role on this property';
} else if (entry.error === 'no_numeric_id') {
status = '<span class="status-no-data">no property ID</span>';
note = 'Not in property-ids.json — run list_properties.py';
} else {
status = `<span class="status-err">${escHtml(entry.error||'error')}</span>`;
note = escHtml((entry.detail||'').slice(0,80));
}
return `<tr>
<td class="num" style="color:var(--muted)">${i+1}</td>
<td class="domain-cell">${escHtml(domain)}</td>
<td class="mid-cell">${escHtml(mid)}</td>
<td class="mid-cell">${escHtml(entry?.numeric_id||'—')}</td>
<td>${status}</td>
<td style="color:var(--muted);font-size:12px">${note}</td>
</tr>`;
});
tbody.innerHTML = rows.join('');
}
// ── refresh ───────────────────────────────────────────────────────────────────
async function triggerRefresh() {
const btn = document.getElementById('btn-refresh');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Refreshing...';
const logEl = document.getElementById('refresh-log');
logEl.style.display = 'block';
logEl.textContent = 'Starting refresh...';
await fetch('/api/refresh');
// Poll status
const poll = setInterval(async () => {
try {
const s = await (await fetch('/api/refresh-status')).json();
logEl.textContent = s.log.join('\n');
logEl.scrollTop = logEl.scrollHeight;
if (!s.running) {
clearInterval(poll);
btn.disabled = false;
btn.innerHTML = 'Refresh Data';
// Reload data
await loadStats();
}
} catch (e) {
clearInterval(poll);
btn.disabled = false;
btn.innerHTML = 'Refresh Data';
}
}, 2000);
}
// ── tabs ──────────────────────────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById(`tab-${name}`).classList.add('active');
// Activate correct tab button
document.querySelectorAll('.tab').forEach(t => {
if (t.getAttribute('onclick') === `switchTab('${name}')`) t.classList.add('active');
});
}
// ── helpers ───────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null || isNaN(n)) return '0';
if (n >= 1_000_000) return (n/1_000_000).toFixed(1) + 'M';
if (n >= 10_000) return Math.round(n/1000) + 'K';
return n.toLocaleString();
}
function fmtK(n) {
if (n >= 1_000_000) return (n/1_000_000).toFixed(1)+'M';
if (n >= 1000) return Math.round(n/1000)+'K';
return n;
}
function truncate(s, max) {
if (!s) return '';
return s.length > max ? s.slice(0, max-1) + '…' : s;
}
function escHtml(s) {
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
function formatAge(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds/60)}m`;
if (seconds < 86400) return `${Math.round(seconds/3600)}h`;
return `${Math.round(seconds/86400)}d`;
}
function destroyChart(id) {
if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}
function darkChartOptions(overrides = {}) {
return {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 500 },
...overrides,
plugins: {
...(overrides.plugins || {}),
},
};
}
// ── boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>