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