- Replace raw TOML textarea with Alpine.js interactive form editor (10 collapsible sections with change-tier badges, dynamic array management for connections/shares/ warmup rules, proper input controls per field type) - Add SSE-based live dashboard updates replacing htmx polling - Add log viewer tab with ring buffer backend and incremental polling - Fix SMB not seeing new shares after config reload: kill entire smbd process group (not just parent PID) so forked workers release port 445 - Add SIGHUP-based smbd config reload for share changes instead of full restart, preserving existing client connections - Generate human-readable commented TOML from config editor instead of bare toml::to_string_pretty() output - Fix Alpine.js 2.x __x.$data calls in dashboard/share templates (now Alpine 3.x) - Fix toggle switch CSS overlap with field labels - Fix dashboard going blank on tab switch (remove hx-swap-oob from tab content) - Add htmx:afterSettle → Alpine.initTree() bridge for robust tab switching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
274 lines
7.5 KiB
Rust
274 lines
7.5 KiB
Rust
//! 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, Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::Json;
|
|
use axum::routing::{get, post};
|
|
use axum::Router;
|
|
use serde::Serialize;
|
|
|
|
use crate::daemon::{LogEntry, SupervisorCmd};
|
|
use crate::web::SharedState;
|
|
|
|
pub fn routes() -> Router<SharedState> {
|
|
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))
|
|
.route("/api/logs", get(get_logs))
|
|
}
|
|
|
|
/// GET /api/status — overall daemon status.
|
|
#[derive(Serialize)]
|
|
struct StatusResponse {
|
|
uptime: String,
|
|
shares: Vec<ShareStatusResponse>,
|
|
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,
|
|
health: String,
|
|
health_message: Option<String>,
|
|
}
|
|
|
|
async fn get_status(State(state): State<SharedState>) -> Json<StatusResponse> {
|
|
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,
|
|
health: s.health_label().to_string(),
|
|
health_message: s.health_message().map(|m| m.to_string()),
|
|
})
|
|
.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<SharedState>,
|
|
Path(share_name): Path<String>,
|
|
) -> Result<Json<ShareStatusResponse>, 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,
|
|
health: share.health_label().to_string(),
|
|
health_message: share.health_message().map(|m| m.to_string()),
|
|
}))
|
|
}
|
|
|
|
/// GET /api/config — current config as JSON.
|
|
async fn get_config(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
|
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<String>,
|
|
}
|
|
|
|
async fn post_config(
|
|
State(state): State<SharedState>,
|
|
Json(body): Json<ConfigSubmit>,
|
|
) -> (StatusCode, Json<ConfigResponse>) {
|
|
// 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<SharedState>,
|
|
Json(body): Json<BwLimitRequest>,
|
|
) -> Json<BwLimitResponse> {
|
|
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}"),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// GET /api/logs?since=0 — recent log entries.
|
|
#[derive(serde::Deserialize)]
|
|
struct LogsQuery {
|
|
#[serde(default)]
|
|
since: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct LogsResponse {
|
|
next_id: u64,
|
|
entries: Vec<LogEntry>,
|
|
}
|
|
|
|
async fn get_logs(
|
|
State(state): State<SharedState>,
|
|
Query(params): Query<LogsQuery>,
|
|
) -> Json<LogsResponse> {
|
|
let logs = state.logs.read().unwrap();
|
|
let entries = logs.since(params.since);
|
|
Json(LogsResponse {
|
|
next_id: logs.next_id(),
|
|
entries,
|
|
})
|
|
}
|