The backend already supports warmup_schedule for periodic cache warmup, but the field was missing from the web config editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
903 lines
37 KiB
HTML
903 lines
37 KiB
HTML
<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 = '';
|
||
if (config.warmup.warmup_schedule == null) config.warmup.warmup_schedule = '';
|
||
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 class="field-row" style="margin-top:12px">
|
||
<label>Schedule (cron)</label>
|
||
<input type="text" x-model="config.warmup.warmup_schedule" placeholder='empty = disabled, e.g. "0 2 * * *" = daily 2am' style="max-width:360px">
|
||
</div>
|
||
<p style="font-size:0.82em;color:var(--text-muted);margin-top:4px;margin-bottom:8px">
|
||
Standard 5-field cron expression. When set, warmup rules run periodically in addition to startup.
|
||
</p>
|
||
<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>
|