Initial commit

This commit is contained in:
Alois 2026-07-29 22:19:13 +02:00
commit 025c8c8e16
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
46 changed files with 13890 additions and 0 deletions

2
.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[env]
MTP_TYPE_MAPS = { value = "apps/frontend/type-maps.yaml", relative = true }

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
.vite/
.direnv/
result
.dev/
.env.local

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
apps/frontend/dist/
apps/frontend/src/routeTree.gen.ts
apps/backend/target/
node_modules/
result

15
LICENSE Normal file
View file

@ -0,0 +1,15 @@
Copyright (c) 2025 Methanium
All rights reserved.
No part of this software, source code, documentation, or
associated materials may be copied, reproduced, modified,
distributed, published, sublicensed, sold, or used to create
derivative works without prior written permission from the
copyright holder.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# Methanium Status
Live availability and incident reporting for Methanium and Tensamin services. The Rust backend checks configured URLs every five minutes, verifies HTTP/3 where requested, persists incident history, and pushes snapshots to the React status page over MTP/WebTransport.
## Development
```sh
nix develop
pnpm install
pnpm check
pnpm dev
```
`pnpm dev` generates a short-lived localhost certificate, uses it for the HTTPS frontend and WebTransport backend, and writes the matching WebTransport certificate hash. The public page is available on Vite's displayed HTTPS URL and the incident editor is available at `http://127.0.0.1:8091/edit` after the frontend has been built once.
For a manual backend configuration, copy `config.example.toml`, update the certificate paths, build the frontend, and run:
```sh
pnpm build
cargo run --manifest-path apps/backend/Cargo.toml -- ./config.toml
```
Set `VITE_MTP_URL` when the development MTP endpoint is not available at the frontend's own `/mtp` origin.
## Configuration
The backend accepts TOML or JSON. Categories contain a local logo and a list of services. Service names and URLs are not editable through the incident UI.
```toml
[[categories]]
id = "example"
name = "Example"
logo = "/path/to/example.svg"
[[categories.services]]
id = "example-home"
name = "Homepage"
url = "https://example.com"
check_http3 = true
```
HTTP 200 through 399 is operational. DNS, connection, and TLS failures are reported as offline; timeouts, HTTP error responses, and HTTP/3 failures have distinct labels. Every non-operational service state uses red on the public page.
The admin page is available at `/edit` on the separately configured admin address and port. It has intentionally no authentication, so the backend requires the admin address to be loopback. The NixOS module binds it to `127.0.0.1:8081` and does not open its firewall port by default. For remote use, prefer an SSH tunnel:
```sh
ssh -L 8081:127.0.0.1:8081 status-host
```
## NixOS
The included module serves the SPA with nginx on TCP 443 and MTP HTTP/3/WebTransport on UDP 443. Both use the same ACME certificate and origin.
```nix
{
inputs.methanium-status.url =
"git+https://git.methanium.net/methanium/status";
outputs = { nixpkgs, methanium-status, ... }: {
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
methanium-status.nixosModules.default
{
security.acme.acceptTerms = true;
security.acme.defaults.email = "admin@example.com";
services.methanium-status = {
enable = true;
hostName = "status.example.com";
openFirewall = true;
categories = [{
id = "example";
name = "Example";
logo = ./example.svg;
services = [{
id = "example-home";
name = "Homepage";
url = "https://example.com";
checkHttp3 = false;
}];
}];
};
}
];
};
};
}
```
Use `acmeHost` to share an already configured NixOS ACME certificate. The service options also expose the check interval, timeout, public listener, admin listener, state directory, packages, firewall behavior, categories, logo paths, and services.
Contact: [contact@methanium.net](mailto:contact@methanium.net)
Security: [security@methanium.net](mailto:security@methanium.net)

View file

@ -0,0 +1,2 @@
[env]
MTP_TYPE_MAPS = { value = "../frontend/type-maps.yaml", relative = true }

1
apps/backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target

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
View 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
View 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
View 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(())
}

View 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
View 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
View 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
View 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());
}
}

View 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),
)
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/"!</div>
}

38
apps/frontend/index.html Normal file
View file

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en" class="dark" data-theme="methanium">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171e26" />
<title>Methanium Status</title>
<style>
html.dark,
html.dark body {
background-color: #171e26;
color: #d2e0f0;
color-scheme: dark;
}
html.light,
html.light body {
background-color: #edf1f4;
color: #10161d;
color-scheme: light;
}
</style>
<script>
const savedTheme = localStorage.getItem("theme");
const theme =
savedTheme === "light" ||
(savedTheme === "system" &&
matchMedia("(prefers-color-scheme: light)").matches)
? "light"
: "dark";
document.documentElement.classList.replace("dark", theme);
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,28 @@
{
"name": "@methanium/status-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@methanium/status-mtp": "workspace:*",
"@methanium/ui": "*",
"@tanstack/react-router": "^1.131.35",
"lucide-react": "^1.21.0",
"mtp": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@tanstack/router-plugin": "^1.131.35",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"tailwindcss": "^4.3.1",
"vite": "^7.1.12"
}
}

View file

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="4441.4" height="6468.8" shape-rendering="geometricPrecision" viewBox="0 0 4441 6469">
<path fill="#331c4e" d="m1714 6033-33-248-5-676 69-1110-247-2069-388-302 196 3614Z"/>
<path fill="#6a438a" d="m1864 2038-214 3093 31 640 820-1486 125-2262Z"/>
<path fill="#291641" d="m3130 1586-504 437-125 2262 305 315Z"/>
<path fill="#0e0619" d="m2501 4285 305 315-120 1018-384 830h-56Z"/>
<path fill="#211136" d="m1762 6196 247 254 237-2 255-2163-822 1476 17 221Z"/>
<path fill="#3f245b" d="m740 4907 255-267 719 1393 48 163-340-112Z"/>
<path fill="#05020d" d="m1407 6080-147 198 414 172h335l-247-254Z"/>
<path fill="#030106" d="m1273 6283-85 173 486-6Z"/>
<path fill="#0a0413" d="m10 5229 297-41 966 1095-85 173-100 3Z"/>
<path fill="#5b3878" d="m740 4907 682 1177-149 199-966-1095Z"/>
<path fill="#794f9a" d="M159 4369 10 5229l297-41Z"/>
<path fill="#b984c5" d="m307 5188 433-281-581-538Z"/>
<path fill="#d4abda" d="m159 4369 581 538 255-267Z"/>
<path d="m2686 5604 312 564-179 263-517 17Z"/>
<path fill="#130922" d="m2686 5604 294-421 588-268 48 443-618 810Z"/>
<path fill="#975dac" d="m2686 5604 1115-1590-379-274-666 1279Z"/>
<path fill="#6a438a" d="m2980 5183 588-268 511-814-429-152Z"/>
<path fill="#190c2b" d="m4079 4101 352 111-815 1146-48-443Z"/>
<path fill="#f0d7f2" d="m2045 11-547 1919 366 108 762-15 70-59Z"/>
<path fill="#a558aa" d="m2045 11 651 1953 434-378Z"/>
<path fill="#b06ab6" d="m2045 11-547 1919-388-302Z"/>
<path fill="#885ca8" d="m1498 1930 238 1992 128-1884Z"/>
<path fill="#c698d0" d="m3650 3949 429 152 273-790Z"/>
<path fill="#4c2d68" d="m4079 4101 352 111-79-901Z"/>
<path fill="#e2c1e6" d="m3422 3740 228 209 702-638Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,270 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="85.836182mm"
height="90.476906mm"
viewBox="0 0 85.836183 90.476906"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="logo_raw_outline.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="2"
inkscape:cx="191.5"
inkscape:cy="117.25"
inkscape:window-width="2500"
inkscape:window-height="1403"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1"><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect1"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1"
radius="1"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="swatch15"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop15" /></linearGradient><linearGradient
id="swatch10"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop10" /></linearGradient><linearGradient
id="swatch3"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop3" /></linearGradient><linearGradient
id="swatch2"
inkscape:swatch="solid"><stop
style="stop-color:#b8f8ff;stop-opacity:1;"
offset="0"
id="stop2" /></linearGradient><linearGradient
id="swatch1"
inkscape:swatch="solid"><stop
style="stop-color:#031616;stop-opacity:1;"
offset="0"
id="stop1" /></linearGradient><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1"
radius="3"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="bspline"
id="path-effect6"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="spiro"
id="path-effect5"
is_visible="true"
lpeversion="1" /><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath1"><path
id="path3"
style="fill:#043b3c;stroke-width:0.320821"
inkscape:label="arrow"
d="m 244.98648,261.60864 -9.19072,26.71639 23.35523,-11.66051 c -4.69011,-5.05439 -10.12762,-9.5023 -14.16451,-15.05588 z m 40.12749,-74.6916 50.72412,18.76364 c 6.70121,39.09457 -10.8946,70.48625 -50.72412,85.29337 z m 0,0 -50.72412,18.76364 c -6.7012,39.09457 10.89461,70.48625 50.72412,85.29337 z"
sodipodi:nodetypes="cccccccccccc" /></clipPath><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient15"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient16"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient17"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient18"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient19"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient20"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient21"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient22"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient23"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient24"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient25"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch2"
id="linearGradient1"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5"
gradientTransform="matrix(1.0144629,0,0,1.0151924,217.46226,13.830072)" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-281.06837,-119.33314)"><g
id="g2"
transform="matrix(0.77770172,0,0,0.82714179,101.85385,-33.291714)"
inkscape:label="background"
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
clip-path="url(#clipPath1)"><path
id="rect1"
style="fill:#004b4a;stroke:url(#linearGradient17);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,273.59631 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1"
style="fill:#00524e;stroke:url(#linearGradient18);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,297.99268 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3"
style="fill:#006560;stroke:url(#linearGradient19);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,322.3891 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6"
style="fill:#02857d;stroke:url(#linearGradient20);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,346.78546 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6-2"
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><path
id="path1-5"
style="fill:none;stroke:url(#linearGradient1);stroke-width:2;stroke-dasharray:none"
inkscape:label="glow_outline"
d="m 323.73413,119.91784 -40.80246,15.9513 c -0.13609,0.0532 -0.26542,0.21326 -0.28843,0.35757 -2.90631,18.22859 -0.19736,34.27444 8.28042,47.36302 0.0794,0.12264 0.10916,0.33484 0.0657,0.47436 l -7.08112,22.75509 a 0.11130973,0.11130973 40.101955 0 0 0.15695,0.13218 l 18.7652,-9.59506 c 0.13011,-0.0665 0.32608,-0.044 0.43816,0.0497 5.75202,4.81168 12.45464,8.67546 20.46583,11.84762 0.13586,0.0538 0.3564,0.0538 0.49225,-3e-5 32.16862,-12.74646 46.41741,-39.59704 41.09168,-73.02692 -0.023,-0.14431 -0.15232,-0.30436 -0.28841,-0.35757 l -40.80297,-15.9513 a 0.67679193,0.67679193 179.99988 0 0 -0.49284,0 z"
sodipodi:nodetypes="cccccccc"
inkscape:original-d="m 323.98055,119.8215 -41.2953,16.14398 c -2.99079,18.43973 -0.24934,34.65473 8.38277,47.84598 l -7.23836,23.26035 19.23635,-9.83596 c 5.85568,4.9403 12.69983,8.88741 20.91454,12.11489 32.42602,-12.73985 46.75142,-39.74883 41.29581,-73.38526 z"
inkscape:path-effect="#path-effect1"
transform="matrix(0.98854883,0,0,0.98930922,3.7119485,1.7325084)" /><g
id="g3"
inkscape:label="foreground"
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"
transform="matrix(0.98854883,0,0,0.98930922,220.2086,17.64618)"><g
id="g1"
inkscape:label="outline"
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path
id="path15-3-5"
style="opacity:0.352;fill:#000000;fill-opacity:1;stroke:url(#linearGradient22);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="filler"
d="m -54.535435,65.443954 -38.869976,15.14533 c -2.902661,17.83718 0.143161,33.687786 8.852689,46.257116 l -5.823934,18.52239 15.016655,-8.2765 c 5.699447,4.98296 12.651108,9.14382 20.824566,12.34447 30.52177,-11.95203 44.00571,-37.29104 38.8705,-68.847476 z m -0.01808,2.85099 36.24585,14.11438 c 4.61111,37.589746 -14.17784,55.293626 -36.24585,64.158876 -21.698042,-8.5864 -40.914886,-26.53641 -36.245337,-64.158876 z"
transform="translate(159.54375,40.87617)" /></g><path
style="opacity:1;fill:#b8f8ff;fill-opacity:1;stroke:url(#linearGradient24);stroke-width:0.5;stroke-dasharray:none"
d="m 98.103726,126.47621 21.412544,0.16758 a 0.41189664,0.41189664 63.022469 0 1 0.33171,0.65164 l -8.29596,11.58914 a 0.41184923,0.41184923 63.019773 0 0 0.33171,0.65157 l 10.05254,0.0777 a 0.3104736,0.3104736 69.080154 0 1 0.20649,0.54017 l -30.495516,27.73194 a 0.15466019,0.15466019 36.691517 0 1 -0.243455,-0.18141 l 10.172421,-21.16912 a 0.50438266,0.50438266 58.099348 0 0 -0.44993,-0.72282 l -11.312564,-0.10524 a 0.52748832,0.52748832 56.926855 0 1 -0.479488,-0.73628 l 7.661544,-17.7722 a 1.1963277,1.1963277 146.88457 0 1 1.107954,-0.72269 z"
id="path2"
sodipodi:nodetypes="cccccccc"
inkscape:label="bolt" /><path
id="path1"
style="fill:#043b3c;stroke:url(#linearGradient25);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="dark_outline"
d="m 104.99968,104.40528 -40.706558,15.90239 c -2.948156,18.16379 0.05742,34.31077 8.566402,47.3046 l -7.438306,22.73763 18.798853,-9.90998 c 5.772204,4.86637 12.68201,8.97564 20.779609,12.15481 31.96374,-12.54919 46.08489,-39.15399 40.70708,-72.28706 z m 0,1.91926 38.88755,15.13603 c 4.9985,39.45937 -15.37196,59.52899 -38.88755,68.8573 -7.461219,-2.95976 -14.605741,-7.00102 -20.723799,-12.39976 l -1.849499,0.95188 -13.267924,7.37991 5.196582,-16.46463 0.704866,-2.3027 c 0.02259,0.0331 0.04422,0.065 0,-5.2e-4 -0.02016,-0.0296 -0.04036,-0.0594 -0.01292,-0.0196 -7.707836,-11.18647 -11.48502,-25.8661 -8.934338,-46.00184 z m 5.2e-4,2.85151 -36.245852,14.11438 c -4.788427,29.4077 7.785188,53.02119 36.245852,64.15939 28.46066,-11.1382 41.03376,-34.75169 36.24533,-64.15939 z m 0,1.70377 34.62527,13.43381 c 4.45069,35.0227 -13.68688,52.83618 -34.62527,61.11564 -20.938395,-8.27946 -39.076477,-26.09294 -34.625796,-61.11564 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,72 @@
import { ExternalLink, Moon, Sun } from "lucide-react";
import { Button, useTheme } from "@methanium/ui";
import { useStatus } from "../../use-status";
import { formatTime } from "../../utils";
export function Header() {
const { resolvedPolarity, setTheme } = useTheme();
const { snapshot, error } = useStatus();
const isDark = resolvedPolarity === "dark";
return (
<header className="sticky top-0 z-40 h-12 border-b border-sidebar-border bg-sidebar text-sidebar-foreground shadow-sm">
<div className="flex h-full items-center justify-between overflow-x-auto px-[10px]">
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="mr-1 shrink-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ width: "46.59px", height: "36px", padding: "3px 13px" }}
render={<a href="/" aria-label="Methanium home" />}
>
<img src="/methanium.svg" alt="" className="h-8 w-6 object-contain" />
</Button>
<nav
className="mr-auto flex h-full shrink-0 items-center"
aria-label="Primary"
>
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
render={<a target="_blank" href="mailto:contact@methanium.net" />}
>
Contact us <ExternalLink />
</Button>
<Button
nativeButton={false}
variant="ghost"
size="sm"
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
render={<a target="_blank" href="mailto:security@methanium.net" />}
>
Report security issue <ExternalLink />
</Button>
</nav>
<div className="flex h-full items-center gap-2">
<p className="text-sm text-muted-foreground">
{snapshot?.generatedAt
? `Updated ${formatTime(snapshot.generatedAt)}`
: (error?.message ?? "Waiting for data")}
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? (
<Sun className="size-3.5" />
) : (
<Moon className="size-3.5" />
)}
</Button>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,32 @@
import "./styles.css";
import { BUILT_IN_THEMES, ThemeProvider } from "@methanium/ui";
import { Provider as MTPProvider } from "@methanium/status-mtp";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import { mtpOptions } from "./mtp-options.ts";
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider
defaultTheme="dark"
themes={BUILT_IN_THEMES}
defaultParentThemeId="methanium"
>
<MTPProvider options={mtpOptions} authenticate={false}>
<RouterProvider router={router} />
</MTPProvider>
</ThemeProvider>
</StrictMode>,
);

View file

@ -0,0 +1,30 @@
import type { MTPClientOptions, MTPLogEvent } from "mtp";
const configuredUrl = import.meta.env.VITE_MTP_URL as string | undefined;
const configuredCertificateHash = import.meta.env.VITE_MTP_CERT_HASH as
string | undefined;
function logMTP(event: MTPLogEvent) {
const label = `[MTP] ${event.direction ?? "internal"} ${event.type}`;
if (event.hint === "error") {
console.error(label, event);
} else if (event.hint === "warning") {
console.warn(label, event);
} else {
console.info(label, event);
}
}
export const mtpOptions: MTPClientOptions | null = location.pathname.startsWith(
"/edit",
)
? null
: {
url: configuredUrl ?? `${location.origin}/mtp`,
descriptor: "methanium-status-frontend",
pings: false,
logger: logMTP,
serverCertificateHashes: configuredCertificateHash
? [configuredCertificateHash]
: undefined,
};

View file

@ -0,0 +1,77 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as EditRouteImport } from './routes/edit'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const EditRoute = EditRouteImport.update({
id: '/edit',
path: '/edit',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/edit': typeof EditRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/edit': typeof EditRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/edit': typeof EditRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/edit'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/edit'
id: '__root__' | '/' | '/edit'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
EditRoute: typeof EditRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/edit': {
id: '/edit'
path: '/edit'
fullPath: '/edit'
preLoaderRoute: typeof EditRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
EditRoute: EditRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -0,0 +1,19 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { Header } from "../components/layout/Header";
const EmptyPage = () => (
<main className="min-h-[calc(100vh-3rem)] bg-background text-2xl w-full h-full items-center justify-center flex font-medium">
404 Page Not Found
</main>
);
export const Route = createRootRoute({
component: () => (
<div className="min-h-screen bg-background text-foreground">
<Header />
<Outlet />
</div>
),
notFoundComponent: EmptyPage,
});

View file

@ -0,0 +1,391 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
cn,
} from "@methanium/ui";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { Check, Loader2, Plus, ShieldAlert, Trash2 } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { Incident, Severity } from "../status";
import { formatTime } from "../utils";
export const Route = createFileRoute("/edit")({
beforeLoad: async () => {
try {
const response = await fetch("/api/admin", { cache: "no-store" });
if (response.status !== 204) throw notFound();
} catch {
throw notFound();
}
},
component: IncidentEditor,
});
const severityLabels: Record<Severity, string> = {
minor: "Minor",
major: "Major",
critical: "Critical",
};
function IncidentEditor() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function refresh() {
try {
const response = await fetch("/api/incidents", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
setIncidents((await response.json()) as Incident[]);
setError(null);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
setSubmitting(true);
try {
const response = await fetch("/api/incidents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: data.get("title"),
message: data.get("message"),
severity: data.get("severity"),
}),
});
if (!response.ok) {
const body = (await response.json()) as { error?: string };
throw new Error(
body.error ?? `Request failed with HTTP ${response.status}`,
);
}
form.reset();
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSubmitting(false);
}
}
async function resolve(id: string) {
try {
const response = await fetch(`/api/incidents/${id}/resolve`, {
method: "POST",
});
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
}
async function deleteIncident(id: string) {
setDeletingId(id);
try {
const response = await fetch(`/api/incidents/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setDeletingId(null);
}
}
const active = incidents.filter((incident) => !incident.resolvedAt);
const resolved = incidents
.filter((incident) => incident.resolvedAt)
.toReversed();
return (
<main className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-10 sm:px-6">
<header className="flex items-center gap-3">
<ShieldAlert className="size-7" />
<h1 className="text-xl font-medium">Incident management</h1>
</header>
{error && (
<div
role="alert"
className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300"
>
{error}
</div>
)}
<div className="grid items-start gap-8 lg:grid-cols-[20rem_minmax(0,1fr)]">
<form
onSubmit={create}
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] p-4 dark:border-[#242D38] dark:bg-[#242D38]"
>
<h2 className="font-medium">Create incident</h2>
<Label className="mt-5 block text-sm" htmlFor="title">
Title
</Label>
<Input
required
id="title"
name="title"
maxLength={120}
className="mt-2 w-full"
/>
<Label className="mt-4 block text-sm" htmlFor="message">
Message
</Label>
<Textarea
required
id="message"
name="message"
maxLength={2000}
rows={6}
className="py-2"
/>
<Label className="mt-4 block text-sm" htmlFor="severity">
Severity
</Label>
<Select name="severity" defaultValue="minor">
<SelectTrigger id="severity" className="mt-2 w-full">
<SelectValue>
{(value) => severityLabels[value as Severity]}
</SelectValue>
</SelectTrigger>
<SelectContent className="border!">
<SelectItem value="minor">Minor</SelectItem>
<SelectItem value="major">Major</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={submitting} className="mt-5 w-full">
{submitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
Publish incident
</Button>
</form>
<section aria-labelledby="active-incidents">
<div className="mb-4 flex items-baseline gap-2">
<h2 id="active-incidents" className="font-medium">
Active incidents
</h2>
<span className="text-sm text-muted-foreground">
{active.length}
</span>
</div>
<div className="flex flex-col gap-3">
{loading && (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading incidents
</div>
)}
{!loading && active.length === 0 && (
<div className="rounded-xl border border-dashed border-[#C5CED6] px-4 py-10 text-center text-sm text-muted-foreground dark:border-[#445161]">
No active incidents
</div>
)}
{active.map((incident) => (
<article
key={incident.id}
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
>
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-medium [overflow-wrap:anywhere]">
{incident.title}
</h3>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => void resolve(incident.id)}
>
<Check className="size-4" />
Resolve
</Button>
</div>
<IncidentExcerpt
incident={incident}
lines={3}
className="mt-3"
/>
</div>
<div className="flex gap-2 border-t border-[#C5CED6] px-4 py-2 text-xs text-muted-foreground dark:border-[#445161]">
<span className="capitalize">{incident.severity}</span>
<span>Started {formatTime(incident.createdAt)}</span>
</div>
</article>
))}
</div>
</section>
</div>
{resolved.length > 0 && (
<section aria-labelledby="resolved-incidents">
<div className="mb-4 flex items-baseline gap-2">
<h2 id="resolved-incidents" className="font-medium">
Resolved
</h2>
<span className="text-sm text-muted-foreground">
{resolved.length}
</span>
</div>
<div className="divide-y divide-[#C5CED6] border-y border-[#C5CED6] dark:divide-[#445161] dark:border-[#445161]">
{resolved.map((incident) => (
<div
key={incident.id}
className="flex flex-col gap-1 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<div className="min-w-0">
<p className="font-medium [overflow-wrap:anywhere]">
{incident.title}
</p>
<IncidentExcerpt incident={incident} lines={1} />
</div>
<div className="flex shrink-0 items-center justify-between gap-3 sm:justify-end">
<p className="text-xs text-muted-foreground">
{formatTime(incident.resolvedAt!)}
</p>
<Button
type="button"
variant="destructive"
size="sm"
disabled={deletingId === incident.id}
onClick={() => void deleteIncident(incident.id)}
>
{deletingId === incident.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Delete
</Button>
</div>
</div>
))}
</div>
</section>
)}
</main>
);
}
function IncidentExcerpt({
incident,
lines,
className,
}: {
incident: Incident;
lines: 1 | 3;
className?: string;
}) {
const messageRef = useRef<HTMLParagraphElement>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
const message = messageRef.current;
if (!message) return;
const update = () =>
setTruncated(
message.scrollHeight > message.clientHeight + 1 ||
message.scrollWidth > message.clientWidth + 1,
);
update();
const observer = new ResizeObserver(update);
observer.observe(message);
return () => observer.disconnect();
}, [incident.message]);
const severity =
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
return (
<div className={cn("min-w-0", className)}>
<p
ref={messageRef}
className={cn(
"text-sm text-muted-foreground [overflow-wrap:anywhere]",
lines === 1 ? "truncate" : "line-clamp-3 whitespace-pre-wrap",
)}
>
{incident.message}
</p>
{truncated && (
<Dialog>
<DialogTrigger
render={
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-0 py-0"
>
Read more
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{incident.title}</DialogTitle>
<DialogDescription>
{severity} incident started {formatTime(incident.createdAt)}
{incident.resolvedAt &&
` and resolved ${formatTime(incident.resolvedAt)}`}
</DialogDescription>
</DialogHeader>
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
{incident.message}
</p>
</DialogContent>
</Dialog>
)}
</div>
);
}

View file

@ -0,0 +1,289 @@
import { createFileRoute } from "@tanstack/react-router";
import {
AlertTriangle,
Check,
ChevronRight,
Clock3,
ExternalLink,
RefreshCw,
ShieldAlert,
WifiOff,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Incident, Service, ServiceStatus } from "../status";
import { useStatus } from "../use-status";
import { formatTime } from "../utils";
import {
Button,
CardContent,
CardHeader,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
cn,
} from "@methanium/ui";
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const { snapshot } = useStatus();
const [showPastIncidents, setShowPastIncidents] = useState(false);
const services =
snapshot?.categories.flatMap((category) => category.services) ?? [];
const failures = services.filter(
(service) => service.status !== "online" && service.status !== "unknown",
);
const activeIncidents =
snapshot?.incidents.filter((incident) => !incident.resolvedAt) ?? [];
const pastIncidents =
snapshot?.incidents
.filter((incident) => incident.resolvedAt)
.toReversed()
.slice(0, 5) ?? [];
const pending = services.some((service) => service.status === "unknown");
const ready = snapshot !== null && !pending;
const healthy =
ready && failures.length === 0 && activeIncidents.length === 0;
return (
<main className="flex flex-col pb-5">
<div className="w-full flex gap-3 justify-center items-center py-10">
<div>
{healthy ? (
<Check className="size-7" />
) : ready ? (
<AlertTriangle className="size-7" />
) : (
<RefreshCw className="size-7" />
)}
</div>
<div className="flex flex-col justify-start">
<p className="text-lg">
{healthy
? "Everythin's alright on our side"
: ready
? `${failures.length + activeIncidents.length} active disruption${failures.length + activeIncidents.length === 1 ? "" : "s"}`
: "Scraping our own data..."}
</p>
<p className="text-sm text-muted-foreground">
{!healthy && ready
? "More details below."
: snapshot
? ""
: "Connecting to Status Page backend..."}
</p>
</div>
</div>
{activeIncidents.length > 0 && (
<div className="grid w-full grid-cols-1 justify-center gap-6 pb-10 sm:grid-cols-[repeat(auto-fit,24rem)]">
{activeIncidents.map((incident) => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
)}
<div className="w-full flex flex-col lg:flex-row! gap-15 items-center justify-center">
{snapshot?.categories.map((category) => (
<div
key={category.id}
className="pt-4 text-sm flex flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
>
<CardHeader className="flex gap-3 items-center">
<img src={category.logo} alt={category.name} className="size-8" />
<p className="text-lg font-medium">{category.name}</p>
</CardHeader>
<CardContent className="px-0! flex flex-col gap-2 bg-[#E2E7EB] dark:bg-[#1D262F]">
{category.services.map((service, index) => (
<ServiceRow
key={service.id}
index={index}
max={category.services.length}
service={service}
/>
))}
</CardContent>
</div>
))}
</div>
{pastIncidents.length > 0 && (
<section className="pt-10 w-full flex flex-col items-center">
<Button
type="button"
variant="ghost"
aria-expanded={showPastIncidents}
aria-controls="past-incidents"
onClick={() => setShowPastIncidents((visible) => !visible)}
>
<ChevronRight
className={cn(
"size-4 transition-transform",
showPastIncidents && "rotate-90",
)}
/>
Show Past Incidents
</Button>
{showPastIncidents && (
<div
id="past-incidents"
className="mt-4 grid w-full grid-cols-1 justify-center gap-6 sm:grid-cols-[repeat(auto-fit,24rem)]"
>
{pastIncidents.map((incident) => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
)}
</section>
)}
</main>
);
}
function IncidentCard({ incident }: { incident: Incident }) {
const messageRef = useRef<HTMLParagraphElement>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
const message = messageRef.current;
if (!message) return;
const update = () =>
setTruncated(message.scrollHeight > message.clientHeight + 1);
update();
const observer = new ResizeObserver(update);
observer.observe(message);
return () => observer.disconnect();
}, [incident.message]);
const severity =
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
return (
<article className="flex h-52 w-96 max-w-[calc(100vw-2rem)] flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] pt-4 text-sm dark:border-[#242D38] dark:bg-[#242D38]">
<CardHeader className="flex items-center gap-3">
<p className="line-clamp-2 text-lg font-medium [overflow-wrap:anywhere]">
{incident.title}
</p>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 bg-[#E2E7EB] px-0! dark:bg-[#1D262F]">
<p
ref={messageRef}
className="line-clamp-3 whitespace-pre-wrap px-4 pt-2 leading-5 [overflow-wrap:anywhere]"
>
{incident.message}
</p>
{truncated && (
<Dialog>
<DialogTrigger
render={
<Button
variant="link"
size="sm"
className="h-auto self-start px-4 py-0"
>
Read more
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{incident.title}</DialogTitle>
<DialogDescription>
{severity} incident{" "}
{incident.resolvedAt ? "resolved" : "started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</DialogDescription>
</DialogHeader>
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
{incident.message}
</p>
</DialogContent>
</Dialog>
)}
<div className="mt-auto flex gap-3 border-t border-[#C5CED6] px-4 py-2 dark:border-[#242D38]">
<p>{severity}</p>
<p>
{incident.resolvedAt ? "Resolved" : "Started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</p>
</div>
</CardContent>
</article>
);
}
const statusDetails: Record<
ServiceStatus,
{ label: string; icon: typeof Check; color: string }
> = {
online: { label: "Operational", icon: Check, color: "text-emerald-500" },
unknown: { label: "Pending", icon: Clock3, color: "text-muted-foreground" },
offline: { label: "Offline", icon: WifiOff, color: "text-red-500" },
timed_out: { label: "Timed out", icon: Clock3, color: "text-red-500" },
http_error: {
label: "HTTP error",
icon: AlertTriangle,
color: "text-red-500",
},
http3_error: {
label: "HTTP/3 error",
icon: ShieldAlert,
color: "text-red-500",
},
};
function ServiceRow({
service,
index,
max,
}: {
service: Service;
index: number;
max: number;
}) {
const details = statusDetails[service.status];
const Icon = details.icon;
return (
<div
className={cn(
"px-4 flex items-center justify-between gap-40 py-2",
index < max - 1
? "border-border border-b border-[#C5CED6] dark:border-[#242D38]"
: "",
)}
>
<div className="min-w-0">
<a
href={service.url}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-1.5 font-medium hover:underline"
>
<span className="truncate">{service.name}</span>
<ExternalLink className="size-3 opacity-0 transition-opacity group-hover:opacity-60" />
</a>
<p className="mt-1 truncate text-xs text-muted-foreground">
{service.error ??
(service.latencyMs !== null
? `${service.latencyMs} ms${service.checkHttp3 ? " (HTTP/3 verified)" : ""}`
: new URL(service.url).hostname)}
</p>
</div>
<div
className={`flex items-center gap-2 text-xs font-medium ${details.color}`}
>
<Icon className="size-4" />
{details.label}
{service.statusCode && service.status === "http_error"
? ` ${service.statusCode}`
: ""}
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
export type ServiceStatus =
"unknown" | "online" | "offline" | "timed_out" | "http_error" | "http3_error";
export type Service = {
id: string;
name: string;
url: string;
checkHttp3: boolean;
status: ServiceStatus;
statusCode: number | null;
latencyMs: number | null;
error: string | null;
checkedAt: string | null;
};
export type Category = {
id: string;
name: string;
logo: string;
services: Service[];
};
export type Severity = "minor" | "major" | "critical";
export type Incident = {
id: string;
title: string;
message: string;
severity: Severity;
createdAt: string;
resolvedAt: string | null;
};
export type Snapshot = {
categories: Category[];
incidents: Incident[];
generatedAt: string;
};
export function parseSnapshot(message: { data: unknown }): Snapshot {
const data = message.data as Record<string, unknown>;
if (typeof data?.Payload !== "string") {
throw new Error("Status response did not contain a payload");
}
return JSON.parse(data.Payload) as Snapshot;
}

View file

@ -0,0 +1,36 @@
@import "tailwindcss";
@import "@methanium/ui/index.css";
@custom-variant dark (&:where(.dark, .dark *));
html {
scroll-behavior: smooth;
}
body {
margin: 0;
}
.status-canvas {
background-color: var(--background);
background-image:
linear-gradient(
to right,
color-mix(in oklab, var(--border) 34%, transparent) 1px,
transparent 1px
),
linear-gradient(
to bottom,
color-mix(in oklab, var(--border) 34%, transparent) 1px,
transparent 1px
),
radial-gradient(
circle at 50% 0%,
color-mix(in oklab, var(--primary) 7%, transparent),
transparent 36rem
);
background-size:
48px 48px,
48px 48px,
100% 100%;
}

View file

@ -0,0 +1,55 @@
import { useMTP } from "@methanium/status-mtp";
import { useEffect, useState } from "react";
import { parseSnapshot, type Snapshot } from "./status";
export function useStatus() {
const { contextReady, error: connectionError, send, subscribe } = useMTP();
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!contextReady) {
console.info("[Status] waiting for MTP readiness", {
connectionError,
});
return;
}
console.info("[Status] MTP ready; subscribing and requesting snapshot");
const receive = (message: { data: unknown }) => {
try {
const nextSnapshot = parseSnapshot(message);
console.info("[Status] received snapshot", {
categories: nextSnapshot.categories.length,
incidents: nextSnapshot.incidents.length,
generatedAt: nextSnapshot.generatedAt,
});
setSnapshot(nextSnapshot);
setError(null);
} catch (cause) {
const nextError =
cause instanceof Error ? cause : new Error(String(cause));
console.error("[Status] failed to parse snapshot", nextError, message);
setError(nextError);
}
};
const unsubscribe = subscribe("StatusSnapshot", receive);
void send("GetStatus", {}, { responseType: "StatusSnapshot" })
.then(receive)
.catch((cause) => {
const nextError =
cause instanceof Error ? cause : new Error(String(cause));
console.error("[Status] snapshot request failed", nextError);
setError(nextError);
});
return unsubscribe;
}, [connectionError, contextReady, send, subscribe]);
return {
snapshot,
connected: contextReady,
error: error ?? connectionError,
};
}

View file

@ -0,0 +1,6 @@
export function formatTime(value: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}

View file

@ -0,0 +1,9 @@
protocol_version: "1.0"
type_maps:
"1.0":
CommunicationTypes:
GetStatus: 32
StatusSnapshot: 33
DataTypes:
Payload: 32

View file

@ -0,0 +1,35 @@
import { methaniumUi } from "@methanium/ui/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { mtp } from "mtp/vite";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
server: {
https: {
cert: fs.readFileSync(path.resolve(root, "../../.dev/cert.pem")),
key: fs.readFileSync(path.resolve(root, "../../.dev/key.pem")),
},
},
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
tailwindcss(),
react(),
methaniumUi({ defaultThemeId: "methanium" }),
mtp({ typeMaps: "./type-maps.yaml" }) as Plugin,
],
resolve: {
alias: {
"@methanium/status-mtp": path.resolve(
root,
"../../packages/mtp/index.ts",
),
},
},
});

72
config.example.toml Normal file
View file

@ -0,0 +1,72 @@
public_address = "0.0.0.0"
public_port = 4433
admin_address = "127.0.0.1"
admin_port = 8091
check_interval_seconds = 300
timeout_seconds = 15
certificate = ".dev/cert.pem"
private_key = ".dev/key.pem"
frontend_dir = "apps/frontend/dist"
state_dir = ".dev/state"
curl = "curl"
[[categories]]
id = "methanium"
name = "Methanium"
logo = "apps/frontend/public/methanium.svg"
[[categories.services]]
id = "methanium-homepage"
name = "Homepage"
url = "https://methanium.net"
[[categories.services]]
id = "methanium-ui"
name = "Methanium UI"
url = "https://ui.methanium.net"
[[categories.services]]
id = "vector-verdict"
name = "Vector Verdict"
url = "https://vv.methanium.net"
[[categories.services]]
id = "methanium-git"
name = "Git"
url = "https://git.methanium.net"
[[categories.services]]
id = "methanium-legal"
name = "Legal Page"
url = "https://legal.methanium.net"
[[categories]]
id = "tensamin"
name = "Tensamin"
logo = "apps/frontend/public/tensamin.svg"
[[categories.services]]
id = "tensamin-homepage"
name = "Homepage"
url = "https://tensamin.net"
[[categories.services]]
id = "tensamin-app"
name = "Web App"
url = "https://app.tensamin.net"
[[categories.services]]
id = "tensamin-dev"
name = "Web App (Dev)"
url = "https://dev.tensamin.net"
[[categories.services]]
id = "tensamin-docs"
name = "Docs"
url = "https://docs.tensamin.net"
[[categories.services]]
id = "tensamin-omega"
name = "Omega"
url = "https://omega.tensamin.net"
check_http3 = true

62
flake.lock generated Normal file
View file

@ -0,0 +1,62 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1785090369,
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "624af665418d3c65d544145b4d34ad696439570e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1785302874,
"narHash": "sha256-fpKEww3TJoo1ANHO2q918ei+ayOrp0YEQAO1DuBLOB4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "b99d48435bc3e34309d2c7ae6f7d45e77a156c38",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

406
flake.nix Normal file
View file

@ -0,0 +1,406 @@
{
description = "Methanium Status";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs =
{ self, nixpkgs, rust-overlay, ... }:
let
systems = [
"aarch64-darwin"
"aarch64-linux"
"x86_64-darwin"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
pkgsFor =
system:
import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
root = toString ./.;
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-unknown-unknown" ];
};
source = pkgs.lib.cleanSourceWith {
src = ./.;
filter =
path: _type:
let
relative = pkgs.lib.removePrefix "${root}/" (toString path);
in
!(pkgs.lib.hasPrefix ".git/" relative)
&& !(pkgs.lib.hasInfix "node_modules" relative)
&& !(pkgs.lib.hasInfix "target/" relative)
&& !(pkgs.lib.hasInfix "apps/frontend/dist/" relative)
&& relative != "result";
};
frontendNativeBuildInputs = [
pkgs.cacert
pkgs.git
pkgs.nodejs
pkgs.pnpm
pkgs.wasm-pack
rustToolchain
];
frontendDeps = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-status-frontend-deps";
version = "0.1.0";
src = source;
nativeBuildInputs = frontendNativeBuildInputs;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = "sha256-hyNuty4/6MvRxnGIOVsqdUC0B1+LL4yGDjNUkDAUDPY=";
dontFixup = true;
__structuredAttrs = true;
unsafeDiscardReferences.out = true;
buildPhase = ''
runHook preBuild
export HOME="$TMPDIR/home"
export CARGO_HOME="$TMPDIR/cargo"
mkdir -p "$HOME"
pnpm install --frozen-lockfile
patchShebangs node_modules
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/apps/frontend"
cp -a node_modules "$out/node_modules"
cp -a apps/frontend/node_modules "$out/apps/frontend/node_modules"
cp -a "$CARGO_HOME" "$out/cargo"
cp -a "$HOME/.cache/.wasm-pack" "$out/wasm-pack"
runHook postInstall
'';
};
frontend = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-status-frontend";
version = "0.1.0";
src = source;
nativeBuildInputs = frontendNativeBuildInputs;
buildPhase = ''
runHook preBuild
cp -a ${frontendDeps}/node_modules .
cp -a ${frontendDeps}/apps/frontend/node_modules apps/frontend/node_modules
cp -a ${frontendDeps}/cargo "$TMPDIR/cargo"
chmod -R u+w node_modules apps/frontend/node_modules "$TMPDIR/cargo"
export HOME="$TMPDIR/home"
export CARGO_HOME="$TMPDIR/cargo"
export CARGO_NET_OFFLINE=true
mkdir -p "$HOME/.cache"
cp -a ${frontendDeps}/wasm-pack "$HOME/.cache/.wasm-pack"
chmod -R u+w "$HOME/.cache/.wasm-pack"
pnpm build
runHook postBuild
'';
installPhase = ''
runHook preInstall
cp -r apps/frontend/dist "$out"
runHook postInstall
'';
};
backend = pkgs.rustPlatform.buildRustPackage {
pname = "methanium-status";
version = "0.1.0";
src = source;
cargoRoot = "apps/backend";
buildAndTestSubdir = "apps/backend";
MTP_TYPE_MAPS = "${source}/apps/frontend/type-maps.yaml";
cargoLock = {
lockFile = ./apps/backend/Cargo.lock;
outputHashes = {
"mtp-0.2.0" = "sha256-xfF1kRp0kDrGgTcQWN7nZHKgxXizZh4t14uHsS/MYSk=";
};
};
nativeBuildInputs = [ pkgs.cmake ];
};
in
{
default = frontend;
inherit backend frontend;
}
);
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [
"clippy"
"rust-src"
"rustfmt"
];
targets = [ "wasm32-unknown-unknown" ];
};
in
{
default = pkgs.mkShell {
packages = [
pkgs.cmake
pkgs.curl
pkgs.nodejs
pkgs.openssl
pkgs.pnpm
pkgs.wasm-pack
rustToolchain
];
};
}
);
checks = forAllSystems (system: {
inherit (self.packages.${system}) backend frontend;
});
nixosModules.default =
{ config, lib, pkgs, ... }:
let
cfg = config.services.methanium-status;
acmeHost = if cfg.acmeHost == null then cfg.hostName else cfg.acmeHost;
serviceType = lib.types.submodule {
options = {
id = lib.mkOption {
type = lib.types.str;
description = "Stable service identifier.";
};
name = lib.mkOption {
type = lib.types.str;
description = "Service display name.";
};
url = lib.mkOption {
type = lib.types.str;
description = "HTTP or HTTPS URL to check.";
};
checkHttp3 = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether this service must also pass an HTTP/3-only check.";
};
};
};
categoryType = lib.types.submodule {
options = {
id = lib.mkOption {
type = lib.types.str;
description = "Stable category identifier.";
};
name = lib.mkOption {
type = lib.types.str;
description = "Category display name.";
};
logo = lib.mkOption {
type = lib.types.path;
description = "Local SVG, PNG, WebP, or JPEG logo file.";
};
services = lib.mkOption {
type = lib.types.listOf serviceType;
default = [ ];
description = "Services shown and monitored in this category.";
};
};
};
configFile = (pkgs.formats.json { }).generate "methanium-status.json" {
public_address = cfg.publicAddress;
public_port = cfg.publicPort;
admin_address = cfg.adminAddress;
admin_port = cfg.adminPort;
check_interval_seconds = cfg.checkInterval;
timeout_seconds = cfg.timeout;
certificate = "/var/lib/acme/${acmeHost}/fullchain.pem";
private_key = "/var/lib/acme/${acmeHost}/key.pem";
frontend_dir = toString cfg.frontendPackage;
state_dir = "/var/lib/${cfg.stateDirectory}";
curl = "${pkgs.curl}/bin/curl";
categories = map (category: {
inherit (category) id name;
logo = toString category.logo;
services = map (service: {
inherit (service) id name url;
check_http3 = service.checkHttp3;
}) category.services;
}) cfg.categories;
};
in
{
options.services.methanium-status = {
enable = lib.mkEnableOption "Methanium Status monitoring and status page";
package = lib.mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "backend" { };
frontendPackage = lib.mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "frontend" { };
hostName = lib.mkOption {
type = lib.types.str;
default = "status.methanium.net";
description = "Public status page hostname.";
};
acmeHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "ACME certificate name, defaulting to hostName.";
};
publicAddress = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "Address on which the UDP MTP server listens.";
};
publicPort = lib.mkOption {
type = lib.types.port;
default = 443;
description = "Shared nginx TCP and MTP UDP public port.";
};
adminAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the unauthenticated incident editor.";
};
adminPort = lib.mkOption {
type = lib.types.port;
default = 8081;
description = "TCP port for the unauthenticated incident editor.";
};
checkInterval = lib.mkOption {
type = lib.types.ints.positive;
default = 300;
description = "Seconds between service checks.";
};
timeout = lib.mkOption {
type = lib.types.ints.positive;
default = 15;
description = "Per-check timeout in seconds.";
};
stateDirectory = lib.mkOption {
type = lib.types.str;
default = "methanium-status";
description = "systemd state directory name for incident history.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open the public TCP and UDP port in the firewall.";
};
openAdminFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open the unauthenticated admin TCP port. Use with care.";
};
categories = lib.mkOption {
type = lib.types.listOf categoryType;
description = "Status categories and checked services.";
default = [
{
id = "methanium";
name = "Methanium";
logo = ./apps/frontend/public/methanium.svg;
services = [
{ id = "methanium-homepage"; name = "Homepage"; url = "https://methanium.net"; }
{ id = "methanium-ui"; name = "Methanium UI"; url = "https://ui.methanium.net"; }
{ id = "vector-verdict"; name = "Vector Verdict"; url = "https://vv.methanium.net"; }
{ id = "methanium-git"; name = "Git"; url = "https://git.methanium.net"; }
{ id = "methanium-legal"; name = "Legal Page"; url = "https://legal.methanium.net"; }
];
}
{
id = "tensamin";
name = "Tensamin";
logo = ./apps/frontend/public/tensamin.svg;
services = [
{ id = "tensamin-homepage"; name = "Homepage"; url = "https://tensamin.net"; }
{ id = "tensamin-app"; name = "Web App"; url = "https://app.tensamin.net"; }
{ id = "tensamin-dev"; name = "Web App (Dev)"; url = "https://dev.tensamin.net"; }
{ id = "tensamin-docs"; name = "Docs"; url = "https://docs.tensamin.net"; }
{ id = "tensamin-omega"; name = "Omega"; url = "https://omega.tensamin.net"; checkHttp3 = true; }
];
}
];
};
};
config = lib.mkIf cfg.enable {
users.groups.methanium-status = { };
users.users.methanium-status = {
isSystemUser = true;
group = "methanium-status";
};
users.users.nginx.extraGroups = [ "methanium-status" ];
security.acme.certs.${acmeHost} = {
group = "methanium-status";
reloadServices = [ "methanium-status.service" ];
};
services.nginx = {
enable = true;
virtualHosts.${cfg.hostName} = {
forceSSL = true;
enableACME = cfg.acmeHost == null;
useACMEHost = lib.mkIf (cfg.acmeHost != null) acmeHost;
root = cfg.frontendPackage;
listen = [
{ addr = "0.0.0.0"; port = cfg.publicPort; ssl = true; }
{ addr = "[::]"; port = cfg.publicPort; ssl = true; }
];
extraConfig = ''
add_header Alt-Svc 'h3=":${toString cfg.publicPort}"; ma=86400' always;
'';
locations."/".tryFiles = "$uri $uri/ /index.html";
locations."^~ /edit".return = "404";
};
};
systemd.services.methanium-status = {
description = "Methanium Status monitor";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" "acme-${acmeHost}.service" ];
after = [ "network-online.target" "acme-${acmeHost}.service" ];
restartTriggers = [ configFile ];
serviceConfig = {
User = "methanium-status";
Group = "methanium-status";
StateDirectory = cfg.stateDirectory;
ExecStart = "${cfg.package}/bin/methanium-status ${configFile}";
Restart = "on-failure";
RestartSec = 5;
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
UMask = "0077";
};
};
networking.firewall.allowedTCPPorts =
lib.optionals cfg.openFirewall [ cfg.publicPort ]
++ lib.optionals cfg.openAdminFirewall [ cfg.adminPort ];
networking.firewall.allowedUDPPorts = lib.optionals cfg.openFirewall [ cfg.publicPort ];
};
};
};
}

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "methanium-status",
"private": true,
"type": "module",
"packageManager": "pnpm@11.17.0",
"scripts": {
"dev": "pnpm dev:cert && concurrently --kill-others --success first --names frontend,backend --prefix-colors cyan,magenta \"pnpm --filter @methanium/status-frontend dev\" \"pnpm dev:backend\"",
"dev:cert": "mkdir -p .dev && if [ ! -f .dev/cert.pem ] || [ ! -f .dev/key.pem ] || ! openssl x509 -in .dev/cert.pem -checkend 0 -noout >/dev/null 2>&1 || ! openssl x509 -in .dev/cert.pem -noout -text | grep -q 'Public Key Algorithm: id-ecPublicKey'; then openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout .dev/key.pem -out .dev/cert.pem -days 10 -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1; fi && printf 'VITE_MTP_URL=https://127.0.0.1:4433/mtp\\nVITE_MTP_CERT_HASH=%s\\n' \"$(openssl x509 -in .dev/cert.pem -outform DER | openssl dgst -sha256 -r | cut -d ' ' -f1)\" > apps/frontend/.env.local",
"dev:backend": "cargo run --manifest-path apps/backend/Cargo.toml -- config.example.toml",
"build": "pnpm --filter @methanium/status-frontend build",
"check": "tsc -p tsconfig.json && cargo check --manifest-path apps/backend/Cargo.toml",
"lint": "oxlint apps/frontend/src packages/mtp && cargo clippy --manifest-path apps/backend/Cargo.toml -- -D warnings",
"test": "cargo test --manifest-path apps/backend/Cargo.toml",
"fmt": "prettier --write . && cargo fmt --manifest-path apps/backend/Cargo.toml",
"fmt:check": "prettier --check . && cargo fmt --manifest-path apps/backend/Cargo.toml --check"
},
"devDependencies": {
"oxlint": "^1.28.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"@types/node": "^24.0.0",
"concurrently": "^9.2.1"
}
}

357
packages/mtp/context.tsx Normal file
View file

@ -0,0 +1,357 @@
import { toast } from "@methanium/ui";
import {
ConnectionState,
MTPClient,
type MTPClientOptions,
type MTPRequestOptions,
} from "mtp";
import {
createContext,
type JSX,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values.ts";
export type ProtocolMessage = {
id?: number;
type: string;
data: unknown;
sender?: bigint;
receiver?: bigint;
};
export type BoundSendFn = (
type: string,
data?: Record<string, unknown>,
options?: MTPRequestOptions,
) => Promise<ProtocolMessage>;
export type PushHandler = (message: ProtocolMessage) => void;
export type MTPExchange = {
type: string;
data: Record<string, unknown>;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
type Subscription = {
type: string;
handler: PushHandler;
unsubscribe?: () => void;
};
type ClientWaiter = {
resolve: (client: MTPClient) => void;
reject: (error: Error) => void;
};
type ContextType = {
send: BoundSendFn;
subscribe: (type: string, handler: PushHandler) => () => void;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
reconnect: () => void;
readyState: number;
connected: boolean;
authenticated: boolean;
contextReady: boolean;
loadingDescription: string;
error: Error | null;
};
const MTPContext = createContext<ContextType | undefined>(undefined);
export type MTPProviderProps = {
children: ReactNode;
options: MTPClientOptions | null;
authenticate?: boolean;
blockConnection?: boolean;
};
export function Provider({
children,
options,
authenticate = true,
blockConnection = false,
}: MTPProviderProps): JSX.Element {
const [readyState, setReadyState] = useState<number>(
ConnectionState.Disconnected,
);
const [authenticated, setAuthenticated] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [reconnectGeneration, setReconnectGeneration] = useState(0);
const clientRef = useRef<MTPClient | null>(null);
const readyRef = useRef(false);
const waitersRef = useRef(new Set<ClientWaiter>());
const subscriptionsRef = useRef(new Set<Subscription>());
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const unbindSubscriptions = useCallback(() => {
for (const subscription of subscriptionsRef.current) {
subscription.unsubscribe?.();
subscription.unsubscribe = undefined;
}
}, []);
const bindSubscriptions = useCallback((client: MTPClient) => {
for (const subscription of subscriptionsRef.current) {
subscription.unsubscribe?.();
subscription.unsubscribe = client.subscribe(
subscription.type,
(message) => subscription.handler(message as ProtocolMessage),
);
}
}, []);
useEffect(() => {
if (!options || blockConnection) {
readyRef.current = false;
setReadyState(ConnectionState.Disconnected);
setAuthenticated(false);
return;
}
const connectionOptions = options;
let disposed = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
let connectionGeneration = 0;
const disconnectClient = () => {
readyRef.current = false;
unbindSubscriptions();
const activeClient = clientRef.current;
clientRef.current = null;
activeClient?.disconnect();
setReadyState(ConnectionState.Disconnected);
setAuthenticated(false);
};
const scheduleReconnect = (connectionError: Error) => {
if (disposed || reconnectTimer) return;
setError(connectionError);
if (attempts >= RECONNECT_TRIES) {
toast.error("MTP connection failed", {
id: "mtp-connection",
description: connectionError.message,
duration: Infinity,
closeButton: true,
});
return;
}
attempts += 1;
toast.loading(`Reconnecting to MTP (${attempts}/${RECONNECT_TRIES})`, {
id: "mtp-connection",
});
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connect();
}, RETRY_INTERVAL);
};
async function connect() {
if (disposed) return;
const generation = ++connectionGeneration;
let client: MTPClient | null = null;
try {
disconnectClient();
setError(null);
setReadyState(ConnectionState.Connecting);
await MTPClient.init(connectionOptions.wasm);
client = await MTPClient.create({
...connectionOptions,
logger: (event) => {
connectionOptions.logger?.(event);
if (disposed || generation !== connectionGeneration) return;
if ((event as { type: string }).type !== "state") return;
const nextState = client?.state ?? ConnectionState.Disconnected;
setReadyState(nextState);
if (
client !== null &&
nextState === ConnectionState.Disconnected &&
clientRef.current === client
) {
disconnectClient();
scheduleReconnect(new Error("MTP connection lost"));
}
},
});
if (disposed || generation !== connectionGeneration) {
client.disconnect();
return;
}
clientRef.current = client;
setReadyState(client.state);
if (authenticate) {
if (!client.credentials || client.credentials.clientId === null) {
throw new Error("MTP credentials are required for authentication");
}
await client.auth();
} else {
await client.connect();
}
if (disposed || clientRef.current !== client) return;
readyRef.current = true;
setReadyState(client.state);
setAuthenticated(true);
setError(null);
bindSubscriptions(client);
for (const waiter of waitersRef.current) waiter.resolve(client);
waitersRef.current.clear();
toast.dismiss("mtp-connection");
if (reconnectResetTimer) clearTimeout(reconnectResetTimer);
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
} catch (cause) {
if (disposed || generation !== connectionGeneration) {
client?.disconnect();
return;
}
disconnectClient();
scheduleReconnect(
cause instanceof Error ? cause : new Error(String(cause)),
);
}
}
void connect();
return () => {
disposed = true;
connectionGeneration += 1;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (reconnectResetTimer) clearTimeout(reconnectResetTimer);
disconnectClient();
toast.dismiss("mtp-connection");
const cancellation = new Error("MTP provider was disconnected");
for (const waiter of waitersRef.current) waiter.reject(cancellation);
waitersRef.current.clear();
};
}, [
authenticate,
bindSubscriptions,
blockConnection,
options,
reconnectGeneration,
unbindSubscriptions,
]);
const getClient = useCallback(() => {
if (readyRef.current && clientRef.current) {
return Promise.resolve(clientRef.current);
}
if (!options || blockConnection) {
return Promise.reject(new Error("MTP connection is not configured"));
}
return new Promise<MTPClient>((resolve, reject) => {
waitersRef.current.add({ resolve, reject });
});
}, [blockConnection, options]);
const send = useCallback<BoundSendFn>(
async (type, data = {}, requestOptions) => {
const client = await getClient();
const response = await client.request(type, data, requestOptions);
const message = response as ProtocolMessage;
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: message }),
).catch((interceptorError) => {
console.error("MTP interceptor failed", interceptorError);
});
}
return message;
},
[getClient],
);
const subscribe = useCallback((type: string, handler: PushHandler) => {
const subscription: Subscription = { type, handler };
subscriptionsRef.current.add(subscription);
if (readyRef.current && clientRef.current) {
subscription.unsubscribe = clientRef.current.subscribe(type, (message) =>
handler(message as ProtocolMessage),
);
}
return () => {
subscription.unsubscribe?.();
subscriptionsRef.current.delete(subscription);
};
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
const reconnect = useCallback(() => {
setReconnectGeneration((generation) => generation + 1);
}, []);
const connected = readyState === ConnectionState.Connected;
const contextReady = connected && authenticated;
const loadingDescription = useMemo(() => {
if (!options) return "Waiting for connection configuration";
if (error) return error.message;
if (readyState === ConnectionState.Connecting || !connected) {
return "Establishing transport channel";
}
if (!authenticated) return "Waiting for authenticated session";
return "Connected";
}, [authenticated, connected, error, options, readyState]);
return (
<MTPContext.Provider
value={{
send,
subscribe,
addInterceptor,
reconnect,
readyState,
connected,
authenticated,
contextReady,
loadingDescription,
error,
}}
>
{children}
</MTPContext.Provider>
);
}
export function useMTP(): ContextType {
const context = useContext(MTPContext);
if (!context) throw new Error("useMTP must be used within an MTP Provider");
return context;
}

10
packages/mtp/index.ts Normal file
View file

@ -0,0 +1,10 @@
export { Provider, useMTP } from "./context.tsx";
export type {
BoundSendFn,
MTPExchange,
MTPInterceptor,
MTPProviderProps,
ProtocolMessage,
PushHandler,
} from "./context.tsx";
export { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values.ts";

18
packages/mtp/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "@methanium/status-mtp",
"private": true,
"type": "module",
"exports": {
".": "./index.ts"
},
"peerDependencies": {
"@methanium/ui": "*",
"mtp": "*",
"react": "^19.0.0"
},
"devDependencies": {
"@methanium/ui": "*",
"mtp": "*",
"react": "^19.2.0"
}
}

3
packages/mtp/values.ts Normal file
View file

@ -0,0 +1,3 @@
export const RETRY_INTERVAL = 3_000;
export const RECONNECT_TRIES = 3;
export const RECONNECT_RESET = 6;

6991
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

12
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,12 @@
packages:
- apps/*
- packages/*
nodeLinker: hoisted
overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz"
allowBuilds:
esbuild: true

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"paths": {
"@methanium/status-mtp": ["./packages/mtp/index.ts"],
"mtp/raw": ["./apps/frontend/node_modules/.vite/mtp/mtp_wasm.d.ts"],
"mtp/type-map": [
"./apps/frontend/node_modules/.vite/mtp/mtp_type_map.d.ts"
]
},
"skipLibCheck": true,
"strict": true,
"target": "ES2023",
"types": ["node", "vite/client"],
"useDefineForClassFields": true
},
"include": [
"apps/frontend/src",
"apps/frontend/vite.config.ts",
"packages/mtp"
]
}