//! The web interface for wallsocket //! See the main crate for more. // TODO: more here #![allow(clippy::result_large_err)] use minijinja::Environment; use std::{fmt::Debug, fs::File, io::Read, ops::Deref, sync::Arc}; use thiserror::Error; use wallsocket_core::{Database, LogRetrievalError, LogRetriever}; #[cfg(debug_assertions)] use std::{path::PathBuf, str::FromStr}; #[cfg(debug_assertions)] use tower_http::services::ServeFile; #[cfg(not(debug_assertions))] use axum::{body::Body, response::Response}; #[cfg(not(debug_assertions))] use include_dir::{Dir, include_dir}; #[cfg(not(debug_assertions))] use tower::service_fn; use include_dir as _; use axum::Router; use tokio::net::ToSocketAddrs; use tracing::info; mod helpers; mod html; /// Configuration for the web interface. pub struct Config { /// User-recognisable name to show. pub service_name: String, /// Where to retrieve logs from. pub log_retriever: LogRetriever, /// Where to look for pending input requests. #[cfg(feature = "input")] pub input_registry: Arc, } /// State shared with the whole axum application. #[derive(Debug)] pub(crate) struct AppState { /// The config we were started with. pub config: Config, /// minijinja templating environment. pub templates: Environment<'static>, } /// Serve the web interface with the given address and config. /// You will often want to background this process, ie using [`tokio::spawn`]. /// # Errors /// If fatal errors are raised by axum. /// If we can't bind to the requested port. /// # Errors /// Errors if it can't get the list of available log sinks. pub async fn serve + Send + Sync + 'static>( addr: impl ToSocketAddrs + Debug, config: Config, ) -> Result<(), ServeError> { let router = router(config)?; info!("starting to listen on {:?}", addr); axum::serve(tokio::net::TcpListener::bind(addr).await?, router).await?; Ok(()) } /// An error encountered when serving the web interface. #[derive(Debug, Error)] pub enum ServeError { /// IO Error. Most likely failing to bind to the port. #[error("io error: {0}")] Io(#[from] std::io::Error), /// An error getting sinks from the database. #[error("error getting available sinks: {0}")] LogRetrieval(#[from] LogRetrievalError), } /// Build an axum [`Router`] that runs the web interface. /// This is useful when integrating with a larger axum app. /// # Errors /// Errors if it can't get the list of available log sinks. pub fn router + Send + Sync + 'static>( config: Config, ) -> Result { info!( message = "building router for wallsocket_web", service_name = config.service_name, wallsocket_web_version = env!("CARGO_PKG_VERSION") ); let mut router = Router::new(); let mut templates = Environment::new(); #[cfg(debug_assertions)] let (static_path, templates_path) = { info!("in debug mode, serving static files from filesystem"); let mut path: PathBuf = file!().into(); path.pop(); (path.join("../static"), path.join("../templates")) }; #[cfg(debug_assertions)] macro_rules! serve_file { ($path:expr, $file:expr, $mime:expr) => { router = router.route_service( $path, ServeFile::new_with_mime( static_path.join($file), &FromStr::from_str($mime).unwrap(), ), ) }; } #[cfg(debug_assertions)] macro_rules! add_template { ($name:expr, $file:expr) => { templates .add_template_owned($name, { let mut buf = String::new(); File::open(templates_path.join($file)) .unwrap() .read_to_string(&mut buf) .unwrap(); buf }) .unwrap(); }; } #[cfg(not(debug_assertions))] static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static"); #[cfg(not(debug_assertions))] static TEMPLATES_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates"); #[cfg(not(debug_assertions))] macro_rules! serve_file { ($path:expr, $file:expr, $mime:expr) => {{ let f = STATIC_DIR.get_file($file).unwrap(); router = router.route_service( $path, service_fn(async |_| { Ok({ Response::builder() .header("Content-Type", $mime) .body(Into::::into(f.contents().to_vec())) .unwrap() }) }), ) }}; } #[cfg(not(debug_assertions))] macro_rules! add_template { ($name:expr, $file:expr) => { let f = TEMPLATES_DIR.get_file($file).unwrap(); let contents = f.contents_utf8().unwrap(); templates.add_template_owned($name, contents).unwrap(); }; } serve_file!("/css.css", "css.css", "text/css"); serve_file!("/js.js", "js.js", "text/javascript"); add_template!("base", "base.html.j2"); add_template!("index", "index.html.j2"); add_template!("pending_inputs", "pending_inputs.html.j2"); add_template!("log_previews", "log_previews.html.j2"); add_template!("log_data", "log_data.html.j2"); templates.add_global("service_name", config.service_name.clone()); templates.add_global("version", env!("CARGO_PKG_VERSION")); let available_sinks = config.log_retriever.available_sinks()?; info!( message = "got available sinks", count = available_sinks.len() ); templates.add_global("available_sinks", available_sinks); { #[cfg(feature = "input")] let with_input = true; #[cfg(not(feature = "input"))] let with_input = false; info!(with_input_module = with_input); templates.add_global("with_input", with_input); } let state = Arc::new(AppState { config, templates }); Ok(router.merge(html::router()).with_state::<()>(state)) } impl Debug for Config { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Config") .field("service_name", &self.service_name) .finish_non_exhaustive() } }