diff --git a/graphs/README.md b/graphs/README.md index f9f7c59..9740e97 100644 --- a/graphs/README.md +++ b/graphs/README.md @@ -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 . The default gRPC endpoint is +Open to log in, then use the analytics page at +. 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. diff --git a/graphs/src/main.rs b/graphs/src/main.rs index f2a755e..1f61133 100644 --- a/graphs/src/main.rs +++ b/graphs/src/main.rs @@ -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, + auth: AuthServiceClient, + definitions: TableDefinitionClient, } #[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 = Result, (StatusCode, Json)>; - #[tokio::main] async fn main() -> Result<(), Box> { let grpc_endpoint = @@ -75,11 +109,16 @@ async fn main() -> Result<(), Box> { 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, Json(input): Json) -> ApiResult { +async fn login_page() -> Html<&'static str> { + Html(LOGIN_HTML) +} + +async fn login(State(state): State, Form(input): Form) -> 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, + headers: HeaderMap, + Form(input): Form, +) -> Html { 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, Json(input): Json) 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("

Please log in before running a query.

".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, Json(input): Json) 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, Json(input): Json) } } - 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, + headers: HeaderMap, + Form(input): Form, +) -> Html { + 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("

Please log in before loading the schema.

".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) -> Html { + 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("".into()); + } + let options = profiles + .iter() + .map(|profile| { + format!( + "", + escape_html(&profile.name), + escape_html(&profile.name), + profile.tables.len(), + ) + }) + .collect::(); + Html(format!( + "{options}" + )) + } + Err(error) => Html(format!( + "", + 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) { - 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 { + 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!("{}", escape_html(&column.name))) + .collect::(); + let rows = result + .rows + .iter() + .map(|row| { + let cells = row + .iter() + .map(|value| format!("{}", escape_html(&display_value(value)))) + .collect::(); + format!("{cells}") + }) + .collect::(); + return Html(format!( + "

{metadata}

{headings}{rows}
" + )); + } + + 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::>(); + 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::>() + }] + }), + "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::>() + }] + }), + "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::>(), + })) + .collect::>(); + 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::>() + }, + "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!( + "

{metadata}

\ + " + )) } -fn api_error(status: StatusCode, message: impl Into) -> (StatusCode, Json) { - ( - status, - Json(ErrorOutput { - error: message.into(), - }), +fn render_catalog(catalog: &GetAnalyticsCatalogResponse) -> Html { + if catalog.tables.is_empty() { + return Html("

This profile has no analytics tables.

".into()); + } + + let tables = catalog + .tables + .iter() + .map(render_catalog_table) + .collect::(); + let llm_context = build_llm_context(catalog); + Html(format!( + "

{} tables available

\ +
{tables}
\ +
\ + LLM schema context\ +

Copy this entire text and include it when asking an LLM to write a query.

\ + \ + \ + \ +
", + 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!( + "
  • {}
  • ", + escape_html("e_identifier(&column.name)), + escape_html(&column.name), + escape_html(&details), + ) + }) + .collect::(); + let links = if table.links.is_empty() { + String::new() + } else { + let items = table + .links + .iter() + .map(|link| { + format!( + "
  • {}{}{}
  • ", + escape_html(&link.source_column), + escape_html(&link.linked_table), + if link.required { " (required)" } else { "" }, + ) + }) + .collect::(); + format!("
    Links
      {items}
    ") + }; + let currency = if table.base_currency.is_empty() { + String::new() + } else { + format!( + "{}", + escape_html(&table.base_currency) + ) + }; + + format!( + "
    \ + {}{currency}\ + \ +
      {columns}
    {links}\ +
    ", + 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 { + Html(format!("

    {}

    ", escape_html(message))) +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +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('=')) +} diff --git a/graphs/static/index.html b/graphs/static/index.html index b9220d6..d2593bc 100644 --- a/graphs/static/index.html +++ b/graphs/static/index.html @@ -4,164 +4,134 @@ Analytics graphs +
    -

    Analytics graphs

    -
    -
    - - - - -
    - -
    - - -
    -
    -
    -
    +

    Analytics graphs

    Login
    + +
    + + +
    +
    +
    + + +
    + +
    + + Running… +
    +
    + +
    +
    +
    - diff --git a/graphs/static/login.html b/graphs/static/login.html new file mode 100644 index 0000000..276e043 --- /dev/null +++ b/graphs/static/login.html @@ -0,0 +1,35 @@ + + + + + + Login · Analytics graphs + + + + +
    +
    +

    Login

    + + + +
    +
    + Back to analytics +
    + +