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.error import urllib.request import psycopg2 import psycopg2.extras from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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); } } /* Per-row AI */ .row-ai-btn { background: none; border: 1px solid #30363d; border-radius: 4px; color: #8b949e; cursor: pointer; padding: 1px 5px; font-size: 0.72rem; line-height: 1.4; } .row-ai-btn:hover { border-color: #388bfd; color: #58a6ff; } .row-ai-btn:disabled { opacity: 0.4; cursor: not-allowed; } .row-ai-btn.done { border-color: #238636; color: #3fb950; } .row-expand-tr td { background: #0a0e14; padding: 0.6rem 1rem 0.6rem 2rem; font-size: 0.8rem; line-height: 1.6; border-bottom: 1px solid #30363d; } .row-explain-text { color: #c9d1d9; white-space: pre-wrap; } .row-explain-error { color: #f85149; } """ JS = """ const NIM_KEY_STORAGE = 'nim_api_key'; const rowCache = {}; let nimQueue = Promise.resolve(); 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 nimFetch(payload) { const resp = await fetch('/explain', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (resp.status === 429) { const retryAfter = parseInt(resp.headers.get('Retry-After') || '10', 10); const err = new Error('Rate limited — retry in ' + retryAfter + 's'); err.retryAfter = retryAfter; throw err; } if (!resp.ok) { const msg = await resp.text(); throw new Error('Proxy ' + resp.status + ': ' + msg); } const data = await resp.json(); if (data.error) throw new Error(data.error); return data.text || '(no response)'; } function queueNim(payload) { const next = nimQueue.then(() => nimFetch(payload)); nimQueue = next.catch(() => {}); return next; } 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 = ''; try { const text = await queueNim({ key: key, mode: 'dataset', dataset: window.DATASET }); textEl.textContent = text; } catch (e) { textEl.className = 'explain-error'; textEl.textContent = 'Error: ' + e.message; } finally { btn.disabled = false; btn.innerHTML = '✦ Analyse top 10 rows'; updateNoKeyHint(); } } async function analyzeRow(idx) { const key = getKey(); if (!key) { openSettings(); return; } if (rowCache[idx] !== undefined) return; const btn = document.getElementById('row-btn-' + idx); const expandRow = document.getElementById('row-expand-' + idx); const textEl = document.getElementById('row-explain-' + idx); if (!btn || !expandRow || !textEl) return; const dataset = window.DATASET; const row = dataset.rows && dataset.rows[idx]; if (!row) { expandRow.style.display = 'table-row'; textEl.className = 'row-explain-error'; textEl.textContent = 'Row data not available.'; return; } btn.disabled = true; btn.innerHTML = ''; expandRow.style.display = 'table-row'; textEl.className = 'row-explain-text'; textEl.textContent = 'Analyzing…'; try { const text = await queueNim({ key: key, mode: 'row', dataset: { label: dataset.label, headers: dataset.headers, row: row }, }); rowCache[idx] = text; textEl.textContent = text; btn.innerHTML = '✓'; btn.classList.add('done'); } catch (e) { textEl.className = 'row-explain-error'; textEl.textContent = e.message; btn.disabled = false; btn.innerHTML = '⚡'; } } 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"