about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock38
-rw-r--r--Cargo.toml5
-rw-r--r--justfile8
-rw-r--r--splorm/Cargo.toml5
-rw-r--r--splorm/splorm.conf5
-rw-r--r--splorm/src/config.rs21
-rw-r--r--splorm/src/connector/gatherer.rs177
-rw-r--r--splorm/src/connector/mod.rs5
-rw-r--r--splorm/src/fs/mod.rs9
-rw-r--r--splorm/src/handler/api.rs43
-rw-r--r--splorm/src/handler/mod.rs11
-rw-r--r--splorm/src/handler/templated.rs19
-rw-r--r--splorm/src/main.rs17
-rw-r--r--splorm/src/runtime_error.rs8
-rw-r--r--splorm/src/state.rs35
-rw-r--r--splorm/src/templated/features.rs20
-rw-r--r--splorm/src/templated/mod.rs26
-rwxr-xr-xtimeparse/src/lib.rs6
18 files changed, 433 insertions, 25 deletions
diff --git a/Cargo.lock b/Cargo.lock
index afe2f68..87bccf3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -187,6 +187,10 @@ dependencies = [
 ]
 
 [[package]]
+name = "blobby-derive"
+version = "0.1.0"
+
+[[package]]
 name = "block-buffer"
 version = "0.10.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -271,6 +275,12 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
 
 [[package]]
 name = "confindent"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea618ded77af626818bde0f0802da7c20d47e38e23e37be40f6f807a76079e82"
+
+[[package]]
+name = "confindent"
 version = "3.0.0"
 source = "git+https://github.com/gennyble/confindent?branch=v3.0-writing#cbb0aa4bc55c346f33e41f979e72cd3a95518a75"
 
@@ -474,10 +484,32 @@ dependencies = [
 name = "gatherer"
 version = "0.1.0"
 dependencies = [
+ "camino",
+ "gatherer-api",
  "gifed",
- "state",
+ "rusqlite",
+ "scurvy",
  "time",
  "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "gatherer-api"
+version = "0.1.0"
+dependencies = [
+ "camino",
+ "gny",
+ "tracing",
+]
+
+[[package]]
+name = "gatherer-client"
+version = "0.1.0"
+dependencies = [
+ "camino",
+ "gatherer-api",
+ "tracing",
 ]
 
 [[package]]
@@ -529,6 +561,8 @@ dependencies = [
 name = "gny"
 version = "0.1.0"
 dependencies = [
+ "blobby-derive",
+ "camino",
  "rand",
  "sternum",
  "time",
@@ -1128,6 +1162,7 @@ version = "0.5.0"
 source = "git+https://git.dreamy.place/crates/scurvy/#832266e7e8c01dbfd236052cd9b740d545ffcffa"
 dependencies = [
  "camino",
+ "confindent 2.2.1",
 ]
 
 [[package]]
@@ -1298,6 +1333,7 @@ dependencies = [
  "bempline",
  "camino",
  "confindent 3.0.0 (git+https://github.com/gennyble/confindent)",
+ "gatherer-client",
  "gny",
  "markup",
  "paige",
diff --git a/Cargo.toml b/Cargo.toml
index 33bde4a..7f0e558 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [workspace]
 resolver = "3"
-members = ["awake", "awake-auth", "awake-auth-objects", "awake-database", "awake-error", "awake-fs", "paige", "awake-state", "gatherer", "markup", "pushwhatever", "shovel", "splorm", "timeparse", "wikarden", "sessionhash"]
+members = ["awake", "awake-auth", "awake-auth-objects", "awake-database", "awake-error", "awake-fs", "paige", "awake-state", "gatherer", "markup", "pushwhatever", "shovel", "splorm", "timeparse", "wikarden", "sessionhash", "gatherer-api", "gatherer-client"]
 
 [workspace.dependencies]
 bempline = { git = "https://github.com/gennyble/bempline" }
@@ -12,11 +12,14 @@ time = { version = "0.3.55", features = ["local-offset"] }
 axum = "0.8.9"
 gny = { path = "../gny/libgny", features = ["web"] }
 tracing = "0.1.40"
+tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
 
 # Local Crates
 markup = { path = "markup" }
 wikarden = { path = "wikarden" }
 timeparse = { path = "timeparse" }
+gatherer-api = { path = "gatherer-api" }
+gatherer-client = { path = "gatherer-client" }
 
 # Awake subcrates
 database = { path = "awake-database" }
diff --git a/justfile b/justfile
index 684ba5f..aec9dd4 100644
--- a/justfile
+++ b/justfile
@@ -12,12 +12,12 @@ run:
 graph: get-db
 	cargo run --bin awake --release --bin awake -- graph dev.conf
 
-deploy:
-	cargo build --bin awake --release --target x86_64-unknown-linux-gnu
-	rsync -aP target/x86_64-unknown-linux-gnu/release/awake {{ prod }}:{{ prod_bin }}
-
 shovel:
 	cargo run --bin shovel -- c=dev.conf
 
 splorm:
 	cargo run --bin splorm -- c=splorm/splorm.conf
+
+deploy NAME:
+	cargo build --bin {{ NAME }} --release --target x86_64-unknown-linux-gnu
+	rsync -aP target/x86_64-unknown-linux-gnu/release/{{ NAME }} {{ prod }}:
diff --git a/splorm/Cargo.toml b/splorm/Cargo.toml
index 6b4d471..349b541 100644
--- a/splorm/Cargo.toml
+++ b/splorm/Cargo.toml
@@ -14,13 +14,14 @@ axum.workspace = true
 camino.workspace = true
 
 # logging
-tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
+tracing-subscriber.workspace = true
 tracing.workspace = true
 
 confindent.workspace = true
 bempline.workspace = true
 gny = { workspace = true, features = ["sternum"] }
-scurvy = { git = "https://git.dreamy.place/crates/scurvy/", version = "0.5.0", features = ["camino"] }
+scurvy.workspace = true
+gatherer-client.workspace = true
 
 # subcrates
 paige.workspace = true
diff --git a/splorm/splorm.conf b/splorm/splorm.conf
index c9a5f5d..7c31c9d 100644
--- a/splorm/splorm.conf
+++ b/splorm/splorm.conf
@@ -4,4 +4,7 @@ Network
 Serving /Users/gen/src/inf/served
 	Hostname dreamy.place
 	Templates /Users/gen/src/inf/templates
-		Default minimal
+		Default post
+
+Connectors
+	Gatherer /var/run/gatherer/socket
diff --git a/splorm/src/config.rs b/splorm/src/config.rs
index c3b5d49..a6cc81d 100644
--- a/splorm/src/config.rs
+++ b/splorm/src/config.rs
@@ -49,6 +49,9 @@ impl Config {
 		let templates: Utf8PathBuf = templates_sec.parse().unwrap();
 		let default_template = templates_sec.child_owned("Default").unwrap();
 
+		let connectors_sec = confi.child("Connectors").unwrap();
+		let gatherer_path: Utf8PathBuf = connectors_sec.child_parse("Gatherer").unwrap();
+
 		Self {
 			port,
 			settings: Settings {
@@ -57,6 +60,7 @@ impl Config {
 
 				hostname,
 				webroot,
+				connectors: Connectors { gatherer_path },
 			},
 		}
 	}
@@ -83,6 +87,8 @@ pub struct Settings {
 
 	default_template: String,
 	template_dir: Utf8PathBuf,
+
+	connectors: Connectors,
 }
 
 impl Settings {
@@ -111,6 +117,21 @@ impl Settings {
 		.system_path()
 		.to_owned()
 	}
+
+	pub fn connectors(&self) -> &Connectors {
+		&self.connectors
+	}
+}
+
+#[derive(Clone, Debug)]
+pub struct Connectors {
+	gatherer_path: Utf8PathBuf,
+}
+
+impl Connectors {
+	pub fn gatherer_path(&self) -> &Utf8Path {
+		&self.gatherer_path
+	}
 }
 
 pub fn create_tracing_subscriber() {
diff --git a/splorm/src/connector/gatherer.rs b/splorm/src/connector/gatherer.rs
new file mode 100644
index 0000000..bb69196
--- /dev/null
+++ b/splorm/src/connector/gatherer.rs
@@ -0,0 +1,177 @@
+use std::{ops::DerefMut, sync::Arc, thread::JoinHandle};
+
+use camino::{Utf8Path, Utf8PathBuf};
+use gatherer_client::{Client, Connection, Error as GathererError, GeneratedGraphs, GraphBounds};
+use timeparse::{Duration, OffsetDateTime};
+use tokio::sync::{
+	Mutex,
+	mpsc::{Receiver, Sender},
+};
+
+struct CacheItem<T: Clone> {
+	item: Mutex<Option<T>>,
+	time: Mutex<OffsetDateTime>,
+	timeout: Duration,
+}
+
+impl<T: Clone> CacheItem<T> {
+	pub fn new(timeout: Duration) -> Self {
+		Self {
+			item: Mutex::new(None),
+			time: Mutex::new(timeparse::now_utc()),
+			timeout,
+		}
+	}
+
+	pub fn new_with_item(item: T, timeout: Duration) -> Self {
+		Self {
+			item: Mutex::new(Some(item)),
+			time: Mutex::new(timeparse::now_utc()),
+			timeout,
+		}
+	}
+
+	pub async fn get(&self) -> Option<T> {
+		if self.duration_since_cache().await > self.timeout {
+			let _ = self.item.lock().await.take();
+			None
+		} else {
+			let item = self.item.lock().await.clone();
+			item
+		}
+	}
+
+	pub async fn store(&self, item: T) {
+		let mut item_lock = self.item.lock().await;
+		let mut time_lock = self.time.lock().await;
+
+		*item_lock.deref_mut() = Some(item);
+		*time_lock.deref_mut() = timeparse::now_utc();
+	}
+
+	pub async fn duration_since_cache(&self) -> Duration {
+		let time_lock = self.time.lock().await;
+		*time_lock - timeparse::now_utc()
+	}
+}
+
+pub struct GathererThread {
+	cmd_tx: Sender<GathererCommand>,
+	data_rx: Receiver<GathererData>,
+	handle: JoinHandle<()>,
+
+	regular_graph_cache: Arc<CacheItem<GeneratedGraphs>>,
+}
+
+impl GathererThread {
+	pub fn spawn(socket_path: &Utf8Path) -> Result<Self, GathererError> {
+		let (cmd_tx, cmd_rx) = tokio::sync::mpsc::channel(8);
+		let (data_tx, data_rx) = tokio::sync::mpsc::channel(8);
+		let client = Client::new(socket_path.to_owned());
+		let connection = client.connect()?;
+
+		let handle = std::thread::spawn(|| handler(connection, cmd_rx, data_tx));
+
+		Ok(Self {
+			cmd_tx,
+			data_rx,
+			handle,
+
+			regular_graph_cache: Arc::new(CacheItem::new(Duration::minutes(5))),
+		})
+	}
+
+	pub fn data_rx(&mut self) -> &mut Receiver<GathererData> {
+		&mut self.data_rx
+	}
+
+	pub async fn get_messsage(&mut self) -> GathererData {
+		match self.data_rx.recv().await {
+			None => panic!("gatherer connector channel closed"),
+			Some(data) => data,
+		}
+	}
+
+	pub fn cmd_tx(&mut self) -> Sender<GathererCommand> {
+		self.cmd_tx.clone()
+	}
+
+	pub async fn regular_graphs(&mut self) -> GeneratedGraphs {
+		if let Some(graphs) = self.regular_graph_cache.get().await {
+			graphs
+		} else {
+			self.cmd_tx
+				.send(GathererCommand::RegularGraphs)
+				.await
+				.unwrap();
+
+			match self.get_messsage().await {
+				GathererData::Graphs(graphs) => {
+					self.regular_graph_cache.store(graphs.clone()).await;
+					graphs
+				}
+				_ => panic!(),
+			}
+		}
+	}
+}
+
+pub enum GathererCommand {
+	GetBounds,
+	GenerateGraphs(Utf8PathBuf),
+	RegularGraphs,
+}
+
+pub enum GathererData {
+	Bounds(GraphBounds),
+	Graphs(GeneratedGraphs),
+	Error(gatherer_client::Error),
+}
+
+fn handler(
+	mut conn: Connection,
+	mut cmd_rx: Receiver<GathererCommand>,
+	data_tx: Sender<GathererData>,
+) {
+	macro_rules! unwrap_or_senderr {
+		($operation:expr) => {
+			match $operation {
+				Err(err) => {
+					data_tx.blocking_send(GathererData::Error(err)).unwrap();
+					continue;
+				}
+				Ok(ok) => ok,
+			}
+		};
+	}
+
+	loop {
+		while let Some(cmd) = cmd_rx.blocking_recv() {
+			match cmd {
+				GathererCommand::GetBounds => {
+					let bounds = unwrap_or_senderr!(conn.graph_bounds());
+					data_tx.blocking_send(GathererData::Bounds(bounds)).unwrap();
+				}
+				GathererCommand::GenerateGraphs(path) => {
+					let graphs = unwrap_or_senderr!(conn.generate_graphs(path));
+					data_tx.blocking_send(GathererData::Graphs(graphs)).unwrap();
+				}
+				GathererCommand::RegularGraphs => {
+					let graphs = unwrap_or_senderr!(conn.regular_graphs());
+					data_tx.blocking_send(GathererData::Graphs(graphs)).unwrap();
+				}
+			}
+		}
+	}
+}
+
+/*
+Currently there is an encoding issue in blobby, it seems, that is causing the
+message from gatherer to be corrupted, and we're getting a panic or just incorrect
+paths during decoding.
+
+ALSO:
+we want to make this connector have a fallible conneciton. It does not need to
+always be connected, and it will keep trying to connect/try to reconnect if
+it disconnects.
+*/
diff --git a/splorm/src/connector/mod.rs b/splorm/src/connector/mod.rs
new file mode 100644
index 0000000..a2fbc85
--- /dev/null
+++ b/splorm/src/connector/mod.rs
@@ -0,0 +1,5 @@
+//! Connectors to external things
+
+mod gatherer;
+
+pub use gatherer::{GathererCommand, GathererData, GathererThread};
diff --git a/splorm/src/fs/mod.rs b/splorm/src/fs/mod.rs
index 032aaed..8450db7 100644
--- a/splorm/src/fs/mod.rs
+++ b/splorm/src/fs/mod.rs
@@ -1,5 +1,6 @@
 mod request_path;
 
+use camino::Utf8PathBuf;
 pub use request_path::{Mime, Mimetype, RequestPath, SafePath, SafePathBuf};
 use tokio::fs::File;
 
@@ -13,8 +14,12 @@ pub async fn open_file<'p>(path: SafePath<'p>) -> Result<File, RuntimeError> {
 
 pub async fn read_file<'p>(path: SafePath<'p>) -> Result<Vec<u8>, RuntimeError> {
 	let system_path = *path;
-	match tokio::fs::read(system_path).await {
-		Err(err) => Err(RuntimeError::file_error(err, system_path.to_path_buf())),
+	unsafe_read_file(system_path.to_owned()).await
+}
+
+pub async fn unsafe_read_file(path: Utf8PathBuf) -> Result<Vec<u8>, RuntimeError> {
+	match tokio::fs::read(&path).await {
+		Err(err) => Err(RuntimeError::file_error(err, path)),
 		Ok(vec) => Ok(vec),
 	}
 }
diff --git a/splorm/src/handler/api.rs b/splorm/src/handler/api.rs
new file mode 100644
index 0000000..f360b87
--- /dev/null
+++ b/splorm/src/handler/api.rs
@@ -0,0 +1,43 @@
+use axum::{
+	Router,
+	body::Body,
+	extract::{Path, State},
+	response::Response,
+	routing::get,
+};
+use sessionhash::RequestContext;
+
+use crate::{RuntimeError, fs, state::SplormState};
+
+pub fn routes() -> Router<SplormState> {
+	Router::new().route("/stats/now/{name}", get(stats))
+}
+
+async fn stats(
+	Path(name): Path<String>,
+	State(state): State<SplormState>,
+	ctx: RequestContext,
+) -> Result<Response, RuntimeError> {
+	tracing::trace!("{ctx} GET /api/stats/{name}");
+
+	let graphs = {
+		let mut connector = state.gatherer_thread().await;
+		connector.regular_graphs().await
+	};
+
+	let file_path = match name.as_str() {
+		"current_hostmeminfo.gif" => graphs.meminfo,
+		"current_hostnetinfo.gif" => graphs.meminfo,
+		"current_hostcpuinfo.gif" => graphs.cpuinfo,
+		_ => return Err(RuntimeError::generic_404()),
+	};
+
+	tracing::trace!("graph {name} system_path={file_path}");
+	let file = fs::unsafe_read_file(file_path).await?;
+
+	Response::builder()
+		.header("content-type", "image/gif")
+		.status(200)
+		.body(Body::from(file))
+		.map_err(|_| RuntimeError::Unknown)
+}
diff --git a/splorm/src/handler/mod.rs b/splorm/src/handler/mod.rs
index 95c7e00..edf86f9 100644
--- a/splorm/src/handler/mod.rs
+++ b/splorm/src/handler/mod.rs
@@ -3,16 +3,17 @@ use std::sync::Arc;
 use axum::{
 	Extension, RequestExt,
 	body::Body,
-	extract::{Path, Request},
+	extract::{Path, Request, State},
 	middleware::Next,
 	response::Response,
 };
 use sessionhash::RequestContext;
 
+pub mod api;
 mod staticfile;
 mod templated;
 
-use crate::{RuntimeError, config::Settings, fs::RequestPath};
+use crate::{RuntimeError, config::Settings, fs::RequestPath, state::SplormState};
 
 /// Log runtime errors using a middleware layer. Very much based on the [axum
 /// error_handling example][axum_errorhandling]
@@ -32,16 +33,18 @@ pub async fn log_errors(mut request: Request, next: Next) -> Response {
 
 /// GET /
 pub async fn index(
+	State(state): State<SplormState>,
 	Extension(settings): Extension<Settings>,
 	ctx: RequestContext,
 ) -> Result<Response, RuntimeError> {
 	tracing::info!("{ctx} GET /");
 	let rqp = RequestPath::verify_safe(settings.root(), "home.html")?;
-	templated::send_file(rqp, &settings).await
+	templated::send_file(rqp, state, &settings).await
 }
 
 /// GET /{*path}
 pub async fn static_path(
+	State(state): State<SplormState>,
 	Extension(settings): Extension<Settings>,
 	Path(path): Path<String>,
 	ctx: RequestContext,
@@ -55,7 +58,7 @@ pub async fn static_path(
 
 	if rqp.guess_mime().as_ref() == "text/html" {
 		tracing::info!("{ctx} GET /{path}");
-		templated::send_file(rqp, &settings).await
+		templated::send_file(rqp, state, &settings).await
 	} else {
 		staticfile::send_or_stream_file(rqp).await
 	}
diff --git a/splorm/src/handler/templated.rs b/splorm/src/handler/templated.rs
index 4a5fa62..dee93ba 100644
--- a/splorm/src/handler/templated.rs
+++ b/splorm/src/handler/templated.rs
@@ -8,15 +8,25 @@ use crate::{
 	RuntimeError,
 	config::Settings,
 	fs::{self, RequestPath, SafePathBuf},
+	state::SplormState,
 	templated::TemplateVariables,
 };
 
-pub async fn send_file(path: RequestPath, settings: &Settings) -> Result<Response, RuntimeError> {
+pub async fn send_file(
+	path: RequestPath,
+	state: SplormState,
+	settings: &Settings,
+) -> Result<Response, RuntimeError> {
 	let safe = path.system_path().to_owned();
 	let string = fs::read_file_to_string(path.system_path()).await?;
 	let template = Templated::from_str(&string)
 		.map_err(|_| RuntimeError::not_a_template(path.system_path().to_path_buf()))?;
 	let tvars = TemplateVariables::build(safe.as_path(), &template.frontmatter);
+	tracing::trace!(
+		"page {} has features {:?}",
+		path.web_path(),
+		tvars.features()
+	);
 
 	let pt_path = match tvars.template() {
 		Some(name) => {
@@ -40,7 +50,12 @@ pub async fn send_file(path: RequestPath, settings: &Settings) -> Result<Respons
 	tvars.apply_pathnav(&mut page_template, &path);
 	tvars.apply_opengraph(&mut page_template, &settings, &path);
 
-	let content = tvars.apply_markup(&mut page_template, template.content);
+	let mut content = template.content;
+	if !tvars.features().is_empty() {
+		content = tvars.apply_features(content, &state).await;
+	}
+
+	let content = tvars.apply_markup(&mut page_template, content);
 	page_template.set("main", content);
 
 	Response::builder()
diff --git a/splorm/src/main.rs b/splorm/src/main.rs
index 2bd2e5a..25d6cd3 100644
--- a/splorm/src/main.rs
+++ b/splorm/src/main.rs
@@ -1,12 +1,16 @@
+use std::sync::Arc;
+
 use axum::{Extension, Router, middleware::from_fn, routing::get};
 use tokio::{net::TcpListener, runtime::Builder};
 
 use crate::{
 	config::{CommandLine, Config},
-	state::SplormState,
+	connector::GathererThread,
+	state::{ConnectorsState, SplormState},
 };
 
 mod config;
+pub mod connector;
 pub mod fs;
 mod handler;
 mod runtime_error;
@@ -22,23 +26,28 @@ fn main() {
 	tracing::trace!(?cli);
 	tracing::trace!(?conf);
 
+	let gatherer = GathererThread::spawn(conf.settings().connectors().gatherer_path()).unwrap();
+
+	let state = SplormState::new(ConnectorsState::new(gatherer));
+
 	// Create and spawn the tokio multithreaded runtime. Execution stays
 	// here until async_main() returns.
 	let rt = Builder::new_multi_thread().enable_all().build().unwrap();
-	rt.block_on(async_main(conf))
+	rt.block_on(async_main(conf, state))
 }
 
-async fn async_main(conf: Config) {
+async fn async_main(conf: Config, state: SplormState) {
 	// Setup the routing
 	let bare_routes = Router::new()
 		.route("/", get(handler::index))
+		.nest("/api", handler::api::routes())
 		.route("/{*path}", get(handler::static_path));
 
 	// Apply Extensions and state
 	let routes = bare_routes
 		.layer(from_fn(handler::log_errors))
 		.layer(Extension(conf.settings().clone()))
-		.with_state(SplormState {});
+		.with_state(state);
 
 	// Create our listener and start axum serving and routing
 	let addr = conf.listen_address();
diff --git a/splorm/src/runtime_error.rs b/splorm/src/runtime_error.rs
index 981853d..225c2d4 100644
--- a/splorm/src/runtime_error.rs
+++ b/splorm/src/runtime_error.rs
@@ -43,6 +43,7 @@ pub enum RuntimeError {
 		source: Option<bempline::ParseError>,
 		system_path: Utf8PathBuf,
 	},
+	Generic404,
 	Unknown,
 }
 
@@ -93,6 +94,10 @@ impl RuntimeError {
 		}
 	}
 
+	pub fn generic_404() -> Self {
+		RuntimeError::Generic404
+	}
+
 	pub fn unknown() -> Self {
 		RuntimeError::Unknown
 	}
@@ -106,6 +111,7 @@ impl RuntimeError {
 				FileErrorReason::Unknown => 500,
 			},
 			Self::TemplateError { .. } => 500,
+			Self::Generic404 => 404,
 			Self::Unknown => 500,
 		}
 	}
@@ -137,6 +143,7 @@ impl fmt::Display for RuntimeError {
 				Some(pe) => write!(f, "template error: {pe}. system={system_path}"),
 				None => write!(f, "template error. reason={reason}, system={system_path}"),
 			},
+			RuntimeError::Generic404 => write!(f, "generic 404"),
 			RuntimeError::Unknown => write!(f, "unknown error!"),
 		}
 	}
@@ -166,6 +173,7 @@ impl<'re> fmt::Display for PresentableRuntimeError<'re> {
 				FileErrorReason::Unknown => estr!(500),
 			},
 			RuntimeError::TemplateError { .. } => estr!(500),
+			RuntimeError::Generic404 => estr!(404),
 			RuntimeError::Unknown => estr!(500),
 		}
 	}
diff --git a/splorm/src/state.rs b/splorm/src/state.rs
index 12ef5d9..59e8629 100644
--- a/splorm/src/state.rs
+++ b/splorm/src/state.rs
@@ -1,2 +1,33 @@
-#[derive(Clone, Debug)]
-pub struct SplormState {}
+use std::sync::Arc;
+
+use tokio::sync::{Mutex, MutexGuard};
+
+use crate::connector::GathererThread;
+
+#[derive(Clone)]
+pub struct SplormState {
+	connectors: ConnectorsState,
+}
+
+impl SplormState {
+	pub fn new(connectors: ConnectorsState) -> Self {
+		Self { connectors }
+	}
+
+	pub async fn gatherer_thread<'s>(&'s self) -> MutexGuard<'s, GathererThread> {
+		self.connectors.gatherer.lock().await
+	}
+}
+
+#[derive(Clone)]
+pub struct ConnectorsState {
+	gatherer: Arc<Mutex<GathererThread>>,
+}
+
+impl ConnectorsState {
+	pub fn new(gatherer: GathererThread) -> Self {
+		Self {
+			gatherer: Arc::new(Mutex::new(gatherer)),
+		}
+	}
+}
diff --git a/splorm/src/templated/features.rs b/splorm/src/templated/features.rs
new file mode 100644
index 0000000..328b955
--- /dev/null
+++ b/splorm/src/templated/features.rs
@@ -0,0 +1,20 @@
+use bempline::Document;
+use gny::sternum::Sternum;
+
+use crate::state::SplormState;
+
+#[derive(Sternum, Debug, PartialEq)]
+pub enum Feature {
+	SystemStats,
+}
+
+pub async fn apply_system_stats(doc: &mut Document, state: &SplormState) {
+	let bounds = {
+		let mut lock = state.gatherer_thread().await;
+		lock.regular_graphs().await.bounds
+	};
+	doc.set("stats.mem.total", bounds.mem_upper / 1000);
+	doc.set("stats.mem.usage", bounds.mem_current / 1000);
+	doc.set("stats.net.max_bound", bounds.net_upper);
+	doc.set("stats.cpu.max_bound", bounds.cpu_upper);
+}
diff --git a/splorm/src/templated/mod.rs b/splorm/src/templated/mod.rs
index 6d649d0..6f15b77 100644
--- a/splorm/src/templated/mod.rs
+++ b/splorm/src/templated/mod.rs
@@ -1,17 +1,22 @@
 use std::str::FromStr;
 
-use bempline::{Document, variables};
+use bempline::{Document, Options, options::IncludeMethod, variables};
 use paige::Frontmatter;
 use timeparse::OffsetDateTime;
 
+mod features;
+
 use crate::{
 	config::Settings,
 	fs::{RequestPath, SafePath},
+	state::SplormState,
+	templated::features::Feature,
 };
 
 pub struct TemplateVariables<'fm> {
 	template: Option<&'fm str>,
 	markup: Option<MarkupStyle>,
+	features: Vec<Feature>,
 
 	style: Vec<&'fm str>,
 
@@ -32,6 +37,11 @@ impl<'fm> TemplateVariables<'fm> {
 				.get("markup")
 				.map(|s| MarkupStyle::from_str(s).ok())
 				.flatten(),
+			features: fm
+				.get_many("features")
+				.into_iter()
+				.filter_map(|s| Feature::from_str(s).ok())
+				.collect(),
 			title: fm.get("title").unwrap_or_else(|| filename),
 			style: fm.get_many("style"),
 			description: fm.get("description"),
@@ -47,6 +57,10 @@ impl<'fm> TemplateVariables<'fm> {
 		self.template
 	}
 
+	pub fn features(&self) -> &[Feature] {
+		&self.features
+	}
+
 	pub fn apply_styles(&self, doc: &mut Document) {
 		if let Some(styles) = doc.get_pattern("styles") {
 			self.style.iter().for_each(|style| {
@@ -120,6 +134,16 @@ impl<'fm> TemplateVariables<'fm> {
 			variables!(doc, og_title, og_url, og_site_name, og_description);
 		}
 	}
+
+	pub async fn apply_features(&self, content: String, state: &SplormState) -> String {
+		let mut doc = Document::from_str(&content, Options::default()).unwrap();
+		for feat in &self.features {
+			match feat {
+				Feature::SystemStats => features::apply_system_stats(&mut doc, state).await,
+			}
+		}
+		doc.compile()
+	}
 }
 
 pub enum MarkupStyle {
diff --git a/timeparse/src/lib.rs b/timeparse/src/lib.rs
index 952a9d1..fdb7bdd 100755
--- a/timeparse/src/lib.rs
+++ b/timeparse/src/lib.rs
@@ -5,7 +5,7 @@ use time::{
 	macros::{format_description, offset, time},
 };
 
-pub use time::OffsetDateTime;
+pub use time::{Duration, OffsetDateTime};
 pub mod format;
 
 #[allow(unused)]
@@ -143,6 +143,10 @@ pub fn parse(raw: &str) -> Result<OffsetDateTime, time::error::Parse> {
 	Ok(OffsetDateTime::new_in_offset(date, time, offset))
 }
 
+pub fn now_utc() -> OffsetDateTime {
+	OffsetDateTime::now_utc()
+}
+
 pub fn whichever_now() -> OffsetDateTime {
 	OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc())
 }