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:
parent
f948cd1a64
commit
ee9ac2ce2d
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
125
static/style.css
125
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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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" %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user