warpgate/templates/web/tabs/config.html
grabbit ee9ac2ce2d 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>
2026-02-19 15:44:36 +08:00

571 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script id="config-init" type="application/json">{{ init_json }}</script>
<script>
function configEditorFn() {
return {
config: {},
originalConfig: {},
submitting: false,
message: null,
isError: false,
sections: {
connections: true,
shares: true,
cache: false,
read: false,
bandwidth: false,
writeback: false,
directory_cache: false,
protocols: false,
smb_auth: false,
warmup: false,
dir_refresh: false,
},
init() {
const data = JSON.parse(document.getElementById('config-init').textContent);
this.config = this.prepareForEdit(data.config);
this.originalConfig = JSON.parse(JSON.stringify(this.config));
if (data.message) {
this.message = data.message;
this.isError = data.is_error;
}
},
/** Convert null optional fields to empty strings for form binding. */
prepareForEdit(config) {
for (const conn of config.connections) {
if (conn.nas_pass == null) conn.nas_pass = '';
if (conn.nas_key_file == null) conn.nas_key_file = '';
}
if (config.smb_auth.username == null) config.smb_auth.username = '';
if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = '';
for (const rule of config.warmup.rules) {
if (rule.newer_than == null) rule.newer_than = '';
}
for (const share of config.shares) {
if (share.dir_refresh_interval == null) share.dir_refresh_interval = '';
}
return config;
},
/** Convert empty optional strings back to null for the API. */
prepareForSubmit(config) {
const c = JSON.parse(JSON.stringify(config));
for (const conn of c.connections) {
if (!conn.nas_pass) conn.nas_pass = null;
if (!conn.nas_key_file) conn.nas_key_file = null;
}
if (!c.smb_auth.username) c.smb_auth.username = null;
if (!c.smb_auth.smb_pass) c.smb_auth.smb_pass = null;
for (const rule of c.warmup.rules) {
if (!rule.newer_than) rule.newer_than = null;
}
for (const share of c.shares) {
if (!share.dir_refresh_interval) share.dir_refresh_interval = null;
}
return c;
},
addConnection() {
this.config.connections.push({
name: '', nas_host: '', nas_user: '',
nas_pass: '', nas_key_file: '',
sftp_port: 22, sftp_connections: 8
});
},
addShare() {
this.config.shares.push({
name: '',
connection: this.config.connections[0]?.name || '',
remote_path: '/',
mount_point: '/mnt/',
read_only: false,
dir_refresh_interval: ''
});
},
addWarmupRule() {
this.config.warmup.rules.push({
share: this.config.shares[0]?.name || '',
path: '',
newer_than: ''
});
},
async submitConfig() {
this.submitting = true;
this.message = null;
try {
const payload = this.prepareForSubmit(this.config);
const resp = await fetch('/config/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
this.message = result.message;
this.isError = !result.ok;
if (result.ok) {
this.originalConfig = JSON.parse(JSON.stringify(this.config));
}
} catch (e) {
this.message = 'Network error: ' + e.message;
this.isError = true;
}
this.submitting = false;
},
resetConfig() {
this.config = JSON.parse(JSON.stringify(this.originalConfig));
this.message = null;
}
}
}
/* Register with Alpine.data for robust htmx swap support.
On initial load: alpine:init fires before Alpine scans the DOM.
On htmx swap: Alpine is already loaded, register directly. */
if (window.Alpine) {
Alpine.data('configEditor', configEditorFn);
} else {
document.addEventListener('alpine:init', function() {
Alpine.data('configEditor', configEditorFn);
});
}
</script>
<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>
</template>
<!-- ═══ Section: Connections ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.connections = !sections.connections">
<h3>Connections <span class="tier-badge tier-pershare">Per-share restart</span></h3>
<span class="chevron" x-text="sections.connections ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.connections" x-transition>
<template x-for="(conn, i) in config.connections" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="conn.name || 'New Connection'"></strong>
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Name *</label>
<input type="text" x-model="conn.name" required placeholder="e.g. home-nas">
</div>
<div class="field-row">
<label>NAS Host *</label>
<input type="text" x-model="conn.nas_host" required placeholder="e.g. 100.64.0.1">
</div>
<div class="field-row">
<label>Username *</label>
<input type="text" x-model="conn.nas_user" required placeholder="e.g. admin">
</div>
<div class="field-row">
<label>Password</label>
<input type="password" x-model="conn.nas_pass" placeholder="(optional if using key)">
</div>
<div class="field-row">
<label>SSH Key File</label>
<input type="text" x-model="conn.nas_key_file" class="mono" placeholder="/root/.ssh/id_rsa">
</div>
<div class="field-row">
<label>SFTP Port</label>
<input type="number" x-model.number="conn.sftp_port" min="1" max="65535">
</div>
<div class="field-row">
<label>SFTP Connections</label>
<input type="number" x-model.number="conn.sftp_connections" min="1" max="128">
</div>
</div>
</div>
</template>
<button type="button" @click="addConnection()" class="add-btn">+ Add Connection</button>
</div>
</section>
<!-- ═══ Section: Shares ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.shares = !sections.shares">
<h3>Shares <span class="tier-badge tier-pershare">Per-share restart</span></h3>
<span class="chevron" x-text="sections.shares ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.shares" x-transition>
<template x-for="(share, i) in config.shares" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="share.name || 'New Share'"></strong>
<button type="button" @click="config.shares.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Name *</label>
<input type="text" x-model="share.name" required placeholder="e.g. photos">
</div>
<div class="field-row">
<label>Connection *</label>
<select x-model="share.connection">
<template x-for="c in config.connections" :key="c.name">
<option :value="c.name" x-text="c.name"></option>
</template>
</select>
</div>
<div class="field-row">
<label>Remote Path *</label>
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos">
</div>
<div class="field-row">
<label>Mount Point *</label>
<input type="text" x-model="share.mount_point" class="mono" required placeholder="/mnt/photos">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="share.read_only">
<span class="slider"></span>
Read Only
</label>
</div>
<div class="field-row" style="margin-top:12px">
<label>Dir Refresh Interval</label>
<input type="text" x-model="share.dir_refresh_interval" placeholder='blank = global, "0" = disable, e.g. 10m' style="max-width:320px">
</div>
</div>
</template>
<button type="button" @click="addShare()" class="add-btn">+ Add Share</button>
</div>
</section>
<!-- ═══ Section: Cache ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.cache = !sections.cache">
<h3>Cache <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.cache ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.cache" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Cache Directory *</label>
<input type="text" x-model="config.cache.dir" class="mono" required placeholder="/mnt/ssd/cache">
</div>
<div class="field-row">
<label>Max Size</label>
<input type="text" x-model="config.cache.max_size" placeholder="e.g. 200G">
</div>
<div class="field-row">
<label>Max Age</label>
<input type="text" x-model="config.cache.max_age" placeholder="e.g. 720h">
</div>
<div class="field-row">
<label>Min Free Space</label>
<input type="text" x-model="config.cache.min_free" placeholder="e.g. 10G">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Read Tuning ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.read = !sections.read">
<h3>Read Tuning <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.read ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.read" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Chunk Size</label>
<input type="text" x-model="config.read.chunk_size" placeholder="e.g. 256M">
</div>
<div class="field-row">
<label>Chunk Limit</label>
<input type="text" x-model="config.read.chunk_limit" placeholder="e.g. 1G">
</div>
<div class="field-row">
<label>Read Ahead</label>
<input type="text" x-model="config.read.read_ahead" placeholder="e.g. 512M">
</div>
<div class="field-row">
<label>Buffer Size</label>
<input type="text" x-model="config.read.buffer_size" placeholder="e.g. 256M">
</div>
<div class="field-row">
<label>Multi-Thread Streams</label>
<input type="number" x-model.number="config.read.multi_thread_streams" min="1" max="64" placeholder="e.g. 4">
</div>
<div class="field-row">
<label>Multi-Thread Cutoff</label>
<input type="text" x-model="config.read.multi_thread_cutoff" placeholder="e.g. 50M">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Bandwidth ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.bandwidth = !sections.bandwidth">
<h3>Bandwidth <span class="tier-badge tier-live">Live</span></h3>
<span class="chevron" x-text="sections.bandwidth ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.bandwidth" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Upload Limit</label>
<input type="text" x-model="config.bandwidth.limit_up" placeholder="0 = unlimited, e.g. 10M">
</div>
<div class="field-row">
<label>Download Limit</label>
<input type="text" x-model="config.bandwidth.limit_down" placeholder="0 = unlimited, e.g. 50M">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.bandwidth.adaptive">
<span class="slider"></span>
Adaptive Throttling
</label>
</div>
</div>
</section>
<!-- ═══ Section: Write-back ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.writeback = !sections.writeback">
<h3>Write-back <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.writeback ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.writeback" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Write-back Delay</label>
<input type="text" x-model="config.writeback.write_back" placeholder="e.g. 5s">
</div>
<div class="field-row">
<label>Concurrent Transfers</label>
<input type="number" x-model.number="config.writeback.transfers" min="1" max="64">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Directory Cache ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.directory_cache = !sections.directory_cache">
<h3>Directory Cache <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.directory_cache ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.directory_cache" x-transition>
<div class="field-row">
<label>Cache Time (TTL)</label>
<input type="text" x-model="config.directory_cache.cache_time" placeholder="e.g. 1h" style="max-width:300px">
</div>
</div>
</section>
<!-- ═══ Section: Protocols ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.protocols = !sections.protocols">
<h3>Protocols <span class="tier-badge tier-protocol">Protocol restart</span></h3>
<span class="chevron" x-text="sections.protocols ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.protocols" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_smb">
<span class="slider"></span>
Enable SMB (Samba)
</label>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_nfs">
<span class="slider"></span>
Enable NFS
</label>
</div>
<div class="field-row" x-show="config.protocols.enable_nfs" x-transition style="margin-top:12px">
<label>NFS Allowed Network</label>
<input type="text" x-model="config.protocols.nfs_allowed_network" placeholder="e.g. 192.168.0.0/24" style="max-width:300px">
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_webdav">
<span class="slider"></span>
Enable WebDAV
</label>
</div>
<div class="field-row" x-show="config.protocols.enable_webdav" x-transition style="margin-top:12px">
<label>WebDAV Port</label>
<input type="number" x-model.number="config.protocols.webdav_port" min="1" max="65535" style="max-width:300px">
</div>
</div>
</section>
<!-- ═══ Section: SMB Auth ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.smb_auth = !sections.smb_auth">
<h3>SMB Auth <span class="tier-badge tier-protocol">Protocol restart</span></h3>
<span class="chevron" x-text="sections.smb_auth ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.smb_auth" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.smb_auth.enabled">
<span class="slider"></span>
Enable SMB Authentication
</label>
</div>
<div x-show="config.smb_auth.enabled" x-transition>
<div class="field-grid" style="margin-top:12px">
<div class="field-row">
<label>Username *</label>
<input type="text" x-model="config.smb_auth.username" placeholder="SMB username">
</div>
<div class="field-row">
<label>Password *</label>
<input type="password" x-model="config.smb_auth.smb_pass" placeholder="SMB password">
</div>
</div>
</div>
</div>
</section>
<!-- ═══ Section: Warmup ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.warmup = !sections.warmup">
<h3>Warmup <span class="tier-badge tier-none">No restart</span></h3>
<span class="chevron" x-text="sections.warmup ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.warmup" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.warmup.auto">
<span class="slider"></span>
Auto-warmup on Startup
</label>
</div>
<div style="margin-top:16px">
<label style="font-size:0.85em;color:var(--text-muted);display:block;margin-bottom:8px">Warmup Rules</label>
<template x-for="(rule, i) in config.warmup.rules" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="(rule.share || '?') + ':' + (rule.path || '/')"></strong>
<button type="button" @click="config.warmup.rules.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Share *</label>
<select x-model="rule.share">
<template x-for="s in config.shares" :key="s.name">
<option :value="s.name" x-text="s.name"></option>
</template>
</select>
</div>
<div class="field-row">
<label>Path *</label>
<input type="text" x-model="rule.path" class="mono" placeholder="e.g. Images/2024">
</div>
<div class="field-row">
<label>Newer Than</label>
<input type="text" x-model="rule.newer_than" placeholder="e.g. 7d, 24h (optional)">
</div>
</div>
</div>
</template>
<button type="button" @click="addWarmupRule()" class="add-btn">+ Add Warmup Rule</button>
</div>
</div>
</section>
<!-- ═══ Section: Dir Refresh ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.dir_refresh = !sections.dir_refresh">
<h3>Dir Refresh <span class="tier-badge tier-none">No restart</span></h3>
<span class="chevron" x-text="sections.dir_refresh ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.dir_refresh" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.dir_refresh.enabled">
<span class="slider"></span>
Enable Periodic Directory Refresh
</label>
</div>
<div x-show="config.dir_refresh.enabled" x-transition>
<div class="field-grid" style="margin-top:12px">
<div class="field-row">
<label>Interval</label>
<input type="text" x-model="config.dir_refresh.interval" placeholder="e.g. 30m, 1h" style="max-width:300px">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.dir_refresh.recursive">
<span class="slider"></span>
Recursive (include subdirectories)
</label>
</div>
</div>
<p style="font-size:0.82em;color:var(--text-muted);margin-top:10px">
Proactively refreshes directory listings so Finder/Explorer sees new files without waiting for cache expiry.
Per-share overrides can be set in the Shares section above.
</p>
</div>
</section>
<!-- ═══ Form Actions ═══ -->
<div class="form-actions" style="margin-top:24px">
<button type="button" @click="submitConfig()" class="btn btn-primary" :disabled="submitting">
<span x-show="!submitting">Apply Config</span>
<span x-show="submitting">Applying...</span>
</button>
<button type="button" @click="resetConfig()" class="btn btn-secondary">Reset</button>
</div>
</div>