feat: initial commit with proof-of-concept

This commit is contained in:
əlemi 2024-12-01 03:35:04 +01:00
commit 397bedea8b
Signed by: alemi
GPG key ID: A4895B84D311642C
8 changed files with 1975 additions and 0 deletions

17
.editorconfig Normal file
View file

@ -0,0 +1,17 @@
# Default to Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = tab
indent_size = 4
[*.rs]
indent_size = 2
[*.js]
indent_size = 2
[*.{yml,yaml}]
indent_style = space
indent_size = 2

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1
.rustfmt.toml Normal file
View file

@ -0,0 +1 @@
hard_tabs = true

1644
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "uppe-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.21", features = ["derive"] }
serde = { version = "1.0.215", features = ["derive"] }
toml = "0.8.19"
tokio = { version = "1.41.1", features = ["rt", "macros"] }
reqwest = { version = "0.12.9", default-features = false, features = ["default-tls", "native-tls"] }
chrono = { version = "0.4.38", features = ["serde"] }
axum = "0.7.9"

104
index.html Normal file
View file

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link crossorigin rel="stylesheet" href="https://cdn.alemi.dev/web/alemi.css">
<title>uppe.rs</title>
<style>
span.cell {
display: inline-block;
width: 1rem;
height: 1rem;
border: 1px solid var(--secondary);
font-size: 6pt;
line-height: 1rem;
background-color: rgba(var(--secondary-rgb), 0.4);
margin-top: .2rem;
margin-bottom: .2rem;
padding-top: 0;
padding-bottom: 0;
transition: .1s;
text-align: center;
cursor: default;
}
span.cell:hover {
padding-top: .2rem;
padding-bottom: .2rem;
margin-top: 0;
margin-bottom: 0;
line-height: 1rem;
}
span.cell.empty {
border: 1px solid var(--accent);
background-color: rgba(var(--accent-rgb), 0.4);
}
hr.color {
color: var(--accent);
border-color: var(--accent);
}
hr.separator {
margin: 2em;
}
</style>
</head>
<body>
<div class="container">
<h1>uppe.rs</h1>
<p>keeping track of your infra's up status</p>
<hr class="color"/>
<main id="uppe-rs-content">
</main>
</div>
</body>
<script>
function cell(timestamp, rtt) {
let d = new Date(timestamp * 1000);
if (rtt === null) {
return `<span class="cell empty" title="${d}"></span>`;
} else {
return `<span class="cell" title="${d}">${rtt}</span>`;
}
}
function card(key, history) {
let bar = "";
for (let el of history) {
bar += cell(el[0], el[1]);
}
return `<div class="card">
<h3>${key}</h3>
<div class="box">
${bar}
</div>
</div>
<hr class="separator"/>`;
}
let main = document.getElementById("uppe-rs-content");
async function updateStatus() {
let res = await fetch("/api/status")
let status = await res.json()
let keys = Object.keys(status);
keys.sort();
let out = "";
for (let key of keys) {
let res = await fetch(`/api/status/${key}`);
let history = await res.json();
out += card(key, history);
}
main.innerHTML = out;
}
setInterval(updateStatus, 60 * 1000)
updateStatus()
</script>
</html>

181
src/main.rs Normal file
View file

@ -0,0 +1,181 @@
use std::{collections::{HashMap, VecDeque}, sync::Arc};
use clap::Parser;
use tokio::sync::RwLock;
#[derive(Parser)]
struct Cli {
/// path to config file
#[arg(short, long, default_value = "uppe-rs.toml")]
config: String,
/// host to bind api onto
#[arg(short, long, default_value = "127.0.0.1:7717")]
addr: String,
}
#[derive(serde::Deserialize)]
struct Config {
/// defined services, singular because makes more sense in toml
service: std::collections::BTreeMap<String, Service>,
/// how many samples of history to keep
history: usize,
/// poll services at this interval
interval_s: u64,
}
#[derive(serde::Deserialize)]
struct Service {
/// url to query
endpoint: String,
/// override poll rate for this service
interval_s: Option<u64>,
}
type AppState = Arc<RwLock<StateStorage>>;
type Event = (i64, Option<i64>);
struct StateStorage {
size: usize,
store: HashMap<String, VecDeque<Event>>,
}
impl StateStorage {
fn new(size: usize) -> AppState {
Arc::new(RwLock::new(Self {
size, store: HashMap::default(),
}))
}
fn get(&self, k: &str) -> Vec<Event> {
match self.store.get(k) {
Some(x) => x.clone().into(),
None => Vec::new(),
}
}
fn put(&mut self, key: &str, timestamp: i64, rtt: Option<i64>) {
match self.store.get_mut(key) {
Some(x) => {
x.push_back((timestamp, rtt));
while x.len() > self.size {
x.pop_front();
}
},
None => {
let mut q = VecDeque::new();
q.push_back((timestamp, rtt));
self.store.insert(key.to_string(), q);
},
}
}
fn up(&self, key: &str) -> bool {
match self.store.get(key) {
None => false, // this key is not being tracked, or we never polled it
Some(x) => match x.back() {
None => false, // this key has never been polled yet
Some((_, None)) => false, // last poll was a failure
Some((_, Some(_))) => true, // last poll was a success
}
}
}
fn services(&self) -> Vec<String> {
self.store.keys().cloned().collect()
}
}
fn main() {
let cli = Cli::parse();
let raw_config = std::fs::read_to_string(&cli.config)
.expect("could not open config file");
let config = toml::from_str::<Config>(&raw_config)
.expect("invalid config format");
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("could not create tokio runtime")
.block_on(entry(cli, config))
.expect("event loop terminated with error");
}
async fn entry(cli: Cli, config: Config) -> Result<(), Box<dyn std::error::Error>> {
let state = StateStorage::new(config.history);
let default_interval = config.interval_s;
for (key, service) in config.service {
let interval = service.interval_s.unwrap_or(default_interval);
let state = state.clone();
tokio::spawn(async move {
loop {
let res = test(&service.endpoint).await;
let timestamp = chrono::Utc::now().timestamp();
match res {
Ok(rtt) => state.write().await.put(&key, timestamp, Some(rtt)),
Err(e) => {
eprintln!("[!] error polling service {key}: {e}");
state.write().await.put(&key, timestamp, None);
},
}
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
}
});
}
// build our application with a single route
let app = axum::Router::new()
.route("/", axum::routing::get(root))
.route("/api/status", axum::routing::get(api_status))
.route("/api/status/:service", axum::routing::get(api_status_service))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&cli.addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn root() -> Html<&'static str> {
Html(include_str!("../index.html"))
}
async fn test(url: &str) -> reqwest::Result<i64> {
eprintln!("testing {url}");
let before = chrono::Utc::now();
reqwest::get(url)
.await?
.error_for_status()?;
let delta = chrono::Utc::now() - before;
Ok(delta.num_milliseconds())
}
use axum::{extract::{Path, State}, response::Html, Json};
async fn api_status(
State(state): State<AppState>,
) -> Json<HashMap<String, bool>> {
let services = state.read().await.services();
let mut out = HashMap::new();
for service in services {
let up = state.read().await.up(&service);
out.insert(service, up);
}
Json(out)
}
async fn api_status_service(
State(state): State<AppState>,
Path(service): axum::extract::Path<String>,
) -> Json<Vec<(i64, Option<i64>)>> {
Json(state.read().await.get(&service))
}

14
uppe-rs.toml Normal file
View file

@ -0,0 +1,14 @@
interval_s = 10
history = 600
[service.moonlit]
endpoint = "https://moonlit.technology"
[service.site]
endpoint = "https://alemi.dev"
[service.codemp]
endpoint = "https://code.mp"
[service.down]
endpoint = "https://down.alemi.dev"