Skip to content

Commit b27af15

Browse files
committed
fix: address Copilot review on #76 — first pass
Four findings, all valid: 1. `filterGearsByAgentCapabilities` was using `agent.NewClient` whose default timeout is 30s. That's on the critical path of every Gears settings page render — a dead agent would freeze the UI for 30s before the fail-open path took over. Now using `NewClientWithTimeout` with a 3s cap, more than enough for a healthy LAN agent. 2. `encodePagesJSON` (palette) and the `Settings()` template were independently spelling out which pages each user can reach. New `models.SettingsPagesFor(user, perms)` is now the single source of truth — both consumers iterate the same slice. Settings cards now come from a `settingsCard` template + name-keyed `settingsCardIcon` helper, so adding a new settings page is a one-line entry in `SettingsPagesFor` plus a switch case for the icon (forgetting the icon falls back to a generic glyph rather than crashing). 3. `services-config.js` silently no-op'd when `#services-config-root` had no `data-action` attribute. A future templ refactor that drops that attribute would have made every toggle look like it saved while doing nothing. Now logs a one-shot console.error + toast. 4. `GearUpdatePost`'s `r.ParseForm` and `GetGear` early-exit branches were always responding with a 303 redirect to HTML, even when the AJAX caller asked for JSON. The fetch client would follow the redirect, get HTML, and `r.json().catch(() => ({success: r.ok}))` would treat it as success. `wantsJSON` is now computed up front and a tiny `jsonError` closure routes every error path through the right format.
1 parent cc1ff1d commit b27af15

5 files changed

Lines changed: 243 additions & 162 deletions

File tree

gearbox/internal/framework/handler/gears.go

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/url"
88
"sort"
99
"strings"
10+
"time"
1011

1112
"github.com/go-chi/chi/v5"
1213
"github.com/sarg3nt/gearbox/internal/framework/agent"
@@ -93,6 +94,12 @@ var dashboardGearToAgentGear = map[string]string{
9394
database.GearOSUpdates: "updates",
9495
}
9596

97+
// capabilitiesFetchTimeout caps the synchronous Gears-page → agent call
98+
// so a dead agent doesn't stall page rendering for the full 30s default.
99+
// 3s is enough for a healthy LAN agent, short enough that operators don't
100+
// notice when an agent is gone.
101+
const capabilitiesFetchTimeout = 3 * time.Second
102+
96103
// filterGearsByAgentCapabilities removes gears the agent has reported as
97104
// not-installed / inaccessible / disabled. Fail-open on any error: when the
98105
// agent is unreachable or running a version without the capabilities
@@ -104,7 +111,10 @@ func (h *Handler) filterGearsByAgentCapabilities(boxID string, gears []database.
104111
return gears
105112
}
106113

107-
client := agent.NewClient(serverConfig.AgentURL, serverConfig.APIKey)
114+
// Short timeout — this call sits on the critical path of every Gears
115+
// page render, so a 30s default would freeze the UI when the box is
116+
// down. Fail-open if it times out.
117+
client := agent.NewClientWithTimeout(serverConfig.AgentURL, serverConfig.APIKey, capabilitiesFetchTimeout)
108118
caps, err := client.GetCapabilities()
109119
if err != nil {
110120
h.logger.Debug("capabilities fetch failed; showing all gears", "box_id", boxID, "error", err)
@@ -355,20 +365,38 @@ func (h *Handler) GearUpdatePost(w http.ResponseWriter, r *http.Request) {
355365
boxID := h.resolveBoxIDFromRequest(r)
356366
redirectBase := "/settings/gears/" + gearName + "?server=" + url.QueryEscape(boxID)
357367

368+
// Compute wantsJSON up front so every error path below (parse failures,
369+
// gear lookup failures, validation errors, save failures) can respond
370+
// in the format the caller actually expects. AJAX auto-savers (services,
371+
// logs) request JSON; classic form POSTs accept HTML and get redirects.
372+
wantsJSON := strings.Contains(r.Header.Get("Accept"), "application/json")
373+
jsonError := func(status int, msg string) {
374+
w.Header().Set("Content-Type", "application/json")
375+
w.WriteHeader(status)
376+
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": msg})
377+
}
378+
358379
if err := r.ParseForm(); err != nil {
380+
if wantsJSON {
381+
jsonError(http.StatusBadRequest, "Invalid form data")
382+
return
383+
}
359384
http.Redirect(w, r, redirectBase+"&error="+url.QueryEscape("Invalid form data"), http.StatusSeeOther)
360385
return
361386
}
362387

363388
// Get current gear
364389
gearItem, err := h.db.GetGear(boxID, gearName)
365390
if err != nil || gearItem == nil {
391+
if wantsJSON {
392+
jsonError(http.StatusNotFound, "Integration not found")
393+
return
394+
}
366395
http.Redirect(w, r, "/settings/gears?server="+url.QueryEscape(boxID)+"&error="+url.QueryEscape("Integration not found"), http.StatusSeeOther)
367396
return
368397
}
369398

370399
// Handle config update based on gear type
371-
wantsJSON := strings.Contains(r.Header.Get("Accept"), "application/json")
372400
var newConfig json.RawMessage
373401
switch gearName {
374402
case database.GearServices:
@@ -381,9 +409,7 @@ func (h *Handler) GearUpdatePost(w http.ResponseWriter, r *http.Request) {
381409
err = h.saveLogSourcesConfig(boxID, r)
382410
if err != nil {
383411
if wantsJSON {
384-
w.Header().Set("Content-Type", "application/json")
385-
w.WriteHeader(http.StatusInternalServerError)
386-
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": err.Error()})
412+
jsonError(http.StatusInternalServerError, err.Error())
387413
return
388414
}
389415
http.Redirect(w, r, redirectBase+"&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
@@ -414,9 +440,7 @@ func (h *Handler) GearUpdatePost(w http.ResponseWriter, r *http.Request) {
414440

415441
if err != nil {
416442
if wantsJSON {
417-
w.Header().Set("Content-Type", "application/json")
418-
w.WriteHeader(http.StatusBadRequest)
419-
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": err.Error()})
443+
jsonError(http.StatusBadRequest, err.Error())
420444
return
421445
}
422446
http.Redirect(w, r, redirectBase+"&error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
@@ -427,9 +451,7 @@ func (h *Handler) GearUpdatePost(w http.ResponseWriter, r *http.Request) {
427451
if err := h.db.SetGearConfig(boxID, gearName, newConfig, &user.ID); err != nil {
428452
h.logger.Error("failed to update gear", "gear", gearName, "error", err)
429453
if wantsJSON {
430-
w.Header().Set("Content-Type", "application/json")
431-
w.WriteHeader(http.StatusInternalServerError)
432-
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": "Failed to save configuration"})
454+
jsonError(http.StatusInternalServerError, "Failed to save configuration")
433455
return
434456
}
435457
http.Redirect(w, r, redirectBase+"&error="+url.QueryEscape("Failed to save configuration"), http.StatusSeeOther)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package models
2+
3+
// SettingsPage is a single entry on the /settings root grid. The Settings
4+
// template renders one card per entry; the Cmd+K palette serializes the
5+
// same slice into its `cmdk-pages` JSON island. Both consumers go through
6+
// SettingsPagesFor so permission gating lives in exactly one place — if
7+
// it drifted, the palette would either dangle 403 destinations or hide
8+
// pages users can in fact reach (PR #76 Copilot review).
9+
type SettingsPage struct {
10+
// Name is the stable kebab-case slug used as the palette item id and
11+
// as the icon-key switch on the Settings card renderer. Must be unique.
12+
Name string
13+
// Label is the user-facing title shown on the card and in the palette.
14+
Label string
15+
// Description is the one-line card subtitle. Empty for palette-only
16+
// entries (none today, kept as an explicit option).
17+
Description string
18+
// Path is the destination URL.
19+
Path string
20+
}
21+
22+
// SettingsPagesFor returns the ordered list of settings pages the given
23+
// user can reach. The Settings template iterates this for card layout;
24+
// encodePagesJSON iterates the same slice to populate the Cmd+K palette.
25+
// Adding a new settings page = appending an entry here once.
26+
//
27+
// perms may be nil for users whose permission record hasn't loaded — we
28+
// treat that as "no extra permissions beyond role" so admins still see
29+
// everything they should.
30+
func SettingsPagesFor(user *User, perms *UserPermissions) []SettingsPage {
31+
isAdmin := user != nil && user.IsAdmin()
32+
hasPerm := func(c Component, p Permission) bool {
33+
return perms != nil && perms.HasPermission(c, p)
34+
}
35+
36+
pages := make([]SettingsPage, 0, 10)
37+
if isAdmin || hasPerm(ComponentSettings, PermissionManageBoxes) {
38+
pages = append(pages, SettingsPage{
39+
Name: "boxes",
40+
Label: "Boxes",
41+
Description: "Manage monitored boxes",
42+
Path: "/settings/boxes",
43+
})
44+
}
45+
if isAdmin || hasPerm(ComponentGears, PermissionManage) {
46+
pages = append(pages, SettingsPage{
47+
Name: "gears",
48+
Label: "Gears",
49+
Description: "Enable/disable features and configure gears",
50+
Path: "/settings/gears",
51+
})
52+
}
53+
pages = append(pages, SettingsPage{
54+
Name: "profile",
55+
Label: "Profile",
56+
Description: "Update your personal information and passkeys",
57+
Path: "/settings/profile",
58+
})
59+
if isAdmin || hasPerm(ComponentUsers, PermissionApproveUsers) {
60+
pages = append(pages, SettingsPage{
61+
Name: "users",
62+
Label: "User Management",
63+
Description: "Manage users and account requests",
64+
Path: "/settings/users",
65+
})
66+
}
67+
if isAdmin {
68+
pages = append(pages, SettingsPage{
69+
Name: "smtp",
70+
Label: "Email Settings",
71+
Description: "Configure SMTP server for email notifications",
72+
Path: "/settings/smtp",
73+
})
74+
}
75+
pages = append(pages, SettingsPage{
76+
Name: "backups",
77+
Label: "Database Backups",
78+
Description: "Create and restore database backups",
79+
Path: "/settings/backup",
80+
})
81+
if isAdmin {
82+
pages = append(pages, SettingsPage{
83+
Name: "permissions",
84+
Label: "Permission Management",
85+
Description: "Manage granular permissions for users",
86+
Path: "/settings/permissions",
87+
})
88+
}
89+
return pages
90+
}

gearbox/internal/framework/templates/layouts/base.templ

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,11 @@ func encodeGearsJSON(ctx context.Context) string {
117117
}
118118

119119
// encodePagesJSON serializes the user's reachable settings/profile pages
120-
// for the command palette. Each entry has the same shape as a gear: name,
121-
// label, path. Only pages the current user can actually visit are emitted
122-
// — admin-only items are filtered out for non-admins so the palette
123-
// doesn't dangle 403 destinations. Issue #71 item 4.
120+
// for the command palette. Source of truth is models.SettingsPagesFor —
121+
// the Settings template renders cards from the same slice, so the palette
122+
// can never dangle a 403 destination or omit a page the user can reach.
123+
// Augmented with palette-only entries (Settings root, Profile sub-pages,
124+
// Add-a-box) that don't need their own Settings card.
124125
func encodePagesJSON(ctx context.Context) string {
125126
type lite struct {
126127
Name string `json:"name"`
@@ -129,35 +130,19 @@ func encodePagesJSON(ctx context.Context) string {
129130
}
130131
user, _ := auth.GetUserFromContext(ctx)
131132
perms, _ := auth.GetUserPermissionsFromContext(ctx)
132-
isAdmin := user != nil && user.IsAdmin()
133-
hasPerm := func(c models.Component, p models.Permission) bool {
134-
return perms != nil && perms.HasPermission(c, p)
135-
}
136-
137-
out := make([]lite, 0, 12)
138-
add := func(name, label, path string) {
139-
out = append(out, lite{Name: name, Label: label, Path: path})
140-
}
141-
142-
// Always available
143-
add("settings", "Settings", "/settings")
144-
add("profile", "Profile", "/settings/profile")
145-
add("change-password", "Change password", "/settings/change-password")
146-
add("backups", "Database backups", "/settings/backup")
147133

148-
if isAdmin || hasPerm(models.ComponentSettings, models.PermissionManageBoxes) {
149-
add("boxes", "Boxes", "/settings/boxes")
150-
add("boxes-new", "Add a box", "/settings/boxes/new")
151-
}
152-
if isAdmin || hasPerm(models.ComponentGears, models.PermissionManage) {
153-
add("gears", "Gears", "/settings/gears")
154-
}
155-
if isAdmin || hasPerm(models.ComponentUsers, models.PermissionApproveUsers) {
156-
add("users", "User management", "/settings/users")
157-
}
158-
if isAdmin {
159-
add("smtp", "Email (SMTP) settings", "/settings/smtp")
160-
add("permissions", "Permission management", "/settings/permissions")
134+
pages := models.SettingsPagesFor(user, perms)
135+
out := make([]lite, 0, len(pages)+4)
136+
// Always reachable, but not their own Settings card.
137+
out = append(out, lite{Name: "settings", Label: "Settings", Path: "/settings"})
138+
out = append(out, lite{Name: "change-password", Label: "Change password", Path: "/settings/change-password"})
139+
for _, p := range pages {
140+
out = append(out, lite{Name: p.Name, Label: p.Label, Path: p.Path})
141+
// Surface "Add a box" alongside the Boxes card for one-keystroke
142+
// access from anywhere — only when the user can see Boxes itself.
143+
if p.Name == "boxes" {
144+
out = append(out, lite{Name: "boxes-new", Label: "Add a box", Path: "/settings/boxes/new"})
145+
}
161146
}
162147

163148
b, err := json.Marshal(out)

0 commit comments

Comments
 (0)