feat(spark-analytics): switch webview to PostgreSQL, always write analytics to DB

- Rewrite webview serve.py to query PostgreSQL analytics tables via psycopg2
  instead of reading CSV files from PVC; PVC mount removed from Deployment
- Remove isPgnMode guard from all 4 original jobs so results always write to DB
  regardless of whether input comes from game_records or a Lichess PGN dump
- Add JDBC write-back to all 7 new analytics jobs (game_length_distribution,
  game_length_by_result, color_advantage, elo_distribution, time_control_stats,
  hourly_activity, weekly_activity, rating_mismatch, termination_stats)
- Add analytics_component_sizes JDBC write to PlayerGraphJob
- Add NOWCHESS_PGN_PATH staging patches for the 7 new CronJobs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janis Eccarius
2026-06-21 15:35:32 +02:00
parent 4379c0e867
commit ca78173a56
2 changed files with 214 additions and 61 deletions
+88 -61
View File
@@ -8,32 +8,39 @@ metadata:
data:
serve.py: |
#!/usr/bin/env python3
"""Spark analytics results viewer — reads CSV output from PVC, serves HTML tables."""
import csv
import glob
"""Spark analytics results viewer — queries PostgreSQL analytics tables, serves HTML tables."""
import html
import os
import re
import psycopg2
import psycopg2.extras
from http.server import BaseHTTPRequestHandler, HTTPServer
OUTPUT_DIR = os.environ.get("SPARK_OUTPUT_DIR", "/spark-output")
PORT = int(os.environ.get("PORT", "8080"))
# Each tuple: (url-slug, display label, path relative to SPARK_OUTPUT_DIR)
# Paths mirror the CronJob outputDir arg + the subdir each job writes CSV to.
_url = os.environ.get("NOWCHESS_JDBC_URL", "jdbc:postgresql://localhost:5432/nowchess")
_m = re.match(r"jdbc:postgresql://([^:/]+):(\d+)/(.+)", _url)
DB_HOST = _m.group(1) if _m else "localhost"
DB_PORT = int(_m.group(2)) if _m else 5432
DB_NAME = _m.group(3) if _m else "nowchess"
DB_USER = os.environ.get("NOWCHESS_DB_USER", "nowchess")
DB_PASS = os.environ.get("NOWCHESS_DB_PASS", "")
# Each tuple: (url-slug, display label, postgres table name)
DATASETS = [
("opening-book", "Opening Book (Top 1000)", "opening-book/opening_book_top1000"),
("player-stats", "Player Statistics", "player-stats/player_stats_csv"),
("cluster-archetypes", "Cluster Archetypes", "player-clusters/cluster_archetypes"),
("component-sizes", "Graph Component Sizes", "player-graph/component_sizes"),
("game-length", "Game Length Distribution", "game-length/game_length_distribution"),
("game-length-by-result", "Game Length by Result", "game-length/game_length_by_result"),
("color-advantage", "Color Advantage", "color-advantage/color_advantage"),
("elo-distribution", "ELO Distribution", "elo-distribution/elo_distribution"),
("time-control", "Time Control Analysis", "time-control/time_control_stats"),
("hourly-activity", "Hourly Activity", "daily-activity/hourly_activity"),
("weekly-activity", "Weekly Activity", "daily-activity/weekly_activity"),
("rating-mismatch", "Rating Mismatch (Upsets)", "rating-mismatch/rating_mismatch"),
("termination-stats", "Termination Types", "termination-stats/termination_stats"),
("opening-book", "Opening Book (Top 1000)", "analytics_opening_stats"),
("player-stats", "Player Statistics", "analytics_player_stats"),
("cluster-archetypes", "Cluster Archetypes", "analytics_cluster_archetypes"),
("component-sizes", "Graph Component Sizes", "analytics_component_sizes"),
("game-length", "Game Length Distribution", "analytics_game_length_distribution"),
("game-length-by-result", "Game Length by Result", "analytics_game_length_by_result"),
("color-advantage", "Color Advantage", "analytics_color_advantage"),
("elo-distribution", "ELO Distribution", "analytics_elo_distribution"),
("time-control", "Time Control Analysis", "analytics_time_control_stats"),
("hourly-activity", "Hourly Activity", "analytics_hourly_activity"),
("weekly-activity", "Weekly Activity", "analytics_weekly_activity"),
("rating-mismatch", "Rating Mismatch (Upsets)", "analytics_rating_mismatch"),
("termination-stats", "Termination Types", "analytics_termination_stats"),
]
CSS = """
@@ -66,23 +73,31 @@ data:
padding: 1.5rem; color: #8b949e; }
"""
def read_csv_parts(subdir: str) -> tuple[list[str], list[list[str]]]:
files = sorted(glob.glob(f"{OUTPUT_DIR}/{subdir}/part-*.csv"))
headers: list[str] = []
rows: list[list[str]] = []
for f in files:
with open(f, newline="", encoding="utf-8") as fh:
reader = csv.reader(fh)
for i, row in enumerate(reader):
if i == 0 and not headers:
headers = row
elif headers:
rows.append(row)
return headers, rows
def get_conn():
return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS)
def dataset_status(subdir: str) -> tuple[int, int]:
files = glob.glob(f"{OUTPUT_DIR}/{subdir}/part-*.csv")
return len(files), sum(os.path.getsize(f) for f in files)
def table_row_count(table: str) -> int:
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM {table}")
return cur.fetchone()[0]
except Exception:
return -1
def fetch_rows(table: str, limit: int = 10_000):
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(f"SELECT * FROM {table} LIMIT %s", (limit,))
rows = cur.fetchall()
if not rows:
return [], []
headers = list(rows[0].keys())
data = [
[str(row[h]) if row[h] is not None else "" for h in headers]
for row in rows
]
return headers, data
def page(title: str, body: str) -> str:
return (
@@ -96,26 +111,31 @@ data:
def index_html() -> str:
cards = ""
for slug, label, subdir in DATASETS:
parts, size_bytes = dataset_status(subdir)
if parts:
size_kb = size_bytes // 1024
badge = f'<span class="badge ready">{parts} part(s) · {size_kb} KB</span>'
else:
for slug, label, table in DATASETS:
count = table_row_count(table)
if count > 0:
badge = f'<span class="badge ready">{count} rows</span>'
elif count == 0:
badge = '<span class="badge">no data yet</span>'
else:
badge = '<span class="badge">table missing</span>'
cards += (
f'<div class="card"><a href="/{slug}">'
f'<div class="card-label"><h3>{html.escape(label)}</h3></div>'
f"{badge}</a></div>"
)
return page(
"Results",
f"<h2>Datasets</h2><div class='cards'>{cards}</div>",
)
return page("Results", f"<h2>Datasets</h2><div class='cards'>{cards}</div>")
def table_html(slug: str, label: str, subdir: str) -> str:
headers, rows = read_csv_parts(subdir)
def table_html(slug: str, label: str, table: str) -> str:
back = '<a class="back" href="/">← All datasets</a>'
try:
headers, rows = fetch_rows(table)
except Exception as e:
return page(
label,
f"{back}<h2>{html.escape(label)}</h2>"
f"<div class='notice'>Error querying {html.escape(table)}: {html.escape(str(e))}</div>",
)
if not headers:
return page(
label,
@@ -136,7 +156,7 @@ data:
f"<tbody>{trs}</tbody></table></div>",
)
SLUG_MAP = {s: (label, subdir) for s, label, subdir in DATASETS}
SLUG_MAP = {s: (label, table) for s, label, table in DATASETS}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
@@ -147,8 +167,8 @@ data:
if path == "" or path == "index.html":
self._send(index_html())
elif path in SLUG_MAP:
label, subdir = SLUG_MAP[path]
self._send(table_html(path, label, subdir))
label, table = SLUG_MAP[path]
self._send(table_html(path, label, table))
else:
self.send_response(404)
self.end_headers()
@@ -163,7 +183,7 @@ data:
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", PORT), Handler)
print(f"Listening on :{PORT} OUTPUT_DIR={OUTPUT_DIR}", flush=True)
print(f"Listening on :{PORT} DB={DB_HOST}:{DB_PORT}/{DB_NAME}", flush=True)
server.serve_forever()
---
apiVersion: apps/v1
@@ -194,25 +214,35 @@ spec:
containers:
- name: webview
image: python:3.12-slim
command: ["python", "/scripts/serve.py"]
command: ["sh", "-c", "pip install psycopg2-binary --quiet && python /scripts/serve.py"]
ports:
- containerPort: 8080
env:
- name: SPARK_OUTPUT_DIR
value: /spark-output
- name: PORT
value: "8080"
- name: NOWCHESS_JDBC_URL
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_URL
- name: NOWCHESS_DB_USER
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_USER
- name: NOWCHESS_DB_PASS
valueFrom:
secretKeyRef:
name: ncs-db-secrets
key: STORE_DB_PASSWORD
volumeMounts:
- name: spark-output
mountPath: /spark-output
readOnly: true
- name: scripts
mountPath: /scripts
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 3
initialDelaySeconds: 15
periodSeconds: 10
resources:
requests:
@@ -222,9 +252,6 @@ spec:
cpu: 200m
memory: 256Mi
volumes:
- name: spark-output
persistentVolumeClaim:
claimName: spark-analytics-output
- name: scripts
configMap:
name: spark-analytics-webview
@@ -205,6 +205,132 @@ patches:
target:
kind: Deployment
name: spark-live-dashboard
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-game-length
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
target:
kind: CronJob
name: spark-game-length
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-color-advantage
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
target:
kind: CronJob
name: spark-color-advantage
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-elo-distribution
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
target:
kind: CronJob
name: spark-elo-distribution
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-time-control
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
target:
kind: CronJob
name: spark-time-control
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-daily-activity
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
target:
kind: CronJob
name: spark-daily-activity
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-rating-mismatch
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
target:
kind: CronJob
name: spark-rating-mismatch
- patch: |-
apiVersion: batch/v1
kind: CronJob
metadata:
name: spark-termination-stats
spec:
jobTemplate:
spec:
template:
spec:
containers:
- name: spark-driver
env:
- name: NOWCHESS_PGN_PATH
value: "https://database.lichess.org/standard/lichess_db_standard_rated_2013-01.pgn.zst"
target:
kind: CronJob
name: spark-termination-stats
- patch: |-
apiVersion: apps/v1
kind: Deployment