generated from methanium/template
Initial commit
This commit is contained in:
commit
025c8c8e16
46 changed files with 13890 additions and 0 deletions
2
apps/backend/.cargo/config.toml
Normal file
2
apps/backend/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "../frontend/type-maps.yaml", relative = true }
|
||||
1
apps/backend/.gitignore
vendored
Normal file
1
apps/backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target
|
||||
3387
apps/backend/Cargo.lock
generated
Normal file
3387
apps/backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
apps/backend/Cargo.toml
Normal file
24
apps/backend/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "methanium-status"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
http = "1"
|
||||
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "a692bed326dbfc8eac1a05825f4a287cbab6fd3e", features = ["crypto", "web-server"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml = "0.9"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
88
apps/backend/src/admin.rs
Normal file
88
apps/backend/src/admin.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AppState, incidents::IncidentError, model::NewIncident};
|
||||
|
||||
pub async fn serve(
|
||||
address: SocketAddr,
|
||||
frontend: PathBuf,
|
||||
state: Arc<AppState>,
|
||||
) -> std::io::Result<()> {
|
||||
let index = frontend.join("index.html");
|
||||
let assets = ServeDir::new(frontend).fallback(ServeFile::new(index));
|
||||
let app = Router::new()
|
||||
.route("/api/admin", get(admin))
|
||||
.route("/api/incidents", get(list).post(create))
|
||||
.route("/api/incidents/{id}", delete(remove))
|
||||
.route("/api/incidents/{id}/resolve", post(resolve))
|
||||
.fallback_service(assets)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
let listener = tokio::net::TcpListener::bind(address).await?;
|
||||
tracing::info!(%address, "admin page listening");
|
||||
axum::serve(listener, app).await
|
||||
}
|
||||
|
||||
async fn admin() -> StatusCode {
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
async fn list(State(state): State<Arc<AppState>>) -> Json<Vec<crate::model::Incident>> {
|
||||
Json(state.incidents.list().await)
|
||||
}
|
||||
|
||||
async fn create(State(state): State<Arc<AppState>>, Json(input): Json<NewIncident>) -> Response {
|
||||
match state.incidents.create(input).await {
|
||||
Ok(incident) => {
|
||||
state.publish().await;
|
||||
(StatusCode::CREATED, Json(incident)).into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
|
||||
match state.incidents.resolve(id).await {
|
||||
Ok(incident) => {
|
||||
state.publish().await;
|
||||
Json(incident).into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
|
||||
match state.incidents.delete(id).await {
|
||||
Ok(_) => {
|
||||
state.publish().await;
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(error: IncidentError) -> Response {
|
||||
let status = match error {
|
||||
IncidentError::Empty => StatusCode::BAD_REQUEST,
|
||||
IncidentError::Active => StatusCode::CONFLICT,
|
||||
IncidentError::NotFound => StatusCode::NOT_FOUND,
|
||||
IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": error.to_string() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
142
apps/backend/src/config.rs
Normal file
142
apps/backend/src/config.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use std::{collections::HashSet, net::IpAddr, path::Path};
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub public_address: IpAddr,
|
||||
pub public_port: u16,
|
||||
pub admin_address: IpAddr,
|
||||
pub admin_port: u16,
|
||||
pub check_interval_seconds: u64,
|
||||
pub timeout_seconds: u64,
|
||||
pub certificate: String,
|
||||
pub private_key: String,
|
||||
pub frontend_dir: String,
|
||||
pub state_dir: String,
|
||||
pub curl: String,
|
||||
pub categories: Vec<CategoryConfig>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
public_address: "0.0.0.0".parse().unwrap(),
|
||||
public_port: 443,
|
||||
admin_address: "127.0.0.1".parse().unwrap(),
|
||||
admin_port: 8081,
|
||||
check_interval_seconds: 300,
|
||||
timeout_seconds: 15,
|
||||
certificate: String::new(),
|
||||
private_key: String::new(),
|
||||
frontend_dir: String::new(),
|
||||
state_dir: "/var/lib/methanium-status".into(),
|
||||
curl: "curl".into(),
|
||||
categories: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CategoryConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub services: Vec<ServiceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ServiceConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub check_http3: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("failed to read config: {0}")]
|
||||
Read(#[from] std::io::Error),
|
||||
#[error("failed to parse config: {0}")]
|
||||
Parse(String),
|
||||
#[error("invalid config: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
let config = if path.extension().and_then(|value| value.to_str()) == Some("json") {
|
||||
serde_json::from_str(&contents)
|
||||
.map_err(|error| ConfigError::Parse(error.to_string()))?
|
||||
} else {
|
||||
toml::from_str(&contents).map_err(|error| ConfigError::Parse(error.to_string()))?
|
||||
};
|
||||
validate(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(config: &Config) -> Result<(), ConfigError> {
|
||||
if config.check_interval_seconds == 0 || config.timeout_seconds == 0 {
|
||||
return Err(ConfigError::Invalid(
|
||||
"check interval and timeout must be greater than zero".into(),
|
||||
));
|
||||
}
|
||||
if !config.admin_address.is_loopback() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"admin_address must be a loopback address".into(),
|
||||
));
|
||||
}
|
||||
if config.certificate.is_empty() || config.private_key.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"certificate and private_key are required".into(),
|
||||
));
|
||||
}
|
||||
if config.frontend_dir.is_empty() {
|
||||
return Err(ConfigError::Invalid("frontend_dir is required".into()));
|
||||
}
|
||||
|
||||
let mut category_ids = HashSet::new();
|
||||
let mut service_ids = HashSet::new();
|
||||
for category in &config.categories {
|
||||
if category.id.is_empty() || category.name.is_empty() || category.logo.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"category id, name, and logo are required".into(),
|
||||
));
|
||||
}
|
||||
if !category_ids.insert(&category.id) {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"duplicate category id {}",
|
||||
category.id
|
||||
)));
|
||||
}
|
||||
for service in &category.services {
|
||||
if service.id.is_empty() || service.name.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"service id and name are required".into(),
|
||||
));
|
||||
}
|
||||
if !service_ids.insert(&service.id) {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"duplicate service id {}",
|
||||
service.id
|
||||
)));
|
||||
}
|
||||
let url = reqwest::Url::parse(&service.url)
|
||||
.map_err(|error| ConfigError::Invalid(format!("{}: {error}", service.url)))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"{} must use http or https",
|
||||
service.url
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
175
apps/backend/src/incidents.rs
Normal file
175
apps/backend/src/incidents.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
use std::{io::ErrorKind, path::PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{Incident, NewIncident};
|
||||
|
||||
pub struct IncidentStore {
|
||||
path: PathBuf,
|
||||
incidents: Mutex<Vec<Incident>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IncidentError {
|
||||
#[error("incident title and message cannot be empty")]
|
||||
Empty,
|
||||
#[error("incident not found")]
|
||||
NotFound,
|
||||
#[error("active incidents cannot be deleted")]
|
||||
Active,
|
||||
#[error("failed to persist incidents: {0}")]
|
||||
Storage(#[from] std::io::Error),
|
||||
#[error("failed to parse incidents: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl IncidentStore {
|
||||
pub async fn load(path: PathBuf) -> Result<Self, IncidentError> {
|
||||
let incidents = match tokio::fs::read(&path).await {
|
||||
Ok(contents) => serde_json::from_slice(&contents)?,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
Ok(Self {
|
||||
path,
|
||||
incidents: Mutex::new(incidents),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<Incident> {
|
||||
self.incidents.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn create(&self, input: NewIncident) -> Result<Incident, IncidentError> {
|
||||
if input.title.trim().is_empty() || input.message.trim().is_empty() {
|
||||
return Err(IncidentError::Empty);
|
||||
}
|
||||
let incident = Incident {
|
||||
id: Uuid::new_v4(),
|
||||
title: input.title.trim().to_owned(),
|
||||
message: input.message.trim().to_owned(),
|
||||
severity: input.severity,
|
||||
created_at: Utc::now(),
|
||||
resolved_at: None,
|
||||
};
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
incidents.push(incident.clone());
|
||||
self.persist(&incidents).await?;
|
||||
Ok(incident)
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, id: Uuid) -> Result<Incident, IncidentError> {
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
let incident = incidents
|
||||
.iter_mut()
|
||||
.find(|incident| incident.id == id)
|
||||
.ok_or(IncidentError::NotFound)?;
|
||||
if incident.resolved_at.is_none() {
|
||||
incident.resolved_at = Some(Utc::now());
|
||||
}
|
||||
let resolved = incident.clone();
|
||||
self.persist(&incidents).await?;
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: Uuid) -> Result<Incident, IncidentError> {
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
let index = incidents
|
||||
.iter()
|
||||
.position(|incident| incident.id == id)
|
||||
.ok_or(IncidentError::NotFound)?;
|
||||
if incidents[index].resolved_at.is_none() {
|
||||
return Err(IncidentError::Active);
|
||||
}
|
||||
let deleted = incidents.remove(index);
|
||||
self.persist(&incidents).await?;
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
async fn persist(&self, incidents: &[Incident]) -> Result<(), std::io::Error> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let temporary = self.path.with_extension("json.tmp");
|
||||
let contents = serde_json::to_vec_pretty(incidents).map_err(std::io::Error::other)?;
|
||||
tokio::fs::write(&temporary, contents).await?;
|
||||
tokio::fs::rename(temporary, &self.path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::model::{NewIncident, Severity};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_created_and_resolved_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("incidents.json");
|
||||
let store = IncidentStore::load(path.clone()).await.unwrap();
|
||||
let created = store
|
||||
.create(NewIncident {
|
||||
title: "Service disruption".into(),
|
||||
message: "Investigating elevated errors.".into(),
|
||||
severity: Severity::Major,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store.resolve(created.id).await.unwrap();
|
||||
|
||||
let reloaded = IncidentStore::load(path).await.unwrap().list().await;
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
assert!(reloaded[0].resolved_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = IncidentStore::load(directory.path().join("incidents.json"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = store
|
||||
.create(NewIncident {
|
||||
title: " ".into(),
|
||||
message: "Details".into(),
|
||||
severity: Severity::Minor,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(IncidentError::Empty)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_deletes_resolved_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("incidents.json");
|
||||
let store = IncidentStore::load(path.clone()).await.unwrap();
|
||||
let created = store
|
||||
.create(NewIncident {
|
||||
title: "Service disruption".into(),
|
||||
message: "Investigating elevated errors.".into(),
|
||||
severity: Severity::Major,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
store.delete(created.id).await,
|
||||
Err(IncidentError::Active)
|
||||
));
|
||||
store.resolve(created.id).await.unwrap();
|
||||
store.delete(created.id).await.unwrap();
|
||||
|
||||
assert!(
|
||||
IncidentStore::load(path)
|
||||
.await
|
||||
.unwrap()
|
||||
.list()
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
142
apps/backend/src/main.rs
Normal file
142
apps/backend/src/main.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
mod admin;
|
||||
mod config;
|
||||
mod incidents;
|
||||
mod model;
|
||||
mod monitor;
|
||||
mod mtp_server;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use chrono::Utc;
|
||||
use config::Config;
|
||||
use incidents::IncidentStore;
|
||||
use model::{Category, Service, ServiceStatus, Snapshot};
|
||||
use tokio::sync::{RwLock, watch};
|
||||
|
||||
pub struct AppState {
|
||||
categories: Arc<RwLock<Vec<Category>>>,
|
||||
incidents: IncidentStore,
|
||||
updates: watch::Sender<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
async fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
categories: self.categories.read().await.clone(),
|
||||
incidents: self.incidents.list().await,
|
||||
generated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn snapshot_json(&self) -> String {
|
||||
serde_json::to_string(&self.snapshot().await).expect("snapshot is serializable")
|
||||
}
|
||||
|
||||
async fn publish(&self) {
|
||||
let payload = self.snapshot_json().await;
|
||||
tracing::info!(bytes = payload.len(), "publishing status snapshot");
|
||||
self.updates.send_replace(payload);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("methanium_status=info".parse()?),
|
||||
)
|
||||
.init();
|
||||
let config_path = std::env::args_os()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| "config.toml".into());
|
||||
let config = Arc::new(Config::load(&config_path)?);
|
||||
let state_dir = PathBuf::from(&config.state_dir);
|
||||
tokio::fs::create_dir_all(&state_dir).await?;
|
||||
|
||||
let categories = Arc::new(RwLock::new(load_categories(&config)?));
|
||||
let incidents = IncidentStore::load(state_dir.join("incidents.json")).await?;
|
||||
let initial = serde_json::to_string(&Snapshot {
|
||||
categories: categories.read().await.clone(),
|
||||
incidents: incidents.list().await,
|
||||
generated_at: Utc::now(),
|
||||
})?;
|
||||
let (updates, _) = watch::channel(initial);
|
||||
let state = Arc::new(AppState {
|
||||
categories: Arc::clone(&categories),
|
||||
incidents,
|
||||
updates,
|
||||
});
|
||||
|
||||
let monitor_config = Arc::clone(&config);
|
||||
let monitor_state = Arc::clone(&state);
|
||||
let publish: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
||||
let state = Arc::clone(&monitor_state);
|
||||
tokio::spawn(async move { state.publish().await });
|
||||
});
|
||||
tokio::spawn(monitor::run(monitor_config, categories, publish));
|
||||
|
||||
let admin_address = (config.admin_address, config.admin_port).into();
|
||||
let admin = admin::serve(
|
||||
admin_address,
|
||||
PathBuf::from(&config.frontend_dir),
|
||||
Arc::clone(&state),
|
||||
);
|
||||
let mtp = mtp_server::serve(
|
||||
config.public_address,
|
||||
config.public_port,
|
||||
tokio::fs::read(&config.certificate).await?,
|
||||
tokio::fs::read(&config.private_key).await?,
|
||||
state,
|
||||
);
|
||||
tokio::try_join!(admin, async { mtp.await.map_err(std::io::Error::other) })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_categories(config: &Config) -> Result<Vec<Category>, std::io::Error> {
|
||||
config
|
||||
.categories
|
||||
.iter()
|
||||
.map(|category| {
|
||||
Ok(Category {
|
||||
id: category.id.clone(),
|
||||
name: category.name.clone(),
|
||||
logo: logo_data_url(Path::new(&category.logo))?,
|
||||
services: category
|
||||
.services
|
||||
.iter()
|
||||
.map(|service| Service {
|
||||
id: service.id.clone(),
|
||||
name: service.name.clone(),
|
||||
url: service.url.clone(),
|
||||
check_http3: service.check_http3,
|
||||
status: ServiceStatus::Unknown,
|
||||
status_code: None,
|
||||
latency_ms: None,
|
||||
error: None,
|
||||
checked_at: None,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn logo_data_url(path: &Path) -> Result<String, std::io::Error> {
|
||||
let media_type = match path.extension().and_then(|value| value.to_str()) {
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("webp") => "image/webp",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
Ok(format!(
|
||||
"data:{media_type};base64,{}",
|
||||
STANDARD.encode(std::fs::read(path)?)
|
||||
))
|
||||
}
|
||||
72
apps/backend/src/model.rs
Normal file
72
apps/backend/src/model.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
pub categories: Vec<Category>,
|
||||
pub incidents: Vec<Incident>,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Category {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub services: Vec<Service>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Service {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub check_http3: bool,
|
||||
pub status: ServiceStatus,
|
||||
pub status_code: Option<u16>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
pub checked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceStatus {
|
||||
Unknown,
|
||||
Online,
|
||||
Offline,
|
||||
TimedOut,
|
||||
HttpError,
|
||||
Http3Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Incident {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub severity: Severity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Severity {
|
||||
Minor,
|
||||
Major,
|
||||
Critical,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NewIncident {
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub severity: Severity,
|
||||
}
|
||||
184
apps/backend/src/monitor.rs
Normal file
184
apps/backend/src/monitor.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
use std::{process::Stdio, sync::Arc, time::Duration};
|
||||
|
||||
use chrono::Utc;
|
||||
use reqwest::redirect::Policy;
|
||||
use tokio::{process::Command, sync::RwLock, time::Instant};
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
model::{Service, ServiceStatus},
|
||||
};
|
||||
|
||||
pub async fn run(
|
||||
config: Arc<Config>,
|
||||
categories: Arc<RwLock<Vec<crate::model::Category>>>,
|
||||
publish: Arc<dyn Fn() + Send + Sync>,
|
||||
) {
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::limited(10))
|
||||
.timeout(Duration::from_secs(config.timeout_seconds))
|
||||
.user_agent("Methanium-Status/0.1")
|
||||
.build()
|
||||
.expect("HTTP client configuration is valid");
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(config.check_interval_seconds));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let jobs = {
|
||||
let categories = categories.read().await;
|
||||
categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(category_index, category)| {
|
||||
category
|
||||
.services
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(service_index, service)| {
|
||||
(category_index, service_index, service.clone())
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut checks = tokio::task::JoinSet::new();
|
||||
for (category_index, service_index, service) in jobs {
|
||||
let client = client.clone();
|
||||
let config = Arc::clone(&config);
|
||||
checks.spawn(async move {
|
||||
(
|
||||
category_index,
|
||||
service_index,
|
||||
check_service(&client, &config, service).await,
|
||||
)
|
||||
});
|
||||
}
|
||||
while let Some(result) = checks.join_next().await {
|
||||
match result {
|
||||
Ok((category_index, service_index, service)) => {
|
||||
categories.write().await[category_index].services[service_index] = service;
|
||||
}
|
||||
Err(error) => tracing::error!(%error, "service check task failed"),
|
||||
}
|
||||
}
|
||||
publish();
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_service(client: &reqwest::Client, config: &Config, mut service: Service) -> Service {
|
||||
let started = Instant::now();
|
||||
let result = client.get(&service.url).send().await;
|
||||
service.checked_at = Some(Utc::now());
|
||||
service.latency_ms = Some(started.elapsed().as_millis() as u64);
|
||||
service.error = None;
|
||||
service.status_code = None;
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
service.status_code = Some(status.as_u16());
|
||||
if !is_operational_status(status) {
|
||||
service.status = ServiceStatus::HttpError;
|
||||
service.error = Some(format!("HTTP {}", status.as_u16()));
|
||||
return service;
|
||||
}
|
||||
}
|
||||
Err(error) if error.is_timeout() => {
|
||||
service.status = ServiceStatus::TimedOut;
|
||||
service.error = Some("Request timed out".into());
|
||||
return service;
|
||||
}
|
||||
Err(error) => {
|
||||
service.status = ServiceStatus::Offline;
|
||||
service.error = Some(error.to_string());
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
if service.check_http3 {
|
||||
match check_http3(config, &service.url).await {
|
||||
Ok((status, latency)) if (200..400).contains(&status) => {
|
||||
service.latency_ms = Some(service.latency_ms.unwrap_or(0).max(latency));
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
service.status = ServiceStatus::Http3Error;
|
||||
service.error = Some(format!("HTTP/3 returned {status}"));
|
||||
return service;
|
||||
}
|
||||
Err(error) => {
|
||||
service.status = ServiceStatus::Http3Error;
|
||||
service.error = Some(error);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.status = ServiceStatus::Online;
|
||||
service
|
||||
}
|
||||
|
||||
async fn check_http3(config: &Config, url: &str) -> Result<(u16, u64), String> {
|
||||
let output = Command::new(&config.curl)
|
||||
.args([
|
||||
"--http3-only",
|
||||
"--location",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--output",
|
||||
"/dev/null",
|
||||
"--write-out",
|
||||
"%{http_code}\t%{time_total}",
|
||||
"--max-time",
|
||||
&config.timeout_seconds.to_string(),
|
||||
url,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| format!("HTTP/3 probe failed to start: {error}"))?;
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
return Err(if error.is_empty() {
|
||||
"HTTP/3 connection failed".into()
|
||||
} else {
|
||||
error
|
||||
});
|
||||
}
|
||||
parse_http3_output(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn is_operational_status(status: reqwest::StatusCode) -> bool {
|
||||
status.is_success() || status.is_redirection()
|
||||
}
|
||||
|
||||
fn parse_http3_output(value: &str) -> Result<(u16, u64), String> {
|
||||
let (status, seconds) = value
|
||||
.trim()
|
||||
.split_once('\t')
|
||||
.ok_or("Invalid HTTP/3 probe output")?;
|
||||
let status = status.parse().map_err(|_| "Invalid HTTP/3 status code")?;
|
||||
let seconds: f64 = seconds.parse().map_err(|_| "Invalid HTTP/3 latency")?;
|
||||
Ok((status, (seconds * 1_000.0) as u64))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_http_statuses() {
|
||||
assert!(is_operational_status(StatusCode::OK));
|
||||
assert!(is_operational_status(StatusCode::TEMPORARY_REDIRECT));
|
||||
assert!(!is_operational_status(StatusCode::BAD_REQUEST));
|
||||
assert!(!is_operational_status(StatusCode::SERVICE_UNAVAILABLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_http3_probe_result() {
|
||||
assert_eq!(parse_http3_output("204\t0.125").unwrap(), (204, 125));
|
||||
assert!(parse_http3_output("not-a-result").is_err());
|
||||
}
|
||||
}
|
||||
96
apps/backend/src/mtp_server.rs
Normal file
96
apps/backend/src/mtp_server.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use std::{net::IpAddr, sync::Arc};
|
||||
|
||||
use mtp::{
|
||||
codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap},
|
||||
host::{HostConfig, Policy, SendMode},
|
||||
webserver::{MTPWebServer, WebMTPConnection, WebServerConfig},
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub async fn serve(
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
certificate: Vec<u8>,
|
||||
private_key: Vec<u8>,
|
||||
state: Arc<crate::AppState>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let policy = Policy::default().with_send_mode(SendMode::SingleStreamPerMessage);
|
||||
let host = HostConfig::new(address, port, certificate, private_key).with_policy(policy);
|
||||
let web = WebServerConfig::new()
|
||||
.mtp_path("/mtp")
|
||||
.serve_tcp_https(false);
|
||||
let mut server = MTPWebServer::new(host, web).await?;
|
||||
tracing::info!(address = %server.local_addr(), "MTP WebTransport listening");
|
||||
|
||||
let mut next_connection_id = 1_u64;
|
||||
while let Some(connection) = server.accept().await? {
|
||||
let connection_id = next_connection_id;
|
||||
next_connection_id += 1;
|
||||
tracing::info!(connection_id, "MTP client connected");
|
||||
let updates = state.updates.subscribe();
|
||||
let state = Arc::clone(&state);
|
||||
tokio::spawn(
|
||||
async move { serve_connection(connection_id, connection, updates, state).await },
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_connection(
|
||||
connection_id: u64,
|
||||
connection: WebMTPConnection,
|
||||
mut updates: watch::Receiver<String>,
|
||||
state: Arc<crate::AppState>,
|
||||
) {
|
||||
let sender = connection.sender.clone();
|
||||
let type_map = connection.codec.type_map().clone();
|
||||
loop {
|
||||
tokio::select! {
|
||||
request = connection.receiver.receive() => {
|
||||
let request = match request {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
tracing::warn!(connection_id, %error, "failed to receive MTP request");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if request.is_type(CommunicationType::GetStatus) {
|
||||
let payload = state.snapshot_json().await;
|
||||
let bytes = payload.len();
|
||||
let response = snapshot(&type_map, payload).with_id(request.get_id());
|
||||
tracing::info!(connection_id, request_id = request.get_id(), bytes, "received status request");
|
||||
if let Err(error) = sender.send(&response).await {
|
||||
tracing::warn!(connection_id, request_id = request.get_id(), %error, "failed to send status response");
|
||||
break;
|
||||
}
|
||||
tracing::info!(connection_id, request_id = request.get_id(), "sent status response");
|
||||
} else {
|
||||
tracing::warn!(connection_id, "received unsupported MTP request");
|
||||
}
|
||||
}
|
||||
changed = updates.changed() => {
|
||||
if changed.is_err() {
|
||||
tracing::info!(connection_id, "status update channel closed");
|
||||
break;
|
||||
}
|
||||
let payload = updates.borrow_and_update().clone();
|
||||
let bytes = payload.len();
|
||||
let response = snapshot(&type_map, payload);
|
||||
if let Err(error) = sender.send(&response).await {
|
||||
tracing::warn!(connection_id, %error, "failed to push status update");
|
||||
break;
|
||||
}
|
||||
tracing::info!(connection_id, bytes, "pushed status update");
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(connection_id, "MTP client disconnected");
|
||||
}
|
||||
|
||||
fn snapshot(type_map: &TypeMap, payload: String) -> CommunicationValue {
|
||||
CommunicationValue::from_comm(CommunicationType::StatusSnapshot, type_map).add_typed(
|
||||
DataType::Payload,
|
||||
type_map,
|
||||
DataValue::Str(payload),
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue