diff --git a/spark-analytics/base/webview.yaml b/spark-analytics/base/webview.yaml index 49b8292..78f80d2 100755 --- a/spark-analytics/base/webview.yaml +++ b/spark-analytics/base/webview.yaml @@ -10,6 +10,7 @@ data: #!/usr/bin/env python3 """Spark analytics results viewer — queries PostgreSQL analytics tables, serves HTML tables.""" import html + import json import os import re import psycopg2 @@ -46,9 +47,13 @@ data: 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; } - header h1 { margin: 0; color: #58a6ff; font-size: 1.25rem; font-weight: 600; } - header span { color: #8b949e; font-size: 0.875rem; margin-left: 0.5rem; } + 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; } @@ -71,6 +76,195 @@ data: .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'; + const NIM_ENDPOINT = 'https://integrate.api.nvidia.com/v1/chat/completions'; + const NIM_MODEL = 'meta/llama-3.3-70b-instruct'; + + 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; + const sampleText = dataset.sample.map((row, i) => + row.map((v, j) => dataset.headers[j] + ': ' + v).join(', ') + ).join('\\n'); + + const systemPrompt = `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.`; + + const userPrompt = `Dataset: "${dataset.label}" +Total rows: ${dataset.total_rows} +Columns: ${dataset.headers.join(', ')} + +Sample data (first ${dataset.sample.length} rows): +${sampleText} + +What does this dataset tell us about chess?`; + + try { + const resp = await fetch(NIM_ENDPOINT, { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + key, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: NIM_MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: 512, + temperature: 0.4, + stream: false, + }), + }); + + if (!resp.ok) { + const err = await resp.text(); + throw new Error('NIM API ' + resp.status + ': ' + err); + } + + const data = await resp.json(); + const text = data.choices?.[0]?.message?.content || '(no response)'; + textEl.textContent = text; + } 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(): @@ -107,14 +301,20 @@ data: ] return headers, data - def page(title: str, body: str) -> str: + def page(title: str, body: str, dataset_json: str = "null") -> str: return ( f"" f"" f"{html.escape(title)} — Spark Analytics" f"" - f"

Spark Analytics

NowChess · staging
" - f"
{body}
" + f"
" + f"

Spark Analytics

NowChess · staging" + f"" + f"
" + f"
{body}
" + f"{SETTINGS_MODAL}" + f"" + f"" ) def index_html() -> str: @@ -151,6 +351,26 @@ data: 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) + "" @@ -161,8 +381,10 @@ data: 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}