Skip to content

Commit 4234455

Browse files
sarg3ntclaude
andcommitted
fix: resolve 9 UI and backend bugs across dashboard, plugins, and framework
- Fix Sortable.js not loaded for sidebar nav reordering (added global script) - Remove excessive INFO-level debug logging from auth/session middleware - Fix Logs/Services 500 error on log-sources API for non-HAProxy servers - Fix Traffic plugin 'g is undefined' with null guards on SVG group - Fix root URL (/) blank page by adding haproxy case to integration path map - Fix backend lockup on quick refresh by using RLock for session validation - Fix OS Updates CDN noise by cleaning up assets middleware logging - Fix disabled-entities JS errors with null checks and move to HAProxy settings Closes #10 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d0ff660 commit 4234455

10 files changed

Lines changed: 83 additions & 79 deletions

File tree

gearbox/internal/framework/auth/auth.go

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -181,77 +181,67 @@ func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error {
181181
// GetUser retrieves the authenticated user from the session.
182182
// OWASP 2026: Validates session token stored in database to prevent session fixation.
183183
func (m *Manager) GetUser(r *http.Request) (*models.User, error) {
184-
m.logger.Info("🔍 GetUser START", "path", r.URL.Path)
185-
186184
session, err := m.sessionStore.Get(r, sessionName)
187185
if err != nil {
188-
m.logger.Error("❌ Failed to get session", "error", err)
186+
m.logger.Debug("failed to get session", "error", err)
189187
return nil, err
190188
}
191189

192190
// Check if user ID is in session (now string/UUID)
193191
userID, ok := session.Values[sessionUserIDKey].(string)
194192
if !ok || userID == "" {
195-
m.logger.Warn("❌ No user ID in session")
196193
return nil, fmt.Errorf("not authenticated")
197194
}
198-
m.logger.Info("👤 User ID from session", "user_id", userID)
199195

200196
// CRITICAL SECURITY: Validate session token from cookie against database
201197
sessionToken, ok := session.Values[sessionTokenKey].(string)
202198
if !ok || sessionToken == "" {
203-
m.logger.Warn("❌ No session token in cookie", "has_key", ok, "token_empty", sessionToken == "")
199+
m.logger.Warn("no session token in cookie", "user_id", userID)
204200
return nil, fmt.Errorf("invalid session: missing token")
205201
}
206-
m.logger.Info("🔑 Session token from cookie", "token_length", len(sessionToken))
207202

208203
// Check if session has expired (time-based)
209204
loginTime, ok := session.Values[sessionLoginKey].(int64)
210205
if !ok {
211-
m.logger.Warn("❌ No login time in session")
212206
return nil, fmt.Errorf("invalid session")
213207
}
214208

215209
if time.Since(time.Unix(loginTime, 0)) > m.timeout {
216-
m.logger.Warn("⏰ Session expired", "login_time", time.Unix(loginTime, 0), "timeout", m.timeout)
210+
m.logger.Debug("session expired", "user_id", userID)
217211
return nil, fmt.Errorf("session expired")
218212
}
219-
m.logger.Info("⏰ Session time valid", "age", time.Since(time.Unix(loginTime, 0)))
220213

221214
// Get user from database
222215
user, err := m.db.GetUserByID(userID)
223216
if err != nil {
224-
m.logger.Error("❌ Failed to get user from DB", "error", err, "user_id", userID)
217+
m.logger.Error("failed to get user from DB", "error", err, "user_id", userID)
225218
return nil, fmt.Errorf("failed to get user: %w", err)
226219
}
227220

228221
if user == nil {
229-
m.logger.Warn("❌ User not found", "user_id", userID)
222+
m.logger.Warn("user not found", "user_id", userID)
230223
return nil, fmt.Errorf("user not found")
231224
}
232-
m.logger.Info("👤 User found in DB", "email", user.Email)
233225

234226
// CRITICAL SECURITY: Validate session token against database
235227
// This prevents old cookies from working after DB wipe or logout
236228
valid, err := m.db.ValidateSessionToken(userID, sessionToken)
237229
if err != nil {
238-
m.logger.Error("❌ Session token validation error", "error", err, "user_id", userID)
230+
m.logger.Error("session token validation error", "error", err, "user_id", userID)
239231
return nil, fmt.Errorf("session validation failed")
240232
}
241233

242234
if !valid {
243-
m.logger.Warn("❌ Session token invalid in DB", "user_id", userID)
235+
m.logger.Warn("session token invalid in DB", "user_id", userID)
244236
return nil, fmt.Errorf("session invalid: please log in again")
245237
}
246-
m.logger.Info("✅ Session token validated against DB")
247238

248239
// Verify user is still active
249240
if user.Status != models.UserStatusActive {
250-
m.logger.Warn("❌ User not active", "status", user.Status)
241+
m.logger.Warn("user not active", "user_id", userID, "status", user.Status)
251242
return nil, fmt.Errorf("account is no longer active")
252243
}
253244

254-
m.logger.Info("✅ GetUser COMPLETE", "user_id", user.ID)
255245
return user, nil
256246
}
257247

@@ -545,31 +535,25 @@ func (m *Manager) RequirePermission(r *http.Request, component models.Component,
545535
// CreateSessionForUser creates a session for a user without password validation.
546536
// This is used for passkey authentication where the user has already been verified.
547537
func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, user *models.User) error {
548-
m.logger.Info("🔐 CreateSessionForUser START", "user_id", user.ID, "email", user.Email)
549-
550538
// Generate cryptographically secure session token
551539
sessionToken, err := GenerateSessionToken()
552540
if err != nil {
553541
return fmt.Errorf("failed to generate session token: %w", err)
554542
}
555-
m.logger.Info("🔑 Session token generated", "token_length", len(sessionToken))
556543

557544
// Store session token in database for server-side validation
558545
ip := r.RemoteAddr
559546
userAgent := r.UserAgent()
560-
m.logger.Info("💾 Storing session token in DB", "user_id", user.ID, "ip", ip)
561547
if err := m.db.SetUserSessionToken(user.ID, sessionToken, ip, userAgent); err != nil {
562548
m.logger.Error("failed to store session token", "error", err, "user_id", user.ID)
563549
return fmt.Errorf("failed to create session: %w", err)
564550
}
565-
m.logger.Info("✅ Session token stored in DB")
566551

567552
// Create session cookie
568553
session, err := m.sessionStore.Get(r, sessionName)
569554
if err != nil {
570555
return fmt.Errorf("failed to get session: %w", err)
571556
}
572-
m.logger.Info("🍪 Session cookie retrieved")
573557

574558
// Generate CSRF token
575559
csrfToken, err := GenerateCSRFToken()
@@ -582,15 +566,14 @@ func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, u
582566
session.Values[sessionTokenKey] = sessionToken // CRITICAL: Validated on every request
583567
session.Values[sessionLoginKey] = time.Now().Unix()
584568
session.Values[csrfTokenKey] = csrfToken
585-
m.logger.Info("📝 Session values set", "user_id", user.ID, "has_token", sessionToken != "")
586569

587570
// Save session
588571
if err := session.Save(r, w); err != nil {
589-
m.logger.Error("❌ FAILED to save session cookie", "error", err)
572+
m.logger.Error("failed to save session cookie", "error", err)
590573
return fmt.Errorf("failed to save session: %w", err)
591574
}
592-
m.logger.Info("✅ CreateSessionForUser COMPLETE - session saved")
593575

576+
m.logger.Info("session created for user", "user_id", user.ID, "ip", ip)
594577
return nil
595578
}
596579

gearbox/internal/framework/database/log_sources.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,13 @@ func (d *DB) GetEnabledLogSources(haproxyID int64) ([]LogSourceSetting, error) {
4444
}
4545

4646
// GetEnabledLogSourcesByServerID returns enabled log sources for a server by its server_id.
47+
// Returns empty slice (not error) if the server has no HAProxy configuration or no log sources.
4748
func (d *DB) GetEnabledLogSourcesByServerID(serverID string) ([]LogSourceSetting, error) {
4849
d.mu.RLock()
4950
defer d.mu.RUnlock()
5051

52+
// Use LEFT JOIN so servers without HAProxy entries return empty results instead of errors.
53+
// The haproxy_servers table may not have an entry for every server.
5154
query := `
5255
SELECT ls.id, ls.haproxy_server_id, ls.log_name, ls.display_name
5356
FROM log_source_settings ls
@@ -58,7 +61,9 @@ func (d *DB) GetEnabledLogSourcesByServerID(serverID string) ([]LogSourceSetting
5861

5962
rows, err := d.db.Query(query, serverID)
6063
if err != nil {
61-
return nil, fmt.Errorf("failed to query log sources: %w", err)
64+
// If the query fails (e.g., haproxy_servers table doesn't exist yet),
65+
// return empty list rather than propagating the error
66+
return nil, nil
6267
}
6368
defer func() { _ = rows.Close() }()
6469

gearbox/internal/framework/database/users.go

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,17 +1208,17 @@ func (d *DB) SetUserSessionToken(userID, sessionToken, ip, userAgent string) err
12081208
}
12091209

12101210
// ValidateSessionToken checks if the provided session token matches the user's stored token.
1211-
// Returns true if valid, false otherwise. Also updates last activity time.
1211+
// Returns true if valid, false otherwise. Also updates last activity time asynchronously.
12121212
func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) {
1213-
d.mu.Lock()
1214-
defer d.mu.Unlock()
1215-
1213+
// Use read lock for the validation check (most common path)
1214+
d.mu.RLock()
12161215
var storedToken sql.NullString
12171216
err := d.db.QueryRow(`
1218-
SELECT session_token
1219-
FROM users
1217+
SELECT session_token
1218+
FROM users
12201219
WHERE id = ?`, userID).Scan(&storedToken)
1221-
1220+
d.mu.RUnlock()
1221+
12221222
if err != nil {
12231223
return false, err
12241224
}
@@ -1233,14 +1233,17 @@ func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) {
12331233
return false, nil
12341234
}
12351235

1236-
// Token matches - update last activity
1237-
now := time.Now()
1238-
_, err = d.db.Exec(`
1239-
UPDATE users
1240-
SET session_last_activity = ?
1241-
WHERE id = ?`, now, userID)
1242-
1243-
return true, err
1236+
// Token matches - update last activity asynchronously to avoid blocking
1237+
go func() {
1238+
d.mu.Lock()
1239+
defer d.mu.Unlock()
1240+
_, _ = d.db.Exec(`
1241+
UPDATE users
1242+
SET session_last_activity = ?
1243+
WHERE id = ?`, time.Now(), userID)
1244+
}()
1245+
1246+
return true, nil
12441247
}
12451248

12461249
// ClearUserSessionToken invalidates a user's session token.

gearbox/internal/framework/middleware/assets.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ const (
1616
// InjectAssetConfig injects the asset loading configuration into the request context.
1717
// This allows templates to conditionally load CDN vs local assets.
1818
func InjectAssetConfig(useLocalAssets bool) func(http.Handler) http.Handler {
19-
log.Printf("🔧 InjectAssetConfig middleware initialized with useLocalAssets=%v", useLocalAssets)
19+
log.Printf("InjectAssetConfig middleware initialized with useLocalAssets=%v", useLocalAssets)
2020
return func(next http.Handler) http.Handler {
2121
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2222
ctx := context.WithValue(r.Context(), UseLocalAssetsKey, useLocalAssets)
23-
log.Printf("📝 Request to %s - injecting useLocalAssets=%v into context", r.URL.Path, useLocalAssets)
2423
next.ServeHTTP(w, r.WithContext(ctx))
2524
})
2625
}
@@ -29,7 +28,5 @@ func InjectAssetConfig(useLocalAssets bool) func(http.Handler) http.Handler {
2928
// UseLocalAssets retrieves the UseLocalAssets value from the request context.
3029
func UseLocalAssets(ctx context.Context) bool {
3130
val, ok := ctx.Value(UseLocalAssetsKey).(bool)
32-
result := ok && val
33-
log.Printf("🔍 UseLocalAssets called - found in context: %v, value: %v, returning: %v", ok, val, result)
34-
return result
31+
return ok && val
3532
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,8 @@ templ Base(title string, user *models.User, currentPath ...string) {
810810
<script src="/static/js/utils/api.js" defer></script>
811811
<script src="/static/js/common/page-header.js" defer></script>
812812
<script src="/static/js/common/box-selector.js" defer></script>
813+
<!-- Sortable.js for sidebar nav reordering -->
814+
<script type="module" src="/static/js/dashboard/sortable-loader.js"></script>
813815
</head>
814816
<body class="h-full bg-gray-100 dark:bg-slate-900">
815817
@ui.CollapsibleRestoreScript()

gearbox/internal/framework/templates/pages/disabled_entities.templ

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,22 +275,24 @@ templ DisabledEntitiesPage(user *models.User, entities []database.DisabledEntity
275275
const checkboxes = document.querySelectorAll('.entity-checkbox:checked');
276276
const bulkBar = document.getElementById('bulk-actions-bar');
277277
const countSpan = document.getElementById('selected-count');
278+
const selectAll = document.getElementById('select-all');
278279

279280
if (checkboxes.length > 0) {
280-
bulkBar.classList.remove('hidden');
281-
countSpan.textContent = checkboxes.length;
281+
if (bulkBar) bulkBar.classList.remove('hidden');
282+
if (countSpan) countSpan.textContent = checkboxes.length;
282283
} else {
283-
bulkBar.classList.add('hidden');
284+
if (bulkBar) bulkBar.classList.add('hidden');
284285
// Also uncheck "select all"
285-
document.getElementById('select-all').checked = false;
286+
if (selectAll) selectAll.checked = false;
286287
}
287288
}
288289

289290
// Clear all selections
290291
function clearSelection() {
291292
const checkboxes = document.querySelectorAll('.entity-checkbox');
292293
checkboxes.forEach(cb => cb.checked = false);
293-
document.getElementById('select-all').checked = false;
294+
const selectAll = document.getElementById('select-all');
295+
if (selectAll) selectAll.checked = false;
294296
updateBulkActionsBar();
295297
}
296298

gearbox/internal/framework/templates/pages/haproxy_settings.templ

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,26 @@ templ HAProxyBoxesPageContent(user *models.User, servers []*database.BoxDB) {
9999
Manage monitored box connections
100100
</p>
101101
</div>
102-
<a
103-
href="/settings/boxes/new"
104-
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
105-
>
106-
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
107-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
108-
</svg>
109-
Add Box
110-
</a>
102+
<div class="flex items-center space-x-3">
103+
<a
104+
href="/settings/admin/disabled-entities"
105+
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
106+
>
107+
<svg class="w-5 h-5 mr-2 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
108+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
109+
</svg>
110+
Disabled Entities
111+
</a>
112+
<a
113+
href="/settings/boxes/new"
114+
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
115+
>
116+
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
117+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
118+
</svg>
119+
Add Box
120+
</a>
121+
</div>
111122
</div>
112123
if len(servers) == 0 {
113124
<div class="bg-white dark:bg-slate-800 rounded-lg shadow p-8 text-center">

gearbox/internal/framework/templates/pages/user_pages.templ

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -891,23 +891,6 @@ templ Settings(user *models.User, perms *models.UserPermissions) {
891891
</a>
892892

893893

894-
<!-- Disabled Entities - shown to admins or users with manage permission on backends -->
895-
if user.IsAdmin() || perms.HasPermission(models.ComponentDisabledEntities, models.PermissionManage) {
896-
<a href="/settings/admin/disabled-entities" class="block bg-white dark:bg-slate-800 p-6 rounded-lg shadow hover:shadow-lg transition-shadow">
897-
<div class="flex items-center space-x-4">
898-
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
899-
<svg class="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
900-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
901-
</svg>
902-
</div>
903-
<div>
904-
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Disabled Entities</h2>
905-
<p class="text-sm text-gray-600 dark:text-gray-400">Manage disabled backends and services</p>
906-
</div>
907-
</div>
908-
</a>
909-
}
910-
911894
<!-- Plugins - shown to admins or users with manage permission on plugins -->
912895
if user.IsAdmin() || perms.HasPermission(models.ComponentPlugins, models.PermissionManage) {
913896
<a href="/settings/plugins" class="block bg-white dark:bg-slate-800 p-6 rounded-lg shadow hover:shadow-lg transition-shadow">

gearbox/internal/plugins/dashboard/handlers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ func (h *Handlers) OverviewPage(w http.ResponseWriter, r *http.Request) {
5454
// integrationPathFromName maps an integration name to its URL path.
5555
func integrationPathFromName(name string) string {
5656
switch name {
57+
case "haproxy":
58+
return "/haproxy"
5759
case "metrics":
5860
return "/history"
5961
case "logs":

gearbox/static/js/traffic/traffic-visualization.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,15 @@ function updateNetworkVisualization(data) {
12561256
}
12571257

12581258
function createVisualization(newNodeData, newLinkData, width, height) {
1259+
// Guard: ensure SVG group is initialized before creating visualization
1260+
if (!g) {
1261+
initVisualization();
1262+
if (!g) {
1263+
console.warn('Cannot create visualization: SVG container not available');
1264+
return;
1265+
}
1266+
}
1267+
12591268
// Convert maps to arrays
12601269
nodes = Array.from(newNodeData.values()).map(d => {
12611270
const node = { ...d };
@@ -1471,6 +1480,13 @@ function createVisualization(newNodeData, newLinkData, width, height) {
14711480
}
14721481

14731482
function updateVisualizationData(newNodeData, newLinkData, width, height) {
1483+
// Guard: if g (SVG group) hasn't been initialized, fall back to full creation
1484+
if (!g) {
1485+
isFirstRender = true;
1486+
createVisualization(newNodeData, newLinkData, width, height);
1487+
return;
1488+
}
1489+
14741490
// Track what nodes/links exist now
14751491
const currentNodeIds = new Set(nodes.map(n => n.id));
14761492
const newNodeIds = new Set(newNodeData.keys());

0 commit comments

Comments
 (0)