htmx datafusion graphs via echarts2
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
# Analytics graphs
|
# Analytics graphs
|
||||||
|
|
||||||
A small Rust web server that forwards SQL queries to `AnalyticsService.ExecuteAnalyticsQuery`
|
A small Rust web server that logs in through `AuthService.Login`, forwards SQL
|
||||||
and displays the streamed result with ECharts.
|
queries to `AnalyticsService.ExecuteAnalyticsQuery`, and displays the streamed
|
||||||
|
result with HTMX and ECharts.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -11,7 +12,8 @@ Start the main komp_ac gRPC server, then run:
|
|||||||
cargo run
|
cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://127.0.0.1:3000>. The default gRPC endpoint is
|
Open <http://127.0.0.1:3000/login> to log in, then use the analytics page at
|
||||||
|
<http://127.0.0.1:3000>. The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
|
||||||
`http://[::1]:50051`. Both addresses can be changed:
|
`http://[::1]:50051`. Both addresses can be changed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -24,5 +26,12 @@ 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
|
all remaining columns as numeric series, pie uses the second column, and scatter
|
||||||
uses the first two columns. Table view displays every returned value.
|
uses the first two columns. Table view displays every returned value.
|
||||||
|
|
||||||
ECharts is loaded from jsDelivr, so the browser needs network access when opening
|
The profile selector is populated automatically through
|
||||||
the page.
|
`TableDefinition.GetProfileTree`. Choosing a profile fetches its live public
|
||||||
|
analytics catalog. The sidebar shows tables, columns, types, and links and can
|
||||||
|
create starter queries. Its **LLM schema context** section generates a complete
|
||||||
|
text summary that can be copied into an LLM prompt to request a valid analytics
|
||||||
|
SQL query.
|
||||||
|
|
||||||
|
HTMX and ECharts are loaded from jsDelivr, so the browser needs network access
|
||||||
|
when opening the page.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use std::{env, net::SocketAddr};
|
use std::{env, net::SocketAddr};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Form, Router,
|
||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
http::{HeaderMap, HeaderValue, header},
|
||||||
response::Html,
|
response::{Html, IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
mod analytics {
|
mod analytics {
|
||||||
@@ -13,30 +13,71 @@ mod analytics {
|
|||||||
"/../common/src/proto/komp_ac.analytics.rs"
|
"/../common/src/proto/komp_ac.analytics.rs"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
mod auth {
|
||||||
|
include!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../common/src/proto/komp_ac.auth.rs"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
mod definitions {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub mod common {
|
||||||
|
include!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../common/src/proto/komp_ac.common.rs"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
pub mod table_definition {
|
||||||
|
include!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../common/src/proto/komp_ac.table_definition.rs"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use analytics::{
|
use analytics::{
|
||||||
AnalyticsResultColumn, AnalyticsValue, ExecuteAnalyticsQueryRequest,
|
AnalyticsResultColumn, AnalyticsTable, AnalyticsValue, ExecuteAnalyticsQueryRequest,
|
||||||
|
GetAnalyticsCatalogRequest, GetAnalyticsCatalogResponse,
|
||||||
analytics_service_client::AnalyticsServiceClient, analytics_value,
|
analytics_service_client::AnalyticsServiceClient, analytics_value,
|
||||||
};
|
};
|
||||||
|
use auth::{LoginRequest, auth_service_client::AuthServiceClient};
|
||||||
|
use definitions::{
|
||||||
|
common::Empty,
|
||||||
|
table_definition::table_definition_client::TableDefinitionClient,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tonic::{Request, metadata::MetadataValue, transport::Channel};
|
use tonic::{Request, metadata::MetadataValue, transport::Channel};
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("../static/index.html");
|
const INDEX_HTML: &str = include_str!("../static/index.html");
|
||||||
|
const LOGIN_HTML: &str = include_str!("../static/login.html");
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
analytics: AnalyticsServiceClient<Channel>,
|
analytics: AnalyticsServiceClient<Channel>,
|
||||||
|
auth: AuthServiceClient<Channel>,
|
||||||
|
definitions: TableDefinitionClient<Channel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct QueryInput {
|
struct QueryInput {
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
sql: String,
|
sql: String,
|
||||||
#[serde(default)]
|
|
||||||
token: String,
|
|
||||||
#[serde(default = "default_max_rows")]
|
#[serde(default = "default_max_rows")]
|
||||||
max_rows: u32,
|
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 {
|
fn default_max_rows() -> u32 {
|
||||||
@@ -58,13 +99,6 @@ struct ColumnOutput {
|
|||||||
data_type: String,
|
data_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ErrorOutput {
|
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<ErrorOutput>)>;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let grpc_endpoint =
|
let grpc_endpoint =
|
||||||
@@ -75,11 +109,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let channel = Channel::from_shared(grpc_endpoint.clone())?.connect_lazy();
|
let channel = Channel::from_shared(grpc_endpoint.clone())?.connect_lazy();
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
analytics: AnalyticsServiceClient::new(channel),
|
analytics: AnalyticsServiceClient::new(channel.clone()),
|
||||||
|
auth: AuthServiceClient::new(channel.clone()),
|
||||||
|
definitions: TableDefinitionClient::new(channel),
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index))
|
.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))
|
.route("/api/query", post(run_query))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
@@ -94,12 +133,50 @@ async fn index() -> Html<&'static str> {
|
|||||||
Html(INDEX_HTML)
|
Html(INDEX_HTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>) -> ApiResult<QueryOutput> {
|
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("/"));
|
||||||
|
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() {
|
if input.profile_name.trim().is_empty() || input.sql.trim().is_empty() {
|
||||||
return Err(api_error(
|
return error_fragment("Profile name and SQL are required");
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Profile name and SQL are required",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut request = Request::new(ExecuteAnalyticsQueryRequest {
|
let mut request = Request::new(ExecuteAnalyticsQueryRequest {
|
||||||
@@ -108,18 +185,19 @@ async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>)
|
|||||||
max_rows: input.max_rows,
|
max_rows: input.max_rows,
|
||||||
});
|
});
|
||||||
|
|
||||||
if !input.token.trim().is_empty() {
|
let Some(token) = cookie_value(&headers, "analytics_token") else {
|
||||||
let value = MetadataValue::try_from(format!("Bearer {}", input.token.trim()))
|
return Html("<p class=\"error\">Please <a href=\"/login\">log in</a> before running a query.</p>".into());
|
||||||
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "The bearer token is invalid"))?;
|
};
|
||||||
request.metadata_mut().insert("authorization", value);
|
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 client = state.analytics;
|
||||||
let mut stream = client
|
let mut stream = match client.execute_analytics_query(request).await {
|
||||||
.execute_analytics_query(request)
|
Ok(response) => response.into_inner(),
|
||||||
.await
|
Err(error) => return error_fragment(error.message()),
|
||||||
.map_err(grpc_error)?
|
};
|
||||||
.into_inner();
|
|
||||||
|
|
||||||
let mut columns = Vec::new();
|
let mut columns = Vec::new();
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
@@ -127,7 +205,12 @@ async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>)
|
|||||||
let mut elapsed_ms = 0;
|
let mut elapsed_ms = 0;
|
||||||
let mut truncated = false;
|
let mut truncated = false;
|
||||||
|
|
||||||
while let Some(batch) = stream.message().await.map_err(grpc_error)? {
|
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() {
|
if !batch.columns.is_empty() {
|
||||||
columns = batch.columns.into_iter().map(column_output).collect();
|
columns = batch.columns.into_iter().map(column_output).collect();
|
||||||
}
|
}
|
||||||
@@ -142,13 +225,71 @@ async fn run_query(State(state): State<AppState>, Json(input): Json<QueryInput>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(QueryOutput {
|
let output = QueryOutput {
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
row_count,
|
row_count,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
truncated,
|
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 {
|
fn column_output(column: AnalyticsResultColumn) -> ColumnOutput {
|
||||||
@@ -172,24 +313,271 @@ fn value_output(value: AnalyticsValue) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn grpc_error(error: tonic::Status) -> (StatusCode, Json<ErrorOutput>) {
|
fn render_result(result: &QueryOutput, chart_type: &str) -> Html<String> {
|
||||||
let status = match error.code() {
|
let metadata = format!(
|
||||||
tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST,
|
"{} rows in {} ms{}",
|
||||||
tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED,
|
result.row_count,
|
||||||
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
|
result.elapsed_ms,
|
||||||
tonic::Code::NotFound => StatusCode::NOT_FOUND,
|
if result.truncated { " (truncated)" } else { "" },
|
||||||
tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
|
);
|
||||||
tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
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"),
|
||||||
};
|
};
|
||||||
api_error(status, error.message())
|
let safe_option = option.to_string().replace('<', "\\u003c");
|
||||||
|
|
||||||
|
Html(format!(
|
||||||
|
"<p class=\"result-meta\">{metadata}</p><div id=\"chart\" class=\"chart\"></div>\
|
||||||
|
<script>echarts.init(document.getElementById('chart')).setOption({safe_option});</script>"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_error(status: StatusCode, message: impl Into<String>) -> (StatusCode, Json<ErrorOutput>) {
|
fn render_catalog(catalog: &GetAnalyticsCatalogResponse) -> Html<String> {
|
||||||
(
|
if catalog.tables.is_empty() {
|
||||||
status,
|
return Html("<p class=\"hint\">This profile has no analytics tables.</p>".into());
|
||||||
Json(ErrorOutput {
|
}
|
||||||
error: message.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 id=\"llm-schema\" readonly>{}</textarea>\
|
||||||
|
<button type=\"button\" class=\"secondary\" data-copy=\"llm-schema\">Copy context</button>\
|
||||||
|
<span id=\"copy-status\" class=\"hint\"></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=\"{}\">{}</button><span>{}</span></li>",
|
||||||
|
escape_html("e_identifier(&column.name)),
|
||||||
|
escape_html(&column.name),
|
||||||
|
escape_html(&details),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
let links = if table.links.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let items = table
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.map(|link| {
|
||||||
|
format!(
|
||||||
|
"<li><code>{}</code> → <code>{}</code>{}</li>",
|
||||||
|
escape_html(&link.source_column),
|
||||||
|
escape_html(&link.linked_table),
|
||||||
|
if link.required { " (required)" } else { "" },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
format!("<div class=\"links\"><span>Links</span><ul>{items}</ul></div>")
|
||||||
|
};
|
||||||
|
let currency = if table.base_currency.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"<span class=\"currency\">{}</span>",
|
||||||
|
escape_html(&table.base_currency)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"<details class=\"catalog-table\">\
|
||||||
|
<summary><code>{}</code>{currency}</summary>\
|
||||||
|
<button type=\"button\" class=\"starter-query\" data-sql=\"{}\">Use starter query</button>\
|
||||||
|
<ul class=\"columns\">{columns}</ul>{links}\
|
||||||
|
</details>",
|
||||||
|
escape_html(&table.name),
|
||||||
|
escape_html(&starter_query),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_llm_context(catalog: &GetAnalyticsCatalogResponse) -> String {
|
||||||
|
let mut text = format!(
|
||||||
|
"Write one read-only PostgreSQL SELECT query for the komp_ac analytics API.\n\
|
||||||
|
Profile: {}\n\n\
|
||||||
|
Rules:\n\
|
||||||
|
- Return only the SQL query, without Markdown fences or explanation.\n\
|
||||||
|
- Use exactly one read-only SELECT statement.\n\
|
||||||
|
- Use only the public table and column aliases listed below.\n\
|
||||||
|
- Physical database column names are not visible through this API.\n\
|
||||||
|
- Add a reasonable LIMIT unless the query is an aggregate with a naturally small result.\n\
|
||||||
|
- For a chart, put the category or x-axis column first and numeric value columns after it.\n\n\
|
||||||
|
AVAILABLE ANALYTICS SCHEMA\n",
|
||||||
|
catalog.profile_name,
|
||||||
|
);
|
||||||
|
|
||||||
|
for table in &catalog.tables {
|
||||||
|
text.push_str(&format!("\nTABLE {}\n", quote_identifier(&table.name)));
|
||||||
|
if !table.base_currency.is_empty() {
|
||||||
|
text.push_str(&format!(" Base currency: {}\n", table.base_currency));
|
||||||
|
}
|
||||||
|
text.push_str(" Columns:\n");
|
||||||
|
for column in &table.columns {
|
||||||
|
text.push_str(&format!(
|
||||||
|
" - {}: {}",
|
||||||
|
quote_identifier(&column.name),
|
||||||
|
column.field_type,
|
||||||
|
));
|
||||||
|
if column.is_system {
|
||||||
|
text.push_str(" [system]");
|
||||||
|
}
|
||||||
|
if !column.rounding.is_empty() {
|
||||||
|
text.push_str(&format!(" [rounding: {}]", column.rounding));
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
if !table.links.is_empty() {
|
||||||
|
text.push_str(" Links:\n");
|
||||||
|
for link in &table.links {
|
||||||
|
text.push_str(&format!(
|
||||||
|
" - {} references {} ({})\n",
|
||||||
|
quote_identifier(&link.source_column),
|
||||||
|
quote_identifier(&link.linked_table),
|
||||||
|
if link.required { "required" } else { "optional" },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_identifier(identifier: &str) -> String {
|
||||||
|
format!("\"{}\"", identifier.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_value(value: &Value) -> String {
|
||||||
|
match value {
|
||||||
|
Value::Null => String::new(),
|
||||||
|
Value::String(value) => value.clone(),
|
||||||
|
value => value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_fragment(message: &str) -> Html<String> {
|
||||||
|
Html(format!("<p class=\"error\">{}</p>", escape_html(message)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
|
headers
|
||||||
|
.get(header::COOKIE)?
|
||||||
|
.to_str()
|
||||||
|
.ok()?
|
||||||
|
.split(';')
|
||||||
|
.map(str::trim)
|
||||||
|
.find_map(|cookie| cookie.strip_prefix(name)?.strip_prefix('='))
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,164 +4,134 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Analytics graphs</title>
|
<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 src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
|
body { margin: 0; font: 14px system-ui, sans-serif; color: #17202a; background: #f4f6f8; }
|
||||||
main { width: min(1200px, calc(100% - 32px)); margin: 24px auto; }
|
main { width: min(1500px, calc(100% - 32px)); margin: 24px auto; }
|
||||||
h1 { margin: 0 0 18px; font-size: 24px; }
|
h1 { margin: 0 0 18px; font-size: 24px; }
|
||||||
.panel { padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
|
h2 { margin: 0 0 14px; font-size: 17px; }
|
||||||
.row { display: grid; grid-template-columns: 1fr 1fr 180px 120px; gap: 12px; margin-bottom: 12px; }
|
.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; }
|
label { display: grid; gap: 5px; color: #4b5563; font-size: 12px; }
|
||||||
input, select, textarea, button { font: inherit; }
|
input, select, textarea, button { font: inherit; }
|
||||||
input, select, textarea { width: 100%; border: 1px solid #cfd6dd; border-radius: 6px; padding: 9px; background: white; }
|
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; }
|
textarea { min-height: 130px; resize: vertical; font-family: ui-monospace, monospace; line-height: 1.45; }
|
||||||
.actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
|
||||||
button { border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
button { border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
|
||||||
button:disabled { opacity: .55; cursor: wait; }
|
button:disabled { opacity: .55; }
|
||||||
#status { color: #667085; }
|
.htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
|
||||||
#status.error { color: #b42318; }
|
.actions { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
|
||||||
#chart { height: 560px; margin-top: 18px; }
|
.hint, .result-meta { color: #667085; }
|
||||||
.table-wrap { display: none; max-height: 560px; overflow: auto; margin-top: 18px; }
|
.error { color: #b42318; }
|
||||||
|
.success { color: #067647; }
|
||||||
|
.chart { height: 560px; }
|
||||||
|
.table-wrap { max-height: 560px; overflow: auto; }
|
||||||
table { width: 100%; border-collapse: collapse; background: white; }
|
table { width: 100%; border-collapse: collapse; background: white; }
|
||||||
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
|
||||||
th { position: sticky; top: 0; background: #f9fafb; }
|
th { position: sticky; top: 0; background: #f9fafb; }
|
||||||
@media (max-width: 760px) { .row { grid-template-columns: 1fr; } main { width: calc(100% - 20px); } }
|
@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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Analytics graphs</h1>
|
<div class="top"><h1>Analytics graphs</h1><a href="/login">Login</a></div>
|
||||||
<form id="query-form" class="panel">
|
|
||||||
<div class="row">
|
<div class="workspace">
|
||||||
<label>Profile<input id="profile" required autocomplete="off"></label>
|
<aside class="panel sidebar">
|
||||||
<label>Bearer token<input id="token" type="password" autocomplete="off"></label>
|
<h2>Available data</h2>
|
||||||
<label>Chart
|
<p class="hint">Load a profile to see the table and column aliases accepted by analytics SQL.</p>
|
||||||
<select id="chart-type">
|
<div hx-get="/api/profiles" hx-trigger="load" hx-target="#profile" hx-swap="innerHTML">
|
||||||
<option value="bar">Bar</option>
|
<form class="catalog-load" hx-post="/api/catalog" hx-target="#catalog" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||||
<option value="line">Line</option>
|
<label>Profile
|
||||||
<option value="pie">Pie</option>
|
<select id="profile" name="profile_name" hx-post="/api/catalog" hx-trigger="change" hx-target="#catalog" hx-swap="innerHTML">
|
||||||
<option value="scatter">Scatter</option>
|
<option value="">Loading profiles…</option>
|
||||||
<option value="table">Table</option>
|
</select>
|
||||||
</select>
|
</label>
|
||||||
</label>
|
<button type="submit">Load schema</button>
|
||||||
<label>Max rows<input id="max-rows" type="number" min="1" value="1000"></label>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<label>SQL<textarea id="sql" required spellcheck="false" placeholder="SELECT category, SUM(amount) AS total FROM sales GROUP BY category"></textarea></label>
|
<div id="catalog" aria-live="polite"><p class="hint">No schema loaded.</p></div>
|
||||||
<div class="actions">
|
</aside>
|
||||||
<button id="run" type="submit">Run query</button>
|
|
||||||
<span id="status"></span>
|
<section>
|
||||||
</div>
|
<form id="query-form" class="panel" hx-post="/api/query" hx-include="#profile" hx-target="#output" hx-swap="innerHTML" hx-disabled-elt="button">
|
||||||
</form>
|
<div class="query-row">
|
||||||
<div id="chart"></div>
|
<label>Chart
|
||||||
<div id="table-wrap" class="table-wrap"><table id="table"></table></div>
|
<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" 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>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const form = document.querySelector('#query-form');
|
document.addEventListener('click', async (event) => {
|
||||||
const status = document.querySelector('#status');
|
const queryButton = event.target.closest('[data-sql]');
|
||||||
const runButton = document.querySelector('#run');
|
if (queryButton) {
|
||||||
const chartElement = document.querySelector('#chart');
|
const editor = document.getElementById('sql');
|
||||||
const tableWrap = document.querySelector('#table-wrap');
|
editor.value = queryButton.dataset.sql;
|
||||||
const table = document.querySelector('#table');
|
editor.focus();
|
||||||
const chart = echarts.init(chartElement);
|
|
||||||
let lastResult = null;
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
runButton.disabled = true;
|
|
||||||
setStatus('Running…');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/query', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
profile_name: document.querySelector('#profile').value,
|
|
||||||
token: document.querySelector('#token').value,
|
|
||||||
sql: document.querySelector('#sql').value,
|
|
||||||
max_rows: Number(document.querySelector('#max-rows').value)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`);
|
|
||||||
lastResult = result;
|
|
||||||
render(result);
|
|
||||||
setStatus(`${result.row_count} rows in ${result.elapsed_ms} ms${result.truncated ? ' (truncated)' : ''}`);
|
|
||||||
} catch (error) {
|
|
||||||
setStatus(error.message, true);
|
|
||||||
} finally {
|
|
||||||
runButton.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector('#chart-type').addEventListener('change', () => {
|
|
||||||
if (lastResult) render(lastResult);
|
|
||||||
});
|
|
||||||
window.addEventListener('resize', () => chart.resize());
|
|
||||||
|
|
||||||
function render(result) {
|
|
||||||
const type = document.querySelector('#chart-type').value;
|
|
||||||
if (type === 'table') return renderTable(result);
|
|
||||||
tableWrap.style.display = 'none';
|
|
||||||
chartElement.style.display = 'block';
|
|
||||||
if (!result.columns.length) {
|
|
||||||
chart.clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = result.columns.map(column => column.name);
|
const columnButton = event.target.closest('[data-insert]');
|
||||||
const rows = result.rows;
|
if (columnButton) {
|
||||||
let option;
|
const editor = document.getElementById('sql');
|
||||||
if (type === 'pie') {
|
const start = editor.selectionStart;
|
||||||
option = {
|
editor.setRangeText(columnButton.dataset.insert, start, editor.selectionEnd, 'end');
|
||||||
tooltip: { trigger: 'item' },
|
editor.focus();
|
||||||
legend: { type: 'scroll', bottom: 0 },
|
return;
|
||||||
series: [{ type: 'pie', radius: ['25%', '68%'], data: rows.map(row => ({ name: String(row[0] ?? ''), value: numeric(row[1]) })) }]
|
|
||||||
};
|
|
||||||
} else if (type === 'scatter') {
|
|
||||||
option = {
|
|
||||||
tooltip: { trigger: 'item' },
|
|
||||||
xAxis: { name: names[0], type: 'value' },
|
|
||||||
yAxis: { name: names[1], type: 'value' },
|
|
||||||
series: [{ name: names[1], type: 'scatter', data: rows.map(row => [numeric(row[0]), numeric(row[1])]) }]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
option = {
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { type: 'scroll', bottom: 0 },
|
|
||||||
grid: { left: 55, right: 25, top: 35, bottom: 70, containLabel: true },
|
|
||||||
xAxis: { type: 'category', data: rows.map(row => String(row[0] ?? '')) },
|
|
||||||
yAxis: { type: 'value' },
|
|
||||||
series: names.slice(1).map((name, index) => ({ name, type, data: rows.map(row => numeric(row[index + 1])), smooth: type === 'line' }))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
chart.setOption(option, true);
|
|
||||||
chart.resize();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable(result) {
|
const copyButton = event.target.closest('[data-copy]');
|
||||||
chartElement.style.display = 'none';
|
if (copyButton) {
|
||||||
tableWrap.style.display = 'block';
|
const text = document.getElementById(copyButton.dataset.copy).value;
|
||||||
const header = `<thead><tr>${result.columns.map(column => `<th>${escapeHtml(column.name)}</th>`).join('')}</tr></thead>`;
|
await navigator.clipboard.writeText(text);
|
||||||
const body = `<tbody>${result.rows.map(row => `<tr>${row.map(value => `<td>${escapeHtml(value ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>`;
|
document.getElementById('copy-status').textContent = 'Copied';
|
||||||
table.innerHTML = header + body;
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
function numeric(value) {
|
|
||||||
const number = Number(value);
|
|
||||||
return Number.isFinite(number) ? number : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
const element = document.createElement('div');
|
|
||||||
element.textContent = String(value);
|
|
||||||
return element.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(message, error = false) {
|
|
||||||
status.textContent = message;
|
|
||||||
status.classList.toggle('error', error);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
35
graphs/static/login.html
Normal file
35
graphs/static/login.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Login · Analytics graphs</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>Login</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="/">Back to analytics</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user