warpgate/templates/web/tabs/config.html

762 lines
31 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) {
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;
}
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,
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.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 testConn(conn, i) {
if (this.connTest[i] && this.connTest[i].loading) return;
this.connTest = { ...this.connTest, [i]: { loading: true, ok: null, message: '' } };
try {
const resp = await fetch('/api/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nas_host: conn.nas_host,
nas_user: conn.nas_user,
nas_pass: conn.nas_pass || null,
nas_key_file: conn.nas_key_file || null,
sftp_port: conn.sftp_port || 22,
})
});
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 } };
}
},
_connParamsFor(connName) {
const conn = this.config.connections.find(c => c.name === connName);
if (!conn) return null;
return {
nas_host: conn.nas_host,
nas_user: conn.nas_user,
nas_pass: conn.nas_pass || null,
nas_key_file: conn.nas_key_file || null,
sftp_port: conn.sftp_port || 22,
};
},
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 resp = await fetch('/api/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nas_host: conn.nas_host,
nas_user: conn.nas_user,
nas_pass: conn.nas_pass || null,
nas_key_file: conn.nas_key_file || null,
sftp_port: conn.sftp_port || 22,
path,
})
});
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;
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="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>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>
<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>
</div>