From ee9ac2ce2d2da264c2bb6194788cc654161c978d Mon Sep 17 00:00:00 2001 From: grabbit Date: Thu, 19 Feb 2026 15:44:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Web=20UI=20=E2=80=94=20offline=20banner?= =?UTF-8?q?,=20sync=20indicator,=20preset=20buttons,=20reconnect=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task A: Offline mode banner in layout (nas_offline field in LayoutTemplate) - Task B: Safe-to-disconnect sync indicator on dashboard (all_synced field) - Task C: Preset apply buttons (photographer/video/office) in config tab with POST /api/preset/{profile} endpoint - Task D: Reconnect button and error banner in share detail panel - Added nas_offline/all_synced fields to DaemonStatus for integration contract Co-Authored-By: Claude Opus 4.6 --- src/daemon.rs | 6 ++ src/web/api.rs | 79 +++++++++++++++++++ src/web/pages.rs | 7 ++ static/style.css | 125 ++++++++++++++++++++++++++++++ templates/web/layout.html | 7 ++ templates/web/tabs/config.html | 39 ++++++++++ templates/web/tabs/dashboard.html | 14 ++++ templates/web/tabs/shares.html | 21 +++++ 8 files changed, 298 insertions(+) diff --git a/src/daemon.rs b/src/daemon.rs index 3e3449a..ebda868 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -60,6 +60,10 @@ pub struct DaemonStatus { pub dir_refresh_dirs_ok: HashMap, /// Number of subdirectories that failed to refresh in the last cycle, keyed by share name. pub dir_refresh_dirs_failed: HashMap, + /// Whether the NAS is currently unreachable. + pub nas_offline: bool, + /// Whether all dirty files have been synced (safe to disconnect). + pub all_synced: bool, } impl DaemonStatus { @@ -93,6 +97,8 @@ impl DaemonStatus { dir_refresh_gen_arc: Arc::new(AtomicU64::new(0)), dir_refresh_dirs_ok: HashMap::new(), dir_refresh_dirs_failed: HashMap::new(), + nas_offline: false, + all_synced: true, } } diff --git a/src/web/api.rs b/src/web/api.rs index 2dedb69..1d2f29a 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -22,6 +22,7 @@ pub fn routes() -> Router { .route("/api/config", post(post_config)) .route("/api/bwlimit", post(post_bwlimit)) .route("/api/logs", get(get_logs)) + .route("/api/preset/{profile}", post(post_preset)) } /// GET /api/status — overall daemon status. @@ -311,6 +312,84 @@ async fn post_bwlimit( } } +/// POST /api/preset/{profile} — apply a configuration preset. +async fn post_preset( + State(state): State, + Path(profile): Path, +) -> axum::response::Response { + use axum::response::IntoResponse; + + let allowed = ["photographer", "video", "office"]; + if !allowed.contains(&profile.as_str()) { + return (StatusCode::BAD_REQUEST, "Unknown preset").into_response(); + } + + let mut config = { + let cfg = state.config.read().unwrap(); + cfg.clone() + }; + + match profile.as_str() { + "photographer" => { + config.read.chunk_size = "256M".into(); + config.read.chunk_limit = "1G".into(); + config.read.read_ahead = "512M".into(); + config.read.buffer_size = "256M".into(); + config.read.multi_thread_streams = 4; + config.read.multi_thread_cutoff = "50M".into(); + config.directory_cache.cache_time = "2h".into(); + config.writeback.write_back = "5s".into(); + config.writeback.transfers = 4; + config.protocols.enable_smb = true; + config.protocols.enable_nfs = false; + config.protocols.enable_webdav = false; + } + "video" => { + config.read.chunk_size = "512M".into(); + config.read.chunk_limit = "2G".into(); + config.read.read_ahead = "1G".into(); + config.read.buffer_size = "512M".into(); + config.read.multi_thread_streams = 2; + config.read.multi_thread_cutoff = "100M".into(); + config.directory_cache.cache_time = "1h".into(); + config.writeback.write_back = "5s".into(); + config.writeback.transfers = 2; + config.protocols.enable_smb = true; + config.protocols.enable_nfs = false; + config.protocols.enable_webdav = false; + } + "office" => { + config.read.chunk_size = "64M".into(); + config.read.chunk_limit = "256M".into(); + config.read.read_ahead = "128M".into(); + config.read.buffer_size = "128M".into(); + config.read.multi_thread_streams = 4; + config.read.multi_thread_cutoff = "10M".into(); + config.directory_cache.cache_time = "30m".into(); + config.writeback.write_back = "3s".into(); + config.writeback.transfers = 4; + config.protocols.enable_smb = true; + config.protocols.enable_nfs = false; + config.protocols.enable_webdav = true; + } + _ => unreachable!(), + } + + let toml_content = config.to_commented_toml(); + if let Err(e) = std::fs::write(&state.config_path, &toml_content) { + return format!("保存失败: {e}").into_response(); + } + + if let Err(e) = state + .cmd_tx + .send(SupervisorCmd::Reload(config)) + { + return format!("重载失败: {e}").into_response(); + } + + format!("✓ 已应用「{profile}」预设,配置重新加载中...").into_response() +} + /// GET /api/logs?lines=200&from_line=0 — recent log file entries. #[derive(serde::Deserialize)] struct LogsQuery { diff --git a/src/web/pages.rs b/src/web/pages.rs index ec182e2..19fcf9f 100644 --- a/src/web/pages.rs +++ b/src/web/pages.rs @@ -241,6 +241,7 @@ struct LayoutTemplate { tab_content: String, uptime: String, config_path: String, + nas_offline: bool, } #[derive(Template)] @@ -257,6 +258,7 @@ struct DashboardTabTemplate { smbd_running: bool, webdav_running: bool, nfs_exported: bool, + all_synced: bool, } #[derive(Template)] @@ -306,6 +308,7 @@ struct StatusPartialTemplate { smbd_running: bool, webdav_running: bool, nfs_exported: bool, + all_synced: bool, } // ─── Full-page handlers (layout shell + tab content) ────────────────────── @@ -354,6 +357,7 @@ fn render_layout( tab_content, uptime: status.uptime_string(), config_path: state.config_path.display().to_string(), + nas_offline: status.nas_offline, }; match tmpl.render() { @@ -407,6 +411,7 @@ fn render_dashboard_tab(status: &DaemonStatus, config: &Config) -> String { 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(), @@ -419,6 +424,7 @@ fn render_dashboard_tab(status: &DaemonStatus, config: &Config) -> String { smbd_running: status.smbd_running, webdav_running: status.webdav_running, nfs_exported: status.nfs_exported, + all_synced, }; tmpl.render().unwrap_or_default() @@ -616,6 +622,7 @@ async fn status_partial(State(state): State) -> Response { smbd_running: status.smbd_running, webdav_running: status.webdav_running, nfs_exported: status.nfs_exported, + all_synced: status.all_synced, }; match tmpl.render() { diff --git a/static/style.css b/static/style.css index 6bc4d83..768e8ad 100644 --- a/static/style.css +++ b/static/style.css @@ -599,6 +599,131 @@ textarea:focus { .toggle-sm .slider::after { width: 12px; height: 12px; } .toggle-sm input:checked + .slider::after { transform: translateX(12px); } +/* ─── Offline banner ───────────────────────────────────── */ + +.offline-banner { + background: #f59e0b; + color: #1c1917; + padding: 10px 20px; + display: flex; + align-items: center; + gap: 10px; + font-size: 0.9rem; + font-weight: 500; + border-bottom: 2px solid #d97706; + position: sticky; + top: 0; + z-index: 100; +} +.offline-banner .offline-icon { font-size: 1.1rem; } +.offline-banner .offline-sub { margin-left: auto; opacity: 0.7; font-size: 0.8rem; } + +/* ─── Sync indicator ──────────────────────────────────── */ + +.sync-indicator { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.9rem; + font-weight: 500; +} +.sync-ok { + background: rgba(34, 197, 94, 0.12); + color: #16a34a; + border: 1px solid rgba(34, 197, 94, 0.3); +} +.sync-pending { + background: rgba(245, 158, 11, 0.12); + color: #d97706; + border: 1px solid rgba(245, 158, 11, 0.3); +} +.sync-indicator .sync-icon { font-size: 1.1rem; } +.sync-indicator .sync-sub { margin-left: auto; opacity: 0.65; font-size: 0.8rem; } + +/* ─── Preset section (config tab) ─────────────────────── */ + +.preset-section { + margin-bottom: 20px; + padding: 16px; + background: var(--surface, #1e1e2e); + border-radius: 10px; + border: 1px solid var(--border, rgba(255,255,255,0.08)); +} +.preset-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.preset-hint { font-size: 0.78rem; opacity: 0.6; } +.preset-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.preset-btn { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--border, rgba(255,255,255,0.12)); + background: var(--surface2, rgba(255,255,255,0.04)); + cursor: pointer; + color: inherit; + min-width: 140px; + transition: background 0.15s, border-color 0.15s; +} +.preset-btn:hover { background: rgba(99,102,241,0.15); border-color: rgba(99,102,241,0.4); } +.preset-btn .preset-icon { font-size: 1.2rem; } +.preset-btn .preset-name { font-weight: 600; font-size: 0.9rem; } +.preset-btn .preset-desc { font-size: 0.72rem; opacity: 0.65; } +.preset-result { margin-top: 10px; min-height: 20px; font-size: 0.85rem; } +.preset-result .ok { color: #22c55e; } +.preset-result .error { color: #ef4444; } +.preset-spinner { display: none; font-size: 0.85rem; opacity: 0.7; } +.htmx-request .preset-spinner { display: block; } + +/* ─── Share error banner & action buttons ─────────────── */ + +.share-error-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 6px; + margin-top: 8px; + font-size: 0.85rem; + color: #f87171; +} +.action-btn { + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border, rgba(255,255,255,0.12)); + background: var(--surface2, rgba(255,255,255,0.06)); + cursor: pointer; + color: inherit; + font-size: 0.85rem; + transition: background 0.15s; +} +.action-btn:hover { background: rgba(99,102,241,0.2); } +.action-btn-sm { + padding: 3px 10px; + border-radius: 4px; + border: 1px solid rgba(239,68,68,0.4); + background: rgba(239,68,68,0.1); + cursor: pointer; + color: #f87171; + font-size: 0.78rem; + margin-left: auto; +} + /* ─── Responsive ───────────────────────────────────────── */ @media (max-width: 768px) { diff --git a/templates/web/layout.html b/templates/web/layout.html index 90c0b16..8499842 100644 --- a/templates/web/layout.html +++ b/templates/web/layout.html @@ -40,6 +40,13 @@ x-init="startTimer()" x-effect="localStorage.setItem('wg_auto_refresh', autoRefresh); localStorage.setItem('wg_refresh_interval', refreshInterval); startTimer()" > +{% if nas_offline %} + +{% endif %}
diff --git a/templates/web/tabs/config.html b/templates/web/tabs/config.html index d7630a2..1a8f995 100644 --- a/templates/web/tabs/config.html +++ b/templates/web/tabs/config.html @@ -137,6 +137,45 @@ if (window.Alpine) {
+ +
+
+ + 一键应用最佳实践配置,不影响 NAS 连接和 shares 设置 +
+
+ + + +
+
+
应用中...
+
+