//! HTML page handlers using askama templates for the htmx + Alpine.js frontend. use askama::Template; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Form, Json}; use axum::Router; use crate::config::Config; use crate::daemon::{DaemonStatus, ShareStatus}; use crate::web::SharedState; pub fn routes() -> Router { Router::new() // Full-page routes (serve layout shell with embedded tab content) .route("/", get(page_dashboard)) .route("/shares", get(page_shares)) .route("/shares/{name}", get(share_redirect)) .route("/config", get(page_config)) .route("/config", post(config_submit)) .route("/config/apply", post(config_apply)) .route("/logs", get(page_logs)) // Tab partial routes (htmx async load) .route("/tabs/dashboard", get(tab_dashboard)) .route("/tabs/shares", get(tab_shares)) .route("/tabs/config", get(tab_config)) .route("/tabs/logs", get(tab_logs)) // Legacy compatibility .route("/partials/status", get(status_partial)) } // ─── View models ────────────────────────────────────────────────────────── /// Compact share view for dashboard cards and status partial. #[allow(dead_code)] // fields used by askama templates struct ShareView { name: String, connection: String, mount_point: String, mounted: bool, cache_display: String, dirty_count: u64, speed_display: String, read_only: bool, health: String, health_message: String, warmup_state: String, warmup_done: usize, warmup_total: usize, dir_refresh_active: bool, last_dir_refresh_ago: String, } /// Extended share view for the shares table with all detail fields. #[allow(dead_code)] // fields used by askama templates struct ShareDetailView { name: String, connection: String, mount_point: String, remote_path: String, mounted: bool, read_only: bool, rc_port: u16, cache_display: String, dirty_count: u64, errored_files: u64, speed_display: String, transfers: u64, errors: u64, health: String, health_message: String, warmup_state: String, warmup_done: usize, warmup_total: usize, warmup_rules: Vec, dir_refresh_active: bool, last_dir_refresh_ago: String, } /// View model for a single warmup rule in the shares detail panel. #[allow(dead_code)] struct WarmupRuleView { path: String, newer_than: String, state: String, badge_class: String, total_files: usize, skipped: usize, cached: usize, errors: usize, } /// Build compact share views from status + config. fn build_share_views(status: &DaemonStatus, config: &Config) -> Vec { status .shares .iter() .map(|s| { let sc = config.find_share(&s.name); let (warmup_state, warmup_done, warmup_total) = status.warmup_summary_for(&s.name); let dir_refresh_active = sc .map(|sc| config.effective_dir_refresh_interval(sc).is_some()) .unwrap_or(false); let last_dir_refresh_ago = status.dir_refresh_ago_for(&s.name).unwrap_or_default(); ShareView { name: s.name.clone(), connection: sc.map(|c| c.connection.clone()).unwrap_or_default(), mount_point: sc .map(|c| c.mount_point.display().to_string()) .unwrap_or_default(), mounted: s.mounted, cache_display: s.cache_display(), dirty_count: s.dirty_count, speed_display: s.speed_display(), read_only: sc.map(|c| c.read_only).unwrap_or(false), health: s.health_label().to_string(), health_message: s.health_message().unwrap_or("").to_string(), warmup_state: warmup_state.to_string(), warmup_done, warmup_total, dir_refresh_active, last_dir_refresh_ago, } }) .collect() } /// Build extended share detail views from status + config. fn build_share_detail_views(status: &DaemonStatus, config: &Config) -> Vec { status .shares .iter() .map(|s| { let sc = config.find_share(&s.name); let (warmup_state, warmup_done, warmup_total) = status.warmup_summary_for(&s.name); let dir_refresh_active = sc .map(|sc| config.effective_dir_refresh_interval(sc).is_some()) .unwrap_or(false); let last_dir_refresh_ago = status.dir_refresh_ago_for(&s.name).unwrap_or_default(); // Build per-rule views for this share let warmup_rules: Vec = status .warmup .iter() .filter(|r| r.share == s.name) .map(|r| { let (state_str, badge_class) = match &r.state { crate::daemon::WarmupRuleState::Pending => ("pending", "warn"), crate::daemon::WarmupRuleState::Listing => ("listing", "warmup"), crate::daemon::WarmupRuleState::Caching => ("caching", "warmup"), crate::daemon::WarmupRuleState::Complete => ("complete", "ok"), crate::daemon::WarmupRuleState::Failed(_) => ("failed", "error"), }; WarmupRuleView { path: r.path.clone(), newer_than: r.newer_than.clone().unwrap_or_default(), state: state_str.to_string(), badge_class: badge_class.to_string(), total_files: r.total_files, skipped: r.skipped, cached: r.cached, errors: r.errors, } }) .collect(); ShareDetailView { name: s.name.clone(), connection: sc.map(|c| c.connection.clone()).unwrap_or_default(), mount_point: sc .map(|c| c.mount_point.display().to_string()) .unwrap_or_default(), remote_path: sc.map(|c| c.remote_path.clone()).unwrap_or_default(), mounted: s.mounted, read_only: sc.map(|c| c.read_only).unwrap_or(false), rc_port: s.rc_port, cache_display: s.cache_display(), dirty_count: s.dirty_count, errored_files: s.errored_files, speed_display: s.speed_display(), transfers: s.transfers, errors: s.errors, health: s.health_label().to_string(), health_message: s.health_message().unwrap_or("").to_string(), warmup_state: warmup_state.to_string(), warmup_done, warmup_total, warmup_rules, dir_refresh_active, last_dir_refresh_ago, } }) .collect() } /// Aggregate stats from share statuses. fn aggregate_stats(shares: &[ShareStatus]) -> (u64, f64, u64) { let total_cache: u64 = shares.iter().map(|s| s.cache_bytes).sum(); let total_speed: f64 = shares.iter().map(|s| s.speed).sum(); let active_transfers: u64 = shares.iter().map(|s| s.transfers).sum(); (total_cache, total_speed, active_transfers) } fn format_bytes(bytes: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = KIB * 1024.0; const GIB: f64 = MIB * 1024.0; const TIB: f64 = GIB * 1024.0; let b = bytes as f64; if b >= TIB { format!("{:.1} TiB", b / TIB) } else if b >= GIB { format!("{:.1} GiB", b / GIB) } else if b >= MIB { format!("{:.1} MiB", b / MIB) } else if b >= KIB { format!("{:.1} KiB", b / KIB) } else { format!("{bytes} B") } } fn format_speed(speed: f64) -> String { if speed < 1.0 { "-".to_string() } else { format!("{}/s", format_bytes(speed as u64)) } } // ─── Templates ──────────────────────────────────────────────────────────── #[derive(Template)] #[template(path = "web/layout.html", escape = "none")] struct LayoutTemplate { active_tab: String, tab_content: String, uptime: String, config_path: String, nas_offline: bool, } #[derive(Template)] #[template(path = "web/tabs/dashboard.html")] struct DashboardTabTemplate { total_shares: usize, healthy_count: usize, #[allow(dead_code)] failed_count: usize, total_cache_display: String, aggregate_speed_display: String, active_transfers: u64, shares: Vec, smbd_running: bool, webdav_running: bool, nfs_exported: bool, all_synced: bool, } #[derive(Template)] #[template(path = "web/tabs/shares.html")] struct SharesTabTemplate { shares: Vec, expand: String, } #[derive(Template)] #[template(path = "web/tabs/config.html", escape = "none")] struct ConfigTabTemplate { init_json: String, } /// Data embedded as JSON for the Alpine.js config editor. #[derive(serde::Serialize)] struct ConfigTabInit { config: Config, message: Option, is_error: bool, } /// JSON response for the `POST /config/apply` endpoint. #[derive(serde::Serialize)] struct ConfigApplyResponse { ok: bool, message: String, } #[derive(Template)] #[template(path = "web/tabs/logs.html")] struct LogsTabTemplate; /// Legacy htmx polling partial (backward compat for `/partials/status`). #[derive(Template)] #[template(path = "web/tabs/dashboard.html")] struct StatusPartialTemplate { total_shares: usize, healthy_count: usize, #[allow(dead_code)] failed_count: usize, total_cache_display: String, aggregate_speed_display: String, active_transfers: u64, shares: Vec, smbd_running: bool, webdav_running: bool, nfs_exported: bool, all_synced: bool, } // ─── Full-page handlers (layout shell + tab content) ────────────────────── async fn page_dashboard(State(state): State) -> Response { render_layout("dashboard", &state, |status, config| { render_dashboard_tab(status, config) }) } async fn page_shares( State(state): State, Query(params): Query, ) -> Response { let expand = params.expand.unwrap_or_default(); render_layout("shares", &state, |status, config| { render_shares_tab(status, config, &expand) }) } async fn page_config(State(state): State) -> Response { render_layout("config", &state, |_status, config| { render_config_tab_html(config, None, false) }) } async fn page_logs(State(state): State) -> Response { render_layout("logs", &state, |_status, _config| { LogsTabTemplate.render().unwrap_or_default() }) } /// Helper: render the layout shell wrapping a tab content generator. fn render_layout( tab: &str, state: &SharedState, tab_fn: impl FnOnce(&DaemonStatus, &Config) -> String, ) -> Response { let status = state.status.read().unwrap(); let config = state.config.read().unwrap(); let tab_content = tab_fn(&status, &config); let tmpl = LayoutTemplate { active_tab: tab.to_string(), tab_content, uptime: status.uptime_string(), config_path: state.config_path.display().to_string(), nas_offline: status.nas_offline, }; match tmpl.render() { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), } } // ─── Tab partial handlers (htmx async) ─────────────────────────────────── #[derive(serde::Deserialize)] struct ExpandQuery { expand: Option, } async fn tab_dashboard(State(state): State) -> Response { let status = state.status.read().unwrap(); let config = state.config.read().unwrap(); let html = render_dashboard_tab(&status, &config); Html(html).into_response() } async fn tab_shares( State(state): State, Query(params): Query, ) -> Response { let status = state.status.read().unwrap(); let config = state.config.read().unwrap(); let expand = params.expand.unwrap_or_default(); let html = render_shares_tab(&status, &config, &expand); Html(html).into_response() } async fn tab_config(State(state): State) -> Response { let config = state.config.read().unwrap(); let html = render_config_tab_html(&config, None, false); Html(html).into_response() } async fn tab_logs() -> Response { match LogsTabTemplate.render() { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), } } // ─── Tab render helpers ─────────────────────────────────────────────────── fn render_dashboard_tab(status: &DaemonStatus, config: &Config) -> String { let shares = build_share_views(status, config); let healthy_count = shares.iter().filter(|s| s.health == "OK").count(); let failed_count = shares.iter().filter(|s| s.health == "FAILED").count(); let (total_cache, total_speed, active_transfers) = aggregate_stats(&status.shares); let all_synced = status.all_synced; let tmpl = DashboardTabTemplate { total_shares: shares.len(), healthy_count, failed_count, total_cache_display: format_bytes(total_cache), aggregate_speed_display: format_speed(total_speed), active_transfers, shares, smbd_running: status.smbd_running, webdav_running: status.webdav_running, nfs_exported: status.nfs_exported, all_synced, }; tmpl.render().unwrap_or_default() } fn render_shares_tab(status: &DaemonStatus, config: &Config, expand: &str) -> String { let shares = build_share_detail_views(status, config); let tmpl = SharesTabTemplate { shares, expand: expand.to_string(), }; tmpl.render().unwrap_or_default() } // ─── Share detail redirect ──────────────────────────────────────────────── async fn share_redirect(Path(name): Path) -> Response { Redirect::to(&format!("/shares?expand={name}")).into_response() } // ─── Config submit ──────────────────────────────────────────────────────── #[derive(serde::Deserialize)] struct ConfigForm { toml: String, } async fn config_submit( State(state): State, Form(form): Form, ) -> Response { // Parse and validate let new_config: Config = match toml::from_str(&form.toml) { Ok(c) => c, Err(e) => { let config = state.config.read().unwrap(); let html = render_config_tab_html( &config, Some(format!("TOML parse error: {e}")), true, ); return Html(html).into_response(); } }; if let Err(e) = new_config.validate() { let html = render_config_tab_html( &new_config, Some(format!("Validation error: {e}")), true, ); return Html(html).into_response(); } // Compute diff 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() { let html = render_config_tab_html( &new_config, Some("No changes detected.".to_string()), false, ); return Html(html).into_response(); } d.summary() }; // Save to disk if let Err(e) = std::fs::write(&state.config_path, &form.toml) { let html = render_config_tab_html( &new_config, Some(format!("Failed to write config: {e}")), true, ); return Html(html).into_response(); } // Send reload command if let Err(e) = state .cmd_tx .send(crate::daemon::SupervisorCmd::Reload(new_config)) { let config = state.config.read().unwrap(); let html = render_config_tab_html( &config, Some(format!("Failed to send reload: {e}")), true, ); return Html(html).into_response(); } // Success — re-read config and show success message let config = state.config.read().unwrap(); let html = render_config_tab_html( &config, Some(format!("Config applied: {diff_summary}")), false, ); Html(html).into_response() } /// JSON endpoint: apply config from the interactive form editor. async fn config_apply( State(state): State, Json(new_config): Json, ) -> Json { // Validate if let Err(e) = new_config.validate() { return Json(ConfigApplyResponse { ok: false, message: format!("Validation error: {e}"), }); } // Compute diff 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 Json(ConfigApplyResponse { ok: true, message: "No changes detected.".to_string(), }); } d.summary() }; // Serialize to human-readable TOML and write to disk let toml_content = new_config.to_commented_toml(); if let Err(e) = std::fs::write(&state.config_path, &toml_content) { return Json(ConfigApplyResponse { ok: false, message: format!("Failed to write config: {e}"), }); } // Send reload command if let Err(e) = state .cmd_tx .send(crate::daemon::SupervisorCmd::Reload(new_config)) { return Json(ConfigApplyResponse { ok: false, message: format!("Failed to send reload: {e}"), }); } tracing::info!("Config applied: {diff_summary}"); Json(ConfigApplyResponse { ok: true, message: format!("Config applied: {diff_summary}"), }) } /// Render the config tab HTML using the interactive form editor. fn render_config_tab_html(config: &Config, message: Option, is_error: bool) -> String { let init = ConfigTabInit { config: config.clone(), message, is_error, }; // Escape tags let init_json = serde_json::to_string(&init) .unwrap_or_default() .replace(") -> Response { let status = state.status.read().unwrap(); let config = state.config.read().unwrap(); let shares = build_share_views(&status, &config); let healthy_count = shares.iter().filter(|s| s.health == "OK").count(); let failed_count = shares.iter().filter(|s| s.health == "FAILED").count(); let (total_cache, total_speed, active_transfers) = aggregate_stats(&status.shares); let tmpl = StatusPartialTemplate { total_shares: shares.len(), healthy_count, failed_count, total_cache_display: format_bytes(total_cache), aggregate_speed_display: format_speed(total_speed), active_transfers, shares, smbd_running: status.smbd_running, webdav_running: status.webdav_running, nfs_exported: status.nfs_exported, all_synced: status.all_synced, }; match tmpl.render() { Ok(html) => Html(html).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Template error: {e}")).into_response(), } }