Initial commit

This commit is contained in:
Alois 2026-07-29 22:19:13 +02:00
commit 025c8c8e16
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
46 changed files with 13890 additions and 0 deletions

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

@ -0,0 +1,88 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, post},
};
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use uuid::Uuid;
use crate::{AppState, incidents::IncidentError, model::NewIncident};
pub async fn serve(
address: SocketAddr,
frontend: PathBuf,
state: Arc<AppState>,
) -> std::io::Result<()> {
let index = frontend.join("index.html");
let assets = ServeDir::new(frontend).fallback(ServeFile::new(index));
let app = Router::new()
.route("/api/admin", get(admin))
.route("/api/incidents", get(list).post(create))
.route("/api/incidents/{id}", delete(remove))
.route("/api/incidents/{id}/resolve", post(resolve))
.fallback_service(assets)
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(address).await?;
tracing::info!(%address, "admin page listening");
axum::serve(listener, app).await
}
async fn admin() -> StatusCode {
StatusCode::NO_CONTENT
}
async fn list(State(state): State<Arc<AppState>>) -> Json<Vec<crate::model::Incident>> {
Json(state.incidents.list().await)
}
async fn create(State(state): State<Arc<AppState>>, Json(input): Json<NewIncident>) -> Response {
match state.incidents.create(input).await {
Ok(incident) => {
state.publish().await;
(StatusCode::CREATED, Json(incident)).into_response()
}
Err(error) => error_response(error),
}
}
async fn resolve(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
match state.incidents.resolve(id).await {
Ok(incident) => {
state.publish().await;
Json(incident).into_response()
}
Err(error) => error_response(error),
}
}
async fn remove(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
match state.incidents.delete(id).await {
Ok(_) => {
state.publish().await;
StatusCode::NO_CONTENT.into_response()
}
Err(error) => error_response(error),
}
}
fn error_response(error: IncidentError) -> Response {
let status = match error {
IncidentError::Empty => StatusCode::BAD_REQUEST,
IncidentError::Active => StatusCode::CONFLICT,
IncidentError::NotFound => StatusCode::NOT_FOUND,
IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
Json(serde_json::json!({ "error": error.to_string() })),
)
.into_response()
}

142
apps/backend/src/config.rs Normal file
View file

@ -0,0 +1,142 @@
use std::{collections::HashSet, net::IpAddr, path::Path};
use serde::Deserialize;
use thiserror::Error;
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub public_address: IpAddr,
pub public_port: u16,
pub admin_address: IpAddr,
pub admin_port: u16,
pub check_interval_seconds: u64,
pub timeout_seconds: u64,
pub certificate: String,
pub private_key: String,
pub frontend_dir: String,
pub state_dir: String,
pub curl: String,
pub categories: Vec<CategoryConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
public_address: "0.0.0.0".parse().unwrap(),
public_port: 443,
admin_address: "127.0.0.1".parse().unwrap(),
admin_port: 8081,
check_interval_seconds: 300,
timeout_seconds: 15,
certificate: String::new(),
private_key: String::new(),
frontend_dir: String::new(),
state_dir: "/var/lib/methanium-status".into(),
curl: "curl".into(),
categories: Vec::new(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CategoryConfig {
pub id: String,
pub name: String,
pub logo: String,
pub services: Vec<ServiceConfig>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServiceConfig {
pub id: String,
pub name: String,
pub url: String,
#[serde(default)]
pub check_http3: bool,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config: {0}")]
Read(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
Parse(String),
#[error("invalid config: {0}")]
Invalid(String),
}
impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let contents = std::fs::read_to_string(path)?;
let config = if path.extension().and_then(|value| value.to_str()) == Some("json") {
serde_json::from_str(&contents)
.map_err(|error| ConfigError::Parse(error.to_string()))?
} else {
toml::from_str(&contents).map_err(|error| ConfigError::Parse(error.to_string()))?
};
validate(&config)?;
Ok(config)
}
}
fn validate(config: &Config) -> Result<(), ConfigError> {
if config.check_interval_seconds == 0 || config.timeout_seconds == 0 {
return Err(ConfigError::Invalid(
"check interval and timeout must be greater than zero".into(),
));
}
if !config.admin_address.is_loopback() {
return Err(ConfigError::Invalid(
"admin_address must be a loopback address".into(),
));
}
if config.certificate.is_empty() || config.private_key.is_empty() {
return Err(ConfigError::Invalid(
"certificate and private_key are required".into(),
));
}
if config.frontend_dir.is_empty() {
return Err(ConfigError::Invalid("frontend_dir is required".into()));
}
let mut category_ids = HashSet::new();
let mut service_ids = HashSet::new();
for category in &config.categories {
if category.id.is_empty() || category.name.is_empty() || category.logo.is_empty() {
return Err(ConfigError::Invalid(
"category id, name, and logo are required".into(),
));
}
if !category_ids.insert(&category.id) {
return Err(ConfigError::Invalid(format!(
"duplicate category id {}",
category.id
)));
}
for service in &category.services {
if service.id.is_empty() || service.name.is_empty() {
return Err(ConfigError::Invalid(
"service id and name are required".into(),
));
}
if !service_ids.insert(&service.id) {
return Err(ConfigError::Invalid(format!(
"duplicate service id {}",
service.id
)));
}
let url = reqwest::Url::parse(&service.url)
.map_err(|error| ConfigError::Invalid(format!("{}: {error}", service.url)))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(ConfigError::Invalid(format!(
"{} must use http or https",
service.url
)));
}
}
}
Ok(())
}

View file

@ -0,0 +1,175 @@
use std::{io::ErrorKind, path::PathBuf};
use chrono::Utc;
use thiserror::Error;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::model::{Incident, NewIncident};
pub struct IncidentStore {
path: PathBuf,
incidents: Mutex<Vec<Incident>>,
}
#[derive(Debug, Error)]
pub enum IncidentError {
#[error("incident title and message cannot be empty")]
Empty,
#[error("incident not found")]
NotFound,
#[error("active incidents cannot be deleted")]
Active,
#[error("failed to persist incidents: {0}")]
Storage(#[from] std::io::Error),
#[error("failed to parse incidents: {0}")]
Parse(#[from] serde_json::Error),
}
impl IncidentStore {
pub async fn load(path: PathBuf) -> Result<Self, IncidentError> {
let incidents = match tokio::fs::read(&path).await {
Ok(contents) => serde_json::from_slice(&contents)?,
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
Ok(Self {
path,
incidents: Mutex::new(incidents),
})
}
pub async fn list(&self) -> Vec<Incident> {
self.incidents.lock().await.clone()
}
pub async fn create(&self, input: NewIncident) -> Result<Incident, IncidentError> {
if input.title.trim().is_empty() || input.message.trim().is_empty() {
return Err(IncidentError::Empty);
}
let incident = Incident {
id: Uuid::new_v4(),
title: input.title.trim().to_owned(),
message: input.message.trim().to_owned(),
severity: input.severity,
created_at: Utc::now(),
resolved_at: None,
};
let mut incidents = self.incidents.lock().await;
incidents.push(incident.clone());
self.persist(&incidents).await?;
Ok(incident)
}
pub async fn resolve(&self, id: Uuid) -> Result<Incident, IncidentError> {
let mut incidents = self.incidents.lock().await;
let incident = incidents
.iter_mut()
.find(|incident| incident.id == id)
.ok_or(IncidentError::NotFound)?;
if incident.resolved_at.is_none() {
incident.resolved_at = Some(Utc::now());
}
let resolved = incident.clone();
self.persist(&incidents).await?;
Ok(resolved)
}
pub async fn delete(&self, id: Uuid) -> Result<Incident, IncidentError> {
let mut incidents = self.incidents.lock().await;
let index = incidents
.iter()
.position(|incident| incident.id == id)
.ok_or(IncidentError::NotFound)?;
if incidents[index].resolved_at.is_none() {
return Err(IncidentError::Active);
}
let deleted = incidents.remove(index);
self.persist(&incidents).await?;
Ok(deleted)
}
async fn persist(&self, incidents: &[Incident]) -> Result<(), std::io::Error> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let temporary = self.path.with_extension("json.tmp");
let contents = serde_json::to_vec_pretty(incidents).map_err(std::io::Error::other)?;
tokio::fs::write(&temporary, contents).await?;
tokio::fs::rename(temporary, &self.path).await
}
}
#[cfg(test)]
mod tests {
use crate::model::{NewIncident, Severity};
use super::*;
#[tokio::test]
async fn persists_created_and_resolved_incidents() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("incidents.json");
let store = IncidentStore::load(path.clone()).await.unwrap();
let created = store
.create(NewIncident {
title: "Service disruption".into(),
message: "Investigating elevated errors.".into(),
severity: Severity::Major,
})
.await
.unwrap();
store.resolve(created.id).await.unwrap();
let reloaded = IncidentStore::load(path).await.unwrap().list().await;
assert_eq!(reloaded.len(), 1);
assert!(reloaded[0].resolved_at.is_some());
}
#[tokio::test]
async fn rejects_empty_incidents() {
let directory = tempfile::tempdir().unwrap();
let store = IncidentStore::load(directory.path().join("incidents.json"))
.await
.unwrap();
let result = store
.create(NewIncident {
title: " ".into(),
message: "Details".into(),
severity: Severity::Minor,
})
.await;
assert!(matches!(result, Err(IncidentError::Empty)));
}
#[tokio::test]
async fn only_deletes_resolved_incidents() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("incidents.json");
let store = IncidentStore::load(path.clone()).await.unwrap();
let created = store
.create(NewIncident {
title: "Service disruption".into(),
message: "Investigating elevated errors.".into(),
severity: Severity::Major,
})
.await
.unwrap();
assert!(matches!(
store.delete(created.id).await,
Err(IncidentError::Active)
));
store.resolve(created.id).await.unwrap();
store.delete(created.id).await.unwrap();
assert!(
IncidentStore::load(path)
.await
.unwrap()
.list()
.await
.is_empty()
);
}
}

142
apps/backend/src/main.rs Normal file
View file

@ -0,0 +1,142 @@
mod admin;
mod config;
mod incidents;
mod model;
mod monitor;
mod mtp_server;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use base64::{Engine, engine::general_purpose::STANDARD};
use chrono::Utc;
use config::Config;
use incidents::IncidentStore;
use model::{Category, Service, ServiceStatus, Snapshot};
use tokio::sync::{RwLock, watch};
pub struct AppState {
categories: Arc<RwLock<Vec<Category>>>,
incidents: IncidentStore,
updates: watch::Sender<String>,
}
impl AppState {
async fn snapshot(&self) -> Snapshot {
Snapshot {
categories: self.categories.read().await.clone(),
incidents: self.incidents.list().await,
generated_at: Utc::now(),
}
}
async fn snapshot_json(&self) -> String {
serde_json::to_string(&self.snapshot().await).expect("snapshot is serializable")
}
async fn publish(&self) {
let payload = self.snapshot_json().await;
tracing::info!(bytes = payload.len(), "publishing status snapshot");
self.updates.send_replace(payload);
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("methanium_status=info".parse()?),
)
.init();
let config_path = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| "config.toml".into());
let config = Arc::new(Config::load(&config_path)?);
let state_dir = PathBuf::from(&config.state_dir);
tokio::fs::create_dir_all(&state_dir).await?;
let categories = Arc::new(RwLock::new(load_categories(&config)?));
let incidents = IncidentStore::load(state_dir.join("incidents.json")).await?;
let initial = serde_json::to_string(&Snapshot {
categories: categories.read().await.clone(),
incidents: incidents.list().await,
generated_at: Utc::now(),
})?;
let (updates, _) = watch::channel(initial);
let state = Arc::new(AppState {
categories: Arc::clone(&categories),
incidents,
updates,
});
let monitor_config = Arc::clone(&config);
let monitor_state = Arc::clone(&state);
let publish: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
let state = Arc::clone(&monitor_state);
tokio::spawn(async move { state.publish().await });
});
tokio::spawn(monitor::run(monitor_config, categories, publish));
let admin_address = (config.admin_address, config.admin_port).into();
let admin = admin::serve(
admin_address,
PathBuf::from(&config.frontend_dir),
Arc::clone(&state),
);
let mtp = mtp_server::serve(
config.public_address,
config.public_port,
tokio::fs::read(&config.certificate).await?,
tokio::fs::read(&config.private_key).await?,
state,
);
tokio::try_join!(admin, async { mtp.await.map_err(std::io::Error::other) })?;
Ok(())
}
fn load_categories(config: &Config) -> Result<Vec<Category>, std::io::Error> {
config
.categories
.iter()
.map(|category| {
Ok(Category {
id: category.id.clone(),
name: category.name.clone(),
logo: logo_data_url(Path::new(&category.logo))?,
services: category
.services
.iter()
.map(|service| Service {
id: service.id.clone(),
name: service.name.clone(),
url: service.url.clone(),
check_http3: service.check_http3,
status: ServiceStatus::Unknown,
status_code: None,
latency_ms: None,
error: None,
checked_at: None,
})
.collect(),
})
})
.collect()
}
fn logo_data_url(path: &Path) -> Result<String, std::io::Error> {
let media_type = match path.extension().and_then(|value| value.to_str()) {
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("jpg" | "jpeg") => "image/jpeg",
_ => "application/octet-stream",
};
Ok(format!(
"data:{media_type};base64,{}",
STANDARD.encode(std::fs::read(path)?)
))
}

72
apps/backend/src/model.rs Normal file
View file

@ -0,0 +1,72 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
pub categories: Vec<Category>,
pub incidents: Vec<Incident>,
pub generated_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Category {
pub id: String,
pub name: String,
pub logo: String,
pub services: Vec<Service>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Service {
pub id: String,
pub name: String,
pub url: String,
pub check_http3: bool,
pub status: ServiceStatus,
pub status_code: Option<u16>,
pub latency_ms: Option<u64>,
pub error: Option<String>,
pub checked_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceStatus {
Unknown,
Online,
Offline,
TimedOut,
HttpError,
Http3Error,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Incident {
pub id: Uuid,
pub title: String,
pub message: String,
pub severity: Severity,
pub created_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Minor,
Major,
Critical,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NewIncident {
pub title: String,
pub message: String,
pub severity: Severity,
}

184
apps/backend/src/monitor.rs Normal file
View file

@ -0,0 +1,184 @@
use std::{process::Stdio, sync::Arc, time::Duration};
use chrono::Utc;
use reqwest::redirect::Policy;
use tokio::{process::Command, sync::RwLock, time::Instant};
use crate::{
config::Config,
model::{Service, ServiceStatus},
};
pub async fn run(
config: Arc<Config>,
categories: Arc<RwLock<Vec<crate::model::Category>>>,
publish: Arc<dyn Fn() + Send + Sync>,
) {
let client = reqwest::Client::builder()
.redirect(Policy::limited(10))
.timeout(Duration::from_secs(config.timeout_seconds))
.user_agent("Methanium-Status/0.1")
.build()
.expect("HTTP client configuration is valid");
let mut interval = tokio::time::interval(Duration::from_secs(config.check_interval_seconds));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
let jobs = {
let categories = categories.read().await;
categories
.iter()
.enumerate()
.flat_map(|(category_index, category)| {
category
.services
.iter()
.enumerate()
.map(move |(service_index, service)| {
(category_index, service_index, service.clone())
})
})
.collect::<Vec<_>>()
};
let mut checks = tokio::task::JoinSet::new();
for (category_index, service_index, service) in jobs {
let client = client.clone();
let config = Arc::clone(&config);
checks.spawn(async move {
(
category_index,
service_index,
check_service(&client, &config, service).await,
)
});
}
while let Some(result) = checks.join_next().await {
match result {
Ok((category_index, service_index, service)) => {
categories.write().await[category_index].services[service_index] = service;
}
Err(error) => tracing::error!(%error, "service check task failed"),
}
}
publish();
}
}
async fn check_service(client: &reqwest::Client, config: &Config, mut service: Service) -> Service {
let started = Instant::now();
let result = client.get(&service.url).send().await;
service.checked_at = Some(Utc::now());
service.latency_ms = Some(started.elapsed().as_millis() as u64);
service.error = None;
service.status_code = None;
match result {
Ok(response) => {
let status = response.status();
service.status_code = Some(status.as_u16());
if !is_operational_status(status) {
service.status = ServiceStatus::HttpError;
service.error = Some(format!("HTTP {}", status.as_u16()));
return service;
}
}
Err(error) if error.is_timeout() => {
service.status = ServiceStatus::TimedOut;
service.error = Some("Request timed out".into());
return service;
}
Err(error) => {
service.status = ServiceStatus::Offline;
service.error = Some(error.to_string());
return service;
}
}
if service.check_http3 {
match check_http3(config, &service.url).await {
Ok((status, latency)) if (200..400).contains(&status) => {
service.latency_ms = Some(service.latency_ms.unwrap_or(0).max(latency));
}
Ok((status, _)) => {
service.status = ServiceStatus::Http3Error;
service.error = Some(format!("HTTP/3 returned {status}"));
return service;
}
Err(error) => {
service.status = ServiceStatus::Http3Error;
service.error = Some(error);
return service;
}
}
}
service.status = ServiceStatus::Online;
service
}
async fn check_http3(config: &Config, url: &str) -> Result<(u16, u64), String> {
let output = Command::new(&config.curl)
.args([
"--http3-only",
"--location",
"--silent",
"--show-error",
"--output",
"/dev/null",
"--write-out",
"%{http_code}\t%{time_total}",
"--max-time",
&config.timeout_seconds.to_string(),
url,
])
.stdin(Stdio::null())
.output()
.await
.map_err(|error| format!("HTTP/3 probe failed to start: {error}"))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr).trim().to_owned();
return Err(if error.is_empty() {
"HTTP/3 connection failed".into()
} else {
error
});
}
parse_http3_output(&String::from_utf8_lossy(&output.stdout))
}
fn is_operational_status(status: reqwest::StatusCode) -> bool {
status.is_success() || status.is_redirection()
}
fn parse_http3_output(value: &str) -> Result<(u16, u64), String> {
let (status, seconds) = value
.trim()
.split_once('\t')
.ok_or("Invalid HTTP/3 probe output")?;
let status = status.parse().map_err(|_| "Invalid HTTP/3 status code")?;
let seconds: f64 = seconds.parse().map_err(|_| "Invalid HTTP/3 latency")?;
Ok((status, (seconds * 1_000.0) as u64))
}
#[cfg(test)]
mod tests {
use reqwest::StatusCode;
use super::*;
#[test]
fn classifies_http_statuses() {
assert!(is_operational_status(StatusCode::OK));
assert!(is_operational_status(StatusCode::TEMPORARY_REDIRECT));
assert!(!is_operational_status(StatusCode::BAD_REQUEST));
assert!(!is_operational_status(StatusCode::SERVICE_UNAVAILABLE));
}
#[test]
fn parses_http3_probe_result() {
assert_eq!(parse_http3_output("204\t0.125").unwrap(), (204, 125));
assert!(parse_http3_output("not-a-result").is_err());
}
}

View file

@ -0,0 +1,96 @@
use std::{net::IpAddr, sync::Arc};
use mtp::{
codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap},
host::{HostConfig, Policy, SendMode},
webserver::{MTPWebServer, WebMTPConnection, WebServerConfig},
};
use tokio::sync::watch;
pub async fn serve(
address: IpAddr,
port: u16,
certificate: Vec<u8>,
private_key: Vec<u8>,
state: Arc<crate::AppState>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let policy = Policy::default().with_send_mode(SendMode::SingleStreamPerMessage);
let host = HostConfig::new(address, port, certificate, private_key).with_policy(policy);
let web = WebServerConfig::new()
.mtp_path("/mtp")
.serve_tcp_https(false);
let mut server = MTPWebServer::new(host, web).await?;
tracing::info!(address = %server.local_addr(), "MTP WebTransport listening");
let mut next_connection_id = 1_u64;
while let Some(connection) = server.accept().await? {
let connection_id = next_connection_id;
next_connection_id += 1;
tracing::info!(connection_id, "MTP client connected");
let updates = state.updates.subscribe();
let state = Arc::clone(&state);
tokio::spawn(
async move { serve_connection(connection_id, connection, updates, state).await },
);
}
Ok(())
}
async fn serve_connection(
connection_id: u64,
connection: WebMTPConnection,
mut updates: watch::Receiver<String>,
state: Arc<crate::AppState>,
) {
let sender = connection.sender.clone();
let type_map = connection.codec.type_map().clone();
loop {
tokio::select! {
request = connection.receiver.receive() => {
let request = match request {
Ok(request) => request,
Err(error) => {
tracing::warn!(connection_id, %error, "failed to receive MTP request");
break;
}
};
if request.is_type(CommunicationType::GetStatus) {
let payload = state.snapshot_json().await;
let bytes = payload.len();
let response = snapshot(&type_map, payload).with_id(request.get_id());
tracing::info!(connection_id, request_id = request.get_id(), bytes, "received status request");
if let Err(error) = sender.send(&response).await {
tracing::warn!(connection_id, request_id = request.get_id(), %error, "failed to send status response");
break;
}
tracing::info!(connection_id, request_id = request.get_id(), "sent status response");
} else {
tracing::warn!(connection_id, "received unsupported MTP request");
}
}
changed = updates.changed() => {
if changed.is_err() {
tracing::info!(connection_id, "status update channel closed");
break;
}
let payload = updates.borrow_and_update().clone();
let bytes = payload.len();
let response = snapshot(&type_map, payload);
if let Err(error) = sender.send(&response).await {
tracing::warn!(connection_id, %error, "failed to push status update");
break;
}
tracing::info!(connection_id, bytes, "pushed status update");
}
}
}
tracing::info!(connection_id, "MTP client disconnected");
}
fn snapshot(type_map: &TypeMap, payload: String) -> CommunicationValue {
CommunicationValue::from_comm(CommunicationType::StatusSnapshot, type_map).add_typed(
DataType::Payload,
type_map,
DataValue::Str(payload),
)
}