sql-quality-env / static /index.html
Deadman922's picture
Upload 16 files
3551dcf verified
Raw History Blame Contribute Delete
57.7 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>SQL Quality Environment</title> <meta name="description" content="OpenEnv-compatible SQL data quality audit environment for AI agent training and evaluation"> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'DM Sans', sans-serif; background: #F8F9FA; color: #18181B; min-height: 100vh; } .header { height: 56px; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); display: flex; align-items: center; justify-content: space-between; padding: 0 24px; position: fixed; top: 0; left: 0; right: 0; z-index: 100; box-shadow: 0 2px 8px rgba(0,0,0,0.15); } .logo { font-family: 'DM Mono', monospace; font-weight: 500; font-size: 16px; color: #E0E7FF; letter-spacing: -0.5px; } .header-center { display: flex; align-items: center; gap: 12px; } .task-select { font-family: 'DM Sans', sans-serif; padding: 6px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; font-size: 14px; background: rgba(255,255,255,0.1); color: #E0E7FF; cursor: pointer; transition: all 0.2s; } .task-select:hover { background: rgba(255,255,255,0.15); } .task-select option { background: #1a1a2e; color: #E0E7FF; } .btn { font-family: 'DM Sans', sans-serif; padding: 7px 18px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.25s ease; position: relative; overflow: hidden; } .btn-primary { background: linear-gradient(135deg, #2D6A4F 0%, #40916C 100%); color: #FFFFFF; box-shadow: 0 2px 6px rgba(45,106,79,0.3); } .btn-primary:hover { background: linear-gradient(135deg, #40916C 0%, #52B788 100%); box-shadow: 0 4px 12px rgba(45,106,79,0.4); transform: translateY(-1px); } .btn-primary:active { transform: translateY(0); } .btn-primary:disabled { background: #D1D5DB; color: #9CA3AF; cursor: not-allowed; box-shadow: none; transform: none; } .btn-danger { background: linear-gradient(135deg, #DC2626 0%, #EF4444 100%); color: #FFFFFF; } .btn-danger:hover { background: linear-gradient(135deg, #EF4444 0%, #F87171 100%); } .status-indicator { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: #E0E7FF; } .status-dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .status-ready .status-dot { background: #22C55E; } .status-active .status-dot { background: #F59E0B; animation: pulse 1s infinite; } .status-error .status-dot { background: #EF4444; } .main-container { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 76px 24px 80px; max-width: 1600px; margin: 0 auto; } .panel { background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); transition: box-shadow 0.3s; } .panel:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); } .panel-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 2px solid #E5E7EB; } .zone-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #6B7280; margin-bottom: 6px; } .upload-section { margin-bottom: 20px; } .file-input-wrapper { display: flex; gap: 12px; align-items: center; } .file-input { display: none; } .file-input-label { font-family: 'DM Sans', sans-serif; padding: 8px 16px; border: 1.5px dashed #D1D5DB; border-radius: 8px; font-size: 14px; cursor: pointer; background: #FAFAFA; display: inline-block; transition: all 0.2s; } .file-input-label:hover { border-color: #2D6A4F; background: #F0FDF4; color: #2D6A4F; } .file-name { font-size: 13px; color: #6B7280; } .schema-panel { min-height: 100px; max-height: 300px; overflow-y: auto; } .schema-empty { font-size: 13px; color: #9CA3AF; font-style: italic; } .schema-table-block { margin-bottom: 14px; padding: 12px; background: #F9FAFB; border-radius: 8px; border: 1px solid #F3F4F6; } .schema-table-name { font-family: 'DM Mono', monospace; font-weight: 500; font-size: 13px; color: #111827; margin-bottom: 4px; } .schema-table-meta { font-size: 12px; color: #6B7280; margin-bottom: 8px; } .schema-columns { display: flex; flex-wrap: wrap; gap: 6px; } .schema-column { font-family: 'DM Mono', monospace; font-size: 11px; padding: 4px 10px; background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: 6px; cursor: pointer; transition: all 0.2s; -webkit-user-select: none; user-select: none; } .schema-column:hover { background: linear-gradient(135deg, #2D6A4F, #40916C); color: #FFFFFF; border-color: #2D6A4F; transform: translateY(-1px); box-shadow: 0 2px 6px rgba(45,106,79,0.3); } .query-section { margin-top: 20px; } .section-label { font-size: 13px; font-weight: 600; color: #111827; margin-bottom: 8px; } .sql-textarea { font-family: 'DM Mono', monospace; width: 100%; min-height: 80px; padding: 12px; border: 1.5px solid #E5E7EB; border-radius: 8px; font-size: 13px; resize: vertical; transition: border-color 0.2s, box-shadow 0.2s; line-height: 1.5; } .sql-textarea:focus { outline: none; border-color: #2D6A4F; box-shadow: 0 0 0 3px rgba(45,106,79,0.1); } .query-controls { display: flex; gap: 10px; margin-top: 12px; } .result-panel { margin-top: 16px; min-height: 80px; max-height: 400px; overflow: auto; border-radius: 8px; } .result-table { width: 100%; border-collapse: collapse; font-size: 12px; } .result-table th, .result-table td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #F3F4F6; font-family: 'DM Mono', monospace; } .result-table th { background: #F9FAFB; font-weight: 600; color: #374151; position: sticky; top: 0; } .result-table tr:nth-child(even) { background: #FAFAFA; } .result-table tr:hover { background: #F0FDF4; } .result-empty { font-size: 13px; color: #9CA3AF; font-style: italic; padding: 20px; } .error-box { padding: 12px 16px; background: #FEF2F2; border: 1px solid #FECACA; border-radius: 8px; color: #DC2626; font-size: 13px; margin-top: 12px; } .success-box { padding: 12px 16px; background: #F0FDF4; border: 1px solid #BBF7D0; border-radius: 8px; color: #166534; font-size: 13px; margin-top: 12px; animation: fadeIn 0.3s ease; } .warning-box { padding: 12px 16px; background: #FFFBEB; border: 1px solid #FDE68A; border-radius: 8px; color: #92400E; font-size: 13px; margin-top: 12px; } @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } .task-description { font-size: 13px; color: #6B7280; margin: 12px 0; line-height: 1.6; font-weight: 500; padding: 10px 14px; background: #F9FAFB; border-radius: 8px; border-left: 3px solid #D1D5DB; } .task-description.active { color: #F97316; background: #FFF7ED; border-left-color: #F97316; } .fix-context-label { font-size: 12px; color: #6B7280; font-style: italic; margin-bottom: 8px; } .insights-panel { margin-top: 16px; padding: 14px; background: linear-gradient(135deg, #F8FAFC, #F1F5F9); border-radius: 8px; min-height: 80px; border: 1px solid #E2E8F0; } .insights-empty { font-size: 13px; color: #94A3B8; font-style: italic; } .insight-item { font-size: 12px; color: #1E293B; margin-bottom: 6px; padding: 4px 0; line-height: 1.5; } .insight-item::before { content: 'β†’ '; color: #2D6A4F; font-weight: bold; } .ai-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 700; color: #7C3AED; background: #EDE9FE; padding: 2px 8px; border-radius: 10px; margin-left: 8px; letter-spacing: 0.5px; } .suggestions-panel { margin-top: 16px; } .suggestion-item { padding: 10px 14px; background: #F0FDF4; border: 1px solid #BBF7D0; border-radius: 8px; margin-bottom: 8px; font-size: 12px; color: #166534; cursor: pointer; transition: all 0.2s; } .suggestion-item:hover { background: #DCFCE7; transform: translateX(2px); } .results-panel { margin-top: 16px; padding: 20px; background: linear-gradient(135deg, #F8FAFC, #F0FDF4); border-radius: 10px; border: 1px solid #D1FAE5; } .score-display { font-size: 42px; font-weight: 700; background: linear-gradient(135deg, #2D6A4F, #16A34A); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin-bottom: 16px; } .dimension-bar { margin-bottom: 10px; } .dimension-label { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; font-weight: 500; } .dimension-track { height: 8px; background: #E5E7EB; border-radius: 4px; overflow: hidden; } .dimension-fill { height: 100%; background: linear-gradient(90deg, #2D6A4F, #40916C); border-radius: 4px; transition: width 0.6s ease; } .feedback-text { font-size: 13px; color: #374151; margin-top: 14px; line-height: 1.6; padding: 10px; background: #FFFFFF; border-radius: 6px; } .extra-fields { margin-top: 12px; display: flex; gap: 12px; } .extra-field { flex: 1; } .extra-field label { display: block; font-size: 12px; color: #6B7280; margin-bottom: 4px; font-weight: 500; } .extra-field input { font-family: 'DM Mono', monospace; width: 100%; padding: 6px 10px; border: 1.5px solid #E5E7EB; border-radius: 8px; font-size: 13px; transition: border-color 0.2s; } .extra-field input:focus { outline: none; border-color: #2D6A4F; } /* Toast notification */ .toast-bar { position: fixed; bottom: 0; left: 0; right: 0; height: 40px; background: linear-gradient(135deg, #1a1a2e, #16213e); color: #E0E7FF; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 500; z-index: 200; transform: translateY(100%); transition: transform 0.3s ease; gap: 8px; } .toast-bar.visible { transform: translateY(0); } .toast-bar.success { background: linear-gradient(135deg, #166534, #15803D); } .toast-bar.error { background: linear-gradient(135deg, #991B1B, #DC2626); } .toast-bar.info { background: linear-gradient(135deg, #1E40AF, #3B82F6); } /* Loading spinner */ .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .btn-loading { pointer-events: none; opacity: 0.8; } .btn-loading .spinner { margin-right: 6px; } /* Chart container */ .chart-container { margin-top: 16px; padding: 16px; background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: 8px; min-height: 120px; } .chart-container canvas { width: 100% !important; } /* Utility classes to replace inline styles */ .hidden { display: none !important; } .mt-20 { margin-top: 20px; } .mb-16 { margin-bottom: 16px; } @media (max-width: 1024px) { .main-container { grid-template-columns: 1fr; } } </style> </head> <body> <header class="header"> <div class="logo">SQL QUALITY ENV</div> <div class="header-center hidden" id="header-task-section"> <select class="task-select" id="task-select"> <option value="">Select a task...</option> <option value="null_audit">null_audit</option> <option value="type_repair">type_repair</option> <option value="dedup_bug">dedup_bug</option> <option value="fk_integrity">fk_integrity</option> <option value="schema_drift">schema_drift</option> </select> <button class="btn btn-primary" id="init-btn" onclick="initializeEpisode()" disabled>Initialize</button> </div> <div class="status-indicator status-ready" id="status-indicator"> <span class="status-dot"></span> <span id="status-text">Ready</span> </div> </header> <div class="main-container"> <div class="panel"> <div class="zone-label">Zone 1 β€” Data Zone</div> <div class="panel-title">Upload & Query Your Data</div> <div class="upload-section"> <div class="file-input-wrapper"> <input type="file" class="file-input" id="file-input" accept=".csv,.sqlite,.db"> <label class="file-input-label" for="file-input">Choose File</label> <span class="file-name" id="file-name">No file selected</span> <button class="btn btn-primary" id="upload-btn" onclick="uploadFile()">Upload</button> </div> </div> <div class="schema-panel" id="schema-panel"> <div class="schema-empty">Upload a file to see its schema</div> </div> <div class="query-section"> <div class="section-label">Run a SELECT query on your uploaded data</div> <textarea class="sql-textarea" id="query-sql" placeholder="SELECT * FROM your_table LIMIT 10"></textarea> <div class="query-controls"> <button class="btn btn-primary" id="run-query-btn" onclick="runQuery()">Run Query</button> </div> <div id="query-error"></div> <div class="result-panel" id="query-result"> <div class="result-empty">Query results will appear here</div> </div> </div> <!-- Data Quality Chart --> <div class="chart-container hidden" id="chart-container"> <div class="zone-label">Query Result Completeness</div> <canvas id="quality-chart" height="140"></canvas> </div> <div class="insights-panel" id="insights-panel"> <div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div> <div class="insights-empty">Run a query to see insights</div> </div> <div class="suggestions-panel" id="suggestions-panel"> <div class="zone-label">Smart Task Suggestions</div> <div class="insights-empty">Upload data and run a query to see suggestions</div> </div> </div> <div class="panel"> <div class="zone-label">Zone 2 β€” Evaluation Zone</div> <div class="panel-title">RL Task Evaluation</div> <div class="task-description" id="task-description"> Select a task from the dropdown above and click Initialize to start an evaluation episode. </div> <div id="rl-schema-panel" class="mb-16"> <div class="schema-empty">Task schema will appear after initialization</div> </div> <div class="mt-20"> <div class="section-label" id="task-input-label">Submit Your Answer</div> <div class="fix-context-label" id="fix-context">Select a task to see context</div> <div class="extra-fields hidden" id="dedup-fields"> <div class="extra-field"> <label>Root Cause Table</label> <input type="text" id="root-cause-table" placeholder="table_name"> </div> <div class="extra-field"> <label>Offending Key Column</label> <input type="text" id="offending-key" placeholder="column_name"> </div> </div> <textarea class="sql-textarea" id="fix-sql" placeholder="Enter your fix query or answer here..."></textarea> <div class="query-controls"> <button class="btn btn-danger" id="apply-fix-btn" onclick="applyFix()">Preview Fix</button> <button class="btn btn-primary" id="submit-final-btn" onclick="submitFinal()">Submit Final</button> </div> <div id="fix-error"></div> </div> <div class="results-panel hidden" id="results-panel"> <div class="score-display" id="final-score">0%</div> <div id="dimension-bars"></div> <div class="feedback-text" id="feedback-text"></div> </div> </div> </div> <!-- Toast bar for status messages --> <div class="toast-bar" id="toast-bar"> <span id="toast-text"></span> </div> <script> const BASE = window.location.origin; let uploadedFile = null; let uploadedSchema = []; let uploadedTableName = null; let episodeId = null; let currentTask = null; let currentTaskContext = null; let lastQueryResult = null; let lastQuerySQL = null; let uploadedTaskContexts = {}; let mode = 'upload'; const taskDescriptions = { null_audit: 'Find the column with the highest null rate in the dataset.', type_repair: 'Fix numeric type mismatches where numbers are stored as TEXT.', dedup_bug: 'Remove duplicate rows that are causing JOIN inflation.', fk_integrity: 'Remove orphan records that violate foreign key constraints.', schema_drift: 'Normalize drifted schemas by canonicalizing column names and types.' }; const fixContexts = { null_audit: 'Enter the column name with the highest null rate', type_repair: 'Enter a SQL query that fixes the type mismatch', dedup_bug: 'Enter a SQL query that removes duplicate rows + table name + key column', fk_integrity: 'Enter a SQL query that removes orphan records', schema_drift: 'Enter a SQL query that normalizes the schema' }; function isAnswerOnlyTask(taskId) { return taskId === 'null_audit'; } function showWarning(elementId, messages) { const items = Array.isArray(messages) ? messages.filter(Boolean) : [messages]; if (!items.length) { document.getElementById(elementId).innerHTML = ''; return; } document.getElementById(elementId).innerHTML = '<div class="warning-box">' + items.map(escapeHtml).join('<br>') + '</div>'; } function resetTaskUIState() { episodeId = null; currentTaskContext = null; document.getElementById('fix-sql').value = ''; document.getElementById('fix-sql').placeholder = 'Enter your fix query or answer here...'; document.getElementById('root-cause-table').value = ''; document.getElementById('offending-key').value = ''; document.getElementById('fix-error').innerHTML = ''; document.getElementById('results-panel').classList.add('hidden'); document.getElementById('rl-schema-panel').innerHTML = '<div class="schema-empty">Task schema will appear after initialization</div>'; document.getElementById('task-description').textContent = 'Select a task from the dropdown above and click Initialize to start an evaluation episode.'; document.getElementById('task-description').classList.remove('active'); document.getElementById('fix-context').textContent = 'Select a task to see context'; document.getElementById('task-input-label').textContent = 'Submit Your Answer'; document.getElementById('apply-fix-btn').textContent = 'Preview Fix'; document.getElementById('submit-final-btn').textContent = 'Submit Final'; } function updateTaskMode(taskId, taskContext) { const inputLabel = document.getElementById('task-input-label'); const applyBtn = document.getElementById('apply-fix-btn'); const submitBtn = document.getElementById('submit-final-btn'); const textarea = document.getElementById('fix-sql'); const answerMode = isAnswerOnlyTask(taskId) || (taskContext && taskContext.input_mode === 'answer'); inputLabel.textContent = answerMode ? 'Submit Column Answer' : 'Submit Fix SQL'; applyBtn.textContent = answerMode ? 'Check Answer' : 'Preview Fix'; submitBtn.textContent = answerMode ? 'Submit Answer' : 'Submit Final'; if (taskContext && taskContext.input_placeholder) { textarea.placeholder = taskContext.input_placeholder; } else { textarea.placeholder = answerMode ? 'Enter the exact column name' : 'Enter the SQL fix you want graded on the cloned database'; } } function renderBackendSuggestions(suggestions) { if (!suggestions || suggestions.length === 0) return false; renderSuggestions(suggestions); return true; } function isAggregateLikeColumn(col) { const tokens = String(col || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); const aggregateTokens = new Set(['avg', 'sum', 'count', 'total', 'min', 'max', 'rank', 'highest', 'lowest']); return tokens.some(token => aggregateTokens.has(token)); } function isKeyLikeColumn(col) { const tokens = String(col || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); const keyTokens = new Set(['id', 'email', 'phone', 'ssn', 'uuid', 'employee', 'patient']); return tokens.some(token => keyTokens.has(token)); } // ── Toast notification system ── let toastTimer = null; function showToast(message, type = 'info', duration = 4000) { const bar = document.getElementById('toast-bar'); const text = document.getElementById('toast-text'); bar.className = 'toast-bar visible ' + type; text.textContent = message; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { bar.classList.remove('visible'); }, duration); } // ── Loading state helpers ── function setLoading(btnId, loading) { const btn = document.getElementById(btnId); if (!btn) return; if (loading) { btn._origText = btn.textContent; btn.innerHTML = '<span class="spinner"></span>' + btn._origText + '...'; btn.classList.add('btn-loading'); } else { // Explicitly check for undefined to handle intentionally empty labels if (btn._origText !== undefined) { btn.textContent = btn._origText; } else { // Fallback: strip spinner HTML and ellipsis btn.textContent = btn.textContent.replace(/<span class="spinner"><\/span>/g, '').replace(/\.\.\.$/, ''); } btn.classList.remove('btn-loading'); } } // ── Health check ── async function checkHealth() { try { const r = await fetch(BASE + '/health'); const indicator = document.getElementById('status-indicator'); const text = document.getElementById('status-text'); if (r.ok) { if (uploadedFile || episodeId) { indicator.className = 'status-indicator status-active'; text.textContent = 'Active'; } else { indicator.className = 'status-indicator status-ready'; text.textContent = 'Ready'; } } else { indicator.className = 'status-indicator status-error'; text.textContent = 'Error'; } } catch (e) { document.getElementById('status-indicator').className = 'status-indicator status-error'; document.getElementById('status-text').textContent = 'Offline'; } } // ── File input handler ── document.getElementById('file-input').addEventListener('change', function() { const file = this.files[0]; document.getElementById('file-name').textContent = file ? file.name : 'No file selected'; }); // ── Task select handler ── function updateTaskAvailability(taskContexts, compatibleTasks) { uploadedTaskContexts = taskContexts || {}; const supported = new Set(compatibleTasks || []); const select = document.getElementById('task-select'); for (const option of Array.from(select.options)) { if (!option.value) continue; const ctx = uploadedTaskContexts[option.value]; const enabled = supported.has(option.value); option.disabled = !enabled; option.textContent = enabled ? option.value : option.value + ' (not supported)'; option.title = (ctx && (ctx.context_hint || ctx.reason)) || ''; } } document.getElementById('task-select').addEventListener('change', function() { currentTask = this.value; currentTaskContext = uploadedTaskContexts[currentTask] || null; document.getElementById('fix-sql').value = ''; document.getElementById('fix-error').innerHTML = ''; document.getElementById('init-btn').disabled = !currentTask; const descEl = document.getElementById('task-description'); const taskContext = currentTaskContext; if (currentTask) { if (taskContext && taskContext.reason && taskContext.supported === false) { descEl.textContent = taskContext.reason; } else if (taskContext && taskContext.context_hint) { descEl.textContent = taskContext.context_hint; } else { descEl.textContent = taskDescriptions[currentTask]; } descEl.classList.add('active'); } else { descEl.textContent = 'Select a task from the dropdown above and click Initialize to start an evaluation episode.'; descEl.classList.remove('active'); } document.getElementById('fix-context').textContent = currentTask ? ((taskContext && (taskContext.action_guidance || taskContext.context_hint)) || fixContexts[currentTask]) : 'Select a task to see context'; updateTaskMode(currentTask, taskContext); const dedupFields = document.getElementById('dedup-fields'); if (currentTask === 'dedup_bug') { dedupFields.classList.remove('hidden'); if (taskContext && taskContext.table) { document.getElementById('root-cause-table').value = taskContext.table; } if (taskContext && taskContext.key_columns && taskContext.key_columns.length > 0) { document.getElementById('offending-key').value = taskContext.key_columns.join(', '); } else if (taskContext && taskContext.column) { document.getElementById('offending-key').value = taskContext.column; } } else { dedupFields.classList.add('hidden'); } if (taskContext && taskContext.supported === false) { document.getElementById('init-btn').disabled = true; } }); // ── File upload ── async function uploadFile() { const fileInput = document.getElementById('file-input'); const file = fileInput.files[0]; if (!file) { showError('query-error', 'Please select a file first.'); return; } setLoading('upload-btn', true); const formData = new FormData(); formData.append('file', file); try { const r = await fetch(BASE + '/upload-db', { method: 'POST', body: formData }); const data = await r.json(); if (!r.ok) { showError('query-error', data.detail || 'Upload failed'); return; } uploadedFile = file.name; uploadedSchema = data.tables || []; uploadedTableName = uploadedSchema.length > 0 ? uploadedSchema[0].table_name : null; uploadedTaskContexts = data.task_contexts || {}; mode = 'upload'; resetTaskUIState(); currentTask = null; document.getElementById('task-select').value = ''; renderSchema(data.tables, 'schema-panel', 'query-sql'); updateTaskAvailability(data.task_contexts || {}, data.compatible_tasks || []); document.getElementById('header-task-section').classList.remove('hidden'); document.getElementById('query-error').innerHTML = ''; document.getElementById('query-result').innerHTML = '<div class="result-empty">Run a query to see results</div>'; document.getElementById('insights-panel').innerHTML = '<div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div><div class="insights-empty">Run a query to see source-aware insights</div>'; document.getElementById('suggestions-panel').innerHTML = '<div class="zone-label">Smart Task Suggestions</div><div class="insights-empty">Run a query to see backend-ranked task suggestions</div>'; // Auto-populate query with table name if (uploadedTableName) { document.getElementById('query-sql').value = 'SELECT * FROM ' + uploadedTableName + ' LIMIT 10'; } showToast('βœ“ File uploaded: ' + file.name + ' (' + uploadedSchema.length + ' table(s))', 'success'); checkHealth(); } catch (e) { showError('query-error', 'Upload error: ' + e.message); } finally { setLoading('upload-btn', false); } } // ── Schema renderer (reusable for Zone 1 and Zone 2) ── function renderSchema(tables, panelId, targetTextareaId) { const panel = document.getElementById(panelId); if (!tables || tables.length === 0) { panel.innerHTML = '<div class="schema-empty">No schema available</div>'; return; } let html = ''; for (const tbl of tables) { const tableName = tbl.table_name || tbl.name || 'Unknown'; const rowCount = tbl.row_count ?? '?'; const columns = tbl.columns || []; html += '<div class="schema-table-block">'; html += '<div class="schema-table-name">' + escapeHtml(tableName) + '</div>'; html += '<div class="schema-table-meta">' + rowCount + ' rows</div>'; html += '<div class="schema-columns">'; for (const col of columns) { const colName = typeof col === 'string' ? col.split(' (')[0] : (col.name || col); const colType = typeof col === 'string' ? (col.match(/\(([^)]+)\)/)?.[1] || 'TEXT') : (col.type || 'TEXT'); html += '<span class="schema-column" data-col="' + escapeHtml(colName) + '" data-target="' + targetTextareaId + '">' + escapeHtml(colName) + ' <small>(' + escapeHtml(colType) + ')</small></span>'; } html += '</div></div>'; } panel.innerHTML = html; // Add delegated click listener for schema columns (only once) if (!panel._hasSchemaListener) { panel.addEventListener('click', function(e) { if (e.target.classList.contains('schema-column')) { const col = e.target.getAttribute('data-col'); const target = e.target.getAttribute('data-target'); insertColumnTo(col, target); } }); panel._hasSchemaListener = true; } } // ── Insert column name into any textarea ── function insertColumnTo(columnName, textareaId) { const textarea = document.getElementById(textareaId); if (!textarea) return; const cursorPos = textarea.selectionStart; const textBefore = textarea.value.substring(0, cursorPos); const textAfter = textarea.value.substring(cursorPos); const escapedName = columnName.replace(/"/g, '""'); textarea.value = textBefore + '"' + escapedName + '"' + textAfter; textarea.focus(); showToast('Inserted column: ' + columnName, 'info', 1500); } // ── BUG FIX #1: Allow WITH/CTE queries ── function isReadOnlySQL(sql) { const trimmed = sql.trim().toUpperCase(); // Remove leading comments const noComments = trimmed.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '').trim(); const firstWord = noComments.split(/\s+/)[0] || ''; return ['SELECT', 'WITH', 'EXPLAIN', 'PRAGMA'].includes(firstWord); } // ── Run Query ── async function runQuery() { const sql = document.getElementById('query-sql').value.trim(); if (!uploadedFile) { showError('query-error', 'Upload a file first.'); return; } if (!sql) { showError('query-error', 'Enter a SQL query.'); return; } // BUG FIX #1: Use the proper check that allows WITH/CTE queries if (!isReadOnlySQL(sql)) { showError('query-error', 'Only SELECT queries allowed here. Supported prefixes: SELECT, WITH, EXPLAIN, PRAGMA.'); return; } setLoading('run-query-btn', true); try { const r = await fetch(BASE + '/query-custom', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sql }) }); const data = await r.json(); if (data.error) { showError('query-error', data.error + (data.message ? ' β€” ' + data.message : '')); if (data.warnings && data.warnings.length > 0) { document.getElementById('query-error').innerHTML += '<div class="warning-box">' + data.warnings.map(escapeHtml).join('<br>') + '</div>'; } if (!renderBackendSuggestions(data.task_suggestions || [])) { if (data.suggestions) { renderSuggestions(data.suggestions); } } return; } if (!r.ok) { showError('query-error', data.detail || 'Query failed'); return; } document.getElementById('query-error').innerHTML = ''; lastQueryResult = data.rows || []; lastQuerySQL = sql; renderQueryResults(lastQueryResult); showWarning('query-error', data.warnings || []); // Draw chart drawQualityChart(lastQueryResult); // Get AI insights (async - don't block) fetchAIInsights(sql, lastQueryResult); // Prefer backend task suggestions over result-grid heuristics. if (!renderBackendSuggestions(data.task_suggestions || [])) { generateSuggestions(lastQueryResult); } showToast('Query returned ' + lastQueryResult.length + ' row(s)', 'success', 2000); } catch (e) { showError('query-error', 'Query error: ' + e.message); } finally { setLoading('run-query-btn', false); } } // ── Render query results table ── function renderQueryResults(rows) { const panel = document.getElementById('query-result'); if (!rows || rows.length === 0) { panel.innerHTML = '<div class="result-empty">No results returned</div>'; return; } const columns = Object.keys(rows[0]); let html = '<table class="result-table"><thead><tr>'; for (const col of columns) { html += '<th>' + escapeHtml(col) + '</th>'; } html += '</tr></thead><tbody>'; for (const row of rows.slice(0, 100)) { html += '<tr>'; for (const col of columns) { const val = row[col]; const display = val === null ? '<em style="color:#9CA3AF">NULL</em>' : escapeHtml(String(val)); html += '<td>' + display + '</td>'; } html += '</tr>'; } html += '</tbody></table>'; if (rows.length > 100) html += '<div style="padding:8px;font-size:12px;color:#6B7280;">Showing 100 of ' + rows.length + ' rows</div>'; panel.innerHTML = html; } // ── BUG FIX #2: AI Insights via Groq API endpoint ── async function fetchAIInsights(sql, rows) { const panel = document.getElementById('insights-panel'); panel.innerHTML = '<div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div><div class="insights-empty"><span class="spinner" style="border-color:rgba(0,0,0,0.1);border-top-color:#2D6A4F;width:12px;height:12px;"></span> Analyzing data...</div>'; try { const r = await fetch(BASE + '/ai-insights', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sql: sql, rows: rows.slice(0, 20), row_count: rows.length, columns: rows.length > 0 ? Object.keys(rows[0]) : [] }) }); if (r.ok) { const data = await r.json(); const insights = data.insights || []; if (insights.length > 0) { let html = '<div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div>'; for (const insight of insights) { html += '<div class="insight-item">' + escapeHtml(insight) + '</div>'; } panel.innerHTML = html; return; } } } catch (e) { console.log('AI insights endpoint not available, using local analysis'); } // Fallback: enhanced local insights computeLocalInsights(rows); } // ── Enhanced local insights (fallback) ── function computeLocalInsights(rows) { const panel = document.getElementById('insights-panel'); if (!rows || rows.length === 0) { panel.innerHTML = '<div class="zone-label">AI Insights <span class="ai-badge">✦ AI</span></div><div class="insights-empty">Query returned no data. Try a broader query or check table name.</div>'; return; } const columns = Object.keys(rows[0]); const insights = []; insights.push('Query returned ' + rows.length + ' rows across ' + columns.length + ' columns'); const rankedContexts = Object.entries(uploadedTaskContexts) .filter(([, ctx]) => ctx && ctx.supported) .sort((a, b) => (b[1].confidence || 0) - (a[1].confidence || 0)); if (rankedContexts.length > 0) { insights.push('Source signal: ' + (rankedContexts[0][1].context_hint || rankedContexts[0][1].description || rankedContexts[0][0])); } // Null analysis const nullCols = []; for (const col of columns) { const values = rows.map(r => r[col]); const nullCount = values.filter(v => v === null || v === undefined || v === '').length; if (nullCount > 0) { const pct = Math.round(nullCount / rows.length * 100); nullCols.push({ col, pct, count: nullCount }); } } if (nullCols.length > 0) { nullCols.sort((a, b) => b.pct - a.pct); const worst = nullCols[0]; insights.push('⚠️ Highest null rate: "' + worst.col + '" at ' + worst.pct + '% (' + worst.count + ' nulls)'); if (nullCols.length > 1) { insights.push('πŸ“‹ ' + nullCols.length + ' columns have null values total'); } } else { insights.push('No NULL values detected in returned data, but source-table quality issues may still exist outside this result set'); } // Numeric analysis for (const col of columns) { const numeric = rows.map(r => r[col]).filter(v => v !== null && !isNaN(parseFloat(v)) && isFinite(v)); if (numeric.length > rows.length * 0.5) { const nums = numeric.map(v => parseFloat(v)); const min = Math.min(...nums); const max = Math.max(...nums); const avg = (nums.reduce((a, b) => a + b, 0) / nums.length).toFixed(2); insights.push('"' + col + '": range [' + min + ' β€” ' + max + '], avg=' + avg); } } // Duplicate detection for (const col of columns) { if (isAggregateLikeColumn(col) || !isKeyLikeColumn(col)) continue; const values = rows.map(r => r[col]).filter(v => v !== null); const unique = new Set(values.map(String)); if (unique.size < values.length * 0.8 && values.length > 5) { const dupCount = values.length - unique.size; insights.push('"' + col + '": ' + dupCount + ' duplicate values detected (' + unique.size + ' unique / ' + values.length + ' total)'); } } let html = '<div class="zone-label">AI Insights <span class="ai-badge">✦ LOCAL</span></div>'; for (const insight of insights.slice(0, 8)) { html += '<div class="insight-item">' + escapeHtml(insight) + '</div>'; } panel.innerHTML = html; } // ── Smart suggestions ── function generateSuggestions(rows) { const panel = document.getElementById('suggestions-panel'); const rankedContexts = Object.entries(uploadedTaskContexts) .filter(([, ctx]) => ctx && ctx.supported) .sort((a, b) => (b[1].confidence || 0) - (a[1].confidence || 0)); if (rankedContexts.length > 0) { renderSuggestions(rankedContexts.slice(0, 4).map(([taskId, ctx]) => taskId + ': ' + ((ctx && (ctx.context_hint || ctx.description)) || taskId))); return; } if (!rows || rows.length === 0) { panel.innerHTML = '<div class="zone-label">Smart Task Suggestions</div><div class="insights-empty">Query returned 0 rows β€” try broadening your query</div>'; return; } const suggestions = []; const columns = Object.keys(rows[0]); for (const col of columns) { const values = rows.map(r => r[col]); const nullCount = values.filter(v => v === null || v === undefined).length; if (nullCount > 0) { const pct = Math.round(nullCount / rows.length * 100); if (pct > 10) { suggestions.push({ text: 'πŸ” Consider running null_audit task β€” column "' + col + '" has ' + pct + '% nulls', task: 'null_audit' }); } } } // Check for duplicates const suggestionCols = columns.filter(col => !isAggregateLikeColumn(col) && isKeyLikeColumn(col)); const allValues = rows.flatMap(r => suggestionCols.map(col => r[col])).filter(v => v !== null); const duplicates = allValues.filter((v, i, a) => a.indexOf(v) !== i); if (suggestionCols.length > 0 && duplicates.length > rows.length * 0.1) { suggestions.push({ text: 'πŸ”„ Consider running dedup_bug task β€” significant duplicate values detected', task: 'dedup_bug' }); } // Check for type mismatches for (const col of columns) { const nonNull = rows.map(r => r[col]).filter(v => v !== null && v !== undefined); const allStrings = nonNull.every(v => typeof v === 'string'); const allNumericLooking = nonNull.length > 0 && nonNull.every(v => /^-?\$?\d+\.?\d*%?$/.test(String(v).trim().replace(/[$,%]/g, ''))); if (allStrings && allNumericLooking && nonNull.length > 3) { suggestions.push({ text: 'πŸ”§ Consider running type_repair task β€” column "' + col + '" has numeric values stored as text', task: 'type_repair' }); } } if (suggestions.length === 0) { suggestions.push({ text: 'βœ… Data looks healthy. Try running an RL task to verify quality.', task: null }); } let html = '<div class="zone-label">Smart Task Suggestions</div>'; for (const suggestion of suggestions.slice(0, 4)) { const onclick = suggestion.task ? ' onclick="document.getElementById(\'task-select\').value=\'' + suggestion.task + '\';document.getElementById(\'task-select\').dispatchEvent(new Event(\'change\'));"' : ''; html += '<div class="suggestion-item"' + onclick + '>' + escapeHtml(suggestion.text) + '</div>'; } panel.innerHTML = html; } function renderSuggestions(suggestionTexts) { const panel = document.getElementById('suggestions-panel'); let html = '<div class="zone-label">Smart Task Suggestions</div>'; for (const text of suggestionTexts) { const taskMatch = String(text).match(/^(null_audit|type_repair|dedup_bug|fk_integrity|schema_drift):/); const onclick = taskMatch ? ' onclick="document.getElementById(\'task-select\').value=\'' + taskMatch[1] + '\';document.getElementById(\'task-select\').dispatchEvent(new Event(\'change\'));"' : ''; html += '<div class="suggestion-item"' + onclick + '>' + escapeHtml(text) + '</div>'; } panel.innerHTML = html; } // ── BUG FIX #7: Initialize Episode β€” use /initialize endpoint with correct response format ── async function initializeEpisode() { if (!currentTask) { alert('Select a task first'); return; } setLoading('init-btn', true); showToast('⏳ Initializing ' + currentTask + ' episode...', 'info', 10000); try { // Use /initialize endpoint which returns { schema: [...] } format const r = await fetch(BASE + '/initialize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: currentTask }) }); const data = await r.json(); if (!r.ok) { showToast('❌ Initialization failed: ' + (data.detail || 'Unknown error'), 'error'); return; } episodeId = data.episode_id; mode = 'task'; currentTaskContext = data.task_context || {}; // BUG FIX #7: The /initialize endpoint returns { schema: [...] } not { schema_info: [...] } const schemaData = data.schema || []; if (schemaData.length > 0) { // Render schema with clickable columns pointing to fix-sql textarea (BUG FIX #5) renderSchema(schemaData, 'rl-schema-panel', 'fix-sql'); } else { document.getElementById('rl-schema-panel').innerHTML = '<div class="schema-empty">No schema data available for this task</div>'; } // Update task description with actual description from server if (data.task_description) { document.getElementById('task-description').textContent = data.task_description; } const taskContext = currentTaskContext; if (taskContext.context_hint) { document.getElementById('fix-context').textContent = taskContext.action_guidance || taskContext.context_hint; } updateTaskMode(currentTask, taskContext); if (taskContext.target_table && currentTask === 'dedup_bug') { document.getElementById('root-cause-table').value = taskContext.target_table; } if (taskContext.key_columns && taskContext.key_columns.length > 0 && currentTask === 'dedup_bug') { document.getElementById('offending-key').value = taskContext.key_columns.join(', '); } else if (taskContext.target_column && currentTask === 'dedup_bug') { document.getElementById('offending-key').value = taskContext.target_column; } if (taskContext.input_placeholder) { document.getElementById('fix-sql').placeholder = taskContext.input_placeholder; } else if (taskContext.verification_sql && !document.getElementById('fix-sql').value.trim()) { document.getElementById('fix-sql').placeholder = 'Suggested verification: ' + taskContext.verification_sql; } // Show investigation hint if (data.investigation_hint) { showToast('πŸ’‘ Hint: ' + data.investigation_hint, 'info', 5000); } const safeId = episodeId ? episodeId.slice(0, 8) : 'unknown'; showToast('\u2713 Episode initialized: ' + currentTask + ' (ID: ' + safeId + '...)', 'success'); checkHealth(); } catch (e) { showToast('❌ Initialization error: ' + e.message, 'error'); } finally { setLoading('init-btn', false); } } // ── BUG FIX #3: Apply Fix β€” send fix_query field + show feedback ── async function applyFix() { if (!episodeId) { showError('fix-error', 'Initialize an episode first.'); return; } const sql = document.getElementById('fix-sql').value.trim(); if (!sql) { showError('fix-error', 'Enter a fix query or answer.'); return; } setLoading('apply-fix-btn', true); // BUG FIX #3: Use fix_query field (not sql) for fix actions const body = { task_id: currentTask, action_type: 'fix', episode_id: episodeId, fix_query: sql // FIXED: was 'sql', should be 'fix_query' }; if (currentTask === 'dedup_bug') { body.root_cause_table = document.getElementById('root-cause-table').value; body.offending_key = document.getElementById('offending-key').value; } try { const r = await fetch(BASE + '/step', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await r.json(); if (!r.ok) { showError('fix-error', data.detail || 'Fix failed'); return; } // BUG FIX #3: Show success feedback const feedback = data.reward?.feedback || 'Fix applied successfully'; const score = data.reward?.step_reward ?? 0; document.getElementById('fix-error').innerHTML = '<div class="success-box">βœ“ ' + escapeHtml(feedback) + ' (step reward: ' + (score * 100).toFixed(1) + '%)</div>'; showToast('βœ“ ' + feedback, 'success'); } catch (e) { showError('fix-error', 'Fix error: ' + e.message); } finally { setLoading('apply-fix-btn', false); } } // ── BUG FIX #4: Submit Final β€” send answer for null_audit, fix_query for others ── async function submitFinal() { if (!episodeId) { showError('fix-error', 'Initialize an episode first.'); return; } const sql = document.getElementById('fix-sql').value.trim(); // Validation: non-null_audit tasks require non-empty sql if (currentTask !== 'null_audit' && !sql) { showError('fix-error', 'Please enter a FIX SQL query before submitting.'); return; } setLoading('submit-final-btn', true); const body = { task_id: currentTask, action_type: 'submit', episode_id: episodeId }; // BUG FIX #4: Route answer vs fix_query based on task type if (currentTask === 'null_audit') { // null_audit expects 'answer' field with the column name body.answer = sql; } else if (sql) { body.fix_query = sql; } if (currentTask === 'dedup_bug') { body.root_cause_table = document.getElementById('root-cause-table').value; body.offending_key = document.getElementById('offending-key').value; } try { const r = await fetch(BASE + '/step', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await r.json(); if (!r.ok) { showError('fix-error', data.detail || 'Submit failed'); return; } document.getElementById('fix-error').innerHTML = ''; renderResults(data.reward); showToast('βœ“ Final score: ' + Math.round((data.reward?.score ?? 0) * 100) + '%', 'success', 6000); } catch (e) { showError('fix-error', 'Submit error: ' + e.message); } finally { setLoading('submit-final-btn', false); } } // ── Render scoring results ── function renderResults(reward) { const panel = document.getElementById('results-panel'); panel.classList.remove('hidden'); const score = reward?.score ?? 0; document.getElementById('final-score').textContent = Math.round(score * 100) + '%'; const dims = reward?.dimensions || {}; let barsHtml = ''; const dimNames = { d1_diagnostic_efficiency: 'D1 β€” Diagnostic Efficiency', d2_hypothesis_quality: 'D2 β€” Hypothesis Quality', d3_fix_correctness: 'D3 β€” Fix Correctness', d4_self_verification: 'D4 β€” Self Verification', d5_generalization: 'D5 β€” Generalization' }; for (const [key, label] of Object.entries(dimNames)) { const val = dims[key] ?? 0; const pct = Math.round(val * 100); const color = pct >= 70 ? '#16A34A' : pct >= 40 ? '#F59E0B' : '#DC2626'; barsHtml += '<div class="dimension-bar">'; barsHtml += '<div class="dimension-label"><span>' + label + '</span><span style="color:' + color + '">' + pct + '%</span></div>'; barsHtml += '<div class="dimension-track"><div class="dimension-fill" style="width: ' + pct + '%; background: ' + color + '"></div></div>'; barsHtml += '</div>'; } document.getElementById('dimension-bars').innerHTML = barsHtml; const feedback = reward?.feedback || ''; const severity = dims?.d3_fix_severity || ''; let feedbackHtml = escapeHtml(feedback); if (severity) { const severityColor = {PERFECT:'#16A34A',EXCELLENT:'#22C55E',ACCEPTABLE:'#84CC16',GOOD:'#65A30D',FAIR:'#F59E0B',PARTIAL:'#F97316',DEGRADED:'#FB923C',DANGEROUS:'#EF4444',CRITICAL:'#DC2626',CATASTROPHIC:'#991B1B'}[severity] || '#6B7280'; feedbackHtml += ' <span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;color:white;background:' + severityColor + '">' + severity + '</span>'; } document.getElementById('feedback-text').innerHTML = feedbackHtml; } // ── Data Quality Chart (simple canvas bar chart) ── function drawQualityChart(rows) { const container = document.getElementById('chart-container'); if (!rows || rows.length === 0) { container.classList.add('hidden'); return; } container.classList.remove('hidden'); const canvas = document.getElementById('quality-chart'); const ctx = canvas.getContext('2d'); // Calculate dimensions canvas.width = canvas.parentElement.clientWidth - 32; canvas.height = 140; const columns = Object.keys(rows[0]); const barWidth = Math.min(60, (canvas.width - 40) / columns.length - 8); const maxHeight = 100; ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw bars showing non-null percentage per column columns.forEach((col, i) => { const values = rows.map(r => r[col]); const nonNull = values.filter(v => v !== null && v !== undefined && v !== '').length; const pct = nonNull / values.length; const barHeight = pct * maxHeight; const x = 20 + i * (barWidth + 8); const y = maxHeight - barHeight + 10; // Bar gradient const grad = ctx.createLinearGradient(x, y, x, y + barHeight); if (pct >= 0.9) { grad.addColorStop(0, '#22C55E'); grad.addColorStop(1, '#16A34A'); } else if (pct >= 0.7) { grad.addColorStop(0, '#F59E0B'); grad.addColorStop(1, '#D97706'); } else { grad.addColorStop(0, '#EF4444'); grad.addColorStop(1, '#DC2626'); } ctx.fillStyle = grad; ctx.beginPath(); if (typeof ctx.roundRect === 'function') { ctx.roundRect(x, y, barWidth, barHeight, 3); } else { // Fallback for older browsers ctx.rect(x, y, barWidth, barHeight); } ctx.fill(); // Label ctx.fillStyle = '#6B7280'; ctx.font = '9px DM Mono'; ctx.textAlign = 'center'; const label = col.length > 8 ? col.slice(0, 7) + '…' : col; ctx.fillText(label, x + barWidth / 2, maxHeight + 24); // Percentage ctx.fillStyle = '#374151'; ctx.font = 'bold 10px DM Sans'; ctx.fillText(Math.round(pct * 100) + '%', x + barWidth / 2, y - 4); }); } // ── Helpers ── function showError(elementId, message) { document.getElementById(elementId).innerHTML = '<div class="error-box">' + escapeHtml(message) + '</div>'; } function escapeHtml(text) { if (!text) return ''; return String(text) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;'); } // ── Init ── checkHealth(); setInterval(checkHealth, 30000); </script> </body> </html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SQL Quality Environment</title>
<meta name="description" content="OpenEnv-compatible SQL data quality audit environment for AI agent training and evaluation">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DM Sans', sans-serif;
background: #F8F9FA;
color: #18181B;
min-height: 100vh;
}
.header {
height: 56px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.logo {
font-family: 'DM Mono', monospace;
font-weight: 500;
font-size: 16px;
color: #E0E7FF;
letter-spacing: -0.5px;
}
.header-center {
display: flex;
align-items: center;
gap: 12px;
}
.task-select {
font-family: 'DM Sans', sans-serif;
padding: 6px 14px;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
font-size: 14px;
background: rgba(255,255,255,0.1);
color: #E0E7FF;
cursor: pointer;
transition: all 0.2s;
}
.task-select:hover { background: rgba(255,255,255,0.15); }
.task-select option { background: #1a1a2e; color: #E0E7FF; }
.btn {
font-family: 'DM Sans', sans-serif;
padding: 7px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s ease;
position: relative;
overflow: hidden;
}
.btn-primary {
background: linear-gradient(135deg, #2D6A4F 0%, #40916C 100%);
color: #FFFFFF;
box-shadow: 0 2px 6px rgba(45,106,79,0.3);
}
.btn-primary:hover {
background: linear-gradient(135deg, #40916C 0%, #52B788 100%);
box-shadow: 0 4px 12px rgba(45,106,79,0.4);
transform: translateY(-1px);
}
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled {
background: #D1D5DB;
color: #9CA3AF;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.btn-danger {
background: linear-gradient(135deg, #DC2626 0%, #EF4444 100%);
color: #FFFFFF;
}
.btn-danger:hover { background: linear-gradient(135deg, #EF4444 0%, #F87171 100%); }
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #E0E7FF;
}