diff --git a/pkg/webui/server.go b/pkg/webui/server.go index 1d4909a5..b5e743fb 100644 --- a/pkg/webui/server.go +++ b/pkg/webui/server.go @@ -1,6 +1,7 @@ package webui import ( + "bufio" "crypto/rand" "encoding/csv" "encoding/hex" @@ -238,6 +239,9 @@ var pageTemplate = strings.Join([]string{ " table th:nth-child(6), table td:nth-child(6) { width: 14%; }", " .details-list li, .details-list a { overflow-wrap: anywhere; word-break: break-word; }", " .findings-table-card { overflow: hidden; }", + " .pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; }", + " .pagination-actions { display: flex; gap: .5rem; }", + " .control-btn:disabled { opacity: .5; cursor: default; }", " #toast { position: fixed; right: 1rem; bottom: 1rem; background: rgba(42,74,37,.96); color: #fff; padding: .7rem .9rem; border-radius: 999px; font-size: .84rem; box-shadow: 0 8px 20px rgba(0,0,0,.2); opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity .18s ease, transform .18s ease; z-index: 2000; }", " #toast.visible { opacity: 1; transform: translateY(0); }", " @media (max-width: 720px) { main { padding: 0 .5rem; } .top-nav-inner { padding: .6rem .75rem; } h1 { font-size: 1.6rem; } }", @@ -332,6 +336,10 @@ var pageTemplate = strings.Join([]string{ " ", " ", "
", + " ", " ", " ", " ", @@ -340,13 +348,18 @@ var pageTemplate = strings.Join([]string{ " ", "
Secret copied to clipboard
", " ", "", @@ -891,12 +927,21 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { } flusher.Flush() - for _, finding := range findings { + replay := bufio.NewWriter(w) + for i, finding := range findings { payload, _ := json.Marshal(finding) - if _, err := fmt.Fprintf(w, "event: finding\ndata: %s\n\n", string(payload)); err != nil { + if _, err := fmt.Fprintf(replay, "event: finding\ndata: %s\n\n", string(payload)); err != nil { return } - flusher.Flush() + if (i+1)%100 == 0 { + if err := replay.Flush(); err != nil { + return + } + flusher.Flush() + } + } + if err := replay.Flush(); err != nil { + return } if _, err := fmt.Fprint(w, "event: replay-complete\ndata: complete\n\n"); err != nil { return diff --git a/pkg/webui/server_test.go b/pkg/webui/server_test.go index 17e2690c..1e6627b4 100644 --- a/pkg/webui/server_test.go +++ b/pkg/webui/server_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/csv" "encoding/json" + "fmt" "net/http" "net/http/httptest" "regexp" @@ -99,6 +100,63 @@ func TestCompletedEventsReplayFindingsAndDone(t *testing.T) { } } +type flushCountingRecorder struct { + *httptest.ResponseRecorder + flushes int +} + +func (r *flushCountingRecorder) Flush() { + r.flushes++ + r.ResponseRecorder.Flush() +} + +func TestLargeReplayBatchesFlushesAndPreservesAllFindings(t *testing.T) { + const count = 2001 + s := &Server{token: "abc", clients: make(map[chan string]struct{})} + for i := range count { + s.handleHit(logging.HitRecord{ + Time: time.UnixMilli(int64(i)), Value: fmt.Sprintf("fixture-%d", i), + }) + } + s.Complete() + req := httptest.NewRequest(http.MethodGet, "/events?token=abc", nil) + res := &flushCountingRecorder{ResponseRecorder: httptest.NewRecorder()} + s.handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected successful replay, got %d", res.Code) + } + events := strings.Split(res.Body.String(), "\n\n") + var findings []Finding + for _, event := range events { + event = strings.TrimSpace(event) + if !strings.HasPrefix(event, "event: finding\ndata: ") { + continue + } + var finding Finding + if err := json.Unmarshal([]byte(strings.TrimPrefix(event, "event: finding\ndata: ")), &finding); err != nil { + t.Fatal(err) + } + findings = append(findings, finding) + } + if len(findings) != count { + t.Fatalf("replayed %d findings, want %d", len(findings), count) + } + for i, finding := range findings { + if finding.ID != fmt.Sprint(i+1) || finding.Value != fmt.Sprintf("fixture-%d", i) { + t.Fatalf("finding %d was lost or reordered: %#v", i, finding) + } + } + // Initial connection, full batches, replay completion, and scan completion. + if want := 1 + count/100 + 2; res.flushes != want { + t.Fatalf("flushed %d times, want %d instead of once per finding", res.flushes, want) + } + body := res.Body.String() + if strings.Index(body, "event: replay-complete") > strings.Index(body, "event: done") { + t.Fatal("scan completion preceded replay completion") + } +} + func TestDoneEventUsesServerCompletionTime(t *testing.T) { startedAt := time.Date(2026, time.October, 1, 12, 0, 0, 0, time.UTC) s := &Server{
SeverityRuleSecretTypeDetailsTime