webs unified

This commit is contained in:
Priec
2026-08-03 13:32:25 +02:00
parent 35d85de556
commit c8afe99d79
59 changed files with 1991 additions and 952 deletions

View File

@@ -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"] }

View File

@@ -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.

View File

@@ -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(&quote_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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
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"));
}
}

View File

@@ -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 {

View File

@@ -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>,

View File

@@ -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 &quot;subtotal&quot;) (get-var &quot;tax&quot;))\">{}</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))
}

View File

@@ -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,

View File

@@ -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>,

View File

@@ -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&#10;issued_on: date&#10;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))
}

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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&#10;4,5 | one-of=-,+&#10;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))
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(),

View 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('"', "\"\""))
}

View 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(&notice)).into_response()
}

View 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))
}

View 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,
}

View 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(),
}
}

View File

@@ -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?,
})
}

View File

@@ -7,6 +7,7 @@ pub(crate) struct ExportForm {
}
pub(crate) struct ExportPageState {
pub nav: crate::ui::Nav,
pub catalog: super::super::common::loader::Catalog,
}

View File

@@ -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))
}

View File

@@ -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,
})
}

View File

@@ -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>,

View File

@@ -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))
}

View 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()
}

View 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))
}

View 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
View 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))
}

View File

@@ -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
View 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>"))
}

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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>

View 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 &quot;subtotal&quot;) (get-var &quot;tax&quot;))">{{ 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 %}

View 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&#10;issued_on: date&#10;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 %}

View 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 %}

View 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 %}

View 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 %}

View 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&#10;4,5 | one-of=-,+&#10;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>

View 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 %}

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 =&gt; 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 %}

View 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 %}

View 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 %}

View 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 -%}

View 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>

View 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 %}

View 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 %}

View 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>

View 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>