aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/check.rs91
-rw-r--r--src/mail.rs0
-rw-r--r--src/main.rs87
-rw-r--r--src/style.css58
-rw-r--r--src/web.rs86
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())
+ }
+}