Files
GitOps/spark-analytics/base/webview.yaml
T
Janis Eccarius f64b838030 feat(spark-analytics): add AccuracyBlunder, ClockPressure, SmurfAnomaly jobs and webview datasets
Adds three new CronJobs (spark-accuracy-blunder, spark-clock-pressure,
spark-smurf-anomaly) and registers their six output tables in the
analytics webview (accuracy_by_rating, blunder_outcome, clock_by_rating,
scramble_outcome, smurf_anomaly, flagged_smurfs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:18:22 +02:00

687 lines
29 KiB
YAML
Executable File

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; }
"""
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 ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), 65000);
let resp;
try {
resp = await fetch('/explain', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: ctrl.signal,
});
} finally {
clearTimeout(tid);
}
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 = '<span class="spinner"></span> 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 = '<span class="spinner"></span>';
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 = """
<div class="modal-overlay" id="nim-modal">
<div class="modal">
<h3>NVIDIA NIM API Key</h3>
<p>Used client-side only — stored in your browser's localStorage, never sent to this server.</p>
<input type="password" id="nim-key-input" placeholder="nvapi-…" autocomplete="off" spellcheck="false" />
<div class="modal-actions">
<button class="btn btn-danger" onclick="clearKey()">Clear</button>
<button class="btn btn-ghost" onclick="closeSettings()">Cancel</button>
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
</div>
</div>
</div>
"""
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"<!doctype html><html lang=en><head><meta charset=utf-8>"
f"<meta name=viewport content='width=device-width,initial-scale=1'>"
f"<title>{html.escape(title)} — Spark Analytics</title>"
f"<style>{CSS}</style></head><body>"
f"<header>"
f"<h1>Spark Analytics</h1><span>NowChess · staging</span>"
f"<button class='settings-btn' id='settings-btn' onclick='openSettings()' title='Configure NIM API key'>⚙ NIM Key</button>"
f"</header>"
f"<main>{body}</main>"
f"{SETTINGS_MODAL}"
f"<script>window.DATASET={dataset_json};\n{JS}</script>"
f"</body></html>"
)
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'<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>")
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)} on {html.escape(DB_HOST)}:{DB_PORT}/{html.escape(DB_NAME)}: {html.escape(str(e))}</div>",
)
if not headers:
return page(
label,
f"{back}<h2>{html.escape(label)}</h2>"
"<div class='notice'>No data yet. Run the CronJob first, then check back.</div>",
)
ai_rows = rows[:200]
dataset_json = json.dumps({
"label": label,
"headers": headers,
"sample": rows[:10],
"rows": ai_rows,
"total_rows": len(rows),
})
explain_bar = (
"<div class='explain-bar'>"
"<button class='explain-btn' id='explain-btn' onclick='explainDataset()'>✦ Analyse top 10 rows</button>"
"<span class='no-key-hint' id='no-key-hint'>— <a href='#' onclick='openSettings();return false;' style='color:#58a6ff'>add NIM key</a> to enable AI explanations</span>"
"</div>"
"<div class='explain-panel' id='explain-panel' style='display:none'>"
"<h4>AI Explanation</h4>"
"<div class='explain-text' id='explain-text'></div>"
"</div>"
)
num_cols = len(headers)
ths = "".join(f"<th>{html.escape(h)}</th>" for h in headers) + "<th></th>"
trs = ""
for i, row in enumerate(rows[:10_000]):
cells = "".join(f"<td>{html.escape(str(c))}</td>" for c in row)
if i < 200:
ai_cell = (
f"<td><button class='row-ai-btn' id='row-btn-{i}' "
f"onclick='analyzeRow({i})' title='Analyse this row with AI'>⚡</button></td>"
)
expand = (
f"<tr class='row-expand-tr' id='row-expand-{i}' style='display:none'>"
f"<td colspan='{num_cols + 1}'>"
f"<span id='row-explain-{i}'></span>"
f"</td></tr>"
)
else:
ai_cell = "<td></td>"
expand = ""
trs += f"<tr>{cells}{ai_cell}</tr>{expand}"
truncated = f" (showing first 10 000 of {len(rows)})" if len(rows) > 10_000 else ""
return page(
label,
f"{back}<h2>{html.escape(label)}</h2>"
f"<p class='meta'>{len(rows)} rows{truncated}</p>"
f"{explain_bar}"
f"<div class='table-wrap'><table><thead><tr>{ths}</tr></thead>"
f"<tbody>{trs}</tbody></table></div>",
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", "")
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))
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": 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",
)
try:
with urllib.request.urlopen(req, timeout=60) 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 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
else:
self._send_json({"error": f"NIM API error {he.code}: {he.reason}"})
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 = 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