apiVersion: v1 kind: ConfigMap metadata: name: spark-analytics-webview labels: app.kubernetes.io/name: spark-analytics app.kubernetes.io/part-of: nowchess data: serve.py: | #!/usr/bin/env python3 """Spark analytics results viewer — queries PostgreSQL analytics tables, serves HTML tables.""" import html import json import os import re import urllib.request import psycopg2 import psycopg2.extras from http.server import BaseHTTPRequestHandler, HTTPServer PORT = int(os.environ.get("PORT", "8080")) _url = os.environ.get("NOWCHESS_JDBC_URL", "") _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 and _m.group(2)) 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)", "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 = """ * { box-sizing: border-box; } body { font-family: 'Segoe UI', sans-serif; margin: 0; background: #0d1117; color: #c9d1d9; } header { background: #161b22; border-bottom: 1px solid #30363d; padding: 1rem 2rem; display: flex; align-items: center; gap: 1rem; } header h1 { margin: 0; color: #58a6ff; font-size: 1.25rem; font-weight: 600; flex: 1; } header span { color: #8b949e; font-size: 0.875rem; } .settings-btn { background: none; border: 1px solid #30363d; border-radius: 6px; color: #8b949e; cursor: pointer; padding: 4px 8px; font-size: 0.8rem; display: flex; align-items: center; gap: 4px; } .settings-btn:hover { border-color: #58a6ff; color: #58a6ff; } .settings-btn.active { border-color: #3fb950; color: #3fb950; } main { padding: 1.5rem 2rem; } h2 { color: #e6edf3; font-size: 1rem; font-weight: 600; margin: 0 0 1rem; } .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; } .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; } .card h3 { margin: 0 0 0.5rem; font-size: 0.9rem; color: #58a6ff; } .card a { text-decoration: none; color: inherit; display: block; } .card a:hover .card-label { text-decoration: underline; } .badge { display: inline-block; background: #21262d; border: 1px solid #30363d; border-radius: 12px; padding: 1px 8px; font-size: 0.75rem; color: #8b949e; } .badge.ready { border-color: #238636; color: #3fb950; } .back { color: #58a6ff; text-decoration: none; font-size: 0.875rem; } .back:hover { text-decoration: underline; } .meta { color: #8b949e; font-size: 0.8rem; margin: 0.5rem 0 1rem; } table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } thead th { background: #161b22; position: sticky; top: 0; padding: 6px 12px; text-align: left; color: #8b949e; border-bottom: 1px solid #30363d; font-weight: 600; white-space: nowrap; } tbody td { padding: 5px 12px; border-bottom: 1px solid #21262d; } tbody tr:hover td { background: #161b22; } .table-wrap { overflow-x: auto; border: 1px solid #30363d; border-radius: 6px; } .notice { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1.5rem; color: #8b949e; } /* Settings modal */ .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100; align-items: center; justify-content: center; } .modal-overlay.open { display: flex; } .modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 1.5rem; width: 420px; max-width: 95vw; } .modal h3 { margin: 0 0 0.25rem; color: #e6edf3; font-size: 0.95rem; } .modal p { margin: 0 0 1rem; color: #8b949e; font-size: 0.8rem; } .modal input { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.85rem; padding: 8px 10px; margin-bottom: 0.75rem; font-family: monospace; } .modal input:focus { outline: none; border-color: #58a6ff; } .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } .btn { border-radius: 6px; padding: 6px 14px; font-size: 0.8rem; cursor: pointer; border: 1px solid transparent; } .btn-primary { background: #238636; color: #fff; border-color: #2ea043; } .btn-primary:hover { background: #2ea043; } .btn-danger { background: none; color: #f85149; border-color: #f85149; } .btn-danger:hover { background: rgba(248,81,73,0.1); } .btn-ghost { background: none; color: #8b949e; border-color: #30363d; } .btn-ghost:hover { color: #c9d1d9; border-color: #8b949e; } /* Explain panel */ .explain-bar { display: flex; align-items: center; gap: 0.75rem; margin: 1rem 0; } .explain-btn { background: #1f3a5f; border: 1px solid #388bfd; color: #58a6ff; border-radius: 6px; padding: 6px 14px; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 6px; } .explain-btn:hover { background: #263d6a; } .explain-btn:disabled { opacity: 0.5; cursor: not-allowed; } .no-key-hint { color: #8b949e; font-size: 0.78rem; } .explain-panel { background: #161b22; border: 1px solid #388bfd; border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; } .explain-panel h4 { margin: 0 0 0.5rem; color: #58a6ff; font-size: 0.85rem; font-weight: 600; } .explain-panel .explain-text { color: #c9d1d9; font-size: 0.85rem; line-height: 1.6; white-space: pre-wrap; } .explain-panel .explain-error { color: #f85149; font-size: 0.82rem; } .spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #388bfd; border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } """ JS = """ const NIM_KEY_STORAGE = 'nim_api_key'; function getKey() { return localStorage.getItem(NIM_KEY_STORAGE) || ''; } function hasKey() { return !!getKey(); } function updateSettingsBtn() { const btn = document.getElementById('settings-btn'); if (!btn) return; if (hasKey()) { btn.classList.add('active'); btn.title = 'NIM API key set — click to change'; } else { btn.classList.remove('active'); btn.title = 'Configure NVIDIA NIM API key for AI explanations'; } } function openSettings() { document.getElementById('nim-modal').classList.add('open'); const inp = document.getElementById('nim-key-input'); inp.value = getKey(); inp.focus(); updateNoKeyHint(); } function closeSettings() { document.getElementById('nim-modal').classList.remove('open'); } function saveSettings() { const val = document.getElementById('nim-key-input').value.trim(); if (val) localStorage.setItem(NIM_KEY_STORAGE, val); else localStorage.removeItem(NIM_KEY_STORAGE); closeSettings(); updateSettingsBtn(); updateNoKeyHint(); } function clearKey() { localStorage.removeItem(NIM_KEY_STORAGE); document.getElementById('nim-key-input').value = ''; updateSettingsBtn(); updateNoKeyHint(); } function updateNoKeyHint() { const hint = document.getElementById('no-key-hint'); if (!hint) return; hint.style.display = hasKey() ? 'none' : 'inline'; const btn = document.getElementById('explain-btn'); if (btn) btn.disabled = !hasKey(); } async function explainDataset() { const key = getKey(); if (!key) { openSettings(); return; } const btn = document.getElementById('explain-btn'); const panel = document.getElementById('explain-panel'); const textEl = document.getElementById('explain-text'); btn.disabled = true; btn.innerHTML = ' Thinking…'; panel.style.display = 'block'; textEl.className = 'explain-text'; textEl.textContent = ''; const dataset = window.DATASET; try { const resp = await fetch('/explain', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key, dataset: dataset }), }); if (!resp.ok) { const err = await resp.text(); throw new Error('Proxy ' + resp.status + ': ' + err); } const data = await resp.json(); if (data.error) throw new Error(data.error); textEl.textContent = data.text || '(no response)'; } catch (e) { textEl.className = 'explain-error'; textEl.textContent = 'Error: ' + e.message; } finally { btn.disabled = false; btn.innerHTML = '✦ Explain this dataset'; updateNoKeyHint(); } } document.addEventListener('DOMContentLoaded', () => { updateSettingsBtn(); updateNoKeyHint(); document.getElementById('nim-modal')?.addEventListener('click', e => { if (e.target === e.currentTarget) closeSettings(); }); document.getElementById('nim-key-input')?.addEventListener('keydown', e => { if (e.key === 'Enter') saveSettings(); if (e.key === 'Escape') closeSettings(); }); }); """ SETTINGS_MODAL = """ """ def get_conn(): return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS) def all_row_counts() -> dict: counts = {} try: with get_conn() as conn: with conn.cursor() as cur: for _, _, table in DATASETS: try: cur.execute(f"SELECT COUNT(*) FROM {table}") counts[table] = cur.fetchone()[0] except Exception: conn.rollback() counts[table] = -1 except Exception: for _, _, table in DATASETS: counts.setdefault(table, -1) return counts 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, dataset_json: str = "null") -> str: return ( f"" f"" f"{html.escape(title)} — Spark Analytics" f"" f"
" f"

Spark Analytics

NowChess · staging" f"" f"
" f"
{body}
" f"{SETTINGS_MODAL}" f"" f"" ) def index_html() -> str: counts = all_row_counts() cards = "" for slug, label, table in DATASETS: count = counts.get(table, -1) if count > 0: badge = f'{count} rows' elif count == 0: badge = 'no data yet' else: badge = 'table missing' cards += ( f'
' f'

{html.escape(label)}

' f"{badge}
" ) return page("Results", f"

Datasets

{cards}
") def table_html(slug: str, label: str, table: str) -> str: back = '← All datasets' try: headers, rows = fetch_rows(table) except Exception as e: return page( label, f"{back}

{html.escape(label)}

" f"
Error querying {html.escape(table)} on {html.escape(DB_HOST)}:{DB_PORT}/{html.escape(DB_NAME)}: {html.escape(str(e))}
", ) if not headers: return page( label, f"{back}

{html.escape(label)}

" "
No data yet. Run the CronJob first, then check back.
", ) sample = rows[:10] dataset_json = json.dumps({ "label": label, "headers": headers, "sample": sample, "total_rows": len(rows), }) explain_bar = ( "
" "" "add NIM key to enable AI explanations" "
" "" ) ths = "".join(f"{html.escape(h)}" for h in headers) trs = "".join( "" + "".join(f"{html.escape(str(c))}" for c in row) + "" for row in rows[:10_000] ) truncated = f" (showing first 10 000 of {len(rows)})" if len(rows) > 10_000 else "" return page( label, f"{back}

{html.escape(label)}

" f"

{len(rows)} rows{truncated}

" f"{explain_bar}" f"
{ths}" f"{trs}
", dataset_json=dataset_json, ) SLUG_MAP = {s: (label, table) for s, label, table in DATASETS} class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def do_GET(self): path = self.path.split("?")[0].lstrip("/") if path == "" or path == "index.html": self._send(index_html()) elif path in SLUG_MAP: label, table = SLUG_MAP[path] self._send(table_html(path, label, table)) else: self.send_response(404) self.end_headers() def do_POST(self): path = self.path.split("?")[0].lstrip("/") if path != "explain": self.send_response(404) self.end_headers() return length = int(self.headers.get("Content-Length", 0)) try: body = json.loads(self.rfile.read(length)) api_key = body.get("key", "") dataset = body.get("dataset", {}) if not api_key: raise ValueError("missing key") sample_text = "\n".join( ", ".join(f"{h}: {v}" for h, v in zip(dataset.get("headers", []), row)) for row in dataset.get("sample", []) ) system_prompt = ( "You are a chess analytics expert. Explain what this dataset shows to a chess player. " "Be concise but insightful — 3-6 sentences. Focus on what the data reveals about chess patterns, " "player behaviour, or game dynamics. When column names reference chess openings (ECO codes, opening names), " "explain what those openings are." ) user_prompt = ( f"Dataset: \"{dataset.get('label', '')}\"\n" f"Total rows: {dataset.get('total_rows', 0)}\n" f"Columns: {', '.join(dataset.get('headers', []))}\n\n" f"Sample data (first {len(dataset.get('sample', []))} rows):\n" f"{sample_text}\n\nWhat does this dataset tell us about chess?" ) payload = json.dumps({ "model": "meta/llama-3.3-70b-instruct", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "max_tokens": 512, "temperature": 0.4, "stream": False, }).encode("utf-8") req = urllib.request.Request( "https://integrate.api.nvidia.com/v1/chat/completions", data=payload, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, method="POST", ) with urllib.request.urlopen(req, timeout=30) as r: nim_data = json.loads(r.read()) text = nim_data.get("choices", [{}])[0].get("message", {}).get("content", "(no response)") self._send_json({"text": text}) except Exception as e: self._send_json({"error": str(e)}) def _send(self, body: str): data = body.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() try: self.wfile.write(data) except BrokenPipeError: pass def _send_json(self, obj: dict): data = json.dumps(obj).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() try: self.wfile.write(data) except BrokenPipeError: pass if __name__ == "__main__": server = HTTPServer(("0.0.0.0", PORT), Handler) print(f"Listening on :{PORT} DB={DB_HOST}:{DB_PORT}/{DB_NAME} JDBC_URL={_url!r}", flush=True) server.serve_forever() --- apiVersion: apps/v1 kind: Deployment metadata: name: spark-analytics-webview labels: app.kubernetes.io/name: spark-analytics app.kubernetes.io/part-of: nowchess spec: replicas: 0 strategy: type: Recreate selector: matchLabels: app: spark-analytics-webview template: metadata: labels: app: spark-analytics-webview app.kubernetes.io/name: spark-analytics app.kubernetes.io/part-of: nowchess spec: securityContext: runAsNonRoot: true runAsUser: 65534 fsGroup: 65534 containers: - name: webview image: python:3.12-slim command: ["sh", "-c", "pip install psycopg2-binary --quiet --no-cache-dir --target=/tmp/pkg && PYTHONPATH=/tmp/pkg python /scripts/serve.py"] ports: - containerPort: 8080 env: - 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: scripts mountPath: /scripts readinessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 15 periodSeconds: 10 resources: requests: cpu: 10m memory: 64Mi limits: cpu: 200m memory: 256Mi volumes: - name: scripts configMap: name: spark-analytics-webview defaultMode: 0755 --- apiVersion: v1 kind: Service metadata: name: spark-analytics-webview labels: app.kubernetes.io/name: spark-analytics app.kubernetes.io/part-of: nowchess spec: selector: app: spark-analytics-webview ports: - name: http port: 8080 targetPort: 8080