From 598d70f73522c3583bd5f45b0f7347a55991e416 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 10 Aug 2026 08:03:02 -0400 Subject: [PATCH] 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. --- internal/router/frontend.go | 32 ++- templates/admin_settings.templ | 147 +++++++----- templates/admin_settings_templ.go | 360 +++++++++++++++++++++++++++++- templates/types.go | 22 ++ templates/utils.go | 19 ++ templates/utils_test.go | 47 ++++ 6 files changed, 559 insertions(+), 68 deletions(-) create mode 100644 templates/utils_test.go diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 70f8154..d8741d6 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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 - err = templates.AdminSettings(user, systemConfig, scanSettings, "").Render(ctx, &buf) + err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf) if err != nil { return err } diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index 3888b30..2c5240b 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -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) { @@ -107,61 +107,10 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan

Device Sync: { systemConfig["base_url"] }/api/sync

- @ScanSettingsSection(scanSettings) -
-
- @Icon("info", "h-5 w-5 shrink-0") -

System Information

-
-

These values are hardcoded and require a code change to modify.

-
-
- Session Duration - 7 days -
-
- Password Requirements - 8+ chars, upper/lower/number/special -
-
- Login Lockout - 5 attempts / 15 min -
-
- Auth Rate Limit - 10 requests/min -
-
- Device Rate Limits - Sync 60/min, Progress 120/min, Metadata 30/min -
-
- Sync Queue - Every 5s, batch of 50 -
-
- Annotation Tombstone TTL - 30 days -
-
- Worker Pool - 3 workers, queue cap 100 -
-
- OPDS Page Size - 50 per page (max 200) -
-
- Conversion Cache TTL - 24 hours -
-
- CORS Origins - * -
-
-
- + @ScanSettingsSection(scanSettings) + @TunableSettingsSection(liveGroups, false) + @TunableSettingsSection(restartGroups, true) + @@ -219,3 +168,89 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) { } + +// 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) { +
+
+ if restartRequired { + @Icon("alert", "h-5 w-5 shrink-0") +

Tunable Settings — Restart Required

+ } else { + @Icon("settings", "h-5 w-5 shrink-0") +

Tunable Settings — Live

+ } +
+ if restartRequired { +

Changes are saved immediately but only take effect after the server restarts.

+ } else { +

Changes apply immediately — no restart needed.

+ } + for _, g := range groups { +
+

{ g.Name }

+
+ for _, e := range g.Entries { + @TunableSettingRow(e) + } +
+
+ } +
+} + +// TunableSettingRow renders a single editable setting as an inline HTMX form. +templ TunableSettingRow(e SettingEntry) { +
+
+ + if !e.IsDefault { +

{ e.Key } — modified from default

+ } else { +

{ e.Key }

+ } +
+
+ + if e.Type == "bool" { + + } else if e.Type == "int" { + + } else { + + } + +
+ +
+} diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index d3e4667..f9ff465 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime" 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) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 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 { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = TunableSettingsSection(liveGroups, false).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { 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 { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

System Information

These values are hardcoded and require a code change to modify.

Session Duration 7 days
Password Requirements 8+ chars, upper/lower/number/special
Login Lockout 5 attempts / 15 min
Auth Rate Limit 10 requests/min
Device Rate Limits Sync 60/min, Progress 120/min, Metadata 30/min
Sync Queue Every 5s, batch of 50
Annotation Tombstone TTL 30 days
Worker Pool 3 workers, queue cap 100
OPDS Page Size 50 per page (max 200)
Conversion Cache TTL 24 hours
CORS Origins *
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -447,7 +447,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component { templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -455,30 +455,30 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

Scanning

Watch libraries for file changes on startup

Watch libraries for file changes on startup

How often to poll libraries for changes (1–3600 seconds). Default: 60.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Save Scan Settings
") + 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, "
") + 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, "

Tunable Settings — Restart Required

") + 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, "

Tunable Settings — Live

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if restartRequired { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

Changes are saved immediately but only take effect after the server restarts.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "

Changes apply immediately — no restart needed.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, g := range groups { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

") + 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, "

") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !e.IsDefault { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "

") + 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

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") + 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if e.Type == "bool" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if e.Type == "int" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/types.go b/templates/types.go index 12f350f..9bde5a7 100644 --- a/templates/types.go +++ b/templates/types.go @@ -71,6 +71,28 @@ type ScanSettingsData struct { 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 { Name string BookCount int64 diff --git a/templates/utils.go b/templates/utils.go index a836c22..1da27a0 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -303,3 +303,22 @@ func formatDateForInput(d pgtype.Date) string { } 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 +} diff --git a/templates/utils_test.go b/templates/utils_test.go new file mode 100644 index 0000000..2b5f50f --- /dev/null +++ b/templates/utils_test.go @@ -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)) + } +}