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

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",
),
},
},
});