feat(admin): editable tunable settings UI with grouped sub-sections

Replace the read-only "System Information" card (which listed hardcoded
values) with editable HTMX forms, organized so the live vs restart
distinction and related settings are visually clear.

admin_settings.templ:
- AdminSettings signature now takes liveGroups and restartGroups
  ([]SettingGroup) instead of a flat entry list.
- Remove the static System Information list. Render two cards: "Live"
  (green, applies immediately) and "Restart Required" (warning header,
  saved but only takes effect after restart).
- Within each card, TunableSettingsSection clusters entries into
  labeled sub-sections by Group (e.g. "Password Quality", "Device Rate
  Limits", "Login Lockout", "Worker Pool") with uppercase tracked
  sub-headers.
- TunableSettingRow renders an inline HTMX form per setting: a Yes/No
  select for bools, a number input with min/max for ints, text
  otherwise, posting to /admin/settings/tunable. Rows show "modified
  from default" when the value differs from the compiled default.

types.go:
- Add SettingEntry (template-local mirror of database.SettingEntry,
  keeps templates from importing database) and SettingGroup.

utils.go:
- Add GroupTunableSettings: splits a flat, group-sorted entry list into
  live and restart []SettingGroup buckets preserving source order.
  utils_test.go covers the multi-group + empty cases.

frontend.go:
- The /admin/settings page handler now loads entries from the registry,
  drops the three keys that have dedicated UI cards (default_timezone
  dropdown, scan_poll_interval_seconds, auto_scan_enabled) so they are
  not listed twice, groups the rest, and passes liveGroups/restartGroups
  into the template.
This commit is contained in:
2026-08-10 08:03:02 -04:00
parent 537330e7e0
commit 598d70f735
6 changed files with 559 additions and 68 deletions
+91 -56
View File
@@ -2,7 +2,7 @@ package templates
import "fmt"
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, errorMessage string) {
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -107,61 +107,10 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Device Sync:</span> { systemConfig["base_url"] }/api/sync</p>
</div>
</div>
@ScanSettingsSection(scanSettings)
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-4">
@Icon("info", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">System Information</h3>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">These values are hardcoded and require a code change to modify.</p>
<div class="divide-y" style="--tw-divide-opacity:1;">
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Session Duration</span>
<span class="text-sm" style="color: var(--text-secondary)">7 days</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Password Requirements</span>
<span class="text-sm" style="color: var(--text-secondary)">8+ chars, upper/lower/number/special</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Login Lockout</span>
<span class="text-sm" style="color: var(--text-secondary)">5 attempts / 15 min</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Auth Rate Limit</span>
<span class="text-sm" style="color: var(--text-secondary)">10 requests/min</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Device Rate Limits</span>
<span class="text-sm" style="color: var(--text-secondary)">Sync 60/min, Progress 120/min, Metadata 30/min</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Sync Queue</span>
<span class="text-sm" style="color: var(--text-secondary)">Every 5s, batch of 50</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Annotation Tombstone TTL</span>
<span class="text-sm" style="color: var(--text-secondary)">30 days</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Worker Pool</span>
<span class="text-sm" style="color: var(--text-secondary)">3 workers, queue cap 100</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">OPDS Page Size</span>
<span class="text-sm" style="color: var(--text-secondary)">50 per page (max 200)</span>
</div>
<div class="flex justify-between items-center py-3" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
<span class="text-sm font-medium" style="color: var(--text-primary)">Conversion Cache TTL</span>
<span class="text-sm" style="color: var(--text-secondary)">24 hours</span>
</div>
<div class="flex justify-between items-center py-3">
<span class="text-sm font-medium" style="color: var(--text-primary)">CORS Origins</span>
<span class="text-sm" style="color: var(--text-secondary)">*</span>
</div>
</div>
</div>
</div>
@ScanSettingsSection(scanSettings)
@TunableSettingsSection(liveGroups, false)
@TunableSettingsSection(restartGroups, true)
</div>
</main>
</body>
</html>
@@ -219,3 +168,89 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
</form>
</div>
}
// TunableSettingsSection renders the editable tunables for a given bucket
// (live vs restart-required). Within the card, settings are clustered into
// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
templ TunableSettingsSection(groups []SettingGroup, restartRequired bool) {
<div class="mt-6 card p-6">
<div class="flex items-center gap-2 mb-2">
if restartRequired {
@Icon("alert", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings Restart Required</h3>
} else {
@Icon("settings", "h-5 w-5 shrink-0")
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings Live</h3>
}
</div>
if restartRequired {
<p class="text-sm mb-4" style="color: var(--status-warning)">Changes are saved immediately but only take effect after the server restarts.</p>
} else {
<p class="text-sm mb-4" style="color: var(--text-secondary)">Changes apply immediately no restart needed.</p>
}
for _, g := range groups {
<div class="mt-5 first:mt-0">
<h4 class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)">{ g.Name }</h4>
<div>
for _, e := range g.Entries {
@TunableSettingRow(e)
}
</div>
</div>
}
</div>
}
// TunableSettingRow renders a single editable setting as an inline HTMX form.
templ TunableSettingRow(e SettingEntry) {
<div class="flex flex-col sm:flex-row sm:items-center gap-3 py-3" style="border-top: 1px solid color-mix(in srgb, var(--text-primary) 7%, transparent);">
<div class="flex-1 min-w-0">
<label class="block text-sm font-medium" style="color: var(--text-primary)">{ e.Description }</label>
if !e.IsDefault {
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key } modified from default</p>
} else {
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key }</p>
}
</div>
<form
class="flex items-center gap-2 shrink-0"
hx-put="/admin/settings/tunable"
hx-target={ "#status-" + e.Key }
hx-swap="innerHTML"
hx-disinherit="*"
>
<input type="hidden" name="key" value={ e.Key }/>
if e.Type == "bool" {
<select name="value" class="input py-1.5 text-sm w-28">
<option value="true" selected?={ e.Value == "true" }>Yes</option>
<option value="false" selected?={ e.Value != "true" }>No</option>
</select>
} else if e.Type == "int" {
<input
type="number"
name="value"
value={ e.Value }
if e.Min != "" {
min={ e.Min }
}
if e.Max != "" {
max={ e.Max }
}
class="input py-1.5 text-sm w-32"
/>
} else {
<input
type="text"
name="value"
value={ e.Value }
class="input py-1.5 text-sm w-40"
/>
}
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm">
@Icon("save", "h-3.5 w-3.5")
Save
</button>
</form>
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
</div>
}