htmx datafusion graphs via echarts3 - no custom scripts, only alpinejs
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A small Rust web server that logs in through `AuthService.Login`, forwards SQL
|
A small Rust web server that logs in through `AuthService.Login`, forwards SQL
|
||||||
queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed
|
queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed
|
||||||
result with HTMX and ECharts.
|
result with HTMX, Alpine.js, and ECharts.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -33,5 +33,6 @@ create starter queries. Its **LLM schema context** section generates a complete
|
|||||||
text summary that can be copied into an LLM prompt to request a valid analytics
|
text summary that can be copied into an LLM prompt to request a valid analytics
|
||||||
SQL query.
|
SQL query.
|
||||||
|
|
||||||
HTMX and ECharts are loaded from jsDelivr, so the browser needs network access
|
HTMX, Alpine.js, and ECharts are loaded from jsDelivr, so the browser needs
|
||||||
when opening the page.
|
network access when opening the page. HTMX owns server communication and fragment
|
||||||
|
swaps; Alpine is limited to browser-local editor, clipboard, and chart behavior.
|
||||||
|
|||||||
@@ -404,11 +404,12 @@ fn render_result(result: &QueryOutput, chart_type: &str) -> Html<String> {
|
|||||||
}
|
}
|
||||||
_ => return error_fragment("Unknown chart type"),
|
_ => return error_fragment("Unknown chart type"),
|
||||||
};
|
};
|
||||||
let safe_option = option.to_string().replace('<', "\\u003c");
|
let option = escape_html(&option.to_string());
|
||||||
|
|
||||||
Html(format!(
|
Html(format!(
|
||||||
"<p class=\"result-meta\">{metadata}</p><div id=\"chart\" class=\"chart\"></div>\
|
"<p class=\"result-meta\">{metadata}</p>\
|
||||||
<script>echarts.init(document.getElementById('chart')).setOption({safe_option});</script>"
|
<div class=\"chart\" data-option=\"{option}\" \
|
||||||
|
x-init=\"echarts.init($el).setOption(JSON.parse($el.dataset.option))\"></div>"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,9 +430,10 @@ fn render_catalog(catalog: &GetAnalyticsCatalogResponse) -> Html<String> {
|
|||||||
<details class=\"llm-context\">\
|
<details class=\"llm-context\">\
|
||||||
<summary>LLM schema context</summary>\
|
<summary>LLM schema context</summary>\
|
||||||
<p class=\"hint\">Copy this entire text and include it when asking an LLM to write a query.</p>\
|
<p class=\"hint\">Copy this entire text and include it when asking an LLM to write a query.</p>\
|
||||||
<textarea id=\"llm-schema\" readonly>{}</textarea>\
|
<textarea x-ref=\"llmSchema\" readonly>{}</textarea>\
|
||||||
<button type=\"button\" class=\"secondary\" data-copy=\"llm-schema\">Copy context</button>\
|
<button type=\"button\" class=\"secondary\" \
|
||||||
<span id=\"copy-status\" class=\"hint\"></span>\
|
x-on:click=\"navigator.clipboard.writeText($refs.llmSchema.value); copied = true\">Copy context</button>\
|
||||||
|
<span class=\"hint\" x-show=\"copied\" x-cloak>Copied</span>\
|
||||||
</details>",
|
</details>",
|
||||||
catalog.tables.len(),
|
catalog.tables.len(),
|
||||||
escape_html(&llm_context),
|
escape_html(&llm_context),
|
||||||
@@ -453,7 +455,8 @@ fn render_catalog_table(table: &AnalyticsTable) -> String {
|
|||||||
details.push_str(&format!(", rounding {}", column.rounding));
|
details.push_str(&format!(", rounding {}", column.rounding));
|
||||||
}
|
}
|
||||||
format!(
|
format!(
|
||||||
"<li><button type=\"button\" class=\"column-name\" data-insert=\"{}\">{}</button><span>{}</span></li>",
|
"<li><button type=\"button\" class=\"column-name\" data-insert=\"{}\" \
|
||||||
|
x-on:click=\"$refs.sql.setRangeText($el.dataset.insert, $refs.sql.selectionStart, $refs.sql.selectionEnd, 'end'); sql = $refs.sql.value; $refs.sql.focus()\">{}</button><span>{}</span></li>",
|
||||||
escape_html("e_identifier(&column.name)),
|
escape_html("e_identifier(&column.name)),
|
||||||
escape_html(&column.name),
|
escape_html(&column.name),
|
||||||
escape_html(&details),
|
escape_html(&details),
|
||||||
@@ -489,7 +492,8 @@ fn render_catalog_table(table: &AnalyticsTable) -> String {
|
|||||||
format!(
|
format!(
|
||||||
"<details class=\"catalog-table\">\
|
"<details class=\"catalog-table\">\
|
||||||
<summary><code>{}</code>{currency}</summary>\
|
<summary><code>{}</code>{currency}</summary>\
|
||||||
<button type=\"button\" class=\"starter-query\" data-sql=\"{}\">Use starter query</button>\
|
<button type=\"button\" class=\"starter-query\" data-sql=\"{}\" \
|
||||||
|
x-on:click=\"sql = $el.dataset.sql; $refs.sql.focus()\">Use starter query</button>\
|
||||||
<ul class=\"columns\">{columns}</ul>{links}\
|
<ul class=\"columns\">{columns}</ul>{links}\
|
||||||
</details>",
|
</details>",
|
||||||
escape_html(&table.name),
|
escape_html(&table.name),
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
<title>Analytics graphs</title>
|
<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/htmx.org@2/dist/htmx.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
|
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
|
||||||
main { width: min(1500px, calc(100% - 32px)); margin: 24px auto; }
|
main { width: min(1500px, calc(100% - 32px)); margin: 24px auto; }
|
||||||
h1 { margin: 0 0 18px; font-size: 24px; }
|
h1 { margin: 0 0 18px; font-size: 24px; }
|
||||||
@@ -61,7 +63,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main x-data="{ sql: '', copied: false }">
|
||||||
<div class="top"><h1>Analytics graphs</h1><a href="/login">Login</a></div>
|
<div class="top"><h1>Analytics graphs</h1><a href="/login">Login</a></div>
|
||||||
|
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>Max rows<input name="max_rows" type="number" min="1" value="1000"></label>
|
<label>Max rows<input name="max_rows" type="number" min="1" value="1000"></label>
|
||||||
</div>
|
</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>
|
<label>SQL<textarea id="sql" x-ref="sql" x-model="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">
|
<div class="actions">
|
||||||
<button type="submit">Run query</button>
|
<button type="submit">Run query</button>
|
||||||
<span class="htmx-indicator hint">Running…</span>
|
<span class="htmx-indicator hint">Running…</span>
|
||||||
@@ -106,32 +108,5 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user