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:
@@ -1024,8 +1024,38 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load tunable settings entries from the registry. Exclude keys that
|
||||||
|
// already have their own dedicated UI cards (timezone dropdown, scan
|
||||||
|
// settings) so they aren't listed twice.
|
||||||
|
dedicatedUI := map[string]bool{
|
||||||
|
"default_timezone": true,
|
||||||
|
"scan_poll_interval_seconds": true,
|
||||||
|
"auto_scan_enabled": true,
|
||||||
|
}
|
||||||
|
var tunableSettings []templates.SettingEntry
|
||||||
|
if cfg.Settings != nil {
|
||||||
|
for _, e := range cfg.Settings.All() {
|
||||||
|
if dedicatedUI[e.Key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tunableSettings = append(tunableSettings, templates.SettingEntry{
|
||||||
|
Key: e.Key,
|
||||||
|
Value: e.Value,
|
||||||
|
Type: e.Type,
|
||||||
|
Min: e.Min,
|
||||||
|
Max: e.Max,
|
||||||
|
RequiresRestart: e.RequiresRestart,
|
||||||
|
Category: e.Category,
|
||||||
|
Group: e.Group,
|
||||||
|
Description: e.Description,
|
||||||
|
IsDefault: e.IsDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.AdminSettings(user, systemConfig, scanSettings, "").Render(ctx, &buf)
|
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package templates
|
|||||||
|
|
||||||
import "fmt"
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ScanSettingsSection(scanSettings)
|
@ScanSettingsSection(scanSettings)
|
||||||
<div class="mt-6 card p-6">
|
@TunableSettingsSection(liveGroups, false)
|
||||||
<div class="flex items-center gap-2 mb-4">
|
@TunableSettingsSection(restartGroups, true)
|
||||||
@Icon("info", "h-5 w-5 shrink-0")
|
</div>
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -219,3 +168,89 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime"
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, errorMessage string) templ.Component {
|
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -410,15 +410,15 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
|
templ_7745c5c3_Err = TunableSettingsSection(liveGroups, false).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = TunableSettingsSection(restartGroups, true).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<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></main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
templ_7745c5c3_Var7 = templ.NopComponent
|
templ_7745c5c3_Var7 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div id=\"scan-settings-section\" class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<div id=\"scan-settings-section\" class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -455,30 +455,30 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form hx-put=\"/admin/settings/scan\" hx-target=\"#scan-settings-section\" hx-swap=\"outerHTML\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form hx-put=\"/admin/settings/scan\" hx-target=\"#scan-settings-section\" hx-swap=\"outerHTML\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if scanSettings.AutoScanEnabled {
|
if scanSettings.AutoScanEnabled {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " class=\"sr-only peer\"><div class=\"w-11 h-6 rounded-full peer peer-checked:bg-brand transition-colors\" style=\"background-color: color-mix(in srgb, var(--text-primary) 15%, transparent);\"></div><div class=\"absolute left-0.5 top-0.5 bg-white rounded-full w-5 h-5 transition-transform peer-checked:translate-x-5\"></div></label></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Scan Interval (seconds)</label> <input type=\"number\" name=\"scan_poll_interval_seconds\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " class=\"sr-only peer\"><div class=\"w-11 h-6 rounded-full peer peer-checked:bg-brand transition-colors\" style=\"background-color: color-mix(in srgb, var(--text-primary) 15%, transparent);\"></div><div class=\"absolute left-0.5 top-0.5 bg-white rounded-full w-5 h-5 transition-transform peer-checked:translate-x-5\"></div></label></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Scan Interval (seconds)</label> <input type=\"number\" name=\"scan_poll_interval_seconds\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 204, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 153, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p></div><div class=\"flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" min=\"1\" max=\"3600\" class=\"input\" required><p class=\"text-sm mt-2\" style=\"color: var(--text-secondary)\">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p></div><div class=\"flex justify-end\"><button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -486,7 +486,345 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "Save Scan Settings</button></div></div></form></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Save Scan Settings</button></div></div></form></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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").
|
||||||
|
func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var9 == nil {
|
||||||
|
templ_7745c5c3_Var9 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if restartRequired {
|
||||||
|
templ_7745c5c3_Err = Icon("alert", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " <h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Tunable Settings — Restart Required</h3>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = Icon("settings", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " <h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Tunable Settings — Live</h3>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if restartRequired {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<p class=\"text-sm mb-4\" style=\"color: var(--status-warning)\">Changes are saved immediately but only take effect after the server restarts.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"text-sm mb-4\" style=\"color: var(--text-secondary)\">Changes apply immediately — no restart needed.</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"mt-5 first:mt-0\"><h4 class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 193, Col: 112}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h4><div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, e := range g.Entries {
|
||||||
|
templ_7745c5c3_Err = TunableSettingRow(e).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunableSettingRow renders a single editable setting as an inline HTMX form.
|
||||||
|
func TunableSettingRow(e SettingEntry) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var11 == nil {
|
||||||
|
templ_7745c5c3_Var11 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<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)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 208, Col: 94}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if !e.IsDefault {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 210, Col: 74}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " — modified from default</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 212, Col: 74}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</div><form class=\"flex items-center gap-2 shrink-0\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 218, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" hx-swap=\"innerHTML\" hx-disinherit=\"*\"><input type=\"hidden\" name=\"key\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 222, Col: 48}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Type == "bool" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<select name=\"value\" class=\"input py-1.5 text-sm w-28\"><option value=\"true\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Value == "true" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, ">Yes</option> <option value=\"false\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Value != "true" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " selected")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, ">No</option></select> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if e.Type == "int" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<input type=\"number\" name=\"value\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 232, Col: 20}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if e.Min != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " min=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 234, Col: 17}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Max != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " max=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 237, Col: 17}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " class=\"input py-1.5 text-sm w-32\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "<input type=\"text\" name=\"value\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 245, Col: 20}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" class=\"input py-1.5 text-sm w-40\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("save", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Save</button></form><span id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 254, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\"></span></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,28 @@ type ScanSettingsData struct {
|
|||||||
ScanPollIntervalSeconds int
|
ScanPollIntervalSeconds int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SettingEntry mirrors database.SettingEntry for the admin UI. Kept as a
|
||||||
|
// template-local type so the templates package does not import database.
|
||||||
|
type SettingEntry struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Type string
|
||||||
|
Min string
|
||||||
|
Max string
|
||||||
|
RequiresRestart bool
|
||||||
|
Category string
|
||||||
|
Group string
|
||||||
|
Description string
|
||||||
|
IsDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingGroup is a labeled cluster of related settings rendered as a
|
||||||
|
// sub-section within a tunable-settings card.
|
||||||
|
type SettingGroup struct {
|
||||||
|
Name string
|
||||||
|
Entries []SettingEntry
|
||||||
|
}
|
||||||
|
|
||||||
type SeriesCardData struct {
|
type SeriesCardData struct {
|
||||||
Name string
|
Name string
|
||||||
BookCount int64
|
BookCount int64
|
||||||
|
|||||||
@@ -303,3 +303,22 @@ func formatDateForInput(d pgtype.Date) string {
|
|||||||
}
|
}
|
||||||
return d.Time.Format("2006-01-02")
|
return d.Time.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupTunableSettings splits a flat, group-sorted entry list into labeled
|
||||||
|
// sub-section groups, separated into "live" (applies immediately) and
|
||||||
|
// "restart required" buckets. Entries keep their original order so groups stay
|
||||||
|
// coherent.
|
||||||
|
func GroupTunableSettings(entries []SettingEntry) (live, restart []SettingGroup) {
|
||||||
|
var liveGroups, restartGroups []SettingGroup
|
||||||
|
for _, e := range entries {
|
||||||
|
target := &liveGroups
|
||||||
|
if e.RequiresRestart {
|
||||||
|
target = &restartGroups
|
||||||
|
}
|
||||||
|
if len(*target) == 0 || (*target)[len(*target)-1].Name != e.Group {
|
||||||
|
*target = append(*target, SettingGroup{Name: e.Group})
|
||||||
|
}
|
||||||
|
(*target)[len(*target)-1].Entries = append((*target)[len(*target)-1].Entries, e)
|
||||||
|
}
|
||||||
|
return liveGroups, restartGroups
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGroupTunableSettings(t *testing.T) {
|
||||||
|
entries := []SettingEntry{
|
||||||
|
{Key: "session_duration_seconds", Group: "Session", RequiresRestart: false},
|
||||||
|
{Key: "password_min_length", Group: "Password Quality", RequiresRestart: false},
|
||||||
|
{Key: "password_require_upper", Group: "Password Quality", RequiresRestart: false},
|
||||||
|
{Key: "opds_default_page_size", Group: "OPDS Catalog", RequiresRestart: false},
|
||||||
|
{Key: "auth_rate_limit_per_min", Group: "Auth Rate Limiting", RequiresRestart: true},
|
||||||
|
{Key: "login_max_attempts", Group: "Login Lockout", RequiresRestart: true},
|
||||||
|
{Key: "login_lockout_minutes", Group: "Login Lockout", RequiresRestart: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
live, restart := GroupTunableSettings(entries)
|
||||||
|
|
||||||
|
if len(live) != 3 {
|
||||||
|
t.Fatalf("expected 3 live groups, got %d", len(live))
|
||||||
|
}
|
||||||
|
if live[0].Name != "Session" || len(live[0].Entries) != 1 {
|
||||||
|
t.Errorf("live[0] = %+v", live[0])
|
||||||
|
}
|
||||||
|
if live[1].Name != "Password Quality" || len(live[1].Entries) != 2 {
|
||||||
|
t.Errorf("live[1] = %+v", live[1])
|
||||||
|
}
|
||||||
|
if live[2].Name != "OPDS Catalog" || len(live[2].Entries) != 1 {
|
||||||
|
t.Errorf("live[2] = %+v", live[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(restart) != 2 {
|
||||||
|
t.Fatalf("expected 2 restart groups, got %d", len(restart))
|
||||||
|
}
|
||||||
|
if restart[0].Name != "Auth Rate Limiting" || len(restart[0].Entries) != 1 {
|
||||||
|
t.Errorf("restart[0] = %+v", restart[0])
|
||||||
|
}
|
||||||
|
if restart[1].Name != "Login Lockout" || len(restart[1].Entries) != 2 {
|
||||||
|
t.Errorf("restart[1] = %+v", restart[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupTunableSettingsEmpty(t *testing.T) {
|
||||||
|
live, restart := GroupTunableSettings(nil)
|
||||||
|
if len(live) != 0 || len(restart) != 0 {
|
||||||
|
t.Errorf("expected empty groups, got live=%d restart=%d", len(live), len(restart))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user