warpgate/src/web/api.rs
grabbit 6bb7ec4d27 Web UI overhaul: interactive config editor, SSE live updates, log viewer, and SMB reload fixes
- 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>
2026-02-18 18:06:52 +08:00

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,
})
}