graphs renamed to web

This commit is contained in:
Priec
2026-08-03 12:23:50 +02:00
parent f77e23a986
commit 35d85de556
47 changed files with 19 additions and 19 deletions

View File

@@ -0,0 +1,137 @@
use axum::http::HeaderMap;
use crate::{
AppState,
auth::GetAuthorizationRequest,
definitions::{common::Empty, table_structure::GetTableStructureRequest},
services::{AuthenticationError, authenticated_request},
};
use super::state::{
AdminPageState, AdminSelection, ColumnView, LoadError, ProfileView, TableView,
};
pub(crate) async fn load_admin_page(
state: AppState,
headers: &HeaderMap,
selection: AdminSelection,
) -> Result<AdminPageState, LoadError> {
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
.map_err(authentication_error)?;
let mut auth = state.auth;
let authorization = auth
.get_authorization(authorization_request)
.await
.map_err(|error| match error.code() {
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
tonic::Code::PermissionDenied => LoadError::Forbidden,
_ => LoadError::Backend(error.message().to_string()),
})?
.into_inner();
if authorization.role != "admin" {
return Err(LoadError::Forbidden);
}
let mut definitions = state.definitions;
let profile_tree = definitions
.get_profile_tree(authenticated_request(headers, Empty {}).map_err(authentication_error)?)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
let profiles = profile_tree
.profiles
.iter()
.map(|profile| ProfileView {
name: profile.name.clone(),
table_count: profile.tables.len(),
})
.collect::<Vec<_>>();
let selected_profile = (!selection.profile.is_empty()).then_some(selection.profile);
let profile = match selected_profile.as_deref() {
Some(name) => Some(
profile_tree
.profiles
.iter()
.find(|profile| profile.name == name)
.ok_or_else(|| LoadError::InvalidSelection(format!("Unknown profile '{name}'")))?,
),
None => None,
};
let tables = profile
.map(|profile| {
profile
.tables
.iter()
.map(|table| TableView {
name: table.name.clone(),
depends_on: table.depends_on.clone(),
row_display_columns: table.row_display_columns.clone(),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let selected_table = (!selection.table.is_empty()).then_some(selection.table);
if let Some(table_name) = selected_table.as_deref() {
if profile.is_none() || !tables.iter().any(|table| table.name == table_name) {
return Err(LoadError::InvalidSelection(format!(
"Table '{table_name}' is not part of the selected profile"
)));
}
}
let columns = match (selected_profile.as_deref(), selected_table.as_deref()) {
(Some(profile_name), Some(table_name)) => {
let request = GetTableStructureRequest {
profile_name: profile_name.to_string(),
table_names: vec![table_name.to_string()],
};
let mut structures = state.structures;
let response = structures
.get_table_structure(
authenticated_request(headers, request).map_err(authentication_error)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
response
.table_structures
.get(table_name)
.ok_or_else(|| {
LoadError::Backend(format!(
"The backend did not return the structure for '{table_name}'"
))
})?
.columns
.iter()
.map(|column| ColumnView {
name: column.name.clone(),
data_type: column.data_type.clone(),
nullable: column.is_nullable,
primary_key: column.is_primary_key,
quantity_ledger: column.quantity_ledger,
})
.collect()
}
_ => Vec::new(),
};
Ok(AdminPageState {
role: authorization.role,
profiles,
selected_profile,
tables,
selected_table,
columns,
})
}
fn authentication_error(error: AuthenticationError) -> LoadError {
match error {
AuthenticationError::Missing | AuthenticationError::Invalid => LoadError::Unauthenticated,
}
}

View File

@@ -0,0 +1,72 @@
use axum::{
extract::{Query, State},
http::{HeaderMap, HeaderValue, header},
response::{Html, IntoResponse, Redirect, Response},
};
use crate::AppState;
use super::{
loader::load_admin_page,
state::{AdminSelection, LoadError},
ui,
};
pub(crate) async fn admin_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<AdminSelection>,
) -> Response {
match load_admin_page(state, &headers, selection).await {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => error_response(error),
}
}
pub(crate) async fn admin_workspace(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<AdminSelection>,
) -> Response {
match load_admin_page(state, &headers, selection).await {
Ok(page) => Html(ui::render_workspace(&page)).into_response(),
Err(error) => error_response(error),
}
}
pub(crate) async fn logout() -> Response {
let mut response = Html(String::new()).into_response();
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_static(
"analytics_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
),
);
response
.headers_mut()
.insert("hx-redirect", HeaderValue::from_static("/login"));
response
}
fn error_response(error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
LoadError::Forbidden => (
axum::http::StatusCode::FORBIDDEN,
Html(ui::render_error(
"This account is not allowed to open the admin panel.",
)),
)
.into_response(),
LoadError::InvalidSelection(message) => (
axum::http::StatusCode::BAD_REQUEST,
Html(ui::render_error(&message)),
)
.into_response(),
LoadError::Backend(message) => (
axum::http::StatusCode::BAD_GATEWAY,
Html(ui::render_error(&message)),
)
.into_response(),
}
}

View File

@@ -0,0 +1,18 @@
mod loader;
mod logic;
mod state;
mod ui;
use axum::{
Router,
routing::{get, post},
};
use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/admin", get(logic::admin_page))
.route("/admin/workspace", get(logic::admin_workspace))
.route("/logout", post(logic::logout))
}

View File

@@ -0,0 +1,47 @@
#[derive(Debug, Default, serde::Deserialize)]
pub(crate) struct AdminSelection {
#[serde(default)]
pub profile: String,
#[serde(default)]
pub table: String,
}
#[derive(Debug)]
pub(crate) struct AdminPageState {
pub role: String,
pub profiles: Vec<ProfileView>,
pub selected_profile: Option<String>,
pub tables: Vec<TableView>,
pub selected_table: Option<String>,
pub columns: Vec<ColumnView>,
}
#[derive(Debug)]
pub(crate) struct ProfileView {
pub name: String,
pub table_count: usize,
}
#[derive(Debug)]
pub(crate) struct TableView {
pub name: String,
pub depends_on: Vec<String>,
pub row_display_columns: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct ColumnView {
pub name: String,
pub data_type: String,
pub nullable: bool,
pub primary_key: bool,
pub quantity_ledger: bool,
}
#[derive(Debug)]
pub(crate) enum LoadError {
Unauthenticated,
Forbidden,
InvalidSelection(String),
Backend(String),
}

View File

@@ -0,0 +1,141 @@
use super::state::AdminPageState;
const ADMIN_CSS: &str = include_str!("../../../../static/admin.css");
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),
)
}
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),
)
}
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())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::admin::admin::state::AdminPageState;
#[test]
fn dashboard_links_every_admin_action() {
let page = AdminPageState {
role: "admin".to_string(),
profiles: Vec::new(),
selected_profile: None,
tables: Vec::new(),
selected_table: None,
columns: Vec::new(),
};
let html = render_page(&page);
for route in [
"/admin/tables/new",
"/admin/logic/new",
"/admin/validation/new",
"/admin/validation/sets/new",
"/admin/import",
"/admin/export",
"/logout",
] {
assert!(html.contains(route), "missing admin action route {route}");
}
}
}

View File

@@ -0,0 +1 @@
pub(crate) mod admin;