//! JSON API handlers for programmatic access and future SPA. //! //! All endpoints return JSON. The htmx frontend uses the page handlers instead, //! but these are available for CLI tools and external integrations. use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::Json; use axum::routing::{get, post}; use axum::Router; use serde::Serialize; use crate::daemon::SupervisorCmd; use crate::web::SharedState; pub fn routes() -> Router { Router::new() .route("/api/status", get(get_status)) .route("/api/status/{share}", get(get_share_status)) .route("/api/config", get(get_config)) .route("/api/config", post(post_config)) .route("/api/bwlimit", post(post_bwlimit)) } /// GET /api/status — overall daemon status. #[derive(Serialize)] struct StatusResponse { uptime: String, shares: Vec, smbd_running: bool, webdav_running: bool, nfs_exported: bool, } #[derive(Serialize)] struct ShareStatusResponse { name: String, mounted: bool, rc_port: u16, cache_bytes: u64, cache_display: String, dirty_count: u64, errored_files: u64, speed: f64, speed_display: String, transfers: u64, errors: u64, } async fn get_status(State(state): State) -> Json { let status = state.status.read().unwrap(); Json(StatusResponse { uptime: status.uptime_string(), shares: status .shares .iter() .map(|s| ShareStatusResponse { name: s.name.clone(), mounted: s.mounted, rc_port: s.rc_port, cache_bytes: s.cache_bytes, cache_display: s.cache_display(), dirty_count: s.dirty_count, errored_files: s.errored_files, speed: s.speed, speed_display: s.speed_display(), transfers: s.transfers, errors: s.errors, }) .collect(), smbd_running: status.smbd_running, webdav_running: status.webdav_running, nfs_exported: status.nfs_exported, }) } /// GET /api/status/{share} — per-share status. async fn get_share_status( State(state): State, Path(share_name): Path, ) -> Result, StatusCode> { let status = state.status.read().unwrap(); let share = status .shares .iter() .find(|s| s.name == share_name) .ok_or(StatusCode::NOT_FOUND)?; Ok(Json(ShareStatusResponse { name: share.name.clone(), mounted: share.mounted, rc_port: share.rc_port, cache_bytes: share.cache_bytes, cache_display: share.cache_display(), dirty_count: share.dirty_count, errored_files: share.errored_files, speed: share.speed, speed_display: share.speed_display(), transfers: share.transfers, errors: share.errors, })) } /// GET /api/config — current config as JSON. async fn get_config(State(state): State) -> Json { let config = state.config.read().unwrap(); Json(serde_json::to_value(&*config).unwrap_or_default()) } /// POST /api/config — submit new config as TOML string. #[derive(serde::Deserialize)] struct ConfigSubmit { toml: String, } #[derive(Serialize)] struct ConfigResponse { ok: bool, message: String, diff_summary: Option, } async fn post_config( State(state): State, Json(body): Json, ) -> (StatusCode, Json) { // Parse and validate the new config let new_config: crate::config::Config = match toml::from_str(&body.toml) { Ok(c) => c, Err(e) => { return ( StatusCode::BAD_REQUEST, Json(ConfigResponse { ok: false, message: format!("TOML parse error: {e}"), diff_summary: None, }), ); } }; if let Err(e) = new_config.validate() { return ( StatusCode::BAD_REQUEST, Json(ConfigResponse { ok: false, message: format!("Validation error: {e}"), diff_summary: None, }), ); } // Compute diff for summary let diff_summary = { let old_config = state.config.read().unwrap(); let d = crate::config_diff::diff(&old_config, &new_config); if d.is_empty() { return ( StatusCode::OK, Json(ConfigResponse { ok: true, message: "No changes detected".to_string(), diff_summary: Some(d.summary()), }), ); } d.summary() }; // Save to disk if let Err(e) = std::fs::write(&state.config_path, &body.toml) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ConfigResponse { ok: false, message: format!("Failed to write config file: {e}"), diff_summary: Some(diff_summary), }), ); } // Send reload command to supervisor if let Err(e) = state.cmd_tx.send(SupervisorCmd::Reload(new_config)) { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ConfigResponse { ok: false, message: format!("Failed to send reload command: {e}"), diff_summary: Some(diff_summary), }), ); } ( StatusCode::OK, Json(ConfigResponse { ok: true, message: "Config applied successfully".to_string(), diff_summary: Some(diff_summary), }), ) } /// POST /api/bwlimit — live bandwidth adjustment. #[derive(serde::Deserialize)] struct BwLimitRequest { #[serde(default = "default_bw")] up: String, #[serde(default = "default_bw")] down: String, } fn default_bw() -> String { "0".to_string() } #[derive(Serialize)] struct BwLimitResponse { ok: bool, message: String, } async fn post_bwlimit( State(state): State, Json(body): Json, ) -> Json { match state .cmd_tx .send(SupervisorCmd::BwLimit { up: body.up, down: body.down, }) { Ok(()) => Json(BwLimitResponse { ok: true, message: "Bandwidth limit updated".to_string(), }), Err(e) => Json(BwLimitResponse { ok: false, message: format!("Failed to send command: {e}"), }), } }