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 # Analytics graphs
A small Rust web server that forwards SQL queries to `AnalyticsService.ExecuteAnalyticsQuery` A small Rust web server that logs in through `AuthService.Login`, forwards SQL
and displays the streamed result with ECharts. queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed
result with HTMX and ECharts.
## Run ## Run
@@ -11,7 +12,8 @@ Start the main komp_ac gRPC server, then run:
cargo 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: `http://[::1]:50051`. Both addresses can be changed:
```sh ```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 all remaining columns as numeric series, pie uses the second column, and scatter
uses the first two columns. Table view displays every returned value. 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 profile selector is populated automatically through
the page. `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 std::{env, net::SocketAddr};
use axum::{ use axum::{
Json, Router, Form, Router,
extract::State, extract::State,
http::StatusCode, http::{HeaderMap, HeaderValue, header},
response::Html, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
}; };
mod analytics { mod analytics {
@@ -13,30 +13,71 @@ mod analytics {
"/../common/src/proto/komp_ac.analytics.rs" "/../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::{ use analytics::{
AnalyticsResultColumn, AnalyticsValue, ExecuteAnalyticsQueryRequest, AnalyticsResultColumn, AnalyticsTable, AnalyticsValue, ExecuteAnalyticsQueryRequest,
GetAnalyticsCatalogRequest, GetAnalyticsCatalogResponse,
analytics_service_client::AnalyticsServiceClient, analytics_value, 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::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tonic::{Request, metadata::MetadataValue, transport::Channel}; use tonic::{Request, metadata::MetadataValue, transport::Channel};
const INDEX_HTML: &str = include_str!("../static/index.html"); const INDEX_HTML: &str = include_str!("../static/index.html");
const LOGIN_HTML: &str = include_str!("../static/login.html");
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
analytics: AnalyticsServiceClient<Channel>, analytics: AnalyticsServiceClient<Channel>,
auth: AuthServiceClient<Channel>,
definitions: TableDefinitionClient<Channel>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct QueryInput { struct QueryInput {
profile_name: String, profile_name: String,
sql: String, sql: String,
#[serde(default)]
token: String,
#[serde(default = "default_max_rows")] #[serde(default = "default_max_rows")]
max_rows: u32, 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 { fn default_max_rows() -> u32 {
@@ -58,13 +99,6 @@ struct ColumnOutput {
data_type: String, data_type: String,
} }
#[derive(Serialize)]
struct ErrorOutput {
error: String,
}
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<ErrorOutput>)>;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let grpc_endpoint = 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 channel = Channel::from_shared(grpc_endpoint.clone())?.connect_lazy();
let state = AppState { 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() let app = Router::new()
.route("/", get(index)) .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)) .route("/api/query", post(run_query))
.with_state(state); .with_state(state);
@@ -94,12 +133,50 @@ async fn index() -> Html<&'static str> {
Html(INDEX_HTML) 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() { if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
return Err(api_error( return error_fragment("Profile name and SQL are required");
StatusCode::BAD_REQUEST,
"Profile name and SQL are required",
));
} }
let mut request = Request::new(ExecuteAnalyticsQueryRequest { 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, max_rows: input.max_rows,
}); });
if !input.token.trim().is_empty() { let Some(token) = cookie_value(&headers, "analytics_token") else {
let value = MetadataValue::try_from(format!("Bearer {}", input.token.trim())) return Html("<p class=\"error\">Please <a href=\"/login\">log in</a> before running a query.</p>".into());
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "The bearer token is invalid"))?; };
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); request.metadata_mut().insert("authorization", value);
}
let mut client = state.analytics; let mut client = state.analytics;
let mut stream = client let mut stream = match client.execute_analytics_query(request).await {
.execute_analytics_query(request) Ok(response) => response.into_inner(),
.await Err(error) => return error_fragment(error.message()),
.map_err(grpc_error)? };
.into_inner();
let mut columns = Vec::new(); let mut columns = Vec::new();
let mut rows = 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 elapsed_ms = 0;
let mut truncated = false; 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() { if !batch.columns.is_empty() {
columns = batch.columns.into_iter().map(column_output).collect(); 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, columns,
rows, rows,
row_count, row_count,
elapsed_ms, elapsed_ms,
truncated, 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 { 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>) { fn render_result(result: &QueryOutput, chart_type: &str) -> Html<String> {
let status = match error.code() { let metadata = format!(
tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST, "{} rows in {} ms{}",
tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED, result.row_count,
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN, result.elapsed_ms,
tonic::Code::NotFound => StatusCode::NOT_FOUND, if result.truncated { " (truncated)" } else { "" },
tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS, );
tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY, 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>) { fn render_catalog(catalog: &GetAnalyticsCatalogResponse) -> Html<String> {
( if catalog.tables.is_empty() {
status, return Html("<p class=\"hint\">This profile has no analytics tables.</p>".into());
Json(ErrorOutput { }
error: message.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,40 +4,88 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<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/echarts@6/dist/echarts.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
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(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; } h1 { margin: 0 0 18px; font-size: 24px; }
.panel { padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; } h2 { margin: 0 0 14px; font-size: 17px; }
.row { display: grid; grid-template-columns: 1fr 1fr 180px 120px; gap: 12px; margin-bottom: 12px; } .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; } label { display: grid; gap: 5px; color: #4b5563; font-size: 12px; }
input, select, textarea, button { font: inherit; } input, select, textarea, button { font: inherit; }
input, select, textarea { width: 100%; border: 1px solid #cfd6dd; border-radius: 6px; padding: 9px; background: white; } 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; } 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 { border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
button:disabled { opacity: .55; cursor: wait; } button:disabled { opacity: .55; }
#status { color: #667085; } .htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
#status.error { color: #b42318; } .actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
#chart { height: 560px; margin-top: 18px; } .hint, .result-meta { color: #667085; }
.table-wrap { display: none; max-height: 560px; overflow: auto; margin-top: 18px; } .error { color: #b42318; }
.success { color: #067647; }
.chart { height: 560px; }
.table-wrap { max-height: 560px; overflow: auto; }
table { width: 100%; border-collapse: collapse; background: white; } table { width: 100%; border-collapse: collapse; background: white; }
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; } th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
th { position: sticky; top: 0; background: #f9fafb; } 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> </style>
</head> </head>
<body> <body>
<main> <main>
<h1>Analytics graphs</h1> <div class="top"><h1>Analytics graphs</h1><a href="/login">Login</a></div>
<form id="query-form" class="panel">
<div class="row"> <div class="workspace">
<label>Profile<input id="profile" required autocomplete="off"></label> <aside class="panel sidebar">
<label>Bearer token<input id="token" type="password" autocomplete="off"></label> <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 <label>Chart
<select id="chart-type"> <select name="chart_type">
<option value="bar">Bar</option> <option value="bar">Bar</option>
<option value="line">Line</option> <option value="line">Line</option>
<option value="pie">Pie</option> <option value="pie">Pie</option>
@@ -45,123 +93,45 @@
<option value="table">Table</option> <option value="table">Table</option>
</select> </select>
</label> </label>
<label>Max rows<input id="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" required spellcheck="false" placeholder="SELECT category, SUM(amount) AS total FROM sales GROUP BY category"></textarea></label> <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"> <div class="actions">
<button id="run" type="submit">Run query</button> <button type="submit">Run query</button>
<span id="status"></span> <span class="htmx-indicator hint">Running…</span>
</div> </div>
</form> </form>
<div id="chart"></div>
<div id="table-wrap" class="table-wrap"><table id="table"></table></div> <section id="output" aria-live="polite"></section>
</section>
</div>
</main> </main>
<script> <script>
const form = document.querySelector('#query-form'); document.addEventListener('click', async (event) => {
const status = document.querySelector('#status'); const queryButton = event.target.closest('[data-sql]');
const runButton = document.querySelector('#run'); if (queryButton) {
const chartElement = document.querySelector('#chart'); const editor = document.getElementById('sql');
const tableWrap = document.querySelector('#table-wrap'); editor.value = queryButton.dataset.sql;
const table = document.querySelector('#table'); editor.focus();
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();
return; return;
} }
const names = result.columns.map(column => column.name); const columnButton = event.target.closest('[data-insert]');
const rows = result.rows; if (columnButton) {
let option; const editor = document.getElementById('sql');
if (type === 'pie') { const start = editor.selectionStart;
option = { editor.setRangeText(columnButton.dataset.insert, start, editor.selectionEnd, 'end');
tooltip: { trigger: 'item' }, editor.focus();
legend: { type: 'scroll', bottom: 0 }, return;
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' }))
};
}
chart.setOption(option, true);
chart.resize();
} }
function renderTable(result) { const copyButton = event.target.closest('[data-copy]');
chartElement.style.display = 'none'; if (copyButton) {
tableWrap.style.display = 'block'; const text = document.getElementById(copyButton.dataset.copy).value;
const header = `<thead><tr>${result.columns.map(column => `<th>${escapeHtml(column.name)}</th>`).join('')}</tr></thead>`; await navigator.clipboard.writeText(text);
const body = `<tbody>${result.rows.map(row => `<tr>${row.map(value => `<td>${escapeHtml(value ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>`; document.getElementById('copy-status').textContent = 'Copied';
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);
} }
});
</script> </script>
</body> </body>
</html> </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>