diff --git a/Cargo.lock b/Cargo.lock
index e4072fa4..7c6796e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -425,6 +425,58 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "askama"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb7125972258312e79827b60c9eb93938334100245081cf701a2dee981b17427"
+dependencies = [
+ "askama_macros",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ba5e7259a1580c61571e3116ebaaa01e3c001b2132b17c4cc5c70780ca3e994"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "askama_macros"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "236ce20b77cb13506eaf5024899f4af6e12e8825f390bd943c4c37fd8f322e46"
+dependencies = [
+ "askama_derive",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c63392767bb2df6aa65a6e1e3b80fd89bb7af6d58359b924c0695620f1512e"
+dependencies = [
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "unicode-ident",
+ "winnow 0.7.15",
+]
+
[[package]]
name = "async-ffi"
version = "0.5.0"
@@ -576,6 +628,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bb8"
version = "0.9.1"
@@ -753,7 +814,7 @@ version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c"
dependencies = [
- "darling 0.20.11",
+ "darling 0.23.0",
"ident_case",
"prettyplease",
"proc-macro2",
@@ -2430,7 +2491,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2594,7 +2655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4573,7 +4634,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5530,7 +5591,7 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
- "heck 0.4.1",
+ "heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
@@ -5681,7 +5742,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6257,7 +6318,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6815,7 +6876,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
dependencies = [
- "heck 0.4.1",
+ "heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -7520,7 +7581,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7867,7 +7928,7 @@ dependencies = [
"toml_datetime",
"toml_parser",
"toml_writer",
- "winnow",
+ "winnow 1.0.3",
]
[[package]]
@@ -7888,7 +7949,7 @@ dependencies = [
"indexmap 2.14.0",
"toml_datetime",
"toml_parser",
- "winnow",
+ "winnow 1.0.3",
]
[[package]]
@@ -7897,7 +7958,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "winnow",
+ "winnow 1.0.3",
]
[[package]]
@@ -8664,6 +8725,7 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549"
name = "web"
version = "0.1.0"
dependencies = [
+ "askama",
"axum",
"prost",
"prost-types",
@@ -8672,6 +8734,7 @@ dependencies = [
"tokio",
"tonic",
"tonic-prost",
+ "tower",
]
[[package]]
@@ -8831,7 +8894,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9001,6 +9064,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "winnow"
version = "1.0.3"
diff --git a/web/Cargo.toml b/web/Cargo.toml
index 606d960c..7b0a820c 100644
--- a/web/Cargo.toml
+++ b/web/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
+askama = "0.15.1"
axum = "0.8"
prost = "0.14.4"
prost-types = "0.14.4"
@@ -12,3 +13,6 @@ serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
tonic = "0.14.6"
tonic-prost = "0.14.6"
+
+[dev-dependencies]
+tower = { version = "0.5.3", features = ["util"] }
diff --git a/web/README.md b/web/README.md
index 2915d26d..96fb64b0 100644
--- a/web/README.md
+++ b/web/README.md
@@ -28,7 +28,88 @@ LISTEN_ADDRESS=127.0.0.1:8080 \
cargo run -p server -- server
```
-The first SQL result column is used for category labels. Bar and line charts use
+## Structure
+
+HTML lives in `.html` files, never in Rust strings. Templates are
+[askama](https://docs.rs/askama): compiled at build time, type-checked against
+their struct, and HTML-escaped automatically.
+
+**`templates/` mirrors `src/` directory for directory.** The Rust for a page
+and the markup for that page live at the same path in the two trees, and
+`src/pages/` itself matches the client crate — the same page names, the same
+`loader / logic / state / ui` split. A `#[template(path = …)]` is therefore the
+module's own path, which is also where the file is in `templates/`.
+
+```
+src/ templates/
+ ui/ ui/
+ mod.rs base.html ,
, navbar
+ (Nav, Alert, Notice, navbar.html the one navbar
+ ErrorPage, render) form_page.html admin form card layout
+ alert.html error/success macros
+ alert_fragment.html standalone POST reply
+ notice.html one-line inline notice
+ error.html full-page load failure
+ pages/ pages/
+ admin/admin/ admin/admin/
+ mod loader logic state ui admin.html
+ workspace.html
+ add_table/ add_table/
+ mod loader logic state ui add_table.html
+ add_logic/ add_logic/
+ … add_logic.html
+ add_validation/ add_validation/
+ … field.html rule.html set.html
+ shared_fields.html
+ analytics/ analytics/
+ … analytics.html catalog.html
+ profile_options.html query_result.html
+ import_export/import/ import_export/import/
+ … import.html
+ import_export/export/ import_export/export/
+ … export.html
+ login/ login/
+ mod logic state ui login.html
+
+static/app.css the only stylesheet, at /static/app.css
+```
+
+Every full page extends `ui/base.html`, which links the stylesheet and renders
+`ui/navbar.html`, so the navbar is on every page without a page opting in. The
+six admin forms extend `ui/form_page.html` for the back link and card chrome on
+top of that.
+
+Each endpoint maps to exactly one template. The Rust side of the mapping is the
+`#[derive(Template)]` struct in the page's `ui.rs`; the template names its
+endpoint in a comment on line 1.
+
+| Endpoint | Page directory | Template |
+| --- | --- | --- |
+| `GET /` | `pages/analytics/` | `analytics.html` |
+| `GET /api/profiles` | `pages/analytics/` | `profile_options.html` |
+| `POST /api/catalog` | `pages/analytics/` | `catalog.html` |
+| `POST /api/query` | `pages/analytics/` | `query_result.html` |
+| `GET /login` | `pages/login/` | `login.html` |
+| `POST /login` | `pages/login/` | sets the cookie, `hx-redirect` |
+| `GET /admin` | `pages/admin/admin/` | `admin.html` |
+| `GET /admin/workspace` | `pages/admin/admin/` | `workspace.html` |
+| `POST /logout` | `pages/admin/admin/` | clears the cookie, `hx-redirect` |
+| `GET /admin/tables/new` | `pages/add_table/` | `add_table.html` |
+| `GET /admin/logic/new` | `pages/add_logic/` | `add_logic.html` |
+| `GET /admin/validation/new` | `pages/add_validation/` | `field.html` |
+| `GET /admin/validation/rules/new` | `pages/add_validation/` | `rule.html` |
+| `GET /admin/validation/sets/new` | `pages/add_validation/` | `set.html` |
+| `GET /admin/import` | `pages/import_export/import/` | `import.html` |
+| `GET /admin/export` | `pages/import_export/export/` | `export.html` |
+
+Every form `POST` answers with `ui/alert_fragment.html`, swapped into the page's
+`#submission-status`. Analytics errors use the lighter `ui/notice.html`.
+
+Inside a page module the split is the same everywhere, and the same as the
+client: `mod.rs` declares the routes, `loader.rs` calls gRPC and shapes the
+result, `state.rs` holds the view model and form parsing, `logic.rs` is the
+handlers, and `ui.rs` binds state to the templates in that directory.
+
## Admin panel
The admin panel mirrors the client workflow with browser-oriented pages:
@@ -48,6 +129,7 @@ existing `TablesData` gRPC service.
## Analytics
+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.
diff --git a/web/src/lib.rs b/web/src/lib.rs
index 73d04810..6e06841d 100644
--- a/web/src/lib.rs
+++ b/web/src/lib.rs
@@ -1,14 +1,15 @@
use std::{env, net::SocketAddr};
use axum::{
- Form, Router,
- extract::State,
+ Router,
http::{HeaderMap, HeaderValue, header},
- response::{Html, IntoResponse, Response},
- routing::{get, post},
+ response::IntoResponse,
+ routing::get,
};
+
mod pages;
mod services;
+mod ui;
mod analytics {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
@@ -61,27 +62,22 @@ mod definitions {
}
}
-use analytics::{
- AnalyticsResultColumn, AnalyticsTable, AnalyticsValue, ExecuteAnalyticsQueryRequest,
- GetAnalyticsCatalogRequest, GetAnalyticsCatalogResponse,
- analytics_service_client::AnalyticsServiceClient, analytics_value,
-};
-use auth::{LoginRequest, auth_service_client::AuthServiceClient};
+use auth::auth_service_client::AuthServiceClient;
use definitions::{
- common::Empty,
table_definition::table_definition_client::TableDefinitionClient,
table_script::table_script_client::TableScriptClient,
table_structure::table_structure_service_client::TableStructureServiceClient,
table_validation::table_validation_service_client::TableValidationServiceClient,
tables_data::tables_data_client::TablesDataClient,
};
-use serde::{Deserialize, Serialize};
-use serde_json::{Value, json};
-use tonic::{Request, metadata::MetadataValue, transport::Channel};
+use tonic::transport::Channel;
-const INDEX_HTML: &str = include_str!("../static/index.html");
-const LOGIN_HTML: &str = include_str!("../static/login.html");
+use analytics::analytics_service_client::AnalyticsServiceClient;
+const APP_CSS: &str = include_str!("../static/app.css");
+
+/// The gRPC clients every handler shares. One lazily connected channel backs
+/// all of them.
#[derive(Clone)]
pub(crate) struct AppState {
analytics: AnalyticsServiceClient,
@@ -93,47 +89,7 @@ pub(crate) struct AppState {
tables_data: TablesDataClient,
}
-#[derive(Deserialize)]
-struct QueryInput {
- profile_name: String,
- sql: 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 {
- 1_000
-}
-
-#[derive(Serialize)]
-struct QueryOutput {
- columns: Vec,
- rows: Vec>,
- row_count: u64,
- elapsed_ms: u64,
- truncated: bool,
-}
-
-#[derive(Serialize)]
-struct ColumnOutput {
- name: String,
- data_type: String,
-}
-
-/// Starts the analytics web UI as a detached task on the current Tokio runtime.
+/// Starts the web UI as a detached task on the current Tokio runtime.
///
/// The web UI still uses the existing gRPC endpoints for now. Keeping startup
/// here limits the server integration to a single call until the application
@@ -146,7 +102,7 @@ pub fn spawn() -> tokio::task::JoinHandle<()> {
})
}
-/// Serves the analytics web UI until the task is cancelled or the listener fails.
+/// Serves the web UI until the task is cancelled or the listener fails.
pub async fn serve() -> Result<(), Box> {
let grpc_endpoint =
env::var("ANALYTICS_GRPC_ENDPOINT").unwrap_or_else(|_| "http://[::1]:50051".into());
@@ -165,474 +121,37 @@ pub async fn serve() -> Result<(), Box> {
structures: TableStructureServiceClient::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))
+ let listener = tokio::net::TcpListener::bind(listen_address).await?;
+ println!("Web UI: http://{listen_address}");
+ println!("Analytics gRPC endpoint: {grpc_endpoint}");
+ axum::serve(listener, router(state)).await?;
+ Ok(())
+}
+
+/// Every route in the site. One `router()` per page module, each of which
+/// documents the templates its endpoints render.
+fn router(state: AppState) -> Router {
+ Router::new()
+ .route("/static/app.css", get(stylesheet))
+ .merge(pages::analytics::router())
+ .merge(pages::login::router())
.merge(pages::admin::admin::router())
.merge(pages::add_table::router())
.merge(pages::add_logic::router())
.merge(pages::add_validation::router())
.merge(pages::import_export::router())
- .with_state(state);
-
- let listener = tokio::net::TcpListener::bind(listen_address).await?;
- println!("Web UI: http://{listen_address}");
- println!("Analytics gRPC endpoint: {grpc_endpoint}");
- axum::serve(listener, app).await?;
- Ok(())
+ .with_state(state)
}
-async fn index() -> Html<&'static str> {
- Html(INDEX_HTML)
-}
-
-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("/admin"));
- 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 error_fragment("Profile name and SQL are required");
- }
-
- let mut request = Request::new(ExecuteAnalyticsQueryRequest {
- profile_name: input.profile_name,
- sql: input.sql,
- max_rows: input.max_rows,
- });
-
- 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 = 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();
- let mut row_count = 0;
- let mut elapsed_ms = 0;
- let mut truncated = false;
-
- 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();
- }
- rows.extend(batch.rows.into_iter().map(|row| {
- row.values.into_iter().map(value_output).collect::>()
- }));
-
- if batch.is_final {
- row_count = batch.row_count;
- elapsed_ms = batch.elapsed_ms;
- truncated = batch.truncated;
- }
- }
-
- 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 {
- ColumnOutput {
- name: column.name,
- data_type: column.data_type,
- }
-}
-
-fn value_output(value: AnalyticsValue) -> Value {
- match value.kind {
- None | Some(analytics_value::Kind::NullValue(_)) => 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)) => {
- Value::String(value.iter().map(|byte| format!("{byte:02x}")).collect())
- }
- }
-}
-
-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}
"
- ));
- }
-
- 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"),
- };
- let option = escape_html(&option.to_string());
-
- Html(format!(
- "{metadata}
\
- "
- ))
-}
-
-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.
\
- \
- \
- Copied\
- ",
- 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!("")
- };
- let currency = if table.base_currency.is_empty() {
- String::new()
- } else {
- format!(
- "{}",
- escape_html(&table.base_currency)
- )
- };
-
- format!(
- "\
- {}{currency}
\
- \
- {links}\
- ",
- escape_html(&table.name),
- escape_html(&starter_query),
+/// The one stylesheet every page links, so no template inlines CSS.
+async fn stylesheet() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
+ APP_CSS,
)
}
-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> {
+pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get(header::COOKIE)?
.to_str()
@@ -641,3 +160,71 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
.map(str::trim)
.find_map(|cookie| cookie.strip_prefix(name)?.strip_prefix('='))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use axum::{body::Body, http::Request};
+ use tower::ServiceExt;
+
+ /// The channel is lazy, so routes that do not call gRPC serve fine
+ /// without a backend.
+ fn test_router() -> Router {
+ let channel = Channel::from_static("http://[::1]:50051").connect_lazy();
+ router(AppState {
+ analytics: AnalyticsServiceClient::new(channel.clone()),
+ auth: AuthServiceClient::new(channel.clone()),
+ definitions: TableDefinitionClient::new(channel.clone()),
+ scripts: TableScriptClient::new(channel.clone()),
+ validations: TableValidationServiceClient::new(channel.clone()),
+ tables_data: TablesDataClient::new(channel.clone()),
+ structures: TableStructureServiceClient::new(channel),
+ })
+ }
+
+ async fn get(path: &str) -> (axum::http::StatusCode, String) {
+ let response = test_router()
+ .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ let status = response.status();
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ (status, String::from_utf8(body.to_vec()).unwrap())
+ }
+
+ #[tokio::test]
+ async fn every_backend_free_page_renders_the_shared_shell() {
+ for path in ["/", "/login"] {
+ let (status, body) = get(path).await;
+ assert!(status.is_success(), "{path} returned {status}");
+ assert!(
+ body.contains(""),
+ "{path} is missing the shared navbar"
+ );
+ assert!(
+ body.contains("href=\"/static/app.css\""),
+ "{path} is missing the shared stylesheet"
+ );
+ assert!(
+ !body.contains("← Admin panel",
- crate::escape_html(&page.form.logic_name),
- crate::escape_html(&page.form.target_column),
- crate::escape_html(&page.form.script),
- crate::escape_html(&page.form.description),
- )
+/// GET /admin/logic/new
+#[derive(Template)]
+#[template(path = "pages/add_logic/add_logic.html")]
+struct AddLogicPage<'a> {
+ nav: Nav,
+ page: &'a AddLogicPageState,
}
+pub(crate) fn render_page(page: &AddLogicPageState) -> String {
+ render(&AddLogicPage {
+ nav: page.nav.clone(),
+ page,
+ })
+}
+
+/// POST /admin/logic — the #submission-status swaps.
pub(crate) fn render_submission_error(message: &str) -> String {
- format!(
- "",
- crate::escape_html(message),
- )
+ render(&Alert::error("Could not save the logic", message))
}
pub(crate) fn render_success(id: i64, warnings: &str) -> String {
- let warnings = if warnings.trim().is_empty() {
- String::new()
+ let message = if warnings.trim().is_empty() {
+ format!("Script #{id} was saved.")
} else {
- format!("Warnings: {}
", crate::escape_html(warnings))
+ format!("Script #{id} was saved. Warnings: {warnings}")
};
- format!(
- ""
- )
+ render(&Alert::success("Logic saved", &message))
}
diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs
index 25319c6a..6d5ab233 100644
--- a/web/src/pages/add_table/loader.rs
+++ b/web/src/pages/add_table/loader.rs
@@ -39,6 +39,7 @@ pub(crate) async fn load_page(
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
Ok(AddTablePageState {
+ nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles: tree.profiles.into_iter().map(|profile| profile.name).collect(),
form,
error,
diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs
index 48c95c8f..4b1727bb 100644
--- a/web/src/pages/add_table/state.rs
+++ b/web/src/pages/add_table/state.rs
@@ -23,6 +23,7 @@ pub(crate) struct CreateTableForm {
}
pub(crate) struct AddTablePageState {
+ pub nav: crate::ui::Nav,
pub profiles: Vec,
pub form: CreateTableForm,
pub error: Option,
diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs
index 86aefc83..12b95ee4 100644
--- a/web/src/pages/add_table/ui.rs
+++ b/web/src/pages/add_table/ui.rs
@@ -1,40 +1,25 @@
+use askama::Template;
+
+use crate::ui::{Alert, Nav, render};
+
use super::state::AddTablePageState;
-const ADMIN_CSS: &str = include_str!("../../../static/admin.css");
+/// GET /admin/tables/new
+#[derive(Template)]
+#[template(path = "pages/add_table/add_table.html")]
+struct AddTablePage<'a> {
+ nav: Nav,
+ page: &'a AddTablePageState,
+}
pub(crate) fn render_page(page: &AddTablePageState) -> String {
- let options = page
- .profiles
- .iter()
- .map(|profile| {
- format!(
- "",
- crate::escape_html(profile),
- if page.form.profile_name == *profile { "selected" } else { "" },
- crate::escape_html(profile),
- )
- })
- .collect::();
- let error = page
- .error
- .as_deref()
- .map(render_submission_error)
- .unwrap_or_default();
- format!(
- "Add table← Admin panel",
- crate::escape_html(&page.form.table_name),
- crate::escape_html(&page.form.columns),
- crate::escape_html(&page.form.indexed_columns),
- crate::escape_html(&page.form.base_currency),
- crate::escape_html(&page.form.required_links),
- crate::escape_html(&page.form.optional_links),
- crate::escape_html(&page.form.row_display_columns),
- )
+ render(&AddTablePage {
+ nav: page.nav.clone(),
+ page,
+ })
}
+/// POST /admin/tables — the #submission-status swap.
pub(crate) fn render_submission_error(message: &str) -> String {
- format!(
- "",
- crate::escape_html(message),
- )
+ render(&Alert::error("Could not create the table", message))
}
diff --git a/web/src/pages/add_validation/loader.rs b/web/src/pages/add_validation/loader.rs
index 03fc6f73..939a36ce 100644
--- a/web/src/pages/add_validation/loader.rs
+++ b/web/src/pages/add_validation/loader.rs
@@ -42,7 +42,14 @@ pub(crate) async fn load_page(
.iter()
.flat_map(|profile| profile.tables.iter().map(|table| table.name.clone()))
.collect();
- Ok(ValidationPageState { profiles, tables, form, reusable_rule, error })
+ Ok(ValidationPageState {
+ nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
+ profiles,
+ tables,
+ form,
+ reusable_rule,
+ error,
+ })
}
pub(crate) async fn load_set_page(
@@ -50,9 +57,10 @@ pub(crate) async fn load_set_page(
headers: &HeaderMap,
form: ValidationSetForm,
error: Option,
-) -> Result<(ValidationSetForm, Option), LoadError> {
- let _ = load_page(state, headers, ValidationForm::default(), true, None).await?;
- Ok((form, error))
+) -> Result<(crate::ui::Nav, ValidationSetForm, Option), LoadError> {
+ // Reuses the rule page load purely for its authorization check and nav.
+ let page = load_page(state, headers, ValidationForm::default(), true, None).await?;
+ Ok((page.nav, form, error))
}
pub(crate) enum LoadError {
diff --git a/web/src/pages/add_validation/logic.rs b/web/src/pages/add_validation/logic.rs
index 4c7bb6f0..91306a04 100644
--- a/web/src/pages/add_validation/logic.rs
+++ b/web/src/pages/add_validation/logic.rs
@@ -119,9 +119,13 @@ pub(crate) async fn save_validation_set(
}
}
-fn render_set_loaded(result: Result<(ValidationSetForm, Option), LoadError>) -> Response {
+fn render_set_loaded(
+ result: Result<(crate::ui::Nav, ValidationSetForm, Option), LoadError>,
+) -> Response {
match result {
- Ok((form, error)) => Html(ui::render_set_page(&form, error.as_deref())).into_response(),
+ Ok((nav, form, error)) => {
+ Html(ui::render_set_page(nav, &form, error.as_deref())).into_response()
+ }
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(),
Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(),
diff --git a/web/src/pages/add_validation/state.rs b/web/src/pages/add_validation/state.rs
index cf9a21bb..b4a0d5a5 100644
--- a/web/src/pages/add_validation/state.rs
+++ b/web/src/pages/add_validation/state.rs
@@ -52,6 +52,7 @@ pub(crate) struct ValidationForm {
pub validation_set_name: String,
}
pub(crate) struct ValidationPageState {
+ pub nav: crate::ui::Nav,
pub profiles: Vec,
pub tables: Vec,
pub form: ValidationForm,
diff --git a/web/src/pages/add_validation/ui.rs b/web/src/pages/add_validation/ui.rs
index e74d56fb..aa41a06b 100644
--- a/web/src/pages/add_validation/ui.rs
+++ b/web/src/pages/add_validation/ui.rs
@@ -1,79 +1,52 @@
+use askama::Template;
+
+use crate::ui::{Alert, Nav, render};
+
use super::state::{ValidationPageState, ValidationSetForm};
-const ADMIN_CSS: &str = include_str!("../../../static/admin.css");
+/// GET /admin/validation/new
+#[derive(Template)]
+#[template(path = "pages/add_validation/field.html")]
+struct FieldValidationPage<'a> {
+ nav: Nav,
+ page: &'a ValidationPageState,
+}
+
+/// GET /admin/validation/rules/new
+#[derive(Template)]
+#[template(path = "pages/add_validation/rule.html")]
+struct ValidationRulePage<'a> {
+ nav: Nav,
+ page: &'a ValidationPageState,
+}
+
+/// GET /admin/validation/sets/new
+#[derive(Template)]
+#[template(path = "pages/add_validation/set.html")]
+struct ValidationSetPage<'a> {
+ nav: Nav,
+ form: &'a ValidationSetForm,
+ error: Option<&'a str>,
+}
pub(crate) fn render_page(page: &ValidationPageState) -> String {
- let profiles = datalist("profiles", &page.profiles);
- let tables = datalist("tables", &page.tables);
- let error = page.error.as_deref().map(render_error).unwrap_or_default();
- let (title, action, mut identity) = if page.reusable_rule {
- (
- "Add reusable validation rule",
- "/admin/validation/rules",
- format!("", crate::escape_html(&page.form.rule_name), crate::escape_html(&page.form.description)),
- )
+ let nav = page.nav.clone();
+ if page.reusable_rule {
+ render(&ValidationRulePage { nav, page })
} else {
- (
- "Add field validation",
- "/admin/validation",
- format!("", crate::escape_html(&page.form.table_name), crate::escape_html(&page.form.data_key)),
- )
- };
- let profile_field = if page.reusable_rule {
- let set_field = if page.reusable_rule {
- String::new()
- } else {
- format!("", crate::escape_html(&page.form.validation_set_name))
- };
- identity.push_str(&format!(
- "{set_field}",
- crate::escape_html(&page.form.pattern_rules),
- crate::escape_html(&page.form.pattern_description),
- ));
- String::new()
- } else {
- format!("", crate::escape_html(&page.form.profile_name))
- };
- format!(
- "{title}← Admin panel",
- crate::escape_html(&page.form.minimum),
- crate::escape_html(&page.form.maximum),
- crate::escape_html(&page.form.warn_at),
- crate::escape_html(&page.form.allowed_values),
- crate::escape_html(&page.form.mask_pattern),
- crate::escape_html(&page.form.mask_input_char),
- crate::escape_html(&page.form.mask_template_char),
- checkbox("required", "Required", page.form.required),
- checkbox("allow_empty", "Allow empty", page.form.allow_empty),
- checkbox("case_insensitive", "Case insensitive", page.form.case_insensitive),
- checkbox("external_validation_enabled", "External validation", page.form.external_validation_enabled),
- checkbox("locked", "Lock configuration", page.form.locked),
- )
+ render(&FieldValidationPage { nav, page })
+ }
}
-pub(crate) fn render_set_page(form: &ValidationSetForm, error: Option<&str>) -> String {
- let error = error.map(render_error).unwrap_or_default();
- format!(
- "Add validation set← Admin panel",
- crate::escape_html(&form.name),
- crate::escape_html(&form.description),
- crate::escape_html(&form.global_rules),
- )
-}
-
-fn datalist(id: &str, values: &[String]) -> String {
- let options = values.iter().map(|value| format!("", crate::escape_html(value))).collect::();
- format!("")
-}
-
-fn checkbox(name: &str, label: &str, checked: bool) -> String {
- format!("", if checked { "checked" } else { "" })
+pub(crate) fn render_set_page(nav: Nav, form: &ValidationSetForm, error: Option<&str>) -> String {
+ render(&ValidationSetPage { nav, form, error })
}
+/// The #submission-status swaps for all three validation POST endpoints.
pub(crate) fn render_error(message: &str) -> String {
- format!("", crate::escape_html(message))
+ render(&Alert::error("Could not save validation", message))
}
pub(crate) fn render_success(message: &str) -> String {
- format!("", crate::escape_html(message))
+ render(&Alert::success("Validation saved", message))
}
diff --git a/web/src/pages/admin/admin/loader.rs b/web/src/pages/admin/admin/loader.rs
index 440e1b98..eb36b6de 100644
--- a/web/src/pages/admin/admin/loader.rs
+++ b/web/src/pages/admin/admin/loader.rs
@@ -121,7 +121,7 @@ pub(crate) async fn load_admin_page(
};
Ok(AdminPageState {
- role: authorization.role,
+ nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles,
selected_profile,
tables,
diff --git a/web/src/pages/admin/admin/state.rs b/web/src/pages/admin/admin/state.rs
index 12c29186..d90c1ab5 100644
--- a/web/src/pages/admin/admin/state.rs
+++ b/web/src/pages/admin/admin/state.rs
@@ -8,7 +8,7 @@ pub(crate) struct AdminSelection {
#[derive(Debug)]
pub(crate) struct AdminPageState {
- pub role: String,
+ pub nav: crate::ui::Nav,
pub profiles: Vec,
pub selected_profile: Option,
pub tables: Vec,
@@ -38,6 +38,21 @@ pub(crate) struct ColumnView {
pub quantity_ledger: bool,
}
+impl ColumnView {
+ /// The badge list rendered under each column name.
+ pub(crate) fn flags(&self) -> Vec<&'static str> {
+ let mut flags = Vec::new();
+ if self.primary_key {
+ flags.push("primary key");
+ }
+ flags.push(if self.nullable { "nullable" } else { "required" });
+ if self.quantity_ledger {
+ flags.push("quantity ledger");
+ }
+ flags
+ }
+}
+
#[derive(Debug)]
pub(crate) enum LoadError {
Unauthenticated,
diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs
index 8ca68bd9..676a81cc 100644
--- a/web/src/pages/admin/admin/ui.rs
+++ b/web/src/pages/admin/admin/ui.rs
@@ -1,124 +1,58 @@
+use askama::Template;
+
+use crate::ui::{ErrorPage, Nav, render};
+
use super::state::AdminPageState;
-const ADMIN_CSS: &str = include_str!("../../../../static/admin.css");
+/// GET /admin
+#[derive(Template)]
+#[template(path = "pages/admin/admin/admin.html")]
+struct AdminPage<'a> {
+ nav: Nav,
+ page: &'a AdminPageState,
+}
+
+/// GET /admin/workspace — the HTMX swap for the three-pane browser.
+#[derive(Template)]
+#[template(path = "pages/admin/admin/workspace.html")]
+struct AdminWorkspace<'a> {
+ page: &'a AdminPageState,
+}
pub(crate) fn render_page(page: &AdminPageState) -> String {
- format!(
- "Admin panelWorkspace
Admin panel
Browse profiles, tables, and their physical columns.
{}
",
- crate::escape_html(&page.role),
- render_workspace(page),
- )
+ render(&AdminPage {
+ nav: page.nav.clone(),
+ page,
+ })
}
pub(crate) fn render_workspace(page: &AdminPageState) -> String {
- format!(
- "{}{}{}
",
- render_profiles(page),
- render_tables(page),
- render_columns(page),
- )
+ render(&AdminWorkspace { page })
}
pub(crate) fn render_error(message: &str) -> String {
- format!(
- "Admin panel unavailable
{}
Try again ",
- crate::escape_html(message),
- )
-}
-
-fn render_profiles(page: &AdminPageState) -> String {
- let items = if page.profiles.is_empty() {
- "No profiles available.
".to_string()
- } else {
- page.profiles
- .iter()
- .map(|profile| {
- let selected = page.selected_profile.as_deref() == Some(profile.name.as_str());
- format!(
- "",
- if selected { "selected" } else { "" },
- crate::escape_html(&profile.name),
- crate::escape_html(&profile.name),
- profile.table_count,
- )
- })
- .collect()
- };
- format!("", page.profiles.len())
-}
-
-fn render_tables(page: &AdminPageState) -> String {
- let items = if page.selected_profile.is_none() {
- "Select a profile to see its tables.
".to_string()
- } else if page.tables.is_empty() {
- "This profile has no tables.
".to_string()
- } else {
- page.tables
- .iter()
- .map(|table| {
- let selected = page.selected_table.as_deref() == Some(table.name.as_str());
- let dependencies = if table.depends_on.is_empty() {
- "No dependencies".to_string()
- } else {
- format!("Depends on {}", table.depends_on.join(", "))
- };
- format!(
- "",
- crate::escape_html(page.selected_profile.as_deref().unwrap_or_default()),
- if selected { "selected" } else { "" },
- crate::escape_html(&table.name),
- crate::escape_html(&table.name),
- crate::escape_html(&dependencies),
- crate::escape_html(&table.row_display_columns.join(", ")),
- )
- })
- .collect()
- };
- format!("", page.tables.len())
-}
-
-fn render_columns(page: &AdminPageState) -> String {
- let items = if page.selected_table.is_none() {
- "Select a table to inspect its columns.
".to_string()
- } else if page.columns.is_empty() {
- "This table has no visible columns.
".to_string()
- } else {
- page.columns
- .iter()
- .map(|column| {
- let mut flags = Vec::new();
- if column.primary_key {
- flags.push("primary key");
- }
- if column.nullable {
- flags.push("nullable");
- } else {
- flags.push("required");
- }
- if column.quantity_ledger {
- flags.push("quantity ledger");
- }
- format!(
- "{}{}{}
",
- crate::escape_html(&column.name),
- crate::escape_html(&column.data_type),
- flags.join(" · "),
- )
- })
- .collect()
- };
- format!("", page.columns.len())
+ render(&ErrorPage {
+ nav: Nav::default(),
+ heading: "Admin panel unavailable",
+ message,
+ })
}
#[cfg(test)]
mod tests {
use super::*;
- use crate::pages::admin::admin::state::AdminPageState;
#[test]
fn dashboard_links_every_admin_action() {
- let page = AdminPageState {
+ // The admin page is only reachable with a session, so the navbar
+ // shows the role badge and the log-out button.
+ let nav = Nav {
+ authenticated: true,
role: "admin".to_string(),
+ active: "admin",
+ };
+ let page = AdminPageState {
+ nav,
profiles: Vec::new(),
selected_profile: None,
tables: Vec::new(),
diff --git a/web/src/pages/analytics/loader.rs b/web/src/pages/analytics/loader.rs
new file mode 100644
index 00000000..5f5255a9
--- /dev/null
+++ b/web/src/pages/analytics/loader.rs
@@ -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, 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 {
+ 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 {
+ 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('"', "\"\""))
+}
diff --git a/web/src/pages/analytics/logic.rs b/web/src/pages/analytics/logic.rs
new file mode 100644
index 00000000..f68da588
--- /dev/null
+++ b/web/src/pages/analytics/logic.rs
@@ -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 {
+ Html(ui::render_page(Nav::new(&headers, "analytics")))
+}
+
+pub(crate) async fn load_profiles(State(state): State) -> Html {
+ 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,
+ headers: HeaderMap,
+ Form(input): Form,
+) -> 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,
+ headers: HeaderMap,
+ Form(input): Form,
+) -> 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()
+}
diff --git a/web/src/pages/analytics/mod.rs b/web/src/pages/analytics/mod.rs
new file mode 100644
index 00000000..d13ad0c4
--- /dev/null
+++ b/web/src/pages/analytics/mod.rs
@@ -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 {
+ 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))
+}
diff --git a/web/src/pages/analytics/state.rs b/web/src/pages/analytics/state.rs
new file mode 100644
index 00000000..c4b5c03d
--- /dev/null
+++ b/web/src/pages/analytics/state.rs
@@ -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 `