From b18a857255b0a0de2efdf3e13c7aeaa4bd4b79f0 Mon Sep 17 00:00:00 2001 From: "Herman J. Radtke III" Date: Sat, 7 Oct 2023 12:00:02 -0400 Subject: [PATCH] feat: API and UI to edit a request --- Cargo.lock | 102 +++++++++ crates/proxy/Cargo.toml | 12 +- crates/proxy/src/cache.rs | 2 +- crates/proxy/src/db.rs | 106 ++++++---- crates/proxy/src/mgmt.rs | 75 ++++++- crates/shared_types/Cargo.toml | 9 + crates/shared_types/src/lib.rs | 89 ++++++++ crates/ui/Cargo.toml | 18 ++ crates/ui/src/error.rs | 32 +++ crates/ui/src/main.rs | 365 +++++++++++++++++++++++++++++++++ crates/ui/static/script.js | 0 crates/ui/static/style.css | 5 + 12 files changed, 768 insertions(+), 47 deletions(-) create mode 100644 crates/shared_types/Cargo.toml create mode 100644 crates/shared_types/src/lib.rs create mode 100644 crates/ui/Cargo.toml create mode 100644 crates/ui/src/error.rs create mode 100644 crates/ui/src/main.rs create mode 100644 crates/ui/static/script.js create mode 100644 crates/ui/static/style.css diff --git a/Cargo.lock b/Cargo.lock index 3c3ce2e..1e3e3ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,6 +1172,30 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" +[[package]] +name = "maud" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0bab19cef8a7fe1c18a43e881793bfc9d4ea984befec3ae5bd0415abf3ecf00" +dependencies = [ + "axum-core", + "http", + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be95d66c3024ffce639216058e5bae17a83ecaf266ffc6e4d060ad447c9eed2" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "md-5" version = "0.10.5" @@ -1202,6 +1226,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1538,6 +1572,30 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.67" @@ -1963,6 +2021,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_types" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "sqlx", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2032,6 +2099,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "shared_types", "sqlx", "tokio", "toml", @@ -2537,7 +2605,13 @@ dependencies = [ "http", "http-body", "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2630,6 +2704,33 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ui" +version = "0.0.0" +dependencies = [ + "anyhow", + "axum", + "maud", + "reqwest", + "serde", + "serde_json", + "shared_types", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.13" @@ -2678,6 +2779,7 @@ dependencies = [ "form_urlencoded", "idna 0.4.0", "percent-encoding", + "serde", ] [[package]] diff --git a/crates/proxy/Cargo.toml b/crates/proxy/Cargo.toml index 8123094..800806a 100644 --- a/crates/proxy/Cargo.toml +++ b/crates/proxy/Cargo.toml @@ -2,24 +2,26 @@ name = "soldr" version = "0.1.0" edition = "2021" +default-run = "soldr" [dependencies] anyhow = "1.0" axum = "0.6.18" +clap = { version = "4.3.8", features = ["derive"] } hyper = { version = "0.14", features = ["full"] } +lettre = { version = "0.10.4", default-features = false, features = ["smtp-transport", "tokio1", "tokio1-rustls-tls", "builder"] } +parking_lot = "0.12.1" +rand = "0.8.5" serde = { version = "1.0.163", features = ["derive"] } serde_json = "1.0.96" +shared_types = { version = "0.0.0", path = "../shared_types" } sqlx = { version = "0.7.1", features = ["sqlite", "runtime-tokio-rustls"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio = { version = "1.0", features = ["full"] } +toml = "0.7.5" tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.4.0", features = ["trace"] } -clap = { version = "4.3.8", features = ["derive"] } -toml = "0.7.5" -parking_lot = "0.12.1" -rand = "0.8.5" -lettre = { version = "0.10.4", default-features = false, features = ["smtp-transport", "tokio1", "tokio1-rustls-tls", "builder"] } [dev-dependencies] criterion = {version = "0.4", features = ["async_tokio"]} diff --git a/crates/proxy/src/cache.rs b/crates/proxy/src/cache.rs index 581897c..82bf5ac 100644 --- a/crates/proxy/src/cache.rs +++ b/crates/proxy/src/cache.rs @@ -2,8 +2,8 @@ use parking_lot::RwLock; use std::collections::HashMap; use std::sync::Arc; -use crate::db::Origin; use crate::error::AppError; +use shared_types::Origin; #[derive(Debug)] pub struct OriginCache(pub(crate) Arc); diff --git a/crates/proxy/src/db.rs b/crates/proxy/src/db.rs index 3f599ed..e0e35f6 100644 --- a/crates/proxy/src/db.rs +++ b/crates/proxy/src/db.rs @@ -1,25 +1,12 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; -use sqlx::sqlite::SqlitePool; +use sqlx::sqlite::{SqlitePool, SqliteQueryResult}; + +use shared_types::{NewOrigin, Origin}; use crate::request::HttpRequest; use crate::retry::backoff; -#[derive(Debug, Deserialize, Serialize, sqlx::FromRow, Clone)] -pub struct Origin { - pub id: i64, - pub domain: String, - pub origin_uri: String, - pub timeout: u32, - pub alert_threshold: Option, - pub alert_email: Option, - pub smtp_host: Option, - pub smtp_username: Option, - pub smtp_password: Option, - pub smtp_port: Option, - pub smtp_tls: bool, -} - #[derive(Debug)] pub struct QueuedRequest { pub id: i64, @@ -323,27 +310,6 @@ pub async fn list_attempts(pool: &SqlitePool) -> Result> { Ok(attempts) } -#[derive(Debug, Default, Deserialize, Serialize)] -pub struct NewOrigin { - pub domain: String, - pub origin_uri: String, - pub timeout: u32, - #[serde(default)] - pub alert_threshold: Option, - #[serde(default)] - pub alert_email: Option, - #[serde(default)] - pub smtp_host: Option, - #[serde(default)] - pub smtp_username: Option, - #[serde(default)] - pub smtp_password: Option, - #[serde(default)] - pub smtp_port: Option, - #[serde(default)] - pub smtp_tls: bool, -} - pub async fn insert_origin(pool: &SqlitePool, origin: NewOrigin) -> Result { tracing::trace!("insert_origin"); let mut conn = pool.acquire().await?; @@ -398,6 +364,46 @@ pub async fn insert_origin(pool: &SqlitePool, origin: NewOrigin) -> Result Result { + tracing::trace!("update_origin"); + let mut conn = pool.acquire().await?; + + let query = r#" + UPDATE origins + SET + domain = ?, + origin_uri = ?, + timeout = ?, + alert_threshold = ?, + alert_email = ?, + smtp_host = ?, + smtp_username = ?, + smtp_password = ?, + smtp_port = ?, + smtp_tls = ?, + updated_at = strftime('%s','now') + WHERE id = ? + RETURNING * + "#; + + let updated_origin = sqlx::query_as::<_, Origin>(query) + .bind(origin.domain) + .bind(origin.origin_uri) + .bind(origin.timeout) + .bind(origin.alert_threshold) + .bind(origin.alert_email) + .bind(origin.smtp_host) + .bind(origin.smtp_username) + .bind(origin.smtp_password) + .bind(origin.smtp_port) + .bind(origin.smtp_tls) + .bind(id) + .fetch_one(&mut *conn) + .await?; + + Ok(updated_origin) +} + pub async fn list_origins(pool: &SqlitePool) -> Result> { tracing::trace!("list_origins"); let mut conn = pool.acquire().await?; @@ -409,6 +415,32 @@ pub async fn list_origins(pool: &SqlitePool) -> Result> { Ok(origins) } +pub async fn get_origin(pool: &SqlitePool, id: i64) -> Result { + tracing::trace!("get_origin"); + let mut conn = pool.acquire().await?; + + let origin = sqlx::query_as::<_, Origin>("SELECT * FROM origins WHERE id = ?;") + .bind(id) + .fetch_one(&mut *conn) + .await?; + + Ok(origin) +} + +pub async fn delete_origin(pool: &SqlitePool, id: i64) -> Result { + tracing::trace!("delete origin"); + let mut conn = pool.acquire().await?; + + let query = r#" + DELETE FROM origins + WHERE id = ?; + "#; + + let result: SqliteQueryResult = sqlx::query(query).bind(id).execute(&mut *conn).await?; + + Ok(result.rows_affected() > 0) +} + pub async fn purge_completed_requests(pool: &SqlitePool, days: u32) -> Result<()> { tracing::trace!("purge_completed_requests"); let mut conn = pool.acquire().await?; diff --git a/crates/proxy/src/mgmt.rs b/crates/proxy/src/mgmt.rs index edc17dc..2fb690a 100644 --- a/crates/proxy/src/mgmt.rs +++ b/crates/proxy/src/mgmt.rs @@ -1,24 +1,30 @@ use std::result::Result as StdResult; use anyhow::Result; -use axum::extract::{Extension, Json}; +use axum::extract::{Extension, Json, Path}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::{ - routing::{get, post}, + routing::{delete, get, post, put}, Router, }; use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePool; use tracing::Level; +use shared_types::{NewOrigin, Origin}; + use crate::cache::OriginCache; use crate::db; use crate::error::AppError; pub fn router(pool: SqlitePool, origin_cache: OriginCache) -> Router { Router::new() + .route("/origins", get(list_origins)) .route("/origins", post(create_origin)) + .route("/origins/:id", get(get_origin)) + .route("/origins/:id", put(update_origin)) + .route("/origins/:id", delete(delete_origin)) .route("/requests", get(list_requests)) .route("/attempts", get(list_attempts)) .route("/queue", post(add_request_to_queue)) @@ -50,11 +56,23 @@ async fn list_attempts( Ok(Json(attempts)) } +async fn list_origins( + Extension(pool): Extension, +) -> StdResult>, AppError> { + let span = tracing::span!(Level::TRACE, "list_origins"); + let _enter = span.enter(); + + let origins = db::list_origins(&pool).await?; + tracing::debug!("response = {:?}", &origins); + + Ok(Json(origins)) +} + async fn create_origin( Extension(pool): Extension, Extension(origin_cache): Extension, - Json(new_origin): Json, -) -> StdResult, AppError> { + Json(new_origin): Json, +) -> StdResult, AppError> { let span = tracing::span!(Level::TRACE, "create_origin"); let _enter = span.enter(); @@ -67,6 +85,55 @@ async fn create_origin( Ok(Json(origin)) } +async fn update_origin( + Extension(pool): Extension, + Extension(origin_cache): Extension, + Path(id): Path, + Json(new_origin): Json, +) -> StdResult, AppError> { + let span = tracing::span!(Level::TRACE, "update_origin"); + let _enter = span.enter(); + + tracing::debug!("request payload = {:?}", &new_origin); + let origin = db::update_origin(&pool, id, new_origin).await?; + tracing::debug!("response = {:?}", &origin); + + update_origin_cache(&pool, &origin_cache).await?; + + Ok(Json(origin)) +} + +async fn get_origin( + Extension(pool): Extension, + Path(id): Path, +) -> StdResult, AppError> { + let span = tracing::span!(Level::TRACE, "get_origin"); + let _enter = span.enter(); + + tracing::debug!("origin id = {}", id); + let origin = db::get_origin(&pool, id).await?; + tracing::debug!("response = {:?}", &origin); + + Ok(Json(origin)) +} + +async fn delete_origin( + Extension(pool): Extension, + Extension(origin_cache): Extension, + Path(id): Path, +) -> StdResult { + let span = tracing::span!(Level::TRACE, "delete_origin"); + let _enter = span.enter(); + + tracing::debug!("origin id = {}", id); + let found = db::delete_origin(&pool, id).await?; + tracing::debug!("response = {:?}", found); + + update_origin_cache(&pool, &origin_cache).await?; + + Ok(StatusCode::ACCEPTED) +} + pub async fn update_origin_cache(pool: &SqlitePool, origin_cache: &OriginCache) -> Result<()> { let origins = db::list_origins(pool).await?; origin_cache.refresh(origins).unwrap(); diff --git a/crates/shared_types/Cargo.toml b/crates/shared_types/Cargo.toml new file mode 100644 index 0000000..8b644f1 --- /dev/null +++ b/crates/shared_types/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "shared_types" +version = "0.0.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0.163", features = ["derive"] } +serde_json = "1.0.96" +sqlx = { version = "0.7.1", features = ["sqlite", "runtime-tokio-rustls"] } diff --git a/crates/shared_types/src/lib.rs b/crates/shared_types/src/lib.rs new file mode 100644 index 0000000..b891626 --- /dev/null +++ b/crates/shared_types/src/lib.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, sqlx::FromRow, Clone)] +pub struct Origin { + pub id: i64, + pub domain: String, + pub origin_uri: String, + pub timeout: u32, + pub alert_threshold: Option, + pub alert_email: Option, + pub smtp_host: Option, + pub smtp_username: Option, + pub smtp_password: Option, + pub smtp_port: Option, + pub smtp_tls: bool, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, sqlx::Type, Eq, PartialEq)] +#[repr(i8)] +pub enum RequestState { + // request has been created and is ready to be processed + Received = 0, + // request has been created and is ready to be processed + Created = 1, + // request to origin is waiting to be processed + Enqueued = 2, + // request to origin is in progress + Active = 3, + // request completed successfully + Completed = 4, + // request to origin had a known error and can be retried + Failed = 5, + // unknown error + Panic = 6, + // request to origin timed out + Timeout = 7, + // no origin was found + Skipped = 8, +} + +#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)] +pub struct Request { + pub id: i64, + pub method: String, + pub uri: String, + pub headers: String, + pub body: Option>, + pub state: RequestState, + pub retry_ms_at: i64, +} + +#[derive(Debug)] +pub struct QueuedRequest { + pub id: i64, + pub method: String, + pub uri: String, + pub headers: Vec<(String, String)>, + pub body: Option>, + pub state: RequestState, +} + +#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)] +pub struct Attempt { + pub id: i64, + pub request_id: i64, + pub response_status: i64, + pub response_body: Vec, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct NewOrigin { + pub domain: String, + pub origin_uri: String, + pub timeout: u32, + #[serde(default)] + pub alert_threshold: Option, + #[serde(default)] + pub alert_email: Option, + #[serde(default)] + pub smtp_host: Option, + #[serde(default)] + pub smtp_username: Option, + #[serde(default)] + pub smtp_password: Option, + #[serde(default)] + pub smtp_port: Option, + #[serde(default)] + pub smtp_tls: bool, +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml new file mode 100644 index 0000000..f394080 --- /dev/null +++ b/crates/ui/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ui" +version = "0.0.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.75" +axum = "0.6.20" +maud = { version = "0.25.0", features = ["axum"] } +reqwest = { version = "0.11.20", features = ["json"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.107" +shared_types = { version = "0.0.0", path = "../shared_types" } +tokio = { version = "1.32.0", features = ["full"] } +tower-http = { version = "0.4.4", features = ["fs"] } +tracing = "0.1.37" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = { version = "2.4.1", features = ["serde"] } diff --git a/crates/ui/src/error.rs b/crates/ui/src/error.rs new file mode 100644 index 0000000..39698fc --- /dev/null +++ b/crates/ui/src/error.rs @@ -0,0 +1,32 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use std::fmt; + +#[derive(Debug)] +pub struct AppError(anyhow::Error); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + tracing::error!("Error: {}", self.0); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Unexpected error!".to_string(), + ) + .into_response() + } +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} diff --git a/crates/ui/src/main.rs b/crates/ui/src/main.rs new file mode 100644 index 0000000..fcf6855 --- /dev/null +++ b/crates/ui/src/main.rs @@ -0,0 +1,365 @@ +mod error; + +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Redirect, Response}, + routing::{delete, get, post, put}, + Form, Router, +}; +use maud::{html, Markup, DOCTYPE}; +use serde::{Deserialize, Serialize}; +use tower_http::services::ServeDir; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use shared_types::{Origin, Request}; + +#[tokio::main] +async fn main() { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "ui=info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let client = reqwest::Client::new(); + let app = Router::new() + .route("/", get(dashboard)) + .route("/origins", get(origins)) + .route("/origins", post(origin_create)) + .route("/origins/new", get(origin_new)) + .route("/origins/:id", get(origin_detail)) + .route("/origins/:id", put(origin_update)) + .route("/origins/:id", delete(origin_delete)) + .route("/origins/:id/edit", get(origin_edit)) + .route("/requests", get(requests)) + .route("/requests/:id", get(request_detail)) + .route("/requests/:id/edit", get(request_edit)) + .route("/attempts/:id", get(attempt_detail)) + .nest_service("/static", ServeDir::new("static")) + .with_state(client); + + axum::Server::bind(&"0.0.0.0:8888".parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} + +async fn dashboard() -> Markup { + html! { + h1 { "Dashboard" } + } +} + +async fn origins(State(client): State) -> Result { + let response = client.get("http://localhost:3443/origins").send().await?; + + let origins: Vec = response.json().await?; + + Ok(page( + "Origins", + html! { + h1 { "Origins" } + a href="/origins/new" { "New Origin" } + ul { + @for origin in origins { + @let url = format!("/origins/{}", origin.id); + li { (origin.origin_uri) " - " a href=(url) { "Details" } } + } + } + }, + )) +} + +async fn origin_new() -> Markup { + page( + "New Origin", + html! { + h1 { "New Origin" } + form action="/origins" method="POST" { + label for="domain" { "Domain:" } + input id="domain" name="domain" type="input" required="true"; + label for="origin_uri" { "Origin URI:" } + input id="origin_uri" name="origin_uri" type="input" required="true"; + label for="timeout" { "Timeout:" } + input id="timeout" name="timeout" type="input" required="true"; + button { "Create" } + } + }, + ) +} + +#[derive(Debug, Deserialize, Serialize)] +struct CreateOrigin { + domain: String, + origin_uri: url::Url, + timeout: u32, + #[serde(default)] + alert_threshold: Option, + #[serde(default)] + alert_email: Option, + #[serde(default)] + smtp_host: Option, + #[serde(default)] + smtp_username: Option, + #[serde(default)] + smtp_password: Option, + #[serde(default)] + smtp_port: Option, + #[serde(default)] + smtp_tls: bool, +} + +async fn origin_create( + State(client): State, + Form(form): Form, +) -> Result { + let response = client + .post("http://localhost:3443/origins") + .json(&form) + .send() + .await?; + + if response.status().is_success() { + Ok(Redirect::to("/origins").into_response()) + } else if response.status().is_client_error() { + Ok(Redirect::to("/origins/new?client_error").into_response()) + } else { + Ok(Redirect::to("/origins/new?server_error").into_response()) + } +} + +type UpdateOrigin = CreateOrigin; + +async fn origin_update( + State(client): State, + Path(id): Path, + Form(form): Form, +) -> Result { + let response = client + .put(format!("http://localhost:3443/origins/{}", id)) + .json(&form) + .send() + .await?; + + if response.status().is_success() { + Ok(Redirect::to(&format!("/origins/{}", id)).into_response()) + } else if response.status().is_client_error() { + Ok(Redirect::to(&format!("/origins/{}?client_error", id)).into_response()) + } else { + Ok(Redirect::to(&format!("/origins/{}?server_error", id)).into_response()) + } +} + +async fn origin_detail( + State(client): State, + Path(id): Path, +) -> Result { + let response = client + .get(format!("http://localhost:3443/origins/{}", id)) + .send() + .await?; + + let origin: Origin = response.json().await?; + + let url = format!("/origins/{}", id); + let edit_url = format!("/origins/{}/edit", id); + Ok(page( + "Origin Detail", + html! { + h1 { "Origin Detail" } + dl { + dt { "ID" } + dd { (origin.id) } + dt { "Domain" } + dd { (origin.domain) } + dt { "Origin URI" } + dd { (origin.origin_uri) } + dt { "Timeout" } + dd { (origin.timeout) } + dt { "Alert Threshold" } + dd { + @if let Some(alert_threshold) = origin.alert_threshold { + (alert_threshold) + } else { + i { "Unset" } + } + } + dt { "Alert Email" } + dd { + @if let Some(alert_email) = origin.alert_email { + (alert_email) + } else { + i { "Unset" } + } + } + dt { "SMTP Host" } + dd { + @if let Some(smtp_host) = origin.smtp_host { + (smtp_host) + } else { + i { "Unset" } + } + } + dt { "SMTP Username" } + dd { + @if let Some(smtp_username) = origin.smtp_username { + (smtp_username) + } else { + i { "Unset" } + } + } + dt { "SMTP Password" } + dd { + @if let Some(smtp_password) = origin.smtp_password { + (smtp_password) + } else { + i { "Unset" } + } + } + dt { "SMTP Port" } + dd { + @if let Some(smtp_port) = origin.smtp_port { + (smtp_port) + } else { + i { "Unset" } + } + } + dt { "SMTP TLS" } + dd { (origin.smtp_tls) } + } + a href=(edit_url) { "Edit Origin" } + button hx-delete=(url) hx-target="body" hx-push-url="true" { "Delete Origin" } + }, + )) +} + +async fn origin_delete( + State(client): State, + Path(id): Path, +) -> Result { + let response = client + .delete(format!("http://localhost:3443/origins/{}", id)) + .send() + .await?; + + if response.status().is_success() { + Ok(Redirect::to("/origins").into_response()) + } else if response.status().is_client_error() { + Ok(Redirect::to(&format!("/origins/{}?client_error", id)).into_response()) + } else { + Ok(Redirect::to(&format!("/origins/{}?server_error", id)).into_response()) + } +} + +async fn origin_edit( + State(client): State, + Path(id): Path, +) -> Result { + let response = client + .get(format!("http://localhost:3443/origins/{}", id)) + .send() + .await?; + + let origin: Origin = response.json().await?; + + let url = format!("/origins/{}", id); + Ok(page( + "Edit Origin", + html! { + h1 { "Edit Origin" } + + form hx-put=(url) hx-target="body" hx-push-url="true" { + input name="id" type="hidden" value=(origin.id); + label for="domain" { "Domain:" } + input id="domain" name="domain" type="input" required="true" value=(origin.domain); + label for="origin_uri" { "Origin URI:" } + input id="origin_uri" name="origin_uri" type="input" required="true" value=(origin.origin_uri); + label for="timeout" { "Timeout:" } + input id="timeout" name="timeout" type="input" required="true" value=(origin.timeout); + button { "Update" } + } + }, + )) +} + +async fn requests(State(client): State) -> Result { + let response = client.get("http://localhost:3443/requests").send().await?; + + let requests: Vec = response.json().await?; + + Ok(html! { + h1 { "Requests" } + ul { + @for request in requests { + @let url = format!("/requests/{}", request.id); + li { (request.method) " " (request.uri) " - " a href=(url) { "Details" } } + } + } + }) +} + +async fn request_detail() -> Markup { + html! { + h1 { "Request Detail" } + } +} + +async fn request_edit() -> Markup { + html! { + h1 { "Request Edit" } + } +} + +async fn attempt_detail() -> Markup { + html! { + h1 { "Attempt Detail" } + } +} +pub fn page(title: &str, content: Markup) -> Markup { + /// A basic header with a dynamic `page_title`. + pub(crate) fn head(page_title: &str) -> Markup { + html! { + (DOCTYPE) + html lang="en"; + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + link rel="stylesheet" type="text/css" href="/style.css"; + title { (page_title) } + } + } + } + + pub(crate) fn header() -> Markup { + html! { + header ."container py-5 flex flex-row place-content-center gap-6 items-center" { + div ."uppercase" { "Soldr" } + ."" { + img src="/favicon.ico" style="image-rendering: pixelated;" alt="soldr's logo"; + } + } + } + } + + /// A static footer. + pub(crate) fn footer() -> Markup { + html! { + script src="https://unpkg.com/htmx.org@1.9.4" {}; + script src="/script.js" {}; + } + } + + html! { + (head(title)) + body ."container relative mx-auto !block" hx-boost="true" { + (header()) + + main ."container" { + (content) + } + (footer()) + } + } +} diff --git a/crates/ui/static/script.js b/crates/ui/static/script.js new file mode 100644 index 0000000..e69de29 diff --git a/crates/ui/static/style.css b/crates/ui/static/style.css new file mode 100644 index 0000000..65857bf --- /dev/null +++ b/crates/ui/static/style.css @@ -0,0 +1,5 @@ +div.htmx-request, +form.htmx-request +{ + opacity: 0.5; +}