From 715c0bb4516942a06cb0e30925007d346248c8b4 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 4 Dec 2025 13:29:08 +0000 Subject: [PATCH 1/5] fix: Docker table resource column overlap regression The max-width:0 CSS trick from d3f22f06 caused the resource column content to overlap with the TYPE badge. Use a proper max-w-[300px] class instead to constrain long container names while maintaining proper column spacing. Related to #810, #789 --- .../src/components/Docker/DockerUnifiedTable.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 1651f6c..dbc89ac 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -1280,12 +1280,9 @@ const DockerContainerRow: Component<{ {(column) => ( {renderCell(column)} @@ -1990,12 +1987,9 @@ const DockerServiceRow: Component<{ {(column) => ( {renderCell(column)} From 77e59afe148a28dd70b0083c21ab768f37a1fca7 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 4 Dec 2025 20:09:37 +0000 Subject: [PATCH 2/5] fix: Force tcp4 network for IPv4 addresses in sensor-proxy HTTP mode On dual-stack systems with net.ipv6.bindv6only=1 (like some Proxmox 8 configurations), Go's net.Listen("tcp", "0.0.0.0:8443") may still bind to IPv6-only. This caused IPv4 localhost connections to hang while IPv6 worked. Fix by detecting IPv4 addresses and explicitly using "tcp4" network type when creating the listener. Related to #805 --- cmd/pulse-sensor-proxy/http_server.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/cmd/pulse-sensor-proxy/http_server.go b/cmd/pulse-sensor-proxy/http_server.go index 76585dd..78223ea 100644 --- a/cmd/pulse-sensor-proxy/http_server.go +++ b/cmd/pulse-sensor-proxy/http_server.go @@ -75,13 +75,34 @@ func (h *HTTPServer) Start() error { TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), } + // Determine network type based on address format + // Use tcp4 for IPv4 addresses to force IPv4-only binding on dual-stack systems + // Some systems (e.g., Proxmox 8 with net.ipv6.bindv6only=1) otherwise bind IPv6-only + network := "tcp" + addr := h.config.HTTPListenAddr + if strings.HasPrefix(addr, "0.0.0.0:") || (len(addr) > 0 && addr[0] >= '0' && addr[0] <= '9' && !strings.Contains(addr, "[")) { + // IPv4 address (starts with digit and no bracket) + network = "tcp4" + } else if strings.HasPrefix(addr, "[") { + // IPv6 address (starts with bracket) + network = "tcp6" + } + log.Info(). - Str("addr", h.config.HTTPListenAddr). + Str("addr", addr). + Str("network", network). Str("cert", h.config.HTTPTLSCertFile). Msg("Starting HTTPS server") + // Create listener explicitly with the correct network type + // This ensures IPv4 addresses bind to IPv4-only sockets + ln, err := net.Listen(network, addr) + if err != nil { + return fmt.Errorf("failed to create listener: %w", err) + } + go func() { - if err := h.server.ListenAndServeTLS(h.config.HTTPTLSCertFile, h.config.HTTPTLSKeyFile); err != nil && err != http.ErrServerClosed { + if err := h.server.ServeTLS(ln, h.config.HTTPTLSCertFile, h.config.HTTPTLSKeyFile); err != nil && err != http.ErrServerClosed { log.Error().Err(err).Msg("HTTPS server failed") } }() From 734b36950067e9c764d897b954aa37b2197ddb63 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 4 Dec 2025 20:11:10 +0000 Subject: [PATCH 3/5] fix: Filter EnhanceCP /var/container_tmp overlay mounts from disk stats EnhanceCP uses /var/container_tmp/{uuid}/merged for container overlays. These are ephemeral container layers, not user storage, and should be filtered from disk usage display. Related to #790 --- pkg/fsfilters/filters.go | 11 +++++++++++ pkg/fsfilters/filters_test.go | 3 +++ 2 files changed, 14 insertions(+) diff --git a/pkg/fsfilters/filters.go b/pkg/fsfilters/filters.go index 8d6b9a4..2dd8792 100644 --- a/pkg/fsfilters/filters.go +++ b/pkg/fsfilters/filters.go @@ -155,5 +155,16 @@ func ShouldSkipFilesystem(fsType, mountpoint string, totalBytes, usedBytes uint6 } } + // EnhanceCP uses /var/container_tmp/{uuid}/merged for container overlays. + // Filter these as they're ephemeral container layers, not user storage. Related to #790. + if strings.Contains(mountpoint, "/container_tmp/") { + for _, pattern := range containerOverlayPatterns { + if strings.Contains(mountpoint, pattern) { + reasons = append(reasons, "container-overlay") + break + } + } + } + return len(reasons) > 0, reasons } diff --git a/pkg/fsfilters/filters_test.go b/pkg/fsfilters/filters_test.go index 4af2622..bb04749 100644 --- a/pkg/fsfilters/filters_test.go +++ b/pkg/fsfilters/filters_test.go @@ -179,6 +179,9 @@ func TestShouldSkipFilesystem(t *testing.T) { {"enhance containers overlay", "ext4", "/var/local/enhance/containers/abc123/overlay/merged", 1000000, 500000, true}, {"custom containers diff", "ext4", "/opt/containers/myapp/diff/layer", 1000000, 500000, true}, {"custom containers overlay2", "ext4", "/data/containers/xyz/overlay2/layer1", 1000000, 500000, true}, + // EnhanceCP container_tmp paths (issue #790) + {"enhancecp container_tmp merged", "overlay", "/var/container_tmp/0d6eaae4-1aa0-4aad-a9d9-d430329c2e38/merged", 1000000, 500000, true}, + {"enhancecp container_tmp overlay", "overlay", "/var/container_tmp/abc123/overlay/layer", 1000000, 500000, true}, // Windows paths {"Windows System Reserved", "NTFS", "System Reserved", 500 * 1024 * 1024, 100 * 1024 * 1024, true}, From 0cd66da8f63a8ba280cc6b41d5758a79bf0327ae Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 4 Dec 2025 20:41:00 +0000 Subject: [PATCH 4/5] fix: Race condition in TestLoadActiveAlerts causing flaky test ClearActiveAlerts triggers an async save to disk, which can race with LoadActiveAlerts reading the file. The test now clears the in-memory map directly without triggering the async save. --- internal/alerts/alerts_test.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index 3bcbedc..6f8dde9 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -14688,7 +14688,6 @@ func TestLoadActiveAlerts(t *testing.T) { t.Run("loads alerts from valid file", func(t *testing.T) { m := newTestManager(t) - m.ClearActiveAlerts() // Create an alert and save it startTime := time.Now().Add(-30 * time.Minute) @@ -14714,8 +14713,10 @@ func TestLoadActiveAlerts(t *testing.T) { // Save to disk _ = m.SaveActiveAlerts() - // Clear and reload - m.ClearActiveAlerts() + // Clear in-memory map only (don't use ClearActiveAlerts which triggers async save) + m.mu.Lock() + m.activeAlerts = make(map[string]*Alert) + m.mu.Unlock() err := m.LoadActiveAlerts() if err != nil { @@ -14739,7 +14740,6 @@ func TestLoadActiveAlerts(t *testing.T) { t.Run("skips old alerts", func(t *testing.T) { m := newTestManager(t) - m.ClearActiveAlerts() // Create an old alert (>24 hours) startTime := time.Now().Add(-25 * time.Hour) @@ -14760,8 +14760,10 @@ func TestLoadActiveAlerts(t *testing.T) { // Save to disk _ = m.SaveActiveAlerts() - // Clear and reload - m.ClearActiveAlerts() + // Clear in-memory map only (don't use ClearActiveAlerts which triggers async save) + m.mu.Lock() + m.activeAlerts = make(map[string]*Alert) + m.mu.Unlock() err := m.LoadActiveAlerts() if err != nil { @@ -14779,7 +14781,6 @@ func TestLoadActiveAlerts(t *testing.T) { t.Run("skips old acknowledged alerts", func(t *testing.T) { m := newTestManager(t) - m.ClearActiveAlerts() // Create an alert acknowledged >1 hour ago startTime := time.Now().Add(-30 * time.Minute) @@ -14804,8 +14805,10 @@ func TestLoadActiveAlerts(t *testing.T) { // Save to disk _ = m.SaveActiveAlerts() - // Clear and reload - m.ClearActiveAlerts() + // Clear in-memory map only (don't use ClearActiveAlerts which triggers async save) + m.mu.Lock() + m.activeAlerts = make(map[string]*Alert) + m.mu.Unlock() err := m.LoadActiveAlerts() if err != nil { @@ -14823,7 +14826,6 @@ func TestLoadActiveAlerts(t *testing.T) { t.Run("restores acknowledgment state", func(t *testing.T) { m := newTestManager(t) - m.ClearActiveAlerts() // Create an acknowledged alert startTime := time.Now().Add(-10 * time.Minute) @@ -14848,8 +14850,11 @@ func TestLoadActiveAlerts(t *testing.T) { // Save to disk _ = m.SaveActiveAlerts() - // Clear and reload - m.ClearActiveAlerts() + // Clear in-memory maps only (don't use ClearActiveAlerts which triggers async save) + m.mu.Lock() + m.activeAlerts = make(map[string]*Alert) + m.ackState = make(map[string]ackRecord) + m.mu.Unlock() err := m.LoadActiveAlerts() if err != nil { From 87507d035335af281e063a93e2c87283a50549fc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 10 Dec 2025 20:14:28 +0000 Subject: [PATCH 5/5] fix(sensor-proxy): Make nodeGate.acquire() context-aware to prevent goroutine leaks The acquire() function blocked indefinitely without respecting context cancellation. When clients disconnect while waiting for the per-node lock, goroutines would remain blocked forever, connections accumulate in CLOSE_WAIT state, and rate limiter semaphores are never released. Added acquireContext() that respects context cancellation and updated both HTTP and RPC handlers to use it. This prevents: - Goroutine leaks from cancelled requests - CLOSE_WAIT connection accumulation - Cascading failures from filled semaphores Related to #832 --- cmd/pulse-sensor-proxy/http_server.go | 9 ++++-- cmd/pulse-sensor-proxy/main.go | 12 +++++--- cmd/pulse-sensor-proxy/throttle.go | 41 +++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/cmd/pulse-sensor-proxy/http_server.go b/cmd/pulse-sensor-proxy/http_server.go index 78223ea..e3163d3 100644 --- a/cmd/pulse-sensor-proxy/http_server.go +++ b/cmd/pulse-sensor-proxy/http_server.go @@ -304,8 +304,13 @@ func (h *HTTPServer) handleTemperature(w http.ResponseWriter, r *http.Request) { } } - // Acquire per-node concurrency lock - releaseNode := h.proxy.nodeGate.acquire(nodeName) + // Acquire per-node concurrency lock (context-aware to prevent goroutine leaks) + releaseNode, err := h.proxy.nodeGate.acquireContext(ctx, nodeName) + if err != nil { + log.Warn().Err(err).Str("node", nodeName).Msg("Request cancelled while waiting for node lock") + h.sendJSONError(w, http.StatusServiceUnavailable, "request cancelled while waiting for node") + return + } defer releaseNode() // Fetch temperature data via SSH with context timeout diff --git a/cmd/pulse-sensor-proxy/main.go b/cmd/pulse-sensor-proxy/main.go index 7fec79f..adbd9b5 100644 --- a/cmd/pulse-sensor-proxy/main.go +++ b/cmd/pulse-sensor-proxy/main.go @@ -1188,16 +1188,20 @@ func (p *Proxy) handleGetTemperatureV2(ctx context.Context, req *RPCRequest, log } } - // Acquire per-node concurrency lock (prevents multiple simultaneous requests to same node) - releaseNode := p.nodeGate.acquire(node) + // Acquire per-node concurrency lock (context-aware to prevent goroutine leaks) + releaseNode, err := p.nodeGate.acquireContext(ctx, node) + if err != nil { + logger.Warn().Err(err).Str("node", node).Msg("Request cancelled while waiting for node lock") + return nil, fmt.Errorf("request cancelled while waiting for node") + } defer releaseNode() logger.Debug().Str("node", node).Msg("Fetching temperature via SSH") // Fetch temperature data with timeout - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + sshCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - tempData, err := p.getTemperatureViaSSH(ctx, node) + tempData, err := p.getTemperatureViaSSH(sshCtx, node) if err != nil { logger.Warn().Err(err).Str("node", node).Msg("Failed to get temperatures") return nil, fmt.Errorf("failed to get temperatures: %w", err) diff --git a/cmd/pulse-sensor-proxy/throttle.go b/cmd/pulse-sensor-proxy/throttle.go index 8d39d73..807ef27 100644 --- a/cmd/pulse-sensor-proxy/throttle.go +++ b/cmd/pulse-sensor-proxy/throttle.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "sync" "time" @@ -253,6 +254,7 @@ func newNodeGate() *nodeGate { // acquire gets exclusive access to make requests to a node // Returns a release function that must be called when done +// Deprecated: Use acquireContext for context-aware acquisition func (g *nodeGate) acquire(node string) func() { g.mu.Lock() lock := g.inFlight[node] @@ -279,3 +281,42 @@ func (g *nodeGate) acquire(node string) func() { g.mu.Unlock() } } + +// acquireContext gets exclusive access to make requests to a node with context cancellation support. +// Returns a release function and nil error on success. +// Returns nil and context error if context is cancelled while waiting. +func (g *nodeGate) acquireContext(ctx context.Context, node string) (func(), error) { + g.mu.Lock() + lock := g.inFlight[node] + if lock == nil { + lock = &nodeLock{ + guard: make(chan struct{}, 1), + } + g.inFlight[node] = lock + } + lock.refCount++ + g.mu.Unlock() + + // Wait for exclusive access OR context cancellation + select { + case lock.guard <- struct{}{}: + return func() { + <-lock.guard + g.mu.Lock() + lock.refCount-- + if lock.refCount == 0 { + delete(g.inFlight, node) + } + g.mu.Unlock() + }, nil + case <-ctx.Done(): + // Clean up refCount since we're not proceeding + g.mu.Lock() + lock.refCount-- + if lock.refCount == 0 { + delete(g.inFlight, node) + } + g.mu.Unlock() + return nil, ctx.Err() + } +}