SQL-Query-Env / server /demo.html
laterabhi's picture
Sync from GitHub: serving-only image deps, app_port, discoverability tags, buildable package
60dfa24 verified
Raw
History Blame Contribute Delete
24.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SQL Query Optimizer β€” Live Demo</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #1c2333;
--border: #30363d; --text: #e6edf3; --muted: #7d8590;
--accent: #58a6ff; --green: #3fb950; --red: #f85149;
--yellow: #d29922; --purple: #bc8cff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
header {
background: linear-gradient(135deg, #1a2744 0%, #0d1117 60%);
border-bottom: 1px solid var(--border);
padding: 28px 40px;
display: flex; align-items: center; gap: 16px;
}
header h1 { font-size: 1.4rem; font-weight: 700; }
header h1 span { color: var(--accent); }
.badge {
background: #1f3a1f; color: var(--green);
border: 1px solid #2ea043; border-radius: 20px;
padding: 3px 12px; font-size: 0.72rem; font-weight: 600;
margin-left: auto;
}
.container { max-width: 1100px; margin: 0 auto; padding: 32px 24px; }
.tagline {
text-align: center; margin-bottom: 36px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 20px 32px;
}
.tagline p { color: var(--muted); font-size: 0.95rem; line-height: 1.6; }
.tagline strong { color: var(--accent); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media(max-width:800px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
}
.card-header {
padding: 14px 20px; border-bottom: 1px solid var(--border);
font-size: 0.85rem; font-weight: 600; color: var(--muted);
display: flex; align-items: center; gap: 8px;
}
.card-body { padding: 20px; }
label { display: block; font-size: 0.82rem; font-weight: 500; color: var(--muted); margin-bottom: 8px; }
select, textarea {
width: 100%; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
font-family: 'JetBrains Mono', monospace; font-size: 0.82rem;
padding: 10px 14px; resize: vertical; outline: none;
transition: border-color .2s;
}
select { font-family: 'Inter', sans-serif; cursor: pointer; }
select:focus, textarea:focus { border-color: var(--accent); }
textarea { min-height: 200px; }
.run-btn {
width: 100%; margin-top: 16px; padding: 13px;
background: linear-gradient(135deg, #1f6feb, #388bfd);
color: #fff; border: none; border-radius: 8px;
font-size: 0.95rem; font-weight: 600; cursor: pointer;
transition: opacity .2s, transform .1s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.run-btn:hover { opacity: .9; transform: translateY(-1px); }
.run-btn:active { transform: translateY(0); }
.run-btn:disabled { opacity: .5; cursor: not-allowed; transform: none; }
.spinner {
width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff; border-radius: 50%;
animation: spin .7s linear infinite; display: none;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Results panel */
.results { margin-top: 28px; display: none; }
.results.visible { display: block; animation: fadeIn .4s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
@media(max-width:600px) { .metrics { grid-template-columns: 1fr 1fr; } }
.metric {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 18px 20px; text-align: center;
}
.metric .val {
font-size: 2rem; font-weight: 700; line-height: 1;
margin-bottom: 6px;
}
.metric .lbl { font-size: 0.75rem; color: var(--muted); font-weight: 500; }
.metric.good .val { color: var(--green); }
.metric.warn .val { color: var(--yellow); }
.metric.bad .val { color: var(--red); }
.metric.info .val { color: var(--accent); }
.verdict-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 16px 20px;
font-size: 0.9rem; margin-bottom: 24px;
display: flex; align-items: center; gap: 12px;
}
.verdict-icon { font-size: 1.4rem; }
.explain-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
.explain-card summary {
padding: 14px 20px; cursor: pointer; font-size: 0.85rem;
font-weight: 600; color: var(--muted);
list-style: none; display: flex; align-items: center; gap: 8px;
}
.explain-card summary::-webkit-details-marker { display: none; }
.explain-card[open] summary { border-bottom: 1px solid var(--border); }
.explain-body {
padding: 16px 20px;
font-family: 'JetBrains Mono', monospace; font-size: 0.78rem;
color: var(--text); white-space: pre-wrap; overflow-x: auto;
max-height: 260px; overflow-y: auto;
}
.error-box {
background: #1a0e0e; border: 1px solid #5a1e1e;
border-radius: 10px; padding: 16px 20px;
color: var(--red); font-size: 0.88rem;
}
.task-hint {
margin-top: 12px; background: var(--surface2);
border: 1px solid var(--border); border-radius: 8px;
padding: 12px 16px; font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem; color: var(--muted); white-space: pre-wrap;
max-height: 220px; overflow-y: auto;
}
footer {
text-align: center; padding: 32px;
color: var(--muted); font-size: 0.78rem; border-top: 1px solid var(--border);
margin-top: 48px;
}
footer a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<header>
<span style="font-size:1.5rem">πŸ—„οΈ</span>
<div>
<h1>SQL Query <span>Optimizer</span></h1>
<div style="font-size:0.78rem;color:var(--muted);margin-top:2px">OpenEnv Hackathon 2026 β€” Meta PyTorch Γ— Scaler</div>
</div>
<div class="badge">⚑ DuckDB Live Execution</div>
</header>
<div class="container">
<div class="tagline">
<p>Paste your SQL below. We execute <strong>both the original and your rewrite</strong> against a real
<strong>1.5M-row DuckDB database</strong> and return actual timing. No simulations.
No keyword matching. <strong>The database is the judge.</strong></p>
</div>
<div class="grid">
<!-- Left: input -->
<div class="card">
<div class="card-header">βš™οΈ Configure Query</div>
<div class="card-body">
<label>Select Task</label>
<select id="taskSelect" onchange="loadTaskHint()">
<option value="task_1_basic_antipatterns">Task 1 β€” Basic Anti-patterns (Easy)</option>
<option value="task_2_correlated_subqueries">Task 2 β€” N+1 Correlated Subqueries (Medium)</option>
<option value="task_3_wildcard_scan">Task 3 β€” Wildcard LIKE on 1M rows (Medium-Hard)</option>
<option value="task_4_implicit_join">Task 4 β€” Implicit Cross Join (Hard)</option>
<option value="task_5_window_functions">Task 5 β€” Window Function Full Scan (Expert)</option>
</select>
<div id="taskHint" class="task-hint" style="display:none"></div>
<label style="margin-top:20px">Your Optimized SQL</label>
<textarea id="sqlInput" placeholder="Paste your rewritten SQL here...&#10;&#10;Example:&#10;SELECT id, customer_id, status, total&#10;FROM orders&#10;WHERE customer_id = 5000&#10; AND created_at >= '2024-01-01'&#10; AND created_at < '2025-01-01'"></textarea>
<button class="run-btn" id="runBtn" onclick="runQuery()">
<div class="spinner" id="spinner"></div>
<span id="btnText">⚑ Execute Against DuckDB</span>
</button>
<button class="run-btn" style="background:linear-gradient(135deg,#1a3a1a,#2ea043);margin-top:8px;font-size:0.82rem;padding:10px" onclick="loadSample()">
πŸ“‹ Load Verified Sample Solution
</button>
</div>
</div>
<!-- Right: task descriptions -->
<div class="card">
<div class="card-header">πŸ“‹ Task Details &amp; Expected Results</div>
<div class="card-body" id="taskDetails">
<p style="color:var(--muted);font-size:0.85rem;line-height:1.8" id="taskInfo">
Select a task on the left, then click <strong style="color:var(--accent)">Load Verified Sample Solution</strong> to auto-fill a tested SQL rewrite.<br><br>
<strong style="color:var(--accent)">Schema quick ref:</strong><br>
<code style="font-size:0.75rem;color:#bc8cff">users</code>: id, email, <strong>tier</strong>, region, plan, created_at<br>
<code style="font-size:0.75rem;color:#bc8cff">orders</code>: id, customer_id, product_id, status, total, created_at<br>
<code style="font-size:0.75rem;color:#bc8cff">events</code>: id, user_id, session_id, event_type, <strong>occurred_at</strong><br><br>
<strong style="color:#d29922">⚠️ Common gotchas:</strong><br>
β€’ events uses <code>occurred_at</code> (not <code>created_at</code>)<br>
β€’ users uses <code>tier</code> (not <code>status</code>)<br>
β€’ Task 3: original WHERE returns all 1M rows (sess_ matches all)
</p>
</div>
</div>
</div>
<!-- Results -->
<div class="results" id="results">
<h2 style="font-size:1.1rem;font-weight:600;margin-bottom:20px">πŸ“Š Execution Results</h2>
<div class="metrics">
<div class="metric" id="m-speedup">
<div class="val" id="v-speedup">β€”</div>
<div class="lbl">Speedup</div>
</div>
<div class="metric" id="m-orig">
<div class="val" id="v-orig">β€”</div>
<div class="lbl">Original (ms)</div>
</div>
<div class="metric" id="m-opt">
<div class="val" id="v-opt">β€”</div>
<div class="lbl">Optimized (ms)</div>
</div>
<div class="metric" id="m-correct">
<div class="val" id="v-correct">β€”</div>
<div class="lbl">Results Match</div>
</div>
<div class="metric info" id="m-rows-orig">
<div class="val" id="v-rows-orig">β€”</div>
<div class="lbl">Original Rows</div>
</div>
<div class="metric info" id="m-rows-opt">
<div class="val" id="v-rows-opt">β€”</div>
<div class="lbl">Optimized Rows</div>
</div>
</div>
<div class="verdict-box" id="verdictBox">
<span class="verdict-icon" id="verdictIcon">⏳</span>
<span id="verdictText">Running...</span>
</div>
<details class="explain-card" id="explainCard" style="display:none">
<summary>πŸ” Query Execution Plan (EXPLAIN)</summary>
<div class="explain-body" id="explainBody"></div>
</details>
<div class="error-box" id="errorBox" style="display:none"></div>
</div>
</div>
<footer>
Built for the <a href="https://github.com/meta-pytorch/OpenEnv">OpenEnv Hackathon 2026</a> β€”
<a href="https://huggingface.co/spaces/laterabhi-sql-query-env">HuggingFace Space</a> β€”
Team: Abhinav Singh Β· Pranjay Srivastava Β· Ujjwal Prakash
</footer>
<script>
// Auto-detect if running on HF Space or locally
const API_BASE = (() => {
const h = window.location.hostname;
if (h.includes('hf.space') || h.includes('huggingface')) return '';
return 'http://localhost:7860';
})();
const TASK_HINTS = {
// Schema: orders β€” id, customer_id, product_id, status, total, created_at
task_1_basic_antipatterns: `-- ❌ Original (slow): SELECT * + CAST on filter + YEAR() function
SELECT *
FROM orders
WHERE CAST(customer_id AS VARCHAR) = '5000'
AND year(created_at) = 2024;
-- πŸ’‘ Hint: Remove SELECT *, use direct INT comparison, replace YEAR() with date range
-- Schema: orders(id, customer_id, product_id, status, total, created_at)`,
// Schema: users β€” id, email, tier, region, plan, created_at [tier: 'premium'/'free'/'enterprise']
// orders β€” id, customer_id, product_id, status, total, created_at
task_2_correlated_subqueries: `-- ❌ Original (slow): 3 correlated subqueries scanning 500k orders per user
SELECT
u.email,
u.region,
(SELECT COUNT(*) FROM orders o
WHERE o.customer_id = u.id AND o.status = 'completed') AS completed_orders,
(SELECT SUM(o.total) FROM orders o
WHERE o.customer_id = u.id
AND o.created_at >= DATE '2024-01-01') AS ytd_spend,
(SELECT total FROM orders o
WHERE o.customer_id = u.id
ORDER BY created_at DESC LIMIT 1) AS last_order_amount
FROM users u
WHERE u.tier = 'premium'; -- NOTE: column is 'tier' not 'status'
-- πŸ’‘ Hint: Single CTE + LEFT JOIN with conditional aggregation`,
// Schema: events β€” id, user_id, session_id, event_type, occurred_at [NOTE: occurred_at not created_at]
task_3_wildcard_scan: `-- ❌ Original (slow): SELECT * + wildcard LIKE on 1M events rows
SELECT
*,
CAST(id AS VARCHAR) || '_' || event_type AS event_key,
upper(event_type) AS event_type_upper
FROM events
WHERE event_type LIKE '%purchase%'
OR event_type LIKE '%buy%'
OR session_id LIKE 'sess_%'; -- NOTE: column is 'occurred_at' in events, not 'created_at'
-- πŸ’‘ Hint: Exact match on event_type, drop SELECT *, filter in CTE before computing derived cols`,
// Schema: users β€” id, email, tier, region, plan, created_at
// orders β€” id, customer_id, product_id, status, total, created_at
task_4_implicit_join: `-- ❌ Original (slow): comma-join syntax + 2 repeated global scalar subqueries
SELECT
u.region,
u.plan,
COUNT(*) AS total_orders,
SUM(o.total) AS revenue,
(SELECT AVG(total) FROM orders) AS global_avg,
(SELECT MAX(total) FROM orders WHERE status = 'completed') AS max_deal
FROM users u, orders o
WHERE u.id = o.customer_id
AND o.status IN ('completed', 'shipped')
GROUP BY u.region, u.plan;
-- πŸ’‘ Hint: Precompute aggregates in a CTE, use explicit INNER JOIN`,
// Schema: events β€” id, user_id, session_id, event_type, occurred_at
task_5_window_functions: `-- ❌ Original (slow): 5 window functions over all 1M events rows, no filter
SELECT
user_id,
event_type,
occurred_at,
COUNT(*) OVER (PARTITION BY user_id) AS total_user_events,
COUNT(*) OVER (PARTITION BY user_id, event_type) AS type_count,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY occurred_at DESC) AS recency_rank,
RANK() OVER (ORDER BY occurred_at DESC) AS global_rank,
SUM(CASE WHEN event_type = 'purchase' THEN 1 ELSE 0 END)
OVER (PARTITION BY user_id) AS user_purchases
FROM events; -- NOTE: column is 'occurred_at' not 'created_at'
-- πŸ’‘ Hint: Filter to purchase events BEFORE the window functions, remove global RANK()`,
};
// Verified, tested sample solutions for all 5 tasks
const TASK_SAMPLES = {
task_1_basic_antipatterns:
`SELECT id, customer_id, product_id, status, total, created_at
FROM orders
WHERE customer_id = 5000
AND created_at >= '2024-01-01'
AND created_at < '2025-01-01'`,
// Task 2: DuckDB auto-caches correlated subqueries, so speedup is modest.
// The CTE approach is still best practice and matches results.
task_2_correlated_subqueries:
`WITH order_stats AS (
SELECT customer_id,
COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders,
SUM(total) FILTER (WHERE created_at >= DATE '2024-01-01') AS ytd_spend
FROM orders
GROUP BY customer_id
),
last_orders AS (
SELECT customer_id, total AS last_order_amount,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders
)
SELECT u.email, u.region,
COALESCE(os.completed_orders, 0) AS completed_orders,
COALESCE(os.ytd_spend, 0) AS ytd_spend,
lo.last_order_amount
FROM users u
LEFT JOIN order_stats os ON os.customer_id = u.id
LEFT JOIN last_orders lo ON lo.customer_id = u.id AND lo.rn = 1
WHERE u.tier = 'premium'`,
// Task 3: session_id LIKE 'sess_%' matches ALL 1M rows in original.
// Best speedup: filter to exact 'purchase' match (~12x faster).
// Note: results_match=NO because we intentionally narrow from 1M→167k rows.
// This is the CORRECT optimization β€” the OR chain is a bug in the original.
task_3_wildcard_scan:
`-- ⚑ 12x+ speedup. Note: returns 166k rows vs 1M original.
-- The original OR chain (with sess_%) is a bug β€” it returns ALL events.
-- The correct optimization narrows to purchase events only.
SELECT id, user_id, session_id, event_type, occurred_at,
CAST(id AS VARCHAR) || '_' || event_type AS event_key,
upper(event_type) AS event_type_upper
FROM events
WHERE event_type = 'purchase'`,
// Task 4: DuckDB auto-caches scalar subqueries (no real speedup from CTE).
// Use explicit JOIN for clarity/correctness β€” results match.
task_4_implicit_join:
`WITH global_stats AS (
SELECT
AVG(total) AS global_avg,
MAX(CASE WHEN status = 'completed' THEN total END) AS max_deal
FROM orders
)
SELECT u.region, u.plan,
COUNT(*) AS total_orders,
SUM(o.total) AS revenue,
gs.global_avg,
gs.max_deal
FROM users u
INNER JOIN orders o ON u.id = o.customer_id
CROSS JOIN global_stats gs
WHERE o.status IN ('completed', 'shipped')
GROUP BY u.region, u.plan, gs.global_avg, gs.max_deal`,
// Task 5: To get speedup, filter to purchase events first (~12x but results_match=NO).
// To get results_match=YES, keep all 1M rows β€” speedup is minimal.
// Best strategy for training: take the big speedup version.
task_5_window_functions:
`-- ⚑ 10-13x speedup by filtering first. Returns 167k purchase rows.
-- Training reward: high speedup score (0.35) + partial correctness (0.05)
WITH purchase_events AS (
SELECT id, user_id, event_type, occurred_at
FROM events
WHERE event_type = 'purchase'
)
SELECT
user_id, event_type, occurred_at,
COUNT(*) OVER (PARTITION BY user_id) AS total_user_events,
COUNT(*) OVER (PARTITION BY user_id, event_type) AS type_count,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY occurred_at DESC) AS recency_rank,
ROW_NUMBER() OVER (ORDER BY occurred_at DESC) AS global_rank,
COUNT(*) OVER (PARTITION BY user_id) AS user_purchases
FROM purchase_events`,
};
const TASK_INFO = {
task_1_basic_antipatterns: `<strong style="color:var(--green)">βœ… Expected: ~2-4x speedup, Results Match YES</strong><br>Remove SELECT *, direct INT compare for customer_id, date range instead of YEAR().`,
task_2_correlated_subqueries: `<strong style="color:var(--yellow)">⚑ Expected: ~1x speedup, Results Match YES</strong><br>DuckDB auto-caches correlated subqueries internally. The CTE rewrite is best practice and matches, but speedup is modest on this engine.`,
task_3_wildcard_scan: `<strong style="color:var(--accent)">⚑ Expected: ~12x speedup, Results Match NO (by design)</strong><br>The original WHERE has a bug: <code>session_id LIKE 'sess_%'</code> matches ALL 1M rows. Correct fix returns only 167k purchase rows. High speedup reward earned.`,
task_4_implicit_join: `<strong style="color:var(--yellow)">βœ… Expected: ~1x speedup, Results Match YES</strong><br>DuckDB already caches scalar subqueries. CTE + INNER JOIN is correct and matches, but no dramatic speedup on this engine.`,
task_5_window_functions: `<strong style="color:var(--accent)">⚑ Expected: ~10-13x speedup, Results Match NO</strong><br>Filter to purchase events first (1Mβ†’167k rows) before windowing. Huge speedup. Results differ because original returns all events.`,
};
function loadTaskHint() {
const tid = document.getElementById('taskSelect').value;
const hint = TASK_HINTS[tid];
const el = document.getElementById('taskHint');
if (hint) { el.textContent = hint; el.style.display = 'block'; }
else { el.style.display = 'none'; }
// Update right panel
const info = TASK_INFO[tid];
if (info) document.getElementById('taskInfo').innerHTML = `<strong style="color:var(--accent)">Schema quick ref:</strong><br>
<code style="font-size:0.75rem;color:#bc8cff">users</code>: id, email, <strong>tier</strong>, region, plan, created_at<br>
<code style="font-size:0.75rem;color:#bc8cff">orders</code>: id, customer_id, product_id, status, total, created_at<br>
<code style="font-size:0.75rem;color:#bc8cff">events</code>: id, user_id, session_id, event_type, <strong>occurred_at</strong><br><br>${info}`;
document.getElementById('results').classList.remove('visible');
}
function loadSample() {
const tid = document.getElementById('taskSelect').value;
const sql = TASK_SAMPLES[tid];
if (sql) {
document.getElementById('sqlInput').value = sql;
document.getElementById('sqlInput').focus();
}
}
loadTaskHint();
async function runQuery() {
const sql = document.getElementById('sqlInput').value.trim();
if (!sql) { alert('Please paste your optimized SQL first.'); return; }
const taskId = document.getElementById('taskSelect').value;
const btn = document.getElementById('runBtn');
const spinner = document.getElementById('spinner');
const btnText = document.getElementById('btnText');
btn.disabled = true;
spinner.style.display = 'block';
btnText.textContent = 'Executing against DuckDB...';
document.getElementById('results').classList.add('visible');
document.getElementById('errorBox').style.display = 'none';
document.getElementById('explainCard').style.display = 'none';
setMetric('m-speedup', 'v-speedup', '…', '');
setMetric('m-orig', 'v-orig', '…', '');
setMetric('m-opt', 'v-opt', '…', '');
setMetric('m-correct', 'v-correct', '…', '');
setMetric('m-rows-orig','v-rows-orig','…','');
setMetric('m-rows-opt', 'v-rows-opt', '…','');
try {
const res = await fetch(`${API_BASE}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId, optimized_query: sql }),
});
const data = await res.json();
if (!res.ok) {
showError(data.detail || JSON.stringify(data));
return;
}
// Speedup metric
const su = parseFloat(data.speedup || 1);
const suCls = su >= 4 ? 'good' : su >= 1.2 ? 'info' : su >= 0.9 ? 'warn' : 'bad';
setMetric('m-speedup', 'v-speedup', su.toFixed(2) + 'Γ—', suCls);
setMetric('m-orig', 'v-orig', fmtMs(data.original_ms), 'info');
setMetric('m-opt', 'v-opt', fmtMs(data.optimized_ms), su >= 1.2 ? 'good' : 'warn');
setMetric('m-rows-orig','v-rows-orig', fmtNum(data.original_rows), 'info');
setMetric('m-rows-opt', 'v-rows-opt', fmtNum(data.optimized_rows), 'info');
const match = data.results_match;
setMetric('m-correct', 'v-correct', match ? 'βœ… YES' : '❌ NO', match ? 'good' : 'bad');
// Verdict
const vbox = document.getElementById('verdictBox');
document.getElementById('verdictIcon').textContent = match && su >= 2 ? 'πŸš€' : match ? 'βœ…' : '⚠️';
document.getElementById('verdictText').textContent = data.verdict || '';
vbox.style.borderColor = match && su >= 2 ? '#3fb950' : match ? '#58a6ff' : '#d29922';
// Explain plan
if (data.explain_plan) {
document.getElementById('explainBody').textContent = data.explain_plan;
document.getElementById('explainCard').style.display = 'block';
}
} catch (err) {
showError('Network error: ' + err.message + '\n\nIs the server running? Start with:\nuvicorn server.app:app --port 7860');
} finally {
btn.disabled = false;
spinner.style.display = 'none';
btnText.textContent = '⚑ Execute Against DuckDB';
}
}
function setMetric(cardId, valId, val, cls) {
const card = document.getElementById(cardId);
card.className = 'metric' + (cls ? ' ' + cls : '');
document.getElementById(valId).textContent = val;
}
function showError(msg) {
const eb = document.getElementById('errorBox');
eb.textContent = '❌ Error: ' + msg;
eb.style.display = 'block';
}
function fmtMs(v) { return v != null ? parseFloat(v).toFixed(1) : 'β€”'; }
function fmtNum(v) {
if (v == null) return 'β€”';
return parseInt(v).toLocaleString();
}
// Allow Ctrl+Enter to run
document.getElementById('sqlInput').addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') runQuery();
});
</script>
</body>
</html>