feat(pipeline): query editor tab templates

- partials/pipeline_query.html: dark-themed SQL textarea (navy bg, Commit
  Mono, 12px border-radius, electric blue focus glow) + schema sidebar
  (collapsible per-table column lists with types) + controls bar (Execute,
  Clear, limit/timeout note) + Tab-key indent + Cmd/Ctrl+Enter submit
- partials/pipeline_query_results.html: results table with sticky headers,
  horizontal scroll, row count + elapsed time metadata, truncation warning,
  error display in red monospace card

Subtask 5 of 6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 12:55:20 +01:00
parent 5b48a11e01
commit 8f8f7f7acb
2 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
<!-- Pipeline Query Tab: SQL editor + schema sidebar + results -->
<style>
.query-layout {
display: flex; gap: 1rem; align-items: flex-start;
}
.query-editor-wrap { flex: 1; min-width: 0; }
/* Dark code editor textarea */
.query-editor {
width: 100%;
min-height: 200px;
max-height: 480px;
resize: vertical;
font-family: 'Commit Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.8125rem;
line-height: 1.6;
tab-size: 2;
background: #0F172A;
color: #CBD5E1;
border: 1px solid #334155;
border-radius: 12px;
padding: 1rem 1.125rem;
caret-color: #60A5FA;
}
.query-editor:focus {
outline: none;
border-color: #3B82F6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.18);
}
.query-editor::placeholder { color: #475569; }
/* Schema panel */
.schema-panel {
width: 230px;
flex-shrink: 0;
background: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 12px;
max-height: 480px;
overflow-y: auto;
font-size: 0.75rem;
}
.schema-panel-header {
padding: 0.625rem 0.875rem;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94A3B8;
border-bottom: 1px solid #E2E8F0;
position: sticky;
top: 0;
background: #F8FAFC;
}
.schema-table-section { border-bottom: 1px solid #F1F5F9; }
.schema-table-toggle {
width: 100%;
text-align: left;
padding: 0.5rem 0.875rem;
font-size: 0.75rem;
font-weight: 600;
color: #0F172A;
background: none;
border: none;
cursor: pointer;
font-family: 'Commit Mono', monospace;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.schema-table-toggle:hover { background: #EFF6FF; color: #1D4ED8; }
.schema-cols { display: none; padding: 0 0.875rem 0.5rem; }
.schema-cols.open { display: block; }
.schema-col {
padding: 2px 0;
display: flex;
justify-content: space-between;
gap: 4px;
}
.schema-col-name { color: #475569; font-family: 'Commit Mono', monospace; font-size: 0.6875rem; }
.schema-col-type { color: #94A3B8; font-size: 0.625rem; white-space: nowrap; }
/* Query controls bar */
.query-controls {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.625rem;
flex-wrap: wrap;
}
.query-limit-note {
font-size: 0.6875rem;
color: #94A3B8;
margin-left: auto;
}
/* Results area */
#query-results { margin-top: 1rem; }
.results-meta {
font-size: 0.75rem;
color: #64748B;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.results-table-wrap {
overflow-x: auto;
max-height: 440px;
overflow-y: auto;
border: 1px solid #E2E8F0;
border-radius: 10px;
}
.results-table-wrap table {
font-size: 0.75rem;
border-radius: 0;
}
.results-table-wrap table thead th {
position: sticky;
top: 0;
background: #F8FAFC;
z-index: 1;
white-space: nowrap;
}
.results-table-wrap table td.mono {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.query-error {
border: 1px solid #FCA5A5;
background: #FEF2F2;
border-radius: 10px;
padding: 0.875rem 1rem;
font-size: 0.8125rem;
color: #DC2626;
font-family: 'Commit Mono', monospace;
word-break: break-all;
}
.results-truncated {
font-size: 0.75rem;
color: #D97706;
margin-top: 0.5rem;
}
@media (max-width: 900px) {
.query-layout { flex-direction: column; }
.schema-panel { width: 100%; max-height: 200px; }
}
</style>
<form id="query-form"
hx-post="{{ url_for('pipeline.pipeline_query_execute') }}"
hx-target="#query-results"
hx-swap="innerHTML"
hx-indicator="#query-spinner">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="query-layout">
<!-- SQL textarea -->
<div class="query-editor-wrap">
<textarea
name="sql"
id="query-sql"
class="query-editor"
rows="10"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
placeholder="-- SELECT * FROM serving.city_market_profile&#10;-- WHERE country_code = 'DE'&#10;-- ORDER BY marktreife_score DESC&#10;-- LIMIT 20"
></textarea>
<div class="query-controls">
<button type="submit" class="btn btn-sm" hx-disabled-elt="this">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" style="width:13px;height:13px;display:inline;margin-right:4px">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"/>
</svg>
Execute
</button>
<button type="button" class="btn-outline btn-sm" onclick="document.getElementById('query-sql').value='';document.getElementById('query-results').innerHTML=''">
Clear
</button>
<svg id="query-spinner" class="htmx-indicator search-spinner" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<span class="query-limit-note">
{{ max_rows | default(1000) }} row limit · {{ timeout_seconds | default(10) }}s timeout · SELECT only
</span>
</div>
</div>
<!-- Schema sidebar -->
<aside class="schema-panel">
<div class="schema-panel-header">Schema — serving.*</div>
{% if schema %}
{% for tname, cols in schema.items() | sort %}
<div class="schema-table-section">
<button type="button" class="schema-table-toggle" onclick="toggleSchema('{{ tname }}')">
{{ tname }}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
id="schema-chevron-{{ tname }}"
style="width:10px;height:10px;transition:transform 0.15s;flex-shrink:0">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/>
</svg>
</button>
<div id="schema-cols-{{ tname }}" class="schema-cols">
{% for col in cols %}
<div class="schema-col">
<span class="schema-col-name">{{ col.name }}</span>
<span class="schema-col-type">{{ col.type | upper }}</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<p style="padding:1rem;font-size:0.75rem;color:#94A3B8">
Analytics DB not available.
</p>
{% endif %}
</aside>
</div>
</form>
<!-- Results area (populated by HTMX after query execution) -->
<div id="query-results"></div>
<script>
// Tab key in textarea inserts spaces instead of losing focus
document.getElementById('query-sql').addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
}
// Cmd/Ctrl+Enter submits
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
htmx.trigger(document.getElementById('query-form'), 'submit');
}
});
function toggleSchema(name) {
var cols = document.getElementById('schema-cols-' + name);
var chevron = document.getElementById('schema-chevron-' + name);
var isOpen = cols.classList.contains('open');
cols.classList.toggle('open', !isOpen);
chevron.style.transform = isOpen ? '' : 'rotate(90deg)';
}
</script>

View File

@@ -0,0 +1,46 @@
<!-- Pipeline Query Results Partial: swapped into #query-results after POST -->
{% if error %}
<div class="query-error">
<strong style="display:block;margin-bottom:4px">Query error</strong>
{{ error }}
</div>
{% elif columns %}
<div class="results-meta">
<span>{{ row_count | default(0) }} row{{ 's' if row_count != 1 }}</span>
<span style="color:#CBD5E1">·</span>
<span>{{ "%.1f" | format(elapsed_ms) }} ms</span>
{% if truncated %}
<span class="results-truncated">Result truncated at {{ row_count }} rows</span>
{% endif %}
</div>
<div class="results-table-wrap">
<table class="table">
<thead>
<tr>
{% for col in columns %}
<th>{{ col }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for val in row %}
<td class="mono" title="{{ val if val is not none else 'null' }}">
{% if val is none %}
<span style="color:#CBD5E1">null</span>
{% else %}
{{ val | string | truncate(60, true) }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="padding:1.5rem;text-align:center">
<p class="text-sm text-slate">Query returned no rows.</p>
</div>
{% endif %}