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