warpgate/templates/web/tabs/config.html
grabbit faf9d80824 feat: fill implementation gaps — preset unification, cron, adaptive bw, update cmd, tests
Step 1 — Unify preset logic (eliminate dual implementation)
- src/cli/preset.rs: add missing fields (chunk_limit, multi_thread_streams,
  multi_thread_cutoff), fix Office buffer_size 64M→128M, implement FromStr
- src/web/api.rs: post_preset() now calls Preset::apply() — no more inlined
  params; Office write_back unified to 5s (was 3s in API)

Step 2 — Fix setup.rs connection test: warn→bail
- All 4 "Warning: Could not connect/resolve" prints replaced with anyhow::bail!
  matching deploy/setup.rs behavior

Step 3 — Web UI: add [web] and [notifications] edit sections
- templates/web/tabs/config.html: new collapsible Web UI (password) and
  Notifications (webhook_url, cache_threshold_pct, nas_offline_minutes,
  writeback_depth) sections, both tagged "No restart"
- Also adds [log] section (file path + level select, "Full restart")

Step 4 — Full cron expression support in warmup scheduler
- Cargo.toml: add cron = "0.12", chrono = "0.4"
- supervisor.rs: normalize_cron_schedule() converts 5-field standard cron to
  7-field cron crate format; replaces naive hour-only matching

Step 5 — Adaptive bandwidth algorithm
- supervisor.rs: extract compute_adaptive_limit() pure function; sliding
  window of 6 samples, cv>0.3→congested (−25%, floor 1MiB/s), stable
  near-limit→maintain, under-utilizing→+10% (capped at limit_up)

Step 6 — warpgate update command
- src/cli/update.rs: query GitHub Releases API, compare with CARGO_PKG_VERSION
- src/main.rs: add Update{apply}, SetupWifi, CloneMac{interface} commands
- src/cli/wifi.rs: TODO stub for WiFi AP setup

Unit tests (+35, total 188→223)
- cli/preset.rs: 10 tests — FromStr, all fields for each preset, idempotency,
  connection/share isolation, write_back consistency regression
- supervisor.rs: 14 tests — normalize_cron_schedule (5 cases),
  compute_adaptive_limit (9 cases: congestion, floor, stable, under-utilizing,
  cap, zero-current, zero-max, empty window)
- config.rs: 11 tests — WebConfig (3), NotificationsConfig (4), LogConfig (4)

Shell tests (+4 scripts)
- tests/09-cli/test-preset-cli.sh: preset CLI without daemon; checks all
  three presets write correct values including unified buffer_size/write_back
- tests/09-cli/test-update-command.sh: update command; skips on no-network
- tests/10-scheduled/test-cron-warmup-schedule.sh: "* * * * *" fires in <90s
- tests/10-scheduled/test-adaptive-bandwidth.sh: adaptive loop stability
- tests/harness/config-gen.sh: add warmup.warmup_schedule override support
- tests/run-all.sh: add 10-scheduled category

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:55:00 +08:00

653 lines
26 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() {
return {
config: {},
originalConfig: {},
submitting: false,
message: null,
isError: false,
sections: {
connections: true,
shares: true,
cache: false,
read: false,
bandwidth: false,
writeback: false,
directory_cache: false,
protocols: false,
smb_auth: false,
warmup: false,
dir_refresh: false,
web: false,
notifications: false,
log: false,
},
init() {
const data = JSON.parse(document.getElementById('config-init').textContent);
this.config = this.prepareForEdit(data.config);
this.originalConfig = JSON.parse(JSON.stringify(this.config));
if (data.message) {
this.message = data.message;
this.isError = data.is_error;
}
},
/** Convert null optional fields to empty strings for form binding. */
prepareForEdit(config) {
for (const conn of config.connections) {
if (conn.nas_pass == null) conn.nas_pass = '';
if (conn.nas_key_file == null) conn.nas_key_file = '';
}
if (config.smb_auth.username == null) config.smb_auth.username = '';
if (config.smb_auth.smb_pass == null) config.smb_auth.smb_pass = '';
for (const rule of config.warmup.rules) {
if (rule.newer_than == null) rule.newer_than = '';
}
for (const share of config.shares) {
if (share.dir_refresh_interval == null) share.dir_refresh_interval = '';
}
return config;
},
/** Convert empty optional strings back to null for the API. */
prepareForSubmit(config) {
const c = JSON.parse(JSON.stringify(config));
for (const conn of c.connections) {
if (!conn.nas_pass) conn.nas_pass = null;
if (!conn.nas_key_file) conn.nas_key_file = null;
}
if (!c.smb_auth.username) c.smb_auth.username = null;
if (!c.smb_auth.smb_pass) c.smb_auth.smb_pass = null;
for (const rule of c.warmup.rules) {
if (!rule.newer_than) rule.newer_than = null;
}
for (const share of c.shares) {
if (!share.dir_refresh_interval) share.dir_refresh_interval = null;
}
return c;
},
addConnection() {
this.config.connections.push({
name: '', nas_host: '', nas_user: '',
nas_pass: '', nas_key_file: '',
sftp_port: 22, sftp_connections: 8
});
},
addShare() {
this.config.shares.push({
name: '',
connection: this.config.connections[0]?.name || '',
remote_path: '/',
mount_point: '/mnt/',
read_only: false,
dir_refresh_interval: ''
});
},
addWarmupRule() {
this.config.warmup.rules.push({
share: this.config.shares[0]?.name || '',
path: '',
newer_than: ''
});
},
async submitConfig() {
this.submitting = true;
this.message = null;
try {
const payload = this.prepareForSubmit(this.config);
const resp = await fetch('/config/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
this.message = result.message;
this.isError = !result.ok;
if (result.ok) {
this.originalConfig = JSON.parse(JSON.stringify(this.config));
}
} catch (e) {
this.message = 'Network error: ' + e.message;
this.isError = true;
}
this.submitting = false;
},
resetConfig() {
this.config = JSON.parse(JSON.stringify(this.originalConfig));
this.message = null;
}
}
}
/* Register with Alpine.data for robust htmx swap support.
On initial load: alpine:init fires before Alpine scans the DOM.
On htmx swap: Alpine is already loaded, register directly. */
if (window.Alpine) {
Alpine.data('configEditor', configEditorFn);
} else {
document.addEventListener('alpine:init', function() {
Alpine.data('configEditor', configEditorFn);
});
}
</script>
<div x-data="configEditor">
<!-- Preset buttons -->
<div class="preset-section">
<div class="preset-header">
<span class="section-label">快速预设 / Quick Presets</span>
<span class="preset-hint">一键应用最佳实践配置,不影响 NAS 连接和 shares 设置</span>
</div>
<div class="preset-buttons">
<button class="preset-btn preset-photographer"
hx-post="/api/preset/photographer"
hx-target="#preset-result"
hx-swap="innerHTML"
hx-indicator="#preset-spinner">
<span class="preset-icon">📷</span>
<span class="preset-name">摄影师</span>
<span class="preset-desc">RAW 大文件256M 分块读取</span>
</button>
<button class="preset-btn preset-video"
hx-post="/api/preset/video"
hx-target="#preset-result"
hx-swap="innerHTML"
hx-indicator="#preset-spinner">
<span class="preset-icon">🎬</span>
<span class="preset-name">视频剪辑</span>
<span class="preset-desc">顺序读取优化1G 预读缓冲</span>
</button>
<button class="preset-btn preset-office"
hx-post="/api/preset/office"
hx-target="#preset-result"
hx-swap="innerHTML"
hx-indicator="#preset-spinner">
<span class="preset-icon">💼</span>
<span class="preset-name">文档办公</span>
<span class="preset-desc">小文件响应30m 目录缓存</span>
</button>
</div>
<div id="preset-result" class="preset-result"></div>
<div id="preset-spinner" class="htmx-indicator preset-spinner">应用中...</div>
</div>
<!-- Message banner -->
<template x-if="message">
<div class="message" :class="isError ? 'message-error' : 'message-ok'" x-text="message"></div>
</template>
<!-- ═══ Section: Connections ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.connections = !sections.connections">
<h3>Connections <span class="tier-badge tier-pershare">Per-share restart</span></h3>
<span class="chevron" x-text="sections.connections ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.connections" x-transition>
<template x-for="(conn, i) in config.connections" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="conn.name || 'New Connection'"></strong>
<button type="button" @click="config.connections.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Name *</label>
<input type="text" x-model="conn.name" required placeholder="e.g. home-nas">
</div>
<div class="field-row">
<label>NAS Host *</label>
<input type="text" x-model="conn.nas_host" required placeholder="e.g. 100.64.0.1">
</div>
<div class="field-row">
<label>Username *</label>
<input type="text" x-model="conn.nas_user" required placeholder="e.g. admin">
</div>
<div class="field-row">
<label>Password</label>
<input type="password" x-model="conn.nas_pass" placeholder="(optional if using key)">
</div>
<div class="field-row">
<label>SSH Key File</label>
<input type="text" x-model="conn.nas_key_file" class="mono" placeholder="/root/.ssh/id_rsa">
</div>
<div class="field-row">
<label>SFTP Port</label>
<input type="number" x-model.number="conn.sftp_port" min="1" max="65535">
</div>
<div class="field-row">
<label>SFTP Connections</label>
<input type="number" x-model.number="conn.sftp_connections" min="1" max="128">
</div>
</div>
</div>
</template>
<button type="button" @click="addConnection()" class="add-btn">+ Add Connection</button>
</div>
</section>
<!-- ═══ Section: Shares ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.shares = !sections.shares">
<h3>Shares <span class="tier-badge tier-pershare">Per-share restart</span></h3>
<span class="chevron" x-text="sections.shares ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.shares" x-transition>
<template x-for="(share, i) in config.shares" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="share.name || 'New Share'"></strong>
<button type="button" @click="config.shares.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Name *</label>
<input type="text" x-model="share.name" required placeholder="e.g. photos">
</div>
<div class="field-row">
<label>Connection *</label>
<select x-model="share.connection">
<template x-for="c in config.connections" :key="c.name">
<option :value="c.name" x-text="c.name"></option>
</template>
</select>
</div>
<div class="field-row">
<label>Remote Path *</label>
<input type="text" x-model="share.remote_path" class="mono" required placeholder="/volume1/photos">
</div>
<div class="field-row">
<label>Mount Point *</label>
<input type="text" x-model="share.mount_point" class="mono" required placeholder="/mnt/photos">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="share.read_only">
<span class="slider"></span>
Read Only
</label>
</div>
<div class="field-row" style="margin-top:12px">
<label>Dir Refresh Interval</label>
<input type="text" x-model="share.dir_refresh_interval" placeholder='blank = global, "0" = disable, e.g. 10m' style="max-width:320px">
</div>
</div>
</template>
<button type="button" @click="addShare()" class="add-btn">+ Add Share</button>
</div>
</section>
<!-- ═══ Section: Cache ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.cache = !sections.cache">
<h3>Cache <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.cache ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.cache" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Cache Directory *</label>
<input type="text" x-model="config.cache.dir" class="mono" required placeholder="/mnt/ssd/cache">
</div>
<div class="field-row">
<label>Max Size</label>
<input type="text" x-model="config.cache.max_size" placeholder="e.g. 200G">
</div>
<div class="field-row">
<label>Max Age</label>
<input type="text" x-model="config.cache.max_age" placeholder="e.g. 720h">
</div>
<div class="field-row">
<label>Min Free Space</label>
<input type="text" x-model="config.cache.min_free" placeholder="e.g. 10G">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Read Tuning ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.read = !sections.read">
<h3>Read Tuning <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.read ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.read" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Chunk Size</label>
<input type="text" x-model="config.read.chunk_size" placeholder="e.g. 256M">
</div>
<div class="field-row">
<label>Chunk Limit</label>
<input type="text" x-model="config.read.chunk_limit" placeholder="e.g. 1G">
</div>
<div class="field-row">
<label>Read Ahead</label>
<input type="text" x-model="config.read.read_ahead" placeholder="e.g. 512M">
</div>
<div class="field-row">
<label>Buffer Size</label>
<input type="text" x-model="config.read.buffer_size" placeholder="e.g. 256M">
</div>
<div class="field-row">
<label>Multi-Thread Streams</label>
<input type="number" x-model.number="config.read.multi_thread_streams" min="1" max="64" placeholder="e.g. 4">
</div>
<div class="field-row">
<label>Multi-Thread Cutoff</label>
<input type="text" x-model="config.read.multi_thread_cutoff" placeholder="e.g. 50M">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Bandwidth ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.bandwidth = !sections.bandwidth">
<h3>Bandwidth <span class="tier-badge tier-live">Live</span></h3>
<span class="chevron" x-text="sections.bandwidth ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.bandwidth" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Upload Limit</label>
<input type="text" x-model="config.bandwidth.limit_up" placeholder="0 = unlimited, e.g. 10M">
</div>
<div class="field-row">
<label>Download Limit</label>
<input type="text" x-model="config.bandwidth.limit_down" placeholder="0 = unlimited, e.g. 50M">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.bandwidth.adaptive">
<span class="slider"></span>
Adaptive Throttling
</label>
</div>
</div>
</section>
<!-- ═══ Section: Write-back ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.writeback = !sections.writeback">
<h3>Write-back <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.writeback ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.writeback" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Write-back Delay</label>
<input type="text" x-model="config.writeback.write_back" placeholder="e.g. 5s">
</div>
<div class="field-row">
<label>Concurrent Transfers</label>
<input type="number" x-model.number="config.writeback.transfers" min="1" max="64">
</div>
</div>
</div>
</section>
<!-- ═══ Section: Directory Cache ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.directory_cache = !sections.directory_cache">
<h3>Directory Cache <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.directory_cache ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.directory_cache" x-transition>
<div class="field-row">
<label>Cache Time (TTL)</label>
<input type="text" x-model="config.directory_cache.cache_time" placeholder="e.g. 1h" style="max-width:300px">
</div>
</div>
</section>
<!-- ═══ Section: Protocols ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.protocols = !sections.protocols">
<h3>Protocols <span class="tier-badge tier-protocol">Protocol restart</span></h3>
<span class="chevron" x-text="sections.protocols ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.protocols" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_smb">
<span class="slider"></span>
Enable SMB (Samba)
</label>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_nfs">
<span class="slider"></span>
Enable NFS
</label>
</div>
<div class="field-row" x-show="config.protocols.enable_nfs" x-transition style="margin-top:12px">
<label>NFS Allowed Network</label>
<input type="text" x-model="config.protocols.nfs_allowed_network" placeholder="e.g. 192.168.0.0/24" style="max-width:300px">
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.protocols.enable_webdav">
<span class="slider"></span>
Enable WebDAV
</label>
</div>
<div class="field-row" x-show="config.protocols.enable_webdav" x-transition style="margin-top:12px">
<label>WebDAV Port</label>
<input type="number" x-model.number="config.protocols.webdav_port" min="1" max="65535" style="max-width:300px">
</div>
</div>
</section>
<!-- ═══ Section: SMB Auth ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.smb_auth = !sections.smb_auth">
<h3>SMB Auth <span class="tier-badge tier-protocol">Protocol restart</span></h3>
<span class="chevron" x-text="sections.smb_auth ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.smb_auth" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.smb_auth.enabled">
<span class="slider"></span>
Enable SMB Authentication
</label>
</div>
<div x-show="config.smb_auth.enabled" x-transition>
<div class="field-grid" style="margin-top:12px">
<div class="field-row">
<label>Username *</label>
<input type="text" x-model="config.smb_auth.username" placeholder="SMB username">
</div>
<div class="field-row">
<label>Password *</label>
<input type="password" x-model="config.smb_auth.smb_pass" placeholder="SMB password">
</div>
</div>
</div>
</div>
</section>
<!-- ═══ Section: Warmup ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.warmup = !sections.warmup">
<h3>Warmup <span class="tier-badge tier-none">No restart</span></h3>
<span class="chevron" x-text="sections.warmup ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.warmup" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.warmup.auto">
<span class="slider"></span>
Auto-warmup on Startup
</label>
</div>
<div style="margin-top:16px">
<label style="font-size:0.85em;color:var(--text-muted);display:block;margin-bottom:8px">Warmup Rules</label>
<template x-for="(rule, i) in config.warmup.rules" :key="i">
<div class="array-item">
<div class="item-header">
<strong x-text="(rule.share || '?') + ':' + (rule.path || '/')"></strong>
<button type="button" @click="config.warmup.rules.splice(i, 1)" class="remove-btn">Remove</button>
</div>
<div class="field-grid">
<div class="field-row">
<label>Share *</label>
<select x-model="rule.share">
<template x-for="s in config.shares" :key="s.name">
<option :value="s.name" x-text="s.name"></option>
</template>
</select>
</div>
<div class="field-row">
<label>Path *</label>
<input type="text" x-model="rule.path" class="mono" placeholder="e.g. Images/2024">
</div>
<div class="field-row">
<label>Newer Than</label>
<input type="text" x-model="rule.newer_than" placeholder="e.g. 7d, 24h (optional)">
</div>
</div>
</div>
</template>
<button type="button" @click="addWarmupRule()" class="add-btn">+ Add Warmup Rule</button>
</div>
</div>
</section>
<!-- ═══ Section: Dir Refresh ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.dir_refresh = !sections.dir_refresh">
<h3>Dir Refresh <span class="tier-badge tier-none">No restart</span></h3>
<span class="chevron" x-text="sections.dir_refresh ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.dir_refresh" x-transition>
<div class="field-row">
<label class="toggle">
<input type="checkbox" x-model="config.dir_refresh.enabled">
<span class="slider"></span>
Enable Periodic Directory Refresh
</label>
</div>
<div x-show="config.dir_refresh.enabled" x-transition>
<div class="field-grid" style="margin-top:12px">
<div class="field-row">
<label>Interval</label>
<input type="text" x-model="config.dir_refresh.interval" placeholder="e.g. 30m, 1h" style="max-width:300px">
</div>
</div>
<div class="field-row" style="margin-top:12px">
<label class="toggle">
<input type="checkbox" x-model="config.dir_refresh.recursive">
<span class="slider"></span>
Recursive (include subdirectories)
</label>
</div>
</div>
<p style="font-size:0.82em;color:var(--text-muted);margin-top:10px">
Proactively refreshes directory listings so Finder/Explorer sees new files without waiting for cache expiry.
Per-share overrides can be set in the Shares section above.
</p>
</div>
</section>
<!-- ═══ Section: Web ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.web = !sections.web">
<h3>Web UI <span class="tier-badge tier-none">No restart</span></h3>
<span class="chevron" x-text="sections.web ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.web" x-transition>
<div class="field-row">
<label>Password</label>
<input type="password" x-model="config.web.password" placeholder="Leave empty to disable authentication" style="max-width:320px">
</div>
<p style="font-size:0.82em;color:var(--text-muted);margin-top:8px">
Protects the Web UI with HTTP Basic Auth. Leave empty to allow unauthenticated access.
</p>
</div>
</section>
<!-- ═══ Section: Notifications ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.notifications = !sections.notifications">
<h3>Notifications <span class="tier-badge tier-none">No restart</span></h3>
<span class="chevron" x-text="sections.notifications ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.notifications" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Webhook URL</label>
<input type="text" x-model="config.notifications.webhook_url" placeholder="https://... (Telegram/Bark/DingTalk)">
</div>
<div class="field-row">
<label>Cache Threshold %</label>
<input type="number" x-model.number="config.notifications.cache_threshold_pct" min="1" max="100" style="max-width:120px">
</div>
<div class="field-row">
<label>NAS Offline Minutes</label>
<input type="number" x-model.number="config.notifications.nas_offline_minutes" min="1" style="max-width:120px">
</div>
<div class="field-row">
<label>Write-back Depth</label>
<input type="number" x-model.number="config.notifications.writeback_depth" min="1" style="max-width:120px">
</div>
</div>
<p style="font-size:0.82em;color:var(--text-muted);margin-top:8px">
Send push notifications when cache is near full, NAS goes offline, or write-back queue grows large.
Leave Webhook URL empty to disable all notifications.
</p>
</div>
</section>
<!-- ═══ Section: Log ═══ -->
<section class="config-section">
<div class="section-header" @click="sections.log = !sections.log">
<h3>Log <span class="tier-badge tier-global">Full restart</span></h3>
<span class="chevron" x-text="sections.log ? '▾' : '▸'"></span>
</div>
<div class="section-body" x-show="sections.log" x-transition>
<div class="field-grid">
<div class="field-row">
<label>Log File</label>
<input type="text" x-model="config.log.file" class="mono" placeholder="/var/log/warpgate/warpgate.log (empty = no file logging)">
</div>
<div class="field-row">
<label>Log Level</label>
<select x-model="config.log.level">
<option value="error">error</option>
<option value="warn">warn</option>
<option value="info">info</option>
<option value="debug">debug</option>
<option value="trace">trace</option>
</select>
</div>
</div>
<p style="font-size:0.82em;color:var(--text-muted);margin-top:8px">
Changes to log settings require a full service restart to take effect.
Leave Log File empty to disable file logging (stdout only).
</p>
</div>
</section>
<!-- ═══ Form Actions ═══ -->
<div class="form-actions" style="margin-top:24px">
<button type="button" @click="submitConfig()" class="btn btn-primary" :disabled="submitting">
<span x-show="!submitting">Apply Config</span>
<span x-show="submitting">Applying...</span>
</button>
<button type="button" @click="resetConfig()" class="btn btn-secondary">Reset</button>
</div>
</div>