webs unified
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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 <html>, <head>, 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.
|
||||
|
||||
|
||||
619
web/src/lib.rs
619
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<Channel>,
|
||||
@@ -93,47 +89,7 @@ pub(crate) struct AppState {
|
||||
tables_data: TablesDataClient<Channel>,
|
||||
}
|
||||
|
||||
#[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<ColumnOutput>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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<AppState>, Form(input): Form<LoginInput>) -> Response {
|
||||
if input.identifier.trim().is_empty() {
|
||||
return error_fragment("Username or email is required").into_response();
|
||||
}
|
||||
|
||||
let mut client = state.auth;
|
||||
match client
|
||||
.login(Request::new(LoginRequest {
|
||||
identifier: input.identifier,
|
||||
password: input.password,
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let login = response.into_inner();
|
||||
let cookie = format!(
|
||||
"analytics_token={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}",
|
||||
login.access_token, login.expires_in,
|
||||
);
|
||||
let Ok(cookie) = HeaderValue::try_from(cookie) else {
|
||||
return error_fragment("The server returned an invalid access token").into_response();
|
||||
};
|
||||
let mut response = Html(String::new()).into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("hx-redirect", HeaderValue::from_static("/admin"));
|
||||
response
|
||||
}
|
||||
Err(error) => error_fragment(error.message()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_query(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(input): Form<QueryInput>,
|
||||
) -> Html<String> {
|
||||
if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
|
||||
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("<p class=\"error\">Please <a href=\"/login\">log in</a> before running a query.</p>".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::<Vec<_>>()
|
||||
}));
|
||||
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(input): Form<CatalogInput>,
|
||||
) -> Html<String> {
|
||||
if input.profile_name.trim().is_empty() {
|
||||
return error_fragment("Enter a profile name to load its schema");
|
||||
}
|
||||
let Some(token) = cookie_value(&headers, "analytics_token") else {
|
||||
return Html("<p class=\"error\">Please <a href=\"/login\">log in</a> before loading the schema.</p>".into());
|
||||
};
|
||||
let Ok(value) = MetadataValue::try_from(format!("Bearer {token}")) else {
|
||||
return error_fragment("The stored bearer token is invalid");
|
||||
};
|
||||
|
||||
let mut request = Request::new(GetAnalyticsCatalogRequest {
|
||||
profile_name: input.profile_name,
|
||||
});
|
||||
request.metadata_mut().insert("authorization", value);
|
||||
|
||||
let mut client = state.analytics;
|
||||
match client.get_analytics_catalog(request).await {
|
||||
Ok(response) => render_catalog(&response.into_inner()),
|
||||
Err(error) => error_fragment(error.message()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_profiles(State(state): State<AppState>) -> Html<String> {
|
||||
let mut client = state.definitions;
|
||||
match client.get_profile_tree(Request::new(Empty {})).await {
|
||||
Ok(response) => {
|
||||
let profiles = response.into_inner().profiles;
|
||||
if profiles.is_empty() {
|
||||
return Html("<option value=\"\">No profiles available</option>".into());
|
||||
}
|
||||
let options = profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
format!(
|
||||
"<option value=\"{}\">{} ({} tables)</option>",
|
||||
escape_html(&profile.name),
|
||||
escape_html(&profile.name),
|
||||
profile.tables.len(),
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
Html(format!(
|
||||
"<option value=\"\">Choose a profile…</option>{options}"
|
||||
))
|
||||
}
|
||||
Err(error) => Html(format!(
|
||||
"<option value=\"\">Could not load profiles: {}</option>",
|
||||
escape_html(error.message()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn column_output(column: AnalyticsResultColumn) -> ColumnOutput {
|
||||
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<String> {
|
||||
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!("<th>{}</th>", escape_html(&column.name)))
|
||||
.collect::<String>();
|
||||
let rows = result
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let cells = row
|
||||
.iter()
|
||||
.map(|value| format!("<td>{}</td>", escape_html(&display_value(value))))
|
||||
.collect::<String>();
|
||||
format!("<tr>{cells}</tr>")
|
||||
})
|
||||
.collect::<String>();
|
||||
return Html(format!(
|
||||
"<p class=\"result-meta\">{metadata}</p><div class=\"table-wrap\"><table><thead><tr>{headings}</tr></thead><tbody>{rows}</tbody></table></div>"
|
||||
));
|
||||
}
|
||||
|
||||
if result.columns.len() < 2 {
|
||||
return error_fragment("Charts require at least two result columns");
|
||||
}
|
||||
|
||||
let names = result
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| column.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let option = match chart_type {
|
||||
"pie" => json!({
|
||||
"tooltip": { "trigger": "item" },
|
||||
"legend": { "type": "scroll", "bottom": 0 },
|
||||
"series": [{
|
||||
"type": "pie",
|
||||
"radius": ["25%", "68%"],
|
||||
"data": result.rows.iter().map(|row| json!({
|
||||
"name": row.first().cloned().unwrap_or(Value::Null),
|
||||
"value": row.get(1).cloned().unwrap_or(Value::Null),
|
||||
})).collect::<Vec<_>>()
|
||||
}]
|
||||
}),
|
||||
"scatter" => json!({
|
||||
"tooltip": { "trigger": "item" },
|
||||
"xAxis": { "name": names[0], "type": "value" },
|
||||
"yAxis": { "name": names[1], "type": "value" },
|
||||
"series": [{
|
||||
"name": names[1],
|
||||
"type": "scatter",
|
||||
"data": result.rows.iter().map(|row| json!([
|
||||
row.first().cloned().unwrap_or(Value::Null),
|
||||
row.get(1).cloned().unwrap_or(Value::Null),
|
||||
])).collect::<Vec<_>>()
|
||||
}]
|
||||
}),
|
||||
"bar" | "line" => {
|
||||
let series = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(1)
|
||||
.map(|(index, name)| json!({
|
||||
"name": name,
|
||||
"type": chart_type,
|
||||
"smooth": chart_type == "line",
|
||||
"data": result.rows.iter().map(|row| row.get(index).cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),
|
||||
}))
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"tooltip": { "trigger": "axis" },
|
||||
"legend": { "type": "scroll", "bottom": 0 },
|
||||
"grid": { "left": 55, "right": 25, "top": 35, "bottom": 70, "containLabel": true },
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": result.rows.iter().map(|row| row.first().cloned().unwrap_or(Value::Null)).collect::<Vec<_>>()
|
||||
},
|
||||
"yAxis": { "type": "value" },
|
||||
"series": series,
|
||||
})
|
||||
}
|
||||
_ => return error_fragment("Unknown chart type"),
|
||||
};
|
||||
let option = escape_html(&option.to_string());
|
||||
|
||||
Html(format!(
|
||||
"<p class=\"result-meta\">{metadata}</p>\
|
||||
<div class=\"chart\" data-option=\"{option}\" \
|
||||
x-init=\"echarts.init($el).setOption(JSON.parse($el.dataset.option))\"></div>"
|
||||
))
|
||||
}
|
||||
|
||||
fn render_catalog(catalog: &GetAnalyticsCatalogResponse) -> Html<String> {
|
||||
if catalog.tables.is_empty() {
|
||||
return Html("<p class=\"hint\">This profile has no analytics tables.</p>".into());
|
||||
}
|
||||
|
||||
let tables = catalog
|
||||
.tables
|
||||
.iter()
|
||||
.map(render_catalog_table)
|
||||
.collect::<String>();
|
||||
let llm_context = build_llm_context(catalog);
|
||||
Html(format!(
|
||||
"<p class=\"schema-summary\"><strong>{}</strong> tables available</p>\
|
||||
<div class=\"catalog-tables\">{tables}</div>\
|
||||
<details class=\"llm-context\">\
|
||||
<summary>LLM schema context</summary>\
|
||||
<p class=\"hint\">Copy this entire text and include it when asking an LLM to write a query.</p>\
|
||||
<textarea x-ref=\"llmSchema\" readonly>{}</textarea>\
|
||||
<button type=\"button\" class=\"secondary\" \
|
||||
x-on:click=\"navigator.clipboard.writeText($refs.llmSchema.value); copied = true\">Copy context</button>\
|
||||
<span class=\"hint\" x-show=\"copied\" x-cloak>Copied</span>\
|
||||
</details>",
|
||||
catalog.tables.len(),
|
||||
escape_html(&llm_context),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_catalog_table(table: &AnalyticsTable) -> String {
|
||||
let table_name = quote_identifier(&table.name);
|
||||
let starter_query = format!("SELECT *\nFROM {table_name}\nLIMIT 100;");
|
||||
let columns = table
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let mut details = column.field_type.clone();
|
||||
if column.is_system {
|
||||
details.push_str(", system");
|
||||
}
|
||||
if !column.rounding.is_empty() {
|
||||
details.push_str(&format!(", rounding {}", column.rounding));
|
||||
}
|
||||
format!(
|
||||
"<li><button type=\"button\" class=\"column-name\" data-insert=\"{}\" \
|
||||
x-on:click=\"$refs.sql.setRangeText($el.dataset.insert, $refs.sql.selectionStart, $refs.sql.selectionEnd, 'end'); sql = $refs.sql.value; $refs.sql.focus()\">{}</button><span>{}</span></li>",
|
||||
escape_html("e_identifier(&column.name)),
|
||||
escape_html(&column.name),
|
||||
escape_html(&details),
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
let links = if table.links.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let items = table
|
||||
.links
|
||||
.iter()
|
||||
.map(|link| {
|
||||
format!(
|
||||
"<li><code>{}</code> → <code>{}</code>{}</li>",
|
||||
escape_html(&link.source_column),
|
||||
escape_html(&link.linked_table),
|
||||
if link.required { " (required)" } else { "" },
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
format!("<div class=\"links\"><span>Links</span><ul>{items}</ul></div>")
|
||||
};
|
||||
let currency = if table.base_currency.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"<span class=\"currency\">{}</span>",
|
||||
escape_html(&table.base_currency)
|
||||
)
|
||||
};
|
||||
|
||||
format!(
|
||||
"<details class=\"catalog-table\">\
|
||||
<summary><code>{}</code>{currency}</summary>\
|
||||
<button type=\"button\" class=\"starter-query\" data-sql=\"{}\" \
|
||||
x-on:click=\"sql = $el.dataset.sql; $refs.sql.focus()\">Use starter query</button>\
|
||||
<ul class=\"columns\">{columns}</ul>{links}\
|
||||
</details>",
|
||||
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<String> {
|
||||
Html(format!("<p class=\"error\">{}</p>", 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("<header class=\"topbar\">"),
|
||||
"{path} is missing the shared navbar"
|
||||
);
|
||||
assert!(
|
||||
body.contains("href=\"/static/app.css\""),
|
||||
"{path} is missing the shared stylesheet"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("<style>"),
|
||||
"{path} inlines CSS instead of linking the stylesheet"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_out_navbar_offers_login_instead_of_logout() {
|
||||
let (_, body) = get("/").await;
|
||||
assert!(body.contains("href=\"/login\""));
|
||||
assert!(!body.contains("hx-post=\"/logout\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stylesheet_is_served_once_for_every_page() {
|
||||
let (status, body) = get("/static/app.css").await;
|
||||
assert!(status.is_success());
|
||||
assert!(body.contains(".topbar"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,12 @@ pub(crate) async fn load_page(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(AddLogicPageState { tables, form, error })
|
||||
Ok(AddLogicPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
|
||||
tables,
|
||||
form,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) enum LoadError {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(crate) struct CreateLogicForm {
|
||||
}
|
||||
|
||||
pub(crate) struct AddLogicPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub tables: Vec<TableOption>,
|
||||
pub form: CreateLogicForm,
|
||||
pub error: Option<String>,
|
||||
|
||||
@@ -1,45 +1,34 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{Alert, Nav, render};
|
||||
|
||||
use super::state::AddLogicPageState;
|
||||
|
||||
const ADMIN_CSS: &str = include_str!("../../../static/admin.css");
|
||||
|
||||
pub(crate) fn render_page(page: &AddLogicPageState) -> String {
|
||||
let tables = page
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| {
|
||||
format!(
|
||||
"<option value=\"{}\" {}>{}.{}</option>",
|
||||
table.id,
|
||||
if page.form.table_definition_id == table.id { "selected" } else { "" },
|
||||
crate::escape_html(&table.profile_name),
|
||||
crate::escape_html(&table.table_name),
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
let error = page.error.as_deref().map(render_submission_error).unwrap_or_default();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Add logic</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Computed column</p><h1>Add logic</h1><p>Create or update a Steel script through the existing table-script service.</p><form hx-post=\"/admin/logic\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\" hx-disabled-elt=\"button[type=submit]\"><div class=\"form-grid\"><label class=\"wide\">Table<select name=\"table_definition_id\" required><option value=\"0\">Choose a table</option>{tables}</select></label><label>Logic name<input name=\"logic_name\" value=\"{}\" placeholder=\"invoice total\"></label><label>Target column<input name=\"target_column\" value=\"{}\" required placeholder=\"total\"></label><label class=\"wide\">Steel expression<textarea name=\"script\" rows=\"12\" required placeholder=\"(+ (get-var "subtotal") (get-var "tax"))\">{}</textarea></label><label class=\"wide\">Description<input name=\"description\" value=\"{}\"></label></div><div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save logic</button></div></form></section></main></body></html>",
|
||||
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!(
|
||||
"<div class=\"form-error\"><strong>Could not save the logic</strong><p>{}</p></div>",
|
||||
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!("<p><strong>Warnings:</strong> {}</p>", crate::escape_html(warnings))
|
||||
format!("Script #{id} was saved. Warnings: {warnings}")
|
||||
};
|
||||
format!(
|
||||
"<div class=\"form-success\"><strong>Logic saved</strong><p>Script #{id} was saved.</p>{warnings}</div>"
|
||||
)
|
||||
render(&Alert::success("Logic saved", &message))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(crate) struct CreateTableForm {
|
||||
}
|
||||
|
||||
pub(crate) struct AddTablePageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub form: CreateTableForm,
|
||||
pub error: Option<String>,
|
||||
|
||||
@@ -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!(
|
||||
"<option value=\"{}\" {}>{}</option>",
|
||||
crate::escape_html(profile),
|
||||
if page.form.profile_name == *profile { "selected" } else { "" },
|
||||
crate::escape_html(profile),
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
let error = page
|
||||
.error
|
||||
.as_deref()
|
||||
.map(render_submission_error)
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Add table</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Table definition</p><h1>Add table</h1><p>Create a table through the existing gRPC table-definition service.</p><form hx-post=\"/admin/tables\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\" hx-disabled-elt=\"button[type=submit]\"><div class=\"form-grid\"><label>Profile<select name=\"profile_name\" required><option value=\"\">Choose a profile</option>{options}</select></label><label>Table name<input name=\"table_name\" value=\"{}\" required placeholder=\"invoices\"></label><label class=\"wide\">Columns<textarea name=\"columns\" rows=\"9\" required placeholder=\"number: text:indexed issued_on: date stock: int:quantity-ledger\">{}</textarea><small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, quantity-ledger.</small></label><label>Additional indexed columns<input name=\"indexed_columns\" value=\"{}\" placeholder=\"number, issued_on\"></label><label>Base currency<input name=\"base_currency\" value=\"{}\" maxlength=\"3\" placeholder=\"EUR\"></label><label>Required links<input name=\"required_links\" value=\"{}\" placeholder=\"customer, address\"></label><label>Optional links<input name=\"optional_links\" value=\"{}\" placeholder=\"project\"></label><label>Row display columns<input name=\"row_display_columns\" value=\"{}\" placeholder=\"name, ico\"></label></div><div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Create table</button></div></form></section></main></body></html>",
|
||||
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!(
|
||||
"<div class=\"form-error\"><strong>Could not create the table</strong><p>{}</p></div>",
|
||||
crate::escape_html(message),
|
||||
)
|
||||
render(&Alert::error("Could not create the table", message))
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
) -> Result<(ValidationSetForm, Option<String>), LoadError> {
|
||||
let _ = load_page(state, headers, ValidationForm::default(), true, None).await?;
|
||||
Ok((form, error))
|
||||
) -> Result<(crate::ui::Nav, ValidationSetForm, Option<String>), 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 {
|
||||
|
||||
@@ -119,9 +119,13 @@ pub(crate) async fn save_validation_set(
|
||||
}
|
||||
}
|
||||
|
||||
fn render_set_loaded(result: Result<(ValidationSetForm, Option<String>), LoadError>) -> Response {
|
||||
fn render_set_loaded(
|
||||
result: Result<(crate::ui::Nav, ValidationSetForm, Option<String>), 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(),
|
||||
|
||||
@@ -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<String>,
|
||||
pub tables: Vec<String>,
|
||||
pub form: ValidationForm,
|
||||
|
||||
@@ -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!("<label>Rule name<input name=\"rule_name\" value=\"{}\" required></label><label>Description<input name=\"description\" value=\"{}\"></label>", 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!("<label>Table<input name=\"table_name\" list=\"tables\" value=\"{}\" required></label><label>Column<input name=\"data_key\" value=\"{}\" required></label>", 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!("<label class=\"wide\">Apply named validation set instead<input name=\"validation_set_name\" value=\"{}\" placeholder=\"Leave empty to save the rules below\"></label>", crate::escape_html(&page.form.validation_set_name))
|
||||
};
|
||||
identity.push_str(&format!(
|
||||
"{set_field}<label class=\"wide\">Position rules<textarea name=\"pattern_rules\" rows=\"6\" placeholder=\"0-3 | alphabetic 4,5 | one-of=-,+ 6+ | regex=[0-9]\">{}</textarea><small>One per line: position | constraint. Positions: 0, 0-3, 4+, or 1,3,5. Constraints: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=...</small></label><label class=\"wide\">Pattern description<input name=\"pattern_description\" value=\"{}\"></label>",
|
||||
crate::escape_html(&page.form.pattern_rules),
|
||||
crate::escape_html(&page.form.pattern_description),
|
||||
));
|
||||
String::new()
|
||||
} else {
|
||||
format!("<label>Profile<input name=\"profile_name\" list=\"profiles\" value=\"{}\" required></label>", crate::escape_html(&page.form.profile_name))
|
||||
};
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>{title}</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Validation</p><h1>{title}</h1><form hx-post=\"{action}\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\"><div class=\"form-grid\">{profile_field}{identity}<label>Minimum length<input name=\"minimum\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Maximum length<input name=\"maximum\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Warning threshold<input name=\"warn_at\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Count mode<select name=\"count_mode\"><option value=\"chars\">Characters</option><option value=\"bytes\">Bytes</option><option value=\"display-width\">Display width</option></select></label><label class=\"wide\">Allowed values<input name=\"allowed_values\" value=\"{}\" placeholder=\"draft, issued, paid\"></label><label>Display mask<input name=\"mask_pattern\" value=\"{}\" placeholder=\"####-##-##\"></label><label>Mask input character<input name=\"mask_input_char\" value=\"{}\" placeholder=\"#\"></label><label>Mask template character<input name=\"mask_template_char\" value=\"{}\" placeholder=\"_\"></label><div class=\"check-group\">{}{}{}{}{} </div></div>{profiles}{tables}<div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save validation</button></div></form></section></main></body></html>",
|
||||
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!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Add validation set</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/admin/validation/rules/new\">New reusable rule</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Global validation library</p><h1>Add validation set</h1><p>Compose an ordered set from existing reusable rule names.</p><form hx-post=\"/admin/validation/sets\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\"><div class=\"form-grid\"><label>Set name<input name=\"name\" value=\"{}\" required></label><label>Description<input name=\"description\" value=\"{}\"></label><label class=\"wide\">Reusable rules<input name=\"global_rules\" value=\"{}\" required placeholder=\"required, invoice-number, max-length\"><small>Comma-separated and applied in this order.</small></label></div><div id=\"submission-status\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save set</button></div></form></section></main></body></html>",
|
||||
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!("<option value=\"{}\"></option>", crate::escape_html(value))).collect::<String>();
|
||||
format!("<datalist id=\"{id}\">{options}</datalist>")
|
||||
}
|
||||
|
||||
fn checkbox(name: &str, label: &str, checked: bool) -> String {
|
||||
format!("<label class=\"check\"><input type=\"checkbox\" name=\"{name}\" value=\"true\" {}>{label}</label>", 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!("<div class=\"form-error\"><strong>Could not save validation</strong><p>{}</p></div>", crate::escape_html(message))
|
||||
render(&Alert::error("Could not save validation", message))
|
||||
}
|
||||
|
||||
pub(crate) fn render_success(message: &str) -> String {
|
||||
format!("<div class=\"form-success\"><strong>Validation saved</strong><p>{}</p></div>", crate::escape_html(message))
|
||||
render(&Alert::success("Validation saved", message))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ProfileView>,
|
||||
pub selected_profile: Option<String>,
|
||||
pub tables: Vec<TableView>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Admin panel</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong><span class=\"role\">{}</span></div><nav><a class=\"active\" href=\"/admin\">Admin</a><a href=\"/\">Analytics</a><form hx-post=\"/logout\" hx-swap=\"none\"><button class=\"link-button\" type=\"submit\">Log out</button></form></nav></header><main><section class=\"heading\"><div><p class=\"eyebrow\">Workspace</p><h1>Admin panel</h1><p>Browse profiles, tables, and their physical columns.</p></div><div class=\"actions\"><a href=\"/admin/tables/new\">Add table</a><a href=\"/admin/logic/new\">Add logic</a><a href=\"/admin/validation/new\">Add validation</a><a href=\"/admin/validation/sets/new\">Add rule</a><a href=\"/admin/import\">Import</a><a href=\"/admin/export\">Export</a></div></section><div id=\"admin-workspace\">{}</div></main></body></html>",
|
||||
crate::escape_html(&page.role),
|
||||
render_workspace(page),
|
||||
)
|
||||
render(&AdminPage {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_workspace(page: &AdminPageState) -> String {
|
||||
format!(
|
||||
"<div class=\"workspace\" aria-live=\"polite\">{}{}{}</div>",
|
||||
render_profiles(page),
|
||||
render_tables(page),
|
||||
render_columns(page),
|
||||
)
|
||||
render(&AdminWorkspace { page })
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
format!(
|
||||
"<div class=\"error-page\"><h1>Admin panel unavailable</h1><p>{}</p><a href=\"/admin\">Try again</a></div>",
|
||||
crate::escape_html(message),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_profiles(page: &AdminPageState) -> String {
|
||||
let items = if page.profiles.is_empty() {
|
||||
"<p class=\"empty\">No profiles available.</p>".to_string()
|
||||
} else {
|
||||
page.profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
let selected = page.selected_profile.as_deref() == Some(profile.name.as_str());
|
||||
format!(
|
||||
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><button class=\"browser-item {}\" type=\"submit\" name=\"profile\" value=\"{}\"><span>{}</span><small>{} tables</small></button></form>",
|
||||
if selected { "selected" } else { "" },
|
||||
crate::escape_html(&profile.name),
|
||||
crate::escape_html(&profile.name),
|
||||
profile.table_count,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Profiles</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.profiles.len())
|
||||
}
|
||||
|
||||
fn render_tables(page: &AdminPageState) -> String {
|
||||
let items = if page.selected_profile.is_none() {
|
||||
"<p class=\"empty\">Select a profile to see its tables.</p>".to_string()
|
||||
} else if page.tables.is_empty() {
|
||||
"<p class=\"empty\">This profile has no tables.</p>".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!(
|
||||
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><input type=\"hidden\" name=\"profile\" value=\"{}\"><button class=\"browser-item {}\" type=\"submit\" name=\"table\" value=\"{}\"><span>{}</span><small>{} · display: {}</small></button></form>",
|
||||
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!("<section class=\"pane\"><div class=\"pane-title\"><h2>Tables</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.tables.len())
|
||||
}
|
||||
|
||||
fn render_columns(page: &AdminPageState) -> String {
|
||||
let items = if page.selected_table.is_none() {
|
||||
"<p class=\"empty\">Select a table to inspect its columns.</p>".to_string()
|
||||
} else if page.columns.is_empty() {
|
||||
"<p class=\"empty\">This table has no visible columns.</p>".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!(
|
||||
"<div class=\"column\"><span>{}</span><code>{}</code><small>{}</small></div>",
|
||||
crate::escape_html(&column.name),
|
||||
crate::escape_html(&column.data_type),
|
||||
flags.join(" · "),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Columns</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", 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(),
|
||||
|
||||
231
web/src/pages/analytics/loader.rs
Normal file
231
web/src/pages/analytics/loader.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! gRPC calls behind the analytics endpoints, plus the shaping of their
|
||||
//! responses into the view models the templates render.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
analytics::{
|
||||
AnalyticsTable, ExecuteAnalyticsQueryRequest, GetAnalyticsCatalogRequest,
|
||||
GetAnalyticsCatalogResponse, analytics_value,
|
||||
},
|
||||
definitions::common::Empty,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{
|
||||
CatalogColumnView, CatalogLinkView, CatalogTableView, CatalogView, ColumnOutput, ProfileOption,
|
||||
QueryOutput,
|
||||
};
|
||||
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
pub(crate) async fn load_profiles(state: AppState) -> Result<Vec<ProfileOption>, String> {
|
||||
let mut definitions = state.definitions;
|
||||
let tree = definitions
|
||||
.get_profile_tree(tonic::Request::new(Empty {}))
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?
|
||||
.into_inner();
|
||||
Ok(tree
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|profile| ProfileOption {
|
||||
table_count: profile.tables.len(),
|
||||
name: profile.name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_catalog(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
profile_name: String,
|
||||
) -> Result<CatalogView, LoadError> {
|
||||
let request = authenticated_request(headers, GetAnalyticsCatalogRequest { profile_name })
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut client = state.analytics;
|
||||
let catalog = client
|
||||
.get_analytics_catalog(request)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
Ok(catalog_view(&catalog))
|
||||
}
|
||||
|
||||
pub(crate) async fn run_query(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
profile_name: String,
|
||||
sql: String,
|
||||
max_rows: u32,
|
||||
) -> Result<QueryOutput, LoadError> {
|
||||
let request = authenticated_request(
|
||||
headers,
|
||||
ExecuteAnalyticsQueryRequest {
|
||||
profile_name,
|
||||
sql,
|
||||
max_rows,
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
|
||||
let mut client = state.analytics;
|
||||
let mut stream = client
|
||||
.execute_analytics_query(request)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
|
||||
let mut output = QueryOutput {
|
||||
columns: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
row_count: 0,
|
||||
elapsed_ms: 0,
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
while let Some(batch) = stream
|
||||
.message()
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
{
|
||||
if !batch.columns.is_empty() {
|
||||
output.columns = batch
|
||||
.columns
|
||||
.into_iter()
|
||||
.map(|column| ColumnOutput {
|
||||
name: column.name,
|
||||
data_type: column.data_type,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
output.rows.extend(
|
||||
batch
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| row.values.into_iter().map(value_output).collect()),
|
||||
);
|
||||
if batch.is_final {
|
||||
output.row_count = batch.row_count;
|
||||
output.elapsed_ms = batch.elapsed_ms;
|
||||
output.truncated = batch.truncated;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn value_output(value: crate::analytics::AnalyticsValue) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
match value.kind {
|
||||
None | Some(analytics_value::Kind::NullValue(_)) => serde_json::Value::Null,
|
||||
Some(analytics_value::Kind::BoolValue(value)) => json!(value),
|
||||
Some(analytics_value::Kind::Int64Value(value)) => json!(value),
|
||||
Some(analytics_value::Kind::Uint64Value(value)) => json!(value),
|
||||
Some(analytics_value::Kind::DoubleValue(value)) => json!(value),
|
||||
Some(analytics_value::Kind::StringValue(value)) => json!(value),
|
||||
Some(analytics_value::Kind::BytesValue(value)) => serde_json::Value::String(
|
||||
value.iter().map(|byte| format!("{byte:02x}")).collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_view(catalog: &GetAnalyticsCatalogResponse) -> CatalogView {
|
||||
CatalogView {
|
||||
tables: catalog.tables.iter().map(catalog_table_view).collect(),
|
||||
llm_context: build_llm_context(catalog),
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_table_view(table: &AnalyticsTable) -> CatalogTableView {
|
||||
CatalogTableView {
|
||||
name: table.name.clone(),
|
||||
base_currency: table.base_currency.clone(),
|
||||
starter_query: format!("SELECT *\nFROM {}\nLIMIT 100;", quote_identifier(&table.name)),
|
||||
columns: table
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let mut details = column.field_type.clone();
|
||||
if column.is_system {
|
||||
details.push_str(", system");
|
||||
}
|
||||
if !column.rounding.is_empty() {
|
||||
details.push_str(&format!(", rounding {}", column.rounding));
|
||||
}
|
||||
CatalogColumnView {
|
||||
name: column.name.clone(),
|
||||
insert_text: quote_identifier(&column.name),
|
||||
details,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
links: table
|
||||
.links
|
||||
.iter()
|
||||
.map(|link| CatalogLinkView {
|
||||
source_column: link.source_column.clone(),
|
||||
linked_table: link.linked_table.clone(),
|
||||
required: link.required,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_llm_context(catalog: &GetAnalyticsCatalogResponse) -> String {
|
||||
let mut text = format!(
|
||||
"Write one read-only PostgreSQL SELECT query for the komp_ac analytics API.\n\
|
||||
Profile: {}\n\n\
|
||||
Rules:\n\
|
||||
- Return only the SQL query, without Markdown fences or explanation.\n\
|
||||
- Use exactly one read-only SELECT statement.\n\
|
||||
- Use only the public table and column aliases listed below.\n\
|
||||
- Physical database column names are not visible through this API.\n\
|
||||
- Add a reasonable LIMIT unless the query is an aggregate with a naturally small result.\n\
|
||||
- For a chart, put the category or x-axis column first and numeric value columns after it.\n\n\
|
||||
AVAILABLE ANALYTICS SCHEMA\n",
|
||||
catalog.profile_name,
|
||||
);
|
||||
|
||||
for table in &catalog.tables {
|
||||
text.push_str(&format!("\nTABLE {}\n", quote_identifier(&table.name)));
|
||||
if !table.base_currency.is_empty() {
|
||||
text.push_str(&format!(" Base currency: {}\n", table.base_currency));
|
||||
}
|
||||
text.push_str(" Columns:\n");
|
||||
for column in &table.columns {
|
||||
text.push_str(&format!(
|
||||
" - {}: {}",
|
||||
quote_identifier(&column.name),
|
||||
column.field_type,
|
||||
));
|
||||
if column.is_system {
|
||||
text.push_str(" [system]");
|
||||
}
|
||||
if !column.rounding.is_empty() {
|
||||
text.push_str(&format!(" [rounding: {}]", column.rounding));
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
if !table.links.is_empty() {
|
||||
text.push_str(" Links:\n");
|
||||
for link in &table.links {
|
||||
text.push_str(&format!(
|
||||
" - {} references {} ({})\n",
|
||||
quote_identifier(&link.source_column),
|
||||
quote_identifier(&link.linked_table),
|
||||
if link.required { "required" } else { "optional" },
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn quote_identifier(identifier: &str) -> String {
|
||||
format!("\"{}\"", identifier.replace('"', "\"\""))
|
||||
}
|
||||
80
web/src/pages/analytics/logic.rs
Normal file
80
web/src/pages/analytics/logic.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
ui::{Nav, Notice, render},
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader::{self, LoadError},
|
||||
state::{CatalogInput, QueryInput},
|
||||
ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn analytics_page(headers: HeaderMap) -> Html<String> {
|
||||
Html(ui::render_page(Nav::new(&headers, "analytics")))
|
||||
}
|
||||
|
||||
pub(crate) async fn load_profiles(State(state): State<AppState>) -> Html<String> {
|
||||
match loader::load_profiles(state).await {
|
||||
Ok(profiles) => Html(ui::render_profile_options(&profiles)),
|
||||
Err(message) => Html(ui::render_profile_options_error(&message)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_catalog(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(input): Form<CatalogInput>,
|
||||
) -> Response {
|
||||
if input.profile_name.trim().is_empty() {
|
||||
return notice(Notice::error("Enter a profile name to load its schema"));
|
||||
}
|
||||
match loader::load_catalog(state, &headers, input.profile_name).await {
|
||||
Ok(catalog) => Html(ui::render_catalog(&catalog)).into_response(),
|
||||
Err(error) => load_error(error, "loading the schema"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_query(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(input): Form<QueryInput>,
|
||||
) -> Response {
|
||||
if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
|
||||
return notice(Notice::error("Profile name and SQL are required"));
|
||||
}
|
||||
let result = loader::run_query(
|
||||
state,
|
||||
&headers,
|
||||
input.profile_name,
|
||||
input.sql,
|
||||
input.max_rows,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(output) => match ui::render_result(&output, &input.chart_type) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(message) => notice(Notice::error(&message)),
|
||||
},
|
||||
Err(error) => load_error(error, "running a query"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_error(error: LoadError, action: &str) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => notice(Notice::login_required(&format!(
|
||||
"You are not signed in for {action}."
|
||||
))),
|
||||
LoadError::Backend(message) => notice(Notice::error(&message)),
|
||||
}
|
||||
}
|
||||
|
||||
fn notice(notice: Notice<'_>) -> Response {
|
||||
Html(render(¬ice)).into_response()
|
||||
}
|
||||
26
web/src/pages/analytics/mod.rs
Normal file
26
web/src/pages/analytics/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! The analytics page and the three HTMX endpoints that feed it.
|
||||
//!
|
||||
//! GET / → analytics.html
|
||||
//! GET /api/profiles → profile_options.html
|
||||
//! POST /api/catalog → catalog.html
|
||||
//! POST /api/query → query_result.html
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) mod loader;
|
||||
pub(crate) mod logic;
|
||||
pub(crate) mod state;
|
||||
pub(crate) mod ui;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(logic::analytics_page))
|
||||
.route("/api/profiles", get(logic::load_profiles))
|
||||
.route("/api/catalog", post(logic::load_catalog))
|
||||
.route("/api/query", post(logic::run_query))
|
||||
}
|
||||
68
web/src/pages/analytics/state.rs
Normal file
68
web/src/pages/analytics/state.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct QueryInput {
|
||||
pub profile_name: String,
|
||||
pub sql: String,
|
||||
#[serde(default = "default_max_rows")]
|
||||
pub max_rows: u32,
|
||||
pub chart_type: String,
|
||||
}
|
||||
|
||||
fn default_max_rows() -> u32 {
|
||||
1000
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct CatalogInput {
|
||||
pub profile_name: String,
|
||||
}
|
||||
|
||||
/// One entry of the profile `<select>` on the analytics page.
|
||||
pub(crate) struct ProfileOption {
|
||||
pub name: String,
|
||||
pub table_count: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct QueryOutput {
|
||||
pub columns: Vec<ColumnOutput>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
pub row_count: u64,
|
||||
pub elapsed_ms: u64,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ColumnOutput {
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// The catalog sidebar, already shaped for `catalog.html` so the
|
||||
/// template holds no SQL-quoting or label-building logic.
|
||||
pub(crate) struct CatalogView {
|
||||
pub tables: Vec<CatalogTableView>,
|
||||
pub llm_context: String,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogTableView {
|
||||
pub name: String,
|
||||
pub base_currency: String,
|
||||
pub starter_query: String,
|
||||
pub columns: Vec<CatalogColumnView>,
|
||||
pub links: Vec<CatalogLinkView>,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogColumnView {
|
||||
pub name: String,
|
||||
/// The quoted identifier inserted into the SQL box when clicked.
|
||||
pub insert_text: String,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
pub(crate) struct CatalogLinkView {
|
||||
pub source_column: String,
|
||||
pub linked_table: String,
|
||||
pub required: bool,
|
||||
}
|
||||
160
web/src/pages/analytics/ui.rs
Normal file
160
web/src/pages/analytics/ui.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use askama::Template;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ui::{Nav, render};
|
||||
|
||||
use super::state::{CatalogView, ColumnOutput, ProfileOption, QueryOutput};
|
||||
|
||||
/// GET /
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/analytics.html")]
|
||||
struct AnalyticsPage {
|
||||
nav: Nav,
|
||||
}
|
||||
|
||||
/// GET /api/profiles — the `<option>` list swapped into the profile select.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/profile_options.html")]
|
||||
struct ProfileOptions<'a> {
|
||||
profiles: &'a [ProfileOption],
|
||||
error: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// POST /api/catalog
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/catalog.html")]
|
||||
struct CatalogFragment<'a> {
|
||||
tables: &'a [super::state::CatalogTableView],
|
||||
llm_context: &'a str,
|
||||
}
|
||||
|
||||
/// POST /api/query — a table when `chart_option` is `None`, otherwise an
|
||||
/// ECharts container carrying the option as a data attribute.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/analytics/query_result.html")]
|
||||
struct QueryResult<'a> {
|
||||
columns: &'a [ColumnOutput],
|
||||
rows: Vec<Vec<String>>,
|
||||
row_count: u64,
|
||||
elapsed_ms: u64,
|
||||
truncated: bool,
|
||||
chart_option: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(nav: Nav) -> String {
|
||||
render(&AnalyticsPage { nav })
|
||||
}
|
||||
|
||||
pub(crate) fn render_profile_options(profiles: &[ProfileOption]) -> String {
|
||||
render(&ProfileOptions {
|
||||
profiles,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_profile_options_error(message: &str) -> String {
|
||||
render(&ProfileOptions {
|
||||
profiles: &[],
|
||||
error: Some(message),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_catalog(catalog: &CatalogView) -> String {
|
||||
render(&CatalogFragment {
|
||||
tables: &catalog.tables,
|
||||
llm_context: &catalog.llm_context,
|
||||
})
|
||||
}
|
||||
|
||||
/// `Err` carries a message for the caller to show as a notice — the only
|
||||
/// failure is a chart type that the result shape cannot satisfy.
|
||||
pub(crate) fn render_result(result: &QueryOutput, chart_type: &str) -> Result<String, String> {
|
||||
let chart_option = match chart_type {
|
||||
"table" => None,
|
||||
_ => Some(chart_option(result, chart_type)?.to_string()),
|
||||
};
|
||||
Ok(render(&QueryResult {
|
||||
columns: &result.columns,
|
||||
rows: result
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(display_value).collect())
|
||||
.collect(),
|
||||
row_count: result.row_count,
|
||||
elapsed_ms: result.elapsed_ms,
|
||||
truncated: result.truncated,
|
||||
chart_option,
|
||||
}))
|
||||
}
|
||||
|
||||
fn chart_option(result: &QueryOutput, chart_type: &str) -> Result<Value, String> {
|
||||
if result.columns.len() < 2 {
|
||||
return Err("Charts require at least two result columns".to_string());
|
||||
}
|
||||
let names = result
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| column.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(match chart_type {
|
||||
"pie" => json!({
|
||||
"tooltip": { "trigger": "item" },
|
||||
"legend": { "type": "scroll", "bottom": 0 },
|
||||
"series": [{
|
||||
"type": "pie",
|
||||
"radius": ["25%", "68%"],
|
||||
"data": result.rows.iter().map(|row| json!({
|
||||
"name": row.first().cloned().unwrap_or(Value::Null),
|
||||
"value": row.get(1).cloned().unwrap_or(Value::Null),
|
||||
})).collect::<Vec<_>>()
|
||||
}]
|
||||
}),
|
||||
"scatter" => json!({
|
||||
"tooltip": { "trigger": "item" },
|
||||
"xAxis": { "name": names[0], "type": "value" },
|
||||
"yAxis": { "name": names[1], "type": "value" },
|
||||
"series": [{
|
||||
"name": names[1],
|
||||
"type": "scatter",
|
||||
"data": result.rows.iter().map(|row| json!([
|
||||
row.first().cloned().unwrap_or(Value::Null),
|
||||
row.get(1).cloned().unwrap_or(Value::Null),
|
||||
])).collect::<Vec<_>>()
|
||||
}]
|
||||
}),
|
||||
"bar" | "line" => {
|
||||
let series = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(1)
|
||||
.map(|(index, name)| json!({
|
||||
"name": name,
|
||||
"type": chart_type,
|
||||
"smooth": chart_type == "line",
|
||||
"data": result.rows.iter().map(|row| row.get(index).cloned().unwrap_or(Value::Null)).collect::<Vec<_>>(),
|
||||
}))
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"tooltip": { "trigger": "axis" },
|
||||
"legend": { "type": "scroll", "bottom": 0 },
|
||||
"grid": { "left": 55, "right": 25, "top": 35, "bottom": 70, "containLabel": true },
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": result.rows.iter().map(|row| row.first().cloned().unwrap_or(Value::Null)).collect::<Vec<_>>()
|
||||
},
|
||||
"yAxis": { "type": "value" },
|
||||
"series": series,
|
||||
})
|
||||
}
|
||||
_ => return Err("Unknown chart type".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
fn display_value(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => String::new(),
|
||||
Value::String(value) => value.clone(),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub(crate) async fn load_page(
|
||||
headers: &HeaderMap,
|
||||
) -> Result<ExportPageState, LoadError> {
|
||||
Ok(ExportPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin"),
|
||||
catalog: load_catalog(state, headers).await?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) struct ExportForm {
|
||||
}
|
||||
|
||||
pub(crate) struct ExportPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub catalog: super::super::common::loader::Catalog,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{Alert, Nav, render};
|
||||
|
||||
use super::state::ExportPageState;
|
||||
|
||||
const ADMIN_CSS: &str = include_str!("../../../../static/admin.css");
|
||||
/// GET /admin/export
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/import_export/export/export.html")]
|
||||
struct ExportPage<'a> {
|
||||
nav: Nav,
|
||||
page: &'a ExportPageState,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &ExportPageState) -> String {
|
||||
let profiles = page.catalog.profiles.iter().map(|profile| format!("<option value=\"{}\">{}</option>", crate::escape_html(&profile.name), crate::escape_html(&profile.name))).collect::<String>();
|
||||
let tables = page.catalog.profiles.iter().flat_map(|profile| profile.tables.iter()).map(|table| format!("<option value=\"{}\"></option>", crate::escape_html(table))).collect::<String>();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Export CSV</title><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Data transfer</p><h1>Export CSV</h1><p>The download is generated from live table data through gRPC.</p><form method=\"post\" action=\"/admin/export.csv\"><div class=\"form-grid\"><label>Profile<select name=\"profile_name\" required><option value=\"\">Choose a profile</option>{profiles}</select></label><label>Tables<input name=\"table_names\" list=\"export-tables\" required placeholder=\"invoices, customers\"><small>Separate multiple tables with commas.</small></label></div><datalist id=\"export-tables\">{tables}</datalist><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Download CSV</button></div></form></section></main></body></html>"
|
||||
)
|
||||
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!("<div class=\"form-error\"><strong>Could not export CSV</strong><p>{}</p></div>", crate::escape_html(message))
|
||||
render(&Alert::error("Could not export CSV", message))
|
||||
}
|
||||
|
||||
@@ -13,5 +13,10 @@ pub(crate) async fn load_page(
|
||||
form: ImportForm,
|
||||
error: Option<String>,
|
||||
) -> Result<ImportPageState, LoadError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
@@ -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!("<option value=\"{}\" {}>{}</option>", crate::escape_html(&profile.name), if page.form.profile_name == profile.name { "selected" } else { "" }, crate::escape_html(&profile.name))).collect::<String>();
|
||||
let tables = page.catalog.profiles.iter().flat_map(|profile| profile.tables.iter()).map(|table| format!("<option value=\"{}\"></option>", crate::escape_html(table))).collect::<String>();
|
||||
let error = page.error.as_deref().map(render_error).unwrap_or_default();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Import CSV</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Data transfer</p><h1>Import CSV</h1><p>Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.</p><form hx-post=\"/admin/import\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\" hx-disabled-elt=\"button[type=submit]\"><div class=\"form-grid\"><label>Profile<select name=\"profile_name\" required><option value=\"\">Choose a profile</option>{profiles}</select></label><label>Target tables<input name=\"table_names\" list=\"import-tables\" value=\"{}\" required placeholder=\"invoices, customers\"></label><label class=\"wide\">CSV file<input type=\"file\" accept=\".csv,text/csv\" onchange=\"this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)\"></label><label class=\"wide\">CSV data<textarea id=\"csv-data\" name=\"csv_data\" rows=\"14\" required>{}</textarea></label></div><datalist id=\"import-tables\">{tables}</datalist><div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Import rows</button></div></form></section></main></body></html>",
|
||||
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!("<div class=\"form-error\"><strong>Could not import CSV</strong><p>{}</p></div>", 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!("<div class=\"form-success\"><strong>Import complete</strong><p>Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s).</p></div>")
|
||||
let message = format!(
|
||||
"Inserted {inserted} record(s) from {source_rows} CSV row(s) across {table_count} table(s)."
|
||||
);
|
||||
render(&Alert::success("Import complete", &message))
|
||||
}
|
||||
|
||||
60
web/src/pages/login/logic.rs
Normal file
60
web/src/pages/login/logic.rs
Normal file
@@ -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<String> {
|
||||
Html(ui::render_page(Nav::new(&headers, "login")))
|
||||
}
|
||||
|
||||
pub(crate) async fn login(
|
||||
State(state): State<AppState>,
|
||||
Form(input): Form<LoginInput>,
|
||||
) -> 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()
|
||||
}
|
||||
16
web/src/pages/login/mod.rs
Normal file
16
web/src/pages/login/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new().route("/login", get(logic::login_page).post(logic::login))
|
||||
}
|
||||
6
web/src/pages/login/state.rs
Normal file
6
web/src/pages/login/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct LoginInput {
|
||||
pub identifier: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
}
|
||||
19
web/src/pages/login/ui.rs
Normal file
19
web/src/pages/login/ui.rs
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
119
web/src/ui/mod.rs
Normal file
119
web/src/ui/mod.rs
Normal file
@@ -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/<the page's path under src>/`.
|
||||
|
||||
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<T: Template>(template: &T) -> String {
|
||||
template
|
||||
.render()
|
||||
.unwrap_or_else(|error| format!("<p class=\"error\">Template error: {error}</p>"))
|
||||
}
|
||||
@@ -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 <style> block. */
|
||||
|
||||
/* ---------- Foundations ---------- */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
:root { color: #17202a; background: #f3f5f7; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||
body { margin: 0; }
|
||||
button, input { font: inherit; }
|
||||
[x-cloak] { display: none !important; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; }
|
||||
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
|
||||
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
|
||||
|
||||
/* ---------- Navbar (components/navbar.html) ---------- */
|
||||
|
||||
.topbar { min-height: 58px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; color: #eef4ff; background: #152238; }
|
||||
.topbar > div, .topbar nav { display: flex; align-items: center; gap: 18px; }
|
||||
.topbar a, .link-button { color: #cbd7e8; text-decoration: none; background: transparent; border: 0; cursor: pointer; padding: 8px 2px; }
|
||||
.topbar a, .link-button { color: #cbd7e8; text-decoration: none; background: transparent; border: 0; cursor: pointer; padding: 8px 2px; width: auto; }
|
||||
.topbar a:hover, .link-button:hover, .topbar a.active { color: white; }
|
||||
.topbar a.active { border-bottom: 2px solid #68a4ff; }
|
||||
.topbar form { margin: 0; }
|
||||
.role { color: #9fb0c8; font-size: 12px; }
|
||||
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
|
||||
|
||||
/* ---------- Page heading (components/heading.html) ---------- */
|
||||
|
||||
.heading { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 22px; }
|
||||
.heading h1 { margin: 0; font-size: 28px; }
|
||||
.heading p { margin: 5px 0 0; color: #687384; }
|
||||
.eyebrow { color: #2563eb !important; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.actions a, .actions span { padding: 8px 12px; border: 1px solid #cfd7e1; border-radius: 7px; color: #283548; background: white; text-decoration: none; }
|
||||
|
||||
/* ---------- Admin three-pane browser ---------- */
|
||||
|
||||
.workspace { display: grid; grid-template-columns: minmax(210px, .75fr) minmax(280px, 1.15fr) minmax(300px, 1.25fr); min-height: 560px; border: 1px solid #d9dfe7; border-radius: 11px; overflow: hidden; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
||||
.pane + .pane { border-left: 1px solid #e1e5eb; }
|
||||
.pane-title { height: 54px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5e8ed; background: #fafbfc; }
|
||||
@@ -30,11 +48,9 @@ main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
|
||||
.column code { color: #315078; font-size: 12px; }
|
||||
.column small { grid-column: 1 / -1; }
|
||||
.empty { margin: 8px; color: #7c8796; }
|
||||
.error-page { width: min(600px, calc(100% - 32px)); margin: 15vh auto; padding: 24px; border: 1px solid #f1c4c0; border-radius: 10px; background: white; }
|
||||
.error-page h1 { margin-top: 0; }
|
||||
.error-page p { color: #a33a31; }
|
||||
select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; }
|
||||
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
|
||||
|
||||
/* ---------- Forms (components/form_card.html) ---------- */
|
||||
|
||||
.form-main { width: min(900px, calc(100% - 32px)); }
|
||||
.back-link { display: inline-block; margin-bottom: 14px; color: #315f9d; text-decoration: none; }
|
||||
.form-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
||||
@@ -47,11 +63,76 @@ textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
|
||||
.form-actions { margin-top: 20px; display: flex; justify-content: end; align-items: center; gap: 14px; }
|
||||
.form-actions a { color: #59677a; }
|
||||
.form-actions button { padding: 9px 16px; border: 0; border-radius: 6px; color: white; background: #2563eb; cursor: pointer; }
|
||||
.check-group { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 12px 20px; }
|
||||
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 6px; }
|
||||
.check input { width: auto; }
|
||||
|
||||
/* ---------- Alerts (components/alert.html) ---------- */
|
||||
|
||||
.form-error { margin-top: 16px; padding: 11px 13px; border: 1px solid #efc3bf; border-radius: 6px; color: #9d342d; background: #fff5f4; }
|
||||
.form-error p { margin: 3px 0 0; white-space: pre-wrap; }
|
||||
.form-success { margin-top: 16px; padding: 11px 13px; border: 1px solid #b9dec6; border-radius: 6px; color: #21643a; background: #f1fbf4; }
|
||||
.form-success p { margin: 3px 0 0; }
|
||||
.check-group { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 12px 20px; }
|
||||
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; }
|
||||
.check input { width: auto; }
|
||||
@media (max-width: 850px) { .heading { align-items: start; flex-direction: column; } .workspace { grid-template-columns: 1fr; } .pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; } .topbar { padding: 10px 16px; align-items: start; gap: 10px; } .topbar, .topbar nav { flex-wrap: wrap; } }
|
||||
.error { color: #b42318; }
|
||||
.success { color: #067647; }
|
||||
.hint, .result-meta { color: #667385; }
|
||||
.error-page { width: min(600px, calc(100% - 32px)); margin: 15vh auto; padding: 24px; border: 1px solid #f1c4c0; border-radius: 10px; background: white; }
|
||||
.error-page h1 { margin-top: 0; }
|
||||
.error-page p { color: #a33a31; }
|
||||
|
||||
/* ---------- Login (pages/login.html) ---------- */
|
||||
|
||||
.login-main { width: min(420px, calc(100% - 32px)); margin: 12vh auto; }
|
||||
.login-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
|
||||
.login-card h1 { margin: 0 0 18px; font-size: 24px; }
|
||||
.login-card label { display: grid; gap: 5px; margin-bottom: 14px; color: #465267; font-size: 12px; }
|
||||
.login-card button { width: 100%; border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
||||
.login-card button:disabled, .login-card button.htmx-request { opacity: .55; cursor: wait; }
|
||||
|
||||
/* ---------- Analytics (pages/analytics.html) ---------- */
|
||||
|
||||
.analytics { display: grid; grid-template-columns: minmax(290px, 380px) minmax(0, 1fr); align-items: start; gap: 16px; }
|
||||
.panel { margin-bottom: 16px; padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
||||
.panel h2 { margin: 0 0 14px; font-size: 17px; }
|
||||
.sidebar { position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
|
||||
.sidebar h2 { margin-bottom: 6px; }
|
||||
.catalog-load { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; margin: 14px 0; }
|
||||
.catalog-load label { margin: 0; }
|
||||
.schema-summary { margin: 8px 0; }
|
||||
.catalog-table { border-top: 1px solid #e4e7ec; padding: 9px 0; }
|
||||
.catalog-table summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.currency { color: #667385; font-size: 11px; }
|
||||
.starter-query, .secondary { margin-top: 9px; padding: 6px 9px; width: auto; border: 0; border-radius: 6px; color: #344054; background: #eef2f6; cursor: pointer; }
|
||||
.columns, .links ul { margin: 8px 0; padding: 0; list-style: none; }
|
||||
.columns li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
|
||||
.columns li span { color: #667385; font-size: 11px; text-align: right; }
|
||||
.column-name { width: auto; padding: 0; border: 0; color: #2563eb; background: none; font-family: ui-monospace, monospace; text-align: left; cursor: pointer; }
|
||||
.links { color: #667385; font-size: 12px; }
|
||||
.links li { padding: 2px 0; }
|
||||
.llm-context { border-top: 1px solid #e4e7ec; margin-top: 10px; padding-top: 10px; }
|
||||
.llm-context summary { cursor: pointer; font-weight: 600; }
|
||||
.llm-context textarea { min-height: 320px; margin-top: 8px; font-size: 11px; }
|
||||
.query-row { display: grid; grid-template-columns: 180px 120px; gap: 12px; margin-bottom: 12px; }
|
||||
.query-form label { display: grid; gap: 5px; color: #465267; font-size: 12px; }
|
||||
.query-form textarea { min-height: 130px; }
|
||||
.query-form .actions { margin-top: 12px; align-items: center; gap: 12px; }
|
||||
.query-form button { border: 0; border-radius: 6px; padding: 10px 18px; width: auto; color: white; background: #2563eb; cursor: pointer; }
|
||||
.query-form button:disabled, .htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
|
||||
.chart { height: 560px; }
|
||||
.table-wrap { max-height: 560px; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; }
|
||||
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
||||
th { position: sticky; top: 0; background: #f9fafb; }
|
||||
|
||||
/* ---------- Narrow screens ---------- */
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.heading { align-items: start; flex-direction: column; }
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; }
|
||||
.topbar { padding: 10px 16px; align-items: start; gap: 10px; }
|
||||
.topbar, .topbar nav { flex-wrap: wrap; }
|
||||
.analytics { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; max-height: none; }
|
||||
.query-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Analytics graphs</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
[x-cloak] { display: none !important; }
|
||||
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
|
||||
main { width: min(1500px, calc(100% - 32px)); margin: 24px auto; }
|
||||
h1 { margin: 0 0 18px; font-size: 24px; }
|
||||
h2 { margin: 0 0 14px; font-size: 17px; }
|
||||
.panel { margin-bottom: 16px; padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
||||
.workspace { display: grid; grid-template-columns: minmax(290px, 380px) minmax(0, 1fr); align-items: start; gap: 16px; }
|
||||
.sidebar { position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
|
||||
.sidebar h2 { margin-bottom: 6px; }
|
||||
.catalog-load { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; margin: 14px 0; }
|
||||
.catalog-load label { margin: 0; }
|
||||
.schema-summary { margin: 8px 0; }
|
||||
.catalog-table { border-top: 1px solid #e4e7ec; padding: 9px 0; }
|
||||
.catalog-table summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.currency { color: #667085; font-size: 11px; }
|
||||
.starter-query, .secondary { margin-top: 9px; padding: 6px 9px; color: #344054; background: #eef2f6; }
|
||||
.columns, .links ul { margin: 8px 0; padding: 0; list-style: none; }
|
||||
.columns li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
|
||||
.columns li span { color: #667085; font-size: 11px; text-align: right; }
|
||||
.column-name { padding: 0; color: #2563eb; background: none; font-family: ui-monospace, monospace; text-align: left; }
|
||||
.links { color: #667085; font-size: 12px; }
|
||||
.links li { padding: 2px 0; }
|
||||
.llm-context { border-top: 1px solid #e4e7ec; margin-top: 10px; padding-top: 10px; }
|
||||
.llm-context summary { cursor: pointer; font-weight: 600; }
|
||||
.llm-context textarea { min-height: 320px; margin-top: 8px; font-size: 11px; }
|
||||
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
|
||||
.top h1 { margin: 0; }
|
||||
.top a { color: #2563eb; }
|
||||
.query-row { display: grid; grid-template-columns: 180px 120px; gap: 12px; margin-bottom: 12px; }
|
||||
label { display: grid; gap: 5px; color: #4b5563; font-size: 12px; }
|
||||
input, select, textarea, button { font: inherit; }
|
||||
input, select, textarea { width: 100%; border: 1px solid #cfd6dd; border-radius: 6px; padding: 9px; background: white; }
|
||||
textarea { min-height: 130px; resize: vertical; font-family: ui-monospace, monospace; line-height: 1.45; }
|
||||
button { border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
||||
button:disabled { opacity: .55; }
|
||||
.htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
|
||||
.actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
||||
.hint, .result-meta { color: #667085; }
|
||||
.error { color: #b42318; }
|
||||
.success { color: #067647; }
|
||||
.chart { height: 560px; }
|
||||
.table-wrap { max-height: 560px; overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; background: white; }
|
||||
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
||||
th { position: sticky; top: 0; background: #f9fafb; }
|
||||
@media (max-width: 760px) {
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; max-height: none; }
|
||||
.query-row { grid-template-columns: 1fr; }
|
||||
main { width: calc(100% - 20px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main x-data="{ sql: '', copied: false }">
|
||||
<div class="top"><h1>Analytics graphs</h1><div><a href="/admin">Admin panel</a> · <a href="/login">Login</a></div></div>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="panel sidebar">
|
||||
<h2>Available data</h2>
|
||||
<p class="hint">Load a profile to see the table and column aliases accepted by analytics SQL.</p>
|
||||
<div hx-get="/api/profiles" hx-trigger="load" hx-target="#profile" hx-swap="innerHTML">
|
||||
<form class="catalog-load" hx-post="/api/catalog" hx-target="#catalog" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<label>Profile
|
||||
<select id="profile" name="profile_name" hx-post="/api/catalog" hx-trigger="change" hx-target="#catalog" hx-swap="innerHTML">
|
||||
<option value="">Loading profiles…</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Load schema</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="catalog" aria-live="polite"><p class="hint">No schema loaded.</p></div>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<form id="query-form" class="panel" hx-post="/api/query" hx-include="#profile" hx-target="#output" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<div class="query-row">
|
||||
<label>Chart
|
||||
<select name="chart_type">
|
||||
<option value="bar">Bar</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="pie">Pie</option>
|
||||
<option value="scatter">Scatter</option>
|
||||
<option value="table">Table</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Max rows<input name="max_rows" type="number" min="1" value="1000"></label>
|
||||
</div>
|
||||
<label>SQL<textarea id="sql" x-ref="sql" x-model="sql" name="sql" required spellcheck="false" placeholder="Load the schema, then choose a starter query or write a SELECT query"></textarea></label>
|
||||
<div class="actions">
|
||||
<button type="submit">Run query</button>
|
||||
<span class="htmx-indicator hint">Running…</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="output" aria-live="polite"></section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login · Komp Accounting</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
|
||||
main { width: min(420px, calc(100% - 32px)); margin: 15vh auto 0; }
|
||||
.panel { padding: 22px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
||||
h1 { margin: 0 0 18px; font-size: 24px; }
|
||||
label { display: grid; gap: 5px; margin-bottom: 14px; color: #4b5563; font-size: 12px; }
|
||||
input, button { width: 100%; font: inherit; }
|
||||
input { border: 1px solid #cfd6dd; border-radius: 6px; padding: 9px; background: white; }
|
||||
button { border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
||||
button:disabled, button.htmx-request { opacity: .55; cursor: wait; }
|
||||
.error { color: #b42318; }
|
||||
.back { display: inline-block; margin-top: 14px; color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<form class="panel" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML" hx-disabled-elt="button" novalidate>
|
||||
<h1>Sign in</h1>
|
||||
<label>Username or email<input name="identifier" autocomplete="username"></label>
|
||||
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
|
||||
<button type="submit">Login</button>
|
||||
<div id="login-status" aria-live="polite"></div>
|
||||
</form>
|
||||
<a class="back" href="/admin">Back to admin panel</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
37
web/templates/pages/add_logic/add_logic.html
Normal file
37
web/templates/pages/add_logic/add_logic.html
Normal file
@@ -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 %}<p>Create or update a Steel script through the existing table-script service.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/logic" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div class="form-grid">
|
||||
<label class="wide">Table
|
||||
<select name="table_definition_id" required>
|
||||
<option value="0">Choose a table</option>
|
||||
{% for table in page.tables %}
|
||||
<option value="{{ table.id }}" {% if page.form.table_definition_id == table.id %}selected{% endif %}>{{ table.profile_name }}.{{ table.table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Logic name<input name="logic_name" value="{{ page.form.logic_name }}" placeholder="invoice total"></label>
|
||||
<label>Target column<input name="target_column" value="{{ page.form.target_column }}" required placeholder="total"></label>
|
||||
<label class="wide">Steel expression
|
||||
<textarea name="script" rows="12" required placeholder="(+ (get-var "subtotal") (get-var "tax"))">{{ page.form.script }}</textarea>
|
||||
</label>
|
||||
<label class="wide">Description<input name="description" value="{{ page.form.description }}"></label>
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not save the logic", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save logic</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
42
web/templates/pages/add_table/add_table.html
Normal file
42
web/templates/pages/add_table/add_table.html
Normal file
@@ -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 %}<p>Create a table through the existing gRPC table-definition service.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/tables" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" required>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.profiles %}
|
||||
<option value="{{ profile }}" {% if page.form.profile_name == *profile %}selected{% endif %}>{{ profile }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Table name<input name="table_name" value="{{ page.form.table_name }}" required placeholder="invoices"></label>
|
||||
<label class="wide">Columns
|
||||
<textarea name="columns" rows="9" required
|
||||
placeholder="number: text:indexed issued_on: date stock: int:quantity-ledger">{{ page.form.columns }}</textarea>
|
||||
<small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, quantity-ledger.</small>
|
||||
</label>
|
||||
<label>Additional indexed columns<input name="indexed_columns" value="{{ page.form.indexed_columns }}" placeholder="number, issued_on"></label>
|
||||
<label>Base currency<input name="base_currency" value="{{ page.form.base_currency }}" maxlength="3" placeholder="EUR"></label>
|
||||
<label>Required links<input name="required_links" value="{{ page.form.required_links }}" placeholder="customer, address"></label>
|
||||
<label>Optional links<input name="optional_links" value="{{ page.form.optional_links }}" placeholder="project"></label>
|
||||
<label>Row display columns<input name="row_display_columns" value="{{ page.form.row_display_columns }}" placeholder="name, ico"></label>
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not create the table", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Create table</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
32
web/templates/pages/add_validation/field.html
Normal file
32
web/templates/pages/add_validation/field.html
Normal file
@@ -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 %}<p>Attach validation to one column of one table.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/validation" hx-target="#submission-status" hx-swap="innerHTML">
|
||||
<div class="form-grid">
|
||||
<label>Profile<input name="profile_name" list="profiles" value="{{ page.form.profile_name }}" required></label>
|
||||
<label>Table<input name="table_name" list="tables" value="{{ page.form.table_name }}" required></label>
|
||||
<label>Column<input name="data_key" value="{{ page.form.data_key }}" required></label>
|
||||
<label class="wide">Apply named validation set instead
|
||||
<input name="validation_set_name" value="{{ page.form.validation_set_name }}"
|
||||
placeholder="Leave empty to save the rules below">
|
||||
</label>
|
||||
{% include "pages/add_validation/shared_fields.html" %}
|
||||
</div>
|
||||
<datalist id="profiles">{% for profile in page.profiles %}<option value="{{ profile }}"></option>{% endfor %}</datalist>
|
||||
<datalist id="tables">{% for table in page.tables %}<option value="{{ table }}"></option>{% endfor %}</datalist>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save validation</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
25
web/templates/pages/add_validation/rule.html
Normal file
25
web/templates/pages/add_validation/rule.html
Normal file
@@ -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 %}<p>A named rule that validation sets can compose, independent of any table.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/validation/rules" hx-target="#submission-status" hx-swap="innerHTML">
|
||||
<div class="form-grid">
|
||||
<label>Rule name<input name="rule_name" value="{{ page.form.rule_name }}" required></label>
|
||||
<label>Description<input name="description" value="{{ page.form.description }}"></label>
|
||||
{% include "pages/add_validation/shared_fields.html" %}
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save rule</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
28
web/templates/pages/add_validation/set.html
Normal file
28
web/templates/pages/add_validation/set.html
Normal file
@@ -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 %}<p>Compose an ordered set from existing reusable rule names.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/validation/sets" hx-target="#submission-status" hx-swap="innerHTML">
|
||||
<div class="form-grid">
|
||||
<label>Set name<input name="name" value="{{ form.name }}" required></label>
|
||||
<label>Description<input name="description" value="{{ form.description }}"></label>
|
||||
<label class="wide">Reusable rules
|
||||
<input name="global_rules" value="{{ form.global_rules }}" required placeholder="required, invoice-number, max-length">
|
||||
<small>Comma-separated and applied in this order.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = error %}{% call alert::error("Could not save validation", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Save set</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
34
web/templates/pages/add_validation/shared_fields.html
Normal file
34
web/templates/pages/add_validation/shared_fields.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{#
|
||||
Validation inputs shared by the field-validation and reusable-rule pages.
|
||||
Reads `page.form` (crate::pages::add_validation::state::ValidationForm).
|
||||
#}
|
||||
<label class="wide">Position rules
|
||||
<textarea name="pattern_rules" rows="6"
|
||||
placeholder="0-3 | alphabetic 4,5 | one-of=-,+ 6+ | regex=[0-9]">{{ page.form.pattern_rules }}</textarea>
|
||||
<small>One per line: position | constraint. Positions: 0, 0-3, 4+, or 1,3,5.
|
||||
Constraints: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=…</small>
|
||||
</label>
|
||||
<label class="wide">Pattern description<input name="pattern_description" value="{{ page.form.pattern_description }}"></label>
|
||||
|
||||
<label>Minimum length<input name="minimum" type="number" min="0" value="{{ page.form.minimum }}"></label>
|
||||
<label>Maximum length<input name="maximum" type="number" min="0" value="{{ page.form.maximum }}"></label>
|
||||
<label>Warning threshold<input name="warn_at" type="number" min="0" value="{{ page.form.warn_at }}"></label>
|
||||
<label>Count mode
|
||||
<select name="count_mode">
|
||||
<option value="chars">Characters</option>
|
||||
<option value="bytes" {% if page.form.count_mode == "bytes" %}selected{% endif %}>Bytes</option>
|
||||
<option value="display-width" {% if page.form.count_mode == "display-width" %}selected{% endif %}>Display width</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">Allowed values<input name="allowed_values" value="{{ page.form.allowed_values }}" placeholder="draft, issued, paid"></label>
|
||||
<label>Display mask<input name="mask_pattern" value="{{ page.form.mask_pattern }}" placeholder="####-##-##"></label>
|
||||
<label>Mask input character<input name="mask_input_char" value="{{ page.form.mask_input_char }}" placeholder="#"></label>
|
||||
<label>Mask template character<input name="mask_template_char" value="{{ page.form.mask_template_char }}" placeholder="_"></label>
|
||||
|
||||
<div class="check-group">
|
||||
<label class="check"><input type="checkbox" name="required" value="true" {% if page.form.required %}checked{% endif %}>Required</label>
|
||||
<label class="check"><input type="checkbox" name="allow_empty" value="true" {% if page.form.allow_empty %}checked{% endif %}>Allow empty</label>
|
||||
<label class="check"><input type="checkbox" name="case_insensitive" value="true" {% if page.form.case_insensitive %}checked{% endif %}>Case insensitive</label>
|
||||
<label class="check"><input type="checkbox" name="external_validation_enabled" value="true" {% if page.form.external_validation_enabled %}checked{% endif %}>External validation</label>
|
||||
<label class="check"><input type="checkbox" name="locked" value="true" {% if page.form.locked %}checked{% endif %}>Lock configuration</label>
|
||||
</div>
|
||||
25
web/templates/pages/admin/admin/admin.html
Normal file
25
web/templates/pages/admin/admin/admin.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{# GET /admin — crate::pages::admin::admin::ui::AdminPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Admin panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace</p>
|
||||
<h1>Admin panel</h1>
|
||||
<p>Browse profiles, tables, and their physical columns.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/admin/tables/new">Add table</a>
|
||||
<a href="/admin/logic/new">Add logic</a>
|
||||
<a href="/admin/validation/new">Add validation</a>
|
||||
<a href="/admin/validation/sets/new">Add rule</a>
|
||||
<a href="/admin/import">Import</a>
|
||||
<a href="/admin/export">Export</a>
|
||||
</div>
|
||||
</section>
|
||||
<div id="admin-workspace">{% include "pages/admin/admin/workspace.html" %}</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
69
web/templates/pages/admin/admin/workspace.html
Normal file
69
web/templates/pages/admin/admin/workspace.html
Normal file
@@ -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.
|
||||
#}
|
||||
<div class="workspace" aria-live="polite">
|
||||
<section class="pane">
|
||||
<div class="pane-title"><h2>Profiles</h2><span>{{ page.profiles.len() }}</span></div>
|
||||
<div class="pane-list">
|
||||
{% if page.profiles.is_empty() %}
|
||||
<p class="empty">No profiles available.</p>
|
||||
{% else %}
|
||||
{% for profile in page.profiles %}
|
||||
<form hx-get="/admin/workspace" hx-target="#admin-workspace" hx-swap="innerHTML">
|
||||
<button class="browser-item {% if page.selected_profile.as_deref() == Some(profile.name.as_str()) %}selected{% endif %}"
|
||||
type="submit" name="profile" value="{{ profile.name }}">
|
||||
<span>{{ profile.name }}</span>
|
||||
<small>{{ profile.table_count }} tables</small>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pane">
|
||||
<div class="pane-title"><h2>Tables</h2><span>{{ page.tables.len() }}</span></div>
|
||||
<div class="pane-list">
|
||||
{% if page.selected_profile.is_none() %}
|
||||
<p class="empty">Select a profile to see its tables.</p>
|
||||
{% else if page.tables.is_empty() %}
|
||||
<p class="empty">This profile has no tables.</p>
|
||||
{% else %}
|
||||
{% for table in page.tables %}
|
||||
<form hx-get="/admin/workspace" hx-target="#admin-workspace" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selected_profile.as_deref().unwrap_or_default() }}">
|
||||
<button class="browser-item {% if page.selected_table.as_deref() == Some(table.name.as_str()) %}selected{% endif %}"
|
||||
type="submit" name="table" value="{{ table.name }}">
|
||||
<span>{{ table.name }}</span>
|
||||
<small>
|
||||
{%- if table.depends_on.is_empty() %}No dependencies{% else %}Depends on {{ table.depends_on|join(", ") }}{% endif %}
|
||||
· display: {{ table.row_display_columns|join(", ") }}
|
||||
</small>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pane">
|
||||
<div class="pane-title"><h2>Columns</h2><span>{{ page.columns.len() }}</span></div>
|
||||
<div class="pane-list">
|
||||
{% if page.selected_table.is_none() %}
|
||||
<p class="empty">Select a table to inspect its columns.</p>
|
||||
{% else if page.columns.is_empty() %}
|
||||
<p class="empty">This table has no visible columns.</p>
|
||||
{% else %}
|
||||
{% for column in page.columns %}
|
||||
<div class="column">
|
||||
<span>{{ column.name }}</span>
|
||||
<code>{{ column.data_type }}</code>
|
||||
<small>{{ column.flags()|join(" · ") }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
68
web/templates/pages/analytics/analytics.html
Normal file
68
web/templates/pages/analytics/analytics.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{# GET / — crate::pages::analytics::ui::AnalyticsPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Analytics{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main x-data="{ sql: '', copied: false }">
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Reporting</p>
|
||||
<h1>Analytics</h1>
|
||||
<p>Query the read-only analytics API and chart the result.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="analytics">
|
||||
<aside class="panel sidebar">
|
||||
<h2>Available data</h2>
|
||||
<p class="hint">Load a profile to see the table and column aliases accepted by analytics SQL.</p>
|
||||
<div hx-get="/api/profiles" hx-trigger="load" hx-target="#profile" hx-swap="innerHTML">
|
||||
<form class="catalog-load" hx-post="/api/catalog" hx-target="#catalog" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<label>Profile
|
||||
<select id="profile" name="profile_name" hx-post="/api/catalog" hx-trigger="change"
|
||||
hx-target="#catalog" hx-swap="innerHTML">
|
||||
<option value="">Loading profiles…</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Load schema</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="catalog" aria-live="polite"><p class="hint">No schema loaded.</p></div>
|
||||
</aside>
|
||||
|
||||
<section>
|
||||
<form id="query-form" class="panel query-form" hx-post="/api/query" hx-include="#profile"
|
||||
hx-target="#output" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||
<div class="query-row">
|
||||
<label>Chart
|
||||
<select name="chart_type">
|
||||
<option value="bar">Bar</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="pie">Pie</option>
|
||||
<option value="scatter">Scatter</option>
|
||||
<option value="table">Table</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Max rows<input name="max_rows" type="number" min="1" value="1000"></label>
|
||||
</div>
|
||||
<label>SQL
|
||||
<textarea id="sql" x-ref="sql" x-model="sql" name="sql" required spellcheck="false"
|
||||
placeholder="Load the schema, then choose a starter query or write a SELECT query"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Run query</button>
|
||||
<span class="htmx-indicator hint">Running…</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="output" aria-live="polite"></section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
45
web/templates/pages/analytics/catalog.html
Normal file
45
web/templates/pages/analytics/catalog.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{# POST /api/catalog — crate::pages::analytics::ui::CatalogFragment #}
|
||||
{% if tables.is_empty() %}
|
||||
<p class="hint">This profile has no analytics tables.</p>
|
||||
{% else %}
|
||||
<p class="schema-summary"><strong>{{ tables.len() }}</strong> tables available</p>
|
||||
<div class="catalog-tables">
|
||||
{% for table in tables %}
|
||||
<details class="catalog-table">
|
||||
<summary>
|
||||
<code>{{ table.name }}</code>
|
||||
{%- if !table.base_currency.is_empty() %}<span class="currency">{{ table.base_currency }}</span>{% endif -%}
|
||||
</summary>
|
||||
<button type="button" class="starter-query" data-sql="{{ table.starter_query }}"
|
||||
x-on:click="sql = $el.dataset.sql; $refs.sql.focus()">Use starter query</button>
|
||||
<ul class="columns">
|
||||
{% for column in table.columns %}
|
||||
<li>
|
||||
<button type="button" class="column-name" data-insert="{{ column.insert_text }}"
|
||||
x-on:click="$refs.sql.setRangeText($el.dataset.insert, $refs.sql.selectionStart, $refs.sql.selectionEnd, 'end'); sql = $refs.sql.value; $refs.sql.focus()">{{ column.name }}</button>
|
||||
<span>{{ column.details }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if !table.links.is_empty() %}
|
||||
<div class="links">
|
||||
<span>Links</span>
|
||||
<ul>
|
||||
{% for link in table.links %}
|
||||
<li><code>{{ link.source_column }}</code> → <code>{{ link.linked_table }}</code>{% if link.required %} (required){% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<details class="llm-context">
|
||||
<summary>LLM schema context</summary>
|
||||
<p class="hint">Copy this entire text and include it when asking an LLM to write a query.</p>
|
||||
<textarea x-ref="llmSchema" readonly>{{ llm_context }}</textarea>
|
||||
<button type="button" class="secondary"
|
||||
x-on:click="navigator.clipboard.writeText($refs.llmSchema.value); copied = true">Copy context</button>
|
||||
<span class="hint" x-show="copied" x-cloak>Copied</span>
|
||||
</details>
|
||||
{% endif %}
|
||||
12
web/templates/pages/analytics/profile_options.html
Normal file
12
web/templates/pages/analytics/profile_options.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{#
|
||||
GET /api/profiles — crate::pages::analytics::ui::ProfileOptions
|
||||
The swap target is a <select>, so even the failure case is an <option>.
|
||||
#}
|
||||
{%- if let Some(message) = error %}
|
||||
<option value="">Could not load profiles: {{ message }}</option>
|
||||
{%- else %}
|
||||
<option value="">{% if profiles.is_empty() %}No profiles available{% else %}Choose a profile…{% endif %}</option>
|
||||
{%- for profile in profiles %}
|
||||
<option value="{{ profile.name }}">{{ profile.name }} ({{ profile.table_count }} tables)</option>
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
22
web/templates/pages/analytics/query_result.html
Normal file
22
web/templates/pages/analytics/query_result.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{#
|
||||
POST /api/query — crate::pages::analytics::ui::QueryResult
|
||||
Either a scrollable table or an ECharts container, depending on the chart
|
||||
type the form asked for. `chart_option` is JSON built in Rust.
|
||||
#}
|
||||
<p class="result-meta">{{ row_count }} rows in {{ elapsed_ms }} ms{% if truncated %} (truncated){% endif %}</p>
|
||||
{% match chart_option %}
|
||||
{% when Some(option) %}
|
||||
<div class="chart" data-option="{{ option }}"
|
||||
x-init="echarts.init($el).setOption(JSON.parse($el.dataset.option))"></div>
|
||||
{% when None %}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>{% for column in columns %}<th>{{ column.name }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>
|
||||
{%- for row in rows %}
|
||||
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmatch %}
|
||||
34
web/templates/pages/import_export/export/export.html
Normal file
34
web/templates/pages/import_export/export/export.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{# GET /admin/export — crate::pages::import_export::export::ui::ExportPage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
|
||||
{% block title %}Export CSV{% endblock %}
|
||||
{% block eyebrow %}Data transfer{% endblock %}
|
||||
{% block heading %}Export CSV{% endblock %}
|
||||
{% block lead %}<p>The download is generated from live table data through gRPC.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
{# A real form post, not HTMX: the response is a file download. #}
|
||||
<form method="post" action="/admin/export.csv">
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" required>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.catalog.profiles %}
|
||||
<option value="{{ profile.name }}">{{ profile.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tables
|
||||
<input name="table_names" list="export-tables" required placeholder="invoices, customers">
|
||||
<small>Separate multiple tables with commas.</small>
|
||||
</label>
|
||||
</div>
|
||||
<datalist id="export-tables">
|
||||
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
|
||||
</datalist>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Download CSV</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
40
web/templates/pages/import_export/import/import.html
Normal file
40
web/templates/pages/import_export/import/import.html
Normal file
@@ -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 %}<p>Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div class="form-grid">
|
||||
<label>Profile
|
||||
<select name="profile_name" required>
|
||||
<option value="">Choose a profile</option>
|
||||
{% for profile in page.catalog.profiles %}
|
||||
<option value="{{ profile.name }}" {% if page.form.profile_name == profile.name %}selected{% endif %}>{{ profile.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Target tables<input name="table_names" list="import-tables" value="{{ page.form.table_names }}" required placeholder="invoices, customers"></label>
|
||||
<label class="wide">CSV file
|
||||
<input type="file" accept=".csv,text/csv"
|
||||
onchange="this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)">
|
||||
</label>
|
||||
<label class="wide">CSV data<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
|
||||
</div>
|
||||
<datalist id="import-tables">
|
||||
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
|
||||
</datalist>
|
||||
<div id="submission-status" aria-live="polite">
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not import CSV", message) %}{% endcall %}{% endif -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/admin">Cancel</a>
|
||||
<button type="submit">Import rows</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
17
web/templates/pages/login/login.html
Normal file
17
web/templates/pages/login/login.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{# GET /login — crate::pages::login::ui::LoginPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Sign in{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="login-main">
|
||||
<form class="login-card" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button" novalidate>
|
||||
<h1>Sign in</h1>
|
||||
<label>Username or email<input name="identifier" autocomplete="username"></label>
|
||||
<label>Password <span>(optional)</span><input name="password" type="password" autocomplete="current-password"></label>
|
||||
<button type="submit">Login</button>
|
||||
<div id="login-status" aria-live="polite"></div>
|
||||
</form>
|
||||
</main>
|
||||
{% endblock %}
|
||||
13
web/templates/ui/alert.html
Normal file
13
web/templates/ui/alert.html
Normal file
@@ -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) %}
|
||||
<div class="form-error"><strong>{{ title }}</strong><p>{{ message }}</p></div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro success(title, message) %}
|
||||
<div class="form-success"><strong>{{ title }}</strong><p>{{ message }}</p></div>
|
||||
{% endmacro %}
|
||||
6
web/templates/ui/alert_fragment.html
Normal file
6
web/templates/ui/alert_fragment.html
Normal file
@@ -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 -%}
|
||||
22
web/templates/ui/base.html
Normal file
22
web/templates/ui/base.html
Normal file
@@ -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.
|
||||
#}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Komp Accounting{% endblock %} · Komp Accounting</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% include "ui/navbar.html" %}
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
14
web/templates/ui/error.html
Normal file
14
web/templates/ui/error.html
Normal file
@@ -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 %}
|
||||
<main>
|
||||
<div class="error-page">
|
||||
<h1>{{ heading }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
<a href="/admin">Back to the admin panel</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
17
web/templates/ui/form_page.html
Normal file
17
web/templates/ui/form_page.html
Normal file
@@ -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 %}
|
||||
<main class="form-main">
|
||||
<a class="back-link" href="/admin">← Admin panel</a>
|
||||
<section class="form-card">
|
||||
<p class="eyebrow">{% block eyebrow %}{% endblock %}</p>
|
||||
<h1>{% block heading %}{% endblock %}</h1>
|
||||
{% block lead %}{% endblock %}
|
||||
{% block form %}{% endblock %}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
21
web/templates/ui/navbar.html
Normal file
21
web/templates/ui/navbar.html
Normal file
@@ -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).
|
||||
#}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<strong>Komp Accounting</strong>
|
||||
{% if !nav.role.is_empty() %}<span class="role">{{ nav.role }}</span>{% endif %}
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/admin" {% if nav.active == "admin" %}class="active"{% endif %}>Admin</a>
|
||||
<a href="/" {% if nav.active == "analytics" %}class="active"{% endif %}>Analytics</a>
|
||||
{% if nav.authenticated %}
|
||||
<form hx-post="/logout" hx-swap="none">
|
||||
<button class="link-button" type="submit">Log out</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="/login" {% if nav.active == "login" %}class="active"{% endif %}>Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
5
web/templates/ui/notice.html
Normal file
5
web/templates/ui/notice.html
Normal file
@@ -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.
|
||||
#}
|
||||
<p class="{% if error %}error{% else %}hint{% endif %}">{{ message }}{% if login_link %} Please <a href="/login">log in</a> first.{% endif %}</p>
|
||||
Reference in New Issue
Block a user