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"), ("accuracy-by-rating", "Accuracy by Rating", "analytics_accuracy_by_rating"), ("blunder-outcome", "Blunder Outcome", "analytics_blunder_outcome"), ("clock-by-rating", "Clock Pressure by Rating", "analytics_clock_by_rating"), ("scramble-outcome", "Scramble Outcome", "analytics_scramble_outcome"), ("smurf-anomaly", "Smurf Anomaly Scores", "analytics_smurf_anomaly"), ("flagged-smurfs", "Flagged Smurfs", "analytics_flagged_smurfs"), ] 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; } .row-ask { display: flex; gap: 0.5rem; margin: 0.6rem 0 0.3rem; } .row-q-input { flex: 1; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.8rem; padding: 5px 8px; } .row-q-input:focus { outline: none; border-color: #388bfd; } .row-ask-btn { background: #1f3a5f; border: 1px solid #388bfd; color: #58a6ff; border-radius: 6px; padding: 4px 12px; font-size: 0.78rem; cursor: pointer; white-space: nowrap; } .row-ask-btn:hover { background: #263d6a; } .row-ask-btn:disabled { opacity: 0.5; cursor: not-allowed; } .row-answer { color: #c9d1d9; white-space: pre-wrap; margin-top: 0.3rem; } .row-answer.err { 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 nimStream(payload, onDelta) { const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), 180000); try { const resp = await fetch('/explain', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: ctrl.signal, }); 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 reader = resp.body.getReader(); const dec = new TextDecoder(); const SEP = String.fromCharCode(10, 10); // SSE frame separator (two newlines) let buf = '', full = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf(SEP)) >= 0) { const frame = buf.slice(0, idx).trim(); buf = buf.slice(idx + 2); if (!frame.startsWith('data:')) continue; const obj = JSON.parse(frame.slice(5).trim()); if (obj.error) throw new Error(obj.error); if (obj.delta) { full += obj.delta; onDelta(full); } } } return full || '(no response)'; } finally { clearTimeout(tid); } } function queueNim(payload, onDelta) { const next = nimQueue.then(() => nimStream(payload, onDelta)); 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 }, partial => { textEl.textContent = partial; }, ); 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 }, }, partial => { textEl.textContent = partial; }, ); 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 = '⚡'; } } async function askRow(idx) { const key = getKey(); if (!key) { openSettings(); return; } const inp = document.getElementById('row-q-' + idx); const btn = document.getElementById('row-ask-btn-' + idx); const out = document.getElementById('row-answer-' + idx); if (!inp || !btn || !out) return; const question = (inp.value || '').trim(); if (!question) { inp.focus(); return; } const dataset = window.DATASET; const row = dataset.rows && dataset.rows[idx]; if (!row) { out.className = 'row-answer err'; out.textContent = 'Row data not available.'; return; } btn.disabled = true; inp.disabled = true; const label = btn.textContent; btn.innerHTML = ''; out.className = 'row-answer'; out.textContent = 'Thinking…'; try { const text = await queueNim( { key: key, mode: 'row', question: question, dataset: { label: dataset.label, headers: dataset.headers, row: row }, }, partial => { out.textContent = partial; }, ); out.textContent = text; } catch (e) { out.className = 'row-answer err'; out.textContent = e.message; } finally { btn.disabled = false; inp.disabled = false; btn.textContent = label; } } 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.
", ) ai_rows = rows[:200] dataset_json = json.dumps({ "label": label, "headers": headers, "sample": rows[:10], "rows": ai_rows, "total_rows": len(rows), }) explain_bar = ( "
" "" "add NIM key to enable AI explanations" "
" "" ) num_cols = len(headers) ths = "".join(f"{html.escape(h)}" for h in headers) + "" trs = "" for i, row in enumerate(rows[:10_000]): cells = "".join(f"{html.escape(str(c))}" for c in row) if i < 200: ai_cell = ( f"" ) expand = ( f"" f"" f"" f"
" f"" f"" f"
" f"
" f"" ) else: ai_cell = "" expand = "" trs += f"{cells}{ai_cell}{expand}" 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)) headers_sent = False try: body = json.loads(self.rfile.read(length)) api_key = body.get("key", "") mode = body.get("mode", "dataset") dataset = body.get("dataset", {}) if not api_key: raise ValueError("missing key") label = dataset.get("label", "") headers = dataset.get("headers", []) if mode == "row": row = dataset.get("row", []) row_desc = ", ".join(f"{h}: {v}" for h, v in zip(headers, row)) question = str(body.get("question", "")).strip() if question: system_prompt = ( "You are a chess analytics expert. Answer the user's question about a single " "data entry from a chess analytics dataset. Be specific, accurate and concise. " "Ground your answer in the entry's values and in chess knowledge. " "If the question cannot be answered from the entry, say so." ) user_prompt = ( f"Dataset: \"{label}\"\n" f"Columns: {', '.join(headers)}\n\n" f"Entry: {row_desc}\n\n" f"Question: {question}\n\n" "Answer:" ) max_tokens = 400 else: system_prompt = ( "You are a chess analytics expert. Analyze a single data entry from a chess analytics dataset. " "Be specific and insightful — 3-4 sentences. " "If the entry involves a chess opening (ECO code or opening name present), explain the opening's " "strategic ideas, strengths and weaknesses, and why players choose it. " "For player data, explain what the stats reveal about their playing style. " "For other types, explain what makes this entry notable." ) user_prompt = ( f"Dataset: \"{label}\"\n" f"Columns: {', '.join(headers)}\n\n" f"Entry to analyze: {row_desc}\n\n" "Provide a detailed, chess-specific analysis of this entry." ) max_tokens = 300 else: sample = dataset.get("sample", []) rows_text = "\n".join( f"Row {i + 1}: " + ", ".join(f"{h}: {v}" for h, v in zip(headers, row)) for i, row in enumerate(sample) ) system_prompt = ( "You are a chess analytics expert. Analyze the top 10 entries from a chess analytics dataset. " "For each entry write 1-2 specific sentences about what is notable. " "If entries are chess openings (ECO codes or opening names present), name each opening properly " "and explain its strategic character and why it ranks here. " "Format your response as 'Row N: [analysis]' for each row." ) user_prompt = ( f"Dataset: \"{label}\" ({dataset.get('total_rows', 0)} total rows)\n" f"Columns: {', '.join(headers)}\n\n" f"Top 10 entries:\n{rows_text}\n\n" "Analyze each entry." ) max_tokens = 512 payload = json.dumps({ "model": "meta/llama-3.3-70b-instruct", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "max_tokens": max_tokens, "temperature": 0.4, "stream": True, }).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", ) try: upstream = urllib.request.urlopen(req, timeout=110) except urllib.error.HTTPError as he: if he.code == 429: retry_after = he.headers.get("Retry-After", "10") resp_body = json.dumps({"error": f"Rate limited — retry in {retry_after}s"}).encode("utf-8") self.send_response(429) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(resp_body))) self.send_header("Retry-After", str(retry_after)) self.end_headers() try: self.wfile.write(resp_body) except BrokenPipeError: pass return raise RuntimeError(f"NIM API error {he.code}: {he.reason}") # Relay NIM's SSE stream to the browser as our own SSE. self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.send_header("X-Accel-Buffering", "no") self.end_headers() headers_sent = True for raw in upstream: line = raw.decode("utf-8", "replace").strip() if not line.startswith("data:"): continue data = line[5:].strip() if data == "[DONE]": break try: obj = json.loads(data) except json.JSONDecodeError: continue piece = obj.get("choices", [{}])[0].get("delta", {}).get("content", "") if piece and not self._emit({"delta": piece}): return self._emit({"done": True}) except Exception as e: if not headers_sent: self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.end_headers() self._emit({"error": str(e)}) def _emit(self, obj: dict) -> bool: try: self.wfile.write(("data: " + json.dumps(obj) + "\n\n").encode("utf-8")) self.wfile.flush() return True except (BrokenPipeError, ConnectionResetError): return False 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 = ThreadingHTTPServer(("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