webs unified
This commit is contained in:
231
web/src/pages/analytics/loader.rs
Normal file
231
web/src/pages/analytics/loader.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! gRPC calls behind the analytics endpoints, plus the shaping of their
|
||||
//! responses into the view models the templates render.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
analytics::{
|
||||
AnalyticsTable, ExecuteAnalyticsQueryRequest, GetAnalyticsCatalogRequest,
|
||||
GetAnalyticsCatalogResponse, analytics_value,
|
||||
},
|
||||
definitions::common::Empty,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{
|
||||
CatalogColumnView, CatalogLinkView, CatalogTableView, CatalogView, ColumnOutput, ProfileOption,
|
||||
QueryOutput,
|
||||
};
|
||||
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
pub(crate) async fn load_profiles(state: AppState) -> Result<Vec<ProfileOption>, String> {
|
||||
let mut definitions = state.definitions;
|
||||
let tree = definitions
|
||||
.get_profile_tree(tonic::Request::new(Empty {}))
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?
|
||||
.into_inner();
|
||||
Ok(tree
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|profile| ProfileOption {
|
||||
table_count: profile.tables.len(),
|
||||
name: profile.name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_catalog(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
profile_name: String,
|
||||
) -> Result<CatalogView, LoadError> {
|
||||
let request = authenticated_request(headers, GetAnalyticsCatalogRequest { profile_name })
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut client = state.analytics;
|
||||
let catalog = client
|
||||
.get_analytics_catalog(request)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
Ok(catalog_view(&catalog))
|
||||
}
|
||||
|
||||
pub(crate) async fn run_query(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
profile_name: String,
|
||||
sql: String,
|
||||
max_rows: u32,
|
||||
) -> Result<QueryOutput, LoadError> {
|
||||
let request = authenticated_request(
|
||||
headers,
|
||||
ExecuteAnalyticsQueryRequest {
|
||||
profile_name,
|
||||
sql,
|
||||
max_rows,
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
|
||||
let mut client = state.analytics;
|
||||
let mut stream = client
|
||||
.execute_analytics_query(request)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
|
||||
let mut output = QueryOutput {
|
||||
columns: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
row_count: 0,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
while let Some(batch) = stream
|
||||
.message()
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
{
|
||||
if !batch.columns.is_empty() {
|
||||
output.columns = batch
|
||||
.columns
|
||||
.into_iter()
|
||||
.map(|column| ColumnOutput {
|
||||
name: column.name,
|
||||
data_type: column.data_type,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
output.rows.extend(
|
||||
batch
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| row.values.into_iter().map(value_output).collect()),
|
||||
);
|
||||
if batch.is_final {
|
||||
output.row_count = batch.row_count;
|
||||
output.elapsed_ms = batch.elapsed_ms;
|
||||
output.truncated = batch.truncated;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn value_output(value: crate::analytics::AnalyticsValue) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
match value.kind {
|
||||
None | Some(analytics_value::Kind::NullValue(_)) => serde_json::Value::Null,
|
||||
Some(analytics_value::Kind::BoolValue(value)) => json!(value),
|
||||
Some(analytics_value::Kind::Int64Value(value)) => json!(value),
|
||||
Some(analytics_value::Kind::Uint64Value(value)) => json!(value),
|
||||
Some(analytics_value::Kind::DoubleValue(value)) => json!(value),
|
||||
Some(analytics_value::Kind::StringValue(value)) => json!(value),
|
||||
Some(analytics_value::Kind::BytesValue(value)) => serde_json::Value::String(
|
||||
value.iter().map(|byte| format!("{byte:02x}")).collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_view(catalog: &GetAnalyticsCatalogResponse) -> CatalogView {
|
||||
CatalogView {
|
||||
tables: catalog.tables.iter().map(catalog_table_view).collect(),
|
||||
llm_context: build_llm_context(catalog),
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView {
|
||||
CatalogTableView {
|
||||
name: table.name.clone(),
|
||||
base_currency: table.base_currency.clone(),
|
||||
starter_query: format!("SELECT *\nFROM {}\nLIMIT 100;", quote_identifier(&table.name)),
|
||||
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));
|
||||
}
|
||||
CatalogColumnView {
|
||||
name: column.name.clone(),
|
||||
insert_text: quote_identifier(&column.name),
|
||||
details,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
links: table
|
||||
.links
|
||||
.iter()
|
||||
.map(|link| CatalogLinkView {
|
||||
source_column: link.source_column.clone(),
|
||||
linked_table: link.linked_table.clone(),
|
||||
required: link.required,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
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('"', "\"\""))
|
||||
}
|
||||
80
web/src/pages/analytics/logic.rs
Normal file
80
web/src/pages/analytics/logic.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
ui::{Nav, Notice, render},
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader::{self, LoadError},
|
||||
state::{CatalogInput, QueryInput},
|
||||
ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn analytics_page(headers: HeaderMap) -> Html<String> {
|
||||
Html(ui::render_page(Nav::new(&headers, "analytics")))
|
||||
}
|
||||
|
||||
pub(crate) async fn load_profiles(State(state): State<AppState>) -> Html<String> {
|
||||
match loader::load_profiles(state).await {
|
||||
Ok(profiles) => Html(ui::render_profile_options(&profiles)),
|
||||
Err(message) => Html(ui::render_profile_options_error(&message)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_catalog(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(input): Form<CatalogInput>,
|
||||
) -> Response {
|
||||
if input.profile_name.trim().is_empty() {
|
||||
return notice(Notice::error("Enter a profile name to load its schema"));
|
||||
}
|
||||
match loader::load_catalog(state, &headers, input.profile_name).await {
|
||||
Ok(catalog) => Html(ui::render_catalog(&catalog)).into_response(),
|
||||
Err(error) => load_error(error, "loading the schema"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_query(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(input): Form<QueryInput>,
|
||||
) -> Response {
|
||||
if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
|
||||
return notice(Notice::error("Profile name and SQL are required"));
|
||||
}
|
||||
let result = loader::run_query(
|
||||
state,
|
||||
&headers,
|
||||
input.profile_name,
|
||||
input.sql,
|
||||
input.max_rows,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(output) => match ui::render_result(&output, &input.chart_type) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(message) => notice(Notice::error(&message)),
|
||||
},
|
||||
Err(error) => load_error(error, "running a query"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_error(error: LoadError, action: &str) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => notice(Notice::login_required(&format!(
|
||||
"You are not signed in for {action}."
|
||||
))),
|
||||
LoadError::Backend(message) => notice(Notice::error(&message)),
|
||||
}
|
||||
}
|
||||
|
||||
fn notice(notice: Notice<'_>) -> Response {
|
||||
Html(render(¬ice)).into_response()
|
||||
}
|
||||
26
web/src/pages/analytics/mod.rs
Normal file
26
web/src/pages/analytics/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! The analytics page and the three HTMX endpoints that feed it.
|
||||
//!
|
||||
//! GET / → analytics.html
|
||||
//! GET /api/profiles → profile_options.html
|
||||
//! POST /api/catalog → catalog.html
|
||||
//! POST /api/query → query_result.html
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) mod loader;
|
||||
pub(crate) mod logic;
|
||||
pub(crate) mod state;
|
||||
pub(crate) mod ui;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(logic::analytics_page))
|
||||
.route("/api/profiles", get(logic::load_profiles))
|
||||
.route("/api/catalog", post(logic::load_catalog))
|
||||
.route("/api/query", post(logic::run_query))
|
||||
}
|
||||
68
web/src/pages/analytics/state.rs
Normal file
68
web/src/pages/analytics/state.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct QueryInput {
|
||||
pub profile_name: String,
|
||||
pub sql: String,
|
||||
#[serde(default = "default_max_rows")]
|
||||
pub max_rows: u32,
|
||||
pub chart_type: String,
|
||||
}
|
||||
|
||||
fn default_max_rows() -> u32 {
|
||||
1000
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct CatalogInput {
|
||||
pub profile_name: String,
|
||||
}
|
||||
|
||||
/// One entry of the profile `<select>` on the analytics page.
|
||||
pub(crate) struct ProfileOption {
|
||||
pub name: String,
|
||||
pub table_count: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct QueryOutput {
|
||||
pub columns: Vec<ColumnOutput>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
pub row_count: u64,
|
||||
pub elapsed_ms: u64,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ColumnOutput {
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// The catalog sidebar, already shaped for `catalog.html` so the
|
||||
/// template holds no SQL-quoting or label-building logic.
|
||||
pub(crate) struct CatalogView {
|
||||
pub tables: Vec<CatalogTableView>,
|
||||
pub llm_context: String,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogTableView {
|
||||
pub name: String,
|
||||
pub base_currency: String,
|
||||
pub starter_query: String,
|
||||
pub columns: Vec<CatalogColumnView>,
|
||||
pub links: Vec<CatalogLinkView>,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogColumnView {
|
||||
pub name: String,
|
||||
/// The quoted identifier inserted into the SQL box when clicked.
|
||||
pub insert_text: String,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogLinkView {
|
||||
pub source_column: String,
|
||||
pub linked_table: String,
|
||||
pub required: bool,
|
||||
}
|
||||
160
web/src/pages/analytics/ui.rs
Normal file
160
web/src/pages/analytics/ui.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use askama::Template;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ui::{Nav, render};
|
||||
|
||||
use super::state::{CatalogView, ColumnOutput, ProfileOption, QueryOutput};
|
||||
|
||||
/// GET /
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/analytics.html")]
|
||||
struct AnalyticsPage {
|
||||
nav: Nav,
|
||||
}
|
||||
|
||||
/// GET /api/profiles — the `<option>` list swapped into the profile select.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/profile_options.html")]
|
||||
struct ProfileOptions<'a> {
|
||||
profiles: &'a [ProfileOption],
|
||||
error: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// POST /api/catalog
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/catalog.html")]
|
||||
struct CatalogFragment<'a> {
|
||||
tables: &'a [super::state::CatalogTableView],
|
||||
llm_context: &'a str,
|
||||
}
|
||||
|
||||
/// POST /api/query — a table when `chart_option` is `None`, otherwise an
|
||||
/// ECharts container carrying the option as a data attribute.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/query_result.html")]
|
||||
struct QueryResult<'a> {
|
||||
columns: &'a [ColumnOutput],
|
||||
rows: Vec<Vec<String>>,
|
||||
row_count: u64,
|
||||
elapsed_ms: u64,
|
||||
truncated: bool,
|
||||
chart_option: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(nav: Nav) -> String {
|
||||
render(&AnalyticsPage { nav })
|
||||
}
|
||||
|
||||
pub(crate) fn render_profile_options(profiles: &[ProfileOption]) -> String {
|
||||
render(&ProfileOptions {
|
||||
profiles,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_profile_options_error(message: &str) -> String {
|
||||
render(&ProfileOptions {
|
||||
profiles: &[],
|
||||
error: Some(message),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_catalog(catalog: &CatalogView) -> String {
|
||||
render(&CatalogFragment {
|
||||
tables: &catalog.tables,
|
||||
llm_context: &catalog.llm_context,
|
||||
})
|
||||
}
|
||||
|
||||
/// `Err` carries a message for the caller to show as a notice — the only
|
||||
/// failure is a chart type that the result shape cannot satisfy.
|
||||
pub(crate) fn render_result(result: &QueryOutput, chart_type: &str) -> Result<String, String> {
|
||||
let chart_option = match chart_type {
|
||||
"table" => None,
|
||||
_ => Some(chart_option(result, chart_type)?.to_string()),
|
||||
};
|
||||
Ok(render(&QueryResult {
|
||||
columns: &result.columns,
|
||||
rows: result
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(display_value).collect())
|
||||
.collect(),
|
||||
row_count: result.row_count,
|
||||
elapsed_ms: result.elapsed_ms,
|
||||
truncated: result.truncated,
|
||||
chart_option,
|
||||
}))
|
||||
}
|
||||
|
||||
fn chart_option(result: &QueryOutput, chart_type: &str) -> Result<Value, String> {
|
||||
if result.columns.len() < 2 {
|
||||
return Err("Charts require at least two result columns".to_string());
|
||||
}
|
||||
let names = result
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| column.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(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 Err("Unknown chart type".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn display_value(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => String::new(),
|
||||
Value::String(value) => value.clone(),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user