warpgate/templates/web/tabs/config.html
grabbit d2b9f46b1a feat: add Apply Config progress modal and fix stale PENDING health after reload
- Add 4-step progress modal to config apply flow (validate, write, reload, services ready)
- Poll SSE-updated data-share-health attributes to detect when services finish restarting
- Fix stale health bug: recalculate health for affected shares based on actual mount
  success instead of preserving old health from before reload
- Add modal overlay/card/step CSS matching the dark theme
- Include connection refactor (multi-protocol support) and probe helpers from prior work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 01:11:50 +08:00

895 lines
37 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() {
// Read config synchronously so x-for renders on the first pass.
// If init() sets config *after* Alpine's first scan, x-for elements
// created in the re-render may miss their event-listener binding.
const _initData = JSON.parse(document.getElementById('config-init').textContent);
function _prepareForEdit(config) {
for (const conn of config.connections) {
// Ensure protocol field exists (default sftp)
if (!conn.protocol) conn.protocol = 'sftp';
// Ensure all optional fields exist for Alpine.js binding
if (conn.pass == null) conn.pass = '';
if (conn.key_file == null) conn.key_file = '';
if (conn.domain == null) conn.domain = '';
if (conn.share == null) conn.share = '';
// Ensure numeric fields have defaults
if (conn.port == null) conn.port = conn.protocol === 'smb' ? 445 : 22;
if (conn.connections == null) conn.connections = 8;
}
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;
}
const _config = _prepareForEdit(_initData.config);
return {
config: _config,
originalConfig: JSON.parse(JSON.stringify(_config)),
submitting: false,
message: _initData.message || null,
isError: _initData.is_error || false,
applyModal: { open: false, steps: [], error: null, done: false },
connTest: {},
browseState: {},
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() {
// config is already set; nothing to do here.
},
/** Convert null optional fields to empty strings for form binding. */
prepareForEdit(config) {
return _prepareForEdit(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.pass) conn.pass = null;
if (conn.protocol === 'sftp') {
// SFTP: keep key_file, connections; remove SMB-only fields
if (!conn.key_file) conn.key_file = null;
delete conn.domain;
delete conn.share;
} else {
// SMB: keep domain, share; remove SFTP-only fields
if (!conn.domain) conn.domain = null;
delete conn.key_file;
delete conn.connections;
}
}
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: '', host: '', protocol: 'sftp',
user: '', pass: '', key_file: '',
port: 22, connections: 8,
domain: '', share: ''
});
},
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 testConn(conn, i) {
if (this.connTest[i] && this.connTest[i].loading) return;
this.connTest = { ...this.connTest, [i]: { loading: true, ok: null, message: '' } };
try {
const payload = this._connPayload(conn);
const resp = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
this.connTest = { ...this.connTest, [i]: { loading: false, ok: result.ok, message: result.message } };
} catch (e) {
this.connTest = { ...this.connTest, [i]: { loading: false, ok: false, message: 'Network error: ' + e.message } };
}
},
/** Build a connection payload for test/browse API (name not required). */
_connPayload(conn) {
const base = {
host: conn.host,
protocol: conn.protocol || 'sftp',
user: conn.user,
pass: conn.pass || null,
port: conn.port,
};
if (base.protocol === 'sftp') {
base.key_file = conn.key_file || null;
base.connections = conn.connections || 8;
} else {
base.domain = conn.domain || null;
base.share = conn.share || '';
}
return base;
},
async browseDir(share, i) {
const path = share.remote_path || '/';
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: true, error: '', path } };
const conn = this.config.connections.find(c => c.name === share.connection);
if (!conn) {
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: false, error: 'Connection not found', path } };
return;
}
try {
const payload = { ...this._connPayload(conn), path };
const resp = await fetch('/api/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
if (result.ok) {
this.browseState = { ...this.browseState, [i]: { dirs: result.dirs, loading: false, error: '', path } };
} else {
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: false, error: result.error || 'Error', path } };
}
} catch (e) {
this.browseState = { ...this.browseState, [i]: { dirs: [], loading: false, error: 'Network error: ' + e.message, path } };
}
},
async browseIntoDir(share, i, subdir) {
const base = (this.browseState[i]?.path || '/').replace(/\/+$/, '');
const newPath = base + '/' + subdir;
share.remote_path = newPath;
await this.browseDir({ ...share, remote_path: newPath }, i);
},
async submitConfig() {
this.submitting = true;
this.message = null;
this.applyModal = {
open: true,
error: null,
done: false,
steps: [
{ label: 'Validating configuration', status: 'active' },
{ label: 'Writing config file', status: 'pending' },
{ label: 'Sending reload command', status: 'pending' },
{ label: 'Restarting services', status: 'pending' },
]
};
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();
if (!result.ok) {
this.applyModal.steps[0].status = 'error';
this.applyModal.error = result.message;
this.submitting = false;
return;
}
// Steps 1-3 all completed (single API call)
this.applyModal.steps[0].status = 'done';
this.applyModal.steps[1].status = 'done';
this.applyModal.steps[2].status = 'done';
this.applyModal.steps[3].status = 'active';
this.message = result.message;
this.isError = false;
this.originalConfig = JSON.parse(JSON.stringify(this.config));
// Watch SSE for service readiness
this._waitForServicesReady();
} catch (e) {
this.applyModal.steps[0].status = 'error';
this.applyModal.error = 'Network error: ' + e.message;
}
this.submitting = false;
},
_waitForServicesReady() {
const checkInterval = setInterval(() => {
const shareRows = document.querySelectorAll('[data-share-health]');
if (shareRows.length === 0) {
clearInterval(checkInterval);
this._markServicesDone();
return;
}
const allSettled = Array.from(shareRows).every(el => {
const h = el.dataset.shareHealth;
return h && h !== 'PENDING' && h !== 'PROBING';
});
if (allSettled) {
clearInterval(checkInterval);
this._markServicesDone();
}
}, 500);
// Safety timeout: 30s max wait
setTimeout(() => {
clearInterval(checkInterval);
if (this.applyModal.steps[3].status === 'active') {
this._markServicesDone();
}
}, 30000);
},
_markServicesDone() {
this.applyModal.steps[3].status = 'done';
this.applyModal.done = true;
},
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="configEditorFn()">
<!-- 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>
<div class="item-header-actions">
<button type="button" @click="testConn(conn, i)" class="test-btn">
<span x-show="!(connTest[i] && connTest[i].loading)">Test</span>
<span x-show="connTest[i] && connTest[i].loading" style="display:none">Testing…</span>
</button>
<span x-show="connTest[i] && connTest[i].ok === true" class="test-ok" style="display:none">✓ Connected</span>
<span x-show="connTest[i] && connTest[i].ok === false" class="test-fail" style="display:none" x-text="connTest[i] ? connTest[i].message : ''"></span>
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
</div>
</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>Protocol *</label>
<select x-model="conn.protocol" @change="conn.port = conn.protocol === 'smb' ? 445 : 22">
<option value="sftp">SFTP</option>
<option value="smb">SMB</option>
</select>
</div>
<div class="field-row">
<label>Host *</label>
<input type="text" x-model="conn.host" required placeholder="e.g. 100.64.0.1">
</div>
<div class="field-row">
<label>Username *</label>
<input type="text" x-model="conn.user" required placeholder="e.g. admin">
</div>
<div class="field-row">
<label>Password</label>
<input type="password" x-model="conn.pass"
:placeholder="conn.protocol === 'smb' ? 'Required for SMB' : '(optional if using key)'">
</div>
<div class="field-row">
<label>Port</label>
<input type="number" x-model.number="conn.port" min="1" max="65535">
</div>
<!-- SFTP-only fields -->
<div class="field-row" x-show="conn.protocol === 'sftp'" x-transition>
<label>SSH Key File</label>
<input type="text" x-model="conn.key_file" class="mono" placeholder="/root/.ssh/id_rsa">
</div>
<div class="field-row" x-show="conn.protocol === 'sftp'" x-transition>
<label>SFTP Connections</label>
<input type="number" x-model.number="conn.connections" min="1" max="128">
</div>
<!-- SMB-only fields -->
<div class="field-row" x-show="conn.protocol === 'smb'" x-transition>
<label>Share Name *</label>
<input type="text" x-model="conn.share" required placeholder="e.g. photos">
</div>
<div class="field-row" x-show="conn.protocol === 'smb'" x-transition>
<label>Domain</label>
<input type="text" x-model="conn.domain" placeholder="e.g. WORKGROUP (optional)">
</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>
<div class="browse-combo">
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos"
@change="browseState = { ...browseState, [i]: null }">
<button type="button" class="browse-btn"
:disabled="(browseState[i] && browseState[i].loading) || !share.connection"
@click="browseDir(share, i)">
<span x-show="!(browseState[i] && browseState[i].loading)">Browse</span>
<span x-show="browseState[i] && browseState[i].loading" style="display:none">Loading…</span>
</button>
</div>
<div x-show="browseState[i] && browseState[i].dirs && browseState[i].dirs.length > 0" class="dir-dropdown">
<template x-for="d in (browseState[i] && browseState[i].dirs || [])" :key="d">
<div class="dir-item">
<span class="dir-name"
@click="share.remote_path = browseState[i].path.replace(/\/+$/, '') + '/' + d; browseState = { ...browseState, [i]: { ...browseState[i], dirs: [] } }"
x-text="d"></span>
<button type="button" class="dir-enter" title="Enter directory"
@click="browseIntoDir(share, i, d)"></button>
</div>
</template>
</div>
<div x-show="browseState[i] && browseState[i].error" class="browse-error" x-text="browseState[i] ? browseState[i].error : ''"></div>
</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>
<!-- Apply Config Progress Modal -->
<div class="modal-overlay" x-show="applyModal.open" x-transition.opacity x-cloak
@keydown.escape.window="if (applyModal.done || applyModal.error) applyModal.open = false">
<div class="modal-card" @click.stop>
<h3 class="modal-title">Applying Configuration</h3>
<div class="modal-steps">
<template x-for="(step, i) in applyModal.steps" :key="i">
<div class="modal-step" :class="'step-' + step.status">
<span class="step-icon">
<template x-if="step.status === 'done'">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 8.5L6.5 12L13 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
<template x-if="step.status === 'active'">
<span class="step-spinner"></span>
</template>
<template x-if="step.status === 'error'">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</template>
<template x-if="step.status === 'pending'">
<span class="step-dot"></span>
</template>
</span>
<span class="step-label" x-text="step.label"></span>
</div>
</template>
</div>
<div x-show="applyModal.error" class="modal-error" x-text="applyModal.error"></div>
<div class="modal-footer">
<button class="btn btn-primary"
x-show="applyModal.done || applyModal.error"
@click="applyModal.open = false">
Close
</button>
</div>
</div>
</div>
</div>