htmx datafusion graphs via echarts2

This commit is contained in:
Priec
2026-07-16 00:47:13 +02:00
parent fac1ada024
commit f2e8db471c
4 changed files with 589 additions and 187 deletions

View File

@@ -1,7 +1,8 @@
# Analytics graphs
A small Rust web server that forwards SQL queries to `AnalyticsService.ExecuteAnalyticsQuery`
and displays the streamed result with ECharts.
A small Rust web server that logs in through `AuthService.Login`, forwards SQL
queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed
result with HTMX and ECharts.
## Run
@@ -11,7 +12,8 @@ Start the main komp_ac gRPC server, then run:
cargo run
```
Open <http://127.0.0.1:3000>. The default gRPC endpoint is
Open <http://127.0.0.1:3000/login> to log in, then use the analytics page at
<http://127.0.0.1:3000>. The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
`http://[::1]:50051`. Both addresses can be changed:
```sh
@@ -24,5 +26,12 @@ The first SQL result column is used for category labels. Bar and line charts use
all remaining columns as numeric series, pie uses the second column, and scatter
uses the first two columns. Table view displays every returned value.
ECharts is loaded from jsDelivr, so the browser needs network access when opening
the page.
The profile selector is populated automatically through
`TableDefinition.GetProfileTree`. Choosing a profile fetches its live public
analytics catalog. The sidebar shows tables, columns, types, and links and can
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
SQL query.
HTMX and ECharts are loaded from jsDelivr, so the browser needs network access
when opening the page.

View File

@@ -1,10 +1,10 @@
use std::{env, net::SocketAddr};
use axum::{
Json, Router,
Form, Router,
extract::State,
http::StatusCode,
response::Html,
http::{HeaderMap, HeaderValue, header},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
mod analytics {
@@ -13,30 +13,71 @@ mod analytics {
"/../common/src/proto/komp_ac.analytics.rs"
));
}
mod auth {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.auth.rs"
));
}
mod definitions {
#[allow(dead_code)]
pub mod common {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.common.rs"
));
}
pub mod table_definition {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../common/src/proto/komp_ac.table_definition.rs"
));
}
}
use analytics::{
AnalyticsResultColumn, AnalyticsValue, ExecuteAnalyticsQueryRequest,
AnalyticsResultColumn, AnalyticsTable, AnalyticsValue, ExecuteAnalyticsQueryRequest,
GetAnalyticsCatalogRequest, GetAnalyticsCatalogResponse,
analytics_service_client::AnalyticsServiceClient, analytics_value,
};
use auth::{LoginRequest, auth_service_client::AuthServiceClient};
use definitions::{
common::Empty,
table_definition::table_definition_client::TableDefinitionClient,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tonic::{Request, metadata::MetadataValue, transport::Channel};
const INDEX_HTML: &str = include_str!("../static/index.html");
const LOGIN_HTML: &str = include_str!("../static/login.html");
#[derive(Clone)]
struct AppState {
analytics: AnalyticsServiceClient<Channel>,
auth: AuthServiceClient<Channel>,
definitions: TableDefinitionClient<Channel>,
}
#[derive(Deserialize)]
struct QueryInput {
profile_name: String,
sql: String,
#[serde(default)]
token: String,
#[serde(default = "default_max_rows")]
max_rows: u32,
chart_type: String,
}
#[derive(Deserialize)]
struct LoginInput {
identifier: String,
#[serde(default)]
password: String,
}
#[derive(Deserialize)]
struct CatalogInput {
profile_name: String,
}
fn default_max_rows() -> u32 {
@@ -58,13 +99,6 @@ struct ColumnOutput {
data_type: String,
}
#[derive(Serialize)]
struct ErrorOutput {
error: String,
}
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<ErrorOutput>)>;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let grpc_endpoint =
@@ -75,11 +109,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let channel = Channel::from_shared(grpc_endpoint.clone())?.connect_lazy();
let state = AppState {
analytics: AnalyticsServiceClient::new(channel),
analytics: AnalyticsServiceClient::new(channel.clone()),
auth: AuthServiceClient::new(channel.clone()),
definitions: TableDefinitionClient::new(channel),
};
let app = Router::new()
.route("/", get(index))
.route("/login", get(login_page).post(login))
.route("/api/profiles", get(load_profiles))
.route("/api/catalog", post(load_catalog))
.route("/api/query", post(run_query))
.with_state(state);
@@ -94,12 +133,50 @@ async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>) -> ApiResult<QueryOutput> {
async fn login_page() -> Html<&'static str> {
Html(LOGIN_HTML)
}
async fn login(State(state): State<AppState>, Form(input): Form<LoginInput>) -> Response {
if input.identifier.trim().is_empty() {
return error_fragment("Username or email is required").into_response();
}
let mut client = state.auth;
match client
.login(Request::new(LoginRequest {
identifier: input.identifier,
password: input.password,
}))
.await
{
Ok(response) => {
let login = response.into_inner();
let cookie = format!(
"analytics_token={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}",
login.access_token, login.expires_in,
);
let Ok(cookie) = HeaderValue::try_from(cookie) else {
return error_fragment("The server returned an invalid access token").into_response();
};
let mut response = Html(String::new()).into_response();
response.headers_mut().insert(header::SET_COOKIE, cookie);
response
.headers_mut()
.insert("hx-redirect", HeaderValue::from_static("/"));
response
}
Err(error) => error_fragment(error.message()).into_response(),
}
}
async fn run_query(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<QueryInput>,
) -> Html<String> {
if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"Profile name and SQL are required",
));
return error_fragment("Profile name and SQL are required");
}
let mut request = Request::new(ExecuteAnalyticsQueryRequest {
@@ -108,18 +185,19 @@ async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>)
max_rows: input.max_rows,
});
if !input.token.trim().is_empty() {
let value = MetadataValue::try_from(format!("Bearer {}", input.token.trim()))
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "The bearer token is invalid"))?;
request.metadata_mut().insert("authorization", value);
}
let Some(token) = cookie_value(&headers, "analytics_token") else {
return Html("<p class=\"error\">Please <a href=\"/login\">log in</a> before running a query.</p>".into());
};
let Ok(value) = MetadataValue::try_from(format!("Bearer {token}")) else {
return error_fragment("The stored bearer token is invalid");
};
request.metadata_mut().insert("authorization", value);
let mut client = state.analytics;
let mut stream = client
.execute_analytics_query(request)
.await
.map_err(grpc_error)?
.into_inner();
let mut stream = match client.execute_analytics_query(request).await {
Ok(response) => response.into_inner(),
Err(error) => return error_fragment(error.message()),
};
let mut columns = Vec::new();
let mut rows = Vec::new();
@@ -127,7 +205,12 @@ async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>)
let mut elapsed_ms = 0;
let mut truncated = false;
while let Some(batch) = stream.message().await.map_err(grpc_error)? {
loop {
let batch = match stream.message().await {
Ok(Some(batch)) => batch,
Ok(None) => break,
Err(error) => return error_fragment(error.message()),
};
if !batch.columns.is_empty() {
columns = batch.columns.into_iter().map(column_output).collect();
}
@@ -142,13 +225,71 @@ async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>)
}
}
Ok(Json(QueryOutput {
let output = QueryOutput {
columns,
rows,
row_count,
elapsed_ms,
truncated,
}))
};
render_result(&output, &input.chart_type)
}
async fn load_catalog(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<CatalogInput>,
) -> Html<String> {
if input.profile_name.trim().is_empty() {
return error_fragment("Enter a profile name to load its schema");
}
let Some(token) = cookie_value(&headers, "analytics_token") else {
return Html("<p class=\"error\">Please <a href=\"/login\">log in</a> before loading the schema.</p>".into());
};
let Ok(value) = MetadataValue::try_from(format!("Bearer {token}")) else {
return error_fragment("The stored bearer token is invalid");
};
let mut request = Request::new(GetAnalyticsCatalogRequest {
profile_name: input.profile_name,
});
request.metadata_mut().insert("authorization", value);
let mut client = state.analytics;
match client.get_analytics_catalog(request).await {
Ok(response) => render_catalog(&response.into_inner()),
Err(error) => error_fragment(error.message()),
}
}
async fn load_profiles(State(state): State<AppState>) -> Html<String> {
let mut client = state.definitions;
match client.get_profile_tree(Request::new(Empty {})).await {
Ok(response) => {
let profiles = response.into_inner().profiles;
if profiles.is_empty() {
return Html("<option value=\"\">No profiles available</option>".into());
}
let options = profiles
.iter()
.map(|profile| {
format!(
"<option value=\"{}\">{} ({} tables)</option>",
escape_html(&profile.name),
escape_html(&profile.name),
profile.tables.len(),
)
})
.collect::<String>();
Html(format!(
"<option value=\"\">Choose a profile…</option>{options}"
))
}
Err(error) => Html(format!(
"<option value=\"\">Could not load profiles: {}</option>",
escape_html(error.message()),
)),
}
}
fn column_output(column: AnalyticsResultColumn) -> ColumnOutput {
@@ -172,24 +313,271 @@ fn value_output(value: AnalyticsValue) -> Value {
}
}
fn grpc_error(error: tonic::Status) -> (StatusCode, Json<ErrorOutput>) {
let status = match error.code() {
tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST,
tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED,
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
tonic::Code::NotFound => StatusCode::NOT_FOUND,
tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
fn render_result(result: &QueryOutput, chart_type: &str) -> Html<String> {
let metadata = format!(
"{} rows in {} ms{}",
result.row_count,
result.elapsed_ms,
if result.truncated { " (truncated)" } else { "" },
);
if chart_type == "table" {
let headings = result
.columns
.iter()
.map(|column| format!("<th>{}</th>", escape_html(&column.name)))
.collect::<String>();
let rows = result
.rows
.iter()
.map(|row| {
let cells = row
.iter()
.map(|value| format!("<td>{}</td>", escape_html(&display_value(value))))
.collect::<String>();
format!("<tr>{cells}</tr>")
})
.collect::<String>();
return Html(format!(
"<p class=\"result-meta\">{metadata}</p><div class=\"table-wrap\"><table><thead><tr>{headings}</tr></thead><tbody>{rows}</tbody></table></div>"
));
}
if result.columns.len() < 2 {
return error_fragment("Charts require at least two result columns");
}
let names = result
.columns
.iter()
.map(|column| column.name.clone())
.collect::<Vec<_>>();
let option = match chart_type {
"pie" => json!({
"tooltip": { "trigger": "item" },
"legend": { "type": "scroll", "bottom": 0 },
"series": [{
"type": "pie",
"radius": ["25%", "68%"],
"data": result.rows.iter().map(|row| json!({
"name": row.first().cloned().unwrap_or(Value::Null),
"value": row.get(1).cloned().unwrap_or(Value::Null),
})).collect::<Vec<_>>()
}]
}),
"scatter" => json!({
"tooltip": { "trigger": "item" },
"xAxis": { "name": names[0], "type": "value" },
"yAxis": { "name": names[1], "type": "value" },
"series": [{
"name": names[1],
"type": "scatter",
"data": result.rows.iter().map(|row| json!([
row.first().cloned().unwrap_or(Value::Null),
row.get(1).cloned().unwrap_or(Value::Null),
])).collect::<Vec<_>>()
}]
}),
"bar" | "line" => {
let series = names
.iter()
.enumerate()
.skip(1)
.map(|(index, name)| json!({
"name": name,
"type": chart_type,
"smooth": chart_type == "line",
"data": result.rows.iter().map(|row| row.get(index).cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),
}))
.collect::<Vec<_>>();
json!({
"tooltip": { "trigger": "axis" },
"legend": { "type": "scroll", "bottom": 0 },
"grid": { "left": 55, "right": 25, "top": 35, "bottom": 70, "containLabel": true },
"xAxis": {
"type": "category",
"data": result.rows.iter().map(|row| row.first().cloned().unwrap_or(Value::Null)).collect::<Vec<_>>()
},
"yAxis": { "type": "value" },
"series": series,
})
}
_ => return error_fragment("Unknown chart type"),
};
api_error(status, error.message())
let safe_option = option.to_string().replace('<', "\\u003c");
Html(format!(
"<p class=\"result-meta\">{metadata}</p><div id=\"chart\" class=\"chart\"></div>\
<script>echarts.init(document.getElementById('chart')).setOption({safe_option});</script>"
))
}
fn api_error(status: StatusCode, message: impl Into<String>) -> (StatusCode, Json<ErrorOutput>) {
(
status,
Json(ErrorOutput {
error: message.into(),
}),
fn render_catalog(catalog: &GetAnalyticsCatalogResponse) -> Html<String> {
if catalog.tables.is_empty() {
return Html("<p class=\"hint\">This profile has no analytics tables.</p>".into());
}
let tables = catalog
.tables
.iter()
.map(render_catalog_table)
.collect::<String>();
let llm_context = build_llm_context(catalog);
Html(format!(
"<p class=\"schema-summary\"><strong>{}</strong> tables available</p>\
<div class=\"catalog-tables\">{tables}</div>\
<details class=\"llm-context\">\
<summary>LLM schema context</summary>\
<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>\
<button type=\"button\" class=\"secondary\" data-copy=\"llm-schema\">Copy context</button>\
<span id=\"copy-status\" class=\"hint\"></span>\
</details>",
catalog.tables.len(),
escape_html(&llm_context),
))
}
fn render_catalog_table(table: &AnalyticsTable) -> String {
let table_name = quote_identifier(&table.name);
let starter_query = format!("SELECT *\nFROM {table_name}\nLIMIT 100;");
let columns = table
.columns
.iter()
.map(|column| {
let mut details = column.field_type.clone();
if column.is_system {
details.push_str(", system");
}
if !column.rounding.is_empty() {
details.push_str(&format!(", rounding {}", column.rounding));
}
format!(
"<li><button type=\"button\" class=\"column-name\" data-insert=\"{}\">{}</button><span>{}</span></li>",
escape_html(&quote_identifier(&column.name)),
escape_html(&column.name),
escape_html(&details),
)
})
.collect::<String>();
let links = if table.links.is_empty() {
String::new()
} else {
let items = table
.links
.iter()
.map(|link| {
format!(
"<li><code>{}</code> → <code>{}</code>{}</li>",
escape_html(&link.source_column),
escape_html(&link.linked_table),
if link.required { " (required)" } else { "" },
)
})
.collect::<String>();
format!("<div class=\"links\"><span>Links</span><ul>{items}</ul></div>")
};
let currency = if table.base_currency.is_empty() {
String::new()
} else {
format!(
"<span class=\"currency\">{}</span>",
escape_html(&table.base_currency)
)
};
format!(
"<details class=\"catalog-table\">\
<summary><code>{}</code>{currency}</summary>\
<button type=\"button\" class=\"starter-query\" data-sql=\"{}\">Use starter query</button>\
<ul class=\"columns\">{columns}</ul>{links}\
</details>",
escape_html(&table.name),
escape_html(&starter_query),
)
}
fn build_llm_context(catalog: &GetAnalyticsCatalogResponse) -> String {
let mut text = format!(
"Write one read-only PostgreSQL SELECT query for the komp_ac analytics API.\n\
Profile: {}\n\n\
Rules:\n\
- Return only the SQL query, without Markdown fences or explanation.\n\
- Use exactly one read-only SELECT statement.\n\
- Use only the public table and column aliases listed below.\n\
- Physical database column names are not visible through this API.\n\
- Add a reasonable LIMIT unless the query is an aggregate with a naturally small result.\n\
- For a chart, put the category or x-axis column first and numeric value columns after it.\n\n\
AVAILABLE ANALYTICS SCHEMA\n",
catalog.profile_name,
);
for table in &catalog.tables {
text.push_str(&format!("\nTABLE {}\n", quote_identifier(&table.name)));
if !table.base_currency.is_empty() {
text.push_str(&format!(" Base currency: {}\n", table.base_currency));
}
text.push_str(" Columns:\n");
for column in &table.columns {
text.push_str(&format!(
" - {}: {}",
quote_identifier(&column.name),
column.field_type,
));
if column.is_system {
text.push_str(" [system]");
}
if !column.rounding.is_empty() {
text.push_str(&format!(" [rounding: {}]", column.rounding));
}
text.push('\n');
}
if !table.links.is_empty() {
text.push_str(" Links:\n");
for link in &table.links {
text.push_str(&format!(
" - {} references {} ({})\n",
quote_identifier(&link.source_column),
quote_identifier(&link.linked_table),
if link.required { "required" } else { "optional" },
));
}
}
}
text
}
fn quote_identifier(identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}
fn display_value(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(value) => value.clone(),
value => value.to_string(),
}
}
fn error_fragment(message: &str) -> Html<String> {
Html(format!("<p class=\"error\">{}</p>", escape_html(message)))
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(header::COOKIE)?
.to_str()
.ok()?
.split(';')
.map(str::trim)
.find_map(|cookie| cookie.strip_prefix(name)?.strip_prefix('='))
}

View File

@@ -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>

35
graphs/static/login.html Normal file
View File

@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login · Analytics graphs</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
main { width: min(420px, calc(100% - 32px)); margin: 15vh auto 0; }
.panel { padding: 22px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
h1 { margin: 0 0 18px; font-size: 24px; }
label { display: grid; gap: 5px; margin-bottom: 14px; color: #4b5563; font-size: 12px; }
input, button { width: 100%; font: inherit; }
input { border: 1px solid #cfd6dd; border-radius: 6px; padding: 9px; background: white; }
button { border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
button:disabled, button.htmx-request { opacity: .55; cursor: wait; }
.error { color: #b42318; }
.back { display: inline-block; margin-top: 14px; color: #2563eb; }
</style>
</head>
<body>
<main>
<form class="panel" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML" hx-disabled-elt="button" novalidate>
<h1>Login</h1>
<label>Username or email<input name="identifier" autocomplete="username"></label>
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
<button type="submit">Login</button>
<div id="login-status" aria-live="polite"></div>
</form>
<a class="back" href="/">Back to analytics</a>
</main>
</body>
</html>