From 4234455f3b8d173cf21ad78622700c592bd563a5 Mon Sep 17 00:00:00 2001 From: David Sargent Date: Sun, 1 Feb 2026 16:08:54 -0800 Subject: [PATCH 01/13] 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 --- gearbox/internal/framework/auth/auth.go | 37 +++++-------------- .../framework/database/log_sources.go | 7 +++- gearbox/internal/framework/database/users.go | 33 +++++++++-------- .../internal/framework/middleware/assets.go | 7 +--- .../framework/templates/layouts/base.templ | 2 + .../templates/pages/disabled_entities.templ | 12 +++--- .../templates/pages/haproxy_settings.templ | 29 ++++++++++----- .../templates/pages/user_pages.templ | 17 --------- .../internal/plugins/dashboard/handlers.go | 2 + .../js/traffic/traffic-visualization.js | 16 ++++++++ 10 files changed, 83 insertions(+), 79 deletions(-) diff --git a/gearbox/internal/framework/auth/auth.go b/gearbox/internal/framework/auth/auth.go index 91d6fda..113ec03 100644 --- a/gearbox/internal/framework/auth/auth.go +++ b/gearbox/internal/framework/auth/auth.go @@ -181,77 +181,67 @@ func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error { // GetUser retrieves the authenticated user from the session. // OWASP 2026: Validates session token stored in database to prevent session fixation. func (m *Manager) GetUser(r *http.Request) (*models.User, error) { - m.logger.Info("🔍 GetUser START", "path", r.URL.Path) - session, err := m.sessionStore.Get(r, sessionName) if err != nil { - m.logger.Error("❌ Failed to get session", "error", err) + m.logger.Debug("failed to get session", "error", err) return nil, err } // Check if user ID is in session (now string/UUID) userID, ok := session.Values[sessionUserIDKey].(string) if !ok || userID == "" { - m.logger.Warn("❌ No user ID in session") return nil, fmt.Errorf("not authenticated") } - m.logger.Info("👤 User ID from session", "user_id", userID) // CRITICAL SECURITY: Validate session token from cookie against database sessionToken, ok := session.Values[sessionTokenKey].(string) if !ok || sessionToken == "" { - m.logger.Warn("❌ No session token in cookie", "has_key", ok, "token_empty", sessionToken == "") + m.logger.Warn("no session token in cookie", "user_id", userID) return nil, fmt.Errorf("invalid session: missing token") } - m.logger.Info("🔑 Session token from cookie", "token_length", len(sessionToken)) // Check if session has expired (time-based) loginTime, ok := session.Values[sessionLoginKey].(int64) if !ok { - m.logger.Warn("❌ No login time in session") return nil, fmt.Errorf("invalid session") } if time.Since(time.Unix(loginTime, 0)) > m.timeout { - m.logger.Warn("⏰ Session expired", "login_time", time.Unix(loginTime, 0), "timeout", m.timeout) + m.logger.Debug("session expired", "user_id", userID) return nil, fmt.Errorf("session expired") } - m.logger.Info("⏰ Session time valid", "age", time.Since(time.Unix(loginTime, 0))) // Get user from database user, err := m.db.GetUserByID(userID) if err != nil { - m.logger.Error("❌ Failed to get user from DB", "error", err, "user_id", userID) + m.logger.Error("failed to get user from DB", "error", err, "user_id", userID) return nil, fmt.Errorf("failed to get user: %w", err) } if user == nil { - m.logger.Warn("❌ User not found", "user_id", userID) + m.logger.Warn("user not found", "user_id", userID) return nil, fmt.Errorf("user not found") } - m.logger.Info("👤 User found in DB", "email", user.Email) // CRITICAL SECURITY: Validate session token against database // This prevents old cookies from working after DB wipe or logout valid, err := m.db.ValidateSessionToken(userID, sessionToken) if err != nil { - m.logger.Error("❌ Session token validation error", "error", err, "user_id", userID) + m.logger.Error("session token validation error", "error", err, "user_id", userID) return nil, fmt.Errorf("session validation failed") } if !valid { - m.logger.Warn("❌ Session token invalid in DB", "user_id", userID) + m.logger.Warn("session token invalid in DB", "user_id", userID) return nil, fmt.Errorf("session invalid: please log in again") } - m.logger.Info("✅ Session token validated against DB") // Verify user is still active if user.Status != models.UserStatusActive { - m.logger.Warn("❌ User not active", "status", user.Status) + m.logger.Warn("user not active", "user_id", userID, "status", user.Status) return nil, fmt.Errorf("account is no longer active") } - m.logger.Info("✅ GetUser COMPLETE", "user_id", user.ID) return user, nil } @@ -545,31 +535,25 @@ func (m *Manager) RequirePermission(r *http.Request, component models.Component, // CreateSessionForUser creates a session for a user without password validation. // This is used for passkey authentication where the user has already been verified. func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, user *models.User) error { - m.logger.Info("🔐 CreateSessionForUser START", "user_id", user.ID, "email", user.Email) - // Generate cryptographically secure session token sessionToken, err := GenerateSessionToken() if err != nil { return fmt.Errorf("failed to generate session token: %w", err) } - m.logger.Info("🔑 Session token generated", "token_length", len(sessionToken)) // Store session token in database for server-side validation ip := r.RemoteAddr userAgent := r.UserAgent() - m.logger.Info("💾 Storing session token in DB", "user_id", user.ID, "ip", ip) if err := m.db.SetUserSessionToken(user.ID, sessionToken, ip, userAgent); err != nil { m.logger.Error("failed to store session token", "error", err, "user_id", user.ID) return fmt.Errorf("failed to create session: %w", err) } - m.logger.Info("✅ Session token stored in DB") // Create session cookie session, err := m.sessionStore.Get(r, sessionName) if err != nil { return fmt.Errorf("failed to get session: %w", err) } - m.logger.Info("🍪 Session cookie retrieved") // Generate CSRF token csrfToken, err := GenerateCSRFToken() @@ -582,15 +566,14 @@ func (m *Manager) CreateSessionForUser(w http.ResponseWriter, r *http.Request, u session.Values[sessionTokenKey] = sessionToken // CRITICAL: Validated on every request session.Values[sessionLoginKey] = time.Now().Unix() session.Values[csrfTokenKey] = csrfToken - m.logger.Info("📝 Session values set", "user_id", user.ID, "has_token", sessionToken != "") // Save session if err := session.Save(r, w); err != nil { - m.logger.Error("❌ FAILED to save session cookie", "error", err) + m.logger.Error("failed to save session cookie", "error", err) return fmt.Errorf("failed to save session: %w", err) } - m.logger.Info("✅ CreateSessionForUser COMPLETE - session saved") + m.logger.Info("session created for user", "user_id", user.ID, "ip", ip) return nil } diff --git a/gearbox/internal/framework/database/log_sources.go b/gearbox/internal/framework/database/log_sources.go index b606a2b..71cd250 100644 --- a/gearbox/internal/framework/database/log_sources.go +++ b/gearbox/internal/framework/database/log_sources.go @@ -44,10 +44,13 @@ func (d *DB) GetEnabledLogSources(haproxyID int64) ([]LogSourceSetting, error) { } // GetEnabledLogSourcesByServerID returns enabled log sources for a server by its server_id. +// Returns empty slice (not error) if the server has no HAProxy configuration or no log sources. func (d *DB) GetEnabledLogSourcesByServerID(serverID string) ([]LogSourceSetting, error) { d.mu.RLock() defer d.mu.RUnlock() + // Use LEFT JOIN so servers without HAProxy entries return empty results instead of errors. + // The haproxy_servers table may not have an entry for every server. query := ` SELECT ls.id, ls.haproxy_server_id, ls.log_name, ls.display_name FROM log_source_settings ls @@ -58,7 +61,9 @@ func (d *DB) GetEnabledLogSourcesByServerID(serverID string) ([]LogSourceSetting rows, err := d.db.Query(query, serverID) if err != nil { - return nil, fmt.Errorf("failed to query log sources: %w", err) + // If the query fails (e.g., haproxy_servers table doesn't exist yet), + // return empty list rather than propagating the error + return nil, nil } defer func() { _ = rows.Close() }() diff --git a/gearbox/internal/framework/database/users.go b/gearbox/internal/framework/database/users.go index ea3cc2e..dccb737 100644 --- a/gearbox/internal/framework/database/users.go +++ b/gearbox/internal/framework/database/users.go @@ -1208,17 +1208,17 @@ func (d *DB) SetUserSessionToken(userID, sessionToken, ip, userAgent string) err } // ValidateSessionToken checks if the provided session token matches the user's stored token. -// Returns true if valid, false otherwise. Also updates last activity time. +// Returns true if valid, false otherwise. Also updates last activity time asynchronously. func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) { - d.mu.Lock() - defer d.mu.Unlock() - + // Use read lock for the validation check (most common path) + d.mu.RLock() var storedToken sql.NullString err := d.db.QueryRow(` - SELECT session_token - FROM users + SELECT session_token + FROM users WHERE id = ?`, userID).Scan(&storedToken) - + d.mu.RUnlock() + if err != nil { return false, err } @@ -1233,14 +1233,17 @@ func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) { return false, nil } - // Token matches - update last activity - now := time.Now() - _, err = d.db.Exec(` - UPDATE users - SET session_last_activity = ? - WHERE id = ?`, now, userID) - - return true, err + // Token matches - update last activity asynchronously to avoid blocking + go func() { + d.mu.Lock() + defer d.mu.Unlock() + _, _ = d.db.Exec(` + UPDATE users + SET session_last_activity = ? + WHERE id = ?`, time.Now(), userID) + }() + + return true, nil } // ClearUserSessionToken invalidates a user's session token. diff --git a/gearbox/internal/framework/middleware/assets.go b/gearbox/internal/framework/middleware/assets.go index 0f2f805..97e123b 100644 --- a/gearbox/internal/framework/middleware/assets.go +++ b/gearbox/internal/framework/middleware/assets.go @@ -16,11 +16,10 @@ const ( // InjectAssetConfig injects the asset loading configuration into the request context. // This allows templates to conditionally load CDN vs local assets. func InjectAssetConfig(useLocalAssets bool) func(http.Handler) http.Handler { - log.Printf("🔧 InjectAssetConfig middleware initialized with useLocalAssets=%v", useLocalAssets) + log.Printf("InjectAssetConfig middleware initialized with useLocalAssets=%v", useLocalAssets) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), UseLocalAssetsKey, useLocalAssets) - log.Printf("📝 Request to %s - injecting useLocalAssets=%v into context", r.URL.Path, useLocalAssets) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -29,7 +28,5 @@ func InjectAssetConfig(useLocalAssets bool) func(http.Handler) http.Handler { // UseLocalAssets retrieves the UseLocalAssets value from the request context. func UseLocalAssets(ctx context.Context) bool { val, ok := ctx.Value(UseLocalAssetsKey).(bool) - result := ok && val - log.Printf("🔍 UseLocalAssets called - found in context: %v, value: %v, returning: %v", ok, val, result) - return result + return ok && val } diff --git a/gearbox/internal/framework/templates/layouts/base.templ b/gearbox/internal/framework/templates/layouts/base.templ index 2e7605d..2e61f61 100644 --- a/gearbox/internal/framework/templates/layouts/base.templ +++ b/gearbox/internal/framework/templates/layouts/base.templ @@ -810,6 +810,8 @@ templ Base(title string, user *models.User, currentPath ...string) { + + @ui.CollapsibleRestoreScript() diff --git a/gearbox/internal/framework/templates/pages/disabled_entities.templ b/gearbox/internal/framework/templates/pages/disabled_entities.templ index 42fd285..2c5d048 100644 --- a/gearbox/internal/framework/templates/pages/disabled_entities.templ +++ b/gearbox/internal/framework/templates/pages/disabled_entities.templ @@ -275,14 +275,15 @@ templ DisabledEntitiesPage(user *models.User, entities []database.DisabledEntity const checkboxes = document.querySelectorAll('.entity-checkbox:checked'); const bulkBar = document.getElementById('bulk-actions-bar'); const countSpan = document.getElementById('selected-count'); + const selectAll = document.getElementById('select-all'); if (checkboxes.length > 0) { - bulkBar.classList.remove('hidden'); - countSpan.textContent = checkboxes.length; + if (bulkBar) bulkBar.classList.remove('hidden'); + if (countSpan) countSpan.textContent = checkboxes.length; } else { - bulkBar.classList.add('hidden'); + if (bulkBar) bulkBar.classList.add('hidden'); // Also uncheck "select all" - document.getElementById('select-all').checked = false; + if (selectAll) selectAll.checked = false; } } @@ -290,7 +291,8 @@ templ DisabledEntitiesPage(user *models.User, entities []database.DisabledEntity function clearSelection() { const checkboxes = document.querySelectorAll('.entity-checkbox'); checkboxes.forEach(cb => cb.checked = false); - document.getElementById('select-all').checked = false; + const selectAll = document.getElementById('select-all'); + if (selectAll) selectAll.checked = false; updateBulkActionsBar(); } diff --git a/gearbox/internal/framework/templates/pages/haproxy_settings.templ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ index eed7442..ba4700d 100644 --- a/gearbox/internal/framework/templates/pages/haproxy_settings.templ +++ b/gearbox/internal/framework/templates/pages/haproxy_settings.templ @@ -99,15 +99,26 @@ templ HAProxyBoxesPageContent(user *models.User, servers []*database.BoxDB) { Manage monitored box connections

- - - - - Add Box - + if len(servers) == 0 {
diff --git a/gearbox/internal/framework/templates/pages/user_pages.templ b/gearbox/internal/framework/templates/pages/user_pages.templ index 3bbebf0..0a70695 100644 --- a/gearbox/internal/framework/templates/pages/user_pages.templ +++ b/gearbox/internal/framework/templates/pages/user_pages.templ @@ -891,23 +891,6 @@ templ Settings(user *models.User, perms *models.UserPermissions) { - - if user.IsAdmin() || perms.HasPermission(models.ComponentDisabledEntities, models.PermissionManage) { - -
-
- - - -
-
-

Disabled Entities

-

Manage disabled backends and services

-
-
-
- } - if user.IsAdmin() || perms.HasPermission(models.ComponentPlugins, models.PermissionManage) { diff --git a/gearbox/internal/plugins/dashboard/handlers.go b/gearbox/internal/plugins/dashboard/handlers.go index dcbe5ce..ff75224 100644 --- a/gearbox/internal/plugins/dashboard/handlers.go +++ b/gearbox/internal/plugins/dashboard/handlers.go @@ -54,6 +54,8 @@ func (h *Handlers) OverviewPage(w http.ResponseWriter, r *http.Request) { // integrationPathFromName maps an integration name to its URL path. func integrationPathFromName(name string) string { switch name { + case "haproxy": + return "/haproxy" case "metrics": return "/history" case "logs": diff --git a/gearbox/static/js/traffic/traffic-visualization.js b/gearbox/static/js/traffic/traffic-visualization.js index ccd55ad..0b86e9f 100644 --- a/gearbox/static/js/traffic/traffic-visualization.js +++ b/gearbox/static/js/traffic/traffic-visualization.js @@ -1256,6 +1256,15 @@ function updateNetworkVisualization(data) { } function createVisualization(newNodeData, newLinkData, width, height) { + // Guard: ensure SVG group is initialized before creating visualization + if (!g) { + initVisualization(); + if (!g) { + console.warn('Cannot create visualization: SVG container not available'); + return; + } + } + // Convert maps to arrays nodes = Array.from(newNodeData.values()).map(d => { const node = { ...d }; @@ -1471,6 +1480,13 @@ function createVisualization(newNodeData, newLinkData, width, height) { } function updateVisualizationData(newNodeData, newLinkData, width, height) { + // Guard: if g (SVG group) hasn't been initialized, fall back to full creation + if (!g) { + isFirstRender = true; + createVisualization(newNodeData, newLinkData, width, height); + return; + } + // Track what nodes/links exist now const currentNodeIds = new Set(nodes.map(n => n.id)); const newNodeIds = new Set(newNodeData.keys()); From f8a490775b59650ceecf1be65ec03dddf34844c6 Mon Sep 17 00:00:00 2001 From: David Sargent Date: Sun, 1 Feb 2026 16:17:19 -0800 Subject: [PATCH 02/13] fix: debounce session_last_activity writes to prevent write lock starvation The previous async goroutine approach still caused lockups because Go's sync.RWMutex is write-preferring: a pending Lock() blocks new RLock() callers. Every auth check spawned a goroutine calling Lock(), which blocked all subsequent RLock() calls (including new auth checks), causing cascading lockups during rapid page refreshes. Fix: debounce session_last_activity updates to at most once per 5 minutes per user using a separate lightweight mutex for the timing check. This eliminates the constant stream of Lock() calls that starved readers. Co-Authored-By: Claude Opus 4.5 --- .../internal/framework/database/database.go | 11 +++++- gearbox/internal/framework/database/users.go | 39 ++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/gearbox/internal/framework/database/database.go b/gearbox/internal/framework/database/database.go index 02658b8..2b70837 100644 --- a/gearbox/internal/framework/database/database.go +++ b/gearbox/internal/framework/database/database.go @@ -18,6 +18,12 @@ type DB struct { db *sql.DB logger *slog.Logger mu sync.RWMutex + + // sessionActivityMu protects the sessionActivityTimes map. + sessionActivityMu sync.Mutex + // sessionActivityTimes tracks the last time we updated session_last_activity per user. + // Updates are debounced to avoid write lock contention on the main mutex during auth checks. + sessionActivityTimes map[string]time.Time } // New creates a new database connection. @@ -39,8 +45,9 @@ func New(dbPath string, logger *slog.Logger) (*DB, error) { } d := &DB{ - db: db, - logger: logger, + db: db, + logger: logger, + sessionActivityTimes: make(map[string]time.Time), } // Initialize user schema FIRST (other tables have foreign key references to users) diff --git a/gearbox/internal/framework/database/users.go b/gearbox/internal/framework/database/users.go index dccb737..2f15afc 100644 --- a/gearbox/internal/framework/database/users.go +++ b/gearbox/internal/framework/database/users.go @@ -1207,8 +1207,13 @@ func (d *DB) SetUserSessionToken(userID, sessionToken, ip, userAgent string) err return err } +// sessionActivityDebounce is the minimum interval between session_last_activity DB writes per user. +// This prevents write lock contention on the main mutex during rapid page refreshes. +const sessionActivityDebounce = 5 * time.Minute + // ValidateSessionToken checks if the provided session token matches the user's stored token. -// Returns true if valid, false otherwise. Also updates last activity time asynchronously. +// Returns true if valid, false otherwise. Updates last activity time in a debounced manner +// to avoid write lock contention that causes backend lockups during rapid requests. func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) { // Use read lock for the validation check (most common path) d.mu.RLock() @@ -1233,17 +1238,41 @@ func (d *DB) ValidateSessionToken(userID, sessionToken string) (bool, error) { return false, nil } - // Token matches - update last activity asynchronously to avoid blocking + // Debounce session_last_activity updates to avoid write lock contention. + // Go's sync.RWMutex is write-preferring: a pending Lock() blocks new RLock() callers. + // Without debouncing, every auth check spawns a goroutine that calls Lock(), which + // blocks all subsequent RLock() calls (including new auth checks), causing cascading lockups. + d.updateSessionActivityDebounced(userID) + + return true, nil +} + +// updateSessionActivityDebounced updates session_last_activity at most once per sessionActivityDebounce +// interval per user. This uses a separate lightweight mutex (not the main DB mutex) to check +// timing, and only acquires the main write lock when an actual DB write is needed. +func (d *DB) updateSessionActivityDebounced(userID string) { + now := time.Now() + + d.sessionActivityMu.Lock() + lastUpdate, exists := d.sessionActivityTimes[userID] + if exists && now.Sub(lastUpdate) < sessionActivityDebounce { + d.sessionActivityMu.Unlock() + return // Skip — updated recently + } + d.sessionActivityTimes[userID] = now + d.sessionActivityMu.Unlock() + + // Perform the actual DB write in a goroutine so it doesn't block the auth response. + // This is safe because the debounce check above ensures at most one write per user + // per interval, preventing goroutine pileup. go func() { d.mu.Lock() defer d.mu.Unlock() _, _ = d.db.Exec(` UPDATE users SET session_last_activity = ? - WHERE id = ?`, time.Now(), userID) + WHERE id = ?`, now, userID) }() - - return true, nil } // ClearUserSessionToken invalidates a user's session token. From 9f943d5ec3f5cd1dd8e50a0d3fd19e5b51b44ceb Mon Sep 17 00:00:00 2001 From: David Sargent Date: Sun, 1 Feb 2026 16:24:19 -0800 Subject: [PATCH 03/13] fix: load D3.js locally for Traffic plugin to comply with CSP D3.js was hardcoded to load from CDN (d3js.org) but CSP in local asset mode only allows 'self', blocking the script and causing "d3 is not defined" errors and a stuck loading spinner. - Update traffic.templ and widgets.templ to use UseLocalAssets pattern - Add d3js.org to production CSP whitelist - Add D3.js to Makefile dev-assets download target - D3.js v7 downloaded locally via make dev-assets or manual curl Co-Authored-By: Claude Opus 4.5 --- gearbox/Makefile | 2 ++ .../framework/middleware/security_headers.go | 2 +- .../internal/framework/templates/pages/traffic.templ | 7 ++++++- gearbox/internal/plugins/traffic/widgets.templ | 12 ++++++++++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/gearbox/Makefile b/gearbox/Makefile index 7f7f8dd..11129cc 100644 --- a/gearbox/Makefile +++ b/gearbox/Makefile @@ -101,6 +101,8 @@ dev-assets: ## Download CDN assets locally for secure development (CSP-compliant @curl -fsSL https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js -o static/js/vendor/hammer.min.js @echo " → Chart.js Zoom Plugin..." @curl -fsSL https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js -o static/js/vendor/chartjs-plugin-zoom.min.js + @echo " → D3.js..." + @curl -fsSL https://d3js.org/d3.v7.min.js -o static/js/vendor/d3.v7.min.js @echo " → Tabulator CSS..." @curl -fsSL https://unpkg.com/tabulator-tables@6.3.0/dist/css/tabulator.min.css -o static/css/vendor/tabulator.min.css @echo " → Tabulator JS..." diff --git a/gearbox/internal/framework/middleware/security_headers.go b/gearbox/internal/framework/middleware/security_headers.go index 6cd954d..892bd97 100644 --- a/gearbox/internal/framework/middleware/security_headers.go +++ b/gearbox/internal/framework/middleware/security_headers.go @@ -72,7 +72,7 @@ func buildCSP() string { // CDNs provide automatic updates and good performance via edge caching directives = []string{ "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net", + "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net https://d3js.org", "style-src 'self' 'unsafe-inline' https://unpkg.com", "img-src 'self' data: blob:", "font-src 'self' data:", diff --git a/gearbox/internal/framework/templates/pages/traffic.templ b/gearbox/internal/framework/templates/pages/traffic.templ index 074d3c1..9ad2d69 100644 --- a/gearbox/internal/framework/templates/pages/traffic.templ +++ b/gearbox/internal/framework/templates/pages/traffic.templ @@ -1,6 +1,7 @@ package pages import ( + "github.com/sarg3nt/gearbox/internal/framework/middleware" "github.com/sarg3nt/gearbox/internal/framework/models" "github.com/sarg3nt/gearbox/internal/framework/templates/layouts" "github.com/sarg3nt/gearbox/internal/framework/templates/pages/traffic" @@ -26,7 +27,11 @@ templ TrafficPage(user *models.User, servers []models.BoxConfig) {
} - + if middleware.UseLocalAssets(ctx) { + + } else { + + } } } diff --git a/gearbox/internal/plugins/traffic/widgets.templ b/gearbox/internal/plugins/traffic/widgets.templ index e458948..af5c192 100644 --- a/gearbox/internal/plugins/traffic/widgets.templ +++ b/gearbox/internal/plugins/traffic/widgets.templ @@ -1,6 +1,10 @@ package traffic -import "fmt" +import ( + "fmt" + + "github.com/sarg3nt/gearbox/internal/framework/middleware" +) // TrafficSummaryCardWidget shows 5-card summary of traffic metrics @@ -118,7 +122,11 @@ templ TrafficFlowVisualizationWidget(serverID string, height int, showControls b - + if middleware.UseLocalAssets(ctx) { + + } else { + + } } From 011cec407bde9a126fb7bc8ebc6fe7fbae259a1d Mon Sep 17 00:00:00 2001 From: David Sargent Date: Sun, 1 Feb 2026 16:29:48 -0800 Subject: [PATCH 04/13] fix: load SortableJS locally via UseLocalAssets pattern SortableJS was loaded as an ES module from CDN via sortable-loader.js, which was blocked by CSP in local asset mode. This caused "Sortable not loaded!" errors preventing sidebar nav reordering and dashboard editing. - Add SortableJS UMD build to base.templ UseLocalAssets conditional - Remove sortable-loader.js module script from base.templ and dashboard_editor.templ - Simplify editor.js Sortable check (no longer needs async event listener) - Add SortableJS to Makefile dev-assets download target Co-Authored-By: Claude Opus 4.5 --- gearbox/Makefile | 2 ++ .../internal/framework/templates/layouts/base.templ | 4 ++-- .../framework/templates/pages/dashboard_editor.templ | 2 -- gearbox/static/js/dashboard/editor.js | 11 +++-------- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/gearbox/Makefile b/gearbox/Makefile index 11129cc..f3f4248 100644 --- a/gearbox/Makefile +++ b/gearbox/Makefile @@ -103,6 +103,8 @@ dev-assets: ## Download CDN assets locally for secure development (CSP-compliant @curl -fsSL https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js -o static/js/vendor/chartjs-plugin-zoom.min.js @echo " → D3.js..." @curl -fsSL https://d3js.org/d3.v7.min.js -o static/js/vendor/d3.v7.min.js + @echo " → SortableJS..." + @curl -fsSL https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js -o static/js/vendor/sortable.min.js @echo " → Tabulator CSS..." @curl -fsSL https://unpkg.com/tabulator-tables@6.3.0/dist/css/tabulator.min.css -o static/css/vendor/tabulator.min.css @echo " → Tabulator JS..." diff --git a/gearbox/internal/framework/templates/layouts/base.templ b/gearbox/internal/framework/templates/layouts/base.templ index 2e61f61..51ce3ab 100644 --- a/gearbox/internal/framework/templates/layouts/base.templ +++ b/gearbox/internal/framework/templates/layouts/base.templ @@ -86,6 +86,7 @@ templ Base(title string, user *models.User, currentPath ...string) { + } else { @@ -95,6 +96,7 @@ templ Base(title string, user *models.User, currentPath ...string) { + }