- Replace raw TOML textarea with Alpine.js interactive form editor (10 collapsible sections with change-tier badges, dynamic array management for connections/shares/ warmup rules, proper input controls per field type) - Add SSE-based live dashboard updates replacing htmx polling - Add log viewer tab with ring buffer backend and incremental polling - Fix SMB not seeing new shares after config reload: kill entire smbd process group (not just parent PID) so forked workers release port 445 - Add SIGHUP-based smbd config reload for share changes instead of full restart, preserving existing client connections - Generate human-readable commented TOML from config editor instead of bare toml::to_string_pretty() output - Fix Alpine.js 2.x __x.$data calls in dashboard/share templates (now Alpine 3.x) - Fix toggle switch CSS overlap with field labels - Fix dashboard going blank on tab switch (remove hx-swap-oob from tab content) - Add htmx:afterSettle → Alpine.initTree() bridge for robust tab switching Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
476 lines
18 KiB
HTML
476 lines
18 KiB
HTML
<script id="config-init" type="application/json">{{ init_json }}</script>
|
|
<script>
|
|
function configEditorFn() {
|
|
return {
|
|
config: {},
|
|
originalConfig: {},
|
|
submitting: false,
|
|
message: null,
|
|
isError: false,
|
|
sections: {
|
|
connections: true,
|
|
shares: true,
|
|
cache: false,
|
|
read: false,
|
|
bandwidth: false,
|
|
writeback: false,
|
|
directory_cache: false,
|
|
protocols: false,
|
|
smb_auth: false,
|
|
warmup: false,
|
|
},
|
|
|
|
init() {
|
|
const data = JSON.parse(document.getElementById('config-init').textContent);
|
|
this.config = this.prepareForEdit(data.config);
|
|
this.originalConfig = JSON.parse(JSON.stringify(this.config));
|
|
if (data.message) {
|
|
this.message = data.message;
|
|
this.isError = data.is_error;
|
|
}
|
|
},
|
|
|
|
/** Convert null optional fields to empty strings for form binding. */
|
|
prepareForEdit(config) {
|
|
for (const conn of config.connections) {
|
|
if (conn.nas_pass == null) conn.nas_pass = '';
|
|
if (conn.nas_key_file == null) conn.nas_key_file = '';
|
|
}
|
|
if (config.smb_auth.username == null) config.smb_auth.username = '';
|
|
if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = '';
|
|
for (const rule of config.warmup.rules) {
|
|
if (rule.newer_than == null) rule.newer_than = '';
|
|
}
|
|
return config;
|
|
},
|
|
|
|
/** Convert empty optional strings back to null for the API. */
|
|
prepareForSubmit(config) {
|
|
const c = JSON.parse(JSON.stringify(config));
|
|
for (const conn of c.connections) {
|
|
if (!conn.nas_pass) conn.nas_pass = null;
|
|
if (!conn.nas_key_file) conn.nas_key_file = null;
|
|
}
|
|
if (!c.smb_auth.username) c.smb_auth.username = null;
|
|
if (!c.smb_auth.smb_pass) c.smb_auth.smb_pass = null;
|
|
for (const rule of c.warmup.rules) {
|
|
if (!rule.newer_than) rule.newer_than = null;
|
|
}
|
|
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
|
|
});
|
|
},
|
|
|
|
addWarmupRule() {
|
|
this.config.warmup.rules.push({
|
|
share: this.config.shares[0]?.name || '',
|
|
path: '',
|
|
newer_than: ''
|
|
});
|
|
},
|
|
|
|
async submitConfig() {
|
|
this.submitting = true;
|
|
this.message = null;
|
|
try {
|
|
const payload = this.prepareForSubmit(this.config);
|
|
const resp = await fetch('/config/apply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const result = await resp.json();
|
|
this.message = result.message;
|
|
this.isError = !result.ok;
|
|
if (result.ok) {
|
|
this.originalConfig = JSON.parse(JSON.stringify(this.config));
|
|
}
|
|
} catch (e) {
|
|
this.message = 'Network error: ' + e.message;
|
|
this.isError = true;
|
|
}
|
|
this.submitting = false;
|
|
},
|
|
|
|
resetConfig() {
|
|
this.config = JSON.parse(JSON.stringify(this.originalConfig));
|
|
this.message = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Register with Alpine.data for robust htmx swap support.
|
|
On initial load: alpine:init fires before Alpine scans the DOM.
|
|
On htmx swap: Alpine is already loaded, register directly. */
|
|
if (window.Alpine) {
|
|
Alpine.data('configEditor', configEditorFn);
|
|
} else {
|
|
document.addEventListener('alpine:init', function() {
|
|
Alpine.data('configEditor', configEditorFn);
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<div x-data="configEditor">
|
|
|
|
<!-- Message banner -->
|
|
<template x-if="message">
|
|
<div class="message" :class="isError ? 'message-error' : 'message-ok'" x-text="message"></div>
|
|
</template>
|
|
|
|
<!-- ═══ Section: Connections ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.connections = !sections.connections">
|
|
<h3>Connections <span class="tier-badge tier-pershare">Per-share restart</span></h3>
|
|
<span class="chevron" x-text="sections.connections ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.connections" x-transition>
|
|
<template x-for="(conn, i) in config.connections" :key="i">
|
|
<div class="array-item">
|
|
<div class="item-header">
|
|
<strong x-text="conn.name || 'New Connection'"></strong>
|
|
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
|
|
</div>
|
|
<div class="field-grid">
|
|
<div class="field-row">
|
|
<label>Name *</label>
|
|
<input type="text" x-model="conn.name" required placeholder="e.g. home-nas">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>NAS Host *</label>
|
|
<input type="text" x-model="conn.nas_host" required placeholder="e.g. 100.64.0.1">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Username *</label>
|
|
<input type="text" x-model="conn.nas_user" required placeholder="e.g. admin">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Password</label>
|
|
<input type="password" x-model="conn.nas_pass" placeholder="(optional if using key)">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>SSH Key File</label>
|
|
<input type="text" x-model="conn.nas_key_file" class="mono" placeholder="/root/.ssh/id_rsa">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>SFTP Port</label>
|
|
<input type="number" x-model.number="conn.sftp_port" min="1" max="65535">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>SFTP Connections</label>
|
|
<input type="number" x-model.number="conn.sftp_connections" min="1" max="128">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<button type="button" @click="addConnection()" class="add-btn">+ Add Connection</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Shares ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.shares = !sections.shares">
|
|
<h3>Shares <span class="tier-badge tier-pershare">Per-share restart</span></h3>
|
|
<span class="chevron" x-text="sections.shares ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.shares" x-transition>
|
|
<template x-for="(share, i) in config.shares" :key="i">
|
|
<div class="array-item">
|
|
<div class="item-header">
|
|
<strong x-text="share.name || 'New Share'"></strong>
|
|
<button type="button" @click="config.shares.splice(i, 1)" class="remove-btn">Remove</button>
|
|
</div>
|
|
<div class="field-grid">
|
|
<div class="field-row">
|
|
<label>Name *</label>
|
|
<input type="text" x-model="share.name" required placeholder="e.g. photos">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Connection *</label>
|
|
<select x-model="share.connection">
|
|
<template x-for="c in config.connections" :key="c.name">
|
|
<option :value="c.name" x-text="c.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Remote Path *</label>
|
|
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Mount Point *</label>
|
|
<input type="text" x-model="share.mount_point" class="mono" required placeholder="/mnt/photos">
|
|
</div>
|
|
</div>
|
|
<div class="field-row" style="margin-top:12px">
|
|
<label class="toggle">
|
|
<input type="checkbox" x-model="share.read_only">
|
|
<span class="slider"></span>
|
|
Read Only
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<button type="button" @click="addShare()" class="add-btn">+ Add Share</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Cache ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.cache = !sections.cache">
|
|
<h3>Cache <span class="tier-badge tier-global">Full restart</span></h3>
|
|
<span class="chevron" x-text="sections.cache ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.cache" x-transition>
|
|
<div class="field-grid">
|
|
<div class="field-row">
|
|
<label>Cache Directory *</label>
|
|
<input type="text" x-model="config.cache.dir" class="mono" required placeholder="/mnt/ssd/cache">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Max Size</label>
|
|
<input type="text" x-model="config.cache.max_size" placeholder="e.g. 200G">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Max Age</label>
|
|
<input type="text" x-model="config.cache.max_age" placeholder="e.g. 720h">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Min Free Space</label>
|
|
<input type="text" x-model="config.cache.min_free" placeholder="e.g. 10G">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Read Tuning ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.read = !sections.read">
|
|
<h3>Read Tuning <span class="tier-badge tier-global">Full restart</span></h3>
|
|
<span class="chevron" x-text="sections.read ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.read" x-transition>
|
|
<div class="field-grid">
|
|
<div class="field-row">
|
|
<label>Chunk Size</label>
|
|
<input type="text" x-model="config.read.chunk_size" placeholder="e.g. 256M">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Chunk Limit</label>
|
|
<input type="text" x-model="config.read.chunk_limit" placeholder="e.g. 1G">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Read Ahead</label>
|
|
<input type="text" x-model="config.read.read_ahead" placeholder="e.g. 512M">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Buffer Size</label>
|
|
<input type="text" x-model="config.read.buffer_size" placeholder="e.g. 256M">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Bandwidth ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.bandwidth = !sections.bandwidth">
|
|
<h3>Bandwidth <span class="tier-badge tier-live">Live</span></h3>
|
|
<span class="chevron" x-text="sections.bandwidth ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.bandwidth" x-transition>
|
|
<div class="field-grid">
|
|
<div class="field-row">
|
|
<label>Upload Limit</label>
|
|
<input type="text" x-model="config.bandwidth.limit_up" placeholder="0 = unlimited, e.g. 10M">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Download Limit</label>
|
|
<input type="text" x-model="config.bandwidth.limit_down" placeholder="0 = unlimited, e.g. 50M">
|
|
</div>
|
|
</div>
|
|
<div class="field-row" style="margin-top:12px">
|
|
<label class="toggle">
|
|
<input type="checkbox" x-model="config.bandwidth.adaptive">
|
|
<span class="slider"></span>
|
|
Adaptive Throttling
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Write-back ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.writeback = !sections.writeback">
|
|
<h3>Write-back <span class="tier-badge tier-global">Full restart</span></h3>
|
|
<span class="chevron" x-text="sections.writeback ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.writeback" x-transition>
|
|
<div class="field-grid">
|
|
<div class="field-row">
|
|
<label>Write-back Delay</label>
|
|
<input type="text" x-model="config.writeback.write_back" placeholder="e.g. 5s">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Concurrent Transfers</label>
|
|
<input type="number" x-model.number="config.writeback.transfers" min="1" max="64">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Directory Cache ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.directory_cache = !sections.directory_cache">
|
|
<h3>Directory Cache <span class="tier-badge tier-global">Full restart</span></h3>
|
|
<span class="chevron" x-text="sections.directory_cache ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.directory_cache" x-transition>
|
|
<div class="field-row">
|
|
<label>Cache Time (TTL)</label>
|
|
<input type="text" x-model="config.directory_cache.cache_time" placeholder="e.g. 1h" style="max-width:300px">
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Protocols ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.protocols = !sections.protocols">
|
|
<h3>Protocols <span class="tier-badge tier-protocol">Protocol restart</span></h3>
|
|
<span class="chevron" x-text="sections.protocols ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.protocols" x-transition>
|
|
<div class="field-row">
|
|
<label class="toggle">
|
|
<input type="checkbox" x-model="config.protocols.enable_smb">
|
|
<span class="slider"></span>
|
|
Enable SMB (Samba)
|
|
</label>
|
|
</div>
|
|
<div class="field-row" style="margin-top:12px">
|
|
<label class="toggle">
|
|
<input type="checkbox" x-model="config.protocols.enable_nfs">
|
|
<span class="slider"></span>
|
|
Enable NFS
|
|
</label>
|
|
</div>
|
|
<div class="field-row" x-show="config.protocols.enable_nfs" x-transition style="margin-top:12px">
|
|
<label>NFS Allowed Network</label>
|
|
<input type="text" x-model="config.protocols.nfs_allowed_network" placeholder="e.g. 192.168.0.0/24" style="max-width:300px">
|
|
</div>
|
|
<div class="field-row" style="margin-top:12px">
|
|
<label class="toggle">
|
|
<input type="checkbox" x-model="config.protocols.enable_webdav">
|
|
<span class="slider"></span>
|
|
Enable WebDAV
|
|
</label>
|
|
</div>
|
|
<div class="field-row" x-show="config.protocols.enable_webdav" x-transition style="margin-top:12px">
|
|
<label>WebDAV Port</label>
|
|
<input type="number" x-model.number="config.protocols.webdav_port" min="1" max="65535" style="max-width:300px">
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: SMB Auth ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.smb_auth = !sections.smb_auth">
|
|
<h3>SMB Auth <span class="tier-badge tier-protocol">Protocol restart</span></h3>
|
|
<span class="chevron" x-text="sections.smb_auth ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.smb_auth" x-transition>
|
|
<div class="field-row">
|
|
<label class="toggle">
|
|
<input type="checkbox" x-model="config.smb_auth.enabled">
|
|
<span class="slider"></span>
|
|
Enable SMB Authentication
|
|
</label>
|
|
</div>
|
|
<div x-show="config.smb_auth.enabled" x-transition>
|
|
<div class="field-grid" style="margin-top:12px">
|
|
<div class="field-row">
|
|
<label>Username *</label>
|
|
<input type="text" x-model="config.smb_auth.username" placeholder="SMB username">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Password *</label>
|
|
<input type="password" x-model="config.smb_auth.smb_pass" placeholder="SMB password">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ Section: Warmup ═══ -->
|
|
<section class="config-section">
|
|
<div class="section-header" @click="sections.warmup = !sections.warmup">
|
|
<h3>Warmup <span class="tier-badge tier-none">No restart</span></h3>
|
|
<span class="chevron" x-text="sections.warmup ? '▾' : '▸'"></span>
|
|
</div>
|
|
<div class="section-body" x-show="sections.warmup" x-transition>
|
|
<div class="field-row">
|
|
<label class="toggle">
|
|
<input type="checkbox" x-model="config.warmup.auto">
|
|
<span class="slider"></span>
|
|
Auto-warmup on Startup
|
|
</label>
|
|
</div>
|
|
<div style="margin-top:16px">
|
|
<label style="font-size:0.85em;color:var(--text-muted);display:block;margin-bottom:8px">Warmup Rules</label>
|
|
<template x-for="(rule, i) in config.warmup.rules" :key="i">
|
|
<div class="array-item">
|
|
<div class="item-header">
|
|
<strong x-text="(rule.share || '?') + ':' + (rule.path || '/')"></strong>
|
|
<button type="button" @click="config.warmup.rules.splice(i, 1)" class="remove-btn">Remove</button>
|
|
</div>
|
|
<div class="field-grid">
|
|
<div class="field-row">
|
|
<label>Share *</label>
|
|
<select x-model="rule.share">
|
|
<template x-for="s in config.shares" :key="s.name">
|
|
<option :value="s.name" x-text="s.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Path *</label>
|
|
<input type="text" x-model="rule.path" class="mono" placeholder="e.g. Images/2024">
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Newer Than</label>
|
|
<input type="text" x-model="rule.newer_than" placeholder="e.g. 7d, 24h (optional)">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<button type="button" @click="addWarmupRule()" class="add-btn">+ Add Warmup Rule</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══ 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>
|