generated from methanium/template
Add basic status page
This commit is contained in:
parent
0e4d64e6f2
commit
de54eb0b90
43 changed files with 6080 additions and 244 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 }
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,3 +3,5 @@ dist/
|
|||
.vite/
|
||||
.direnv/
|
||||
result
|
||||
.dev/
|
||||
.env.local
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
apps/web/dist/
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/frontend/dist/
|
||||
apps/frontend/src/routeTree.gen.ts
|
||||
apps/backend/target/
|
||||
node_modules/
|
||||
result
|
||||
|
|
|
|||
83
README.md
83
README.md
|
|
@ -1,47 +1,86 @@
|
|||
# Methanium Template
|
||||
# Methanium Status
|
||||
|
||||
A pnpm React template with Vite, Tailwind CSS, Methanium UI, MTP, TanStack Router, and a base nix flake.
|
||||
Live availability and incident reporting for Methanium and Tensamin services. The Rust backend checks configured URLs every five minutes, verifies HTTP/3 where requested, persists incident history, and pushes snapshots to the React status page over MTP/WebTransport.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
pnpm install
|
||||
pnpm check
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Configure MTP
|
||||
`pnpm dev` generates a short-lived localhost certificate, uses it for the HTTPS frontend and WebTransport backend, and writes the matching WebTransport certificate hash. The public page is available on Vite's displayed HTTPS URL and the incident editor is available at `http://127.0.0.1:8091/edit` after the frontend has been built once.
|
||||
|
||||
1. Define the host's communication and data types in `apps/web/type-maps.yaml`.
|
||||
2. Set the connection options in `apps/web/src/mtp-options.ts`.
|
||||
|
||||
The reusable provider and `useMTP` hook are in `packages/mtp/`.
|
||||
|
||||
## Nix
|
||||
|
||||
Build the static frontend:
|
||||
For a manual backend configuration, copy `config.example.toml`, update the certificate paths, build the frontend, and run:
|
||||
|
||||
```sh
|
||||
nix build
|
||||
pnpm build
|
||||
cargo run --manifest-path apps/backend/Cargo.toml -- ./config.toml
|
||||
```
|
||||
|
||||
To serve it with nginx on NixOS, import the included module:
|
||||
Set `VITE_MTP_URL` when the development MTP endpoint is not available at the frontend's own `/mtp` origin.
|
||||
|
||||
## Configuration
|
||||
|
||||
The backend accepts TOML or JSON. Categories contain a local logo and a list of services. Service names and URLs are not editable through the incident UI.
|
||||
|
||||
```toml
|
||||
[[categories]]
|
||||
id = "example"
|
||||
name = "Example"
|
||||
logo = "/path/to/example.svg"
|
||||
|
||||
[[categories.services]]
|
||||
id = "example-home"
|
||||
name = "Homepage"
|
||||
url = "https://example.com"
|
||||
check_http3 = true
|
||||
```
|
||||
|
||||
HTTP 200 through 399 is operational. DNS, connection, and TLS failures are reported as offline; timeouts, HTTP error responses, and HTTP/3 failures have distinct labels. Every non-operational service state uses red on the public page.
|
||||
|
||||
The admin page is available at `/edit` on the separately configured admin address and port. It has intentionally no authentication, so the backend requires the admin address to be loopback. The NixOS module binds it to `127.0.0.1:8081` and does not open its firewall port by default. For remote use, prefer an SSH tunnel:
|
||||
|
||||
```sh
|
||||
ssh -L 8081:127.0.0.1:8081 status-host
|
||||
```
|
||||
|
||||
## NixOS
|
||||
|
||||
The included module serves the SPA with nginx on TCP 443 and MTP HTTP/3/WebTransport on UDP 443. Both use the same ACME certificate and origin.
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.methanium-template.url =
|
||||
"git+https://git.methanium.net/methanium/template";
|
||||
inputs.methanium-status.url =
|
||||
"git+https://git.methanium.net/methanium/status";
|
||||
|
||||
outputs = {nixpkgs, methanium-template, ...}: {
|
||||
outputs = { nixpkgs, methanium-status, ... }: {
|
||||
nixosConfigurations.host = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
methanium-template.nixosModules.default
|
||||
methanium-status.nixosModules.default
|
||||
{
|
||||
services.methanium-template = {
|
||||
security.acme.acceptTerms = true;
|
||||
security.acme.defaults.email = "admin@example.com";
|
||||
|
||||
services.methanium-status = {
|
||||
enable = true;
|
||||
hostName = "app.example.com";
|
||||
hostName = "status.example.com";
|
||||
openFirewall = true;
|
||||
|
||||
categories = [{
|
||||
id = "example";
|
||||
name = "Example";
|
||||
logo = ./example.svg;
|
||||
services = [{
|
||||
id = "example-home";
|
||||
name = "Homepage";
|
||||
url = "https://example.com";
|
||||
checkHttp3 = false;
|
||||
}];
|
||||
}];
|
||||
};
|
||||
}
|
||||
];
|
||||
|
|
@ -49,3 +88,9 @@ To serve it with nginx on NixOS, import the included module:
|
|||
};
|
||||
}
|
||||
```
|
||||
|
||||
Use `acmeHost` to share an already configured NixOS ACME certificate. The service options also expose the check interval, timeout, public listener, admin listener, state directory, packages, firewall behavior, categories, logo paths, and services.
|
||||
|
||||
Contact: [contact@methanium.net](mailto:contact@methanium.net)
|
||||
|
||||
Security: [security@methanium.net](mailto:security@methanium.net)
|
||||
|
|
|
|||
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"
|
||||
76
apps/backend/src/admin.rs
Normal file
76
apps/backend/src/admin.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use tower_http::{
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AppState, incidents::IncidentError, model::NewIncident};
|
||||
|
||||
pub async fn serve(
|
||||
address: SocketAddr,
|
||||
frontend: PathBuf,
|
||||
state: Arc<AppState>,
|
||||
) -> std::io::Result<()> {
|
||||
let index = frontend.join("index.html");
|
||||
let assets = ServeDir::new(frontend).fallback(ServeFile::new(index));
|
||||
let app = Router::new()
|
||||
.route("/api/admin", get(admin))
|
||||
.route("/api/incidents", get(list).post(create))
|
||||
.route("/api/incidents/{id}/resolve", post(resolve))
|
||||
.fallback_service(assets)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
let listener = tokio::net::TcpListener::bind(address).await?;
|
||||
tracing::info!(%address, "admin page listening");
|
||||
axum::serve(listener, app).await
|
||||
}
|
||||
|
||||
async fn admin() -> StatusCode {
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
async fn list(State(state): State<Arc<AppState>>) -> Json<Vec<crate::model::Incident>> {
|
||||
Json(state.incidents.list().await)
|
||||
}
|
||||
|
||||
async fn create(State(state): State<Arc<AppState>>, Json(input): Json<NewIncident>) -> Response {
|
||||
match state.incidents.create(input).await {
|
||||
Ok(incident) => {
|
||||
state.publish().await;
|
||||
(StatusCode::CREATED, Json(incident)).into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
|
||||
match state.incidents.resolve(id).await {
|
||||
Ok(incident) => {
|
||||
state.publish().await;
|
||||
Json(incident).into_response()
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(error: IncidentError) -> Response {
|
||||
let status = match error {
|
||||
IncidentError::Empty => StatusCode::BAD_REQUEST,
|
||||
IncidentError::NotFound => StatusCode::NOT_FOUND,
|
||||
IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({ "error": error.to_string() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
142
apps/backend/src/config.rs
Normal file
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(())
|
||||
}
|
||||
128
apps/backend/src/incidents.rs
Normal file
128
apps/backend/src/incidents.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use std::{io::ErrorKind, path::PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{Incident, NewIncident};
|
||||
|
||||
pub struct IncidentStore {
|
||||
path: PathBuf,
|
||||
incidents: Mutex<Vec<Incident>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum IncidentError {
|
||||
#[error("incident title and message cannot be empty")]
|
||||
Empty,
|
||||
#[error("incident not found")]
|
||||
NotFound,
|
||||
#[error("failed to persist incidents: {0}")]
|
||||
Storage(#[from] std::io::Error),
|
||||
#[error("failed to parse incidents: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl IncidentStore {
|
||||
pub async fn load(path: PathBuf) -> Result<Self, IncidentError> {
|
||||
let incidents = match tokio::fs::read(&path).await {
|
||||
Ok(contents) => serde_json::from_slice(&contents)?,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
Ok(Self {
|
||||
path,
|
||||
incidents: Mutex::new(incidents),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<Incident> {
|
||||
self.incidents.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn create(&self, input: NewIncident) -> Result<Incident, IncidentError> {
|
||||
if input.title.trim().is_empty() || input.message.trim().is_empty() {
|
||||
return Err(IncidentError::Empty);
|
||||
}
|
||||
let incident = Incident {
|
||||
id: Uuid::new_v4(),
|
||||
title: input.title.trim().to_owned(),
|
||||
message: input.message.trim().to_owned(),
|
||||
severity: input.severity,
|
||||
created_at: Utc::now(),
|
||||
resolved_at: None,
|
||||
};
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
incidents.push(incident.clone());
|
||||
self.persist(&incidents).await?;
|
||||
Ok(incident)
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, id: Uuid) -> Result<Incident, IncidentError> {
|
||||
let mut incidents = self.incidents.lock().await;
|
||||
let incident = incidents
|
||||
.iter_mut()
|
||||
.find(|incident| incident.id == id)
|
||||
.ok_or(IncidentError::NotFound)?;
|
||||
if incident.resolved_at.is_none() {
|
||||
incident.resolved_at = Some(Utc::now());
|
||||
}
|
||||
let resolved = incident.clone();
|
||||
self.persist(&incidents).await?;
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn persist(&self, incidents: &[Incident]) -> Result<(), std::io::Error> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let temporary = self.path.with_extension("json.tmp");
|
||||
let contents = serde_json::to_vec_pretty(incidents).map_err(std::io::Error::other)?;
|
||||
tokio::fs::write(&temporary, contents).await?;
|
||||
tokio::fs::rename(temporary, &self.path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::model::{NewIncident, Severity};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_created_and_resolved_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("incidents.json");
|
||||
let store = IncidentStore::load(path.clone()).await.unwrap();
|
||||
let created = store
|
||||
.create(NewIncident {
|
||||
title: "Service disruption".into(),
|
||||
message: "Investigating elevated errors.".into(),
|
||||
severity: Severity::Major,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store.resolve(created.id).await.unwrap();
|
||||
|
||||
let reloaded = IncidentStore::load(path).await.unwrap().list().await;
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
assert!(reloaded[0].resolved_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_incidents() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let store = IncidentStore::load(directory.path().join("incidents.json"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = store
|
||||
.create(NewIncident {
|
||||
title: " ".into(),
|
||||
message: "Details".into(),
|
||||
severity: Severity::Minor,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(result, Err(IncidentError::Empty)));
|
||||
}
|
||||
}
|
||||
142
apps/backend/src/main.rs
Normal file
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>
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#171e26" />
|
||||
<title>Methanium Vite Template</title>
|
||||
<title>Methanium Status</title>
|
||||
<style>
|
||||
html.dark,
|
||||
html.dark body {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@methanium/template-web",
|
||||
"name": "@methanium/status-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@methanium/template-mtp": "workspace:*",
|
||||
"@methanium/status-mtp": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tanstack/react-router": "^1.131.35",
|
||||
"lucide-react": "^1.21.0",
|
||||
|
Before Width: | Height: | Size: 1.7 KiB 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 |
|
|
@ -1,11 +1,12 @@
|
|||
import { Moon, Sun } from "lucide-react";
|
||||
import { ExternalLink, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button, useTheme } from "@methanium/ui";
|
||||
|
||||
const homepageUrl = "https://methanium.net";
|
||||
import { useStatus } from "../../use-status";
|
||||
import { formatTime } from "../../utils";
|
||||
|
||||
export function Header() {
|
||||
const { resolvedPolarity, setTheme } = useTheme();
|
||||
const { snapshot, error } = useStatus();
|
||||
const isDark = resolvedPolarity === "dark";
|
||||
|
||||
return (
|
||||
|
|
@ -29,13 +30,27 @@ export function Header() {
|
|||
nativeButton={false}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
render={<a href={homepageUrl} />}
|
||||
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
render={<a target="_blank" href="mailto:contact@methanium.net" />}
|
||||
>
|
||||
Homepage
|
||||
Contact us <ExternalLink />
|
||||
</Button>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
render={<a target="_blank" href="mailto:security@methanium.net" />}
|
||||
>
|
||||
Report Security Issue <ExternalLink />
|
||||
</Button>
|
||||
</nav>
|
||||
<div className="flex h-full items-center gap-1">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{snapshot?.generatedAt
|
||||
? `Updated ${formatTime(snapshot.generatedAt)}`
|
||||
: (error?.message ?? "Waiting for data")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import "./styles.css";
|
||||
|
||||
import { BUILT_IN_THEMES, ThemeProvider } from "@methanium/ui";
|
||||
import { Provider as MTPProvider } from "@methanium/template-mtp";
|
||||
import { Provider as MTPProvider } from "@methanium/status-mtp";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
|
@ -24,7 +24,7 @@ createRoot(document.getElementById("root")!).render(
|
|||
themes={BUILT_IN_THEMES}
|
||||
defaultParentThemeId="methanium"
|
||||
>
|
||||
<MTPProvider options={mtpOptions}>
|
||||
<MTPProvider options={mtpOptions} authenticate={false}>
|
||||
<RouterProvider router={router} />
|
||||
</MTPProvider>
|
||||
</ThemeProvider>
|
||||
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,
|
||||
};
|
||||
|
|
@ -10,33 +10,43 @@
|
|||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as EditRouteImport } from './routes/edit'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EditRoute = EditRouteImport.update({
|
||||
id: '/edit',
|
||||
path: '/edit',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/edit': typeof EditRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/edit': typeof EditRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/edit': typeof EditRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/'
|
||||
fullPaths: '/' | '/edit'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/'
|
||||
id: '__root__' | '/'
|
||||
to: '/' | '/edit'
|
||||
id: '__root__' | '/' | '/edit'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
EditRoute: typeof EditRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -48,11 +58,19 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/edit': {
|
||||
id: '/edit'
|
||||
path: '/edit'
|
||||
fullPath: '/edit'
|
||||
preLoaderRoute: typeof EditRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
EditRoute: EditRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
@ -3,7 +3,9 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
|
|||
import { Header } from "../components/layout/Header";
|
||||
|
||||
const EmptyPage = () => (
|
||||
<main className="min-h-[calc(100vh-3rem)] bg-background" />
|
||||
<main className="min-h-[calc(100vh-3rem)] bg-background text-2xl w-full h-full items-center justify-center flex font-medium">
|
||||
404 Page Not Found
|
||||
</main>
|
||||
);
|
||||
|
||||
export const Route = createRootRoute({
|
||||
250
apps/frontend/src/routes/edit.tsx
Normal file
250
apps/frontend/src/routes/edit.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { Button } from "@methanium/ui";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { CheckCircle2, Loader2, ShieldAlert } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
|
||||
import type { Incident, Severity } from "../status";
|
||||
|
||||
export const Route = createFileRoute("/edit")({
|
||||
beforeLoad: async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin", { cache: "no-store" });
|
||||
if (response.status !== 204) throw notFound();
|
||||
} catch {
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
component: IncidentEditor,
|
||||
});
|
||||
|
||||
function IncidentEditor() {
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch("/api/incidents", { cache: "no-store" });
|
||||
if (!response.ok)
|
||||
throw new Error(`Request failed with HTTP ${response.status}`);
|
||||
setIncidents((await response.json()) as Incident[]);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
async function create(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const response = await fetch("/api/incidents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: form.get("title"),
|
||||
message: form.get("message"),
|
||||
severity: form.get("severity"),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json()) as { error?: string };
|
||||
throw new Error(
|
||||
body.error ?? `Request failed with HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
event.currentTarget.reset();
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(id: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/incidents/${id}/resolve`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Request failed with HTTP ${response.status}`);
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
const active = incidents.filter((incident) => !incident.resolvedAt);
|
||||
const resolved = incidents
|
||||
.filter((incident) => incident.resolvedAt)
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<main className="status-canvas min-h-[calc(100vh-3.5rem)]">
|
||||
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div className="mb-8 flex items-start gap-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-red-500/10 text-red-500">
|
||||
<ShieldAlert className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Unprotected administration
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-semibold tracking-tight">
|
||||
Incident editor
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This interface has no login and should only be exposed on a
|
||||
trusted network.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-600 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[22rem_1fr]">
|
||||
<form
|
||||
onSubmit={create}
|
||||
className="h-fit rounded-2xl border border-border bg-card/80 p-5 shadow-sm"
|
||||
>
|
||||
<h2 className="font-semibold">Create incident</h2>
|
||||
<label
|
||||
className="mt-5 block text-xs font-semibold text-muted-foreground"
|
||||
htmlFor="title"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
id="title"
|
||||
name="title"
|
||||
maxLength={120}
|
||||
className="mt-2 h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<label
|
||||
className="mt-4 block text-xs font-semibold text-muted-foreground"
|
||||
htmlFor="message"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
id="message"
|
||||
name="message"
|
||||
maxLength={2000}
|
||||
rows={5}
|
||||
className="mt-2 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<label
|
||||
className="mt-4 block text-xs font-semibold text-muted-foreground"
|
||||
htmlFor="severity"
|
||||
>
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
id="severity"
|
||||
name="severity"
|
||||
defaultValue="minor"
|
||||
className="mt-2 h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{(["minor", "major", "critical"] as Severity[]).map(
|
||||
(severity) => (
|
||||
<option key={severity} value={severity}>
|
||||
{severity[0].toUpperCase() + severity.slice(1)}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<Button type="submit" disabled={submitting} className="mt-5 w-full">
|
||||
{submitting && <Loader2 className="size-4 animate-spin" />}Publish
|
||||
incident
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 font-semibold">
|
||||
Active incidents{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{active.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading incidents
|
||||
</div>
|
||||
)}
|
||||
{!loading && active.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
No active incidents
|
||||
</div>
|
||||
)}
|
||||
{active.map((incident) => (
|
||||
<article
|
||||
key={incident.id}
|
||||
className="rounded-xl border border-border bg-card/80 p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em] text-red-500">
|
||||
{incident.severity}
|
||||
</span>
|
||||
<h3 className="mt-1 font-semibold">{incident.title}</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void resolve(incident.id)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
|
||||
{incident.message}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{resolved.length > 0 && (
|
||||
<>
|
||||
<h2 className="mb-3 mt-8 font-semibold">Resolved history</h2>
|
||||
<div className="space-y-2">
|
||||
{resolved.map((incident) => (
|
||||
<div
|
||||
key={incident.id}
|
||||
className="rounded-lg border border-border/70 bg-card/50 px-4 py-3"
|
||||
>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{incident.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(incident.resolvedAt!).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
302
apps/frontend/src/routes/index.tsx
Normal file
302
apps/frontend/src/routes/index.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Incident, Service, ServiceStatus, Severity } from "../status";
|
||||
import { useStatus } from "../use-status";
|
||||
import { formatTime } from "../utils";
|
||||
import {
|
||||
Button,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
cn,
|
||||
} from "@methanium/ui";
|
||||
|
||||
export const Route = createFileRoute("/")({ component: Home });
|
||||
|
||||
function Home() {
|
||||
const { snapshot } = useStatus();
|
||||
const services =
|
||||
snapshot?.categories.flatMap((category) => category.services) ?? [];
|
||||
const failures = services.filter(
|
||||
(service) => service.status !== "online" && service.status !== "unknown",
|
||||
);
|
||||
const activeIncidents =
|
||||
snapshot?.incidents.filter((incident) => !incident.resolvedAt) ?? [];
|
||||
const pending = services.some((service) => service.status === "unknown");
|
||||
const ready = snapshot !== null && !pending;
|
||||
const healthy =
|
||||
ready && failures.length === 0 && activeIncidents.length === 0;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col pb-5">
|
||||
<div className="w-full flex gap-3 justify-center items-center py-10">
|
||||
<div>
|
||||
{healthy ? (
|
||||
<Check className="size-7" />
|
||||
) : ready ? (
|
||||
<AlertTriangle className="size-7" />
|
||||
) : (
|
||||
<RefreshCw className="size-7" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col justify-start">
|
||||
<p className="text-lg">
|
||||
{healthy
|
||||
? "Everythin's alright on our side"
|
||||
: ready
|
||||
? `${failures.length + activeIncidents.length} active disruption${failures.length + activeIncidents.length === 1 ? "" : "s"}`
|
||||
: "Scraping our own data..."}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{!healthy && ready
|
||||
? "More details below."
|
||||
: snapshot
|
||||
? ""
|
||||
: "Connecting to Status Page backend..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-1 justify-center gap-6 pb-10 sm:grid-cols-[repeat(auto-fit,24rem)]">
|
||||
{activeIncidents.map((incident) => (
|
||||
<ActiveIncidentCard key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col lg:flex-row! gap-15 items-center justify-center">
|
||||
{snapshot?.categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="pt-4 text-sm flex flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
|
||||
>
|
||||
<CardHeader className="flex gap-3 items-center">
|
||||
<img src={category.logo} alt={category.name} className="size-8" />
|
||||
|
||||
<p className="text-lg font-medium">{category.name}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0! flex flex-col gap-2 bg-[#E2E7EB] dark:bg-[#1D262F]">
|
||||
{category.services.map((service, index) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
index={index}
|
||||
max={category.services.length}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!snapshot?.incidents.some((incident) => incident.resolvedAt) && (
|
||||
<section className="mt-12" aria-labelledby="resolved-incidents">
|
||||
<h2 id="resolved-incidents" className="mb-4 text-lg font-semibold">
|
||||
Recently resolved
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{snapshot.incidents
|
||||
.filter((incident) => incident.resolvedAt)
|
||||
.reverse()
|
||||
.slice(0, 5)
|
||||
.map((incident) => (
|
||||
<IncidentCard key={incident.id} incident={incident} resolved />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveIncidentCard({ incident }: { incident: Incident }) {
|
||||
const messageRef = useRef<HTMLParagraphElement>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const message = messageRef.current;
|
||||
if (!message) return;
|
||||
|
||||
const update = () =>
|
||||
setTruncated(message.scrollHeight > message.clientHeight + 1);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(message);
|
||||
return () => observer.disconnect();
|
||||
}, [incident.message]);
|
||||
|
||||
const severity =
|
||||
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
|
||||
|
||||
return (
|
||||
<article className="flex h-52 w-96 max-w-[calc(100vw-2rem)] flex-col gap-4 overflow-hidden rounded-xl border border-[#C5CED6] bg-[#EDF1F4] pt-4 text-sm dark:border-[#242D38] dark:bg-[#242D38]">
|
||||
<CardHeader className="flex items-center gap-3">
|
||||
<p className="line-clamp-2 text-lg font-medium [overflow-wrap:anywhere]">
|
||||
{incident.title}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 bg-[#E2E7EB] px-0! dark:bg-[#1D262F]">
|
||||
<p
|
||||
ref={messageRef}
|
||||
className="line-clamp-3 whitespace-pre-wrap px-4 pt-2 leading-5 [overflow-wrap:anywhere]"
|
||||
>
|
||||
{incident.message}
|
||||
</p>
|
||||
{truncated && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto self-start px-4 py-0"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{incident.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{severity} incident started {formatTime(incident.createdAt)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
|
||||
{incident.message}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<div className="mt-auto flex gap-3 border-t border-[#C5CED6] px-4 py-2 dark:border-[#242D38]">
|
||||
<p>{severity}</p>
|
||||
<p>Started {formatTime(incident.createdAt)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const statusDetails: Record<
|
||||
ServiceStatus,
|
||||
{ label: string; icon: typeof Check; color: string }
|
||||
> = {
|
||||
online: { label: "Operational", icon: Check, color: "text-emerald-500" },
|
||||
unknown: { label: "Pending", icon: Clock3, color: "text-muted-foreground" },
|
||||
offline: { label: "Offline", icon: WifiOff, color: "text-red-500" },
|
||||
timed_out: { label: "Timed out", icon: Clock3, color: "text-red-500" },
|
||||
http_error: {
|
||||
label: "HTTP error",
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-500",
|
||||
},
|
||||
http3_error: {
|
||||
label: "HTTP/3 error",
|
||||
icon: ShieldAlert,
|
||||
color: "text-red-500",
|
||||
},
|
||||
};
|
||||
|
||||
function ServiceRow({
|
||||
service,
|
||||
index,
|
||||
max,
|
||||
}: {
|
||||
service: Service;
|
||||
index: number;
|
||||
max: number;
|
||||
}) {
|
||||
const details = statusDetails[service.status];
|
||||
const Icon = details.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 flex items-center justify-between gap-40 py-2",
|
||||
index < max - 1
|
||||
? "border-border border-b border-[#C5CED6] dark:border-[#242D38]"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group flex items-center gap-1.5 font-medium hover:underline"
|
||||
>
|
||||
<span className="truncate">{service.name}</span>
|
||||
<ExternalLink className="size-3 opacity-0 transition-opacity group-hover:opacity-60" />
|
||||
</a>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{service.error ??
|
||||
(service.latencyMs !== null
|
||||
? `${service.latencyMs} ms${service.checkHttp3 ? " (HTTP/3 verified)" : ""}`
|
||||
: new URL(service.url).hostname)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs font-medium ${details.color}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{details.label}
|
||||
{service.statusCode && service.status === "http_error"
|
||||
? ` ${service.statusCode}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const severityStyles: Record<Severity, string> = {
|
||||
minor:
|
||||
"border-amber-500/30 bg-amber-500/[0.07] text-amber-700 dark:text-amber-300",
|
||||
major:
|
||||
"border-orange-500/30 bg-orange-500/[0.07] text-orange-700 dark:text-orange-300",
|
||||
critical:
|
||||
"border-red-500/35 bg-red-500/[0.08] text-red-700 dark:text-red-300",
|
||||
};
|
||||
|
||||
function IncidentCard({
|
||||
incident,
|
||||
resolved = false,
|
||||
}: {
|
||||
incident: Incident;
|
||||
resolved?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={`rounded-xl border p-5 ${resolved ? "border-border bg-card/60" : severityStyles[incident.severity]}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-semibold">{incident.title}</h3>
|
||||
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em]">
|
||||
{resolved ? "Resolved" : incident.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-2 text-sm leading-6 ${resolved ? "text-muted-foreground" : "text-foreground/80"}`}
|
||||
>
|
||||
{incident.message}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{resolved && incident.resolvedAt
|
||||
? `Resolved ${formatTime(incident.resolvedAt)}`
|
||||
: `Started ${formatTime(incident.createdAt)}`}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
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
|
||||
|
|
@ -3,6 +3,7 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { mtp } from "mtp/vite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
|
@ -10,6 +11,12 @@ import { defineConfig, type Plugin } from "vite";
|
|||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
https: {
|
||||
cert: fs.readFileSync(path.resolve(root, "../../.dev/cert.pem")),
|
||||
key: fs.readFileSync(path.resolve(root, "../../.dev/key.pem")),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tanstackRouter({ target: "react", autoCodeSplitting: true }),
|
||||
tailwindcss(),
|
||||
|
|
@ -19,7 +26,7 @@ export default defineConfig({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@methanium/template-mtp": path.resolve(
|
||||
"@methanium/status-mtp": path.resolve(
|
||||
root,
|
||||
"../../packages/mtp/index.ts",
|
||||
),
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import type { MTPClientOptions } from "mtp";
|
||||
|
||||
// Replace null with your application's stable MTP connection configuration.
|
||||
export const mtpOptions: MTPClientOptions | null = null;
|
||||
|
||||
// Example:
|
||||
// export const mtpOptions: MTPClientOptions = {
|
||||
// url: "https://mtp.example.com:4433",
|
||||
// credentials: { clientId: 1, keyring: new Uint8Array([...]) },
|
||||
// hostPublicKey: "...",
|
||||
// descriptor: "web",
|
||||
// pings: true,
|
||||
// };
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({ component: Home });
|
||||
|
||||
function Home() {
|
||||
return <main className="min-h-[calc(100vh-3rem)] bg-background" />;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
@import "@methanium/ui/index.css";
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
protocol_version: "1.0"
|
||||
|
||||
type_maps:
|
||||
"1.0":
|
||||
CommunicationTypes:
|
||||
ExampleRequest: 32
|
||||
ExampleResponse: 33
|
||||
DataTypes:
|
||||
Message: 32
|
||||
ReceivedAt: 33
|
||||
72
config.example.toml
Normal file
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
|
||||
323
flake.nix
323
flake.nix
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
description = "Methanium Template";
|
||||
description = "Methanium Status";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
|
@ -7,12 +7,7 @@
|
|||
};
|
||||
|
||||
outputs =
|
||||
inputs@{
|
||||
self,
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
...
|
||||
}:
|
||||
{ self, nixpkgs, rust-overlay, ... }:
|
||||
let
|
||||
systems = [
|
||||
"aarch64-darwin"
|
||||
|
|
@ -44,15 +39,13 @@
|
|||
let
|
||||
relative = pkgs.lib.removePrefix "${root}/" (toString path);
|
||||
in
|
||||
!(pkgs.lib.hasPrefix "node_modules/" relative)
|
||||
&& !(pkgs.lib.hasPrefix "apps/web/node_modules/" relative)
|
||||
&& !(pkgs.lib.hasPrefix "apps/web/dist/" relative)
|
||||
&& relative != "node_modules"
|
||||
&& relative != "apps/web/node_modules"
|
||||
&& relative != "apps/web/dist"
|
||||
!(pkgs.lib.hasPrefix ".git/" relative)
|
||||
&& !(pkgs.lib.hasInfix "node_modules" relative)
|
||||
&& !(pkgs.lib.hasInfix "target/" relative)
|
||||
&& !(pkgs.lib.hasInfix "apps/frontend/dist/" relative)
|
||||
&& relative != "result";
|
||||
};
|
||||
nativeBuildInputs = [
|
||||
frontendNativeBuildInputs = [
|
||||
pkgs.cacert
|
||||
pkgs.git
|
||||
pkgs.nodejs
|
||||
|
|
@ -60,22 +53,14 @@
|
|||
pkgs.wasm-pack
|
||||
rustToolchain
|
||||
];
|
||||
webDeps = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "methanium-template-web-deps";
|
||||
frontendDeps = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "methanium-status-frontend-deps";
|
||||
version = "0.1.0";
|
||||
src = source;
|
||||
nativeBuildInputs = [
|
||||
pkgs.cacert
|
||||
pkgs.git
|
||||
pkgs.nodejs
|
||||
pkgs.pnpm
|
||||
pkgs.wasm-pack
|
||||
rustToolchain
|
||||
];
|
||||
|
||||
nativeBuildInputs = frontendNativeBuildInputs;
|
||||
outputHashAlgo = "sha256";
|
||||
outputHashMode = "recursive";
|
||||
outputHash = "sha256-ihavd0hJDptdyEtjWjVgghwAl/rn6aUdQ9CT3B4if3E=";
|
||||
outputHash = "sha256-hyNuty4/6MvRxnGIOVsqdUC0B1+LL4yGDjNUkDAUDPY=";
|
||||
dontFixup = true;
|
||||
__structuredAttrs = true;
|
||||
unsafeDiscardReferences.out = true;
|
||||
|
|
@ -93,31 +78,31 @@
|
|||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p "$out/apps/web"
|
||||
mkdir -p "$out/apps/frontend"
|
||||
cp -a node_modules "$out/node_modules"
|
||||
cp -a apps/web/node_modules "$out/apps/web/node_modules"
|
||||
cp -a apps/frontend/node_modules "$out/apps/frontend/node_modules"
|
||||
cp -a "$CARGO_HOME" "$out/cargo"
|
||||
cp -a "$HOME/.cache/.wasm-pack" "$out/wasm-pack"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
web = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "methanium-template-web";
|
||||
frontend = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "methanium-status-frontend";
|
||||
version = "0.1.0";
|
||||
src = source;
|
||||
inherit nativeBuildInputs;
|
||||
nativeBuildInputs = frontendNativeBuildInputs;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
cp -a ${webDeps}/node_modules .
|
||||
cp -a ${webDeps}/apps/web/node_modules apps/web/node_modules
|
||||
cp -a ${webDeps}/cargo "$TMPDIR/cargo"
|
||||
chmod -R u+w node_modules apps/web/node_modules "$TMPDIR/cargo"
|
||||
cp -a ${frontendDeps}/node_modules .
|
||||
cp -a ${frontendDeps}/apps/frontend/node_modules apps/frontend/node_modules
|
||||
cp -a ${frontendDeps}/cargo "$TMPDIR/cargo"
|
||||
chmod -R u+w node_modules apps/frontend/node_modules "$TMPDIR/cargo"
|
||||
export HOME="$TMPDIR/home"
|
||||
export CARGO_HOME="$TMPDIR/cargo"
|
||||
export CARGO_NET_OFFLINE=true
|
||||
mkdir -p "$HOME/.cache"
|
||||
cp -a ${webDeps}/wasm-pack "$HOME/.cache/.wasm-pack"
|
||||
cp -a ${frontendDeps}/wasm-pack "$HOME/.cache/.wasm-pack"
|
||||
chmod -R u+w "$HOME/.cache/.wasm-pack"
|
||||
pnpm build
|
||||
runHook postBuild
|
||||
|
|
@ -125,14 +110,29 @@
|
|||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
cp -r apps/web/dist "$out"
|
||||
cp -r apps/frontend/dist "$out"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
backend = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "methanium-status";
|
||||
version = "0.1.0";
|
||||
src = source;
|
||||
cargoRoot = "apps/backend";
|
||||
buildAndTestSubdir = "apps/backend";
|
||||
MTP_TYPE_MAPS = "${source}/apps/frontend/type-maps.yaml";
|
||||
cargoLock = {
|
||||
lockFile = ./apps/backend/Cargo.lock;
|
||||
outputHashes = {
|
||||
"mtp-0.2.0" = "sha256-xfF1kRp0kDrGgTcQWN7nZHKgxXizZh4t14uHsS/MYSk=";
|
||||
};
|
||||
};
|
||||
nativeBuildInputs = [ pkgs.cmake ];
|
||||
};
|
||||
in
|
||||
{
|
||||
default = web;
|
||||
web = web;
|
||||
default = frontend;
|
||||
inherit backend frontend;
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -141,14 +141,21 @@
|
|||
let
|
||||
pkgs = pkgsFor system;
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" ];
|
||||
extensions = [
|
||||
"clippy"
|
||||
"rust-src"
|
||||
"rustfmt"
|
||||
];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.cmake
|
||||
pkgs.curl
|
||||
pkgs.nodejs
|
||||
pkgs.openssl
|
||||
pkgs.pnpm
|
||||
pkgs.wasm-pack
|
||||
rustToolchain
|
||||
|
|
@ -158,53 +165,241 @@
|
|||
);
|
||||
|
||||
checks = forAllSystems (system: {
|
||||
inherit (self.packages.${system}) web;
|
||||
inherit (self.packages.${system}) backend frontend;
|
||||
});
|
||||
|
||||
nixosModules.default =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.methanium-template;
|
||||
cfg = config.services.methanium-status;
|
||||
acmeHost = if cfg.acmeHost == null then cfg.hostName else cfg.acmeHost;
|
||||
serviceType = lib.types.submodule {
|
||||
options = {
|
||||
id = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Stable service identifier.";
|
||||
};
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Service display name.";
|
||||
};
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "HTTP or HTTPS URL to check.";
|
||||
};
|
||||
checkHttp3 = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether this service must also pass an HTTP/3-only check.";
|
||||
};
|
||||
};
|
||||
};
|
||||
categoryType = lib.types.submodule {
|
||||
options = {
|
||||
id = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Stable category identifier.";
|
||||
};
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Category display name.";
|
||||
};
|
||||
logo = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Local SVG, PNG, WebP, or JPEG logo file.";
|
||||
};
|
||||
services = lib.mkOption {
|
||||
type = lib.types.listOf serviceType;
|
||||
default = [ ];
|
||||
description = "Services shown and monitored in this category.";
|
||||
};
|
||||
};
|
||||
};
|
||||
configFile = (pkgs.formats.json { }).generate "methanium-status.json" {
|
||||
public_address = cfg.publicAddress;
|
||||
public_port = cfg.publicPort;
|
||||
admin_address = cfg.adminAddress;
|
||||
admin_port = cfg.adminPort;
|
||||
check_interval_seconds = cfg.checkInterval;
|
||||
timeout_seconds = cfg.timeout;
|
||||
certificate = "/var/lib/acme/${acmeHost}/fullchain.pem";
|
||||
private_key = "/var/lib/acme/${acmeHost}/key.pem";
|
||||
frontend_dir = toString cfg.frontendPackage;
|
||||
state_dir = "/var/lib/${cfg.stateDirectory}";
|
||||
curl = "${pkgs.curl}/bin/curl";
|
||||
categories = map (category: {
|
||||
inherit (category) id name;
|
||||
logo = toString category.logo;
|
||||
services = map (service: {
|
||||
inherit (service) id name url;
|
||||
check_http3 = service.checkHttp3;
|
||||
}) category.services;
|
||||
}) cfg.categories;
|
||||
};
|
||||
in
|
||||
{
|
||||
options.services.methanium-template = {
|
||||
enable = lib.mkEnableOption "the Methanium frontend";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
defaultText = lib.literalExpression "self.packages.${pkgs.stdenv.hostPlatform.system}.default";
|
||||
description = "Static frontend package to serve.";
|
||||
};
|
||||
|
||||
options.services.methanium-status = {
|
||||
enable = lib.mkEnableOption "Methanium Status monitoring and status page";
|
||||
package = lib.mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "backend" { };
|
||||
frontendPackage = lib.mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "frontend" { };
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "localhost";
|
||||
description = "nginx virtual host name.";
|
||||
default = "status.methanium.net";
|
||||
description = "Public status page hostname.";
|
||||
};
|
||||
acmeHost = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "ACME certificate name, defaulting to hostName.";
|
||||
};
|
||||
publicAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Address on which the UDP MTP server listens.";
|
||||
};
|
||||
publicPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Shared nginx TCP and MTP UDP public port.";
|
||||
};
|
||||
adminAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address for the unauthenticated incident editor.";
|
||||
};
|
||||
adminPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8081;
|
||||
description = "TCP port for the unauthenticated incident editor.";
|
||||
};
|
||||
checkInterval = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 300;
|
||||
description = "Seconds between service checks.";
|
||||
};
|
||||
timeout = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 15;
|
||||
description = "Per-check timeout in seconds.";
|
||||
};
|
||||
stateDirectory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "methanium-status";
|
||||
description = "systemd state directory name for incident history.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open TCP port 80 in the firewall.";
|
||||
description = "Open the public TCP and UDP port in the firewall.";
|
||||
};
|
||||
openAdminFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open the unauthenticated admin TCP port. Use with care.";
|
||||
};
|
||||
categories = lib.mkOption {
|
||||
type = lib.types.listOf categoryType;
|
||||
description = "Status categories and checked services.";
|
||||
default = [
|
||||
{
|
||||
id = "methanium";
|
||||
name = "Methanium";
|
||||
logo = ./apps/frontend/public/methanium.svg;
|
||||
services = [
|
||||
{ id = "methanium-homepage"; name = "Homepage"; url = "https://methanium.net"; }
|
||||
{ id = "methanium-ui"; name = "Methanium UI"; url = "https://ui.methanium.net"; }
|
||||
{ id = "vector-verdict"; name = "Vector Verdict"; url = "https://vv.methanium.net"; }
|
||||
{ id = "methanium-git"; name = "Git"; url = "https://git.methanium.net"; }
|
||||
{ id = "methanium-legal"; name = "Legal Page"; url = "https://legal.methanium.net"; }
|
||||
];
|
||||
}
|
||||
{
|
||||
id = "tensamin";
|
||||
name = "Tensamin";
|
||||
logo = ./apps/frontend/public/tensamin.svg;
|
||||
services = [
|
||||
{ id = "tensamin-homepage"; name = "Homepage"; url = "https://tensamin.net"; }
|
||||
{ id = "tensamin-app"; name = "Web App"; url = "https://app.tensamin.net"; }
|
||||
{ id = "tensamin-dev"; name = "Web App (Dev)"; url = "https://dev.tensamin.net"; }
|
||||
{ id = "tensamin-docs"; name = "Docs"; url = "https://docs.tensamin.net"; }
|
||||
{ id = "tensamin-omega"; name = "Omega"; url = "https://omega.tensamin.net"; checkHttp3 = true; }
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.groups.methanium-status = { };
|
||||
users.users.methanium-status = {
|
||||
isSystemUser = true;
|
||||
group = "methanium-status";
|
||||
};
|
||||
users.users.nginx.extraGroups = [ "methanium-status" ];
|
||||
|
||||
security.acme.certs.${acmeHost} = {
|
||||
group = "methanium-status";
|
||||
reloadServices = [ "methanium-status.service" ];
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts.${cfg.hostName} = {
|
||||
root = cfg.package;
|
||||
forceSSL = true;
|
||||
enableACME = cfg.acmeHost == null;
|
||||
useACMEHost = lib.mkIf (cfg.acmeHost != null) acmeHost;
|
||||
root = cfg.frontendPackage;
|
||||
listen = [
|
||||
{ addr = "0.0.0.0"; port = cfg.publicPort; ssl = true; }
|
||||
{ addr = "[::]"; port = cfg.publicPort; ssl = true; }
|
||||
];
|
||||
extraConfig = ''
|
||||
add_header Alt-Svc 'h3=":${toString cfg.publicPort}"; ma=86400' always;
|
||||
'';
|
||||
locations."/".tryFiles = "$uri $uri/ /index.html";
|
||||
locations."^~ /edit".return = "404";
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ 80 ];
|
||||
systemd.services.methanium-status = {
|
||||
description = "Methanium Status monitor";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" "acme-${acmeHost}.service" ];
|
||||
after = [ "network-online.target" "acme-${acmeHost}.service" ];
|
||||
restartTriggers = [ configFile ];
|
||||
serviceConfig = {
|
||||
User = "methanium-status";
|
||||
Group = "methanium-status";
|
||||
StateDirectory = cfg.stateDirectory;
|
||||
ExecStart = "${cfg.package}/bin/methanium-status ${configFile}";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
|
||||
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
|
||||
LockPersonality = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
PrivateTmp = true;
|
||||
ProtectClock = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectHome = true;
|
||||
ProtectHostname = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectSystem = "strict";
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
SystemCallArchitectures = "native";
|
||||
UMask = "0077";
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts =
|
||||
lib.optionals cfg.openFirewall [ cfg.publicPort ]
|
||||
++ lib.optionals cfg.openAdminFirewall [ cfg.adminPort ];
|
||||
networking.firewall.allowedUDPPorts = lib.optionals cfg.openFirewall [ cfg.publicPort ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -1,20 +1,24 @@
|
|||
{
|
||||
"name": "methanium-template",
|
||||
"name": "methanium-status",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.17.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter @methanium/template-web dev",
|
||||
"build": "pnpm --filter @methanium/template-web build",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"lint": "oxlint apps/web/src packages/mtp",
|
||||
"fmt": "prettier --write .",
|
||||
"fmt:check": "prettier --check ."
|
||||
"dev": "pnpm dev:cert && concurrently --kill-others --success first --names frontend,backend --prefix-colors cyan,magenta \"pnpm --filter @methanium/status-frontend dev\" \"pnpm dev:backend\"",
|
||||
"dev:cert": "mkdir -p .dev && if [ ! -f .dev/cert.pem ] || [ ! -f .dev/key.pem ] || ! openssl x509 -in .dev/cert.pem -checkend 0 -noout >/dev/null 2>&1 || ! openssl x509 -in .dev/cert.pem -noout -text | grep -q 'Public Key Algorithm: id-ecPublicKey'; then openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout .dev/key.pem -out .dev/cert.pem -days 10 -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1; fi && printf 'VITE_MTP_URL=https://127.0.0.1:4433/mtp\\nVITE_MTP_CERT_HASH=%s\\n' \"$(openssl x509 -in .dev/cert.pem -outform DER | openssl dgst -sha256 -r | cut -d ' ' -f1)\" > apps/frontend/.env.local",
|
||||
"dev:backend": "cargo run --manifest-path apps/backend/Cargo.toml -- config.example.toml",
|
||||
"build": "pnpm --filter @methanium/status-frontend build",
|
||||
"check": "tsc -p tsconfig.json && cargo check --manifest-path apps/backend/Cargo.toml",
|
||||
"lint": "oxlint apps/frontend/src packages/mtp && cargo clippy --manifest-path apps/backend/Cargo.toml -- -D warnings",
|
||||
"test": "cargo test --manifest-path apps/backend/Cargo.toml",
|
||||
"fmt": "prettier --write . && cargo fmt --manifest-path apps/backend/Cargo.toml",
|
||||
"fmt:check": "prettier --check . && cargo fmt --manifest-path apps/backend/Cargo.toml --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"oxlint": "^1.28.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^24.0.0"
|
||||
"@types/node": "^24.0.0",
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@methanium/template-mtp",
|
||||
"name": "@methanium/status-mtp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
430
pnpm-lock.yaml
generated
430
pnpm-lock.yaml
generated
|
|
@ -14,6 +14,9 @@ importers:
|
|||
"@types/node":
|
||||
specifier: ^24.0.0
|
||||
version: 24.13.3
|
||||
concurrently:
|
||||
specifier: ^9.2.1
|
||||
version: 9.2.4
|
||||
oxlint:
|
||||
specifier: ^1.28.0
|
||||
version: 1.76.0
|
||||
|
|
@ -24,14 +27,14 @@ importers:
|
|||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
apps/web:
|
||||
apps/frontend:
|
||||
dependencies:
|
||||
"@methanium/template-mtp":
|
||||
"@methanium/status-mtp":
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/mtp
|
||||
"@methanium/ui":
|
||||
specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz
|
||||
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(typescript@5.9.3)
|
||||
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@8.1.1)(typescript@5.9.3)
|
||||
"@tanstack/react-router":
|
||||
specifier: ^1.131.35
|
||||
version: 1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
|
|
@ -53,7 +56,7 @@ importers:
|
|||
version: 4.3.3(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
|
||||
"@tanstack/router-plugin":
|
||||
specifier: ^1.131.35
|
||||
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
|
||||
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
|
||||
"@types/react":
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.17
|
||||
|
|
@ -62,7 +65,7 @@ importers:
|
|||
version: 19.2.3(@types/react@19.2.17)
|
||||
"@vitejs/plugin-react":
|
||||
specifier: ^5.0.4
|
||||
version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
|
||||
version: 5.2.0(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
|
||||
tailwindcss:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.3
|
||||
|
|
@ -74,7 +77,7 @@ importers:
|
|||
dependencies:
|
||||
"@methanium/ui":
|
||||
specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz
|
||||
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(typescript@5.9.3)
|
||||
version: https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@8.1.1)(typescript@5.9.3)
|
||||
mtp:
|
||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz
|
||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz
|
||||
|
|
@ -1783,6 +1786,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=12" }
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==,
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
ansis@4.3.1:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -1908,6 +1918,13 @@ packages:
|
|||
integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==,
|
||||
}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==,
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
chalk@5.6.2:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -1942,6 +1959,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=6" }
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==,
|
||||
}
|
||||
engines: { node: ">=12" }
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -1964,6 +1988,19 @@ packages:
|
|||
integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==,
|
||||
}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==,
|
||||
}
|
||||
engines: { node: ">=7.0.0" }
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==,
|
||||
}
|
||||
|
||||
commander@11.1.0:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -1978,6 +2015,14 @@ packages:
|
|||
}
|
||||
engines: { node: ">=20" }
|
||||
|
||||
concurrently@9.2.4:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==,
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
hasBin: true
|
||||
|
||||
conf@10.2.0:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -2308,6 +2353,12 @@ packages:
|
|||
integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==,
|
||||
}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==,
|
||||
}
|
||||
|
||||
encodeurl@2.0.0:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -2568,6 +2619,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=6.9.0" }
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==,
|
||||
}
|
||||
engines: { node: 6.* || 8.* || >= 10.* }
|
||||
|
||||
get-east-asian-width@1.6.0:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -2637,6 +2695,13 @@ packages:
|
|||
integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==,
|
||||
}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==,
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -2777,6 +2842,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=0.10.0" }
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==,
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3707,6 +3779,13 @@ packages:
|
|||
integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==,
|
||||
}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==,
|
||||
}
|
||||
engines: { node: ">=0.10.0" }
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3775,6 +3854,12 @@ packages:
|
|||
integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==,
|
||||
}
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==,
|
||||
}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3860,6 +3945,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
shell-quote@1.9.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==,
|
||||
}
|
||||
engines: { node: ">= 0.4" }
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3944,6 +4036,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==,
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -3993,6 +4092,20 @@ packages:
|
|||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==,
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
supports-color@8.1.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==,
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
systeminformation@5.33.1:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -4048,6 +4161,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=0.6" }
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==,
|
||||
}
|
||||
hasBin: true
|
||||
|
||||
ts-morph@26.0.0:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -4301,6 +4421,13 @@ packages:
|
|||
engines: { node: ^16.13.0 || >=18.0.0 }
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==,
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -4314,6 +4441,13 @@ packages:
|
|||
}
|
||||
engines: { node: ">=20" }
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==,
|
||||
}
|
||||
engines: { node: ">=10" }
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -4328,6 +4462,20 @@ packages:
|
|||
engines: { node: ">= 14.6" }
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==,
|
||||
}
|
||||
engines: { node: ">=12" }
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==,
|
||||
}
|
||||
engines: { node: ">=12" }
|
||||
|
||||
yocto-spinner@1.2.2:
|
||||
resolution:
|
||||
{
|
||||
|
|
@ -4371,20 +4519,20 @@ snapshots:
|
|||
|
||||
"@babel/compat-data@7.29.7": {}
|
||||
|
||||
"@babel/core@7.29.7":
|
||||
"@babel/core@7.29.7(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/code-frame": 7.29.7
|
||||
"@babel/generator": 7.29.7
|
||||
"@babel/helper-compilation-targets": 7.29.7
|
||||
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
"@babel/helpers": 7.29.7
|
||||
"@babel/parser": 7.29.7
|
||||
"@babel/template": 7.29.7
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/types": 7.29.7
|
||||
"@jridgewell/remapping": 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
|
|
@ -4411,41 +4559,41 @@ snapshots:
|
|||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
"@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-annotate-as-pure": 7.29.7
|
||||
"@babel/helper-member-expression-to-functions": 7.29.7
|
||||
"@babel/helper-member-expression-to-functions": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-optimise-call-expression": 7.29.7
|
||||
"@babel/helper-replace-supers": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/helper-replace-supers": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@babel/helper-globals@7.29.7": {}
|
||||
|
||||
"@babel/helper-member-expression-to-functions@7.29.7":
|
||||
"@babel/helper-member-expression-to-functions@7.29.7(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/types": 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@babel/helper-module-imports@7.29.7":
|
||||
"@babel/helper-module-imports@7.29.7(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/types": 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/helper-module-imports": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-module-imports": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-validator-identifier": 7.29.7
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -4455,18 +4603,18 @@ snapshots:
|
|||
|
||||
"@babel/helper-plugin-utils@7.29.7": {}
|
||||
|
||||
"@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/helper-member-expression-to-functions": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-member-expression-to-functions": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-optimise-call-expression": 7.29.7
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@babel/helper-skip-transparent-expression-wrappers@7.29.7":
|
||||
"@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/types": 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -4486,53 +4634,53 @@ snapshots:
|
|||
dependencies:
|
||||
"@babel/types": 7.29.7
|
||||
|
||||
"@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
|
||||
"@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
|
||||
"@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-module-transforms": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
|
||||
"@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-annotate-as-pure": 7.29.7
|
||||
"@babel/helper-create-class-features-plugin": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/helper-create-class-features-plugin": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7
|
||||
"@babel/plugin-syntax-typescript": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/helper-skip-transparent-expression-wrappers": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/plugin-syntax-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@babel/preset-typescript@7.29.7(@babel/core@7.29.7)":
|
||||
"@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/helper-plugin-utils": 7.29.7
|
||||
"@babel/helper-validator-option": 7.29.7
|
||||
"@babel/plugin-syntax-jsx": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/plugin-transform-modules-commonjs": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/plugin-syntax-jsx": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
|
||||
"@babel/plugin-transform-modules-commonjs": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -4544,7 +4692,7 @@ snapshots:
|
|||
"@babel/parser": 7.29.7
|
||||
"@babel/types": 7.29.7
|
||||
|
||||
"@babel/traverse@7.29.7":
|
||||
"@babel/traverse@7.29.7(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/code-frame": 7.29.7
|
||||
"@babel/generator": 7.29.7
|
||||
|
|
@ -4552,7 +4700,7 @@ snapshots:
|
|||
"@babel/parser": 7.29.7
|
||||
"@babel/template": 7.29.7
|
||||
"@babel/types": 7.29.7
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -4725,7 +4873,7 @@ snapshots:
|
|||
"@jridgewell/resolve-uri": 3.1.2
|
||||
"@jridgewell/sourcemap-codec": 1.5.5
|
||||
|
||||
"@methanium/ui@https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(typescript@5.9.3)":
|
||||
"@methanium/ui@https://git.methanium.net/methanium/ui/releases/download/0.0.12/methanium-ui.tgz(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@8.1.1)(typescript@5.9.3)":
|
||||
dependencies:
|
||||
"@base-ui/react": 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
"@fontsource-variable/public-sans": 5.3.0
|
||||
|
|
@ -4741,7 +4889,7 @@ snapshots:
|
|||
react-dom: 19.2.8(react@19.2.8)
|
||||
react-resizable-panels: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
recharts: 3.8.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)
|
||||
shadcn: 4.16.0(typescript@5.9.3)
|
||||
shadcn: 4.16.0(supports-color@8.1.1)(typescript@5.9.3)
|
||||
sonner: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
tailwind-merge: 3.6.0
|
||||
tw-animate-css: 1.4.0
|
||||
|
|
@ -4758,7 +4906,7 @@ snapshots:
|
|||
- supports-color
|
||||
- typescript
|
||||
|
||||
"@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)":
|
||||
"@modelcontextprotocol/sdk@1.30.0(supports-color@8.1.1)(zod@3.25.76)":
|
||||
dependencies:
|
||||
"@hono/node-server": 2.0.12(hono@4.12.32)
|
||||
ajv: 8.20.0
|
||||
|
|
@ -4768,8 +4916,8 @@ snapshots:
|
|||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.1.0
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.6.1(express@5.2.1)
|
||||
express: 5.2.1(supports-color@8.1.1)
|
||||
express-rate-limit: 8.6.1(express@5.2.1(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
hono: 4.12.32
|
||||
jose: 6.2.4
|
||||
json-schema-typed: 8.0.2
|
||||
|
|
@ -5176,11 +5324,11 @@ snapshots:
|
|||
seroval: 1.5.6
|
||||
seroval-plugins: 1.5.6(seroval@1.5.6)
|
||||
|
||||
"@tanstack/router-generator@1.167.21":
|
||||
"@tanstack/router-generator@1.167.21(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/types": 7.29.7
|
||||
"@tanstack/router-core": 1.171.15
|
||||
"@tanstack/router-utils": 1.162.2
|
||||
"@tanstack/router-utils": 1.162.2(supports-color@8.1.1)
|
||||
"@tanstack/virtual-file-routes": 1.162.0
|
||||
jiti: 2.7.0
|
||||
magic-string: 0.30.21
|
||||
|
|
@ -5189,14 +5337,14 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
"@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
|
||||
"@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(esbuild@0.28.1)(rollup@4.62.3)(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/template": 7.29.7
|
||||
"@babel/types": 7.29.7
|
||||
"@tanstack/router-core": 1.171.15
|
||||
"@tanstack/router-generator": 1.167.21
|
||||
"@tanstack/router-utils": 1.162.2
|
||||
"@tanstack/router-generator": 1.167.21(supports-color@8.1.1)
|
||||
"@tanstack/router-utils": 1.162.2(supports-color@8.1.1)
|
||||
chokidar: 5.0.0
|
||||
unplugin: 3.3.0(esbuild@0.28.1)(rollup@4.62.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))
|
||||
zod: 4.4.3
|
||||
|
|
@ -5213,13 +5361,13 @@ snapshots:
|
|||
- supports-color
|
||||
- unloader
|
||||
|
||||
"@tanstack/router-utils@1.162.2":
|
||||
"@tanstack/router-utils@1.162.2(supports-color@8.1.1)":
|
||||
dependencies:
|
||||
"@babel/generator": 7.29.7
|
||||
"@babel/parser": 7.29.7
|
||||
"@babel/types": 7.29.7
|
||||
ansis: 4.3.1
|
||||
babel-dead-code-elimination: 1.0.12
|
||||
babel-dead-code-elimination: 1.0.12(supports-color@8.1.1)
|
||||
diff: 8.0.4
|
||||
pathe: 2.0.3
|
||||
tinyglobby: 0.2.17
|
||||
|
|
@ -5301,11 +5449,11 @@ snapshots:
|
|||
|
||||
"@types/validate-npm-package-name@4.0.2": {}
|
||||
|
||||
"@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
|
||||
"@vitejs/plugin-react@5.2.0(supports-color@8.1.1)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))":
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/plugin-transform-react-jsx-self": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
|
||||
"@babel/plugin-transform-react-jsx-source": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
|
||||
"@rolldown/pluginutils": 1.0.0-rc.3
|
||||
"@types/babel__core": 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
|
|
@ -5339,6 +5487,10 @@ snapshots:
|
|||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansis@4.3.1: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
|
@ -5353,11 +5505,11 @@ snapshots:
|
|||
|
||||
atomically@1.7.0: {}
|
||||
|
||||
babel-dead-code-elimination@1.0.12:
|
||||
babel-dead-code-elimination@1.0.12(supports-color@8.1.1):
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/parser": 7.29.7
|
||||
"@babel/traverse": 7.29.7
|
||||
"@babel/traverse": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/types": 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -5366,11 +5518,11 @@ snapshots:
|
|||
|
||||
baseline-browser-mapping@2.11.5: {}
|
||||
|
||||
body-parser@2.3.0:
|
||||
body-parser@2.3.0(supports-color@8.1.1):
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 2.0.0
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
on-finished: 2.4.1
|
||||
|
|
@ -5416,6 +5568,11 @@ snapshots:
|
|||
|
||||
caniuse-lite@1.0.30001806: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
chalk@5.6.2: {}
|
||||
|
||||
chokidar@5.0.0:
|
||||
|
|
@ -5432,6 +5589,12 @@ snapshots:
|
|||
|
||||
cli-spinners@2.9.2: {}
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
|
|
@ -5448,10 +5611,25 @@ snapshots:
|
|||
|
||||
code-block-writer@13.0.3: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
concurrently@9.2.4:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
rxjs: 7.8.2
|
||||
shell-quote: 1.9.0
|
||||
supports-color: 8.1.1
|
||||
tree-kill: 1.2.2
|
||||
yargs: 17.7.2
|
||||
|
||||
conf@10.2.0:
|
||||
dependencies:
|
||||
ajv: 8.20.0
|
||||
|
|
@ -5545,9 +5723,11 @@ snapshots:
|
|||
dependencies:
|
||||
mimic-fn: 3.1.0
|
||||
|
||||
debug@4.4.3:
|
||||
debug@4.4.3(supports-color@8.1.1):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 8.1.1
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
|
|
@ -5604,6 +5784,8 @@ snapshots:
|
|||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
enhanced-resolve@5.24.3:
|
||||
|
|
@ -5704,28 +5886,28 @@ snapshots:
|
|||
strip-final-newline: 4.0.0
|
||||
yoctocolors: 2.2.0
|
||||
|
||||
express-rate-limit@8.6.1(express@5.2.1):
|
||||
express-rate-limit@8.6.1(express@5.2.1(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
express: 5.2.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
express: 5.2.1(supports-color@8.1.1)
|
||||
ip-address: 10.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express@5.2.1:
|
||||
express@5.2.1(supports-color@8.1.1):
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.3.0
|
||||
body-parser: 2.3.0(supports-color@8.1.1)
|
||||
content-disposition: 1.1.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.1
|
||||
finalhandler: 2.1.1(supports-color@8.1.1)
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 2.0.0
|
||||
|
|
@ -5736,9 +5918,9 @@ snapshots:
|
|||
proxy-addr: 2.0.7
|
||||
qs: 6.15.3
|
||||
range-parser: 1.3.0
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
serve-static: 2.2.1
|
||||
router: 2.2.0(supports-color@8.1.1)
|
||||
send: 1.2.1(supports-color@8.1.1)
|
||||
serve-static: 2.2.1(supports-color@8.1.1)
|
||||
statuses: 2.0.2
|
||||
type-is: 2.1.0
|
||||
vary: 1.1.2
|
||||
|
|
@ -5773,9 +5955,9 @@ snapshots:
|
|||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
finalhandler@2.1.1:
|
||||
finalhandler@2.1.1(supports-color@8.1.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
|
|
@ -5807,6 +5989,8 @@ snapshots:
|
|||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.6.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
|
|
@ -5846,6 +6030,8 @@ snapshots:
|
|||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
hasown@2.0.4:
|
||||
|
|
@ -5902,6 +6088,8 @@ snapshots:
|
|||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
|
@ -6365,6 +6553,8 @@ snapshots:
|
|||
|
||||
redux@5.0.1: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
|
@ -6411,9 +6601,9 @@ snapshots:
|
|||
"@rollup/rollup-win32-x64-msvc": 4.62.3
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
router@2.2.0(supports-color@8.1.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
|
|
@ -6427,6 +6617,10 @@ snapshots:
|
|||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
rxjs@7.8.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
|
@ -6435,9 +6629,9 @@ snapshots:
|
|||
|
||||
semver@7.8.5: {}
|
||||
|
||||
send@1.2.1:
|
||||
send@1.2.1(supports-color@8.1.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
|
|
@ -6457,25 +6651,25 @@ snapshots:
|
|||
|
||||
seroval@1.5.6: {}
|
||||
|
||||
serve-static@2.2.1:
|
||||
serve-static@2.2.1(supports-color@8.1.1):
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1
|
||||
send: 1.2.1(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shadcn@4.16.0(typescript@5.9.3):
|
||||
shadcn@4.16.0(supports-color@8.1.1)(typescript@5.9.3):
|
||||
dependencies:
|
||||
"@babel/core": 7.29.7
|
||||
"@babel/core": 7.29.7(supports-color@8.1.1)
|
||||
"@babel/parser": 7.29.7
|
||||
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/preset-typescript": 7.29.7(@babel/core@7.29.7)
|
||||
"@babel/plugin-transform-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
"@babel/preset-typescript": 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
"@dotenvx/dotenvx": 1.75.1
|
||||
"@modelcontextprotocol/sdk": 1.30.0(zod@3.25.76)
|
||||
"@modelcontextprotocol/sdk": 1.30.0(supports-color@8.1.1)(zod@3.25.76)
|
||||
"@types/validate-npm-package-name": 4.0.2
|
||||
browserslist: 4.28.7
|
||||
commander: 14.0.3
|
||||
|
|
@ -6514,6 +6708,8 @@ snapshots:
|
|||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.9.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
|
@ -6561,6 +6757,12 @@ snapshots:
|
|||
|
||||
stdin-discarder@0.2.2: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
|
|
@ -6587,6 +6789,14 @@ snapshots:
|
|||
|
||||
strip-final-newline@4.0.0: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-color@8.1.1:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
systeminformation@5.33.1: {}
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
|
|
@ -6608,6 +6818,8 @@ snapshots:
|
|||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
ts-morph@26.0.0:
|
||||
dependencies:
|
||||
"@ts-morph/common": 0.27.0
|
||||
|
|
@ -6733,6 +6945,12 @@ snapshots:
|
|||
dependencies:
|
||||
isexe: 3.1.5
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
|
|
@ -6740,10 +6958,24 @@ snapshots:
|
|||
is-wsl: 3.1.1
|
||||
powershell-utils: 0.1.0
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yocto-spinner@1.2.2:
|
||||
dependencies:
|
||||
yoctocolors: 2.2.0
|
||||
|
|
|
|||
|
|
@ -7,15 +7,21 @@
|
|||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@methanium/template-mtp": ["./packages/mtp/index.ts"],
|
||||
"mtp/raw": ["./apps/web/node_modules/.vite/mtp/mtp_wasm.d.ts"],
|
||||
"mtp/type-map": ["./apps/web/node_modules/.vite/mtp/mtp_type_map.d.ts"]
|
||||
"@methanium/status-mtp": ["./packages/mtp/index.ts"],
|
||||
"mtp/raw": ["./apps/frontend/node_modules/.vite/mtp/mtp_wasm.d.ts"],
|
||||
"mtp/type-map": [
|
||||
"./apps/frontend/node_modules/.vite/mtp/mtp_type_map.d.ts"
|
||||
]
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2023",
|
||||
"types": ["node"],
|
||||
"types": ["node", "vite/client"],
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["apps/web/src", "apps/web/vite.config.ts", "packages/mtp"]
|
||||
"include": [
|
||||
"apps/frontend/src",
|
||||
"apps/frontend/vite.config.ts",
|
||||
"packages/mtp"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue