warpgate/templates/web/tabs/config.html
grabbit 74b0e72549 Add periodic dir-refresh and per-share refresh status display
Introduces a ScheduledTask mechanism that periodically calls rclone RC
vfs/refresh to keep directory listing caches warm (no file downloads),
with two-level config (global default + per-share override). Adds
dir-refresh status badges and timestamps to the web UI shares tab and
CLI status output, following the same pattern as warmup/warmed.

- src/scheduler.rs: New generic ScheduledTask runner with generation-based
  cancellation and parse_interval() helper
- src/rclone/rc.rs: Add vfs_refresh() RC API call
- src/config.rs: Add DirRefreshConfig, per-share dir_refresh_interval
  override, effective_dir_refresh_interval() resolution method
- src/config_diff.rs: Track dir_refresh_changed for hot-reload
- src/daemon.rs: Track per-share last_dir_refresh timestamps (HashMap),
  add dir_refresh_ago_for() helper and format_ago()
- src/supervisor.rs: spawn_dir_refresh() per-share background threads,
  called on startup and config reload
- src/web/api.rs: Expose dir_refresh_active + last_dir_refresh_ago in
  ShareStatusResponse
- src/web/pages.rs: Populate dir_refresh_active + last_dir_refresh_ago
  in ShareView and ShareDetailView
- templates/web/tabs/shares.html: DIR-REFRESH badge (yellow=pending,
  green=N ago) in health column; Dir Refresh row in detail panel
- templates/web/tabs/config.html: Dir Refresh section and per-share
  interval field in interactive config editor
- src/cli/status.rs: Append Dir-Refresh suffix to mount status lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 10:54:08 +08:00

524 lines
20 KiB
HTML

<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">
<!-- 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>
</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>