diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/check.rs | 91 | ||||
-rw-r--r-- | src/mail.rs | 0 | ||||
-rw-r--r-- | src/main.rs | 87 | ||||
-rw-r--r-- | src/style.css | 58 | ||||
-rw-r--r-- | src/web.rs | 86 |
5 files changed, 322 insertions, 0 deletions
diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..bfde1b9 --- /dev/null +++ b/src/check.rs @@ -0,0 +1,91 @@ +use crate::{Check, Config, Success, STATUS}; +use anyhow::{anyhow, bail, Context, Result}; +use futures::{stream::FuturesUnordered, StreamExt}; +use log::info; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::{ + process::Command, + time::{sleep, timeout}, +}; + +pub async fn check_loop(config: Arc<Config>, i: usize) { + loop { + check_service(&config, i).await; + sleep(Duration::from_secs(config.interval)).await; + } +} + +async fn check_service(config: &Arc<Config>, i: usize) { + let service = &config.services[i]; + let mut futs = FuturesUnordered::from_iter(service.checks.iter().enumerate().map( + |(j, check)| async move { + let r = match timeout(Duration::from_secs(30), check.check()).await { + Ok(Ok(succ)) => Ok(succ), + Ok(Err(e)) => Err(e), + Err(_) => Err(anyhow!("timed out")), + }; + info!("check {i}:{j} => {r:?}"); + { + let mut g = STATUS.write().await; + g.insert((i, j), r); + } + }, + )); + while let Some(_) = futs.next().await {} +} + +impl Check { + pub async fn check(&self) -> Result<Success> { + match self { + Check::Systemd(sname) => { + let output = Command::new("systemctl") + .arg("show") + .arg("--no-pager") + .arg(sname) + .output() + .await + .context("systemctl")?; + let output = String::from_utf8(output.stdout).context("systemctl output")?; + + for line in output.split("\n") { + if let Some((key, value)) = line.split_once("=") { + match key { + "ActiveState" if value != "active" => { + bail!("{value}") + } + _ => (), + } + } + } + Ok(Success::default()) + } + Check::Shell { command, .. } => { + let args = shlex::split(&command).ok_or(anyhow!("command syntax invalid"))?; + let status = Command::new(args.get(0).ok_or(anyhow!("argv0 missing"))?) + .args(&args[1..]) + .status() + .await; + + match status { + Ok(status) if status.success() => Ok(Success::default()), + Ok(status) => bail!("failed with code {}", status.code().unwrap_or(1)), + Err(e) => bail!("command failed to execute: {e}"), + } + } + Check::Http { url, .. } => { + let k = Instant::now(); + let r = reqwest::get(url).await?; + if !r.status().is_success() { + bail!("http status: {}", r.status().as_str()) + } + Ok(Success { + latency: Some(k.elapsed()), + ..Default::default() + }) + } + } + } +} diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/mail.rs diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0136f94 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,87 @@ +pub mod check; +pub mod web; +pub mod mail; + +use anyhow::{anyhow, Result}; +use axum::{routing::get, Router}; +use check::check_loop; +use log::error; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + net::SocketAddr, + sync::Arc, + time::{Duration, SystemTime}, +}; +use tokio::{fs::read_to_string, sync::RwLock}; +use web::send_html_page; + +#[tokio::main] +async fn main() { + env_logger::init_from_env("LOG"); + if let Err(e) = run().await { + error!("{e:?}") + } +} + +#[derive(Debug, Deserialize)] +pub struct Config { + title: String, + bind: SocketAddr, + interval: u64, + services: Vec<Service>, +} + +#[derive(Debug, Deserialize)] +pub struct Service { + title: String, + checks: Vec<Check>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Check { + Systemd(String), + Http { title: Option<String>, url: String }, + Shell { title: String, command: String }, +} + +#[derive(Debug, Clone)] +pub struct Success { + pub latency: Option<Duration>, + pub updated: SystemTime, +} +impl Default for Success { + fn default() -> Self { + Self { + latency: None, + updated: SystemTime::now(), + } + } +} + +static STATUS: RwLock<BTreeMap<(usize, usize), Result<Success>>> = + RwLock::const_new(BTreeMap::new()); + +async fn run() -> anyhow::Result<()> { + let config = std::env::args() + .nth(1) + .ok_or(anyhow!("expected config path as first argument"))?; + let config = read_to_string(config).await?; + let config = Arc::<Config>::new(serde_yaml::from_str(&config)?); + + for i in 0..config.services.len() { + tokio::task::spawn(check_loop(config.clone(), i)); + } + + let app = Router::new().route( + "/", + get({ + let config = config.clone(); + move || send_html_page(config.clone()) + }), + ); + let listener = tokio::net::TcpListener::bind(config.bind).await?; + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..c207f62 --- /dev/null +++ b/src/style.css @@ -0,0 +1,58 @@ +body { + background-color: white; +} + +main { + margin-left: auto; + margin-right: auto; + width: min(60em, 80%); + background-color: rgb(255, 255, 255); + border-radius: 5px; +} + +h1 { + color: black; + text-align: center; +} + +h2 { + margin-top: 0.5em; + color: black; +} + +div.service { + padding: 1em; + margin: 2em; + box-shadow: 0 10px 60px rgba(0, 0, 0, 0.15); + border-radius: 10px; +} +div.service.ok { + border-top: 12px solid rgb(130, 255, 80); +} +div.service.error { + border-top: 12px solid rgb(255, 80, 80); +} + +div.checks { + display: grid; + width: 100%; + grid-template-columns: 30% 10% 60%; +} +div.checks span { + text-overflow: ellipsis; + overflow: hidden; + text-wrap: nowrap; +} + +span.details.ok { + color: rgb(173, 173, 173); +} +span.status { + font-weight: bolder; +} +span.status.ok { + color: rgb(39, 138, 0); +} +span.status.error { + color: rgb(158, 0, 0); +} diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..0080697 --- /dev/null +++ b/src/web.rs @@ -0,0 +1,86 @@ +use crate::{Check, Config, Service, Success, STATUS}; +use anyhow::Result; +use axum::response::Html; +use markup::{doctype, Render}; +use std::{collections::BTreeMap, ops::Deref, sync::Arc, time::Duration}; + +pub async fn send_html_page(config: Arc<Config>) -> Html<String> { + let mut out = String::new(); + let status = STATUS.read().await; + let status = status.deref(); + + #[cfg(not(debug_assertions))] + let css = include_str!("style.css"); + #[cfg(debug_assertions)] + let css = tokio::fs::read_to_string("src/style.css").await.unwrap(); + + markup::new! { + @doctype() + html { + head { + meta[charset="UTF-8"]; + meta[name="viewport", content="width=device-width, initial-scale=1.0"]; + title { "Status Page" } + style { @css } + } + body { + main { + h1 { @config.title } + @for (i, service) in config.services.iter().enumerate() { + @ServiceCard { i, status, service } + } + } + } + } + } + .render(&mut out) + .unwrap(); + Html(out) +} + +markup::define!( + ServiceCard<'a>(status: &'a BTreeMap<(usize, usize), Result<Success>>, service: &'a Service, i: usize) { + @let any_err = status.range((*i,usize::MIN)..(*i,usize::MAX)).any(|(_,v)|v.is_err()); + div.service.{if any_err { "error" } else { "ok" }} { + h2 { @service.title } + div.checks { + @for (j, check) in service.checks.iter().enumerate() { + @if let Some(status) = status.get(&(*i, j)) { + span.name { @check.display() } + @match status { + Ok(Success { latency, updated }) => { + span.status.ok { "Ok" } + span.details.ok { + "Checked " @format_duration(updated.elapsed().unwrap_or_default()) " ago" + @if let Some(latency) = latency { ", took " @format_duration(*latency) } "." + } + } + Err(e) => { + span.status.error { "Error" } + span.details { @format!("{e}") } + } + } + } + } + } + } + } +); + +impl Check { + pub fn display(&self) -> String { + match self { + Check::Systemd(_) => "Service".to_string(), + Check::Http { title, .. } => title.clone().unwrap_or("HTTP".to_string()), + Check::Shell { title, .. } => title.to_owned(), + } + } +} + +fn format_duration(d: Duration) -> String { + if d.as_millis() < 1500 { + format!("{}ms", d.as_millis()) + } else { + format!("{}s", d.as_secs()) + } +} |