Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions cmd/server/compress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package main

import (
"compress/gzip"
"net/http"
"strings"
)

type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
wrote bool
}

func (g *gzipResponseWriter) WriteHeader(code int) {
g.ResponseWriter.Header().Del("Content-Length")
g.ResponseWriter.WriteHeader(code)
}

func (g *gzipResponseWriter) Write(b []byte) (int, error) {
if !g.wrote {
g.wrote = true
g.ResponseWriter.Header().Del("Content-Length")
}
return g.gz.Write(b)
}

// gzipMiddleware compresses HTTP responses when the client supports gzip.
// WebSocket upgrade requests are passed through unmodified.
func gzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
gz, _ := gzip.NewWriterLevel(w, gzip.DefaultCompression)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, gz: gz}, r)
})
}
109 changes: 109 additions & 0 deletions cmd/server/compress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"testing"
)

func TestCompressionConfigDefaults(t *testing.T) {
cfg := &Config{}
if cfg.GZipEnabled() {
t.Error("GZipEnabled should be false when compression is nil")
}
if cfg.WSCompressionEnabled() {
t.Error("WSCompressionEnabled should be false when compression is nil")
}
}

func TestCompressionConfigExplicitFalse(t *testing.T) {
cfg := &Config{Compression: &CompressionConfig{GZip: false, Websocket: false}}
if cfg.GZipEnabled() {
t.Error("GZipEnabled should be false")
}
if cfg.WSCompressionEnabled() {
t.Error("WSCompressionEnabled should be false")
}
}

func TestCompressionConfigEnabled(t *testing.T) {
cfg := &Config{Compression: &CompressionConfig{GZip: true, Websocket: true}}
if !cfg.GZipEnabled() {
t.Error("GZipEnabled should be true")
}
if !cfg.WSCompressionEnabled() {
t.Error("WSCompressionEnabled should be true")
}
}

func TestGZipMiddlewareCompresses(t *testing.T) {
body := `{"nodes":[{"id":"abc"}]}`
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
}))

req := httptest.NewRequest("GET", "/api/nodes", nil)
req.Header.Set("Accept-Encoding", "gzip")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Header().Get("Content-Encoding") != "gzip" {
t.Errorf("expected Content-Encoding: gzip, got %q", rr.Header().Get("Content-Encoding"))
}
if rr.Header().Get("Vary") != "Accept-Encoding" {
t.Errorf("expected Vary: Accept-Encoding, got %q", rr.Header().Get("Vary"))
}
gz, err := gzip.NewReader(rr.Body)
if err != nil {
t.Fatalf("response is not valid gzip: %v", err)
}
defer gz.Close()
decoded, err := io.ReadAll(gz)
if err != nil {
t.Fatalf("reading gzip: %v", err)
}
if string(decoded) != body {
t.Errorf("decompressed body = %q, want %q", string(decoded), body)
}
}

func TestGZipMiddlewareSkipsNoAcceptEncoding(t *testing.T) {
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
}))

req := httptest.NewRequest("GET", "/api/nodes", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Header().Get("Content-Encoding") != "" {
t.Errorf("expected no Content-Encoding, got %q", rr.Header().Get("Content-Encoding"))
}
if rr.Body.String() != "hello" {
t.Errorf("expected plain body, got %q", rr.Body.String())
}
}

func TestGZipMiddlewareSkipsWebSocket(t *testing.T) {
called := false
handler := gzipMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.Write([]byte("ws"))
}))

req := httptest.NewRequest("GET", "/ws", nil)
req.Header.Set("Accept-Encoding", "gzip")
req.Header.Set("Upgrade", "websocket")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if !called {
t.Error("expected next handler to be called")
}
if rr.Header().Get("Content-Encoding") != "" {
t.Errorf("WebSocket should not be gzip-encoded, got %q", rr.Header().Get("Content-Encoding"))
}
}
18 changes: 18 additions & 0 deletions cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type Config struct {

DebugAffinity bool `json:"debugAffinity,omitempty"`

Compression *CompressionConfig `json:"compression,omitempty"`
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
}
Expand Down Expand Up @@ -102,6 +103,23 @@ func IsWeakAPIKey(key string) bool {
return false
}

// CompressionConfig controls HTTP gzip and WebSocket permessage-deflate compression.
// Both are disabled by default — enable only when the upstream proxy does not already compress.
type CompressionConfig struct {
GZip bool `json:"gzip"`
Websocket bool `json:"websocket"`
}

// GZipEnabled returns true when HTTP gzip compression is explicitly enabled.
func (c *Config) GZipEnabled() bool {
return c.Compression != nil && c.Compression.GZip
}

// WSCompressionEnabled returns true when WebSocket permessage-deflate is explicitly enabled.
func (c *Config) WSCompressionEnabled() bool {
return c.Compression != nil && c.Compression.Websocket
}

// ResolvedPathConfig controls async backfill behavior.
type ResolvedPathConfig struct {
BackfillHours int `json:"backfillHours"` // how far back (hours) to scan for NULL resolved_path (default 24)
Expand Down
11 changes: 10 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ func main() {

// WebSocket hub
hub := NewHub()
hub.upgrader.EnableCompression = cfg.WSCompressionEnabled()

// HTTP server
srv := NewServer(database, cfg, hub)
Expand Down Expand Up @@ -404,9 +405,17 @@ func main() {
}

// Graceful shutdown
var handler http.Handler = router
if cfg.GZipEnabled() {
handler = gzipMiddleware(router)
log.Printf("[server] HTTP gzip compression enabled")
}
if cfg.WSCompressionEnabled() {
log.Printf("[server] WebSocket permessage-deflate compression enabled")
}
httpServer := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: router,
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
Expand Down
18 changes: 9 additions & 9 deletions cmd/server/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,11 @@ import (
"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { return true },
}

// Hub manages WebSocket clients and broadcasts.
type Hub struct {
mu sync.RWMutex
clients map[*Client]bool
mu sync.RWMutex
clients map[*Client]bool
upgrader websocket.Upgrader
}

// Client is a single WebSocket connection.
Expand All @@ -33,6 +28,11 @@ type Client struct {
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { return true },
},
}
}

Expand Down Expand Up @@ -95,7 +95,7 @@ func (h *Hub) Broadcast(msg interface{}) {

// ServeWS handles the WebSocket upgrade and runs the client.
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[ws] upgrade error: %v", err)
return
Expand Down
13 changes: 5 additions & 8 deletions test-e2e-playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,9 @@ async function run() {
// Test 5: Node detail loads (reuses nodes page from test 2)
await test('Node detail loads', async () => {
await page.waitForSelector('table tbody tr');
// Click first row
const firstRow = await page.$('table tbody tr');
assert(firstRow, 'No node rows found');
await firstRow.click();
// Use page.click() instead of an element handle to avoid detached-element races
// when the WebSocket auto-refresh re-renders the table between querySelector and click.
await page.click('table tbody tr');
// Wait for detail pane to appear
await page.waitForSelector('.node-detail');
const html = await page.content();
Expand All @@ -240,10 +239,8 @@ async function run() {
await test('Node side panel Details link navigates', async () => {
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('table tbody tr');
// Click first row to open side panel
const firstRow = await page.$('table tbody tr');
assert(firstRow, 'No node rows found');
await firstRow.click();
// Use page.click() to avoid detached-element race with WebSocket auto-refresh.
await page.click('table tbody tr');
await page.waitForSelector('.node-detail');
// Find the Details link in the side panel
const detailsLink = await page.$('#nodesRight a.btn-primary[href^="#/nodes/"]');
Expand Down