generated from methanium/template
Initial commit
This commit is contained in:
commit
025c8c8e16
46 changed files with 13890 additions and 0 deletions
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "apps/frontend/type-maps.yaml", relative = true }
|
||||
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.direnv/
|
||||
result
|
||||
.dev/
|
||||
.env.local
|
||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
apps/frontend/dist/
|
||||
apps/frontend/src/routeTree.gen.ts
|
||||
apps/backend/target/
|
||||
node_modules/
|
||||
result
|
||||
15
LICENSE
Normal file
15
LICENSE
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Copyright (c) 2025 Methanium
|
||||
|
||||
All rights reserved.
|
||||
|
||||
No part of this software, source code, documentation, or
|
||||
associated materials may be copied, reproduced, modified,
|
||||
distributed, published, sublicensed, sold, or used to create
|
||||
derivative works without prior written permission from the
|
||||
copyright holder.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
||||
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
||||
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
||||
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||
96
README.md
Normal file
96
README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Methanium Status
|
||||
|
||||
Live availability and incident reporting for Methanium and Tensamin services. The Rust backend checks configured URLs every five minutes, verifies HTTP/3 where requested, persists incident history, and pushes snapshots to the React status page over MTP/WebTransport.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm check
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`pnpm dev` generates a short-lived localhost certificate, uses it for the HTTPS frontend and WebTransport backend, and writes the matching WebTransport certificate hash. The public page is available on Vite's displayed HTTPS URL and the incident editor is available at `http://127.0.0.1:8091/edit` after the frontend has been built once.
|
||||
|
||||
For a manual backend configuration, copy `config.example.toml`, update the certificate paths, build the frontend, and run:
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
cargo run --manifest-path apps/backend/Cargo.toml -- ./config.toml
|
||||
```
|
||||
|
||||
Set `VITE_MTP_URL` when the development MTP endpoint is not available at the frontend's own `/mtp` origin.
|
||||
|
||||
## Configuration
|
||||
|
||||
The backend accepts TOML or JSON. Categories contain a local logo and a list of services. Service names and URLs are not editable through the incident UI.
|
||||
|
||||
```toml
|
||||
[[categories]]
|
||||
id = "example"
|
||||
name = "Example"
|
||||
logo = "/path/to/example.svg"
|
||||
|
||||
[[categories.services]]
|
||||
id = "example-home"
|
||||
name = "Homepage"
|
||||
url = "https://example.com"
|
||||
check_http3 = true
|
||||
```
|
||||
|
||||
HTTP 200 through 399 is operational. DNS, connection, and TLS failures are reported as offline; timeouts, HTTP error responses, and HTTP/3 failures have distinct labels. Every non-operational service state uses red on the public page.
|
||||
|
||||
The admin page is available at `/edit` on the separately configured admin address and port. It has intentionally no authentication, so the backend requires the admin address to be loopback. The NixOS module binds it to `127.0.0.1:8081` and does not open its firewall port by default. For remote use, prefer an SSH tunnel:
|
||||
|
||||
```sh
|
||||
ssh -L 8081:127.0.0.1:8081 status-host
|
||||
```
|
||||
|
||||
## NixOS
|
||||
|
||||
The included module serves the SPA with nginx on TCP 443 and MTP HTTP/3/WebTransport on UDP 443. Both use the same ACME certificate and origin.
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.methanium-status.url =
|
||||
"git+https://git.methanium.net/methanium/status";
|
||||
|
||||
outputs = { nixpkgs, methanium-status, ... }: {
|
||||
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
methanium-status.nixosModules.default
|
||||
{
|
||||
security.acme.acceptTerms = true;
|
||||
security.acme.defaults.email = "admin@example.com";
|
||||
|
||||
services.methanium-status = {
|
||||
enable = true;
|
||||
hostName = "status.example.com";
|
||||
openFirewall = true;
|
||||
|
||||
categories = [{
|
||||
id = "example";
|
||||
name = "Example";
|
||||
logo = ./example.svg;
|
||||
services = [{
|
||||
id = "example-home";
|
||||
name = "Homepage";
|
||||
url = "https://example.com";
|
||||
checkHttp3 = false;
|
||||
}];
|
||||
}];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Use `acmeHost` to share an already configured NixOS ACME certificate. The service options also expose the check interval, timeout, public listener, admin listener, state directory, packages, firewall behavior, categories, logo paths, and services.
|
||||
|
||||
Contact: [contact@methanium.net](mailto:contact@methanium.net)
|
||||
|
||||
Security: [security@methanium.net](mailto:security@methanium.net)
|
||||
2
apps/backend/.cargo/config.toml
Normal file
2
apps/backend/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "../frontend/type-maps.yaml", relative = true }
|
||||
1
apps/backend/.gitignore
vendored
Normal file
1
apps/backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target
|
||||
3387
apps/backend/Cargo.lock
generated
Normal file
3387
apps/backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
apps/backend/Cargo.toml
Normal file
24
apps/backend/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "methanium-status"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
http = "1"
|
||||
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "a692bed326dbfc8eac1a05825f4a287cbab6fd3e", features = ["crypto", "web-server"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml = "0.9"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
88
apps/backend/src/admin.rs
Normal file
88
apps/backend/src/admin.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AppState, incidents::IncidentError, model::NewIncident};
|
||||
|
||||
pub async fn serve(
|
||||
address: SocketAddr,
|
||||
frontend: PathBuf,
|
||||
state: Arc<AppState>,
|
||||
) -> std::io::Result<()> {
|
||||
let index = frontend.join("index.html");
|
||||
let assets = ServeDir::new(frontend).fallback(ServeFile::new(index));
|
||||
let app = Router::new()
|
||||
.route("/api/admin", get(admin))
|
||||
.route("/api/incidents", get(list).post(create))
|
||||
.route("/api/incidents/{id}", delete(remove))
|
||||
.route("/api/incidents/{id}/resolve", post(resolve))
|
||||
.fallback_service(assets)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
let listener = tokio::net::TcpListener::bind(address).await?;
|
||||
tracing::info!(%address, "admin page listening");
|
||||
axum::serve(listener, app).await
|
||||
}
|
||||
|
||||
async fn admin() -> StatusCode {
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
async fn list(State(state): State<Arc<AppState>>) -> Json<Vec<crate::model::Incident>> {
|
||||
Json(state.incidents.list().await)
|
||||
}
|
||||
|
||||
async fn create(State(state): State<Arc<AppState>>, Json(input): Json<NewIncident>) -> Response {
|
||||
match state.incidents.create(input).await {
|
||||
Ok(incident) => {
|
||||
state.publish().await;
|
||||
(StatusCode::CREATED, Json(incident)).into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
|
||||
match state.incidents.resolve(id).await {
|
||||
Ok(incident) => {
|
||||
state.publish().await;
|
||||
Json(incident).into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
|
||||
match state.incidents.delete(id).await {
|
||||
Ok(_) => {
|
||||
state.publish().await;
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(error: IncidentError) -> Response {
|
||||
let status = match error {
|
||||
IncidentError::Empty => StatusCode::BAD_REQUEST,
|
||||
IncidentError::Active => StatusCode::CONFLICT,
|
||||
IncidentError::NotFound => StatusCode::NOT_FOUND,
|
||||
IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": error.to_string() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
142
apps/backend/src/config.rs
Normal file
142
apps/backend/src/config.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use std::{collections::HashSet, net::IpAddr, path::Path};
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub public_address: IpAddr,
|
||||
pub public_port: u16,
|
||||
pub admin_address: IpAddr,
|
||||
pub admin_port: u16,
|
||||
pub check_interval_seconds: u64,
|
||||
pub timeout_seconds: u64,
|
||||
pub certificate: String,
|
||||
pub private_key: String,
|
||||
pub frontend_dir: String,
|
||||
pub state_dir: String,
|
||||
pub curl: String,
|
||||
pub categories: Vec<CategoryConfig>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
public_address: "0.0.0.0".parse().unwrap(),
|
||||
public_port: 443,
|
||||
admin_address: "127.0.0.1".parse().unwrap(),
|
||||
admin_port: 8081,
|
||||
check_interval_seconds: 300,
|
||||
timeout_seconds: 15,
|
||||
certificate: String::new(),
|
||||
private_key: String::new(),
|
||||
frontend_dir: String::new(),
|
||||
state_dir: "/var/lib/methanium-status".into(),
|
||||
curl: "curl".into(),
|
||||
categories: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CategoryConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub services: Vec<ServiceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ServiceConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub check_http3: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("failed to read config: {0}")]
|
||||
Read(#[from] std::io::Error),
|
||||
#[error("failed to parse config: {0}")]
|
||||
Parse(String),
|
||||
#[error("invalid config: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
let config = if path.extension().and_then(|value| value.to_str()) == Some("json") {
|
||||
serde_json::from_str(&contents)
|
||||
.map_err(|error| ConfigError::Parse(error.to_string()))?
|
||||
} else {
|
||||
toml::from_str(&contents).map_err(|error| ConfigError::Parse(error.to_string()))?
|
||||
};
|
||||
validate(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(config: &Config) -> Result<(), ConfigError> {
|
||||
if config.check_interval_seconds == 0 || config.timeout_seconds == 0 {
|
||||
return Err(ConfigError::Invalid(
|
||||
"check interval and timeout must be greater than zero".into(),
|
||||
));
|
||||
}
|
||||
if !config.admin_address.is_loopback() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"admin_address must be a loopback address".into(),
|
||||
));
|
||||
}
|
||||
if config.certificate.is_empty() || config.private_key.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"certificate and private_key are required".into(),
|
||||
));
|
||||
}
|
||||
if config.frontend_dir.is_empty() {
|
||||
return Err(ConfigError::Invalid("frontend_dir is required".into()));
|
||||
}
|
||||
|
||||
let mut category_ids = HashSet::new();
|
||||
let mut service_ids = HashSet::new();
|
||||
for category in &config.categories {
|
||||
if category.id.is_empty() || category.name.is_empty() || category.logo.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"category id, name, and logo are required".into(),
|
||||
));
|
||||
}
|
||||
if !category_ids.insert(&category.id) {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"duplicate category id {}",
|
||||
category.id
|
||||
)));
|
||||
}
|
||||
for service in &category.services {
|
||||
if service.id.is_empty() || service.name.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"service id and name are required".into(),
|
||||
));
|
||||
}
|
||||
if !service_ids.insert(&service.id) {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"duplicate service id {}",
|
||||
service.id
|
||||
)));
|
||||
}
|
||||
let url = reqwest::Url::parse(&service.url)
|
||||
.map_err(|error| ConfigError::Invalid(format!("{}: {error}", service.url)))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"{} must use http or https",
|
||||
service.url
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
175
apps/backend/src/incidents.rs
Normal file
175
apps/backend/src/incidents.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
use std::{io::ErrorKind, path::PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{Incident, NewIncident};
|
||||
|
||||
pub struct IncidentStore {
|
||||
path: PathBuf,
|
||||
incidents: Mutex<Vec<Incident>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IncidentError {
|
||||
#[error("incident title and message cannot be empty")]
|
||||
Empty,
|
||||
#[error("incident not found")]
|
||||
NotFound,
|
||||
#[error("active incidents cannot be deleted")]
|
||||
Active,
|
||||
#[error("failed to persist incidents: {0}")]
|
||||
Storage(#[from] std::io::Error),
|
||||
#[error("failed to parse incidents: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl IncidentStore {
|
||||
pub async fn load(path: PathBuf) -> Result<Self, IncidentError> {
|
||||
let incidents = match tokio::fs::read(&path).await {
|
||||
Ok(contents) => serde_json::from_slice(&contents)?,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
Ok(Self {
|
||||
path,
|
||||
incidents: Mutex::new(incidents),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<Incident> {
|
||||
self.incidents.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn create(&self, input: NewIncident) -> Result<Incident, IncidentError> {
|
||||
if input.title.trim().is_empty() || input.message.trim().is_empty() {
|
||||
return Err(IncidentError::Empty);
|
||||
}
|
||||
let incident = Incident {
|
||||
id: Uuid::new_v4(),
|
||||
title: input.title.trim().to_owned(),
|
||||
message: input.message.trim().to_owned(),
|
||||
severity: input.severity,
|
||||
created_at: Utc::now(),
|
||||
resolved_at: None,
|
||||
};
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
incidents.push(incident.clone());
|
||||
self.persist(&incidents).await?;
|
||||
Ok(incident)
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, id: Uuid) -> Result<Incident, IncidentError> {
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
let incident = incidents
|
||||
.iter_mut()
|
||||
.find(|incident| incident.id == id)
|
||||
.ok_or(IncidentError::NotFound)?;
|
||||
if incident.resolved_at.is_none() {
|
||||
incident.resolved_at = Some(Utc::now());
|
||||
}
|
||||
let resolved = incident.clone();
|
||||
self.persist(&incidents).await?;
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: Uuid) -> Result<Incident, IncidentError> {
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
let index = incidents
|
||||
.iter()
|
||||
.position(|incident| incident.id == id)
|
||||
.ok_or(IncidentError::NotFound)?;
|
||||
if incidents[index].resolved_at.is_none() {
|
||||
return Err(IncidentError::Active);
|
||||
}
|
||||
let deleted = incidents.remove(index);
|
||||
self.persist(&incidents).await?;
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
async fn persist(&self, incidents: &[Incident]) -> Result<(), std::io::Error> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let temporary = self.path.with_extension("json.tmp");
|
||||
let contents = serde_json::to_vec_pretty(incidents).map_err(std::io::Error::other)?;
|
||||
tokio::fs::write(&temporary, contents).await?;
|
||||
tokio::fs::rename(temporary, &self.path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::model::{NewIncident, Severity};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_created_and_resolved_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("incidents.json");
|
||||
let store = IncidentStore::load(path.clone()).await.unwrap();
|
||||
let created = store
|
||||
.create(NewIncident {
|
||||
title: "Service disruption".into(),
|
||||
message: "Investigating elevated errors.".into(),
|
||||
severity: Severity::Major,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store.resolve(created.id).await.unwrap();
|
||||
|
||||
let reloaded = IncidentStore::load(path).await.unwrap().list().await;
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
assert!(reloaded[0].resolved_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = IncidentStore::load(directory.path().join("incidents.json"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = store
|
||||
.create(NewIncident {
|
||||
title: " ".into(),
|
||||
message: "Details".into(),
|
||||
severity: Severity::Minor,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(IncidentError::Empty)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_deletes_resolved_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("incidents.json");
|
||||
let store = IncidentStore::load(path.clone()).await.unwrap();
|
||||
let created = store
|
||||
.create(NewIncident {
|
||||
title: "Service disruption".into(),
|
||||
message: "Investigating elevated errors.".into(),
|
||||
severity: Severity::Major,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
store.delete(created.id).await,
|
||||
Err(IncidentError::Active)
|
||||
));
|
||||
store.resolve(created.id).await.unwrap();
|
||||
store.delete(created.id).await.unwrap();
|
||||
|
||||
assert!(
|
||||
IncidentStore::load(path)
|
||||
.await
|
||||
.unwrap()
|
||||
.list()
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
142
apps/backend/src/main.rs
Normal file
142
apps/backend/src/main.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
mod admin;
|
||||
mod config;
|
||||
mod incidents;
|
||||
mod model;
|
||||
mod monitor;
|
||||
mod mtp_server;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use chrono::Utc;
|
||||
use config::Config;
|
||||
use incidents::IncidentStore;
|
||||
use model::{Category, Service, ServiceStatus, Snapshot};
|
||||
use tokio::sync::{RwLock, watch};
|
||||
|
||||
pub struct AppState {
|
||||
categories: Arc<RwLock<Vec<Category>>>,
|
||||
incidents: IncidentStore,
|
||||
updates: watch::Sender<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
async fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
categories: self.categories.read().await.clone(),
|
||||
incidents: self.incidents.list().await,
|
||||
generated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn snapshot_json(&self) -> String {
|
||||
serde_json::to_string(&self.snapshot().await).expect("snapshot is serializable")
|
||||
}
|
||||
|
||||
async fn publish(&self) {
|
||||
let payload = self.snapshot_json().await;
|
||||
tracing::info!(bytes = payload.len(), "publishing status snapshot");
|
||||
self.updates.send_replace(payload);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("methanium_status=info".parse()?),
|
||||
)
|
||||
.init();
|
||||
let config_path = std::env::args_os()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| "config.toml".into());
|
||||
let config = Arc::new(Config::load(&config_path)?);
|
||||
let state_dir = PathBuf::from(&config.state_dir);
|
||||
tokio::fs::create_dir_all(&state_dir).await?;
|
||||
|
||||
let categories = Arc::new(RwLock::new(load_categories(&config)?));
|
||||
let incidents = IncidentStore::load(state_dir.join("incidents.json")).await?;
|
||||
let initial = serde_json::to_string(&Snapshot {
|
||||
categories: categories.read().await.clone(),
|
||||
incidents: incidents.list().await,
|
||||
generated_at: Utc::now(),
|
||||
})?;
|
||||
let (updates, _) = watch::channel(initial);
|
||||
let state = Arc::new(AppState {
|
||||
categories: Arc::clone(&categories),
|
||||
incidents,
|
||||
updates,
|
||||
});
|
||||
|
||||
let monitor_config = Arc::clone(&config);
|
||||
let monitor_state = Arc::clone(&state);
|
||||
let publish: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
||||
let state = Arc::clone(&monitor_state);
|
||||
tokio::spawn(async move { state.publish().await });
|
||||
});
|
||||
tokio::spawn(monitor::run(monitor_config, categories, publish));
|
||||
|
||||
let admin_address = (config.admin_address, config.admin_port).into();
|
||||
let admin = admin::serve(
|
||||
admin_address,
|
||||
PathBuf::from(&config.frontend_dir),
|
||||
Arc::clone(&state),
|
||||
);
|
||||
let mtp = mtp_server::serve(
|
||||
config.public_address,
|
||||
config.public_port,
|
||||
tokio::fs::read(&config.certificate).await?,
|
||||
tokio::fs::read(&config.private_key).await?,
|
||||
state,
|
||||
);
|
||||
tokio::try_join!(admin, async { mtp.await.map_err(std::io::Error::other) })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_categories(config: &Config) -> Result<Vec<Category>, std::io::Error> {
|
||||
config
|
||||
.categories
|
||||
.iter()
|
||||
.map(|category| {
|
||||
Ok(Category {
|
||||
id: category.id.clone(),
|
||||
name: category.name.clone(),
|
||||
logo: logo_data_url(Path::new(&category.logo))?,
|
||||
services: category
|
||||
.services
|
||||
.iter()
|
||||
.map(|service| Service {
|
||||
id: service.id.clone(),
|
||||
name: service.name.clone(),
|
||||
url: service.url.clone(),
|
||||
check_http3: service.check_http3,
|
||||
status: ServiceStatus::Unknown,
|
||||
status_code: None,
|
||||
latency_ms: None,
|
||||
error: None,
|
||||
checked_at: None,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn logo_data_url(path: &Path) -> Result<String, std::io::Error> {
|
||||
let media_type = match path.extension().and_then(|value| value.to_str()) {
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("webp") => "image/webp",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
Ok(format!(
|
||||
"data:{media_type};base64,{}",
|
||||
STANDARD.encode(std::fs::read(path)?)
|
||||
))
|
||||
}
|
||||
72
apps/backend/src/model.rs
Normal file
72
apps/backend/src/model.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
pub categories: Vec<Category>,
|
||||
pub incidents: Vec<Incident>,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Category {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub logo: String,
|
||||
pub services: Vec<Service>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Service {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub check_http3: bool,
|
||||
pub status: ServiceStatus,
|
||||
pub status_code: Option<u16>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
pub checked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceStatus {
|
||||
Unknown,
|
||||
Online,
|
||||
Offline,
|
||||
TimedOut,
|
||||
HttpError,
|
||||
Http3Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Incident {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub severity: Severity,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Severity {
|
||||
Minor,
|
||||
Major,
|
||||
Critical,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NewIncident {
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub severity: Severity,
|
||||
}
|
||||
184
apps/backend/src/monitor.rs
Normal file
184
apps/backend/src/monitor.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
use std::{process::Stdio, sync::Arc, time::Duration};
|
||||
|
||||
use chrono::Utc;
|
||||
use reqwest::redirect::Policy;
|
||||
use tokio::{process::Command, sync::RwLock, time::Instant};
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
model::{Service, ServiceStatus},
|
||||
};
|
||||
|
||||
pub async fn run(
|
||||
config: Arc<Config>,
|
||||
categories: Arc<RwLock<Vec<crate::model::Category>>>,
|
||||
publish: Arc<dyn Fn() + Send + Sync>,
|
||||
) {
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::limited(10))
|
||||
.timeout(Duration::from_secs(config.timeout_seconds))
|
||||
.user_agent("Methanium-Status/0.1")
|
||||
.build()
|
||||
.expect("HTTP client configuration is valid");
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(config.check_interval_seconds));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let jobs = {
|
||||
let categories = categories.read().await;
|
||||
categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(category_index, category)| {
|
||||
category
|
||||
.services
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(service_index, service)| {
|
||||
(category_index, service_index, service.clone())
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let mut checks = tokio::task::JoinSet::new();
|
||||
for (category_index, service_index, service) in jobs {
|
||||
let client = client.clone();
|
||||
let config = Arc::clone(&config);
|
||||
checks.spawn(async move {
|
||||
(
|
||||
category_index,
|
||||
service_index,
|
||||
check_service(&client, &config, service).await,
|
||||
)
|
||||
});
|
||||
}
|
||||
while let Some(result) = checks.join_next().await {
|
||||
match result {
|
||||
Ok((category_index, service_index, service)) => {
|
||||
categories.write().await[category_index].services[service_index] = service;
|
||||
}
|
||||
Err(error) => tracing::error!(%error, "service check task failed"),
|
||||
}
|
||||
}
|
||||
publish();
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_service(client: &reqwest::Client, config: &Config, mut service: Service) -> Service {
|
||||
let started = Instant::now();
|
||||
let result = client.get(&service.url).send().await;
|
||||
service.checked_at = Some(Utc::now());
|
||||
service.latency_ms = Some(started.elapsed().as_millis() as u64);
|
||||
service.error = None;
|
||||
service.status_code = None;
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
service.status_code = Some(status.as_u16());
|
||||
if !is_operational_status(status) {
|
||||
service.status = ServiceStatus::HttpError;
|
||||
service.error = Some(format!("HTTP {}", status.as_u16()));
|
||||
return service;
|
||||
}
|
||||
}
|
||||
Err(error) if error.is_timeout() => {
|
||||
service.status = ServiceStatus::TimedOut;
|
||||
service.error = Some("Request timed out".into());
|
||||
return service;
|
||||
}
|
||||
Err(error) => {
|
||||
service.status = ServiceStatus::Offline;
|
||||
service.error = Some(error.to_string());
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
if service.check_http3 {
|
||||
match check_http3(config, &service.url).await {
|
||||
Ok((status, latency)) if (200..400).contains(&status) => {
|
||||
service.latency_ms = Some(service.latency_ms.unwrap_or(0).max(latency));
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
service.status = ServiceStatus::Http3Error;
|
||||
service.error = Some(format!("HTTP/3 returned {status}"));
|
||||
return service;
|
||||
}
|
||||
Err(error) => {
|
||||
service.status = ServiceStatus::Http3Error;
|
||||
service.error = Some(error);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.status = ServiceStatus::Online;
|
||||
service
|
||||
}
|
||||
|
||||
async fn check_http3(config: &Config, url: &str) -> Result<(u16, u64), String> {
|
||||
let output = Command::new(&config.curl)
|
||||
.args([
|
||||
"--http3-only",
|
||||
"--location",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--output",
|
||||
"/dev/null",
|
||||
"--write-out",
|
||||
"%{http_code}\t%{time_total}",
|
||||
"--max-time",
|
||||
&config.timeout_seconds.to_string(),
|
||||
url,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| format!("HTTP/3 probe failed to start: {error}"))?;
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
return Err(if error.is_empty() {
|
||||
"HTTP/3 connection failed".into()
|
||||
} else {
|
||||
error
|
||||
});
|
||||
}
|
||||
parse_http3_output(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
fn is_operational_status(status: reqwest::StatusCode) -> bool {
|
||||
status.is_success() || status.is_redirection()
|
||||
}
|
||||
|
||||
fn parse_http3_output(value: &str) -> Result<(u16, u64), String> {
|
||||
let (status, seconds) = value
|
||||
.trim()
|
||||
.split_once('\t')
|
||||
.ok_or("Invalid HTTP/3 probe output")?;
|
||||
let status = status.parse().map_err(|_| "Invalid HTTP/3 status code")?;
|
||||
let seconds: f64 = seconds.parse().map_err(|_| "Invalid HTTP/3 latency")?;
|
||||
Ok((status, (seconds * 1_000.0) as u64))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_http_statuses() {
|
||||
assert!(is_operational_status(StatusCode::OK));
|
||||
assert!(is_operational_status(StatusCode::TEMPORARY_REDIRECT));
|
||||
assert!(!is_operational_status(StatusCode::BAD_REQUEST));
|
||||
assert!(!is_operational_status(StatusCode::SERVICE_UNAVAILABLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_http3_probe_result() {
|
||||
assert_eq!(parse_http3_output("204\t0.125").unwrap(), (204, 125));
|
||||
assert!(parse_http3_output("not-a-result").is_err());
|
||||
}
|
||||
}
|
||||
96
apps/backend/src/mtp_server.rs
Normal file
96
apps/backend/src/mtp_server.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use std::{net::IpAddr, sync::Arc};
|
||||
|
||||
use mtp::{
|
||||
codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap},
|
||||
host::{HostConfig, Policy, SendMode},
|
||||
webserver::{MTPWebServer, WebMTPConnection, WebServerConfig},
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
|
||||
pub async fn serve(
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
certificate: Vec<u8>,
|
||||
private_key: Vec<u8>,
|
||||
state: Arc<crate::AppState>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let policy = Policy::default().with_send_mode(SendMode::SingleStreamPerMessage);
|
||||
let host = HostConfig::new(address, port, certificate, private_key).with_policy(policy);
|
||||
let web = WebServerConfig::new()
|
||||
.mtp_path("/mtp")
|
||||
.serve_tcp_https(false);
|
||||
let mut server = MTPWebServer::new(host, web).await?;
|
||||
tracing::info!(address = %server.local_addr(), "MTP WebTransport listening");
|
||||
|
||||
let mut next_connection_id = 1_u64;
|
||||
while let Some(connection) = server.accept().await? {
|
||||
let connection_id = next_connection_id;
|
||||
next_connection_id += 1;
|
||||
tracing::info!(connection_id, "MTP client connected");
|
||||
let updates = state.updates.subscribe();
|
||||
let state = Arc::clone(&state);
|
||||
tokio::spawn(
|
||||
async move { serve_connection(connection_id, connection, updates, state).await },
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_connection(
|
||||
connection_id: u64,
|
||||
connection: WebMTPConnection,
|
||||
mut updates: watch::Receiver<String>,
|
||||
state: Arc<crate::AppState>,
|
||||
) {
|
||||
let sender = connection.sender.clone();
|
||||
let type_map = connection.codec.type_map().clone();
|
||||
loop {
|
||||
tokio::select! {
|
||||
request = connection.receiver.receive() => {
|
||||
let request = match request {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
tracing::warn!(connection_id, %error, "failed to receive MTP request");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if request.is_type(CommunicationType::GetStatus) {
|
||||
let payload = state.snapshot_json().await;
|
||||
let bytes = payload.len();
|
||||
let response = snapshot(&type_map, payload).with_id(request.get_id());
|
||||
tracing::info!(connection_id, request_id = request.get_id(), bytes, "received status request");
|
||||
if let Err(error) = sender.send(&response).await {
|
||||
tracing::warn!(connection_id, request_id = request.get_id(), %error, "failed to send status response");
|
||||
break;
|
||||
}
|
||||
tracing::info!(connection_id, request_id = request.get_id(), "sent status response");
|
||||
} else {
|
||||
tracing::warn!(connection_id, "received unsupported MTP request");
|
||||
}
|
||||
}
|
||||
changed = updates.changed() => {
|
||||
if changed.is_err() {
|
||||
tracing::info!(connection_id, "status update channel closed");
|
||||
break;
|
||||
}
|
||||
let payload = updates.borrow_and_update().clone();
|
||||
let bytes = payload.len();
|
||||
let response = snapshot(&type_map, payload);
|
||||
if let Err(error) = sender.send(&response).await {
|
||||
tracing::warn!(connection_id, %error, "failed to push status update");
|
||||
break;
|
||||
}
|
||||
tracing::info!(connection_id, bytes, "pushed status update");
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(connection_id, "MTP client disconnected");
|
||||
}
|
||||
|
||||
fn snapshot(type_map: &TypeMap, payload: String) -> CommunicationValue {
|
||||
CommunicationValue::from_comm(CommunicationType::StatusSnapshot, type_map).add_typed(
|
||||
DataType::Payload,
|
||||
type_map,
|
||||
DataValue::Str(payload),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/"!</div>
|
||||
}
|
||||
38
apps/frontend/index.html
Normal file
38
apps/frontend/index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark" data-theme="methanium">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#171e26" />
|
||||
<title>Methanium Status</title>
|
||||
<style>
|
||||
html.dark,
|
||||
html.dark body {
|
||||
background-color: #171e26;
|
||||
color: #d2e0f0;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.light,
|
||||
html.light body {
|
||||
background-color: #edf1f4;
|
||||
color: #10161d;
|
||||
color-scheme: light;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
const theme =
|
||||
savedTheme === "light" ||
|
||||
(savedTheme === "system" &&
|
||||
matchMedia("(prefers-color-scheme: light)").matches)
|
||||
? "light"
|
||||
: "dark";
|
||||
document.documentElement.classList.replace("dark", theme);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
apps/frontend/package.json
Normal file
28
apps/frontend/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@methanium/status-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@methanium/status-mtp": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tanstack/react-router": "^1.131.35",
|
||||
"lucide-react": "^1.21.0",
|
||||
"mtp": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/router-plugin": "^1.131.35",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vite": "^7.1.12"
|
||||
}
|
||||
}
|
||||
27
apps/frontend/public/methanium.svg
Normal file
27
apps/frontend/public/methanium.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="4441.4" height="6468.8" shape-rendering="geometricPrecision" viewBox="0 0 4441 6469">
|
||||
<path fill="#331c4e" d="m1714 6033-33-248-5-676 69-1110-247-2069-388-302 196 3614Z"/>
|
||||
<path fill="#6a438a" d="m1864 2038-214 3093 31 640 820-1486 125-2262Z"/>
|
||||
<path fill="#291641" d="m3130 1586-504 437-125 2262 305 315Z"/>
|
||||
<path fill="#0e0619" d="m2501 4285 305 315-120 1018-384 830h-56Z"/>
|
||||
<path fill="#211136" d="m1762 6196 247 254 237-2 255-2163-822 1476 17 221Z"/>
|
||||
<path fill="#3f245b" d="m740 4907 255-267 719 1393 48 163-340-112Z"/>
|
||||
<path fill="#05020d" d="m1407 6080-147 198 414 172h335l-247-254Z"/>
|
||||
<path fill="#030106" d="m1273 6283-85 173 486-6Z"/>
|
||||
<path fill="#0a0413" d="m10 5229 297-41 966 1095-85 173-100 3Z"/>
|
||||
<path fill="#5b3878" d="m740 4907 682 1177-149 199-966-1095Z"/>
|
||||
<path fill="#794f9a" d="M159 4369 10 5229l297-41Z"/>
|
||||
<path fill="#b984c5" d="m307 5188 433-281-581-538Z"/>
|
||||
<path fill="#d4abda" d="m159 4369 581 538 255-267Z"/>
|
||||
<path d="m2686 5604 312 564-179 263-517 17Z"/>
|
||||
<path fill="#130922" d="m2686 5604 294-421 588-268 48 443-618 810Z"/>
|
||||
<path fill="#975dac" d="m2686 5604 1115-1590-379-274-666 1279Z"/>
|
||||
<path fill="#6a438a" d="m2980 5183 588-268 511-814-429-152Z"/>
|
||||
<path fill="#190c2b" d="m4079 4101 352 111-815 1146-48-443Z"/>
|
||||
<path fill="#f0d7f2" d="m2045 11-547 1919 366 108 762-15 70-59Z"/>
|
||||
<path fill="#a558aa" d="m2045 11 651 1953 434-378Z"/>
|
||||
<path fill="#b06ab6" d="m2045 11-547 1919-388-302Z"/>
|
||||
<path fill="#885ca8" d="m1498 1930 238 1992 128-1884Z"/>
|
||||
<path fill="#c698d0" d="m3650 3949 429 152 273-790Z"/>
|
||||
<path fill="#4c2d68" d="m4079 4101 352 111-79-901Z"/>
|
||||
<path fill="#e2c1e6" d="m3422 3740 228 209 702-638Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
270
apps/frontend/public/tensamin.svg
Normal file
270
apps/frontend/public/tensamin.svg
Normal 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 |
72
apps/frontend/src/components/layout/Header.tsx
Normal file
72
apps/frontend/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { ExternalLink, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button, useTheme } from "@methanium/ui";
|
||||
import { useStatus } from "../../use-status";
|
||||
import { formatTime } from "../../utils";
|
||||
|
||||
export function Header() {
|
||||
const { resolvedPolarity, setTheme } = useTheme();
|
||||
const { snapshot, error } = useStatus();
|
||||
const isDark = resolvedPolarity === "dark";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-12 border-b border-sidebar-border bg-sidebar text-sidebar-foreground shadow-sm">
|
||||
<div className="flex h-full items-center justify-between overflow-x-auto px-[10px]">
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mr-1 shrink-0 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
style={{ width: "46.59px", height: "36px", padding: "3px 13px" }}
|
||||
render={<a href="/" aria-label="Methanium home" />}
|
||||
>
|
||||
<img src="/methanium.svg" alt="" className="h-8 w-6 object-contain" />
|
||||
</Button>
|
||||
<nav
|
||||
className="mr-auto flex h-full shrink-0 items-center"
|
||||
aria-label="Primary"
|
||||
>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
render={<a target="_blank" href="mailto:contact@methanium.net" />}
|
||||
>
|
||||
Contact us <ExternalLink />
|
||||
</Button>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
render={<a target="_blank" href="mailto:security@methanium.net" />}
|
||||
>
|
||||
Report security issue <ExternalLink />
|
||||
</Button>
|
||||
</nav>
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{snapshot?.generatedAt
|
||||
? `Updated ${formatTime(snapshot.generatedAt)}`
|
||||
: (error?.message ?? "Waiting for data")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="size-3.5" />
|
||||
) : (
|
||||
<Moon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
32
apps/frontend/src/main.tsx
Normal file
32
apps/frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import "./styles.css";
|
||||
|
||||
import { BUILT_IN_THEMES, ThemeProvider } from "@methanium/ui";
|
||||
import { Provider as MTPProvider } from "@methanium/status-mtp";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { mtpOptions } from "./mtp-options.ts";
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider
|
||||
defaultTheme="dark"
|
||||
themes={BUILT_IN_THEMES}
|
||||
defaultParentThemeId="methanium"
|
||||
>
|
||||
<MTPProvider options={mtpOptions} authenticate={false}>
|
||||
<RouterProvider router={router} />
|
||||
</MTPProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
30
apps/frontend/src/mtp-options.ts
Normal file
30
apps/frontend/src/mtp-options.ts
Normal 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,
|
||||
};
|
||||
77
apps/frontend/src/routeTree.gen.ts
Normal file
77
apps/frontend/src/routeTree.gen.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as EditRouteImport } from './routes/edit'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EditRoute = EditRouteImport.update({
|
||||
id: '/edit',
|
||||
path: '/edit',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/edit': typeof EditRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/edit': typeof EditRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/edit': typeof EditRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/edit'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/edit'
|
||||
id: '__root__' | '/' | '/edit'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
EditRoute: typeof EditRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/edit': {
|
||||
id: '/edit'
|
||||
path: '/edit'
|
||||
fullPath: '/edit'
|
||||
preLoaderRoute: typeof EditRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
EditRoute: EditRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
19
apps/frontend/src/routes/__root.tsx
Normal file
19
apps/frontend/src/routes/__root.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
import { Header } from "../components/layout/Header";
|
||||
|
||||
const EmptyPage = () => (
|
||||
<main className="min-h-[calc(100vh-3rem)] bg-background text-2xl w-full h-full items-center justify-center flex font-medium">
|
||||
404 Page Not Found
|
||||
</main>
|
||||
);
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
),
|
||||
notFoundComponent: EmptyPage,
|
||||
});
|
||||
391
apps/frontend/src/routes/edit.tsx
Normal file
391
apps/frontend/src/routes/edit.tsx
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
cn,
|
||||
} from "@methanium/ui";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { Check, Loader2, Plus, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Incident, Severity } from "../status";
|
||||
import { formatTime } from "../utils";
|
||||
|
||||
export const Route = createFileRoute("/edit")({
|
||||
beforeLoad: async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin", { cache: "no-store" });
|
||||
if (response.status !== 204) throw notFound();
|
||||
} catch {
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
component: IncidentEditor,
|
||||
});
|
||||
|
||||
const severityLabels: Record<Severity, string> = {
|
||||
minor: "Minor",
|
||||
major: "Major",
|
||||
critical: "Critical",
|
||||
};
|
||||
|
||||
function IncidentEditor() {
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch("/api/incidents", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with HTTP ${response.status}`);
|
||||
}
|
||||
setIncidents((await response.json()) as Incident[]);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
async function create(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const data = new FormData(form);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/incidents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: data.get("title"),
|
||||
message: data.get("message"),
|
||||
severity: data.get("severity"),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json()) as { error?: string };
|
||||
throw new Error(
|
||||
body.error ?? `Request failed with HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
form.reset();
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(id: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/incidents/${id}/resolve`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with HTTP ${response.status}`);
|
||||
}
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIncident(id: string) {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const response = await fetch(`/api/incidents/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with HTTP ${response.status}`);
|
||||
}
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const active = incidents.filter((incident) => !incident.resolvedAt);
|
||||
const resolved = incidents
|
||||
.filter((incident) => incident.resolvedAt)
|
||||
.toReversed();
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-10 sm:px-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<ShieldAlert className="size-7" />
|
||||
<h1 className="text-xl font-medium">Incident management</h1>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid items-start gap-8 lg:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<form
|
||||
onSubmit={create}
|
||||
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] p-4 dark:border-[#242D38] dark:bg-[#242D38]"
|
||||
>
|
||||
<h2 className="font-medium">Create incident</h2>
|
||||
|
||||
<Label className="mt-5 block text-sm" htmlFor="title">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
required
|
||||
id="title"
|
||||
name="title"
|
||||
maxLength={120}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
|
||||
<Label className="mt-4 block text-sm" htmlFor="message">
|
||||
Message
|
||||
</Label>
|
||||
<Textarea
|
||||
required
|
||||
id="message"
|
||||
name="message"
|
||||
maxLength={2000}
|
||||
rows={6}
|
||||
className="py-2"
|
||||
/>
|
||||
|
||||
<Label className="mt-4 block text-sm" htmlFor="severity">
|
||||
Severity
|
||||
</Label>
|
||||
<Select name="severity" defaultValue="minor">
|
||||
<SelectTrigger id="severity" className="mt-2 w-full">
|
||||
<SelectValue>
|
||||
{(value) => severityLabels[value as Severity]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border!">
|
||||
<SelectItem value="minor">Minor</SelectItem>
|
||||
<SelectItem value="major">Major</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="mt-5 w-full">
|
||||
{submitting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
Publish incident
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<section aria-labelledby="active-incidents">
|
||||
<div className="mb-4 flex items-baseline gap-2">
|
||||
<h2 id="active-incidents" className="font-medium">
|
||||
Active incidents
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{active.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading incidents
|
||||
</div>
|
||||
)}
|
||||
{!loading && active.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed border-[#C5CED6] px-4 py-10 text-center text-sm text-muted-foreground dark:border-[#445161]">
|
||||
No active incidents
|
||||
</div>
|
||||
)}
|
||||
{active.map((incident) => (
|
||||
<article
|
||||
key={incident.id}
|
||||
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium [overflow-wrap:anywhere]">
|
||||
{incident.title}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => void resolve(incident.id)}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
<IncidentExcerpt
|
||||
incident={incident}
|
||||
lines={3}
|
||||
className="mt-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 border-t border-[#C5CED6] px-4 py-2 text-xs text-muted-foreground dark:border-[#445161]">
|
||||
<span className="capitalize">{incident.severity}</span>
|
||||
<span>Started {formatTime(incident.createdAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{resolved.length > 0 && (
|
||||
<section aria-labelledby="resolved-incidents">
|
||||
<div className="mb-4 flex items-baseline gap-2">
|
||||
<h2 id="resolved-incidents" className="font-medium">
|
||||
Resolved
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{resolved.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[#C5CED6] border-y border-[#C5CED6] dark:divide-[#445161] dark:border-[#445161]">
|
||||
{resolved.map((incident) => (
|
||||
<div
|
||||
key={incident.id}
|
||||
className="flex flex-col gap-1 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium [overflow-wrap:anywhere]">
|
||||
{incident.title}
|
||||
</p>
|
||||
<IncidentExcerpt incident={incident} lines={1} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 sm:justify-end">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTime(incident.resolvedAt!)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deletingId === incident.id}
|
||||
onClick={() => void deleteIncident(incident.id)}
|
||||
>
|
||||
{deletingId === incident.id ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentExcerpt({
|
||||
incident,
|
||||
lines,
|
||||
className,
|
||||
}: {
|
||||
incident: Incident;
|
||||
lines: 1 | 3;
|
||||
className?: string;
|
||||
}) {
|
||||
const messageRef = useRef<HTMLParagraphElement>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const message = messageRef.current;
|
||||
if (!message) return;
|
||||
|
||||
const update = () =>
|
||||
setTruncated(
|
||||
message.scrollHeight > message.clientHeight + 1 ||
|
||||
message.scrollWidth > message.clientWidth + 1,
|
||||
);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(message);
|
||||
return () => observer.disconnect();
|
||||
}, [incident.message]);
|
||||
|
||||
const severity =
|
||||
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<p
|
||||
ref={messageRef}
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground [overflow-wrap:anywhere]",
|
||||
lines === 1 ? "truncate" : "line-clamp-3 whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
{incident.message}
|
||||
</p>
|
||||
{truncated && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-0 py-0"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{incident.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{severity} incident started {formatTime(incident.createdAt)}
|
||||
{incident.resolvedAt &&
|
||||
` and resolved ${formatTime(incident.resolvedAt)}`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
|
||||
{incident.message}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
apps/frontend/src/routes/index.tsx
Normal file
289
apps/frontend/src/routes/index.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Incident, Service, ServiceStatus } from "../status";
|
||||
import { useStatus } from "../use-status";
|
||||
import { formatTime } from "../utils";
|
||||
import {
|
||||
Button,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
cn,
|
||||
} from "@methanium/ui";
|
||||
|
||||
export const Route = createFileRoute("/")({ component: Home });
|
||||
|
||||
function Home() {
|
||||
const { snapshot } = useStatus();
|
||||
const [showPastIncidents, setShowPastIncidents] = useState(false);
|
||||
const services =
|
||||
snapshot?.categories.flatMap((category) => category.services) ?? [];
|
||||
const failures = services.filter(
|
||||
(service) => service.status !== "online" && service.status !== "unknown",
|
||||
);
|
||||
const activeIncidents =
|
||||
snapshot?.incidents.filter((incident) => !incident.resolvedAt) ?? [];
|
||||
const pastIncidents =
|
||||
snapshot?.incidents
|
||||
.filter((incident) => incident.resolvedAt)
|
||||
.toReversed()
|
||||
.slice(0, 5) ?? [];
|
||||
const pending = services.some((service) => service.status === "unknown");
|
||||
const ready = snapshot !== null && !pending;
|
||||
const healthy =
|
||||
ready && failures.length === 0 && activeIncidents.length === 0;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col pb-5">
|
||||
<div className="w-full flex gap-3 justify-center items-center py-10">
|
||||
<div>
|
||||
{healthy ? (
|
||||
<Check className="size-7" />
|
||||
) : ready ? (
|
||||
<AlertTriangle className="size-7" />
|
||||
) : (
|
||||
<RefreshCw className="size-7" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-start">
|
||||
<p className="text-lg">
|
||||
{healthy
|
||||
? "Everythin's alright on our side"
|
||||
: ready
|
||||
? `${failures.length + activeIncidents.length} active disruption${failures.length + activeIncidents.length === 1 ? "" : "s"}`
|
||||
: "Scraping our own data..."}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{!healthy && ready
|
||||
? "More details below."
|
||||
: snapshot
|
||||
? ""
|
||||
: "Connecting to Status Page backend..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeIncidents.length > 0 && (
|
||||
<div className="grid w-full grid-cols-1 justify-center gap-6 pb-10 sm:grid-cols-[repeat(auto-fit,24rem)]">
|
||||
{activeIncidents.map((incident) => (
|
||||
<IncidentCard key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full flex flex-col lg:flex-row! gap-15 items-center justify-center">
|
||||
{snapshot?.categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="pt-4 text-sm flex flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
|
||||
>
|
||||
<CardHeader className="flex gap-3 items-center">
|
||||
<img src={category.logo} alt={category.name} className="size-8" />
|
||||
|
||||
<p className="text-lg font-medium">{category.name}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0! flex flex-col gap-2 bg-[#E2E7EB] dark:bg-[#1D262F]">
|
||||
{category.services.map((service, index) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
index={index}
|
||||
max={category.services.length}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pastIncidents.length > 0 && (
|
||||
<section className="pt-10 w-full flex flex-col items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-expanded={showPastIncidents}
|
||||
aria-controls="past-incidents"
|
||||
onClick={() => setShowPastIncidents((visible) => !visible)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
showPastIncidents && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
Show Past Incidents
|
||||
</Button>
|
||||
{showPastIncidents && (
|
||||
<div
|
||||
id="past-incidents"
|
||||
className="mt-4 grid w-full grid-cols-1 justify-center gap-6 sm:grid-cols-[repeat(auto-fit,24rem)]"
|
||||
>
|
||||
{pastIncidents.map((incident) => (
|
||||
<IncidentCard key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentCard({ incident }: { incident: Incident }) {
|
||||
const messageRef = useRef<HTMLParagraphElement>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const message = messageRef.current;
|
||||
if (!message) return;
|
||||
|
||||
const update = () =>
|
||||
setTruncated(message.scrollHeight > message.clientHeight + 1);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(message);
|
||||
return () => observer.disconnect();
|
||||
}, [incident.message]);
|
||||
|
||||
const severity =
|
||||
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
|
||||
|
||||
return (
|
||||
<article className="flex h-52 w-96 max-w-[calc(100vw-2rem)] flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] pt-4 text-sm dark:border-[#242D38] dark:bg-[#242D38]">
|
||||
<CardHeader className="flex items-center gap-3">
|
||||
<p className="line-clamp-2 text-lg font-medium [overflow-wrap:anywhere]">
|
||||
{incident.title}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 bg-[#E2E7EB] px-0! dark:bg-[#1D262F]">
|
||||
<p
|
||||
ref={messageRef}
|
||||
className="line-clamp-3 whitespace-pre-wrap px-4 pt-2 leading-5 [overflow-wrap:anywhere]"
|
||||
>
|
||||
{incident.message}
|
||||
</p>
|
||||
{truncated && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto self-start px-4 py-0"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{incident.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{severity} incident{" "}
|
||||
{incident.resolvedAt ? "resolved" : "started"}{" "}
|
||||
{formatTime(incident.resolvedAt ?? incident.createdAt)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
|
||||
{incident.message}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<div className="mt-auto flex gap-3 border-t border-[#C5CED6] px-4 py-2 dark:border-[#242D38]">
|
||||
<p>{severity}</p>
|
||||
<p>
|
||||
{incident.resolvedAt ? "Resolved" : "Started"}{" "}
|
||||
{formatTime(incident.resolvedAt ?? incident.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const statusDetails: Record<
|
||||
ServiceStatus,
|
||||
{ label: string; icon: typeof Check; color: string }
|
||||
> = {
|
||||
online: { label: "Operational", icon: Check, color: "text-emerald-500" },
|
||||
unknown: { label: "Pending", icon: Clock3, color: "text-muted-foreground" },
|
||||
offline: { label: "Offline", icon: WifiOff, color: "text-red-500" },
|
||||
timed_out: { label: "Timed out", icon: Clock3, color: "text-red-500" },
|
||||
http_error: {
|
||||
label: "HTTP error",
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-500",
|
||||
},
|
||||
http3_error: {
|
||||
label: "HTTP/3 error",
|
||||
icon: ShieldAlert,
|
||||
color: "text-red-500",
|
||||
},
|
||||
};
|
||||
|
||||
function ServiceRow({
|
||||
service,
|
||||
index,
|
||||
max,
|
||||
}: {
|
||||
service: Service;
|
||||
index: number;
|
||||
max: number;
|
||||
}) {
|
||||
const details = statusDetails[service.status];
|
||||
const Icon = details.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 flex items-center justify-between gap-40 py-2",
|
||||
index < max - 1
|
||||
? "border-border border-b border-[#C5CED6] dark:border-[#242D38]"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group flex items-center gap-1.5 font-medium hover:underline"
|
||||
>
|
||||
<span className="truncate">{service.name}</span>
|
||||
<ExternalLink className="size-3 opacity-0 transition-opacity group-hover:opacity-60" />
|
||||
</a>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{service.error ??
|
||||
(service.latencyMs !== null
|
||||
? `${service.latencyMs} ms${service.checkHttp3 ? " (HTTP/3 verified)" : ""}`
|
||||
: new URL(service.url).hostname)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs font-medium ${details.color}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{details.label}
|
||||
{service.statusCode && service.status === "http_error"
|
||||
? ` ${service.statusCode}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
apps/frontend/src/status.ts
Normal file
46
apps/frontend/src/status.ts
Normal 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;
|
||||
}
|
||||
36
apps/frontend/src/styles.css
Normal file
36
apps/frontend/src/styles.css
Normal 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%;
|
||||
}
|
||||
55
apps/frontend/src/use-status.ts
Normal file
55
apps/frontend/src/use-status.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
apps/frontend/src/utils.ts
Normal file
6
apps/frontend/src/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
9
apps/frontend/type-maps.yaml
Normal file
9
apps/frontend/type-maps.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
protocol_version: "1.0"
|
||||
|
||||
type_maps:
|
||||
"1.0":
|
||||
CommunicationTypes:
|
||||
GetStatus: 32
|
||||
StatusSnapshot: 33
|
||||
DataTypes:
|
||||
Payload: 32
|
||||
35
apps/frontend/vite.config.ts
Normal file
35
apps/frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { methaniumUi } from "@methanium/ui/vite";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { mtp } from "mtp/vite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
https: {
|
||||
cert: fs.readFileSync(path.resolve(root, "../../.dev/cert.pem")),
|
||||
key: fs.readFileSync(path.resolve(root, "../../.dev/key.pem")),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tanstackRouter({ target: "react", autoCodeSplitting: true }),
|
||||
tailwindcss(),
|
||||
react(),
|
||||
methaniumUi({ defaultThemeId: "methanium" }),
|
||||
mtp({ typeMaps: "./type-maps.yaml" }) as Plugin,
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@methanium/status-mtp": path.resolve(
|
||||
root,
|
||||
"../../packages/mtp/index.ts",
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
72
config.example.toml
Normal file
72
config.example.toml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
public_address = "0.0.0.0"
|
||||
public_port = 4433
|
||||
admin_address = "127.0.0.1"
|
||||
admin_port = 8091
|
||||
check_interval_seconds = 300
|
||||
timeout_seconds = 15
|
||||
certificate = ".dev/cert.pem"
|
||||
private_key = ".dev/key.pem"
|
||||
frontend_dir = "apps/frontend/dist"
|
||||
state_dir = ".dev/state"
|
||||
curl = "curl"
|
||||
|
||||
[[categories]]
|
||||
id = "methanium"
|
||||
name = "Methanium"
|
||||
logo = "apps/frontend/public/methanium.svg"
|
||||
|
||||
[[categories.services]]
|
||||
id = "methanium-homepage"
|
||||
name = "Homepage"
|
||||
url = "https://methanium.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "methanium-ui"
|
||||
name = "Methanium UI"
|
||||
url = "https://ui.methanium.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "vector-verdict"
|
||||
name = "Vector Verdict"
|
||||
url = "https://vv.methanium.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "methanium-git"
|
||||
name = "Git"
|
||||
url = "https://git.methanium.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "methanium-legal"
|
||||
name = "Legal Page"
|
||||
url = "https://legal.methanium.net"
|
||||
|
||||
[[categories]]
|
||||
id = "tensamin"
|
||||
name = "Tensamin"
|
||||
logo = "apps/frontend/public/tensamin.svg"
|
||||
|
||||
[[categories.services]]
|
||||
id = "tensamin-homepage"
|
||||
name = "Homepage"
|
||||
url = "https://tensamin.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "tensamin-app"
|
||||
name = "Web App"
|
||||
url = "https://app.tensamin.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "tensamin-dev"
|
||||
name = "Web App (Dev)"
|
||||
url = "https://dev.tensamin.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "tensamin-docs"
|
||||
name = "Docs"
|
||||
url = "https://docs.tensamin.net"
|
||||
|
||||
[[categories.services]]
|
||||
id = "tensamin-omega"
|
||||
name = "Omega"
|
||||
url = "https://omega.tensamin.net"
|
||||
check_http3 = true
|
||||
62
flake.lock
generated
Normal file
62
flake.lock
generated
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785090369,
|
||||
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "624af665418d3c65d544145b4d34ad696439570e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785302874,
|
||||
"narHash": "sha256-fpKEww3TJoo1ANHO2q918ei+ayOrp0YEQAO1DuBLOB4=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b99d48435bc3e34309d2c7ae6f7d45e77a156c38",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
406
flake.nix
Normal file
406
flake.nix
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
{
|
||||
description = "Methanium Status";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
systems = [
|
||||
"aarch64-darwin"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"x86_64-linux"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
pkgsFor =
|
||||
system:
|
||||
import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
root = toString ./.;
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
};
|
||||
source = pkgs.lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
filter =
|
||||
path: _type:
|
||||
let
|
||||
relative = pkgs.lib.removePrefix "${root}/" (toString path);
|
||||
in
|
||||
!(pkgs.lib.hasPrefix ".git/" relative)
|
||||
&& !(pkgs.lib.hasInfix "node_modules" relative)
|
||||
&& !(pkgs.lib.hasInfix "target/" relative)
|
||||
&& !(pkgs.lib.hasInfix "apps/frontend/dist/" relative)
|
||||
&& relative != "result";
|
||||
};
|
||||
frontendNativeBuildInputs = [
|
||||
pkgs.cacert
|
||||
pkgs.git
|
||||
pkgs.nodejs
|
||||
pkgs.pnpm
|
||||
pkgs.wasm-pack
|
||||
rustToolchain
|
||||
];
|
||||
frontendDeps = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "methanium-status-frontend-deps";
|
||||
version = "0.1.0";
|
||||
src = source;
|
||||
nativeBuildInputs = frontendNativeBuildInputs;
|
||||
outputHashAlgo = "sha256";
|
||||
outputHashMode = "recursive";
|
||||
outputHash = "sha256-hyNuty4/6MvRxnGIOVsqdUC0B1+LL4yGDjNUkDAUDPY=";
|
||||
dontFixup = true;
|
||||
__structuredAttrs = true;
|
||||
unsafeDiscardReferences.out = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export HOME="$TMPDIR/home"
|
||||
export CARGO_HOME="$TMPDIR/cargo"
|
||||
mkdir -p "$HOME"
|
||||
pnpm install --frozen-lockfile
|
||||
patchShebangs node_modules
|
||||
pnpm build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p "$out/apps/frontend"
|
||||
cp -a node_modules "$out/node_modules"
|
||||
cp -a apps/frontend/node_modules "$out/apps/frontend/node_modules"
|
||||
cp -a "$CARGO_HOME" "$out/cargo"
|
||||
cp -a "$HOME/.cache/.wasm-pack" "$out/wasm-pack"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
frontend = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "methanium-status-frontend";
|
||||
version = "0.1.0";
|
||||
src = source;
|
||||
nativeBuildInputs = frontendNativeBuildInputs;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
cp -a ${frontendDeps}/node_modules .
|
||||
cp -a ${frontendDeps}/apps/frontend/node_modules apps/frontend/node_modules
|
||||
cp -a ${frontendDeps}/cargo "$TMPDIR/cargo"
|
||||
chmod -R u+w node_modules apps/frontend/node_modules "$TMPDIR/cargo"
|
||||
export HOME="$TMPDIR/home"
|
||||
export CARGO_HOME="$TMPDIR/cargo"
|
||||
export CARGO_NET_OFFLINE=true
|
||||
mkdir -p "$HOME/.cache"
|
||||
cp -a ${frontendDeps}/wasm-pack "$HOME/.cache/.wasm-pack"
|
||||
chmod -R u+w "$HOME/.cache/.wasm-pack"
|
||||
pnpm build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
cp -r apps/frontend/dist "$out"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
backend = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "methanium-status";
|
||||
version = "0.1.0";
|
||||
src = source;
|
||||
cargoRoot = "apps/backend";
|
||||
buildAndTestSubdir = "apps/backend";
|
||||
MTP_TYPE_MAPS = "${source}/apps/frontend/type-maps.yaml";
|
||||
cargoLock = {
|
||||
lockFile = ./apps/backend/Cargo.lock;
|
||||
outputHashes = {
|
||||
"mtp-0.2.0" = "sha256-xfF1kRp0kDrGgTcQWN7nZHKgxXizZh4t14uHsS/MYSk=";
|
||||
};
|
||||
};
|
||||
nativeBuildInputs = [ pkgs.cmake ];
|
||||
};
|
||||
in
|
||||
{
|
||||
default = frontend;
|
||||
inherit backend frontend;
|
||||
}
|
||||
);
|
||||
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"clippy"
|
||||
"rust-src"
|
||||
"rustfmt"
|
||||
];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.cmake
|
||||
pkgs.curl
|
||||
pkgs.nodejs
|
||||
pkgs.openssl
|
||||
pkgs.pnpm
|
||||
pkgs.wasm-pack
|
||||
rustToolchain
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (system: {
|
||||
inherit (self.packages.${system}) backend frontend;
|
||||
});
|
||||
|
||||
nixosModules.default =
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.methanium-status;
|
||||
acmeHost = if cfg.acmeHost == null then cfg.hostName else cfg.acmeHost;
|
||||
serviceType = lib.types.submodule {
|
||||
options = {
|
||||
id = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Stable service identifier.";
|
||||
};
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Service display name.";
|
||||
};
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "HTTP or HTTPS URL to check.";
|
||||
};
|
||||
checkHttp3 = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether this service must also pass an HTTP/3-only check.";
|
||||
};
|
||||
};
|
||||
};
|
||||
categoryType = lib.types.submodule {
|
||||
options = {
|
||||
id = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Stable category identifier.";
|
||||
};
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Category display name.";
|
||||
};
|
||||
logo = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Local SVG, PNG, WebP, or JPEG logo file.";
|
||||
};
|
||||
services = lib.mkOption {
|
||||
type = lib.types.listOf serviceType;
|
||||
default = [ ];
|
||||
description = "Services shown and monitored in this category.";
|
||||
};
|
||||
};
|
||||
};
|
||||
configFile = (pkgs.formats.json { }).generate "methanium-status.json" {
|
||||
public_address = cfg.publicAddress;
|
||||
public_port = cfg.publicPort;
|
||||
admin_address = cfg.adminAddress;
|
||||
admin_port = cfg.adminPort;
|
||||
check_interval_seconds = cfg.checkInterval;
|
||||
timeout_seconds = cfg.timeout;
|
||||
certificate = "/var/lib/acme/${acmeHost}/fullchain.pem";
|
||||
private_key = "/var/lib/acme/${acmeHost}/key.pem";
|
||||
frontend_dir = toString cfg.frontendPackage;
|
||||
state_dir = "/var/lib/${cfg.stateDirectory}";
|
||||
curl = "${pkgs.curl}/bin/curl";
|
||||
categories = map (category: {
|
||||
inherit (category) id name;
|
||||
logo = toString category.logo;
|
||||
services = map (service: {
|
||||
inherit (service) id name url;
|
||||
check_http3 = service.checkHttp3;
|
||||
}) category.services;
|
||||
}) cfg.categories;
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.methanium-status = {
|
||||
enable = lib.mkEnableOption "Methanium Status monitoring and status page";
|
||||
package = lib.mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "backend" { };
|
||||
frontendPackage = lib.mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "frontend" { };
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "status.methanium.net";
|
||||
description = "Public status page hostname.";
|
||||
};
|
||||
acmeHost = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "ACME certificate name, defaulting to hostName.";
|
||||
};
|
||||
publicAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Address on which the UDP MTP server listens.";
|
||||
};
|
||||
publicPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Shared nginx TCP and MTP UDP public port.";
|
||||
};
|
||||
adminAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address for the unauthenticated incident editor.";
|
||||
};
|
||||
adminPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8081;
|
||||
description = "TCP port for the unauthenticated incident editor.";
|
||||
};
|
||||
checkInterval = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 300;
|
||||
description = "Seconds between service checks.";
|
||||
};
|
||||
timeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 15;
|
||||
description = "Per-check timeout in seconds.";
|
||||
};
|
||||
stateDirectory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "methanium-status";
|
||||
description = "systemd state directory name for incident history.";
|
||||
};
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open the public TCP and UDP port in the firewall.";
|
||||
};
|
||||
openAdminFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open the unauthenticated admin TCP port. Use with care.";
|
||||
};
|
||||
categories = lib.mkOption {
|
||||
type = lib.types.listOf categoryType;
|
||||
description = "Status categories and checked services.";
|
||||
default = [
|
||||
{
|
||||
id = "methanium";
|
||||
name = "Methanium";
|
||||
logo = ./apps/frontend/public/methanium.svg;
|
||||
services = [
|
||||
{ id = "methanium-homepage"; name = "Homepage"; url = "https://methanium.net"; }
|
||||
{ id = "methanium-ui"; name = "Methanium UI"; url = "https://ui.methanium.net"; }
|
||||
{ id = "vector-verdict"; name = "Vector Verdict"; url = "https://vv.methanium.net"; }
|
||||
{ id = "methanium-git"; name = "Git"; url = "https://git.methanium.net"; }
|
||||
{ id = "methanium-legal"; name = "Legal Page"; url = "https://legal.methanium.net"; }
|
||||
];
|
||||
}
|
||||
{
|
||||
id = "tensamin";
|
||||
name = "Tensamin";
|
||||
logo = ./apps/frontend/public/tensamin.svg;
|
||||
services = [
|
||||
{ id = "tensamin-homepage"; name = "Homepage"; url = "https://tensamin.net"; }
|
||||
{ id = "tensamin-app"; name = "Web App"; url = "https://app.tensamin.net"; }
|
||||
{ id = "tensamin-dev"; name = "Web App (Dev)"; url = "https://dev.tensamin.net"; }
|
||||
{ id = "tensamin-docs"; name = "Docs"; url = "https://docs.tensamin.net"; }
|
||||
{ id = "tensamin-omega"; name = "Omega"; url = "https://omega.tensamin.net"; checkHttp3 = true; }
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.groups.methanium-status = { };
|
||||
users.users.methanium-status = {
|
||||
isSystemUser = true;
|
||||
group = "methanium-status";
|
||||
};
|
||||
users.users.nginx.extraGroups = [ "methanium-status" ];
|
||||
|
||||
security.acme.certs.${acmeHost} = {
|
||||
group = "methanium-status";
|
||||
reloadServices = [ "methanium-status.service" ];
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts.${cfg.hostName} = {
|
||||
forceSSL = true;
|
||||
enableACME = cfg.acmeHost == null;
|
||||
useACMEHost = lib.mkIf (cfg.acmeHost != null) acmeHost;
|
||||
root = cfg.frontendPackage;
|
||||
listen = [
|
||||
{ addr = "0.0.0.0"; port = cfg.publicPort; ssl = true; }
|
||||
{ addr = "[::]"; port = cfg.publicPort; ssl = true; }
|
||||
];
|
||||
extraConfig = ''
|
||||
add_header Alt-Svc 'h3=":${toString cfg.publicPort}"; ma=86400' always;
|
||||
'';
|
||||
locations."/".tryFiles = "$uri $uri/ /index.html";
|
||||
locations."^~ /edit".return = "404";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.methanium-status = {
|
||||
description = "Methanium Status monitor";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" "acme-${acmeHost}.service" ];
|
||||
after = [ "network-online.target" "acme-${acmeHost}.service" ];
|
||||
restartTriggers = [ configFile ];
|
||||
serviceConfig = {
|
||||
User = "methanium-status";
|
||||
Group = "methanium-status";
|
||||
StateDirectory = cfg.stateDirectory;
|
||||
ExecStart = "${cfg.package}/bin/methanium-status ${configFile}";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
|
||||
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
|
||||
LockPersonality = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
PrivateTmp = true;
|
||||
ProtectClock = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectHome = true;
|
||||
ProtectHostname = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectSystem = "strict";
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
SystemCallArchitectures = "native";
|
||||
UMask = "0077";
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts =
|
||||
lib.optionals cfg.openFirewall [ cfg.publicPort ]
|
||||
++ lib.optionals cfg.openAdminFirewall [ cfg.adminPort ];
|
||||
networking.firewall.allowedUDPPorts = lib.optionals cfg.openFirewall [ cfg.publicPort ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "methanium-status",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm dev:cert && concurrently --kill-others --success first --names frontend,backend --prefix-colors cyan,magenta \"pnpm --filter @methanium/status-frontend dev\" \"pnpm dev:backend\"",
|
||||
"dev:cert": "mkdir -p .dev && if [ ! -f .dev/cert.pem ] || [ ! -f .dev/key.pem ] || ! openssl x509 -in .dev/cert.pem -checkend 0 -noout >/dev/null 2>&1 || ! openssl x509 -in .dev/cert.pem -noout -text | grep -q 'Public Key Algorithm: id-ecPublicKey'; then openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout .dev/key.pem -out .dev/cert.pem -days 10 -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1; fi && printf 'VITE_MTP_URL=https://127.0.0.1:4433/mtp\\nVITE_MTP_CERT_HASH=%s\\n' \"$(openssl x509 -in .dev/cert.pem -outform DER | openssl dgst -sha256 -r | cut -d ' ' -f1)\" > apps/frontend/.env.local",
|
||||
"dev:backend": "cargo run --manifest-path apps/backend/Cargo.toml -- config.example.toml",
|
||||
"build": "pnpm --filter @methanium/status-frontend build",
|
||||
"check": "tsc -p tsconfig.json && cargo check --manifest-path apps/backend/Cargo.toml",
|
||||
"lint": "oxlint apps/frontend/src packages/mtp && cargo clippy --manifest-path apps/backend/Cargo.toml -- -D warnings",
|
||||
"test": "cargo test --manifest-path apps/backend/Cargo.toml",
|
||||
"fmt": "prettier --write . && cargo fmt --manifest-path apps/backend/Cargo.toml",
|
||||
"fmt:check": "prettier --check . && cargo fmt --manifest-path apps/backend/Cargo.toml --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"oxlint": "^1.28.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
357
packages/mtp/context.tsx
Normal file
357
packages/mtp/context.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { toast } from "@methanium/ui";
|
||||
import {
|
||||
ConnectionState,
|
||||
MTPClient,
|
||||
type MTPClientOptions,
|
||||
type MTPRequestOptions,
|
||||
} from "mtp";
|
||||
import {
|
||||
createContext,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values.ts";
|
||||
|
||||
export type ProtocolMessage = {
|
||||
id?: number;
|
||||
type: string;
|
||||
data: unknown;
|
||||
sender?: bigint;
|
||||
receiver?: bigint;
|
||||
};
|
||||
|
||||
export type BoundSendFn = (
|
||||
type: string,
|
||||
data?: Record<string, unknown>,
|
||||
options?: MTPRequestOptions,
|
||||
) => Promise<ProtocolMessage>;
|
||||
|
||||
export type PushHandler = (message: ProtocolMessage) => void;
|
||||
|
||||
export type MTPExchange = {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
response: ProtocolMessage;
|
||||
};
|
||||
|
||||
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
|
||||
|
||||
type Subscription = {
|
||||
type: string;
|
||||
handler: PushHandler;
|
||||
unsubscribe?: () => void;
|
||||
};
|
||||
|
||||
type ClientWaiter = {
|
||||
resolve: (client: MTPClient) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
type ContextType = {
|
||||
send: BoundSendFn;
|
||||
subscribe: (type: string, handler: PushHandler) => () => void;
|
||||
addInterceptor: (interceptor: MTPInterceptor) => () => void;
|
||||
reconnect: () => void;
|
||||
readyState: number;
|
||||
connected: boolean;
|
||||
authenticated: boolean;
|
||||
contextReady: boolean;
|
||||
loadingDescription: string;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
const MTPContext = createContext<ContextType | undefined>(undefined);
|
||||
|
||||
export type MTPProviderProps = {
|
||||
children: ReactNode;
|
||||
options: MTPClientOptions | null;
|
||||
authenticate?: boolean;
|
||||
blockConnection?: boolean;
|
||||
};
|
||||
|
||||
export function Provider({
|
||||
children,
|
||||
options,
|
||||
authenticate = true,
|
||||
blockConnection = false,
|
||||
}: MTPProviderProps): JSX.Element {
|
||||
const [readyState, setReadyState] = useState<number>(
|
||||
ConnectionState.Disconnected,
|
||||
);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [reconnectGeneration, setReconnectGeneration] = useState(0);
|
||||
const clientRef = useRef<MTPClient | null>(null);
|
||||
const readyRef = useRef(false);
|
||||
const waitersRef = useRef(new Set<ClientWaiter>());
|
||||
const subscriptionsRef = useRef(new Set<Subscription>());
|
||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||
|
||||
const unbindSubscriptions = useCallback(() => {
|
||||
for (const subscription of subscriptionsRef.current) {
|
||||
subscription.unsubscribe?.();
|
||||
subscription.unsubscribe = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const bindSubscriptions = useCallback((client: MTPClient) => {
|
||||
for (const subscription of subscriptionsRef.current) {
|
||||
subscription.unsubscribe?.();
|
||||
subscription.unsubscribe = client.subscribe(
|
||||
subscription.type,
|
||||
(message) => subscription.handler(message as ProtocolMessage),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options || blockConnection) {
|
||||
readyRef.current = false;
|
||||
setReadyState(ConnectionState.Disconnected);
|
||||
setAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
const connectionOptions = options;
|
||||
|
||||
let disposed = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let attempts = 0;
|
||||
let connectionGeneration = 0;
|
||||
|
||||
const disconnectClient = () => {
|
||||
readyRef.current = false;
|
||||
unbindSubscriptions();
|
||||
const activeClient = clientRef.current;
|
||||
clientRef.current = null;
|
||||
activeClient?.disconnect();
|
||||
setReadyState(ConnectionState.Disconnected);
|
||||
setAuthenticated(false);
|
||||
};
|
||||
|
||||
const scheduleReconnect = (connectionError: Error) => {
|
||||
if (disposed || reconnectTimer) return;
|
||||
setError(connectionError);
|
||||
|
||||
if (attempts >= RECONNECT_TRIES) {
|
||||
toast.error("MTP connection failed", {
|
||||
id: "mtp-connection",
|
||||
description: connectionError.message,
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
toast.loading(`Reconnecting to MTP (${attempts}/${RECONNECT_TRIES})`, {
|
||||
id: "mtp-connection",
|
||||
});
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
}, RETRY_INTERVAL);
|
||||
};
|
||||
|
||||
async function connect() {
|
||||
if (disposed) return;
|
||||
|
||||
const generation = ++connectionGeneration;
|
||||
let client: MTPClient | null = null;
|
||||
try {
|
||||
disconnectClient();
|
||||
setError(null);
|
||||
setReadyState(ConnectionState.Connecting);
|
||||
|
||||
await MTPClient.init(connectionOptions.wasm);
|
||||
client = await MTPClient.create({
|
||||
...connectionOptions,
|
||||
logger: (event) => {
|
||||
connectionOptions.logger?.(event);
|
||||
if (disposed || generation !== connectionGeneration) return;
|
||||
if ((event as { type: string }).type !== "state") return;
|
||||
|
||||
const nextState = client?.state ?? ConnectionState.Disconnected;
|
||||
setReadyState(nextState);
|
||||
if (
|
||||
client !== null &&
|
||||
nextState === ConnectionState.Disconnected &&
|
||||
clientRef.current === client
|
||||
) {
|
||||
disconnectClient();
|
||||
scheduleReconnect(new Error("MTP connection lost"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (disposed || generation !== connectionGeneration) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
clientRef.current = client;
|
||||
setReadyState(client.state);
|
||||
|
||||
if (authenticate) {
|
||||
if (!client.credentials || client.credentials.clientId === null) {
|
||||
throw new Error("MTP credentials are required for authentication");
|
||||
}
|
||||
await client.auth();
|
||||
} else {
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
if (disposed || clientRef.current !== client) return;
|
||||
|
||||
readyRef.current = true;
|
||||
setReadyState(client.state);
|
||||
setAuthenticated(true);
|
||||
setError(null);
|
||||
bindSubscriptions(client);
|
||||
for (const waiter of waitersRef.current) waiter.resolve(client);
|
||||
waitersRef.current.clear();
|
||||
toast.dismiss("mtp-connection");
|
||||
|
||||
if (reconnectResetTimer) clearTimeout(reconnectResetTimer);
|
||||
reconnectResetTimer = setTimeout(() => {
|
||||
attempts = 0;
|
||||
reconnectResetTimer = null;
|
||||
}, RECONNECT_RESET * 1_000);
|
||||
} catch (cause) {
|
||||
if (disposed || generation !== connectionGeneration) {
|
||||
client?.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectClient();
|
||||
scheduleReconnect(
|
||||
cause instanceof Error ? cause : new Error(String(cause)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
connectionGeneration += 1;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (reconnectResetTimer) clearTimeout(reconnectResetTimer);
|
||||
disconnectClient();
|
||||
toast.dismiss("mtp-connection");
|
||||
|
||||
const cancellation = new Error("MTP provider was disconnected");
|
||||
for (const waiter of waitersRef.current) waiter.reject(cancellation);
|
||||
waitersRef.current.clear();
|
||||
};
|
||||
}, [
|
||||
authenticate,
|
||||
bindSubscriptions,
|
||||
blockConnection,
|
||||
options,
|
||||
reconnectGeneration,
|
||||
unbindSubscriptions,
|
||||
]);
|
||||
|
||||
const getClient = useCallback(() => {
|
||||
if (readyRef.current && clientRef.current) {
|
||||
return Promise.resolve(clientRef.current);
|
||||
}
|
||||
|
||||
if (!options || blockConnection) {
|
||||
return Promise.reject(new Error("MTP connection is not configured"));
|
||||
}
|
||||
|
||||
return new Promise<MTPClient>((resolve, reject) => {
|
||||
waitersRef.current.add({ resolve, reject });
|
||||
});
|
||||
}, [blockConnection, options]);
|
||||
|
||||
const send = useCallback<BoundSendFn>(
|
||||
async (type, data = {}, requestOptions) => {
|
||||
const client = await getClient();
|
||||
const response = await client.request(type, data, requestOptions);
|
||||
const message = response as ProtocolMessage;
|
||||
|
||||
for (const interceptor of interceptorsRef.current) {
|
||||
void Promise.resolve(
|
||||
interceptor({ type, data, response: message }),
|
||||
).catch((interceptorError) => {
|
||||
console.error("MTP interceptor failed", interceptorError);
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
},
|
||||
[getClient],
|
||||
);
|
||||
|
||||
const subscribe = useCallback((type: string, handler: PushHandler) => {
|
||||
const subscription: Subscription = { type, handler };
|
||||
subscriptionsRef.current.add(subscription);
|
||||
|
||||
if (readyRef.current && clientRef.current) {
|
||||
subscription.unsubscribe = clientRef.current.subscribe(type, (message) =>
|
||||
handler(message as ProtocolMessage),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe?.();
|
||||
subscriptionsRef.current.delete(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
||||
interceptorsRef.current.add(interceptor);
|
||||
return () => interceptorsRef.current.delete(interceptor);
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
setReconnectGeneration((generation) => generation + 1);
|
||||
}, []);
|
||||
|
||||
const connected = readyState === ConnectionState.Connected;
|
||||
const contextReady = connected && authenticated;
|
||||
const loadingDescription = useMemo(() => {
|
||||
if (!options) return "Waiting for connection configuration";
|
||||
if (error) return error.message;
|
||||
if (readyState === ConnectionState.Connecting || !connected) {
|
||||
return "Establishing transport channel";
|
||||
}
|
||||
if (!authenticated) return "Waiting for authenticated session";
|
||||
return "Connected";
|
||||
}, [authenticated, connected, error, options, readyState]);
|
||||
|
||||
return (
|
||||
<MTPContext.Provider
|
||||
value={{
|
||||
send,
|
||||
subscribe,
|
||||
addInterceptor,
|
||||
reconnect,
|
||||
readyState,
|
||||
connected,
|
||||
authenticated,
|
||||
contextReady,
|
||||
loadingDescription,
|
||||
error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MTPContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMTP(): ContextType {
|
||||
const context = useContext(MTPContext);
|
||||
if (!context) throw new Error("useMTP must be used within an MTP Provider");
|
||||
return context;
|
||||
}
|
||||
10
packages/mtp/index.ts
Normal file
10
packages/mtp/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export { Provider, useMTP } from "./context.tsx";
|
||||
export type {
|
||||
BoundSendFn,
|
||||
MTPExchange,
|
||||
MTPInterceptor,
|
||||
MTPProviderProps,
|
||||
ProtocolMessage,
|
||||
PushHandler,
|
||||
} from "./context.tsx";
|
||||
export { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values.ts";
|
||||
18
packages/mtp/package.json
Normal file
18
packages/mtp/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@methanium/status-mtp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@methanium/ui": "*",
|
||||
"mtp": "*",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@methanium/ui": "*",
|
||||
"mtp": "*",
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
}
|
||||
3
packages/mtp/values.ts
Normal file
3
packages/mtp/values.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const RETRY_INTERVAL = 3_000;
|
||||
export const RECONNECT_TRIES = 3;
|
||||
export const RECONNECT_RESET = 6;
|
||||
6991
pnpm-lock.yaml
generated
Normal file
6991
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
12
pnpm-workspace.yaml
Normal file
12
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
|
||||
nodeLinker: hoisted
|
||||
|
||||
overrides:
|
||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz"
|
||||
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz"
|
||||
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@methanium/status-mtp": ["./packages/mtp/index.ts"],
|
||||
"mtp/raw": ["./apps/frontend/node_modules/.vite/mtp/mtp_wasm.d.ts"],
|
||||
"mtp/type-map": [
|
||||
"./apps/frontend/node_modules/.vite/mtp/mtp_type_map.d.ts"
|
||||
]
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2023",
|
||||
"types": ["node", "vite/client"],
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": [
|
||||
"apps/frontend/src",
|
||||
"apps/frontend/vite.config.ts",
|
||||
"packages/mtp"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue