feat: initial commit with proof-of-concept
This commit is contained in:
commit
397bedea8b
8 changed files with 1975 additions and 0 deletions
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
1
.rustfmt.toml
Normal file
1
.rustfmt.toml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
hard_tabs = true
|
1644
Cargo.lock
generated
Normal file
1644
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal 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
104
index.html
Normal 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
181
src/main.rs
Normal 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
14
uppe-rs.toml
Normal 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"
|
Loading…
Reference in a new issue