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}

{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"), - }; - 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!("
    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), +/// 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("
    Komp Accounting
    ← Admin panel

    Computed column

    Add logic

    Create or update a Steel script through the existing table-script service.

    {error}
    Cancel
    ", - 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!( - "
    Could not save the logic

    {}

    ", - 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!( - "
    Logic saved

    Script #{id} was saved.

    {warnings}
    " - ) + 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
    Komp Accounting
    ← Admin panel

    Table definition

    Add table

    Create a table through the existing gRPC table-definition service.

    {error}
    Cancel
    ", - 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!( - "
    Could not create the table

    {}

    ", - 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}
    Komp Accounting
    ← Admin panel

    Validation

    {title}

    {profile_field}{identity}
    {}{}{}{}{}
    {profiles}{tables}
    {error}
    Cancel
    ", - 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
    Komp Accounting
    ← Admin panel

    Global validation library

    Add validation set

    Compose an ordered set from existing reusable rule names.

    {error}
    Cancel
    ", - 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!("{options}") -} - -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!("
    Could not save validation

    {}

    ", crate::escape_html(message)) + render(&Alert::error("Could not save validation", message)) } pub(crate) fn render_success(message: &str) -> String { - format!("
    Validation saved

    {}

    ", 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 panel
    Komp Accounting{}

    Workspace

    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!("

    Profiles

    {}
    {items}
    ", 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!("

    Tables

    {}
    {items}
    ", 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!("

    Columns

    {}
    {items}
    ", 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 `{profiles}{tables}
    Cancel
    " - ) + render(&ExportPage { + nav: page.nav.clone(), + page, + }) } +/// POST /admin/export.csv — shown only when the download cannot be produced. pub(crate) fn render_error(message: &str) -> String { - format!("
    Could not export CSV

    {}

    ", crate::escape_html(message)) + render(&Alert::error("Could not export CSV", message)) } diff --git a/web/src/pages/import_export/import/loader.rs b/web/src/pages/import_export/import/loader.rs index 94397c32..7967ef64 100644 --- a/web/src/pages/import_export/import/loader.rs +++ b/web/src/pages/import_export/import/loader.rs @@ -13,5 +13,10 @@ pub(crate) async fn load_page( form: ImportForm, error: Option, ) -> Result { - Ok(ImportPageState { catalog: load_catalog(state, headers).await?, form, error }) + Ok(ImportPageState { + nav: crate::ui::Nav::new(headers, "admin"), + catalog: load_catalog(state, headers).await?, + form, + error, + }) } diff --git a/web/src/pages/import_export/import/state.rs b/web/src/pages/import_export/import/state.rs index a83108d9..f8d247c4 100644 --- a/web/src/pages/import_export/import/state.rs +++ b/web/src/pages/import_export/import/state.rs @@ -9,6 +9,7 @@ pub(crate) struct ImportForm { } pub(crate) struct ImportPageState { + pub nav: crate::ui::Nav, pub catalog: super::super::common::loader::Catalog, pub form: ImportForm, pub error: Option, diff --git a/web/src/pages/import_export/import/ui.rs b/web/src/pages/import_export/import/ui.rs index d89fd065..f0ad7eb4 100644 --- a/web/src/pages/import_export/import/ui.rs +++ b/web/src/pages/import_export/import/ui.rs @@ -1,22 +1,32 @@ +use askama::Template; + +use crate::ui::{Alert, Nav, render}; + use super::state::ImportPageState; -const ADMIN_CSS: &str = include_str!("../../../../static/admin.css"); - -pub(crate) fn render_page(page: &ImportPageState) -> String { - let profiles = page.catalog.profiles.iter().map(|profile| format!("", crate::escape_html(&profile.name), if page.form.profile_name == profile.name { "selected" } else { "" }, crate::escape_html(&profile.name))).collect::(); - let tables = page.catalog.profiles.iter().flat_map(|profile| profile.tables.iter()).map(|table| format!("", crate::escape_html(table))).collect::(); - let error = page.error.as_deref().map(render_error).unwrap_or_default(); - format!( - "Import CSV
    Komp Accounting
    ← Admin panel

    Data transfer

    Import CSV

    Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.

    {tables}
    {error}
    Cancel
    ", - crate::escape_html(&page.form.table_names), - crate::escape_html(&page.form.csv_data), - ) +/// GET /admin/import +#[derive(Template)] +#[template(path = "pages/import_export/import/import.html")] +struct ImportPage<'a> { + nav: Nav, + page: &'a ImportPageState, } +pub(crate) fn render_page(page: &ImportPageState) -> String { + render(&ImportPage { + nav: page.nav.clone(), + page, + }) +} + +/// POST /admin/import — the #submission-status swaps. pub(crate) fn render_error(message: &str) -> String { - format!("
    Could not import CSV

    {}

    ", crate::escape_html(message)) + render(&Alert::error("Could not import CSV", message)) } pub(crate) fn render_success(inserted: usize, source_rows: usize, table_count: usize) -> String { - format!("
    Import complete

    Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s).

    ") + let message = format!( + "Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s)." + ); + render(&Alert::success("Import complete", &message)) } diff --git a/web/src/pages/login/logic.rs b/web/src/pages/login/logic.rs new file mode 100644 index 00000000..1085a4dd --- /dev/null +++ b/web/src/pages/login/logic.rs @@ -0,0 +1,60 @@ +use axum::{ + Form, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{Html, IntoResponse, Response}, +}; +use tonic::Request; + +use crate::{AppState, auth::LoginRequest, ui::Nav}; + +use super::{state::LoginInput, ui}; + +pub(crate) async fn login_page(headers: HeaderMap) -> Html { + Html(ui::render_page(Nav::new(&headers, "login"))) +} + +pub(crate) async fn login( + State(state): State, + Form(input): Form, +) -> Response { + if input.identifier.trim().is_empty() { + return error(StatusCode::BAD_REQUEST, "Username or email is required"); + } + + let mut client = state.auth; + let login = match client + .login(Request::new(LoginRequest { + identifier: input.identifier, + password: input.password, + })) + .await + { + Ok(response) => response.into_inner(), + Err(status) => return error(StatusCode::UNAUTHORIZED, status.message()), + }; + + let cookie = format!( + "{}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", + crate::ui::SESSION_COOKIE, + login.access_token, + login.expires_in, + ); + let Ok(cookie) = HeaderValue::try_from(cookie) else { + return error( + StatusCode::BAD_GATEWAY, + "The server returned an invalid access token", + ); + }; + + 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 +} + +fn error(status: StatusCode, message: &str) -> Response { + (status, Html(ui::render_error(message))).into_response() +} diff --git a/web/src/pages/login/mod.rs b/web/src/pages/login/mod.rs new file mode 100644 index 00000000..77bbee07 --- /dev/null +++ b/web/src/pages/login/mod.rs @@ -0,0 +1,16 @@ +//! GET /login → login.html +//! POST /login → sets the session cookie, then hx-redirects to /admin +//! +//! The matching POST /logout lives with the admin page, which owns the button. + +use axum::{Router, routing::get}; + +use crate::AppState; + +pub(crate) mod logic; +pub(crate) mod state; +pub(crate) mod ui; + +pub(crate) fn router() -> Router { + Router::new().route("/login", get(logic::login_page).post(logic::login)) +} diff --git a/web/src/pages/login/state.rs b/web/src/pages/login/state.rs new file mode 100644 index 00000000..a02fc15c --- /dev/null +++ b/web/src/pages/login/state.rs @@ -0,0 +1,6 @@ +#[derive(serde::Deserialize)] +pub(crate) struct LoginInput { + pub identifier: String, + #[serde(default)] + pub password: String, +} diff --git a/web/src/pages/login/ui.rs b/web/src/pages/login/ui.rs new file mode 100644 index 00000000..b6efce77 --- /dev/null +++ b/web/src/pages/login/ui.rs @@ -0,0 +1,19 @@ +use askama::Template; + +use crate::ui::{Alert, Nav, render}; + +/// GET /login +#[derive(Template)] +#[template(path = "pages/login/login.html")] +struct LoginPage { + nav: Nav, +} + +pub(crate) fn render_page(nav: Nav) -> String { + render(&LoginPage { nav }) +} + +/// POST /login — the #login-status swap when the credentials are rejected. +pub(crate) fn render_error(message: &str) -> String { + render(&Alert::error("Could not sign in", message)) +} diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 0aac2241..7503ce96 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -2,4 +2,6 @@ pub(crate) mod add_logic; pub(crate) mod add_table; pub(crate) mod add_validation; pub(crate) mod admin; +pub(crate) mod analytics; pub(crate) mod import_export; +pub(crate) mod login; diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs new file mode 100644 index 00000000..4c00bfe6 --- /dev/null +++ b/web/src/ui/mod.rs @@ -0,0 +1,119 @@ +//! The page shell: navbar, layouts, and the status blocks every page reuses. +//! +//! `templates/` mirrors `src/` directory for directory, so this module's +//! markup is in `templates/ui/` and a page's markup is in +//! `templates//`. + +use askama::Template; +use axum::http::HeaderMap; + +pub(crate) const SESSION_COOKIE: &str = "analytics_token"; + +/// Navbar state. Every full-page template struct carries one of these, because +/// `ui/base.html` renders `ui/navbar.html` unconditionally. +#[derive(Clone, Debug)] +pub(crate) struct Nav { + pub authenticated: bool, + pub role: String, + pub active: &'static str, +} + +impl Nav { + /// `active` is the nav link to highlight: `"admin"`, `"analytics"`, + /// `"login"`, or `""` for pages that are not themselves nav entries. + pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self { + Self { + authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(), + role: String::new(), + active, + } + } + + /// The admin page is the only one that learns the caller's role, so it is + /// the only one that can show the role badge. + pub(crate) fn with_role(mut self, role: String) -> Self { + self.role = role; + self + } +} + +impl Default for Nav { + fn default() -> Self { + Self { + authenticated: false, + role: String::new(), + active: "", + } + } +} + +/// The swap target every form POST answers with. +#[derive(Template)] +#[template(path = "ui/alert_fragment.html")] +pub(crate) struct Alert<'a> { + success: bool, + title: &'a str, + message: &'a str, +} + +impl<'a> Alert<'a> { + pub(crate) fn error(title: &'a str, message: &'a str) -> Self { + Self { + success: false, + title, + message, + } + } + + pub(crate) fn success(title: &'a str, message: &'a str) -> Self { + Self { + success: true, + title, + message, + } + } +} + +/// A one-line notice, for places where the alert card is too heavy. +#[derive(Template)] +#[template(path = "ui/notice.html")] +pub(crate) struct Notice<'a> { + error: bool, + message: &'a str, + login_link: bool, +} + +impl<'a> Notice<'a> { + pub(crate) fn error(message: &'a str) -> Self { + Self { + error: true, + message, + login_link: false, + } + } + + pub(crate) fn login_required(message: &'a str) -> Self { + Self { + error: true, + message, + login_link: true, + } + } +} + +/// Standalone page for load failures that are not worth a redirect. +#[derive(Template)] +#[template(path = "ui/error.html")] +pub(crate) struct ErrorPage<'a> { + pub nav: Nav, + pub heading: &'a str, + pub message: &'a str, +} + +/// Renders a template, or a plain error paragraph if the template itself +/// fails. Askama only fails on `fmt` errors, so this is a formality. +pub(crate) fn render(template: &T) -> String { + template + .render() + .unwrap_or_else(|error| format!("

    Template error: {error}

    ")) +} diff --git a/web/static/admin.css b/web/static/app.css similarity index 50% rename from web/static/admin.css rename to web/static/app.css index 4c588157..d012c4f3 100644 --- a/web/static/admin.css +++ b/web/static/app.css @@ -1,20 +1,38 @@ +/* One stylesheet for every page. Served at /static/app.css and linked from + templates/base.html, so no page inlines its own - - -
    -

    Analytics graphs

    - -
    - - -
    -
    -
    - - -
    - -
    - - Running… -
    -
    - -
    -
    -
    -
    - - diff --git a/web/static/login.html b/web/static/login.html deleted file mode 100644 index d6eeca29..00000000 --- a/web/static/login.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - Login · Komp Accounting - - - - -
    -
    -

    Sign in

    - - - -
    -
    - Back to admin panel -
    - - diff --git a/web/templates/pages/add_logic/add_logic.html b/web/templates/pages/add_logic/add_logic.html new file mode 100644 index 00000000..3be221d4 --- /dev/null +++ b/web/templates/pages/add_logic/add_logic.html @@ -0,0 +1,37 @@ +{# GET /admin/logic/new — crate::pages::add_logic::ui::AddLogicPage #} +{% extends "ui/form_page.html" %} +{% import "ui/alert.html" as alert %} + +{% block title %}Add logic{% endblock %} +{% block eyebrow %}Computed column{% endblock %} +{% block heading %}Add logic{% endblock %} +{% block lead %}

    Create or update a Steel script through the existing table-script service.

    {% endblock %} + +{% block form %} +
    +
    + + + + + +
    +
    + {%- if let Some(message) = page.error %}{% call alert::error("Could not save the logic", message) %}{% endcall %}{% endif -%} +
    +
    + Cancel + +
    +
    +{% endblock %} diff --git a/web/templates/pages/add_table/add_table.html b/web/templates/pages/add_table/add_table.html new file mode 100644 index 00000000..e297e1b9 --- /dev/null +++ b/web/templates/pages/add_table/add_table.html @@ -0,0 +1,42 @@ +{# GET /admin/tables/new — crate::pages::add_table::ui::AddTablePage #} +{% extends "ui/form_page.html" %} +{% import "ui/alert.html" as alert %} + +{% block title %}Add table{% endblock %} +{% block eyebrow %}Table definition{% endblock %} +{% block heading %}Add table{% endblock %} +{% block lead %}

    Create a table through the existing gRPC table-definition service.

    {% endblock %} + +{% block form %} +
    +
    + + + + + + + + +
    +
    + {%- if let Some(message) = page.error %}{% call alert::error("Could not create the table", message) %}{% endcall %}{% endif -%} +
    +
    + Cancel + +
    +
    +{% endblock %} diff --git a/web/templates/pages/add_validation/field.html b/web/templates/pages/add_validation/field.html new file mode 100644 index 00000000..bd1ac6d2 --- /dev/null +++ b/web/templates/pages/add_validation/field.html @@ -0,0 +1,32 @@ +{# GET /admin/validation/new — crate::pages::add_validation::ui::FieldValidationPage #} +{% extends "ui/form_page.html" %} +{% import "ui/alert.html" as alert %} + +{% block title %}Add field validation{% endblock %} +{% block eyebrow %}Validation{% endblock %} +{% block heading %}Add field validation{% endblock %} +{% block lead %}

    Attach validation to one column of one table.

    {% endblock %} + +{% block form %} +
    +
    + + + + + {% include "pages/add_validation/shared_fields.html" %} +
    + {% for profile in page.profiles %}{% endfor %} + {% for table in page.tables %}{% endfor %} +
    + {%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%} +
    +
    + Cancel + +
    +
    +{% endblock %} diff --git a/web/templates/pages/add_validation/rule.html b/web/templates/pages/add_validation/rule.html new file mode 100644 index 00000000..bb912b7c --- /dev/null +++ b/web/templates/pages/add_validation/rule.html @@ -0,0 +1,25 @@ +{# GET /admin/validation/rules/new — crate::pages::add_validation::ui::ValidationRulePage #} +{% extends "ui/form_page.html" %} +{% import "ui/alert.html" as alert %} + +{% block title %}Add reusable validation rule{% endblock %} +{% block eyebrow %}Global validation library{% endblock %} +{% block heading %}Add reusable validation rule{% endblock %} +{% block lead %}

    A named rule that validation sets can compose, independent of any table.

    {% endblock %} + +{% block form %} +
    +
    + + + {% include "pages/add_validation/shared_fields.html" %} +
    +
    + {%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%} +
    +
    + Cancel + +
    +
    +{% endblock %} diff --git a/web/templates/pages/add_validation/set.html b/web/templates/pages/add_validation/set.html new file mode 100644 index 00000000..9a810b0e --- /dev/null +++ b/web/templates/pages/add_validation/set.html @@ -0,0 +1,28 @@ +{# GET /admin/validation/sets/new — crate::pages::add_validation::ui::ValidationSetPage #} +{% extends "ui/form_page.html" %} +{% import "ui/alert.html" as alert %} + +{% block title %}Add validation set{% endblock %} +{% block eyebrow %}Global validation library{% endblock %} +{% block heading %}Add validation set{% endblock %} +{% block lead %}

    Compose an ordered set from existing reusable rule names.

    {% endblock %} + +{% block form %} +
    +
    + + + +
    +
    + {%- if let Some(message) = error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%} +
    +
    + Cancel + +
    +
    +{% endblock %} diff --git a/web/templates/pages/add_validation/shared_fields.html b/web/templates/pages/add_validation/shared_fields.html new file mode 100644 index 00000000..cb307fed --- /dev/null +++ b/web/templates/pages/add_validation/shared_fields.html @@ -0,0 +1,34 @@ +{# + Validation inputs shared by the field-validation and reusable-rule pages. + Reads `page.form` (crate::pages::add_validation::state::ValidationForm). +#} + + + + + + + + + + + + +
    + + + + + +
    diff --git a/web/templates/pages/admin/admin/admin.html b/web/templates/pages/admin/admin/admin.html new file mode 100644 index 00000000..0c51c574 --- /dev/null +++ b/web/templates/pages/admin/admin/admin.html @@ -0,0 +1,25 @@ +{# GET /admin — crate::pages::admin::admin::ui::AdminPage #} +{% extends "ui/base.html" %} + +{% block title %}Admin panel{% endblock %} + +{% block content %} +
    +
    +
    +

    Workspace

    +

    Admin panel

    +

    Browse profiles, tables, and their physical columns.

    +
    + +
    +
    {% include "pages/admin/admin/workspace.html" %}
    +
    +{% endblock %} diff --git a/web/templates/pages/admin/admin/workspace.html b/web/templates/pages/admin/admin/workspace.html new file mode 100644 index 00000000..9db81f7d --- /dev/null +++ b/web/templates/pages/admin/admin/workspace.html @@ -0,0 +1,69 @@ +{# + GET /admin/workspace — crate::pages::admin::admin::ui::AdminWorkspace + Also included by pages/admin.html for the first paint. Both structs expose + the same `page: AdminPageState` field. +#} +
    +
    +

    Profiles

    {{ page.profiles.len() }}
    +
    + {% if page.profiles.is_empty() %} +

    No profiles available.

    + {% else %} + {% for profile in page.profiles %} +
    + +
    + {% endfor %} + {% endif %} +
    +
    + +
    +

    Tables

    {{ page.tables.len() }}
    +
    + {% if page.selected_profile.is_none() %} +

    Select a profile to see its tables.

    + {% else if page.tables.is_empty() %} +

    This profile has no tables.

    + {% else %} + {% for table in page.tables %} +
    + + +
    + {% endfor %} + {% endif %} +
    +
    + +
    +

    Columns

    {{ page.columns.len() }}
    +
    + {% if page.selected_table.is_none() %} +

    Select a table to inspect its columns.

    + {% else if page.columns.is_empty() %} +

    This table has no visible columns.

    + {% else %} + {% for column in page.columns %} +
    + {{ column.name }} + {{ column.data_type }} + {{ column.flags()|join(" · ") }} +
    + {% endfor %} + {% endif %} +
    +
    +
    diff --git a/web/templates/pages/analytics/analytics.html b/web/templates/pages/analytics/analytics.html new file mode 100644 index 00000000..9579c28d --- /dev/null +++ b/web/templates/pages/analytics/analytics.html @@ -0,0 +1,68 @@ +{# GET / — crate::pages::analytics::ui::AnalyticsPage #} +{% extends "ui/base.html" %} + +{% block title %}Analytics{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
    +
    +
    +

    Reporting

    +

    Analytics

    +

    Query the read-only analytics API and chart the result.

    +
    +
    + +
    + + +
    +
    +
    + + +
    + +
    + + Running… +
    +
    + +
    +
    +
    +
    +{% endblock %} diff --git a/web/templates/pages/analytics/catalog.html b/web/templates/pages/analytics/catalog.html new file mode 100644 index 00000000..6d867ca8 --- /dev/null +++ b/web/templates/pages/analytics/catalog.html @@ -0,0 +1,45 @@ +{# POST /api/catalog — crate::pages::analytics::ui::CatalogFragment #} +{% if tables.is_empty() %} +

    This profile has no analytics tables.

    +{% else %} +

    {{ tables.len() }} tables available

    +
    + {% for table in tables %} +
    + + {{ table.name }} + {%- if !table.base_currency.is_empty() %}{{ table.base_currency }}{% endif -%} + + +
      + {% for column in table.columns %} +
    • + + {{ column.details }} +
    • + {% endfor %} +
    + {% if !table.links.is_empty() %} + + {% endif %} +
    + {% endfor %} +
    +
    + LLM schema context +

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

    + + + Copied +
    +{% endif %} diff --git a/web/templates/pages/analytics/profile_options.html b/web/templates/pages/analytics/profile_options.html new file mode 100644 index 00000000..0c4bd3fe --- /dev/null +++ b/web/templates/pages/analytics/profile_options.html @@ -0,0 +1,12 @@ +{# + GET /api/profiles — crate::pages::analytics::ui::ProfileOptions + The swap target is a + + {% for profile in page.catalog.profiles %} + + {% endfor %} + + + + + + {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%} + +
    + Cancel + +
    + +{% endblock %} diff --git a/web/templates/pages/import_export/import/import.html b/web/templates/pages/import_export/import/import.html new file mode 100644 index 00000000..90c95ceb --- /dev/null +++ b/web/templates/pages/import_export/import/import.html @@ -0,0 +1,40 @@ +{# GET /admin/import — crate::pages::import_export::import::ui::ImportPage #} +{% extends "ui/form_page.html" %} +{% import "ui/alert.html" as alert %} + +{% block title %}Import CSV{% endblock %} +{% block eyebrow %}Data transfer{% endblock %} +{% block heading %}Import CSV{% endblock %} +{% block lead %}

    Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.

    {% endblock %} + +{% block form %} +
    +
    + + + + +
    + + {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%} + +
    + {%- if let Some(message) = page.error %}{% call alert::error("Could not import CSV", message) %}{% endcall %}{% endif -%} +
    +
    + Cancel + +
    +
    +{% endblock %} diff --git a/web/templates/pages/login/login.html b/web/templates/pages/login/login.html new file mode 100644 index 00000000..236123ad --- /dev/null +++ b/web/templates/pages/login/login.html @@ -0,0 +1,17 @@ +{# GET /login — crate::pages::login::ui::LoginPage #} +{% extends "ui/base.html" %} + +{% block title %}Sign in{% endblock %} + +{% block content %} +
    + +
    +{% endblock %} diff --git a/web/templates/ui/alert.html b/web/templates/ui/alert.html new file mode 100644 index 00000000..170c408f --- /dev/null +++ b/web/templates/ui/alert.html @@ -0,0 +1,13 @@ +{# + Inline status blocks. Imported wherever a form reports its outcome: + + {% import "ui/alert.html" as alert %} + {% call alert::error("Could not create the table", message) %}{% endcall %} +#} +{% macro error(title, message) %} +
    {{ title }}

    {{ message }}

    +{% endmacro %} + +{% macro success(title, message) %} +
    {{ title }}

    {{ message }}

    +{% endmacro %} diff --git a/web/templates/ui/alert_fragment.html b/web/templates/ui/alert_fragment.html new file mode 100644 index 00000000..5bec6c3d --- /dev/null +++ b/web/templates/ui/alert_fragment.html @@ -0,0 +1,6 @@ +{# + Standalone swap target for every form POST — crate::ui::Alert. + Rendered into the page's #submission-status / #login-status div. +#} +{% import "ui/alert.html" as alert %} +{%- if success %}{% call alert::success(title, message) %}{% endcall %}{% else %}{% call alert::error(title, message) %}{% endcall %}{% endif -%} diff --git a/web/templates/ui/base.html b/web/templates/ui/base.html new file mode 100644 index 00000000..5d51d2f9 --- /dev/null +++ b/web/templates/ui/base.html @@ -0,0 +1,22 @@ +{# + The shell every full page extends. A page template overrides `title`, + optionally `head` (extra scripts) and `body_class`, and always `content`. + + Every page struct must carry a `nav: crate::ui::Nav` field, because the + navbar component below reads it. +#} + + + + + + {% block title %}Komp Accounting{% endblock %} · Komp Accounting + + + {% block head %}{% endblock %} + + + {% include "ui/navbar.html" %} + {% block content %}{% endblock %} + + diff --git a/web/templates/ui/error.html b/web/templates/ui/error.html new file mode 100644 index 00000000..d2e9bbea --- /dev/null +++ b/web/templates/ui/error.html @@ -0,0 +1,14 @@ +{# Load failures that are not worth a redirect — crate::ui::ErrorPage #} +{% extends "ui/base.html" %} + +{% block title %}{{ heading }}{% endblock %} + +{% block content %} +
    +
    +

    {{ heading }}

    +

    {{ message }}

    + Back to the admin panel +
    +
    +{% endblock %} diff --git a/web/templates/ui/form_page.html b/web/templates/ui/form_page.html new file mode 100644 index 00000000..841c3f1e --- /dev/null +++ b/web/templates/ui/form_page.html @@ -0,0 +1,17 @@ +{# + Card layout shared by every admin form page. A page supplies `eyebrow`, + `heading`, an optional `lead` paragraph, and the `form` itself. +#} +{% extends "ui/base.html" %} + +{% block content %} +
    + ← Admin panel +
    +

    {% block eyebrow %}{% endblock %}

    +

    {% block heading %}{% endblock %}

    + {% block lead %}{% endblock %} + {% block form %}{% endblock %} +
    +
    +{% endblock %} diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html new file mode 100644 index 00000000..ebcb66c9 --- /dev/null +++ b/web/templates/ui/navbar.html @@ -0,0 +1,21 @@ +{# + Shared navbar, rendered on every page by base.html. + Reads the `nav` field of the surrounding page struct (crate::ui::Nav). +#} +
    +
    + Komp Accounting + {% if !nav.role.is_empty() %}{{ nav.role }}{% endif %} +
    + +
    diff --git a/web/templates/ui/notice.html b/web/templates/ui/notice.html new file mode 100644 index 00000000..40d5821e --- /dev/null +++ b/web/templates/ui/notice.html @@ -0,0 +1,5 @@ +{# + One-line inline notice — crate::ui::Notice. Used where a full alert card is + too heavy: analytics errors and the "log in first" prompts. +#} +

    {{ message }}{% if login_link %} Please log in first.{% endif %}