Add basic status page

This commit is contained in:
Alois 2026-07-29 21:52:32 +02:00
commit de54eb0b90
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
43 changed files with 6080 additions and 244 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 }

2
.gitignore vendored
View file

@ -3,3 +3,5 @@ dist/
.vite/
.direnv/
result
.dev/
.env.local

View file

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

View file

@ -1,47 +1,86 @@
# Methanium Template
# Methanium Status
A pnpm React template with Vite, Tailwind CSS, Methanium UI, MTP, TanStack Router, and a base nix flake.
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
```
## Configure MTP
`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.
1. Define the host's communication and data types in `apps/web/type-maps.yaml`.
2. Set the connection options in `apps/web/src/mtp-options.ts`.
The reusable provider and `useMTP` hook are in `packages/mtp/`.
## Nix
Build the static frontend:
For a manual backend configuration, copy `config.example.toml`, update the certificate paths, build the frontend, and run:
```sh
nix build
pnpm build
cargo run --manifest-path apps/backend/Cargo.toml -- ./config.toml
```
To serve it with nginx on NixOS, import the included module:
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-template.url =
"git+https://git.methanium.net/methanium/template";
inputs.methanium-status.url =
"git+https://git.methanium.net/methanium/status";
outputs = {nixpkgs, methanium-template, ...}: {
outputs = { nixpkgs, methanium-status, ... }: {
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
methanium-template.nixosModules.default
methanium-status.nixosModules.default
{
services.methanium-template = {
security.acme.acceptTerms = true;
security.acme.defaults.email = "admin@example.com";
services.methanium-status = {
enable = true;
hostName = "app.example.com";
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;
}];
}];
};
}
];
@ -49,3 +88,9 @@ To serve it with nginx on NixOS, import the included module:
};
}
```
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"

76
apps/backend/src/admin.rs Normal file
View file

@ -0,0 +1,76 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{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}/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),
}
}
fn error_response(error: IncidentError) -> Response {
let status = match error {
IncidentError::Empty => StatusCode::BAD_REQUEST,
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,128 @@
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("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)
}
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)));
}
}

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>
}

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171e26" />
<title>Methanium Vite Template</title>
<title>Methanium Status</title>
<style>
html.dark,
html.dark body {

View file

@ -1,5 +1,5 @@
{
"name": "@methanium/template-web",
"name": "@methanium/status-frontend",
"private": true,
"type": "module",
"scripts": {
@ -8,7 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"@methanium/template-mtp": "workspace:*",
"@methanium/status-mtp": "workspace:*",
"@methanium/ui": "*",
"@tanstack/react-router": "^1.131.35",
"lucide-react": "^1.21.0",

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

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

@ -1,11 +1,12 @@
import { Moon, Sun } from "lucide-react";
import { ExternalLink, Moon, Sun } from "lucide-react";
import { Button, useTheme } from "@methanium/ui";
const homepageUrl = "https://methanium.net";
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 (
@ -29,13 +30,27 @@ export function Header() {
nativeButton={false}
variant="ghost"
size="sm"
className="h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
render={<a href={homepageUrl} />}
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" />}
>
Homepage
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-1">
<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"

View file

@ -1,7 +1,7 @@
import "./styles.css";
import { BUILT_IN_THEMES, ThemeProvider } from "@methanium/ui";
import { Provider as MTPProvider } from "@methanium/template-mtp";
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";
@ -24,7 +24,7 @@ createRoot(document.getElementById("root")!).render(
themes={BUILT_IN_THEMES}
defaultParentThemeId="methanium"
>
<MTPProvider options={mtpOptions}>
<MTPProvider options={mtpOptions} authenticate={false}>
<RouterProvider router={router} />
</MTPProvider>
</ThemeProvider>

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

@ -10,33 +10,43 @@
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: '/'
fullPaths: '/' | '/edit'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/edit'
id: '__root__' | '/' | '/edit'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
EditRoute: typeof EditRoute
}
declare module '@tanstack/react-router' {
@ -48,11 +58,19 @@ declare module '@tanstack/react-router' {
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)

View file

@ -3,7 +3,9 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
import { Header } from "../components/layout/Header";
const EmptyPage = () => (
<main className="min-h-[calc(100vh-3rem)] bg-background" />
<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({

View file

@ -0,0 +1,250 @@
import { Button } from "@methanium/ui";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { CheckCircle2, Loader2, ShieldAlert } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import type { Incident, Severity } from "../status";
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,
});
function IncidentEditor() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
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();
setSubmitting(true);
const form = new FormData(event.currentTarget);
try {
const response = await fetch("/api/incidents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: form.get("title"),
message: form.get("message"),
severity: form.get("severity"),
}),
});
if (!response.ok) {
const body = (await response.json()) as { error?: string };
throw new Error(
body.error ?? `Request failed with HTTP ${response.status}`,
);
}
event.currentTarget.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));
}
}
const active = incidents.filter((incident) => !incident.resolvedAt);
const resolved = incidents
.filter((incident) => incident.resolvedAt)
.reverse();
return (
<main className="status-canvas min-h-[calc(100vh-3.5rem)]">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
<div className="mb-8 flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-red-500/10 text-red-500">
<ShieldAlert className="size-5" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Unprotected administration
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">
Incident editor
</h1>
<p className="mt-2 text-sm text-muted-foreground">
This interface has no login and should only be exposed on a
trusted network.
</p>
</div>
</div>
{error && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-600 dark:text-red-300">
{error}
</div>
)}
<div className="grid gap-8 lg:grid-cols-[22rem_1fr]">
<form
onSubmit={create}
className="h-fit rounded-2xl border border-border bg-card/80 p-5 shadow-sm"
>
<h2 className="font-semibold">Create incident</h2>
<label
className="mt-5 block text-xs font-semibold text-muted-foreground"
htmlFor="title"
>
Title
</label>
<input
required
id="title"
name="title"
maxLength={120}
className="mt-2 h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<label
className="mt-4 block text-xs font-semibold text-muted-foreground"
htmlFor="message"
>
Message
</label>
<textarea
required
id="message"
name="message"
maxLength={2000}
rows={5}
className="mt-2 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<label
className="mt-4 block text-xs font-semibold text-muted-foreground"
htmlFor="severity"
>
Severity
</label>
<select
id="severity"
name="severity"
defaultValue="minor"
className="mt-2 h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
>
{(["minor", "major", "critical"] as Severity[]).map(
(severity) => (
<option key={severity} value={severity}>
{severity[0].toUpperCase() + severity.slice(1)}
</option>
),
)}
</select>
<Button type="submit" disabled={submitting} className="mt-5 w-full">
{submitting && <Loader2 className="size-4 animate-spin" />}Publish
incident
</Button>
</form>
<div>
<h2 className="mb-3 flex items-center gap-2 font-semibold">
Active incidents{" "}
<span className="text-xs text-muted-foreground">
{active.length}
</span>
</h2>
<div className="space-y-3">
{loading && (
<div className="flex items-center gap-2 rounded-xl border border-border bg-card p-5 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-border p-8 text-center text-sm text-muted-foreground">
No active incidents
</div>
)}
{active.map((incident) => (
<article
key={incident.id}
className="rounded-xl border border-border bg-card/80 p-5"
>
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em] text-red-500">
{incident.severity}
</span>
<h3 className="mt-1 font-semibold">{incident.title}</h3>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void resolve(incident.id)}
>
<CheckCircle2 className="size-4" />
Resolve
</Button>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{incident.message}
</p>
</article>
))}
</div>
{resolved.length > 0 && (
<>
<h2 className="mb-3 mt-8 font-semibold">Resolved history</h2>
<div className="space-y-2">
{resolved.map((incident) => (
<div
key={incident.id}
className="rounded-lg border border-border/70 bg-card/50 px-4 py-3"
>
<div className="flex justify-between gap-3">
<span className="text-sm font-medium">
{incident.title}
</span>
<span className="text-xs text-muted-foreground">
{new Date(incident.resolvedAt!).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
</main>
);
}

View file

@ -0,0 +1,302 @@
import { createFileRoute } from "@tanstack/react-router";
import {
AlertTriangle,
Check,
Clock3,
ExternalLink,
RefreshCw,
ShieldAlert,
WifiOff,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Incident, Service, ServiceStatus, Severity } 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 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 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>
<div className="grid w-full grid-cols-1 justify-center gap-6 pb-10 sm:grid-cols-[repeat(auto-fit,24rem)]">
{activeIncidents.map((incident) => (
<ActiveIncidentCard 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>
{!!snapshot?.incidents.some((incident) => incident.resolvedAt) && (
<section className="mt-12" aria-labelledby="resolved-incidents">
<h2 id="resolved-incidents" className="mb-4 text-lg font-semibold">
Recently resolved
</h2>
<div className="space-y-3">
{snapshot.incidents
.filter((incident) => incident.resolvedAt)
.reverse()
.slice(0, 5)
.map((incident) => (
<IncidentCard key={incident.id} incident={incident} resolved />
))}
</div>
</section>
)}
</main>
);
}
function ActiveIncidentCard({ 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 started {formatTime(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>Started {formatTime(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>
);
}
const severityStyles: Record<Severity, string> = {
minor:
"border-amber-500/30 bg-amber-500/[0.07] text-amber-700 dark:text-amber-300",
major:
"border-orange-500/30 bg-orange-500/[0.07] text-orange-700 dark:text-orange-300",
critical:
"border-red-500/35 bg-red-500/[0.08] text-red-700 dark:text-red-300",
};
function IncidentCard({
incident,
resolved = false,
}: {
incident: Incident;
resolved?: boolean;
}) {
return (
<article
className={`rounded-xl border p-5 ${resolved ? "border-border bg-card/60" : severityStyles[incident.severity]}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{incident.title}</h3>
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em]">
{resolved ? "Resolved" : incident.severity}
</span>
</div>
<p
className={`mt-2 text-sm leading-6 ${resolved ? "text-muted-foreground" : "text-foreground/80"}`}
>
{incident.message}
</p>
<p className="mt-3 text-xs text-muted-foreground">
{resolved && incident.resolvedAt
? `Resolved ${formatTime(incident.resolvedAt)}`
: `Started ${formatTime(incident.createdAt)}`}
</p>
</article>
);
}

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

@ -3,6 +3,7 @@ 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";
@ -10,6 +11,12 @@ 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(),
@ -19,7 +26,7 @@ export default defineConfig({
],
resolve: {
alias: {
"@methanium/template-mtp": path.resolve(
"@methanium/status-mtp": path.resolve(
root,
"../../packages/mtp/index.ts",
),

View file

@ -1,13 +0,0 @@
import type { MTPClientOptions } from "mtp";
// Replace null with your application's stable MTP connection configuration.
export const mtpOptions: MTPClientOptions | null = null;
// Example:
// export const mtpOptions: MTPClientOptions = {
// url: "https://mtp.example.com:4433",
// credentials: { clientId: 1, keyring: new Uint8Array([...]) },
// hostPublicKey: "...",
// descriptor: "web",
// pings: true,
// };

View file

@ -1,7 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: Home });
function Home() {
return <main className="min-h-[calc(100vh-3rem)] bg-background" />;
}

View file

@ -1,2 +0,0 @@
@import "tailwindcss";
@import "@methanium/ui/index.css";

View file

@ -1,10 +0,0 @@
protocol_version: "1.0"
type_maps:
"1.0":
CommunicationTypes:
ExampleRequest: 32
ExampleResponse: 33
DataTypes:
Message: 32
ReceivedAt: 33

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

323
flake.nix
View file

@ -1,5 +1,5 @@
{
description = "Methanium Template";
description = "Methanium Status";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@ -7,12 +7,7 @@
};
outputs =
inputs@{
self,
nixpkgs,
rust-overlay,
...
}:
{ self, nixpkgs, rust-overlay, ... }:
let
systems = [
"aarch64-darwin"
@ -44,15 +39,13 @@
let
relative = pkgs.lib.removePrefix "${root}/" (toString path);
in
!(pkgs.lib.hasPrefix "node_modules/" relative)
&& !(pkgs.lib.hasPrefix "apps/web/node_modules/" relative)
&& !(pkgs.lib.hasPrefix "apps/web/dist/" relative)
&& relative != "node_modules"
&& relative != "apps/web/node_modules"
&& relative != "apps/web/dist"
!(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";
};
nativeBuildInputs = [
frontendNativeBuildInputs = [
pkgs.cacert
pkgs.git
pkgs.nodejs
@ -60,22 +53,14 @@
pkgs.wasm-pack
rustToolchain
];
webDeps = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-template-web-deps";
frontendDeps = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-status-frontend-deps";
version = "0.1.0";
src = source;
nativeBuildInputs = [
pkgs.cacert
pkgs.git
pkgs.nodejs
pkgs.pnpm
pkgs.wasm-pack
rustToolchain
];
nativeBuildInputs = frontendNativeBuildInputs;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = "sha256-ihavd0hJDptdyEtjWjVgghwAl/rn6aUdQ9CT3B4if3E=";
outputHash = "sha256-hyNuty4/6MvRxnGIOVsqdUC0B1+LL4yGDjNUkDAUDPY=";
dontFixup = true;
__structuredAttrs = true;
unsafeDiscardReferences.out = true;
@ -93,31 +78,31 @@
installPhase = ''
runHook preInstall
mkdir -p "$out/apps/web"
mkdir -p "$out/apps/frontend"
cp -a node_modules "$out/node_modules"
cp -a apps/web/node_modules "$out/apps/web/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
'';
};
web = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-template-web";
frontend = pkgs.stdenvNoCC.mkDerivation {
pname = "methanium-status-frontend";
version = "0.1.0";
src = source;
inherit nativeBuildInputs;
nativeBuildInputs = frontendNativeBuildInputs;
buildPhase = ''
runHook preBuild
cp -a ${webDeps}/node_modules .
cp -a ${webDeps}/apps/web/node_modules apps/web/node_modules
cp -a ${webDeps}/cargo "$TMPDIR/cargo"
chmod -R u+w node_modules apps/web/node_modules "$TMPDIR/cargo"
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 ${webDeps}/wasm-pack "$HOME/.cache/.wasm-pack"
cp -a ${frontendDeps}/wasm-pack "$HOME/.cache/.wasm-pack"
chmod -R u+w "$HOME/.cache/.wasm-pack"
pnpm build
runHook postBuild
@ -125,14 +110,29 @@
installPhase = ''
runHook preInstall
cp -r apps/web/dist "$out"
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 = web;
web = web;
default = frontend;
inherit backend frontend;
}
);
@ -141,14 +141,21 @@
let
pkgs = pkgsFor system;
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
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
@ -158,53 +165,241 @@
);
checks = forAllSystems (system: {
inherit (self.packages.${system}) web;
inherit (self.packages.${system}) backend frontend;
});
nixosModules.default =
{
config,
lib,
pkgs,
...
}:
{ config, lib, pkgs, ... }:
let
cfg = config.services.methanium-template;
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-template = {
enable = lib.mkEnableOption "the Methanium frontend";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "self.packages.${pkgs.stdenv.hostPlatform.system}.default";
description = "Static frontend package to serve.";
};
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 = "localhost";
description = "nginx virtual host name.";
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 = "Whether to open TCP port 80 in the firewall.";
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} = {
root = cfg.package;
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";
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 ];
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 ];
};
};
};

View file

@ -1,20 +1,24 @@
{
"name": "methanium-template",
"name": "methanium-status",
"private": true,
"type": "module",
"packageManager": "pnpm@11.17.0",
"scripts": {
"dev": "pnpm --filter @methanium/template-web dev",
"build": "pnpm --filter @methanium/template-web build",
"check": "tsc -p tsconfig.json",
"lint": "oxlint apps/web/src packages/mtp",
"fmt": "prettier --write .",
"fmt:check": "prettier --check ."
"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"
"@types/node": "^24.0.0",
"concurrently": "^9.2.1"
}
}

View file

@ -1,5 +1,5 @@
{
"name": "@methanium/template-mtp",
"name": "@methanium/status-mtp",
"private": true,
"type": "module",
"exports": {

430
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ importers:
"@types/node":
specifier: ^24.0.0
version: 24.13.3
concurrently:
specifier: ^9.2.1
version: 9.2.4
oxlint:
specifier: ^1.28.0
version: 1.76.0
@ -24,14 +27,14 @@ importers:
specifier: ^5.9.3
version: 5.9.3
apps/web:
apps/frontend:
dependencies:
"@methanium/template-mtp":
"@methanium/status-mtp":
specifier: workspace:*
version: link:../../packages/mtp
"@methanium/ui":
specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(typescript@5.9.3)
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@8.1.1)(typescript@5.9.3)
"@tanstack/react-router":
specifier: ^1.131.35
version: 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@ -53,7 +56,7 @@ importers:
version: 4.3.3(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
"@tanstack/router-plugin":
specifier: ^1.131.35
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
"@types/react":
specifier: ^19.2.0
version: 19.2.17
@ -62,7 +65,7 @@ importers:
version: 19.2.3(@types/react@19.2.17)
"@vitejs/plugin-react":
specifier: ^5.0.4
version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
version: 5.2.0(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
tailwindcss:
specifier: ^4.3.1
version: 4.3.3
@ -74,7 +77,7 @@ importers:
dependencies:
"@methanium/ui":
specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(typescript@5.9.3)
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@8.1.1)(typescript@5.9.3)
mtp:
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz
version: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz
@ -1783,6 +1786,13 @@ packages:
}
engines: { node: ">=12" }
ansi-styles@4.3.0:
resolution:
{
integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==,
}
engines: { node: ">=8" }
ansis@4.3.1:
resolution:
{
@ -1908,6 +1918,13 @@ packages:
integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==,
}
chalk@4.1.2:
resolution:
{
integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==,
}
engines: { node: ">=10" }
chalk@5.6.2:
resolution:
{
@ -1942,6 +1959,13 @@ packages:
}
engines: { node: ">=6" }
cliui@8.0.1:
resolution:
{
integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==,
}
engines: { node: ">=12" }
clsx@2.1.1:
resolution:
{
@ -1964,6 +1988,19 @@ packages:
integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==,
}
color-convert@2.0.1:
resolution:
{
integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==,
}
engines: { node: ">=7.0.0" }
color-name@1.1.4:
resolution:
{
integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==,
}
commander@11.1.0:
resolution:
{
@ -1978,6 +2015,14 @@ packages:
}
engines: { node: ">=20" }
concurrently@9.2.4:
resolution:
{
integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==,
}
engines: { node: ">=18" }
hasBin: true
conf@10.2.0:
resolution:
{
@ -2308,6 +2353,12 @@ packages:
integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==,
}
emoji-regex@8.0.0:
resolution:
{
integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==,
}
encodeurl@2.0.0:
resolution:
{
@ -2568,6 +2619,13 @@ packages:
}
engines: { node: ">=6.9.0" }
get-caller-file@2.0.5:
resolution:
{
integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==,
}
engines: { node: 6.* || 8.* || >= 10.* }
get-east-asian-width@1.6.0:
resolution:
{
@ -2637,6 +2695,13 @@ packages:
integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==,
}
has-flag@4.0.0:
resolution:
{
integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==,
}
engines: { node: ">=8" }
has-symbols@1.1.0:
resolution:
{
@ -2777,6 +2842,13 @@ packages:
}
engines: { node: ">=0.10.0" }
is-fullwidth-code-point@3.0.0:
resolution:
{
integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==,
}
engines: { node: ">=8" }
is-glob@4.0.3:
resolution:
{
@ -3707,6 +3779,13 @@ packages:
integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==,
}
require-directory@2.1.1:
resolution:
{
integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==,
}
engines: { node: ">=0.10.0" }
require-from-string@2.0.2:
resolution:
{
@ -3775,6 +3854,12 @@ packages:
integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==,
}
rxjs@7.8.2:
resolution:
{
integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==,
}
safer-buffer@2.1.2:
resolution:
{
@ -3860,6 +3945,13 @@ packages:
}
engines: { node: ">=8" }
shell-quote@1.9.0:
resolution:
{
integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==,
}
engines: { node: ">= 0.4" }
side-channel-list@1.0.1:
resolution:
{
@ -3944,6 +4036,13 @@ packages:
}
engines: { node: ">=18" }
string-width@4.2.3:
resolution:
{
integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==,
}
engines: { node: ">=8" }
string-width@7.2.0:
resolution:
{
@ -3993,6 +4092,20 @@ packages:
}
engines: { node: ">=18" }
supports-color@7.2.0:
resolution:
{
integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==,
}
engines: { node: ">=8" }
supports-color@8.1.1:
resolution:
{
integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==,
}
engines: { node: ">=10" }
systeminformation@5.33.1:
resolution:
{
@ -4048,6 +4161,13 @@ packages:
}
engines: { node: ">=0.6" }
tree-kill@1.2.2:
resolution:
{
integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==,
}
hasBin: true
ts-morph@26.0.0:
resolution:
{
@ -4301,6 +4421,13 @@ packages:
engines: { node: ^16.13.0 || >=18.0.0 }
hasBin: true
wrap-ansi@7.0.0:
resolution:
{
integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==,
}
engines: { node: ">=10" }
wrappy@1.0.2:
resolution:
{
@ -4314,6 +4441,13 @@ packages:
}
engines: { node: ">=20" }
y18n@5.0.8:
resolution:
{
integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==,
}
engines: { node: ">=10" }
yallist@3.1.1:
resolution:
{
@ -4328,6 +4462,20 @@ packages:
engines: { node: ">= 14.6" }
hasBin: true
yargs-parser@21.1.1:
resolution:
{
integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==,
}
engines: { node: ">=12" }
yargs@17.7.2:
resolution:
{
integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==,
}
engines: { node: ">=12" }
yocto-spinner@1.2.2:
resolution:
{
@ -4371,20 +4519,20 @@ snapshots:
"@babel/compat-data@7.29.7": {}
"@babel/core@7.29.7":
"@babel/core@7.29.7(supports-color@8.1.1)":
dependencies:
"@babel/code-frame": 7.29.7
"@babel/generator": 7.29.7
"@babel/helper-compilation-targets": 7.29.7
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
"@babel/helpers": 7.29.7
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@8.1.1)
"@babel/types": 7.29.7
"@jridgewell/remapping": 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -4411,41 +4559,41 @@ snapshots:
lru-cache: 5.1.1
semver: 6.3.1
"@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)":
"@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-annotate-as-pure": 7.29.7
"@babel/helper-member-expression-to-functions": 7.29.7
"@babel/helper-member-expression-to-functions": 7.29.7(supports-color@8.1.1)
"@babel/helper-optimise-call-expression": 7.29.7
"@babel/helper-replace-supers": 7.29.7(@babel/core@7.29.7)
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7
"@babel/traverse": 7.29.7
"@babel/helper-replace-supers": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7(supports-color@8.1.1)
"@babel/traverse": 7.29.7(supports-color@8.1.1)
semver: 6.3.1
transitivePeerDependencies:
- supports-color
"@babel/helper-globals@7.29.7": {}
"@babel/helper-member-expression-to-functions@7.29.7":
"@babel/helper-member-expression-to-functions@7.29.7(supports-color@8.1.1)":
dependencies:
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@8.1.1)
"@babel/types": 7.29.7
transitivePeerDependencies:
- supports-color
"@babel/helper-module-imports@7.29.7":
"@babel/helper-module-imports@7.29.7(supports-color@8.1.1)":
dependencies:
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@8.1.1)
"@babel/types": 7.29.7
transitivePeerDependencies:
- supports-color
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)":
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
dependencies:
"@babel/core": 7.29.7
"@babel/helper-module-imports": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-module-imports": 7.29.7(supports-color@8.1.1)
"@babel/helper-validator-identifier": 7.29.7
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -4455,18 +4603,18 @@ snapshots:
"@babel/helper-plugin-utils@7.29.7": {}
"@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)":
"@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
dependencies:
"@babel/core": 7.29.7
"@babel/helper-member-expression-to-functions": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-member-expression-to-functions": 7.29.7(supports-color@8.1.1)
"@babel/helper-optimise-call-expression": 7.29.7
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
"@babel/helper-skip-transparent-expression-wrappers@7.29.7":
"@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@8.1.1)":
dependencies:
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@8.1.1)
"@babel/types": 7.29.7
transitivePeerDependencies:
- supports-color
@ -4486,53 +4634,53 @@ snapshots:
dependencies:
"@babel/types": 7.29.7
"@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-plugin-utils": 7.29.7
"@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-plugin-utils": 7.29.7
"@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
dependencies:
"@babel/core": 7.29.7
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
"@babel/helper-plugin-utils": 7.29.7
transitivePeerDependencies:
- supports-color
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-plugin-utils": 7.29.7
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-plugin-utils": 7.29.7
"@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)":
"@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-annotate-as-pure": 7.29.7
"@babel/helper-create-class-features-plugin": 7.29.7(@babel/core@7.29.7)
"@babel/helper-create-class-features-plugin": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
"@babel/helper-plugin-utils": 7.29.7
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7
"@babel/plugin-syntax-typescript": 7.29.7(@babel/core@7.29.7)
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7(supports-color@8.1.1)
"@babel/plugin-syntax-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
transitivePeerDependencies:
- supports-color
"@babel/preset-typescript@7.29.7(@babel/core@7.29.7)":
"@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/helper-plugin-utils": 7.29.7
"@babel/helper-validator-option": 7.29.7
"@babel/plugin-syntax-jsx": 7.29.7(@babel/core@7.29.7)
"@babel/plugin-transform-modules-commonjs": 7.29.7(@babel/core@7.29.7)
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7)
"@babel/plugin-syntax-jsx": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
"@babel/plugin-transform-modules-commonjs": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -4544,7 +4692,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/types": 7.29.7
"@babel/traverse@7.29.7":
"@babel/traverse@7.29.7(supports-color@8.1.1)":
dependencies:
"@babel/code-frame": 7.29.7
"@babel/generator": 7.29.7
@ -4552,7 +4700,7 @@ snapshots:
"@babel/parser": 7.29.7
"@babel/template": 7.29.7
"@babel/types": 7.29.7
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -4725,7 +4873,7 @@ snapshots:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.5
"@methanium/ui@https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(typescript@5.9.3)":
"@methanium/ui@https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@8.1.1)(typescript@5.9.3)":
dependencies:
"@base-ui/react": 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
"@fontsource-variable/public-sans": 5.3.0
@ -4741,7 +4889,7 @@ snapshots:
react-dom: 19.2.8(react@19.2.8)
react-resizable-panels: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
recharts: 3.8.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)
shadcn: 4.16.0(typescript@5.9.3)
shadcn: 4.16.0(supports-color@8.1.1)(typescript@5.9.3)
sonner: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
tailwind-merge: 3.6.0
tw-animate-css: 1.4.0
@ -4758,7 +4906,7 @@ snapshots:
- supports-color
- typescript
"@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)":
"@modelcontextprotocol/sdk@1.30.0(supports-color@8.1.1)(zod@3.25.76)":
dependencies:
"@hono/node-server": 2.0.12(hono@4.12.32)
ajv: 8.20.0
@ -4768,8 +4916,8 @@ snapshots:
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.1.0
express: 5.2.1
express-rate-limit: 8.6.1(express@5.2.1)
express: 5.2.1(supports-color@8.1.1)
express-rate-limit: 8.6.1(express@5.2.1(supports-color@8.1.1))(supports-color@8.1.1)
hono: 4.12.32
jose: 6.2.4
json-schema-typed: 8.0.2
@ -5176,11 +5324,11 @@ snapshots:
seroval: 1.5.6
seroval-plugins: 1.5.6(seroval@1.5.6)
"@tanstack/router-generator@1.167.21":
"@tanstack/router-generator@1.167.21(supports-color@8.1.1)":
dependencies:
"@babel/types": 7.29.7
"@tanstack/router-core": 1.171.15
"@tanstack/router-utils": 1.162.2
"@tanstack/router-utils": 1.162.2(supports-color@8.1.1)
"@tanstack/virtual-file-routes": 1.162.0
jiti: 2.7.0
magic-string: 0.30.21
@ -5189,14 +5337,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
"@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
"@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/template": 7.29.7
"@babel/types": 7.29.7
"@tanstack/router-core": 1.171.15
"@tanstack/router-generator": 1.167.21
"@tanstack/router-utils": 1.162.2
"@tanstack/router-generator": 1.167.21(supports-color@8.1.1)
"@tanstack/router-utils": 1.162.2(supports-color@8.1.1)
chokidar: 5.0.0
unplugin: 3.3.0(esbuild@0.28.1)(rollup@4.62.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
zod: 4.4.3
@ -5213,13 +5361,13 @@ snapshots:
- supports-color
- unloader
"@tanstack/router-utils@1.162.2":
"@tanstack/router-utils@1.162.2(supports-color@8.1.1)":
dependencies:
"@babel/generator": 7.29.7
"@babel/parser": 7.29.7
"@babel/types": 7.29.7
ansis: 4.3.1
babel-dead-code-elimination: 1.0.12
babel-dead-code-elimination: 1.0.12(supports-color@8.1.1)
diff: 8.0.4
pathe: 2.0.3
tinyglobby: 0.2.17
@ -5301,11 +5449,11 @@ snapshots:
"@types/validate-npm-package-name@4.0.2": {}
"@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
"@vitejs/plugin-react@5.2.0(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
dependencies:
"@babel/core": 7.29.7
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7)
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7)
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
"@rolldown/pluginutils": 1.0.0-rc.3
"@types/babel__core": 7.20.5
react-refresh: 0.18.0
@ -5339,6 +5487,10 @@ snapshots:
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansis@4.3.1: {}
argparse@2.0.1: {}
@ -5353,11 +5505,11 @@ snapshots:
atomically@1.7.0: {}
babel-dead-code-elimination@1.0.12:
babel-dead-code-elimination@1.0.12(supports-color@8.1.1):
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/parser": 7.29.7
"@babel/traverse": 7.29.7
"@babel/traverse": 7.29.7(supports-color@8.1.1)
"@babel/types": 7.29.7
transitivePeerDependencies:
- supports-color
@ -5366,11 +5518,11 @@ snapshots:
baseline-browser-mapping@2.11.5: {}
body-parser@2.3.0:
body-parser@2.3.0(supports-color@8.1.1):
dependencies:
bytes: 3.1.2
content-type: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
http-errors: 2.0.1
iconv-lite: 0.7.3
on-finished: 2.4.1
@ -5416,6 +5568,11 @@ snapshots:
caniuse-lite@1.0.30001806: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {}
chokidar@5.0.0:
@ -5432,6 +5589,12 @@ snapshots:
cli-spinners@2.9.2: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
@ -5448,10 +5611,25 @@ snapshots:
code-block-writer@13.0.3: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
commander@11.1.0: {}
commander@14.0.3: {}
concurrently@9.2.4:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.9.0
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
conf@10.2.0:
dependencies:
ajv: 8.20.0
@ -5545,9 +5723,11 @@ snapshots:
dependencies:
mimic-fn: 3.1.0
debug@4.4.3:
debug@4.4.3(supports-color@8.1.1):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 8.1.1
decimal.js-light@2.5.1: {}
@ -5604,6 +5784,8 @@ snapshots:
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
encodeurl@2.0.0: {}
enhanced-resolve@5.24.3:
@ -5704,28 +5886,28 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.2.0
express-rate-limit@8.6.1(express@5.2.1):
express-rate-limit@8.6.1(express@5.2.1(supports-color@8.1.1))(supports-color@8.1.1):
dependencies:
debug: 4.4.3
express: 5.2.1
debug: 4.4.3(supports-color@8.1.1)
express: 5.2.1(supports-color@8.1.1)
ip-address: 10.3.1
transitivePeerDependencies:
- supports-color
express@5.2.1:
express@5.2.1(supports-color@8.1.1):
dependencies:
accepts: 2.0.0
body-parser: 2.3.0
body-parser: 2.3.0(supports-color@8.1.1)
content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.1
finalhandler: 2.1.1(supports-color@8.1.1)
fresh: 2.0.0
http-errors: 2.0.1
merge-descriptors: 2.0.0
@ -5736,9 +5918,9 @@ snapshots:
proxy-addr: 2.0.7
qs: 6.15.3
range-parser: 1.3.0
router: 2.2.0
send: 1.2.1
serve-static: 2.2.1
router: 2.2.0(supports-color@8.1.1)
send: 1.2.1(supports-color@8.1.1)
serve-static: 2.2.1(supports-color@8.1.1)
statuses: 2.0.2
type-is: 2.1.0
vary: 1.1.2
@ -5773,9 +5955,9 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
finalhandler@2.1.1:
finalhandler@2.1.1(supports-color@8.1.1):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@ -5807,6 +5989,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0:
@ -5846,6 +6030,8 @@ snapshots:
graceful-fs@4.2.11: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
hasown@2.0.4:
@ -5902,6 +6088,8 @@ snapshots:
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
@ -6365,6 +6553,8 @@ snapshots:
redux@5.0.1: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
reselect@5.1.1: {}
@ -6411,9 +6601,9 @@ snapshots:
"@rollup/rollup-win32-x64-msvc": 4.62.3
fsevents: 2.3.3
router@2.2.0:
router@2.2.0(supports-color@8.1.1):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@ -6427,6 +6617,10 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
safer-buffer@2.1.2: {}
scheduler@0.27.0: {}
@ -6435,9 +6629,9 @@ snapshots:
semver@7.8.5: {}
send@1.2.1:
send@1.2.1(supports-color@8.1.1):
dependencies:
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@ -6457,25 +6651,25 @@ snapshots:
seroval@1.5.6: {}
serve-static@2.2.1:
serve-static@2.2.1(supports-color@8.1.1):
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.1
send: 1.2.1(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
setprototypeof@1.2.0: {}
shadcn@4.16.0(typescript@5.9.3):
shadcn@4.16.0(supports-color@8.1.1)(typescript@5.9.3):
dependencies:
"@babel/core": 7.29.7
"@babel/core": 7.29.7(supports-color@8.1.1)
"@babel/parser": 7.29.7
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7)
"@babel/preset-typescript": 7.29.7(@babel/core@7.29.7)
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
"@babel/preset-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
"@dotenvx/dotenvx": 1.75.1
"@modelcontextprotocol/sdk": 1.30.0(zod@3.25.76)
"@modelcontextprotocol/sdk": 1.30.0(supports-color@8.1.1)(zod@3.25.76)
"@types/validate-npm-package-name": 4.0.2
browserslist: 4.28.7
commander: 14.0.3
@ -6514,6 +6708,8 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.9.0: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@ -6561,6 +6757,12 @@ snapshots:
stdin-discarder@0.2.2: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
@ -6587,6 +6789,14 @@ snapshots:
strip-final-newline@4.0.0: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
systeminformation@5.33.1: {}
tailwind-merge@3.6.0: {}
@ -6608,6 +6818,8 @@ snapshots:
toidentifier@1.0.1: {}
tree-kill@1.2.2: {}
ts-morph@26.0.0:
dependencies:
"@ts-morph/common": 0.27.0
@ -6733,6 +6945,12 @@ snapshots:
dependencies:
isexe: 3.1.5
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
wsl-utils@0.3.1:
@ -6740,10 +6958,24 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
y18n@5.0.8: {}
yallist@3.1.1: {}
yaml@2.9.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-spinner@1.2.2:
dependencies:
yoctocolors: 2.2.0

View file

@ -7,15 +7,21 @@
"moduleResolution": "Bundler",
"noEmit": true,
"paths": {
"@methanium/template-mtp": ["./packages/mtp/index.ts"],
"mtp/raw": ["./apps/web/node_modules/.vite/mtp/mtp_wasm.d.ts"],
"mtp/type-map": ["./apps/web/node_modules/.vite/mtp/mtp_type_map.d.ts"]
"@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"],
"types": ["node", "vite/client"],
"useDefineForClassFields": true
},
"include": ["apps/web/src", "apps/web/vite.config.ts", "packages/mtp"]
"include": [
"apps/frontend/src",
"apps/frontend/vite.config.ts",
"packages/mtp"
]
}