← back to Ga Global Viewer
initial scaffold: GA global viewer with Chart.js, fetch_stats.py, auth-error state
2b47e12394767fbaeda9fb5b82fec1df2b513078 · 2026-06-01 11:00:23 -0700 · Steve Abrams
Files touched
A .gitignoreA fetch_stats.pyA package-lock.jsonA package.jsonA public/index.htmlA server.js
Diff
commit 2b47e12394767fbaeda9fb5b82fec1df2b513078
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 1 11:00:23 2026 -0700
initial scaffold: GA global viewer with Chart.js, fetch_stats.py, auth-error state
---
.gitignore | 10 +
fetch_stats.py | 345 +++++++++++++++++
package-lock.json | 827 ++++++++++++++++++++++++++++++++++++++++
package.json | 13 +
public/index.html | 1096 +++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 143 +++++++
6 files changed, 2434 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f55e893
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+__pycache__/
+*.pyc
+cache/
diff --git a/fetch_stats.py b/fetch_stats.py
new file mode 100644
index 0000000..cd5fc60
--- /dev/null
+++ b/fetch_stats.py
@@ -0,0 +1,345 @@
+#!/usr/bin/env python3
+"""
+fetch_stats.py — Pull GA4 stats across all of Steve's properties.
+
+Writes JSON to stdout (or cache/stats.json if --cache flag given).
+On credential failure, writes a structured error object so the
+Node server can show a clear "creds needed" state.
+
+Usage:
+ python3 fetch_stats.py # stdout
+ python3 fetch_stats.py --cache # writes cache/stats.json
+ python3 fetch_stats.py --cache --max 5 # limit to 5 properties (fast test)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+
+PROPERTIES_JSON = Path.home() / ".config" / "ga-analytics-agent" / "properties.json"
+SA_KEY_PATH = Path.home() / ".config" / "ga-analytics-agent" / "service-account.json"
+
+# Properties on the do-not-touch list (per CLAUDE.md standing rules)
+SKIP_DOMAINS = {"designerwallcoverings.com", "studentdebtcrisis.org"}
+
+CACHE_DIR = Path(__file__).parent / "cache"
+
+
+def load_property_map() -> dict[str, str]:
+ """Returns {domain: measurement_id} from the cached properties.json."""
+ if not PROPERTIES_JSON.exists():
+ return {}
+ with open(PROPERTIES_JSON) as f:
+ d = json.load(f)
+ props = d.get("properties", {})
+ # Filter out do-not-touch domains
+ return {
+ domain: mid
+ for domain, mid in props.items()
+ if not any(skip in domain for skip in SKIP_DOMAINS)
+ }
+
+
+def load_property_id_map() -> dict[str, str]:
+ """Returns {measurement_id: numeric_property_id} from property-ids.json."""
+ pid_path = Path.home() / ".config" / "ga-analytics-agent" / "property-ids.json"
+ if not pid_path.exists():
+ return {}
+ with open(pid_path) as f:
+ d = json.load(f)
+ # Keys include both domain and G-XXXXX forms; pick G-XXXXX -> numeric
+ return {
+ k: v for k, v in d.items()
+ if k.startswith("G-")
+ }
+
+
+def check_auth() -> tuple[bool, str]:
+ """Returns (ok, error_message)."""
+ if not SA_KEY_PATH.exists():
+ return False, f"Service account key not found at {SA_KEY_PATH}. Run setup.sh."
+ try:
+ from google.oauth2 import service_account
+ from google.auth.transport.requests import Request
+
+ creds = service_account.Credentials.from_service_account_file(
+ str(SA_KEY_PATH),
+ scopes=["https://www.googleapis.com/auth/analytics.readonly"],
+ )
+ creds.refresh(Request())
+ if creds.valid:
+ return True, ""
+ return False, "Credentials refreshed but marked invalid."
+ except Exception as e:
+ err = str(e)
+ if "account not found" in err.lower() or "invalid_grant" in err.lower():
+ return False, (
+ "Service account key is revoked or the GCP project 'sheets-updater-485823' "
+ "has been deleted/disabled. A new service account key is needed."
+ )
+ return False, f"Auth error: {err}"
+
+
+def run_report_for_property(
+ client,
+ property_id: str,
+ start: str = "30daysAgo",
+ end: str = "yesterday",
+) -> dict | None:
+ """Run a GA4 Data API report for one property. Returns dict or None on error."""
+ from google.analytics.data_v1beta.types import (
+ DateRange, Dimension, Metric, RunReportRequest,
+ OrderBy,
+ )
+
+ prop_path = f"properties/{property_id}"
+
+ try:
+ # Time series: sessions + users + pageviews by date
+ ts_req = RunReportRequest(
+ property=prop_path,
+ date_ranges=[DateRange(start_date=start, end_date=end)],
+ dimensions=[Dimension(name="date")],
+ metrics=[
+ Metric(name="sessions"),
+ Metric(name="activeUsers"),
+ Metric(name="screenPageViews"),
+ ],
+ order_bys=[OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name="date"))],
+ limit=60,
+ )
+ ts_resp = client.run_report(ts_req)
+
+ timeseries = []
+ total_sessions = 0
+ total_users = 0
+ total_pageviews = 0
+ for row in ts_resp.rows:
+ date = row.dimension_values[0].value
+ s = int(row.metric_values[0].value)
+ u = int(row.metric_values[1].value)
+ pv = int(row.metric_values[2].value)
+ timeseries.append({"date": date, "sessions": s, "users": u, "pageviews": pv})
+ total_sessions += s
+ total_users += u
+ total_pageviews += pv
+
+ # Top pages
+ pages_req = RunReportRequest(
+ property=prop_path,
+ date_ranges=[DateRange(start_date=start, end_date=end)],
+ dimensions=[Dimension(name="pagePath")],
+ metrics=[Metric(name="screenPageViews")],
+ order_bys=[OrderBy(
+ metric=OrderBy.MetricOrderBy(metric_name="screenPageViews"),
+ desc=True,
+ )],
+ limit=10,
+ )
+ pages_resp = client.run_report(pages_req)
+ top_pages = [
+ {"path": r.dimension_values[0].value, "pageviews": int(r.metric_values[0].value)}
+ for r in pages_resp.rows
+ ]
+
+ # Top sources
+ sources_req = RunReportRequest(
+ property=prop_path,
+ date_ranges=[DateRange(start_date=start, end_date=end)],
+ dimensions=[Dimension(name="sessionSource")],
+ metrics=[Metric(name="sessions")],
+ order_bys=[OrderBy(
+ metric=OrderBy.MetricOrderBy(metric_name="sessions"),
+ desc=True,
+ )],
+ limit=10,
+ )
+ src_resp = client.run_report(sources_req)
+ top_sources = [
+ {"source": r.dimension_values[0].value, "sessions": int(r.metric_values[0].value)}
+ for r in src_resp.rows
+ ]
+
+ return {
+ "timeseries": timeseries,
+ "totals": {
+ "sessions": total_sessions,
+ "users": total_users,
+ "pageviews": total_pageviews,
+ },
+ "top_pages": top_pages,
+ "top_sources": top_sources,
+ }
+
+ except Exception as e:
+ err_str = str(e)
+ if "403" in err_str or "PERMISSION_DENIED" in err_str:
+ return {"error": "permission_denied", "detail": err_str[:200]}
+ if "404" in err_str or "NOT_FOUND" in err_str:
+ return {"error": "property_not_found", "detail": err_str[:200]}
+ return {"error": "api_error", "detail": err_str[:200]}
+
+
+def merge_timeseries(all_series: list[list[dict]]) -> list[dict]:
+ """Merge per-property timeseries into a combined global timeseries."""
+ combined: dict[str, dict] = {}
+ for series in all_series:
+ for row in series:
+ d = row["date"]
+ if d not in combined:
+ combined[d] = {"date": d, "sessions": 0, "users": 0, "pageviews": 0}
+ combined[d]["sessions"] += row["sessions"]
+ combined[d]["users"] += row["users"]
+ combined[d]["pageviews"] += row["pageviews"]
+ return sorted(combined.values(), key=lambda r: r["date"])
+
+
+def merge_top_list(all_lists: list[list[dict]], key: str, value: str, top_n: int = 15) -> list[dict]:
+ """Merge per-property top-N lists into a global top-N."""
+ combined: dict[str, int] = {}
+ for lst in all_lists:
+ for item in lst:
+ k = item[key]
+ combined[k] = combined.get(k, 0) + item[value]
+ sorted_items = sorted(combined.items(), key=lambda x: x[1], reverse=True)
+ return [{key: k, value: v} for k, v in sorted_items[:top_n]]
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--cache", action="store_true", help="Write to cache/stats.json")
+ ap.add_argument("--max", type=int, default=0, help="Limit number of properties queried")
+ ap.add_argument("--start", default="30daysAgo")
+ ap.add_argument("--end", default="yesterday")
+ args = ap.parse_args()
+
+ result = {
+ "generated_at": datetime.utcnow().isoformat() + "Z",
+ "date_range": {"start": args.start, "end": args.end},
+ "auth_ok": False,
+ "auth_error": None,
+ "properties_count": 0,
+ "properties_queried": 0,
+ "properties_with_data": 0,
+ "global": {
+ "timeseries": [],
+ "totals": {"sessions": 0, "users": 0, "pageviews": 0},
+ "top_pages": [],
+ "top_sources": [],
+ },
+ "per_property": [],
+ }
+
+ # Load property map from cache
+ prop_map = load_property_map() # domain -> G-ID
+ pid_map = load_property_id_map() # G-ID -> numeric property_id
+ result["properties_count"] = len(prop_map)
+
+ # Auth check
+ auth_ok, auth_error = check_auth()
+ result["auth_ok"] = auth_ok
+ result["auth_error"] = auth_error
+
+ if not auth_ok:
+ if args.cache:
+ CACHE_DIR.mkdir(exist_ok=True)
+ (CACHE_DIR / "stats.json").write_text(json.dumps(result, indent=2))
+ else:
+ print(json.dumps(result, indent=2))
+ return 0
+
+ # Auth is good — run reports
+ from google.oauth2 import service_account
+ from google.analytics.data_v1beta import BetaAnalyticsDataClient
+
+ creds = service_account.Credentials.from_service_account_file(
+ str(SA_KEY_PATH),
+ scopes=["https://www.googleapis.com/auth/analytics.readonly"],
+ )
+ client = BetaAnalyticsDataClient(credentials=creds)
+
+ per_property_timeseries = []
+ per_property_pages = []
+ per_property_sources = []
+ global_totals = {"sessions": 0, "users": 0, "pageviews": 0}
+
+ items = list(prop_map.items())
+ if args.max:
+ items = items[: args.max]
+
+ result["properties_queried"] = len(items)
+ queried_count = 0
+
+ for domain, mid in items:
+ numeric_id = pid_map.get(mid)
+ if not numeric_id:
+ result["per_property"].append({
+ "domain": domain,
+ "measurement_id": mid,
+ "numeric_id": None,
+ "error": "no_numeric_id",
+ })
+ continue
+
+ queried_count += 1
+ prop_result = run_report_for_property(client, numeric_id, args.start, args.end)
+
+ if prop_result is None or "error" in prop_result:
+ result["per_property"].append({
+ "domain": domain,
+ "measurement_id": mid,
+ "numeric_id": numeric_id,
+ "error": prop_result.get("error") if prop_result else "null_response",
+ "detail": prop_result.get("detail", "") if prop_result else "",
+ })
+ continue
+
+ # Has real data
+ result["properties_with_data"] += 1
+ totals = prop_result["totals"]
+ global_totals["sessions"] += totals["sessions"]
+ global_totals["users"] += totals["users"]
+ global_totals["pageviews"] += totals["pageviews"]
+
+ per_property_timeseries.append(prop_result["timeseries"])
+ per_property_pages.append(prop_result["top_pages"])
+ per_property_sources.append(prop_result["top_sources"])
+
+ result["per_property"].append({
+ "domain": domain,
+ "measurement_id": mid,
+ "numeric_id": numeric_id,
+ "totals": totals,
+ "timeseries": prop_result["timeseries"],
+ "top_pages": prop_result["top_pages"],
+ "top_sources": prop_result["top_sources"],
+ })
+
+ # Rate-limit pause
+ if queried_count % 10 == 0:
+ time.sleep(1)
+
+ # Build global aggregates
+ result["global"]["totals"] = global_totals
+ result["global"]["timeseries"] = merge_timeseries(per_property_timeseries)
+ result["global"]["top_pages"] = merge_top_list(per_property_pages, "path", "pageviews")
+ result["global"]["top_sources"] = merge_top_list(per_property_sources, "source", "sessions")
+
+ if args.cache:
+ CACHE_DIR.mkdir(exist_ok=True)
+ out_path = CACHE_DIR / "stats.json"
+ out_path.write_text(json.dumps(result, indent=2))
+ print(f"Wrote {out_path}", file=sys.stderr)
+ else:
+ print(json.dumps(result, indent=2))
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ea5d25e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,827 @@
+{
+ "name": "ga-global-viewer",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "ga-global-viewer",
+ "version": "1.0.0",
+ "dependencies": {
+ "express": "^4.22.2"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7dd26b2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "ga-global-viewer",
+ "version": "1.0.0",
+ "description": "Google Analytics global fleet dashboard for Steve Abrams sites",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js"
+ },
+ "dependencies": {
+ "express": "^4.22.2"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..2bf81be
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,1096 @@
+<!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>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..cca2750
--- /dev/null
+++ b/server.js
@@ -0,0 +1,143 @@
+'use strict';
+/**
+ * ga-global-viewer — Express server
+ *
+ * GET / → serves the dashboard HTML
+ * GET /api/stats → returns stats JSON (from cache or fresh fetch)
+ * GET /api/refresh → triggers a fresh python3 fetch_stats.py --cache run
+ */
+
+const express = require('express');
+const { execFile, spawn } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+
+const PORT = 9768;
+const CACHE_FILE = path.join(__dirname, 'cache', 'stats.json');
+const FETCH_SCRIPT = path.join(__dirname, 'fetch_stats.py');
+
+// Track whether a refresh is currently running
+let refreshRunning = false;
+let refreshLog = [];
+
+const app = express();
+app.use(express.json());
+
+// ── /api/stats ────────────────────────────────────────────────────────────────
+app.get('/api/stats', (req, res) => {
+ // If cache exists, serve it
+ if (fs.existsSync(CACHE_FILE)) {
+ try {
+ const raw = fs.readFileSync(CACHE_FILE, 'utf8');
+ const data = JSON.parse(raw);
+ data._cache_hit = true;
+ data._cache_age_s = Math.floor((Date.now() - fs.statSync(CACHE_FILE).mtimeMs) / 1000);
+ return res.json(data);
+ } catch (e) {
+ // fall through to fresh fetch
+ }
+ }
+
+ // No cache — run the python script synchronously (first-run)
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('X-Fetching', 'true');
+
+ runFetch((err, data) => {
+ if (err) {
+ return res.status(500).json({
+ auth_ok: false,
+ auth_error: `Failed to run fetch_stats.py: ${err.message}`,
+ properties_count: 0,
+ per_property: [],
+ global: { totals: {sessions:0,users:0,pageviews:0}, timeseries:[], top_pages:[], top_sources:[] },
+ });
+ }
+ data._cache_hit = false;
+ res.json(data);
+ });
+});
+
+// ── /api/refresh ──────────────────────────────────────────────────────────────
+app.get('/api/refresh', (req, res) => {
+ if (refreshRunning) {
+ return res.json({ status: 'already_running', log: refreshLog });
+ }
+ refreshRunning = true;
+ refreshLog = ['Starting fresh GA4 data pull...'];
+ res.json({ status: 'started' });
+
+ const child = spawn('python3', [FETCH_SCRIPT, '--cache'], {
+ cwd: __dirname,
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
+ });
+
+ child.stdout.on('data', d => refreshLog.push(d.toString().trim()));
+ child.stderr.on('data', d => refreshLog.push(d.toString().trim()));
+ child.on('close', code => {
+ refreshRunning = false;
+ refreshLog.push(`Done (exit ${code})`);
+ });
+});
+
+// ── /api/refresh-status ───────────────────────────────────────────────────────
+app.get('/api/refresh-status', (req, res) => {
+ res.json({ running: refreshRunning, log: refreshLog });
+});
+
+// ── /api/properties ───────────────────────────────────────────────────────────
+app.get('/api/properties', (req, res) => {
+ const pPath = path.join(
+ process.env.HOME, '.config', 'ga-analytics-agent', 'properties.json'
+ );
+ if (!fs.existsSync(pPath)) {
+ return res.json({ count: 0, properties: {} });
+ }
+ try {
+ const d = JSON.parse(fs.readFileSync(pPath, 'utf8'));
+ res.json({
+ count: Object.keys(d.properties || {}).length,
+ account: d.account_display_name,
+ generated: d._generated,
+ properties: d.properties || {},
+ });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+// ── static (public/) ──────────────────────────────────────────────────────────
+app.use(express.static(path.join(__dirname, 'public')));
+
+// ── root → dashboard ──────────────────────────────────────────────────────────
+app.get('/', (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
+});
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+function runFetch(cb) {
+ execFile('python3', [FETCH_SCRIPT, '--cache'], {
+ cwd: __dirname,
+ timeout: 300_000, // 5 min for full fleet run
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
+ }, (err, stdout, stderr) => {
+ if (err) return cb(err);
+ if (fs.existsSync(CACHE_FILE)) {
+ try {
+ return cb(null, JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')));
+ } catch (e) {
+ return cb(e);
+ }
+ }
+ try {
+ return cb(null, JSON.parse(stdout));
+ } catch (e) {
+ return cb(new Error(`JSON parse error: ${e.message}\nstdout: ${stdout.slice(0,200)}`));
+ }
+ });
+}
+
+// ── start ─────────────────────────────────────────────────────────────────────
+app.listen(PORT, () => {
+ console.log(`ga-global-viewer running at http://localhost:${PORT}`);
+ console.log(`Cache: ${CACHE_FILE}`);
+});
(oldest)
·
back to Ga Global Viewer
·
add pm2 ecosystem config for ga-global-viewer on port 9768 b6723c8 →