Step 1 — Unify preset logic (eliminate dual implementation)
- src/cli/preset.rs: add missing fields (chunk_limit, multi_thread_streams,
multi_thread_cutoff), fix Office buffer_size 64M→128M, implement FromStr
- src/web/api.rs: post_preset() now calls Preset::apply() — no more inlined
params; Office write_back unified to 5s (was 3s in API)
Step 2 — Fix setup.rs connection test: warn→bail
- All 4 "Warning: Could not connect/resolve" prints replaced with anyhow::bail!
matching deploy/setup.rs behavior
Step 3 — Web UI: add [web] and [notifications] edit sections
- templates/web/tabs/config.html: new collapsible Web UI (password) and
Notifications (webhook_url, cache_threshold_pct, nas_offline_minutes,
writeback_depth) sections, both tagged "No restart"
- Also adds [log] section (file path + level select, "Full restart")
Step 4 — Full cron expression support in warmup scheduler
- Cargo.toml: add cron = "0.12", chrono = "0.4"
- supervisor.rs: normalize_cron_schedule() converts 5-field standard cron to
7-field cron crate format; replaces naive hour-only matching
Step 5 — Adaptive bandwidth algorithm
- supervisor.rs: extract compute_adaptive_limit() pure function; sliding
window of 6 samples, cv>0.3→congested (−25%, floor 1MiB/s), stable
near-limit→maintain, under-utilizing→+10% (capped at limit_up)
Step 6 — warpgate update command
- src/cli/update.rs: query GitHub Releases API, compare with CARGO_PKG_VERSION
- src/main.rs: add Update{apply}, SetupWifi, CloneMac{interface} commands
- src/cli/wifi.rs: TODO stub for WiFi AP setup
Unit tests (+35, total 188→223)
- cli/preset.rs: 10 tests — FromStr, all fields for each preset, idempotency,
connection/share isolation, write_back consistency regression
- supervisor.rs: 14 tests — normalize_cron_schedule (5 cases),
compute_adaptive_limit (9 cases: congestion, floor, stable, under-utilizing,
cap, zero-current, zero-max, empty window)
- config.rs: 11 tests — WebConfig (3), NotificationsConfig (4), LogConfig (4)
Shell tests (+4 scripts)
- tests/09-cli/test-preset-cli.sh: preset CLI without daemon; checks all
three presets write correct values including unified buffer_size/write_back
- tests/09-cli/test-update-command.sh: update command; skips on no-network
- tests/10-scheduled/test-cron-warmup-schedule.sh: "* * * * *" fires in <90s
- tests/10-scheduled/test-adaptive-bandwidth.sh: adaptive loop stability
- tests/harness/config-gen.sh: add warmup.warmup_schedule override support
- tests/run-all.sh: add 10-scheduled category
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
653 lines
26 KiB
HTML
653 lines
26 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,
|
||
web: false,
|
||
notifications: false,
|
||
log: 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>
|
||
|
||
<!-- ═══ Section: Web ═══ -->
|
||
<section class="config-section">
|
||
<div class="section-header" @click="sections.web = !sections.web">
|
||
<h3>Web UI <span class="tier-badge tier-none">No restart</span></h3>
|
||
<span class="chevron" x-text="sections.web ? '▾' : '▸'"></span>
|
||
</div>
|
||
<div class="section-body" x-show="sections.web" x-transition>
|
||
<div class="field-row">
|
||
<label>Password</label>
|
||
<input type="password" x-model="config.web.password" placeholder="Leave empty to disable authentication" style="max-width:320px">
|
||
</div>
|
||
<p style="font-size:0.82em;color:var(--text-muted);margin-top:8px">
|
||
Protects the Web UI with HTTP Basic Auth. Leave empty to allow unauthenticated access.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══ Section: Notifications ═══ -->
|
||
<section class="config-section">
|
||
<div class="section-header" @click="sections.notifications = !sections.notifications">
|
||
<h3>Notifications <span class="tier-badge tier-none">No restart</span></h3>
|
||
<span class="chevron" x-text="sections.notifications ? '▾' : '▸'"></span>
|
||
</div>
|
||
<div class="section-body" x-show="sections.notifications" x-transition>
|
||
<div class="field-grid">
|
||
<div class="field-row">
|
||
<label>Webhook URL</label>
|
||
<input type="text" x-model="config.notifications.webhook_url" placeholder="https://... (Telegram/Bark/DingTalk)">
|
||
</div>
|
||
<div class="field-row">
|
||
<label>Cache Threshold %</label>
|
||
<input type="number" x-model.number="config.notifications.cache_threshold_pct" min="1" max="100" style="max-width:120px">
|
||
</div>
|
||
<div class="field-row">
|
||
<label>NAS Offline Minutes</label>
|
||
<input type="number" x-model.number="config.notifications.nas_offline_minutes" min="1" style="max-width:120px">
|
||
</div>
|
||
<div class="field-row">
|
||
<label>Write-back Depth</label>
|
||
<input type="number" x-model.number="config.notifications.writeback_depth" min="1" style="max-width:120px">
|
||
</div>
|
||
</div>
|
||
<p style="font-size:0.82em;color:var(--text-muted);margin-top:8px">
|
||
Send push notifications when cache is near full, NAS goes offline, or write-back queue grows large.
|
||
Leave Webhook URL empty to disable all notifications.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══ Section: Log ═══ -->
|
||
<section class="config-section">
|
||
<div class="section-header" @click="sections.log = !sections.log">
|
||
<h3>Log <span class="tier-badge tier-global">Full restart</span></h3>
|
||
<span class="chevron" x-text="sections.log ? '▾' : '▸'"></span>
|
||
</div>
|
||
<div class="section-body" x-show="sections.log" x-transition>
|
||
<div class="field-grid">
|
||
<div class="field-row">
|
||
<label>Log File</label>
|
||
<input type="text" x-model="config.log.file" class="mono" placeholder="/var/log/warpgate/warpgate.log (empty = no file logging)">
|
||
</div>
|
||
<div class="field-row">
|
||
<label>Log Level</label>
|
||
<select x-model="config.log.level">
|
||
<option value="error">error</option>
|
||
<option value="warn">warn</option>
|
||
<option value="info">info</option>
|
||
<option value="debug">debug</option>
|
||
<option value="trace">trace</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<p style="font-size:0.82em;color:var(--text-muted);margin-top:8px">
|
||
Changes to log settings require a full service restart to take effect.
|
||
Leave Log File empty to disable file logging (stdout only).
|
||
</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>
|