htmx datafusion graphs via echarts2
This commit is contained in:
@@ -4,164 +4,134 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Analytics graphs</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
|
||||
main { width: min(1200px, calc(100% - 32px)); margin: 24px auto; }
|
||||
main { width: min(1500px, calc(100% - 32px)); margin: 24px auto; }
|
||||
h1 { margin: 0 0 18px; font-size: 24px; }
|
||||
.panel { padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr 180px 120px; gap: 12px; margin-bottom: 12px; }
|
||||
h2 { margin: 0 0 14px; font-size: 17px; }
|
||||
.panel { margin-bottom: 16px; padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
||||
.workspace { display: grid; grid-template-columns: minmax(290px, 380px) minmax(0, 1fr); align-items: start; gap: 16px; }
|
||||
.sidebar { position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
|
||||
.sidebar h2 { margin-bottom: 6px; }
|
||||
.catalog-load { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; margin: 14px 0; }
|
||||
.catalog-load label { margin: 0; }
|
||||
.schema-summary { margin: 8px 0; }
|
||||
.catalog-table { border-top: 1px solid #e4e7ec; padding: 9px 0; }
|
||||
.catalog-table summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.currency { color: #667085; font-size: 11px; }
|
||||
.starter-query, .secondary { margin-top: 9px; padding: 6px 9px; color: #344054; background: #eef2f6; }
|
||||
.columns, .links ul { margin: 8px 0; padding: 0; list-style: none; }
|
||||
.columns li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
|
||||
.columns li span { color: #667085; font-size: 11px; text-align: right; }
|
||||
.column-name { padding: 0; color: #2563eb; background: none; font-family: ui-monospace, monospace; text-align: left; }
|
||||
.links { color: #667085; font-size: 12px; }
|
||||
.links li { padding: 2px 0; }
|
||||
.llm-context { border-top: 1px solid #e4e7ec; margin-top: 10px; padding-top: 10px; }
|
||||
.llm-context summary { cursor: pointer; font-weight: 600; }
|
||||
.llm-context textarea { min-height: 320px; margin-top: 8px; font-size: 11px; }
|
||||
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
|
||||
.top h1 { margin: 0; }
|
||||
.top a { color: #2563eb; }
|
||||
.query-row { display: grid; grid-template-columns: 180px 120px; gap: 12px; margin-bottom: 12px; }
|
||||
label { display: grid; gap: 5px; color: #4b5563; font-size: 12px; }
|
||||
input, select, textarea, button { font: inherit; }
|
||||
input, select, textarea { width: 100%; border: 1px solid #cfd6dd; border-radius: 6px; padding: 9px; background: white; }
|
||||
textarea { min-height: 130px; resize: vertical; font-family: ui-monospace, monospace; line-height: 1.45; }
|
||||
.actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
||||
button { border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
||||
button:disabled { opacity: .55; cursor: wait; }
|
||||
#status { color: #667085; }
|
||||
#status.error { color: #b42318; }
|
||||
#chart { height: 560px; margin-top: 18px; }
|
||||
.table-wrap { display: none; max-height: 560px; overflow: auto; margin-top: 18px; }
|
||||
button:disabled { opacity: .55; }
|
||||
.htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
|
||||
.actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
||||
.hint, .result-meta { color: #667085; }
|
||||
.error { color: #b42318; }
|
||||
.success { color: #067647; }
|
||||
.chart { height: 560px; }
|
||||
.table-wrap { max-height: 560px; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; }
|
||||
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
||||
th { position: sticky; top: 0; background: #f9fafb; }
|
||||
@media (max-width: 760px) { .row { grid-template-columns: 1fr; } main { width: calc(100% - 20px); } }
|
||||
@media (max-width: 760px) {
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; max-height: none; }
|
||||
.query-row { grid-template-columns: 1fr; }
|
||||
main { width: calc(100% - 20px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Analytics graphs</h1>
|
||||
<form id="query-form" class="panel">
|
||||
<div class="row">
|
||||
<label>Profile<input id="profile" required autocomplete="off"></label>
|
||||
<label>Bearer token<input id="token" type="password" autocomplete="off"></label>
|
||||
<label>Chart
|
||||
<select id="chart-type">
|
||||
<option value="bar">Bar</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="pie">Pie</option>
|
||||
<option value="scatter">Scatter</option>
|
||||
<option value="table">Table</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Max rows<input id="max-rows" type="number" min="1" value="1000"></label>
|
||||
</div>
|
||||
<label>SQL<textarea id="sql" required spellcheck="false" placeholder="SELECT category, SUM(amount) AS total FROM sales GROUP BY category"></textarea></label>
|
||||
<div class="actions">
|
||||
<button id="run" type="submit">Run query</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="chart"></div>
|
||||
<div id="table-wrap" class="table-wrap"><table id="table"></table></div>
|
||||
<div class="top"><h1>Analytics graphs</h1><a href="/login">Login</a></div>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="panel sidebar">
|
||||
<h2>Available data</h2>
|
||||
<p class="hint">Load a profile to see the table and column aliases accepted by analytics SQL.</p>
|
||||
<div hx-get="/api/profiles" hx-trigger="load" hx-target="#profile" hx-swap="innerHTML">
|
||||
<form class="catalog-load" hx-post="/api/catalog" hx-target="#catalog" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<label>Profile
|
||||
<select id="profile" name="profile_name" hx-post="/api/catalog" hx-trigger="change" hx-target="#catalog" hx-swap="innerHTML">
|
||||
<option value="">Loading profiles…</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Load schema</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="catalog" aria-live="polite"><p class="hint">No schema loaded.</p></div>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<form id="query-form" class="panel" hx-post="/api/query" hx-include="#profile" hx-target="#output" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<div class="query-row">
|
||||
<label>Chart
|
||||
<select name="chart_type">
|
||||
<option value="bar">Bar</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="pie">Pie</option>
|
||||
<option value="scatter">Scatter</option>
|
||||
<option value="table">Table</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Max rows<input name="max_rows" type="number" min="1" value="1000"></label>
|
||||
</div>
|
||||
<label>SQL<textarea id="sql" name="sql" required spellcheck="false" placeholder="Load the schema, then choose a starter query or write a SELECT query"></textarea></label>
|
||||
<div class="actions">
|
||||
<button type="submit">Run query</button>
|
||||
<span class="htmx-indicator hint">Running…</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="output" aria-live="polite"></section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const form = document.querySelector('#query-form');
|
||||
const status = document.querySelector('#status');
|
||||
const runButton = document.querySelector('#run');
|
||||
const chartElement = document.querySelector('#chart');
|
||||
const tableWrap = document.querySelector('#table-wrap');
|
||||
const table = document.querySelector('#table');
|
||||
const chart = echarts.init(chartElement);
|
||||
let lastResult = null;
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
runButton.disabled = true;
|
||||
setStatus('Running…');
|
||||
try {
|
||||
const response = await fetch('/api/query', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile_name: document.querySelector('#profile').value,
|
||||
token: document.querySelector('#token').value,
|
||||
sql: document.querySelector('#sql').value,
|
||||
max_rows: Number(document.querySelector('#max-rows').value)
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`);
|
||||
lastResult = result;
|
||||
render(result);
|
||||
setStatus(`${result.row_count} rows in ${result.elapsed_ms} ms${result.truncated ? ' (truncated)' : ''}`);
|
||||
} catch (error) {
|
||||
setStatus(error.message, true);
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#chart-type').addEventListener('change', () => {
|
||||
if (lastResult) render(lastResult);
|
||||
});
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
|
||||
function render(result) {
|
||||
const type = document.querySelector('#chart-type').value;
|
||||
if (type === 'table') return renderTable(result);
|
||||
tableWrap.style.display = 'none';
|
||||
chartElement.style.display = 'block';
|
||||
if (!result.columns.length) {
|
||||
chart.clear();
|
||||
document.addEventListener('click', async (event) => {
|
||||
const queryButton = event.target.closest('[data-sql]');
|
||||
if (queryButton) {
|
||||
const editor = document.getElementById('sql');
|
||||
editor.value = queryButton.dataset.sql;
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const names = result.columns.map(column => column.name);
|
||||
const rows = result.rows;
|
||||
let option;
|
||||
if (type === 'pie') {
|
||||
option = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { type: 'scroll', bottom: 0 },
|
||||
series: [{ type: 'pie', radius: ['25%', '68%'], data: rows.map(row => ({ name: String(row[0] ?? ''), value: numeric(row[1]) })) }]
|
||||
};
|
||||
} else if (type === 'scatter') {
|
||||
option = {
|
||||
tooltip: { trigger: 'item' },
|
||||
xAxis: { name: names[0], type: 'value' },
|
||||
yAxis: { name: names[1], type: 'value' },
|
||||
series: [{ name: names[1], type: 'scatter', data: rows.map(row => [numeric(row[0]), numeric(row[1])]) }]
|
||||
};
|
||||
} else {
|
||||
option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { type: 'scroll', bottom: 0 },
|
||||
grid: { left: 55, right: 25, top: 35, bottom: 70, containLabel: true },
|
||||
xAxis: { type: 'category', data: rows.map(row => String(row[0] ?? '')) },
|
||||
yAxis: { type: 'value' },
|
||||
series: names.slice(1).map((name, index) => ({ name, type, data: rows.map(row => numeric(row[index + 1])), smooth: type === 'line' }))
|
||||
};
|
||||
const columnButton = event.target.closest('[data-insert]');
|
||||
if (columnButton) {
|
||||
const editor = document.getElementById('sql');
|
||||
const start = editor.selectionStart;
|
||||
editor.setRangeText(columnButton.dataset.insert, start, editor.selectionEnd, 'end');
|
||||
editor.focus();
|
||||
return;
|
||||
}
|
||||
chart.setOption(option, true);
|
||||
chart.resize();
|
||||
}
|
||||
|
||||
function renderTable(result) {
|
||||
chartElement.style.display = 'none';
|
||||
tableWrap.style.display = 'block';
|
||||
const header = `<thead><tr>${result.columns.map(column => `<th>${escapeHtml(column.name)}</th>`).join('')}</tr></thead>`;
|
||||
const body = `<tbody>${result.rows.map(row => `<tr>${row.map(value => `<td>${escapeHtml(value ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>`;
|
||||
table.innerHTML = header + body;
|
||||
}
|
||||
|
||||
function numeric(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
const element = document.createElement('div');
|
||||
element.textContent = String(value);
|
||||
return element.innerHTML;
|
||||
}
|
||||
|
||||
function setStatus(message, error = false) {
|
||||
status.textContent = message;
|
||||
status.classList.toggle('error', error);
|
||||
}
|
||||
const copyButton = event.target.closest('[data-copy]');
|
||||
if (copyButton) {
|
||||
const text = document.getElementById(copyButton.dataset.copy).value;
|
||||
await navigator.clipboard.writeText(text);
|
||||
document.getElementById('copy-status').textContent = 'Copied';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user