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:
@@ -8,32 +8,39 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
serve.py: |
|
serve.py: |
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Spark analytics results viewer — reads CSV output from PVC, serves HTML tables."""
|
"""Spark analytics results viewer — queries PostgreSQL analytics tables, serves HTML tables."""
|
||||||
import csv
|
|
||||||
import glob
|
|
||||||
import html
|
import html
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
OUTPUT_DIR = os.environ.get("SPARK_OUTPUT_DIR", "/spark-output")
|
|
||||||
PORT = int(os.environ.get("PORT", "8080"))
|
PORT = int(os.environ.get("PORT", "8080"))
|
||||||
|
|
||||||
# Each tuple: (url-slug, display label, path relative to SPARK_OUTPUT_DIR)
|
_url = os.environ.get("NOWCHESS_JDBC_URL", "jdbc:postgresql://localhost:5432/nowchess")
|
||||||
# Paths mirror the CronJob outputDir arg + the subdir each job writes CSV to.
|
_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 = [
|
DATASETS = [
|
||||||
("opening-book", "Opening Book (Top 1000)", "opening-book/opening_book_top1000"),
|
("opening-book", "Opening Book (Top 1000)", "analytics_opening_stats"),
|
||||||
("player-stats", "Player Statistics", "player-stats/player_stats_csv"),
|
("player-stats", "Player Statistics", "analytics_player_stats"),
|
||||||
("cluster-archetypes", "Cluster Archetypes", "player-clusters/cluster_archetypes"),
|
("cluster-archetypes", "Cluster Archetypes", "analytics_cluster_archetypes"),
|
||||||
("component-sizes", "Graph Component Sizes", "player-graph/component_sizes"),
|
("component-sizes", "Graph Component Sizes", "analytics_component_sizes"),
|
||||||
("game-length", "Game Length Distribution", "game-length/game_length_distribution"),
|
("game-length", "Game Length Distribution", "analytics_game_length_distribution"),
|
||||||
("game-length-by-result", "Game Length by Result", "game-length/game_length_by_result"),
|
("game-length-by-result", "Game Length by Result", "analytics_game_length_by_result"),
|
||||||
("color-advantage", "Color Advantage", "color-advantage/color_advantage"),
|
("color-advantage", "Color Advantage", "analytics_color_advantage"),
|
||||||
("elo-distribution", "ELO Distribution", "elo-distribution/elo_distribution"),
|
("elo-distribution", "ELO Distribution", "analytics_elo_distribution"),
|
||||||
("time-control", "Time Control Analysis", "time-control/time_control_stats"),
|
("time-control", "Time Control Analysis", "analytics_time_control_stats"),
|
||||||
("hourly-activity", "Hourly Activity", "daily-activity/hourly_activity"),
|
("hourly-activity", "Hourly Activity", "analytics_hourly_activity"),
|
||||||
("weekly-activity", "Weekly Activity", "daily-activity/weekly_activity"),
|
("weekly-activity", "Weekly Activity", "analytics_weekly_activity"),
|
||||||
("rating-mismatch", "Rating Mismatch (Upsets)", "rating-mismatch/rating_mismatch"),
|
("rating-mismatch", "Rating Mismatch (Upsets)", "analytics_rating_mismatch"),
|
||||||
("termination-stats", "Termination Types", "termination-stats/termination_stats"),
|
("termination-stats", "Termination Types", "analytics_termination_stats"),
|
||||||
]
|
]
|
||||||
|
|
||||||
CSS = """
|
CSS = """
|
||||||
@@ -66,23 +73,31 @@ data:
|
|||||||
padding: 1.5rem; color: #8b949e; }
|
padding: 1.5rem; color: #8b949e; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def read_csv_parts(subdir: str) -> tuple[list[str], list[list[str]]]:
|
def get_conn():
|
||||||
files = sorted(glob.glob(f"{OUTPUT_DIR}/{subdir}/part-*.csv"))
|
return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS)
|
||||||
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 dataset_status(subdir: str) -> tuple[int, int]:
|
def table_row_count(table: str) -> int:
|
||||||
files = glob.glob(f"{OUTPUT_DIR}/{subdir}/part-*.csv")
|
try:
|
||||||
return len(files), sum(os.path.getsize(f) for f in files)
|
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:
|
def page(title: str, body: str) -> str:
|
||||||
return (
|
return (
|
||||||
@@ -96,26 +111,31 @@ data:
|
|||||||
|
|
||||||
def index_html() -> str:
|
def index_html() -> str:
|
||||||
cards = ""
|
cards = ""
|
||||||
for slug, label, subdir in DATASETS:
|
for slug, label, table in DATASETS:
|
||||||
parts, size_bytes = dataset_status(subdir)
|
count = table_row_count(table)
|
||||||
if parts:
|
if count > 0:
|
||||||
size_kb = size_bytes // 1024
|
badge = f'<span class="badge ready">{count} rows</span>'
|
||||||
badge = f'<span class="badge ready">{parts} part(s) · {size_kb} KB</span>'
|
elif count == 0:
|
||||||
else:
|
|
||||||
badge = '<span class="badge">no data yet</span>'
|
badge = '<span class="badge">no data yet</span>'
|
||||||
|
else:
|
||||||
|
badge = '<span class="badge">table missing</span>'
|
||||||
cards += (
|
cards += (
|
||||||
f'<div class="card"><a href="/{slug}">'
|
f'<div class="card"><a href="/{slug}">'
|
||||||
f'<div class="card-label"><h3>{html.escape(label)}</h3></div>'
|
f'<div class="card-label"><h3>{html.escape(label)}</h3></div>'
|
||||||
f"{badge}</a></div>"
|
f"{badge}</a></div>"
|
||||||
)
|
)
|
||||||
return page(
|
return page("Results", f"<h2>Datasets</h2><div class='cards'>{cards}</div>")
|
||||||
"Results",
|
|
||||||
f"<h2>Datasets</h2><div class='cards'>{cards}</div>",
|
|
||||||
)
|
|
||||||
|
|
||||||
def table_html(slug: str, label: str, subdir: str) -> str:
|
def table_html(slug: str, label: str, table: str) -> str:
|
||||||
headers, rows = read_csv_parts(subdir)
|
|
||||||
back = '<a class="back" href="/">← All datasets</a>'
|
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:
|
if not headers:
|
||||||
return page(
|
return page(
|
||||||
label,
|
label,
|
||||||
@@ -136,7 +156,7 @@ data:
|
|||||||
f"<tbody>{trs}</tbody></table></div>",
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def log_message(self, fmt, *args):
|
def log_message(self, fmt, *args):
|
||||||
@@ -147,8 +167,8 @@ data:
|
|||||||
if path == "" or path == "index.html":
|
if path == "" or path == "index.html":
|
||||||
self._send(index_html())
|
self._send(index_html())
|
||||||
elif path in SLUG_MAP:
|
elif path in SLUG_MAP:
|
||||||
label, subdir = SLUG_MAP[path]
|
label, table = SLUG_MAP[path]
|
||||||
self._send(table_html(path, label, subdir))
|
self._send(table_html(path, label, table))
|
||||||
else:
|
else:
|
||||||
self.send_response(404)
|
self.send_response(404)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
@@ -163,7 +183,7 @@ data:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
server = HTTPServer(("0.0.0.0", PORT), Handler)
|
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()
|
server.serve_forever()
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
@@ -194,25 +214,35 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: webview
|
- name: webview
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
command: ["python", "/scripts/serve.py"]
|
command: ["sh", "-c", "pip install psycopg2-binary --quiet && python /scripts/serve.py"]
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
env:
|
env:
|
||||||
- name: SPARK_OUTPUT_DIR
|
|
||||||
value: /spark-output
|
|
||||||
- name: PORT
|
- name: PORT
|
||||||
value: "8080"
|
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:
|
volumeMounts:
|
||||||
- name: spark-output
|
|
||||||
mountPath: /spark-output
|
|
||||||
readOnly: true
|
|
||||||
- name: scripts
|
- name: scripts
|
||||||
mountPath: /scripts
|
mountPath: /scripts
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
port: 8080
|
port: 8080
|
||||||
initialDelaySeconds: 3
|
initialDelaySeconds: 15
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
@@ -222,9 +252,6 @@ spec:
|
|||||||
cpu: 200m
|
cpu: 200m
|
||||||
memory: 256Mi
|
memory: 256Mi
|
||||||
volumes:
|
volumes:
|
||||||
- name: spark-output
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: spark-analytics-output
|
|
||||||
- name: scripts
|
- name: scripts
|
||||||
configMap:
|
configMap:
|
||||||
name: spark-analytics-webview
|
name: spark-analytics-webview
|
||||||
|
|||||||
@@ -205,6 +205,132 @@ patches:
|
|||||||
target:
|
target:
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: spark-live-dashboard
|
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: |-
|
- patch: |-
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
|
|||||||
Reference in New Issue
Block a user