feat: Web UI — offline banner, sync indicator, preset buttons, reconnect button

- 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 <noreply@anthropic.com>
This commit is contained in:
grabbit 2026-02-19 15:44:36 +08:00
parent f948cd1a64
commit ee9ac2ce2d
8 changed files with 298 additions and 0 deletions

View File

@ -60,6 +60,10 @@ pub struct DaemonStatus {
pub dir_refresh_dirs_ok: HashMap<String, usize>,
/// Number of subdirectories that failed to refresh in the last cycle, keyed by share name.
pub dir_refresh_dirs_failed: HashMap<String, usize>,
/// 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,
}
}

View File

@ -22,6 +22,7 @@ pub fn routes() -> Router<SharedState> {
.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<SharedState>,
Path(profile): Path<String>,
) -> 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!("<span class='error'>保存失败: {e}</span>").into_response();
}
if let Err(e) = state
.cmd_tx
.send(SupervisorCmd::Reload(config))
{
return format!("<span class='error'>重载失败: {e}</span>").into_response();
}
format!("<span class='ok'>✓ 已应用「{profile}」预设,配置重新加载中...</span>").into_response()
}
/// GET /api/logs?lines=200&from_line=0 — recent log file entries.
#[derive(serde::Deserialize)]
struct LogsQuery {

View File

@ -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<SharedState>) -> Response {
smbd_running: status.smbd_running,
webdav_running: status.webdav_running,
nfs_exported: status.nfs_exported,
all_synced: status.all_synced,
};
match tmpl.render() {

View File

@ -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) {

View File

@ -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 %}
<div class="offline-banner" role="alert">
<span class="offline-icon"></span>
<strong>NAS 离线</strong> — 正在使用本地缓存(写入已排队)
<span class="offline-sub">Offline mode: using local cache, writes are queued</span>
</div>
{% endif %}
<div class="shell">
<div class="header">
<div>

View File

@ -137,6 +137,45 @@ if (window.Alpine) {
<div x-data="configEditor">
<!-- Preset buttons -->
<div class="preset-section">
<div class="preset-header">
<span class="section-label">快速预设 / Quick Presets</span>
<span class="preset-hint">一键应用最佳实践配置,不影响 NAS 连接和 shares 设置</span>
</div>
<div class="preset-buttons">
<button class="preset-btn preset-photographer"
hx-post="/api/preset/photographer"
hx-target="#preset-result"
hx-swap="innerHTML"
hx-indicator="#preset-spinner">
<span class="preset-icon">📷</span>
<span class="preset-name">摄影师</span>
<span class="preset-desc">RAW 大文件256M 分块读取</span>
</button>
<button class="preset-btn preset-video"
hx-post="/api/preset/video"
hx-target="#preset-result"
hx-swap="innerHTML"
hx-indicator="#preset-spinner">
<span class="preset-icon">🎬</span>
<span class="preset-name">视频剪辑</span>
<span class="preset-desc">顺序读取优化1G 预读缓冲</span>
</button>
<button class="preset-btn preset-office"
hx-post="/api/preset/office"
hx-target="#preset-result"
hx-swap="innerHTML"
hx-indicator="#preset-spinner">
<span class="preset-icon">💼</span>
<span class="preset-name">文档办公</span>
<span class="preset-desc">小文件响应30m 目录缓存</span>
</button>
</div>
<div id="preset-result" class="preset-result"></div>
<div id="preset-spinner" class="htmx-indicator preset-spinner">应用中...</div>
</div>
<!-- Message banner -->
<template x-if="message">
<div class="message" :class="isError ? 'message-error' : 'message-ok'" x-text="message"></div>

View File

@ -19,6 +19,20 @@
</div>
</div>
{% if all_synced %}
<div class="sync-indicator sync-ok" id="sync-status">
<span class="sync-icon"></span>
<span class="sync-text">已全部同步 — 可以断网</span>
<span class="sync-sub">All synced — safe to disconnect</span>
</div>
{% else %}
<div class="sync-indicator sync-pending" id="sync-status">
<span class="sync-icon"></span>
<span class="sync-text">同步进行中 — 请勿断网</span>
<span class="sync-sub">Sync in progress — do not disconnect</span>
</div>
{% endif %}
<div id="share-rows">
<div class="cards">
{% for share in shares %}

View File

@ -70,6 +70,27 @@
<div class="value">{{ share.transfers }}</div>
</div>
</div>
{% if share.health == "FAILED" %}
<div class="share-error-banner">
<span class="error-icon"></span>
<span class="error-msg">{{ share.health_message }}</span>
<button class="action-btn-sm"
hx-post="/api/reconnect/{{ share.name }}"
hx-target="closest .share-error-banner"
hx-swap="outerHTML">
重试
</button>
</div>
{% endif %}
<div style="margin-bottom:12px">
<button class="action-btn"
hx-post="/api/reconnect/{{ share.name }}"
hx-confirm="重新连接 {{ share.name }}"
hx-target="this"
hx-swap="outerHTML">
重新连接
</button>
</div>
<table class="info-table">
<tr><td>Health</td><td>{{ share.health }}</td></tr>
{% if share.health == "FAILED" %}