diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2ac6ec7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +on: + push: + tags: + - v* + branches: + - main + pull_request: + +name: run tests + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + - name: Run tests + run: go test -v ./... + + acceptance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + - name: Restore Playwright browsers cache + id: playwright-cache + uses: actions/cache/restore@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('acceptance/go.sum') }} + restore-keys: | + playwright-${{ runner.os }}- + - name: Run acceptance tests + run: go test -v -timeout 5m ./acceptance/... + - name: Save Playwright browsers cache + if: always() && steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('acceptance/go.sum') }} diff --git a/README.md b/README.md index a7aa403..b2eefb1 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,12 @@ A lightweight, embeddable development dashboard for Go applications. Monitor log - **Logs**: Capture and browse structured logs with filtering and detail view - **HTTP Client**: Monitor outgoing HTTP requests with timing, headers, and response info - **HTTP Server**: Track incoming HTTP requests to your application -- **Low Overhead**: Designed to be lightweight to run in development and testing setups +- **SQL Queries**: Monitor database queries with timing and arguments +- **On-Demand Capture**: Start/stop capturing through the dashboard UI with session or global modes +- **Multi-User Isolation**: Each user gets their own event storage with independent clearing +- **Low Overhead**: Designed to be lightweight; no events captured until you start a session - **Easy to Integrate**: Embeds into your application with minimal configuration -- **Realtime**: See events as they occur +- **Realtime**: See events as they occur via Server-Sent Events - **Clean UI**: Modern, minimalist interface with responsive design ## Note @@ -86,6 +89,21 @@ See [example](example/main.go) for a more complete example showing all features. ## Usage +### Capture Sessions + +By default, no events are collected until a user starts a capture session through the dashboard UI. This on-demand approach: + +- Reduces overhead when not actively debugging +- Provides isolation between users (each gets their own event storage) +- Allows clearing events without affecting other users + +**Capture Modes:** + +- **Session Mode** (default): Only captures events from HTTP requests that include your session cookie. Useful for isolating your own requests in a shared environment. +- **Global Mode**: Captures all events from all requests. Useful when you need to see everything happening in the application. + +Toggle between modes using the buttons in the dashboard header. + ### Capturing Logs devlog integrates with Go's `slog` package: @@ -250,28 +268,71 @@ The collected queries will be visible in the devlog dashboard, showing: ### Configuring the Dashboard -Use options to customize the dashboard: +Use functional options to customize the dashboard handler: ```go -dashboard := devlog.NewWithOptions(devlog.Options{ - LogCapacity: 1000, // Maximum number of log entries to keep - HTTPClientCapacity: 100, // Maximum number of HTTP client requests to keep - HTTPServerCapacity: 100, // Maximum number of HTTP server requests to keep - SQLCapacity: 100, // Maximum number of SQL queries to keep +mux.Handle("/_devlog/", http.StripPrefix("/_devlog", dlog.DashboardHandler("/_devlog", + dashboard.WithStorageCapacity(5000), // Events per user (default: 1000) + dashboard.WithSessionIdleTimeout(time.Minute), // Cleanup timeout (default: 30s) + dashboard.WithTruncateAfter(100), // Limit displayed events +))) +``` + +### Configuring Collectors + +Use options to customize collector behavior: + +```go +dlog := devlog.NewWithOptions(devlog.Options{ + HTTPServerOptions: &collector.HTTPServerOptions{ + CaptureRequestBody: true, + CaptureResponseBody: true, + MaxBodySize: 1024 * 1024, // 1MB max body capture + SkipPaths: []string{"/_devlog"}, // Skip dashboard routes + }, + HTTPClientOptions: &collector.HTTPClientOptions{ + CaptureRequestBody: true, + CaptureResponseBody: true, + MaxBodySize: 1024 * 1024, + }, }) ``` +## Development + +### Running Acceptance Tests + +The project includes Playwright-based acceptance tests that verify the dashboard UI works correctly with the backend. + +**Prerequisites:** + +Playwright browsers will be automatically installed on first run. + +**Run all acceptance tests:** + +```bash +go test -v -timeout 5m ./acceptance/... +``` + +**Debug mode (visible browser):** + +```bash +HEADLESS=false go test -v -parallel=1 ./acceptance/... +``` + +The acceptance tests cover: +- Dashboard access and session management +- Global and session capture modes +- Event capturing and display (HTTP server/client, logs, DB queries) +- SSE real-time updates +- Mode switching and event clearing + ## TODOs -- [ ] Add support for generic events/groups that can be used in user-code -- [ ] Implement on-demand activation of devlog (record / stop) -- [ ] Implement reset of collected events +- [ ] Add support for generic events/groups that can be used in user-code - [ ] Add pretty printing of JSON - [ ] Implement ad-hoc change of log level via slog.Leveler via UI - [ ] Implement filtering of events -- [ ] Support plugins (e.g. for GraphQL) to add attributes to HTTP requests (operation name) -- [x] Change display of time or implement timers via JS -- [x] Implement SQL query logging with adapters ## License diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go new file mode 100644 index 0000000..807b383 --- /dev/null +++ b/acceptance/acceptance_test.go @@ -0,0 +1,17 @@ +package acceptance + +import ( + "log" + "os" + "testing" + + "github.com/playwright-community/playwright-go" +) + +// TestMain installs Playwright browsers before running tests. +func TestMain(m *testing.M) { + if err := playwright.Install(); err != nil { + log.Fatalf("could not install playwright: %v", err) + } + os.Exit(m.Run()) +} diff --git a/acceptance/capture_mode_test.go b/acceptance/capture_mode_test.go new file mode 100644 index 0000000..40bdd7c --- /dev/null +++ b/acceptance/capture_mode_test.go @@ -0,0 +1,344 @@ +package acceptance + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ----------------------------------------------------------------------------- +// Global Mode Tests +// ----------------------------------------------------------------------------- + +// TestGlobalMode_SameContext verifies that global mode captures requests from the same browser context. +func TestGlobalMode_SameContext(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Make a request from the same browser context + dashboard.FetchAPI("/api/test") + + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) +} + +// TestGlobalMode_DifferentContext verifies that global mode captures requests from different browser contexts. +func TestGlobalMode_DifferentContext(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx1 := pw.NewContext(t) + defer ctx1.Close() + + dashboard := NewDashboardPage(t, ctx1, app.DevlogURL) + dashboard.StartCapture("global") + + // Make request from a completely different browser context (no cookies) + ctx2 := pw.NewContext(t) + defer ctx2.Close() + + page2, err := ctx2.NewPage() + require.NoError(t, err) + + _, err = page2.Goto(app.AppURL + "/api/test") + require.NoError(t, err) + + // In global mode, this should still be captured + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) +} + +// TestGlobalMode_ReloadPersistence verifies that events persist after page reload in global mode. +func TestGlobalMode_ReloadPersistence(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Make a request + dashboard.FetchAPI("/api/test") + + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) + + // Reload the page + dashboard.Reload() + + // Events should persist after reload + assert.Equal(t, 1, dashboard.GetEventCount()) +} + +// TestClearEvents_GlobalMode verifies that events can be cleared in global mode. +func TestClearEvents_GlobalMode(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Create some events + for i := 0; i < 3; i++ { + _, err := dashboard.Page.Evaluate(fmt.Sprintf(`fetch('/api/test?i=%d')`, i)) + require.NoError(t, err) + } + + dashboard.WaitForEventCount(3, 5000) + assert.Equal(t, 3, dashboard.GetEventCount()) + + // Clear events + dashboard.ClearEvents() + + assert.Equal(t, 0, dashboard.GetEventCount()) +} + +// ----------------------------------------------------------------------------- +// Session Mode Tests +// ----------------------------------------------------------------------------- + +// TestSessionMode_SameContext verifies that session mode captures requests from the same browser context. +func TestSessionMode_SameContext(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("session") + + // Make a request from the same browser context (has session cookie) + dashboard.FetchAPI("/api/test") + + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) +} + +// TestSessionMode_DifferentContext verifies that session mode does NOT capture requests from different browser contexts. +func TestSessionMode_DifferentContext(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx1 := pw.NewContext(t) + defer ctx1.Close() + + dashboard := NewDashboardPage(t, ctx1, app.DevlogURL) + dashboard.StartCapture("session") + + // Make request from different browser context (no session cookie) + ctx2 := pw.NewContext(t) + defer ctx2.Close() + + page2, err := ctx2.NewPage() + require.NoError(t, err) + + _, err = page2.Goto(app.AppURL + "/api/test") + require.NoError(t, err) + + // In session mode, this should NOT be captured (no cookie) + // Poll for 2 seconds to verify no events appear + dashboard.ExpectNoEvents(2 * time.Second) +} + +// TestSessionMode_ReloadPersistence verifies that events persist and new events are captured after reload in session mode. +func TestSessionMode_ReloadPersistence(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("session") + + // Make first request + dashboard.FetchAPI("/api/test?n=1") + + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) + + // Reload page + dashboard.Reload() + + // Old events should persist + assert.Equal(t, 1, dashboard.GetEventCount()) + + // Make another request after reload - should still capture + dashboard.FetchAPI("/api/test?n=2") + + dashboard.WaitForEventCount(2, 5000) + assert.Equal(t, 2, dashboard.GetEventCount()) +} + +// TestClearEvents_SessionMode verifies that events can be cleared in session mode. +func TestClearEvents_SessionMode(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("session") + + // Create some events + for i := 0; i < 3; i++ { + _, err := dashboard.Page.Evaluate(fmt.Sprintf(`fetch('/api/test?i=%d')`, i)) + require.NoError(t, err) + } + + dashboard.WaitForEventCount(3, 5000) + assert.Equal(t, 3, dashboard.GetEventCount()) + + // Clear events + dashboard.ClearEvents() + + assert.Equal(t, 0, dashboard.GetEventCount()) +} + +// ----------------------------------------------------------------------------- +// Mode Switching Tests +// ----------------------------------------------------------------------------- + +// TestModeSwitching verifies that switching between session and global mode works correctly. +func TestModeSwitching(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx1 := pw.NewContext(t) + defer ctx1.Close() + + dashboard := NewDashboardPage(t, ctx1, app.DevlogURL) + dashboard.StartCapture("session") + + // Create a second context for external requests + ctx2 := pw.NewContext(t) + defer ctx2.Close() + + page2, err := ctx2.NewPage() + require.NoError(t, err) + + // External request in session mode - should NOT be captured + _, err = page2.Goto(app.AppURL + "/api/test?source=external1") + require.NoError(t, err) + + // Poll for 1 second to verify no events appear + dashboard.ExpectNoEvents(1 * time.Second) + + // Switch to global mode + dashboard.SwitchMode("global") + + // External request in global mode - should be captured + _, err = page2.Goto(app.AppURL + "/api/test?source=external2") + require.NoError(t, err) + + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount(), "global mode should capture external requests") +} + +// TestModeSwitchingBackToSession verifies switching from global back to session mode. +func TestModeSwitchingBackToSession(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx1 := pw.NewContext(t) + defer ctx1.Close() + + dashboard := NewDashboardPage(t, ctx1, app.DevlogURL) + dashboard.StartCapture("global") + + // Create a second context for external requests + ctx2 := pw.NewContext(t) + defer ctx2.Close() + + page2, err := ctx2.NewPage() + require.NoError(t, err) + + // External request in global mode - should be captured + _, err = page2.Goto(app.AppURL + "/api/test?source=external1") + require.NoError(t, err) + + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) + + // Clear and switch to session mode + dashboard.ClearEvents() + dashboard.SwitchMode("session") + + // External request in session mode - should NOT be captured + _, err = page2.Goto(app.AppURL + "/api/test?source=external2") + require.NoError(t, err) + + // Poll for 1 second to verify no events appear + dashboard.ExpectNoEvents(1 * time.Second) + + // Same context request should still work + dashboard.FetchAPI("/api/test?source=same") + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount(), "session mode should capture same-context requests") +} diff --git a/acceptance/dashboard_page.go b/acceptance/dashboard_page.go new file mode 100644 index 0000000..568f726 --- /dev/null +++ b/acceptance/dashboard_page.go @@ -0,0 +1,381 @@ +package acceptance + +import ( + "fmt" + "testing" + "time" + + "github.com/playwright-community/playwright-go" + "github.com/stretchr/testify/require" +) + +// DashboardPage provides helper methods for interacting with the devlog dashboard. +// It implements the Page Object pattern for cleaner test code. +type DashboardPage struct { + Page playwright.Page + DevlogURL string + SessionURL string + t *testing.T +} + +// waitForSSEConnection waits for the SSE connection on #event-list to be open. +// It polls the htmx internal data to check the EventSource readyState. +func (dp *DashboardPage) waitForSSEConnection(timeout float64) { + dp.t.Helper() + + _, err := dp.Page.WaitForFunction(`() => { + const el = document.getElementById('event-list'); + const internalData = el && el['htmx-internal-data']; + if (internalData && internalData.sseEventSource) { + return internalData.sseEventSource.readyState === 1; // OPEN + } + return false; + }`, playwright.PageWaitForFunctionOptions{ + Timeout: playwright.Float(timeout), + Polling: playwright.Float(100), + }) + require.NoError(dp.t, err, "failed to establish SSE connection") +} + +// NewDashboardPage navigates to the devlog dashboard and waits for redirect to session URL. +func NewDashboardPage(t *testing.T, ctx playwright.BrowserContext, devlogURL string) *DashboardPage { + t.Helper() + + page, err := ctx.NewPage() + require.NoError(t, err) + + _, err = page.Goto(devlogURL) + require.NoError(t, err) + + // Wait for redirect to session URL + err = page.WaitForURL("**/_devlog/s/*/", playwright.PageWaitForURLOptions{ + Timeout: playwright.Float(10000), + }) + require.NoError(t, err, "failed to redirect to session URL") + + return &DashboardPage{ + Page: page, + DevlogURL: devlogURL, + SessionURL: page.URL(), + t: t, + } +} + +// StartCapture starts capturing events in the specified mode ("session" or "global"). +func (dp *DashboardPage) StartCapture(mode string) { + dp.t.Helper() + + // If mode is global, click the Global button first (before capturing) + if mode == "global" { + globalBtn := dp.Page.Locator("button:has-text('Global')") + err := globalBtn.Click() + require.NoError(dp.t, err, "failed to click Global button") + + // Wait for mode to be set in data attribute + err = dp.Page.Locator("#capture-controls[data-mode='global']").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateAttached, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "failed to switch to global mode") + } + + // Click record button + recordBtn := dp.Page.Locator("button[title='Start capture']") + err := recordBtn.Click() + require.NoError(dp.t, err, "failed to click record button") + + // Wait for the placeholder text to disappear (indicates capture UI has loaded) + err = dp.Page.Locator("text=Click Record to start capturing events").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateHidden, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "capture UI did not load") + + dp.waitForSSEConnection(5000) +} + +// StopCapture stops capturing events. +func (dp *DashboardPage) StopCapture() { + dp.t.Helper() + + stopBtn := dp.Page.Locator("button[title='Stop capture']") + err := stopBtn.Click() + require.NoError(dp.t, err, "failed to click stop button") + + // Wait for the record button to become enabled (indicates capture stopped) + err = dp.Page.Locator("button[title='Start capture']:not([disabled])").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateVisible, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "capture did not stop") +} + +// GetEventCount returns the number of events currently shown in the event list. +func (dp *DashboardPage) GetEventCount() int { + dp.t.Helper() + + count, err := dp.Page.Locator("#event-list > li").Count() + require.NoError(dp.t, err) + return count +} + +// WaitForEventCount waits for the specified number of events to appear. +func (dp *DashboardPage) WaitForEventCount(expectedCount int, timeout float64) { + dp.t.Helper() + + if expectedCount == 0 { + // For zero events, wait for list to be empty + err := dp.Page.Locator("#event-list:empty, #event-list:not(:has(> li))").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateAttached, + Timeout: playwright.Float(timeout), + }) + // Don't fail on timeout for zero - just return and let caller check + if err != nil { + dp.t.Logf("Note: timeout waiting for empty event list (may be expected)") + } + return + } + + selector := fmt.Sprintf("#event-list > li:nth-child(%d)", expectedCount) + err := dp.Page.Locator(selector).WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateAttached, + Timeout: playwright.Float(timeout), + }) + require.NoError(dp.t, err, "failed to wait for %d events", expectedCount) +} + +// ExpectDuring repeatedly checks that the assertion function returns true +// for the entire duration. Polls every interval. Fails immediately if +// the assertion returns false at any point. +// This is useful for negative assertions where we want to verify something +// does NOT happen over a period of time. +func (dp *DashboardPage) ExpectDuring(assertion func() bool, interval time.Duration, duration time.Duration, msgAndArgs ...interface{}) { + dp.t.Helper() + + deadline := time.Now().Add(duration) + for time.Now().Before(deadline) { + if !assertion() { + if len(msgAndArgs) > 0 { + dp.t.Fatalf("assertion failed during wait: %v", msgAndArgs...) + } else { + dp.t.Fatal("assertion failed during wait") + } + } + time.Sleep(interval) + } + + // Final check + if !assertion() { + if len(msgAndArgs) > 0 { + dp.t.Fatalf("assertion failed at end of wait: %v", msgAndArgs...) + } else { + dp.t.Fatal("assertion failed at end of wait") + } + } +} + +// ExpectNoEvents verifies that no events appear during the given duration. +// Polls every 100ms and fails immediately if any event appears. +func (dp *DashboardPage) ExpectNoEvents(duration time.Duration) { + dp.t.Helper() + + dp.ExpectDuring(func() bool { + return dp.GetEventCount() == 0 + }, 100*time.Millisecond, duration, "expected no events but some appeared") +} + +// ExpectEventCountStable verifies that the event count stays at the expected value +// for the given duration. Fails immediately if the count changes. +func (dp *DashboardPage) ExpectEventCountStable(expectedCount int, duration time.Duration) { + dp.t.Helper() + + dp.ExpectDuring(func() bool { + return dp.GetEventCount() == expectedCount + }, 100*time.Millisecond, duration, "expected %d events to remain stable", expectedCount) +} + +// ClearEvents clears all events from the event list. +func (dp *DashboardPage) ClearEvents() { + dp.t.Helper() + + clearBtn := dp.Page.Locator("button[title='Clear list']") + err := clearBtn.Click() + require.NoError(dp.t, err, "failed to click clear button") + + // Wait for event list to be empty (HTMX swaps the content) + err = dp.Page.Locator("#event-list:not(:has(> li))").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateAttached, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "failed to wait for clear to complete") +} + +// SwitchMode switches the capture mode ("session" or "global") while capturing. +func (dp *DashboardPage) SwitchMode(mode string) { + dp.t.Helper() + + var btn playwright.Locator + if mode == "session" { + btn = dp.Page.Locator("button:has-text('Session')") + } else { + btn = dp.Page.Locator("button:has-text('Global')") + } + + err := btn.Click() + require.NoError(dp.t, err, "failed to switch to %s mode", mode) + + // Wait for mode to be updated in the capture controls data attribute + selector := fmt.Sprintf("#capture-controls[data-mode='%s']", mode) + err = dp.Page.Locator(selector).WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateAttached, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "failed to confirm mode switch to %s", mode) + + dp.waitForSSEConnection(5000) +} + +// ClickFirstEvent clicks on the first parent event in the event list to show its details. +// This specifically targets the parent HTTP event div, not nested child events. +func (dp *DashboardPage) ClickFirstEvent() { + dp.t.Helper() + + // Target the parent event's div directly (first div child of first li) + firstEvent := dp.Page.Locator("#event-list > li:first-child > div[id$='-item']") + err := firstEvent.Click() + require.NoError(dp.t, err, "failed to click first event") + + // Wait for event details to load + dp.WaitForEventDetails(5000) +} + +// ClickFirstChildEvent clicks on the first child event (nested inside a parent event). +func (dp *DashboardPage) ClickFirstChildEvent() { + dp.t.Helper() + + // Target child events inside the nested ul + childEvent := dp.Page.Locator("#event-list > li:first-child ul div[id$='-item']").First() + err := childEvent.Click() + require.NoError(dp.t, err, "failed to click first child event") + + // Wait for event details to load + dp.WaitForEventDetails(5000) +} + +// Reload reloads the current page and waits for SSE to reconnect if capture was active. +func (dp *DashboardPage) Reload() { + dp.t.Helper() + + // Check if capture is currently active (before reload) + stopBtn := dp.Page.Locator("button[title='Stop capture']:not([disabled])") + wasCapturing, _ := stopBtn.IsVisible() + + _, err := dp.Page.Reload() + require.NoError(dp.t, err, "failed to reload page") + + // Wait for DOM to be loaded + err = dp.Page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{ + State: playwright.LoadStateDomcontentloaded, + }) + require.NoError(dp.t, err, "failed to wait for DOM load") + + // Wait for capture controls to be present (indicates page is ready) + err = dp.Page.Locator("#capture-controls").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateVisible, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "failed to wait for page load") + + // If capture was active, wait for SSE to be connected + if wasCapturing { + // Wait for event list with SSE to be present + err = dp.Page.Locator("ul#event-list[sse-connect]").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateAttached, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "failed to find SSE element after reload") + + dp.waitForSSEConnection(5000) + } +} + +// GetEventDetailsText returns the text content of the event details panel. +func (dp *DashboardPage) GetEventDetailsText() string { + dp.t.Helper() + + details := dp.Page.Locator("#event-details") + text, err := details.TextContent() + require.NoError(dp.t, err) + return text +} + +// GetFirstEventText returns the text content of the first parent event in the list. +// This specifically targets the parent HTTP event div, not nested child events. +func (dp *DashboardPage) GetFirstEventText() string { + dp.t.Helper() + + firstEvent := dp.Page.Locator("#event-list > li:first-child > div[id$='-item']") + text, err := firstEvent.TextContent() + require.NoError(dp.t, err) + return text +} + +// WaitForEventDetails waits for the event details panel to show content. +func (dp *DashboardPage) WaitForEventDetails(timeout float64) { + dp.t.Helper() + + detailsHeader := dp.Page.Locator("#event-details h2") + err := detailsHeader.WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateVisible, + Timeout: playwright.Float(timeout), + }) + require.NoError(dp.t, err, "failed to wait for event details") +} + +// FetchAPI executes a fetch request from the browser context and waits for the response. +func (dp *DashboardPage) FetchAPI(path string) { + dp.t.Helper() + + // Wait for the fetch to complete fully (read the response body) + _, err := dp.Page.Evaluate(fmt.Sprintf(`fetch('%s').then(r => r.text())`, path)) + require.NoError(dp.t, err) +} + +// FetchAPIWithBody executes a POST fetch request with JSON body from the browser context. +func (dp *DashboardPage) FetchAPIWithBody(path string, body string) { + dp.t.Helper() + + js := fmt.Sprintf(`fetch('%s', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(%s) + })`, path, body) + + _, err := dp.Page.Evaluate(js) + require.NoError(dp.t, err) +} + +// GetUsagePanelText returns the text content of the usage panel. +func (dp *DashboardPage) GetUsagePanelText() string { + dp.t.Helper() + + usagePanel := dp.Page.Locator("#usage-panel") + text, err := usagePanel.TextContent() + require.NoError(dp.t, err) + return text +} + +// WaitForUsagePanel waits for the usage panel content to load (not show "Loading..."). +func (dp *DashboardPage) WaitForUsagePanel(timeout float64) { + dp.t.Helper() + + // Wait for the usage panel to contain actual content (not "Loading...") + _, err := dp.Page.WaitForFunction(`() => { + const el = document.getElementById('usage-panel'); + return el && !el.textContent.includes('Loading'); + }`, playwright.PageWaitForFunctionOptions{ + Timeout: playwright.Float(timeout), + Polling: playwright.Float(100), + }) + require.NoError(dp.t, err, "usage panel did not load content") +} diff --git a/acceptance/dashboard_test.go b/acceptance/dashboard_test.go new file mode 100644 index 0000000..0ca87cb --- /dev/null +++ b/acceptance/dashboard_test.go @@ -0,0 +1,277 @@ +package acceptance + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/playwright-community/playwright-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDashboardAccess verifies that the dashboard is accessible and redirects to a session URL. +func TestDashboardAccess(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + page, err := ctx.NewPage() + require.NoError(t, err) + + _, err = page.Goto(app.DevlogURL) + require.NoError(t, err) + + // Verify redirect to session URL + err = page.WaitForURL("**/_devlog/s/*/", playwright.PageWaitForURLOptions{ + Timeout: playwright.Float(5000), + }) + require.NoError(t, err) + + url := page.URL() + assert.Regexp(t, `/_devlog/s/[a-f0-9-]+/$`, url) + + // Verify dashboard elements present + captureControls := page.Locator("#capture-controls") + visible, err := captureControls.IsVisible() + require.NoError(t, err) + assert.True(t, visible, "capture controls should be visible") +} + +// TestSSERealTimeUpdates verifies that events appear in real-time via SSE. +func TestSSERealTimeUpdates(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Make requests via Go HTTP client (external to browser) + for i := 0; i < 3; i++ { + go func(n int) { + http.Get(app.AppURL + fmt.Sprintf("/api/test?i=%d", n)) + }(i) + } + + // Events should appear via SSE without page refresh + dashboard.WaitForEventCount(3, 10000) + assert.Equal(t, 3, dashboard.GetEventCount()) +} + +// TestEventDetailsPanel verifies that clicking an event shows its details. +func TestEventDetailsPanel(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Make a POST request with body + dashboard.FetchAPIWithBody("/api/echo", `{"test": "data"}`) + dashboard.WaitForEventCount(1, 5000) + + // Click the event to see details + dashboard.ClickFirstEvent() + dashboard.WaitForEventDetails(5000) + + // Verify the path is shown in details + text := dashboard.GetEventDetailsText() + assert.Contains(t, text, "/api/echo") +} + +// TestStopCapture verifies that stopping capture prevents new events from being recorded. +func TestStopCapture(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Make a request while capturing (from browser to ensure cookie/context) + dashboard.FetchAPI("/api/test?before=stop") + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) + + // Stop capture + dashboard.StopCapture() + + // Make another request after stopping (from browser) + dashboard.FetchAPI("/api/test?after=stop") + + // Poll for 2 seconds to verify event count stays at 1 (no new events) + dashboard.ExpectEventCountStable(1, 2*time.Second) +} + +// TestMultipleConcurrentRequests verifies that multiple concurrent requests are all captured. +func TestMultipleConcurrentRequests(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Fire multiple concurrent requests from browser + _, err := dashboard.Page.Evaluate(` + Promise.all([ + fetch('/api/test?n=1'), + fetch('/api/test?n=2'), + fetch('/api/test?n=3'), + fetch('/api/test?n=4'), + fetch('/api/test?n=5') + ]) + `) + require.NoError(t, err) + + // All should appear + dashboard.WaitForEventCount(5, 10000) + assert.Equal(t, 5, dashboard.GetEventCount()) +} + +// TestLargeRequestBody verifies that large request bodies are captured. +func TestLargeRequestBody(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Create a request with larger body + _, err := dashboard.Page.Evaluate(` + const largeData = 'x'.repeat(10000); + fetch('/api/echo', { + method: 'POST', + headers: {'Content-Type': 'text/plain'}, + body: largeData + }) + `) + require.NoError(t, err) + + dashboard.WaitForEventCount(1, 5000) + assert.Equal(t, 1, dashboard.GetEventCount()) +} + +// TestNewSessionAfterBrowserClose verifies that closing and reopening creates a new session. +func TestNewSessionAfterBrowserClose(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + // First session + ctx1 := pw.NewContext(t) + dashboard1 := NewDashboardPage(t, ctx1, app.DevlogURL) + firstSessionURL := dashboard1.SessionURL + + dashboard1.StartCapture("global") + dashboard1.FetchAPI("/api/test") + dashboard1.WaitForEventCount(1, 5000) + ctx1.Close() + + // Second session - should get new session URL + ctx2 := pw.NewContext(t) + defer ctx2.Close() + + dashboard2 := NewDashboardPage(t, ctx2, app.DevlogURL) + secondSessionURL := dashboard2.SessionURL + + assert.NotEqual(t, firstSessionURL, secondSessionURL, "should get new session URL") + + // Second session should start fresh + assert.Equal(t, 0, dashboard2.GetEventCount(), "new session should have no events") +} + +// TestUsagePanel verifies that the usage panel shows memory and session stats. +func TestUsagePanel(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + + // Wait for usage panel to load + dashboard.WaitForUsagePanel(5000) + + // Check that usage panel is visible and has content + usageText := dashboard.GetUsagePanelText() + assert.NotEmpty(t, usageText, "usage panel should have content") + + // Should show memory usage (contains "B" for bytes suffix) + assert.Contains(t, usageText, "B", "usage panel should show memory stats") + + // Should show session count (at least 1 session - the current one) + assert.Regexp(t, `\d`, usageText, "usage panel should show session count") + + // Start capture and make some requests + dashboard.StartCapture("global") + + // Make requests to generate events with body content + dashboard.FetchAPIWithBody("/api/echo", `{"test": "data with some content"}`) + dashboard.WaitForEventCount(1, 5000) + + // Wait for stats to refresh (polls every 5 seconds, but we wait for first poll) + time.Sleep(1 * time.Second) + + // Check that memory increased (panel should show non-zero memory) + // Just verify the panel is still showing valid stats + updatedText := dashboard.GetUsagePanelText() + assert.NotEmpty(t, updatedText, "usage panel should still have content after events") + assert.Contains(t, updatedText, "B", "usage panel should still show memory stats") +} diff --git a/acceptance/event_types_test.go b/acceptance/event_types_test.go new file mode 100644 index 0000000..03c4823 --- /dev/null +++ b/acceptance/event_types_test.go @@ -0,0 +1,255 @@ +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// ----------------------------------------------------------------------------- +// HTTP Server Event Tests +// ----------------------------------------------------------------------------- + +// TestHTTPServerEvents verifies that HTTP server request events are displayed correctly. +func TestHTTPServerEvents(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Make HTTP server request + dashboard.FetchAPI("/api/test") + + dashboard.WaitForEventCount(1, 5000) + + // Verify event appears with correct info + text := dashboard.GetFirstEventText() + assert.Contains(t, text, "GET") + assert.Contains(t, text, "/api/test") +} + +// TestHTTPServerEventWithPOST verifies POST requests are displayed correctly. +func TestHTTPServerEventWithPOST(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Make POST request + dashboard.FetchAPIWithBody("/api/echo", `{"message": "hello"}`) + + dashboard.WaitForEventCount(1, 5000) + + // Verify POST is shown + text := dashboard.GetFirstEventText() + assert.Contains(t, text, "POST") + assert.Contains(t, text, "/api/echo") +} + +// ----------------------------------------------------------------------------- +// HTTP Client Event Tests +// ----------------------------------------------------------------------------- + +// TestHTTPClientEvents verifies that outgoing HTTP client requests are captured. +func TestHTTPClientEvents(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Trigger endpoint that makes HTTP client request + dashboard.FetchAPI("/api/external") + + // Wait for the HTTP server event (the endpoint response) + dashboard.WaitForEventCount(1, 5000) + + // Click to see details - the HTTP client request should be nested + dashboard.ClickFirstEvent() + dashboard.WaitForEventDetails(5000) + + text := dashboard.GetEventDetailsText() + assert.Contains(t, text, "/api/external") +} + +// ----------------------------------------------------------------------------- +// Database Query Event Tests +// ----------------------------------------------------------------------------- + +// TestDBQueryEvents verifies that database query events are captured. +func TestDBQueryEvents(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Trigger endpoint that creates DB query event + dashboard.FetchAPI("/api/db") + + // Should have HTTP request event which contains DB query as child + dashboard.WaitForEventCount(1, 5000) + + // Click to see details + dashboard.ClickFirstEvent() + dashboard.WaitForEventDetails(5000) + + // The parent HTTP event details should be shown + text := dashboard.GetEventDetailsText() + assert.Contains(t, text, "/api/db") +} + +// ----------------------------------------------------------------------------- +// Log Event Tests +// ----------------------------------------------------------------------------- + +// TestLogEvents verifies that slog entries appear in the event list. +func TestLogEvents(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Trigger endpoint that creates log events + dashboard.FetchAPI("/api/log") + + // HTTP server event should appear (logs are nested inside) + dashboard.WaitForEventCount(1, 5000) + + // Verify the first event contains the log endpoint + text := dashboard.GetFirstEventText() + assert.Contains(t, text, "/api/log") +} + +// ----------------------------------------------------------------------------- +// Nested Event Tests +// ----------------------------------------------------------------------------- + +// TestNestedEvents verifies that parent-child event relationships are displayed correctly. +func TestNestedEvents(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Trigger endpoint that creates multiple nested events (logs + DB query) + dashboard.FetchAPI("/api/combined") + + // Wait for the parent HTTP event + dashboard.WaitForEventCount(1, 5000) + + // Verify the first event contains the combined endpoint + text := dashboard.GetFirstEventText() + assert.Contains(t, text, "/api/combined") +} + +// TestNestedEventsShowChildren verifies that nested events are visible in the UI. +func TestNestedEventsShowChildren(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Trigger endpoint that creates multiple nested events + dashboard.FetchAPI("/api/combined") + + dashboard.WaitForEventCount(1, 5000) + + // The event list item should indicate it has children + // Look for the event item and check if there's visual indication of nested events + text := dashboard.GetFirstEventText() + // The parent HTTP event should be shown + assert.Contains(t, text, "/api/combined") +} + +// ----------------------------------------------------------------------------- +// Multiple Event Types Together +// ----------------------------------------------------------------------------- + +// TestMixedEventTypes verifies that different event types are all captured and displayed. +func TestMixedEventTypes(t *testing.T) { + t.Parallel() + + app := NewTestApp(t) + defer app.Close() + + pw := NewPlaywrightFixture(t) + defer pw.Close() + + ctx := pw.NewContext(t) + defer ctx.Close() + + dashboard := NewDashboardPage(t, ctx, app.DevlogURL) + dashboard.StartCapture("global") + + // Trigger multiple endpoints that create different event types + dashboard.FetchAPI("/api/test") // Simple HTTP + dashboard.FetchAPI("/api/log") // HTTP + logs + dashboard.FetchAPI("/api/db") // HTTP + DB query + dashboard.FetchAPI("/api/combined") // HTTP + logs + DB + + // Should have 4 top-level HTTP events + dashboard.WaitForEventCount(4, 10000) + assert.Equal(t, 4, dashboard.GetEventCount()) +} diff --git a/acceptance/go.mod b/acceptance/go.mod new file mode 100644 index 0000000..a465c02 --- /dev/null +++ b/acceptance/go.mod @@ -0,0 +1,51 @@ +module github.com/networkteam/devlog/acceptance + +go 1.23.8 + +require ( + github.com/networkteam/devlog v0.0.0 + github.com/playwright-community/playwright-go v0.5200.1 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/a-h/templ v0.3.865 // indirect + github.com/alecthomas/chroma/v2 v2.17.2 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/apex/log v1.9.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deckarep/golang-set/v2 v2.7.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-stack/stack v1.8.1 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/natefinch/atomic v1.0.1 // indirect + github.com/networkteam/refresh v1.15.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/r3labs/sse/v2 v2.10.0 // indirect + github.com/rjeczalik/notify v0.9.3 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/samber/lo v1.50.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/tools v0.33.0 // indirect + gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/networkteam/devlog => ../ diff --git a/acceptance/go.sum b/acceptance/go.sum new file mode 100644 index 0000000..086291f --- /dev/null +++ b/acceptance/go.sum @@ -0,0 +1,201 @@ +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= +github.com/a-h/templ v0.3.865 h1:nYn5EWm9EiXaDgWcMQaKiKvrydqgxDUtT1+4zU2C43A= +github.com/a-h/templ v0.3.865/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI= +github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0= +github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA= +github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= +github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= +github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= +github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.7.0 h1:gIloKvD7yH2oip4VLhsv3JyLLFnC0Y2mlusgcvJYW5k= +github.com/deckarep/golang-set/v2 v2.7.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/networkteam/refresh v1.15.0 h1:8qobXuU29Ic08WkJC+lt4GHtoe9gDHTSEmgBqG5S0rg= +github.com/networkteam/refresh v1.15.0/go.mod h1:J6iKbX8RO9eERGJ5yYm0gXQLy5MrsgtwiQJF7K80uoE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/playwright-community/playwright-go v0.5200.1 h1:Sm2oOuhqt0M5Y4kUi/Qh9w4cyyi3ZIWTBeGKImc2UVo= +github.com/playwright-community/playwright-go v0.5200.1/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= +github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= +github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= +github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= +github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= +github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= +github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc= +github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= +github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= +github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= +gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/acceptance/playwright.go b/acceptance/playwright.go new file mode 100644 index 0000000..facdd18 --- /dev/null +++ b/acceptance/playwright.go @@ -0,0 +1,47 @@ +package acceptance + +import ( + "os" + "testing" + + "github.com/playwright-community/playwright-go" + "github.com/stretchr/testify/require" +) + +// PlaywrightFixture manages Playwright browser instances for tests. +type PlaywrightFixture struct { + PW *playwright.Playwright + Browser playwright.Browser +} + +// NewPlaywrightFixture creates a new Playwright fixture with a Chromium browser. +// Set HEADLESS=false environment variable to run with visible browser for debugging. +func NewPlaywrightFixture(t *testing.T) *PlaywrightFixture { + t.Helper() + + pw, err := playwright.Run() + require.NoError(t, err, "failed to start playwright") + + headless := os.Getenv("HEADLESS") != "false" + browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ + Headless: playwright.Bool(headless), + }) + require.NoError(t, err, "failed to launch browser") + + return &PlaywrightFixture{PW: pw, Browser: browser} +} + +// NewContext creates a new browser context with isolated cookies and storage. +// Each context is independent, useful for testing session isolation. +func (pf *PlaywrightFixture) NewContext(t *testing.T) playwright.BrowserContext { + t.Helper() + ctx, err := pf.Browser.NewContext() + require.NoError(t, err, "failed to create browser context") + return ctx +} + +// Close releases all Playwright resources. +func (pf *PlaywrightFixture) Close() { + pf.Browser.Close() + pf.PW.Stop() +} diff --git a/acceptance/testapp.go b/acceptance/testapp.go new file mode 100644 index 0000000..67147ba --- /dev/null +++ b/acceptance/testapp.go @@ -0,0 +1,150 @@ +package acceptance + +import ( + "database/sql/driver" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/networkteam/devlog" + "github.com/networkteam/devlog/collector" +) + +// TestApp represents a test application with devlog fully integrated. +// It provides endpoints that generate all event types for testing. +type TestApp struct { + Server *httptest.Server + DevlogURL string + AppURL string + Devlog *devlog.Instance + HTTPClient *http.Client + Logger *slog.Logger +} + +// NewTestApp creates a new test application with devlog fully integrated. +// It sets up endpoints for all event types: HTTP server, HTTP client, DB queries, and logs. +func NewTestApp(t *testing.T) *TestApp { + t.Helper() + + dlog := devlog.NewWithOptions(devlog.Options{ + HTTPServerOptions: &collector.HTTPServerOptions{ + MaxBodySize: 1024 * 1024, + CaptureRequestBody: true, + CaptureResponseBody: true, + }, + HTTPClientOptions: &collector.HTTPClientOptions{ + MaxBodySize: 1024 * 1024, + CaptureRequestBody: true, + CaptureResponseBody: true, + }, + }) + + // Create an HTTP client that collects requests + httpClient := &http.Client{ + Transport: dlog.CollectHTTPClient(http.DefaultTransport), + Timeout: 5 * time.Second, + } + + // Create a logger that collects logs + logger := slog.New(dlog.CollectSlogLogs(collector.CollectSlogLogsOptions{ + Level: slog.LevelDebug, + })) + + // Create DB query collector + collectDBQuery := dlog.CollectDBQuery() + + mux := http.NewServeMux() + + // Test endpoint: simple JSON response (HTTP server event) + mux.HandleFunc("GET /api/test", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + }) + + // Test endpoint: echo request body (HTTP server event) + mux.HandleFunc("POST /api/echo", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", r.Header.Get("Content-Type")) + w.WriteHeader(http.StatusOK) + w.Write(body) + }) + + // Test endpoint: makes HTTP client request (HTTP client event) + mux.HandleFunc("GET /api/external", func(w http.ResponseWriter, r *http.Request) { + // Make request to a mock endpoint - this will fail but still generate an event + resp, err := httpClient.Get("http://example.com/external-api") + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"external":"attempted"}`)) + return + } + defer resp.Body.Close() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"external":"called"}`)) + }) + + // Test endpoint: simulates DB query (DB query event) + mux.HandleFunc("GET /api/db", func(w http.ResponseWriter, r *http.Request) { + // Simulate a DB query event + collectDBQuery(r.Context(), collector.DBQuery{ + Query: "SELECT * FROM users WHERE id = $1", + Args: []driver.NamedValue{{Ordinal: 1, Value: 1}}, + Duration: 5 * time.Millisecond, + Language: "postgresql", + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"users":[{"id":1,"name":"Test User"}]}`)) + }) + + // Test endpoint: writes slog entries (log event) + mux.HandleFunc("GET /api/log", func(w http.ResponseWriter, r *http.Request) { + logger.InfoContext(r.Context(), "Test log message", slog.String("key", "value")) + logger.DebugContext(r.Context(), "Debug message", slog.Int("count", 42)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"logged":true}`)) + }) + + // Test endpoint: combined events (nested events) + mux.HandleFunc("GET /api/combined", func(w http.ResponseWriter, r *http.Request) { + logger.InfoContext(r.Context(), "Starting combined operation") + collectDBQuery(r.Context(), collector.DBQuery{ + Query: "INSERT INTO logs (message) VALUES ($1)", + Args: []driver.NamedValue{{Ordinal: 1, Value: "combined test"}}, + Duration: 2 * time.Millisecond, + Language: "postgresql", + }) + logger.InfoContext(r.Context(), "Combined operation complete") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"combined":true}`)) + }) + + outerMux := http.NewServeMux() + outerMux.Handle("/", dlog.CollectHTTPServer(mux)) + outerMux.Handle("/_devlog/", http.StripPrefix("/_devlog", dlog.DashboardHandler("/_devlog"))) + + server := httptest.NewServer(outerMux) + + return &TestApp{ + Server: server, + DevlogURL: server.URL + "/_devlog/", + AppURL: server.URL, + Devlog: dlog, + HTTPClient: httpClient, + Logger: logger, + } +} + +// Close shuts down the test application and releases resources. +func (ta *TestApp) Close() { + ta.Devlog.Close() + ta.Server.Close() +} diff --git a/collector/body.go b/collector/body.go index bced122..5c41230 100644 --- a/collector/body.go +++ b/collector/body.go @@ -142,12 +142,12 @@ func (b *Body) Bytes() []byte { } // Size returns the captured size of the body -func (b *Body) Size() int64 { +func (b *Body) Size() uint64 { if b == nil || b.buffer == nil { return 0 } - return int64(b.buffer.Len()) + return uint64(b.buffer.Len()) } // IsTruncated returns true if the body was truncated diff --git a/collector/body_test.go b/collector/body_test.go index 1d560ff..5dbb5cd 100644 --- a/collector/body_test.go +++ b/collector/body_test.go @@ -29,7 +29,7 @@ func TestBody_PartialRead(t *testing.T) { // Verify partial capture assert.Equal(t, "This is te", body.String()) - assert.Equal(t, int64(10), body.Size()) + assert.Equal(t, uint64(10), body.Size()) assert.False(t, body.IsFullyCaptured()) // Close without reading the rest @@ -38,7 +38,7 @@ func TestBody_PartialRead(t *testing.T) { // Now the body should have captured everything assert.Equal(t, testData, body.String()) - assert.Equal(t, int64(len(testData)), body.Size()) + assert.Equal(t, uint64(len(testData)), body.Size()) // The isFullyCaptured flag should be set to true after Close() assert.True(t, body.IsFullyCaptured()) diff --git a/collector/collector_test.go b/collector/collector_test.go new file mode 100644 index 0000000..d0a826b --- /dev/null +++ b/collector/collector_test.go @@ -0,0 +1,83 @@ +package collector_test + +import ( + "context" + "sync" + "testing" + "time" +) + +// TestCollector collects items from a subscription channel for testing. +// This is a test helper that should only be used in tests. +type TestCollector[T any] struct { + t testing.TB + items []T + cancel func() + mu sync.Mutex + done chan struct{} // signals when collector goroutine finishes +} + +// Collect starts collecting from a subscription. +// Use Wait(n) to block until n items are received or timeout. +func Collect[T any](t testing.TB, subscribe func(context.Context) <-chan T) *TestCollector[T] { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + ch := subscribe(ctx) + + c := &TestCollector[T]{ + t: t, + cancel: cancel, + done: make(chan struct{}), + } + + // Collect items in background + go func() { + defer close(c.done) + for item := range ch { + c.mu.Lock() + c.items = append(c.items, item) + c.mu.Unlock() + } + }() + + return c +} + +// Stop cancels collection and returns items collected so far. +// It waits for the collector goroutine to finish to ensure all items are received. +func (c *TestCollector[T]) Stop() []T { + c.cancel() + <-c.done // Wait for collector goroutine to finish + c.mu.Lock() + defer c.mu.Unlock() + items := make([]T, len(c.items)) + copy(items, c.items) + return items +} + +// Wait blocks until at least n items are collected or timeout (3 seconds). +// Returns the collected items after stopping collection. +func (c *TestCollector[T]) Wait(n int) []T { + c.t.Helper() + + timeout := 3 * time.Second + deadline := time.Now().Add(timeout) + + for { + c.mu.Lock() + count := len(c.items) + c.mu.Unlock() + + if count >= n { + break + } + + if time.Now().After(deadline) { + c.t.Fatalf("Timeout waiting for %d items, got %d", n, count) + } + + time.Sleep(10 * time.Millisecond) + } + + return c.Stop() +} diff --git a/collector/db_query_collector.go b/collector/db_query_collector.go index 1e2e4df..3400867 100644 --- a/collector/db_query_collector.go +++ b/collector/db_query_collector.go @@ -22,27 +22,26 @@ type DBQuery struct { Error error } +// Size returns the estimated memory size of this query in bytes +func (q DBQuery) Size() uint64 { + size := uint64(100) // base struct overhead + size += uint64(len(q.Query)) + size += uint64(len(q.Language)) + // Estimate 50 bytes per arg (name + value) + size += uint64(len(q.Args) * 50) + return size +} + type DBQueryCollector struct { - buffer *RingBuffer[DBQuery] - notifier *Notifier[DBQuery] - eventCollector *EventCollector + notifier *Notifier[DBQuery] + eventAggregator *EventAggregator } func (c *DBQueryCollector) Collect(ctx context.Context, query DBQuery) { - if c.buffer != nil { - c.buffer.Add(query) - } c.notifier.Notify(query) - if c.eventCollector != nil { - c.eventCollector.CollectEvent(ctx, query) - } -} - -func (c *DBQueryCollector) Tail(n int) []DBQuery { - if c.buffer == nil { - return nil + if c.eventAggregator != nil { + c.eventAggregator.CollectEvent(ctx, query) } - return c.buffer.GetRecords(uint64(n)) } // Subscribe returns a channel that receives notifications of new query records @@ -54,37 +53,31 @@ type DBQueryOptions struct { // NotifierOptions are options for notification about new queries NotifierOptions *NotifierOptions - // EventCollector is an optional event collector for collecting logs as grouped events - EventCollector *EventCollector + // EventAggregator is the aggregator for collecting queries as grouped events + EventAggregator *EventAggregator } func DefaultDBQueryOptions() DBQueryOptions { return DBQueryOptions{} } -func NewDBQueryCollector(capacity uint64) *DBQueryCollector { - return NewDBQueryCollectorWithOptions(capacity, DefaultDBQueryOptions()) +func NewDBQueryCollector() *DBQueryCollector { + return NewDBQueryCollectorWithOptions(DefaultDBQueryOptions()) } -func NewDBQueryCollectorWithOptions(capacity uint64, options DBQueryOptions) *DBQueryCollector { +func NewDBQueryCollectorWithOptions(options DBQueryOptions) *DBQueryCollector { notifierOptions := DefaultNotifierOptions() if options.NotifierOptions != nil { notifierOptions = *options.NotifierOptions } - collector := &DBQueryCollector{ - notifier: NewNotifierWithOptions[DBQuery](notifierOptions), - eventCollector: options.EventCollector, + return &DBQueryCollector{ + notifier: NewNotifierWithOptions[DBQuery](notifierOptions), + eventAggregator: options.EventAggregator, } - if capacity > 0 { - collector.buffer = NewRingBuffer[DBQuery](capacity) - } - - return collector } // Close releases resources used by the collector func (c *DBQueryCollector) Close() { c.notifier.Close() - c.buffer = nil } diff --git a/collector/event.go b/collector/event.go new file mode 100644 index 0000000..3c3a399 --- /dev/null +++ b/collector/event.go @@ -0,0 +1,67 @@ +package collector + +import ( + "iter" + "time" + + "github.com/gofrs/uuid" +) + +// Sizer is implemented by event data types to report their memory size +type Sizer interface { + Size() uint64 +} + +type Event struct { + ID uuid.UUID + + GroupID *uuid.UUID + + Data any + + Start time.Time + End time.Time + + // Children is a slice of events that are children of this event + Children []*Event + + // Size is the calculated memory size of this event (excluding children) + Size uint64 +} + +// calculateSize computes the memory size of this event (excluding children) +func (e *Event) calculateSize() uint64 { + const baseEventSize = 100 // UUID, pointers, time.Time fields, slice header + size := uint64(baseEventSize) + if sizer, ok := e.Data.(Sizer); ok { + size += sizer.Size() + } + return size +} + +func (e *Event) Visit() iter.Seq2[uuid.UUID, *Event] { + return func(yield func(uuid.UUID, *Event) bool) { + e.visitInternal(yield) + } +} + +func (e *Event) visitInternal(yield func(uuid.UUID, *Event) bool) bool { + if !yield(e.ID, e) { + return false + } + for _, child := range e.Children { + if !child.visitInternal(yield) { + return false + } + } + return true +} + +func (e *Event) free() { + if freer, ok := e.Data.(interface{ free() }); ok { + freer.free() + } + for _, child := range e.Children { + child.free() + } +} diff --git a/collector/event_aggregator.go b/collector/event_aggregator.go new file mode 100644 index 0000000..a307949 --- /dev/null +++ b/collector/event_aggregator.go @@ -0,0 +1,211 @@ +package collector + +import ( + "context" + "sync" + "time" + + "github.com/gofrs/uuid" +) + +// EventAggregator coordinates event collection and dispatches events to registered storages. +// It does not store events itself - each storage has its own buffer. +type EventAggregator struct { + storages map[uuid.UUID]EventStorage + openGroups map[uuid.UUID]*Event + + mu sync.RWMutex +} + +// NewEventAggregator creates a new EventAggregator. +func NewEventAggregator() *EventAggregator { + return &EventAggregator{ + storages: make(map[uuid.UUID]EventStorage), + openGroups: make(map[uuid.UUID]*Event), + } +} + +// RegisterStorage registers a storage with the aggregator. +func (a *EventAggregator) RegisterStorage(storage EventStorage) { + a.mu.Lock() + defer a.mu.Unlock() + a.storages[storage.ID()] = storage +} + +// UnregisterStorage removes a storage from the aggregator. +func (a *EventAggregator) UnregisterStorage(id uuid.UUID) { + a.mu.Lock() + defer a.mu.Unlock() + delete(a.storages, id) +} + +// GetStorage returns a storage by ID, or nil if not found. +func (a *EventAggregator) GetStorage(id uuid.UUID) EventStorage { + a.mu.RLock() + defer a.mu.RUnlock() + return a.storages[id] +} + +// ShouldCapture returns true if any registered storage wants to capture events for the given context. +func (a *EventAggregator) ShouldCapture(ctx context.Context) bool { + a.mu.RLock() + defer a.mu.RUnlock() + + for _, storage := range a.storages { + if storage.ShouldCapture(ctx) { + return true + } + } + return false +} + +// StartEvent starts a new event and returns a new context with the group ID. +// Child events collected with this context will be grouped under this event. +// Call EndEvent to finish the event. +func (a *EventAggregator) StartEvent(ctx context.Context) context.Context { + eventID := uuid.Must(uuid.NewV7()) + + a.mu.Lock() + defer a.mu.Unlock() + + evt := &Event{ + ID: eventID, + Start: time.Now(), + } + + // Check if there's an outer group + outerGroupID, ok := groupIDFromContext(ctx) + if ok { + evt.GroupID = &outerGroupID + } + + a.openGroups[eventID] = evt + + return withGroupID(ctx, eventID) +} + +// EndEvent finishes an event started with StartEvent and dispatches it to matching storages. +func (a *EventAggregator) EndEvent(ctx context.Context, data any) { + groupID, ok := groupIDFromContext(ctx) + if !ok { + return + } + + a.mu.Lock() + defer a.mu.Unlock() + + evt := a.openGroups[groupID] + if evt == nil { + return + } + + evt.Data = data + evt.End = time.Now() + evt.Size = evt.calculateSize() + + // Link to parent if exists + if evt.GroupID != nil { + parentEvt := a.openGroups[*evt.GroupID] + if parentEvt != nil { + parentEvt.Children = append(parentEvt.Children, evt) + } + } + + delete(a.openGroups, groupID) + + // Only dispatch top-level events to storages + if evt.GroupID == nil { + a.dispatchToStorages(ctx, evt) + } +} + +// CollectEvent creates and immediately completes an event, dispatching to matching storages. +func (a *EventAggregator) CollectEvent(ctx context.Context, data any) { + eventID := uuid.Must(uuid.NewV7()) + now := time.Now() + + a.mu.Lock() + defer a.mu.Unlock() + + evt := &Event{ + ID: eventID, + Data: data, + Start: now, + End: now, + } + evt.Size = evt.calculateSize() + + // Check if there's a parent group + outerGroupID, ok := groupIDFromContext(ctx) + if ok { + evt.GroupID = &outerGroupID + parentEvt := a.openGroups[outerGroupID] + if parentEvt != nil { + parentEvt.Children = append(parentEvt.Children, evt) + } + } + + // Only dispatch top-level events to storages + if evt.GroupID == nil { + a.dispatchToStorages(ctx, evt) + } +} + +// dispatchToStorages sends the event to all storages that want to capture it. +// Must be called with lock held. +func (a *EventAggregator) dispatchToStorages(ctx context.Context, evt *Event) { + for _, storage := range a.storages { + if storage.ShouldCapture(ctx) { + storage.Add(evt) + } + } +} + +// Close releases resources used by the aggregator. +func (a *EventAggregator) Close() { + a.mu.Lock() + defer a.mu.Unlock() + + for _, storage := range a.storages { + storage.Close() + } + a.storages = make(map[uuid.UUID]EventStorage) + a.openGroups = make(map[uuid.UUID]*Event) +} + +// Stats holds aggregated statistics across all storages +type Stats struct { + TotalMemory uint64 + EventCount int + StorageCount int +} + +// CalculateStats computes stats across all storages, de-duplicating events by ID +func (a *EventAggregator) CalculateStats() Stats { + a.mu.RLock() + defer a.mu.RUnlock() + + seen := make(map[uuid.UUID]struct{}) + var totalMemory uint64 + + for _, storage := range a.storages { + // Get all events from storage (use a large limit to get all) + events := storage.GetEvents(100000) + for _, event := range events { + if _, exists := seen[event.ID]; !exists { + seen[event.ID] = struct{}{} + totalMemory += event.Size + // Add children sizes + for _, child := range event.Children { + totalMemory += child.Size + } + } + } + } + + return Stats{ + TotalMemory: totalMemory, + EventCount: len(seen), + StorageCount: len(a.storages), + } +} diff --git a/collector/event_aggregator_test.go b/collector/event_aggregator_test.go new file mode 100644 index 0000000..024c208 --- /dev/null +++ b/collector/event_aggregator_test.go @@ -0,0 +1,412 @@ +package collector_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/networkteam/devlog/collector" +) + +func TestEventAggregator_ShouldCapture_NoStorages(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + ctx := context.Background() + + assert.False(t, aggregator.ShouldCapture(ctx)) +} + +func TestEventAggregator_ShouldCapture_SessionModeMatch(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + aggregator.RegisterStorage(storage) + + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{sessionID}) + + assert.True(t, aggregator.ShouldCapture(ctx)) +} + +func TestEventAggregator_ShouldCapture_SessionModeNoMatch(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + otherSessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + aggregator.RegisterStorage(storage) + + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{otherSessionID}) + + assert.False(t, aggregator.ShouldCapture(ctx)) +} + +func TestEventAggregator_ShouldCapture_GlobalMode(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + // Should return true for any context + assert.True(t, aggregator.ShouldCapture(context.Background())) + + // Even with a different session ID + otherSessionID := uuid.Must(uuid.NewV4()) + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{otherSessionID}) + assert.True(t, aggregator.ShouldCapture(ctx)) +} + +func TestEventAggregator_CollectEvent_DispatchesToMatchingStorages(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionA := uuid.Must(uuid.NewV4()) + sessionB := uuid.Must(uuid.NewV4()) + + storageA := collector.NewCaptureStorage(sessionA, 100, collector.CaptureModeSession) + storageB := collector.NewCaptureStorage(sessionB, 100, collector.CaptureModeGlobal) + + aggregator.RegisterStorage(storageA) + aggregator.RegisterStorage(storageB) + + // Event with session A should go to both storages + // (A matches session, B is global) + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{sessionA}) + aggregator.CollectEvent(ctx, "test event") + + eventsA := storageA.GetEvents(10) + eventsB := storageB.GetEvents(10) + + require.Len(t, eventsA, 1) + require.Len(t, eventsB, 1) + assert.Equal(t, "test event", eventsA[0].Data) + assert.Equal(t, "test event", eventsB[0].Data) +} + +func TestEventAggregator_CollectEvent_MultipleGlobalStorages(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionA := uuid.Must(uuid.NewV4()) + sessionB := uuid.Must(uuid.NewV4()) + + storageA := collector.NewCaptureStorage(sessionA, 100, collector.CaptureModeGlobal) + storageB := collector.NewCaptureStorage(sessionB, 100, collector.CaptureModeGlobal) + + aggregator.RegisterStorage(storageA) + aggregator.RegisterStorage(storageB) + + // Event should go to both global storages + aggregator.CollectEvent(context.Background(), "test event") + + eventsA := storageA.GetEvents(10) + eventsB := storageB.GetEvents(10) + + require.Len(t, eventsA, 1) + require.Len(t, eventsB, 1) +} + +func TestEventAggregator_CollectEvent_NoCapture_NoDispatch(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionA := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionA, 100, collector.CaptureModeSession) + aggregator.RegisterStorage(storage) + + // Event with different session should not be captured + otherSessionID := uuid.Must(uuid.NewV4()) + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{otherSessionID}) + aggregator.CollectEvent(ctx, "test event") + + events := storage.GetEvents(10) + assert.Len(t, events, 0) +} + +func TestEventAggregator_RegisterUnregister_Storage(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + + // Register + aggregator.RegisterStorage(storage) + assert.True(t, aggregator.ShouldCapture(context.Background())) + + // Get storage + retrieved := aggregator.GetStorage(storage.ID()) + assert.NotNil(t, retrieved) + assert.Equal(t, storage.ID(), retrieved.ID()) + + // Unregister + aggregator.UnregisterStorage(storage.ID()) + assert.False(t, aggregator.ShouldCapture(context.Background())) + assert.Nil(t, aggregator.GetStorage(storage.ID())) +} + +func TestEventAggregator_StartEndEvent_WithCapture(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + ctx := context.Background() + ctx = aggregator.StartEvent(ctx) + + time.Sleep(10 * time.Millisecond) + + aggregator.EndEvent(ctx, "test event") + + events := storage.GetEvents(10) + require.Len(t, events, 1) + assert.Equal(t, "test event", events[0].Data) + assert.True(t, events[0].End.After(events[0].Start)) +} + +func TestEventAggregator_StartEndEvent_NoCapture(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + // No storages registered + + ctx := context.Background() + ctx = aggregator.StartEvent(ctx) + aggregator.EndEvent(ctx, "test event") + + // Can't verify no events stored since there's no storage + // but at least it shouldn't panic + assert.False(t, aggregator.ShouldCapture(ctx)) +} + +func TestEventAggregator_NestedEvents_WithCapture(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + // Start parent + ctx := context.Background() + parentCtx := aggregator.StartEvent(ctx) + + // Collect child + aggregator.CollectEvent(parentCtx, "child event") + + // End parent + aggregator.EndEvent(parentCtx, "parent event") + + events := storage.GetEvents(10) + require.Len(t, events, 1) + + parent := events[0] + assert.Equal(t, "parent event", parent.Data) + require.Len(t, parent.Children, 1) + assert.Equal(t, "child event", parent.Children[0].Data) +} + +func TestEventAggregator_DeeplyNestedEvents(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + ctx := context.Background() + + // Level 1 + ctx1 := aggregator.StartEvent(ctx) + + // Level 2 + ctx2 := aggregator.StartEvent(ctx1) + + // Level 3 + ctx3 := aggregator.StartEvent(ctx2) + + // End in reverse order + aggregator.EndEvent(ctx3, "Level 3") + aggregator.EndEvent(ctx2, "Level 2") + aggregator.EndEvent(ctx1, "Level 1") + + events := storage.GetEvents(10) + require.Len(t, events, 1) + + lvl1 := events[0] + assert.Equal(t, "Level 1", lvl1.Data) + require.Len(t, lvl1.Children, 1) + + lvl2 := lvl1.Children[0] + assert.Equal(t, "Level 2", lvl2.Data) + require.Len(t, lvl2.Children, 1) + + lvl3 := lvl2.Children[0] + assert.Equal(t, "Level 3", lvl3.Data) + assert.Len(t, lvl3.Children, 0) +} + +func TestEventAggregator_ConcurrentEvents(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + ctx := context.Background() + numGoroutines := 10 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + + eventCtx := aggregator.StartEvent(ctx) + aggregator.CollectEvent(eventCtx, "Child") + aggregator.EndEvent(eventCtx, "Parent") + }(i) + } + + wg.Wait() + + events := storage.GetEvents(uint64(numGoroutines * 2)) + assert.Len(t, events, numGoroutines) + + for _, evt := range events { + assert.Equal(t, "Parent", evt.Data) + require.Len(t, evt.Children, 1) + assert.Equal(t, "Child", evt.Children[0].Data) + } +} + +func TestEventAggregator_EndEventWithoutStart(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + // Try to end an event without starting one + ctx := context.Background() + aggregator.EndEvent(ctx, "This should not be captured") + + events := storage.GetEvents(10) + assert.Len(t, events, 0) +} + +func TestEventAggregator_MultipleTopLevelEvents(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + ctx := context.Background() + + // Event 1: using CollectEvent + aggregator.CollectEvent(ctx, "Event 1") + + // Event 2: using CollectEvent + aggregator.CollectEvent(ctx, "Event 2") + + // Event 3: using StartEvent/EndEvent + ctx3 := aggregator.StartEvent(ctx) + aggregator.EndEvent(ctx3, "Event 3") + + // Verify all events were collected + events := storage.GetEvents(10) + require.Len(t, events, 3) + + // Check we have all three events + foundEvents := make(map[string]bool) + for _, evt := range events { + if data, ok := evt.Data.(string); ok { + foundEvents[data] = true + } + } + + assert.True(t, foundEvents["Event 1"], "Event 1 should be found") + assert.True(t, foundEvents["Event 2"], "Event 2 should be found") + assert.True(t, foundEvents["Event 3"], "Event 3 should be found") +} + +func TestEventAggregator_WithCustomData(t *testing.T) { + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + // Create custom data types to collect + type HTTPData struct { + Method string + URL string + Status int + } + + type LogData struct { + Level string + Message string + } + + ctx := context.Background() + + // HTTP event + httpData := HTTPData{ + Method: "GET", + URL: "https://example.com", + Status: 200, + } + aggregator.CollectEvent(ctx, httpData) + + // Log event + logData := LogData{ + Level: "INFO", + Message: "This is a log message", + } + aggregator.CollectEvent(ctx, logData) + + // Verify both events were collected + events := storage.GetEvents(10) + require.Len(t, events, 2) + + // Check we can retrieve the typed data + foundHTTP := false + foundLog := false + + for _, evt := range events { + switch data := evt.Data.(type) { + case HTTPData: + assert.Equal(t, "GET", data.Method) + assert.Equal(t, "https://example.com", data.URL) + assert.Equal(t, 200, data.Status) + foundHTTP = true + + case LogData: + assert.Equal(t, "INFO", data.Level) + assert.Equal(t, "This is a log message", data.Message) + foundLog = true + } + } + + assert.True(t, foundHTTP, "HTTP event should be found") + assert.True(t, foundLog, "Log event should be found") +} diff --git a/collector/event_collector.go b/collector/event_collector.go deleted file mode 100644 index 69ec9a3..0000000 --- a/collector/event_collector.go +++ /dev/null @@ -1,241 +0,0 @@ -package collector - -import ( - "context" - "iter" - "sync" - "time" - - "github.com/gofrs/uuid" -) - -type ctxKey string - -const ( - groupIDKey ctxKey = "groupID" -) - -// EventCollector is a collector for events that can be grouped -type EventCollector struct { - buffer *LookupRingBuffer[*Event, uuid.UUID] - openGroups map[uuid.UUID]*Event - notifier *Notifier[Event] - - mx sync.RWMutex -} - -type EventOptions struct { - // NotifierOptions are options for notification about new events - NotifierOptions *NotifierOptions -} - -func DefaultEventOptions() EventOptions { - return EventOptions{} -} - -func NewEventCollectorWithOptions(capacity uint64, options EventOptions) *EventCollector { - notifierOptions := DefaultNotifierOptions() - if options.NotifierOptions != nil { - notifierOptions = *options.NotifierOptions - } - - c := &EventCollector{ - openGroups: make(map[uuid.UUID]*Event), - notifier: NewNotifierWithOptions[Event](notifierOptions), - } - if capacity > 0 { - c.buffer = NewLookupRingBuffer[*Event, uuid.UUID](capacity) - } - - return c -} - -func groupIDFromContext(ctx context.Context) (uuid.UUID, bool) { - if groupID, ok := ctx.Value(groupIDKey).(uuid.UUID); ok { - return groupID, true - } - return uuid.Nil, false -} - -func withGroupID(ctx context.Context, groupID uuid.UUID) context.Context { - return context.WithValue(ctx, groupIDKey, groupID) -} - -// CollectEvent directly adds the event to the buffer and notifies subscribers -func (c *EventCollector) CollectEvent(ctx context.Context, data any) { - eventID := uuid.Must(uuid.NewV7()) - - c.mx.Lock() - defer c.mx.Unlock() - - evt := &Event{ - ID: eventID, - Start: time.Now(), - } - - // Check if the group ID already exists in the context, so we add the event to the outer event as a child - outerGroupID, ok := groupIDFromContext(ctx) - if ok { - evt.GroupID = &outerGroupID - } - - evt.Data = data - evt.End = time.Now() - - // Append the event to the parent event if it exists - if evt.GroupID != nil { - outerEvt := c.openGroups[*evt.GroupID] - if outerEvt != nil { - outerEvt.Children = append(outerEvt.Children, evt) - } - } - - // Add the event to the buffer if it is top-level - if evt.GroupID == nil { - if c.buffer != nil { - c.buffer.Add(evt) - } - c.notifier.Notify(*evt) - } -} - -// StartEvent starts a new event and returns a new context with the group ID to group further events added with this context as children of the event to be created. -// Ensure to call EndEvent() to finish the event and collect it for notification. -func (c *EventCollector) StartEvent(ctx context.Context) (newCtx context.Context) { - eventID := uuid.Must(uuid.NewV7()) - - c.mx.Lock() - defer c.mx.Unlock() - - evt := &Event{ - ID: eventID, - Start: time.Now(), - } - - // Check if the group ID already exists in the context, so we add the event to the outer event as a child - outerGroupID, ok := groupIDFromContext(ctx) - if ok { - evt.GroupID = &outerGroupID - } - - c.openGroups[eventID] = evt - - return withGroupID(ctx, eventID) -} - -func (c *EventCollector) EndEvent(ctx context.Context, data any) { - existingGroupID, ok := groupIDFromContext(ctx) - if !ok { - return - } - - c.mx.Lock() - defer c.mx.Unlock() - - evt := c.openGroups[existingGroupID] - if evt == nil { - return - } - - evt.Data = data - evt.End = time.Now() - - // Append the event to the parent event if it exists - if evt.GroupID != nil { - outerEvt := c.openGroups[*evt.GroupID] - if outerEvt != nil { - outerEvt.Children = append(outerEvt.Children, evt) - } - } - - // Remove the event from the open groups - delete(c.openGroups, existingGroupID) - - // Add the event to the buffer if it is top-level - if evt.GroupID == nil { - if c.buffer != nil { - c.buffer.Add(evt) - } - c.notifier.Notify(*evt) - } -} - -func (c *EventCollector) GetEvents(n uint64) []*Event { - if c.buffer == nil { - return nil - } - return c.buffer.GetRecords(n) -} - -func (c *EventCollector) GetEvent(id uuid.UUID) (*Event, bool) { - if c.buffer == nil { - return nil, false - } - return c.buffer.Lookup(id) -} - -// Subscribe returns a channel that receives notifications of new events -func (c *EventCollector) Subscribe(ctx context.Context) <-chan Event { - return c.notifier.Subscribe(ctx) -} - -// Close releases resources used by the collector -func (c *EventCollector) Close() { - c.notifier.Close() - c.buffer = nil -} - -func (c *EventCollector) Clear() { - if c.buffer == nil { - return - } - c.buffer.Clear() -} - -func (c *EventCollector) Capacity() uint64 { - if c.buffer == nil { - return 0 - } - return c.buffer.Capacity() -} - -type Event struct { - ID uuid.UUID - - GroupID *uuid.UUID - - Data any - - Start time.Time - End time.Time - - // Children is a slice of events that are children of this event - Children []*Event -} - -func (e *Event) Visit() iter.Seq2[uuid.UUID, *Event] { - return func(yield func(uuid.UUID, *Event) bool) { - e.visitInternal(yield) - } -} - -func (e *Event) visitInternal(yield func(uuid.UUID, *Event) bool) bool { - if !yield(e.ID, e) { - return false - } - for _, child := range e.Children { - if !child.visitInternal(yield) { - return false - } - } - return true -} - -func (e *Event) free() { - if freer, ok := e.Data.(interface{ free() }); ok { - freer.free() - } - for _, child := range e.Children { - child.free() - } -} diff --git a/collector/event_collector_test.go b/collector/event_collector_test.go deleted file mode 100644 index 4490e9a..0000000 --- a/collector/event_collector_test.go +++ /dev/null @@ -1,527 +0,0 @@ -package collector_test - -import ( - "context" - "log/slog" - "sync" - "testing" - "time" - - "github.com/gofrs/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/networkteam/devlog/collector" -) - -func TestEventCollector_BasicCollection(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create some test data - testData := "This is test data" - - // Collect the event - ctx := context.Background() - evtCollector.CollectEvent(ctx, testData) - - // Verify the event was collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 1) - - // Check event properties - evt := events[0] - assert.NotEqual(t, uuid.Nil, evt.ID) - assert.Nil(t, evt.GroupID) - assert.Equal(t, testData, evt.Data) - assert.False(t, evt.Start.IsZero()) - assert.False(t, evt.End.IsZero()) - assert.Equal(t, 0, len(evt.Children)) -} - -func TestEventCollector_StartEndEvent(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create some test data - testData := map[string]string{ - "key": "value", - } - - // Start an event - ctx := context.Background() - ctx = evtCollector.StartEvent(ctx) - - // Simulate some work - time.Sleep(10 * time.Millisecond) - - // End the event - evtCollector.EndEvent(ctx, testData) - - // Verify the event was collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 1) - - // Check event properties - evt := events[0] - assert.NotEqual(t, uuid.Nil, evt.ID) - assert.Nil(t, evt.GroupID) - assert.Equal(t, testData, evt.Data) - assert.False(t, evt.Start.IsZero()) - assert.False(t, evt.End.IsZero()) - assert.True(t, evt.End.After(evt.Start)) - assert.Equal(t, 0, len(evt.Children)) -} - -func TestEventCollector_NestedEvents(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Start a parent event - ctx := context.Background() - parentCtx := evtCollector.StartEvent(ctx) - - // Collect a child event - childData := "Child event" - evtCollector.CollectEvent(parentCtx, childData) - - // Start another nested event - nestedCtx := evtCollector.StartEvent(parentCtx) - - // Simulate some work - time.Sleep(10 * time.Millisecond) - - // End the nested event - nestedData := "Nested event" - evtCollector.EndEvent(nestedCtx, nestedData) - - // End the parent event - parentData := "Parent event" - evtCollector.EndEvent(parentCtx, parentData) - - // Verify the events were collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 1) // Only the parent event should be in the top level - - // Check parent event properties - parent := events[0] - assert.Equal(t, parentData, parent.Data) - assert.Nil(t, parent.GroupID) - - // Check children - require.Len(t, parent.Children, 2) - - // Check child event properties - found := 0 - for _, child := range parent.Children { - assert.NotEqual(t, uuid.Nil, child.ID) - assert.NotNil(t, child.GroupID) - assert.Equal(t, parent.ID, *child.GroupID) - - // Verify we have both children - if val, ok := child.Data.(string); ok { - if val == childData { - found++ - } else if val == nestedData { - found++ - // The nested event doesn't have any children since it was empty - assert.Equal(t, 0, len(child.Children)) - } - } - } - assert.Equal(t, 2, found, "Both children should be found") -} - -func TestEventCollector_DeeplyNestedEvents(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create a three-level deep event hierarchy - ctx := context.Background() - - // Level 1 - ctx1 := evtCollector.StartEvent(ctx) - - // Level 2 - ctx2 := evtCollector.StartEvent(ctx1) - - // Level 3 - ctx3 := evtCollector.StartEvent(ctx2) - - // End them in reverse order - evtCollector.EndEvent(ctx3, "Level 3") - evtCollector.EndEvent(ctx2, "Level 2") - evtCollector.EndEvent(ctx1, "Level 1") - - // Verify the events were collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 1) // Only the top level event should be in the main buffer - - // Check level 1 - lvl1 := events[0] - assert.Equal(t, "Level 1", lvl1.Data) - assert.Nil(t, lvl1.GroupID) - require.Len(t, lvl1.Children, 1) - - // Check level 2 - lvl2 := lvl1.Children[0] - assert.Equal(t, "Level 2", lvl2.Data) - assert.NotNil(t, lvl2.GroupID) - assert.Equal(t, lvl1.ID, *lvl2.GroupID) - require.Len(t, lvl2.Children, 1) - - // Check level 3 - lvl3 := lvl2.Children[0] - assert.Equal(t, "Level 3", lvl3.Data) - assert.NotNil(t, lvl3.GroupID) - assert.Equal(t, lvl2.ID, *lvl3.GroupID) - assert.Len(t, lvl3.Children, 0) -} - -func TestEventCollector_MultipleTopLevelEvents(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create multiple top-level events - ctx := context.Background() - - // Event 1 - evtCollector.CollectEvent(ctx, "Event 1") - - // Event 2 - evtCollector.CollectEvent(ctx, "Event 2") - - // Event 3 - ctx3 := evtCollector.StartEvent(ctx) - evtCollector.EndEvent(ctx3, "Event 3") - - // Verify all events were collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 3) - - // Check we have all three events - foundEvents := make(map[string]bool) - for _, evt := range events { - if data, ok := evt.Data.(string); ok { - foundEvents[data] = true - } - } - - assert.True(t, foundEvents["Event 1"], "Event 1 should be found") - assert.True(t, foundEvents["Event 2"], "Event 2 should be found") - assert.True(t, foundEvents["Event 3"], "Event 3 should be found") -} - -func TestEventCollector_EndEventWithoutStart(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Try to end an event without starting one - ctx := context.Background() - evtCollector.EndEvent(ctx, "This should not be collected") - - // Verify no events were collected - events := evtCollector.GetEvents(10) - assert.Len(t, events, 0) -} - -func TestEventCollector_WithCustomData(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create custom data types to collect - type HTTPData struct { - Method string - URL string - Status int - } - - type LogData struct { - Level string - Message string - } - - // Collect different types of events - ctx := context.Background() - - // HTTP event - httpData := HTTPData{ - Method: "GET", - URL: "https://example.com", - Status: 200, - } - evtCollector.CollectEvent(ctx, httpData) - - // Log event - logData := LogData{ - Level: "INFO", - Message: "This is a log message", - } - evtCollector.CollectEvent(ctx, logData) - - // Verify both events were collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 2) - - // Check we can retrieve the typed data - foundHTTP := false - foundLog := false - - for _, evt := range events { - switch data := evt.Data.(type) { - case HTTPData: - assert.Equal(t, "GET", data.Method) - assert.Equal(t, "https://example.com", data.URL) - assert.Equal(t, 200, data.Status) - foundHTTP = true - - case LogData: - assert.Equal(t, "INFO", data.Level) - assert.Equal(t, "This is a log message", data.Message) - foundLog = true - } - } - - assert.True(t, foundHTTP, "HTTP event should be found") - assert.True(t, foundLog, "Log event should be found") -} - -func TestEventCollector_ConcurrentEvents(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create multiple events concurrently - ctx := context.Background() - numGoroutines := 10 - - var wg sync.WaitGroup - wg.Add(numGoroutines) - - for i := 0; i < numGoroutines; i++ { - go func(id int) { - defer wg.Done() - - // Start an event - eventCtx := evtCollector.StartEvent(ctx) - - // Add a child event - evtCollector.CollectEvent(eventCtx, "Child of "+string(rune('A'+id))) - - // End the event - evtCollector.EndEvent(eventCtx, "Parent "+string(rune('A'+id))) - }(i) - } - - // Wait for all goroutines to complete - wg.Wait() - - // Verify all events were collected - events := evtCollector.GetEvents(uint64(numGoroutines * 2)) - assert.Len(t, events, numGoroutines) - - // Check that each parent has a child - for _, evt := range events { - require.Len(t, evt.Children, 1) - - // Parent should be "Parent X" - parentData, ok := evt.Data.(string) - require.True(t, ok) - assert.Contains(t, parentData, "Parent ") - - // Child should be "Child of X" - childData, ok := evt.Children[0].Data.(string) - require.True(t, ok) - assert.Contains(t, childData, "Child of ") - - // The parent and child letters should match - parentLetter := parentData[len(parentData)-1] - childLetter := childData[len(childData)-1] - assert.Equal(t, parentLetter, childLetter) - } -} - -func TestEventCollector_Notification(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create a context for subscription - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Subscribe to notifications - events := make(chan collector.Event, 10) - subscription := evtCollector.Subscribe(ctx) - - // Start a goroutine to collect notifications - var wg sync.WaitGroup - wg.Add(1) - - go func() { - defer wg.Done() - for evt := range subscription { - events <- evt - } - }() - - // Collect some events - eventCtx := context.Background() - evtCollector.CollectEvent(eventCtx, "Event 1") - evtCollector.CollectEvent(eventCtx, "Event 2") - - // Start and end an event - nestedCtx := evtCollector.StartEvent(eventCtx) - evtCollector.EndEvent(nestedCtx, "Event 3") - - // Wait a bit for events to be processed - time.Sleep(50 * time.Millisecond) - - // Cancel the subscription context - cancel() - - // Wait for the goroutine to finish - wg.Wait() - - // Check notifications - assert.Len(t, events, 3) - - // Check the contents of the events - receivedEvents := make([]string, 0, 3) - for i := 0; i < 3; i++ { - select { - case evt := <-events: - if data, ok := evt.Data.(string); ok { - receivedEvents = append(receivedEvents, data) - } - default: - t.Fatal("Expected to receive 3 events") - } - } - - assert.Contains(t, receivedEvents, "Event 1") - assert.Contains(t, receivedEvents, "Event 2") - assert.Contains(t, receivedEvents, "Event 3") -} - -func TestEventCollector_RingBufferCapacity(t *testing.T) { - // Create a new event collector with small capacity - capacity := uint64(5) - evtCollector := collector.NewEventCollectorWithOptions(capacity, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Collect more events than the capacity - ctx := context.Background() - for i := 0; i < 10; i++ { - evtCollector.CollectEvent(ctx, "Event "+string(rune('A'+i))) - } - - // Verify only the capacity number of events were kept - events := evtCollector.GetEvents(20) - assert.Len(t, events, int(capacity)) - - // Verify we have the most recent events (F-J, not A-E) - for _, evt := range events { - data, ok := evt.Data.(string) - require.True(t, ok) - - // The event should be one of the later ones (F-J) - letter := data[len(data)-1] - assert.True(t, letter >= 'F' && letter <= 'J') - } -} - -func TestEventCollector_IntegrationWithSlog(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Create a context with a group ID - ctx := context.Background() - ctx = evtCollector.StartEvent(ctx) - - // Create a log record - record := slog.Record{ - Time: time.Now(), - Message: "Test log message", - Level: slog.LevelInfo, - } - - // Collect the log record as a child event - evtCollector.CollectEvent(ctx, record) - - // End the parent event - evtCollector.EndEvent(ctx, "HTTP request") - - // Verify the events were collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 1) - - // Check parent event - assert.Equal(t, "HTTP request", events[0].Data) - require.Len(t, events[0].Children, 1) - - // Check log event - logEvent := events[0].Children[0] - - // Verify the log record - logRecord, ok := logEvent.Data.(slog.Record) - require.True(t, ok) - assert.Equal(t, "Test log message", logRecord.Message) - assert.Equal(t, slog.LevelInfo, logRecord.Level) -} - -func TestEventCollector_IntegrationWithHTTP(t *testing.T) { - // Create a new event collector - evtCollector := collector.NewEventCollectorWithOptions(100, collector.DefaultEventOptions()) - defer evtCollector.Close() - - // Simulate an HTTP server request - serverCtx := context.Background() - serverCtx = evtCollector.StartEvent(serverCtx) - - // Simulate an HTTP client request made during server request processing - clientData := map[string]interface{}{ - "method": "GET", - "url": "https://api.example.com/data", - "status": 200, - } - evtCollector.CollectEvent(serverCtx, clientData) - - // End the server request - serverData := map[string]interface{}{ - "method": "POST", - "path": "/process", - "status": 200, - "latency": "150ms", - } - evtCollector.EndEvent(serverCtx, serverData) - - // Verify the events were collected - events := evtCollector.GetEvents(10) - require.Len(t, events, 1) - - // Check server event - serverEvent := events[0] - serverEventData, ok := serverEvent.Data.(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "POST", serverEventData["method"]) - assert.Equal(t, "/process", serverEventData["path"]) - assert.Equal(t, 200, serverEventData["status"]) - - // Check client event - require.Len(t, serverEvent.Children, 1) - clientEvent := serverEvent.Children[0] - clientEventData, ok := clientEvent.Data.(map[string]interface{}) - require.True(t, ok) - assert.Equal(t, "GET", clientEventData["method"]) - assert.Equal(t, "https://api.example.com/data", clientEventData["url"]) - assert.Equal(t, 200, clientEventData["status"]) -} diff --git a/collector/event_group.go b/collector/event_group.go new file mode 100644 index 0000000..71ab83f --- /dev/null +++ b/collector/event_group.go @@ -0,0 +1,24 @@ +package collector + +import ( + "context" + + "github.com/gofrs/uuid" +) + +type ctxKey string + +const ( + groupIDKey ctxKey = "groupID" +) + +func groupIDFromContext(ctx context.Context) (uuid.UUID, bool) { + if groupID, ok := ctx.Value(groupIDKey).(uuid.UUID); ok { + return groupID, true + } + return uuid.Nil, false +} + +func withGroupID(ctx context.Context, groupID uuid.UUID) context.Context { + return context.WithValue(ctx, groupIDKey, groupID) +} diff --git a/collector/event_storage.go b/collector/event_storage.go new file mode 100644 index 0000000..9304df6 --- /dev/null +++ b/collector/event_storage.go @@ -0,0 +1,188 @@ +package collector + +import ( + "context" + "slices" + + "github.com/gofrs/uuid" +) + +// EventStorage is the interface for event storage backends. +// Storages are responsible for deciding which events to capture and storing them. +type EventStorage interface { + // ID returns the unique identifier for this storage + ID() uuid.UUID + + // ShouldCapture returns true if this storage wants to capture events for the given context + ShouldCapture(ctx context.Context) bool + + // Add adds an event to the storage + Add(event *Event) + + // GetEvent retrieves an event by its ID + GetEvent(id uuid.UUID) (*Event, bool) + + // GetEvents returns the most recent n events + GetEvents(limit uint64) []*Event + + // Subscribe returns a channel that receives notifications of new events + Subscribe(ctx context.Context) <-chan *Event + + // Clear removes all events from the storage + Clear() + + // Close releases resources used by the storage + Close() +} + +// CaptureMode defines how a CaptureStorage decides which events to capture +type CaptureMode int + +const ( + // CaptureModeSession captures only events from requests with matching session ID + CaptureModeSession CaptureMode = iota + // CaptureModeGlobal captures all events + CaptureModeGlobal +) + +// String returns the string representation of the capture mode +func (m CaptureMode) String() string { + switch m { + case CaptureModeGlobal: + return "global" + case CaptureModeSession: + return "session" + default: + return "session" + } +} + +// ParseCaptureMode parses a string into a CaptureMode. +// Returns the mode and true if valid, or zero value and false if invalid. +func ParseCaptureMode(s string) (CaptureMode, bool) { + switch s { + case "global": + return CaptureModeGlobal, true + case "session": + return CaptureModeSession, true + default: + return 0, false + } +} + +// ParseCaptureModeOrDefault parses a string into a CaptureMode, +// returning CaptureModeSession if the string is invalid or empty. +func ParseCaptureModeOrDefault(s string) CaptureMode { + mode, ok := ParseCaptureMode(s) + if !ok { + return CaptureModeSession + } + return mode +} + +// CaptureStorage implements EventStorage with configurable capture mode. +// Each user gets their own CaptureStorage instance. +type CaptureStorage struct { + id uuid.UUID + sessionID uuid.UUID + captureMode CaptureMode + capturing bool // whether actively capturing events + + buffer *LookupRingBuffer[*Event, uuid.UUID] + notifier *Notifier[*Event] +} + +// NewCaptureStorage creates a new CaptureStorage for the given session ID. +func NewCaptureStorage(sessionID uuid.UUID, capacity uint64, mode CaptureMode) *CaptureStorage { + return &CaptureStorage{ + id: uuid.Must(uuid.NewV7()), + sessionID: sessionID, + captureMode: mode, + capturing: true, + buffer: NewLookupRingBuffer[*Event, uuid.UUID](capacity), + notifier: NewNotifier[*Event](), + } +} + +// ID returns the unique identifier for this storage +func (s *CaptureStorage) ID() uuid.UUID { + return s.id +} + +// SessionID returns the session ID this storage belongs to +func (s *CaptureStorage) SessionID() uuid.UUID { + return s.sessionID +} + +// CaptureMode returns the current capture mode +func (s *CaptureStorage) CaptureMode() CaptureMode { + return s.captureMode +} + +// SetCaptureMode sets the capture mode +func (s *CaptureStorage) SetCaptureMode(mode CaptureMode) { + s.captureMode = mode +} + +// IsCapturing returns whether the storage is actively capturing events +func (s *CaptureStorage) IsCapturing() bool { + return s.capturing +} + +// SetCapturing enables or disables capturing of new events +func (s *CaptureStorage) SetCapturing(capturing bool) { + s.capturing = capturing +} + +// ShouldCapture returns true if this storage wants to capture events for the given context +func (s *CaptureStorage) ShouldCapture(ctx context.Context) bool { + if !s.capturing { + return false + } + switch s.captureMode { + case CaptureModeGlobal: + return true + case CaptureModeSession: + sessionIDs, ok := SessionIDsFromContext(ctx) + if !ok { + return false + } + return slices.Contains(sessionIDs, s.sessionID) + default: + return false + } +} + +// Add adds an event to the storage and notifies subscribers +func (s *CaptureStorage) Add(event *Event) { + s.buffer.Add(event) + s.notifier.Notify(event) +} + +// GetEvent retrieves an event by its ID +func (s *CaptureStorage) GetEvent(id uuid.UUID) (*Event, bool) { + return s.buffer.Lookup(id) +} + +// GetEvents returns the most recent n events +func (s *CaptureStorage) GetEvents(limit uint64) []*Event { + return s.buffer.GetRecords(limit) +} + +// Subscribe returns a channel that receives notifications of new events +func (s *CaptureStorage) Subscribe(ctx context.Context) <-chan *Event { + return s.notifier.Subscribe(ctx) +} + +// Clear removes all events from the storage +func (s *CaptureStorage) Clear() { + s.buffer.Clear() +} + +// Close releases resources used by the storage +func (s *CaptureStorage) Close() { + s.notifier.Close() +} + +// Ensure CaptureStorage implements EventStorage +var _ EventStorage = (*CaptureStorage)(nil) diff --git a/collector/event_storage_test.go b/collector/event_storage_test.go new file mode 100644 index 0000000..e258893 --- /dev/null +++ b/collector/event_storage_test.go @@ -0,0 +1,294 @@ +package collector_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/networkteam/devlog/collector" +) + +func TestCaptureStorage_ShouldCapture_SessionMode_NoSessionInCtx(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + defer storage.Close() + + ctx := context.Background() // no session ID + + assert.False(t, storage.ShouldCapture(ctx)) +} + +func TestCaptureStorage_ShouldCapture_SessionMode_WrongSession(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + otherSessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + defer storage.Close() + + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{otherSessionID}) + + assert.False(t, storage.ShouldCapture(ctx)) +} + +func TestCaptureStorage_ShouldCapture_SessionMode_MatchingSession(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + defer storage.Close() + + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{sessionID}) + + assert.True(t, storage.ShouldCapture(ctx)) +} + +func TestCaptureStorage_ShouldCapture_SessionMode_MultipleSessionsInContext(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + otherSessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + defer storage.Close() + + // Context has multiple session IDs, including the one we're looking for + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{otherSessionID, sessionID}) + + assert.True(t, storage.ShouldCapture(ctx)) +} + +func TestCaptureStorage_ShouldCapture_GlobalMode_AlwaysTrue(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + // Should return true for any context + assert.True(t, storage.ShouldCapture(context.Background())) + + // Even with a different session ID + otherSessionID := uuid.Must(uuid.NewV4()) + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{otherSessionID}) + assert.True(t, storage.ShouldCapture(ctx)) +} + +func TestCaptureStorage_SetCaptureMode(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + defer storage.Close() + + assert.Equal(t, collector.CaptureModeSession, storage.CaptureMode()) + + storage.SetCaptureMode(collector.CaptureModeGlobal) + assert.Equal(t, collector.CaptureModeGlobal, storage.CaptureMode()) + + storage.SetCaptureMode(collector.CaptureModeSession) + assert.Equal(t, collector.CaptureModeSession, storage.CaptureMode()) +} + +func TestCaptureStorage_SetCapturing(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + // Should start in capturing state + assert.True(t, storage.IsCapturing()) + + // Should be able to pause + storage.SetCapturing(false) + assert.False(t, storage.IsCapturing()) + + // Should be able to resume + storage.SetCapturing(true) + assert.True(t, storage.IsCapturing()) +} + +func TestCaptureStorage_ShouldCapture_WhenNotCapturing(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + ctx := context.Background() + + // Should capture when capturing is enabled + assert.True(t, storage.ShouldCapture(ctx)) + + // Pause capturing + storage.SetCapturing(false) + + // Should NOT capture even in global mode when capturing is disabled + assert.False(t, storage.ShouldCapture(ctx)) +} + +func TestCaptureStorage_ShouldCapture_SessionMode_WhenNotCapturing(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + defer storage.Close() + + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{sessionID}) + + // Should capture matching session + assert.True(t, storage.ShouldCapture(ctx)) + + // Pause capturing + storage.SetCapturing(false) + + // Should NOT capture even with matching session when capturing is disabled + assert.False(t, storage.ShouldCapture(ctx)) +} + +func TestCaptureStorage_Add_StoresEvent(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + event := &collector.Event{ + ID: uuid.Must(uuid.NewV7()), + Data: "test data", + Start: time.Now(), + End: time.Now(), + } + + storage.Add(event) + + events := storage.GetEvents(10) + require.Len(t, events, 1) + assert.Equal(t, event.ID, events[0].ID) + assert.Equal(t, "test data", events[0].Data) +} + +func TestCaptureStorage_RingBuffer_Capacity(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 5, collector.CaptureModeGlobal) + defer storage.Close() + + // Add 10 events + for i := 0; i < 10; i++ { + event := &collector.Event{ + ID: uuid.Must(uuid.NewV7()), + Data: i, + Start: time.Now(), + End: time.Now(), + } + storage.Add(event) + } + + // Should only keep last 5 + events := storage.GetEvents(20) + require.Len(t, events, 5) + + // Verify we have the last 5 events (5-9) + for i, evt := range events { + assert.Equal(t, 5+i, evt.Data) + } +} + +func TestCaptureStorage_Subscribe_ReceivesEvents(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + subscription := storage.Subscribe(ctx) + + // Collect events in goroutine + var receivedEvents []*collector.Event + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for evt := range subscription { + receivedEvents = append(receivedEvents, evt) + if len(receivedEvents) >= 2 { + cancel() + return + } + } + }() + + // Add events + event1 := &collector.Event{ + ID: uuid.Must(uuid.NewV7()), + Data: "event1", + Start: time.Now(), + End: time.Now(), + } + event2 := &collector.Event{ + ID: uuid.Must(uuid.NewV7()), + Data: "event2", + Start: time.Now(), + End: time.Now(), + } + + storage.Add(event1) + storage.Add(event2) + + wg.Wait() + + require.Len(t, receivedEvents, 2) + assert.Equal(t, "event1", receivedEvents[0].Data) + assert.Equal(t, "event2", receivedEvents[1].Data) +} + +func TestCaptureStorage_GetEvent_ByID(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + eventID := uuid.Must(uuid.NewV7()) + event := &collector.Event{ + ID: eventID, + Data: "test data", + Start: time.Now(), + End: time.Now(), + } + + storage.Add(event) + + // Should find the event + found, exists := storage.GetEvent(eventID) + assert.True(t, exists) + assert.Equal(t, eventID, found.ID) + assert.Equal(t, "test data", found.Data) + + // Should not find non-existent event + _, exists = storage.GetEvent(uuid.Must(uuid.NewV7())) + assert.False(t, exists) +} + +func TestCaptureStorage_Clear(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + event := &collector.Event{ + ID: uuid.Must(uuid.NewV7()), + Data: "test data", + Start: time.Now(), + End: time.Now(), + } + storage.Add(event) + + require.Len(t, storage.GetEvents(10), 1) + + storage.Clear() + + assert.Len(t, storage.GetEvents(10), 0) +} + +func TestCaptureStorage_ID(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + // Storage ID should not be the same as session ID + assert.NotEqual(t, uuid.Nil, storage.ID()) +} + +func TestCaptureStorage_SessionID(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + defer storage.Close() + + assert.Equal(t, sessionID, storage.SessionID()) +} diff --git a/collector/http_client.go b/collector/http_client.go index 64e26ae..1306670 100644 --- a/collector/http_client.go +++ b/collector/http_client.go @@ -4,7 +4,6 @@ import ( "context" "net/http" "strconv" - "sync" "time" ) @@ -25,8 +24,8 @@ type HTTPClientOptions struct { // NotifierOptions are options for notification about new requests NotifierOptions *NotifierOptions - // EventCollector is an optional event collector for collecting requests as grouped events - EventCollector *EventCollector + // EventAggregator is the aggregator for collecting requests as grouped events + EventAggregator *EventAggregator } type HTTPClientRequestTransformer func(HTTPClientRequest) HTTPClientRequest @@ -42,37 +41,28 @@ func DefaultHTTPClientOptions() HTTPClientOptions { // HTTPClientCollector collects outgoing HTTP requests type HTTPClientCollector struct { - buffer *RingBuffer[HTTPClientRequest] - - options HTTPClientOptions - notifier *Notifier[HTTPClientRequest] - eventCollector *EventCollector - - mu sync.RWMutex + options HTTPClientOptions + notifier *Notifier[HTTPClientRequest] + eventAggregator *EventAggregator } // NewHTTPClientCollector creates a new collector for outgoing HTTP requests -func NewHTTPClientCollector(capacity uint64) *HTTPClientCollector { - return NewHTTPClientCollectorWithOptions(capacity, DefaultHTTPClientOptions()) +func NewHTTPClientCollector() *HTTPClientCollector { + return NewHTTPClientCollectorWithOptions(DefaultHTTPClientOptions()) } // NewHTTPClientCollectorWithOptions creates a new collector with specified options -func NewHTTPClientCollectorWithOptions(capacity uint64, options HTTPClientOptions) *HTTPClientCollector { +func NewHTTPClientCollectorWithOptions(options HTTPClientOptions) *HTTPClientCollector { notifierOptions := DefaultNotifierOptions() if options.NotifierOptions != nil { notifierOptions = *options.NotifierOptions } - collector := &HTTPClientCollector{ - options: options, - notifier: NewNotifierWithOptions[HTTPClientRequest](notifierOptions), - eventCollector: options.EventCollector, + return &HTTPClientCollector{ + options: options, + notifier: NewNotifierWithOptions[HTTPClientRequest](notifierOptions), + eventAggregator: options.EventAggregator, } - if capacity > 0 { - collector.buffer = NewRingBuffer[HTTPClientRequest](capacity) - } - - return collector } // Transport returns an http.RoundTripper that captures request/response data @@ -87,31 +77,19 @@ func (c *HTTPClientCollector) Transport(next http.RoundTripper) http.RoundTrippe } } -// GetRequests returns the most recent n HTTP requests -func (c *HTTPClientCollector) GetRequests(n uint64) []HTTPClientRequest { - if c.buffer == nil { - return nil - } - return c.buffer.GetRecords(n) -} - -// Subscribe returns a channel that receives notifications of new log records +// Subscribe returns a channel that receives notifications of new HTTP requests func (c *HTTPClientCollector) Subscribe(ctx context.Context) <-chan HTTPClientRequest { return c.notifier.Subscribe(ctx) } -// Add adds an HTTP request to the collector +// Add adds an HTTP request to the collector and notifies subscribers func (c *HTTPClientCollector) Add(req HTTPClientRequest) { - if c.buffer != nil { - c.buffer.Add(req) - } c.notifier.Notify(req) } // Close releases resources used by the collector func (c *HTTPClientCollector) Close() { c.notifier.Close() - c.buffer = nil } // httpClientTransport is an http.RoundTripper that captures HTTP request/response data @@ -121,6 +99,21 @@ type httpClientTransport struct { } func (t *httpClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + + // Check if we should capture this request (using EventAggregator) + // Default to true for backward compatibility when neither aggregator nor collector is set + shouldCapture := true + if t.collector.eventAggregator != nil { + shouldCapture = t.collector.eventAggregator.ShouldCapture(ctx) + } + // Note: EventCollector (deprecated) is always-capture, so no change needed + + // Early bailout if not capturing - just pass through + if !shouldCapture { + return t.next.RoundTrip(req) + } + // Generate a unique ID for this request id := generateID() @@ -139,7 +132,7 @@ func (t *httpClientTransport) RoundTrip(req *http.Request) (*http.Response, erro // Track the original request body size if req.ContentLength > 0 { - httpReq.RequestSize = req.ContentLength + httpReq.RequestSize = uint64(req.ContentLength) } // Capture request body if present and configured to do so @@ -154,10 +147,11 @@ func (t *httpClientTransport) RoundTrip(req *http.Request) (*http.Response, erro req.Body = body } - if t.collector.eventCollector != nil { - newCtx := t.collector.eventCollector.StartEvent(req.Context()) + // Start event tracking with EventAggregator + if t.collector.eventAggregator != nil { + newCtx := t.collector.eventAggregator.StartEvent(ctx) defer func(req *HTTPClientRequest) { - t.collector.eventCollector.EndEvent(newCtx, *req) + t.collector.eventAggregator.EndEvent(newCtx, *req) }(&httpReq) req = req.WithContext(newCtx) @@ -177,7 +171,7 @@ func (t *httpClientTransport) RoundTrip(req *http.Request) (*http.Response, erro // Calculate content length from header if available if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { - if length, err := strconv.ParseInt(contentLength, 10, 64); err == nil { + if length, err := strconv.ParseUint(contentLength, 10, 64); err == nil { httpReq.ResponseSize = length } } diff --git a/collector/http_client_request.go b/collector/http_client_request.go index c60e015..f828078 100644 --- a/collector/http_client_request.go +++ b/collector/http_client_request.go @@ -15,8 +15,8 @@ type HTTPClientRequest struct { RequestTime time.Time ResponseTime time.Time StatusCode int - RequestSize int64 - ResponseSize int64 + RequestSize uint64 + ResponseSize uint64 RequestHeaders http.Header ResponseHeaders http.Header RequestBody *Body @@ -31,6 +31,24 @@ func (r HTTPClientRequest) Duration() time.Duration { return r.ResponseTime.Sub(r.RequestTime) } +// Size returns the estimated memory size of this request in bytes +func (r HTTPClientRequest) Size() uint64 { + size := uint64(200) // base struct overhead + size += uint64(len(r.URL) + len(r.Method)) + size += headersSize(r.RequestHeaders) + size += headersSize(r.ResponseHeaders) + if r.RequestBody != nil { + size += r.RequestBody.Size() + } + if r.ResponseBody != nil { + size += r.ResponseBody.Size() + } + for k, v := range r.Tags { + size += uint64(len(k) + len(v)) + } + return size +} + func generateID() uuid.UUID { return uuid.Must(uuid.NewV7()) } diff --git a/collector/http_client_test.go b/collector/http_client_test.go index 97680dd..8564aeb 100644 --- a/collector/http_client_test.go +++ b/collector/http_client_test.go @@ -7,7 +7,6 @@ import ( "strconv" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,7 +33,10 @@ func TestHTTPClientCollector_UnreadResponseBody(t *testing.T) { // Create a collector with specific options for testing options := collector.DefaultHTTPClientOptions() options.MaxBodySize = 1024 // Ensure it's large enough for our test data - httpCollector := collector.NewHTTPClientCollectorWithOptions(10, options) + httpCollector := collector.NewHTTPClientCollectorWithOptions(options) + + // Start collecting before making request + collect := Collect(t, httpCollector.Subscribe) // Create a client with the collector's transport client := &http.Client{ @@ -53,12 +55,7 @@ func TestHTTPClientCollector_UnreadResponseBody(t *testing.T) { // that doesn't consume the response body resp.Body.Close() - // Add a delay to ensure async operations complete - time.Sleep(100 * time.Millisecond) - - // Get the captured requests - requests := httpCollector.GetRequests(10) - require.Len(t, requests, 1) + requests := collect.Stop() // Verify the captured request details req := requests[0] diff --git a/collector/http_server.go b/collector/http_server.go index e4f0278..4cd620d 100644 --- a/collector/http_server.go +++ b/collector/http_server.go @@ -6,10 +6,21 @@ import ( "fmt" "net" "net/http" - "sync" + "strings" "time" + + "github.com/gofrs/uuid" ) +// parseUUID parses a UUID string and returns the UUID or an error +func parseUUID(s string) (uuid.UUID, error) { + return uuid.FromString(s) +} + +// SessionCookiePrefix is the prefix for cookies used to identify capture sessions. +// Each session gets its own cookie named "devlog_session_{uuid}". +const SessionCookiePrefix = "devlog_session_" + // HTTPServerOptions configures the HTTP server collector type HTTPServerOptions struct { // MaxBodySize is the maximum size in bytes of a single body @@ -31,8 +42,8 @@ type HTTPServerOptions struct { // NotifierOptions are options for notification about new requests NotifierOptions *NotifierOptions - // EventCollector is an optional event collector for collecting requests as grouped events - EventCollector *EventCollector + // EventAggregator is the aggregator for collecting requests as grouped events + EventAggregator *EventAggregator } type HTTPServerRequestTransformer func(HTTPServerRequest) HTTPServerRequest @@ -49,45 +60,28 @@ func DefaultHTTPServerOptions() HTTPServerOptions { // HTTPServerCollector collects incoming HTTP requests type HTTPServerCollector struct { - buffer *RingBuffer[HTTPServerRequest] - - options HTTPServerOptions - notifier *Notifier[HTTPServerRequest] - eventCollector *EventCollector - - mu sync.RWMutex + options HTTPServerOptions + notifier *Notifier[HTTPServerRequest] + eventAggregator *EventAggregator } // NewHTTPServerCollector creates a new collector for incoming HTTP requests -func NewHTTPServerCollector(capacity uint64) *HTTPServerCollector { - return NewHTTPServerCollectorWithOptions(capacity, DefaultHTTPServerOptions()) +func NewHTTPServerCollector() *HTTPServerCollector { + return NewHTTPServerCollectorWithOptions(DefaultHTTPServerOptions()) } // NewHTTPServerCollectorWithOptions creates a new collector with specified options -func NewHTTPServerCollectorWithOptions(capacity uint64, options HTTPServerOptions) *HTTPServerCollector { +func NewHTTPServerCollectorWithOptions(options HTTPServerOptions) *HTTPServerCollector { notifierOptions := DefaultNotifierOptions() if options.NotifierOptions != nil { notifierOptions = *options.NotifierOptions } - collector := &HTTPServerCollector{ - options: options, - notifier: NewNotifierWithOptions[HTTPServerRequest](notifierOptions), - eventCollector: options.EventCollector, - } - if capacity > 0 { - collector.buffer = NewRingBuffer[HTTPServerRequest](capacity) - } - - return collector -} - -// GetRequests returns the most recent n HTTP server requests -func (c *HTTPServerCollector) GetRequests(n uint64) []HTTPServerRequest { - if c.buffer == nil { - return nil + return &HTTPServerCollector{ + options: options, + notifier: NewNotifierWithOptions[HTTPServerRequest](notifierOptions), + eventAggregator: options.EventAggregator, } - return c.buffer.GetRecords(n) } // Subscribe returns a channel that receives notifications of new requests @@ -95,11 +89,8 @@ func (c *HTTPServerCollector) Subscribe(ctx context.Context) <-chan HTTPServerRe return c.notifier.Subscribe(ctx) } -// Add adds an HTTP server request to the collector +// Add adds an HTTP server request to the collector and notifies subscribers func (c *HTTPServerCollector) Add(req HTTPServerRequest) { - if c.buffer != nil { - c.buffer.Add(req) - } c.notifier.Notify(req) } @@ -115,6 +106,38 @@ func (c *HTTPServerCollector) Middleware(next http.Handler) http.Handler { } } + ctx := r.Context() + + // Extract session IDs from cookies and add to context + // Each session has its own cookie named "devlog_session_{uuid}" + var sessionIDs []uuid.UUID + for _, cookie := range r.Cookies() { + if strings.HasPrefix(cookie.Name, SessionCookiePrefix) { + idStr := strings.TrimPrefix(cookie.Name, SessionCookiePrefix) + if id, err := parseUUID(idStr); err == nil { + sessionIDs = append(sessionIDs, id) + } + } + } + if len(sessionIDs) > 0 { + ctx = WithSessionIDs(ctx, sessionIDs) + r = r.WithContext(ctx) + } + + // Check if we should capture this request (using EventAggregator) + // Default to true for backward compatibility when neither aggregator nor collector is set + shouldCapture := true + if c.eventAggregator != nil { + shouldCapture = c.eventAggregator.ShouldCapture(ctx) + } + // Note: EventCollector (deprecated) is always-capture, so no change needed + + // Early bailout if not capturing (only applies when EventAggregator is active) + if !shouldCapture { + next.ServeHTTP(w, r) + return + } + // Generate a unique ID for this request id := generateID() @@ -156,10 +179,11 @@ func (c *HTTPServerCollector) Middleware(next http.Handler) http.Handler { collector: c, } - if c.eventCollector != nil { - newCtx := c.eventCollector.StartEvent(r.Context()) + // Start event tracking + if c.eventAggregator != nil { + newCtx := c.eventAggregator.StartEvent(ctx) defer func(req *HTTPServerRequest) { - c.eventCollector.EndEvent(newCtx, *req) + c.eventAggregator.EndEvent(newCtx, *req) }(&httpReq) r = r.WithContext(newCtx) @@ -209,7 +233,6 @@ func (c *HTTPServerCollector) Middleware(next http.Handler) http.Handler { // Close releases resources used by the collector func (c *HTTPServerCollector) Close() { c.notifier.Close() - c.buffer = nil } // captureResponseWriter is a wrapper for http.ResponseWriter that captures the response diff --git a/collector/http_server_request.go b/collector/http_server_request.go index a0f7d37..571f376 100644 --- a/collector/http_server_request.go +++ b/collector/http_server_request.go @@ -17,8 +17,8 @@ type HTTPServerRequest struct { RequestTime time.Time ResponseTime time.Time StatusCode int - RequestSize int64 - ResponseSize int64 + RequestSize uint64 + ResponseSize uint64 RequestHeaders http.Header ResponseHeaders http.Header RequestBody *Body @@ -32,3 +32,32 @@ type HTTPServerRequest struct { func (r HTTPServerRequest) Duration() time.Duration { return r.ResponseTime.Sub(r.RequestTime) } + +// Size returns the estimated memory size of this request in bytes +func (r HTTPServerRequest) Size() uint64 { + size := uint64(200) // base struct overhead + size += uint64(len(r.URL) + len(r.Path) + len(r.Method) + len(r.RemoteAddr)) + size += headersSize(r.RequestHeaders) + size += headersSize(r.ResponseHeaders) + if r.RequestBody != nil { + size += r.RequestBody.Size() + } + if r.ResponseBody != nil { + size += r.ResponseBody.Size() + } + for k, v := range r.Tags { + size += uint64(len(k) + len(v)) + } + return size +} + +func headersSize(h http.Header) uint64 { + var size uint64 + for k, vs := range h { + size += uint64(len(k)) + for _, v := range vs { + size += uint64(len(v)) + } + } + return size +} diff --git a/collector/http_server_test.go b/collector/http_server_test.go index e344961..df1cce2 100644 --- a/collector/http_server_test.go +++ b/collector/http_server_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,7 +20,7 @@ import ( func TestHTTPServerCollector_BasicRequest(t *testing.T) { // Create a server collector - serverCollector := collector.NewHTTPServerCollector(100) + serverCollector := collector.NewHTTPServerCollector() // Create a simple handler handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -35,6 +36,9 @@ func TestHTTPServerCollector_BasicRequest(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + // Create a client and make a request client := &http.Client{} req, err := http.NewRequest(http.MethodGet, server.URL+"/test", nil) @@ -51,9 +55,7 @@ func TestHTTPServerCollector_BasicRequest(t *testing.T) { require.NoError(t, err) assert.Equal(t, "Hello, World!", string(body)) - // Get the collected requests - requests := serverCollector.GetRequests(10) - require.Len(t, requests, 1) + requests := collect.Stop() // Verify the request details serverReq := requests[0] @@ -70,13 +72,13 @@ func TestHTTPServerCollector_BasicRequest(t *testing.T) { assert.NotNil(t, serverReq.ResponseBody) assert.Equal(t, "Hello, World!", serverReq.ResponseBody.String()) assert.True(t, serverReq.Duration() > 0) - assert.Equal(t, int64(13), serverReq.ResponseSize) // "Hello, World!" is 13 bytes + assert.Equal(t, uint64(13), serverReq.ResponseSize) // "Hello, World!" is 13 bytes } // Test for GET request with a body to ensure we capture it correctly func TestHTTPServerCollector_GetRequestWithBody(t *testing.T) { // Create a server collector - serverCollector := collector.NewHTTPServerCollector(100) + serverCollector := collector.NewHTTPServerCollector() // Create a handler that reads the request body even for GET handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -106,6 +108,9 @@ func TestHTTPServerCollector_GetRequestWithBody(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + // Create a client and make a GET request WITH a body client := &http.Client{} requestBody := "This is a GET request with a body" @@ -122,9 +127,7 @@ func TestHTTPServerCollector_GetRequestWithBody(t *testing.T) { require.NoError(t, err) assert.Equal(t, "You sent: "+requestBody, string(respBody)) - // Get the collected requests - requests := serverCollector.GetRequests(10) - require.Len(t, requests, 1) + requests := collect.Stop() // Verify the request details serverReq := requests[0] @@ -143,7 +146,7 @@ func TestHTTPServerCollector_GetRequestWithBody(t *testing.T) { func TestHTTPServerCollector_PostRequestWithBody(t *testing.T) { // Create a server collector - serverCollector := collector.NewHTTPServerCollector(100) + serverCollector := collector.NewHTTPServerCollector() // Create a handler that echoes the request body handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -170,6 +173,9 @@ func TestHTTPServerCollector_PostRequestWithBody(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + // Prepare request body (JSON) requestBody := map[string]interface{}{ "name": "John Doe", @@ -195,9 +201,7 @@ func TestHTTPServerCollector_PostRequestWithBody(t *testing.T) { require.NoError(t, err) assert.Equal(t, string(jsonBody), string(respBody)) - // Get the collected requests - requests := serverCollector.GetRequests(10) - require.Len(t, requests, 1) + requests := collect.Stop() // Verify the request details serverReq := requests[0] @@ -227,43 +231,46 @@ func TestHTTPServerCollector_PostRequestWithBody(t *testing.T) { } func TestHTTPServerCollector_DifferentStatusCodes(t *testing.T) { - // Create a server collector - serverCollector := collector.NewHTTPServerCollector(100) + // Test a variety of status codes + statusCodes := []int{200, 201, 204, 400, 401, 403, 404, 500, 503} - // Create a handler that returns different status codes based on the path - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.URL.Path, "/status/") { - w.WriteHeader(http.StatusNotFound) - return - } + for _, statusCode := range statusCodes { + t.Run(fmt.Sprintf("StatusCode_%d", statusCode), func(t *testing.T) { + // Create a server collector for each subtest + serverCollector := collector.NewHTTPServerCollector() + + // Create a handler that returns different status codes based on the path + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/status/") { + w.WriteHeader(http.StatusNotFound) + return + } - // Extract status code from path - statusCodeStr := strings.TrimPrefix(r.URL.Path, "/status/") - var statusCode int - if _, err := fmt.Sscanf(statusCodeStr, "%d", &statusCode); err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } + // Extract status code from path + statusCodeStr := strings.TrimPrefix(r.URL.Path, "/status/") + var sc int + if _, err := fmt.Sscanf(statusCodeStr, "%d", &sc); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } - w.WriteHeader(statusCode) - w.Write([]byte(fmt.Sprintf("Status: %d", statusCode))) - }) + w.WriteHeader(sc) + w.Write([]byte(fmt.Sprintf("Status: %d", sc))) + }) - // Wrap the handler with our collector - wrappedHandler := serverCollector.Middleware(handler) + // Wrap the handler with our collector + wrappedHandler := serverCollector.Middleware(handler) - // Create a test server - server := httptest.NewServer(wrappedHandler) - defer server.Close() + // Create a test server + server := httptest.NewServer(wrappedHandler) + defer server.Close() - // Create a client - client := &http.Client{} + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) - // Test a variety of status codes - statusCodes := []int{200, 201, 204, 400, 401, 403, 404, 500, 503} + // Create a client + client := &http.Client{} - for _, statusCode := range statusCodes { - t.Run(fmt.Sprintf("StatusCode_%d", statusCode), func(t *testing.T) { // Make request for this status code url := fmt.Sprintf("%s/status/%d", server.URL, statusCode) req, err := http.NewRequest(http.MethodGet, url, nil) @@ -284,20 +291,9 @@ func TestHTTPServerCollector_DifferentStatusCodes(t *testing.T) { assert.Equal(t, fmt.Sprintf("Status: %d", statusCode), string(body)) } - // Get the collected requests - requests := serverCollector.GetRequests(uint64(len(statusCodes) + 1)) - - // Find the request for this status code - var foundRequest *collector.HTTPServerRequest - for i := range requests { - if requests[i].StatusCode == statusCode && requests[i].Path == fmt.Sprintf("/status/%d", statusCode) { - foundRequest = &requests[i] - break - } - } - - require.NotNil(t, foundRequest, "Should have collected request with status code %d", statusCode) - assert.Equal(t, statusCode, foundRequest.StatusCode) + requests := collect.Stop() + require.Len(t, requests, 1, "Should have collected request with status code %d", statusCode) + assert.Equal(t, statusCode, requests[0].StatusCode) }) } } @@ -306,7 +302,7 @@ func TestHTTPServerCollector_LargeResponseBody(t *testing.T) { // Create a server collector with a small max body size to test truncation options := collector.DefaultHTTPServerOptions() options.MaxBodySize = 100 // 100 bytes max - serverCollector := collector.NewHTTPServerCollectorWithOptions(100, options) + serverCollector := collector.NewHTTPServerCollectorWithOptions(options) // Create a handler that returns a large response body handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -325,6 +321,9 @@ func TestHTTPServerCollector_LargeResponseBody(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + // Create a client and make a request client := &http.Client{} req, err := http.NewRequest(http.MethodGet, server.URL+"/large", nil) @@ -340,9 +339,7 @@ func TestHTTPServerCollector_LargeResponseBody(t *testing.T) { require.NoError(t, err) assert.Equal(t, 10000, len(body)) // 10 KB - // Get the collected requests - requests := serverCollector.GetRequests(10) - require.Len(t, requests, 1) + requests := collect.Stop() // Verify the request details serverReq := requests[0] @@ -352,7 +349,7 @@ func TestHTTPServerCollector_LargeResponseBody(t *testing.T) { // Verify the response body was captured but truncated assert.NotNil(t, serverReq.ResponseBody) - assert.Equal(t, int64(100), serverReq.ResponseBody.Size()) // Should be truncated to 100 bytes + assert.Equal(t, uint64(100), serverReq.ResponseBody.Size()) // Should be truncated to 100 bytes assert.True(t, serverReq.ResponseBody.IsTruncated()) assert.Equal(t, strings.Repeat("abcdefghij", 10), serverReq.ResponseBody.String()) } @@ -361,7 +358,7 @@ func TestHTTPServerCollector_SkipPaths(t *testing.T) { // Create a server collector with path skipping options := collector.DefaultHTTPServerOptions() options.SkipPaths = []string{"/skip/", "/assets/"} - serverCollector := collector.NewHTTPServerCollectorWithOptions(100, options) + serverCollector := collector.NewHTTPServerCollectorWithOptions(options) // Create a simple handler handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -377,6 +374,9 @@ func TestHTTPServerCollector_SkipPaths(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making requests + collect := Collect(t, serverCollector.Subscribe) + // Create a client client := &http.Client{} @@ -411,8 +411,8 @@ func TestHTTPServerCollector_SkipPaths(t *testing.T) { }) } - // Get the collected requests - requests := serverCollector.GetRequests(10) + // Collect all the requests + requests := collect.Stop() // Should only have collected the non-skipped paths assert.Equal(t, 2, len(requests), "Should have collected exactly 2 requests (the non-skipped ones)") @@ -431,7 +431,7 @@ func TestHTTPServerCollector_SkipPaths(t *testing.T) { func TestHTTPServerCollector_StreamingResponse(t *testing.T) { // Create a server collector - serverCollector := collector.NewHTTPServerCollector(100) + serverCollector := collector.NewHTTPServerCollector() // Create a handler that streams a response handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -458,6 +458,9 @@ func TestHTTPServerCollector_StreamingResponse(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + // Create a client and make a request client := &http.Client{} req, err := http.NewRequest(http.MethodGet, server.URL+"/streaming", nil) @@ -486,9 +489,7 @@ func TestHTTPServerCollector_StreamingResponse(t *testing.T) { expectedResponse := "Chunk 0\nChunk 1\nChunk 2\nChunk 3\nChunk 4\n" assert.Equal(t, expectedResponse, receivedData.String()) - // Get the collected requests - requests := serverCollector.GetRequests(10) - require.Len(t, requests, 1) + requests := collect.Stop() // Verify the request details serverReq := requests[0] @@ -506,7 +507,7 @@ func TestHTTPServerCollector_DisabledBodyCapture(t *testing.T) { options := collector.DefaultHTTPServerOptions() options.CaptureRequestBody = false options.CaptureResponseBody = false - serverCollector := collector.NewHTTPServerCollectorWithOptions(100, options) + serverCollector := collector.NewHTTPServerCollectorWithOptions(options) // Create a simple POST handler handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -529,6 +530,9 @@ func TestHTTPServerCollector_DisabledBodyCapture(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + // Prepare request body requestBody := `{"message":"This body should not be captured"}` @@ -548,9 +552,7 @@ func TestHTTPServerCollector_DisabledBodyCapture(t *testing.T) { require.NoError(t, err) assert.Equal(t, requestBody, string(respBody)) - // Get the collected requests - requests := serverCollector.GetRequests(10) - require.Len(t, requests, 1) + requests := collect.Stop() // Verify the request details serverReq := requests[0] @@ -563,90 +565,9 @@ func TestHTTPServerCollector_DisabledBodyCapture(t *testing.T) { assert.Nil(t, serverReq.ResponseBody) } -func TestHTTPServerCollector_RingBufferCapacity(t *testing.T) { - // Create a server collector with a small capacity - capacity := uint64(3) - serverCollector := collector.NewHTTPServerCollector(capacity) - - // Create a simple handler - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte("Path: " + r.URL.Path)) - }) - - // Wrap the handler with our collector - wrappedHandler := serverCollector.Middleware(handler) - - // Create a test server - server := httptest.NewServer(wrappedHandler) - defer server.Close() - - // Create a client - client := &http.Client{} - - // Make 5 requests (more than our capacity) - for i := 0; i < 5; i++ { - path := fmt.Sprintf("/test-%d", i) - req, err := http.NewRequest(http.MethodGet, server.URL+path, nil) - require.NoError(t, err) - - // Send the request - resp, err := client.Do(req) - require.NoError(t, err) - - // Verify the response - assert.Equal(t, http.StatusOK, resp.StatusCode) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Equal(t, "Path: "+path, string(body)) - - resp.Body.Close() - } - - // Get the collected requests - requests := serverCollector.GetRequests(10) - - // Should only have collected the most recent 3 requests - assert.Equal(t, int(capacity), len(requests), "Should have collected exactly %d requests (limited by capacity)", capacity) - - // Verify we have the most recent requests - expectedPaths := make(map[string]bool) - for i := 2; i < 5; i++ { // Should have requests 2, 3, and 4 - expectedPaths[fmt.Sprintf("/test-%d", i)] = false - } - - for _, req := range requests { - if _, exists := expectedPaths[req.Path]; exists { - expectedPaths[req.Path] = true - } else { - t.Errorf("Unexpected path in results: %s", req.Path) - } - } - - // All expected paths should have been found - for path, found := range expectedPaths { - assert.True(t, found, "Expected path %s not found in results", path) - } -} - func TestHTTPServerCollector_UnreadRequestBodyCapture(t *testing.T) { // This test verifies that request bodies are captured even when handlers don't read them - serverCollector := collector.NewHTTPServerCollector(100) - - mux := http.NewServeMux() - mux.HandleFunc("/exists", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("OK")) - }) - - // Wrap the handler with our collector - wrappedHandler := serverCollector.Middleware(mux) - - // Create a test server - server := httptest.NewServer(wrappedHandler) - defer server.Close() - testCases := []struct { name string path string @@ -679,6 +600,24 @@ func TestHTTPServerCollector_UnreadRequestBodyCapture(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + // Create a fresh collector for each subtest + serverCollector := collector.NewHTTPServerCollector() + + mux := http.NewServeMux() + mux.HandleFunc("/exists", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("OK")) + }) + + // Wrap the handler with our collector + wrappedHandler := serverCollector.Middleware(mux) + + // Create a test server + server := httptest.NewServer(wrappedHandler) + defer server.Close() + + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + // Create a client and make a POST request with a body that won't be read client := &http.Client{} req, err := http.NewRequest(http.MethodPost, server.URL+tc.path, strings.NewReader(tc.body)) @@ -693,19 +632,10 @@ func TestHTTPServerCollector_UnreadRequestBodyCapture(t *testing.T) { // Verify expected status assert.Equal(t, tc.expectedStatus, resp.StatusCode) - // Get the collected requests - requests := serverCollector.GetRequests(10) - require.GreaterOrEqual(t, len(requests), 1, "Should have collected at least one request") + requests := collect.Stop() - // Find our specific request - var serverReq *collector.HTTPServerRequest - for i := range requests { - if requests[i].Path == tc.path && requests[i].Method == http.MethodPost { - serverReq = &requests[i] - break - } - } - require.NotNil(t, serverReq, "Should have found our POST request to %s", tc.path) + // Get the request (should be the only one) + serverReq := requests[0] // Verify the request details assert.Equal(t, http.MethodPost, serverReq.Method) @@ -718,7 +648,7 @@ func TestHTTPServerCollector_UnreadRequestBodyCapture(t *testing.T) { capturedBody := serverReq.RequestBody.String() assert.Equal(t, tc.body, capturedBody, "Should capture the exact body content even when unread") assert.True(t, serverReq.RequestBody.IsFullyCaptured(), "Body should be marked as fully captured") - assert.Equal(t, int64(len(tc.body)), serverReq.RequestBody.Size(), "Should capture the full body size") + assert.Equal(t, uint64(len(tc.body)), serverReq.RequestBody.Size(), "Should capture the full body size") } }) } @@ -726,7 +656,7 @@ func TestHTTPServerCollector_UnreadRequestBodyCapture(t *testing.T) { func TestHTTPServerCollector_MultipleHandlers(t *testing.T) { // Create a server collector - serverCollector := collector.NewHTTPServerCollector(100) + serverCollector := collector.NewHTTPServerCollector() // Create handlers for different routes mux := http.NewServeMux() @@ -752,6 +682,9 @@ func TestHTTPServerCollector_MultipleHandlers(t *testing.T) { server := httptest.NewServer(wrappedHandler) defer server.Close() + // Start collecting before making requests + collect := Collect(t, serverCollector.Subscribe) + // Create a client client := &http.Client{} @@ -787,8 +720,8 @@ func TestHTTPServerCollector_MultipleHandlers(t *testing.T) { }) } - // Get the collected requests - requests := serverCollector.GetRequests(10) + // Get all requests + requests := collect.Stop() assert.Equal(t, 2, len(requests), "Should have collected 2 requests") // Verify we have both paths @@ -807,3 +740,206 @@ func TestHTTPServerCollector_MultipleHandlers(t *testing.T) { assert.True(t, capturedPaths["/api/users"], "Should have captured /api/users") assert.True(t, capturedPaths["/web/index"], "Should have captured /web/index") } + +func TestHTTPServerCollector_WithEventAggregator_GlobalMode(t *testing.T) { + // Create an EventAggregator with a GlobalMode storage + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeGlobal) + aggregator.RegisterStorage(storage) + + // Create a server collector with the EventAggregator + options := collector.DefaultHTTPServerOptions() + options.EventAggregator = aggregator + serverCollector := collector.NewHTTPServerCollectorWithOptions(options) + + // Create a simple handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Hello, World!")) + }) + + // Wrap the handler with our collector + wrappedHandler := serverCollector.Middleware(handler) + + // Create a test server + server := httptest.NewServer(wrappedHandler) + defer server.Close() + + // Make a request (without session cookie - GlobalMode should capture anyway) + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, server.URL+"/test", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "Hello, World!", string(body)) + + // Verify the event was captured in the storage + events := storage.GetEvents(10) + require.Len(t, events, 1) + + // The event data should be an HTTPServerRequest + httpReq, ok := events[0].Data.(collector.HTTPServerRequest) + require.True(t, ok, "Event data should be HTTPServerRequest") + assert.Equal(t, http.MethodGet, httpReq.Method) + assert.Equal(t, "/test", httpReq.Path) + assert.Equal(t, http.StatusOK, httpReq.StatusCode) +} + +func TestHTTPServerCollector_WithEventAggregator_SessionMode_NoMatch(t *testing.T) { + // Create an EventAggregator with a SessionMode storage + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + aggregator.RegisterStorage(storage) + + // Create a server collector with the EventAggregator + options := collector.DefaultHTTPServerOptions() + options.EventAggregator = aggregator + serverCollector := collector.NewHTTPServerCollectorWithOptions(options) + + // Create a simple handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Hello, World!")) + }) + + // Wrap the handler with our collector + wrappedHandler := serverCollector.Middleware(handler) + + // Create a test server + server := httptest.NewServer(wrappedHandler) + defer server.Close() + + // Make a request without a session cookie (SessionMode should NOT capture) + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, server.URL+"/test", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "Hello, World!", string(body)) + + // Verify no events were captured (session doesn't match) + events := storage.GetEvents(10) + assert.Len(t, events, 0) +} + +func TestHTTPServerCollector_WithEventAggregator_SessionMode_Match(t *testing.T) { + // Create an EventAggregator with a SessionMode storage + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := collector.NewCaptureStorage(sessionID, 100, collector.CaptureModeSession) + aggregator.RegisterStorage(storage) + + // Create a server collector with the EventAggregator + options := collector.DefaultHTTPServerOptions() + options.EventAggregator = aggregator + serverCollector := collector.NewHTTPServerCollectorWithOptions(options) + + // Create a simple handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Hello, World!")) + }) + + // Wrap the handler with our collector + wrappedHandler := serverCollector.Middleware(handler) + + // Create a test server + server := httptest.NewServer(wrappedHandler) + defer server.Close() + + // Make a request WITH the session cookie (SessionMode should capture) + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, server.URL+"/test", nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{ + Name: collector.SessionCookiePrefix + sessionID.String(), + Value: "1", + }) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "Hello, World!", string(body)) + + // Verify the event was captured + events := storage.GetEvents(10) + require.Len(t, events, 1) + + // The event data should be an HTTPServerRequest + httpReq, ok := events[0].Data.(collector.HTTPServerRequest) + require.True(t, ok, "Event data should be HTTPServerRequest") + assert.Equal(t, http.MethodGet, httpReq.Method) + assert.Equal(t, "/test", httpReq.Path) +} + +func TestHTTPServerCollector_WithEventAggregator_NoStorage(t *testing.T) { + // Create an EventAggregator with NO storage registered + aggregator := collector.NewEventAggregator() + defer aggregator.Close() + + // Create a server collector with the EventAggregator + options := collector.DefaultHTTPServerOptions() + options.EventAggregator = aggregator + serverCollector := collector.NewHTTPServerCollectorWithOptions(options) + + // Create a simple handler + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Hello, World!")) + }) + + // Wrap the handler with our collector + wrappedHandler := serverCollector.Middleware(handler) + + // Create a test server + server := httptest.NewServer(wrappedHandler) + defer server.Close() + + // Start collecting before making request + collect := Collect(t, serverCollector.Subscribe) + + // Make a request (no storage means ShouldCapture returns false, early bailout) + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, server.URL+"/test", nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "Hello, World!", string(body)) + + // Small delay to ensure any notification would have been received + time.Sleep(10 * time.Millisecond) + + // Verify no requests were captured (early bailout should prevent capture) + requests := collect.Stop() + assert.Len(t, requests, 0) +} diff --git a/collector/log_collector.go b/collector/log_collector.go index 48914f7..cf03daf 100644 --- a/collector/log_collector.go +++ b/collector/log_collector.go @@ -9,26 +9,15 @@ import ( ) type LogCollector struct { - buffer *RingBuffer[slog.Record] - notifier *Notifier[slog.Record] - eventCollector *EventCollector + notifier *Notifier[slog.Record] + eventAggregator *EventAggregator } func (c *LogCollector) Collect(ctx context.Context, record slog.Record) { - if c.buffer != nil { - c.buffer.Add(record) - } c.notifier.Notify(record) - if c.eventCollector != nil { - c.eventCollector.CollectEvent(ctx, record) - } -} - -func (c *LogCollector) Tail(n int) []slog.Record { - if c.buffer == nil { - return nil + if c.eventAggregator != nil { + c.eventAggregator.CollectEvent(ctx, record) } - return c.buffer.GetRecords(uint64(n)) } // Subscribe returns a channel that receives notifications of new log records @@ -36,8 +25,8 @@ func (c *LogCollector) Subscribe(ctx context.Context) <-chan slog.Record { return c.notifier.Subscribe(ctx) } -func NewLogCollector(capacity uint64) *LogCollector { - return NewLogCollectorWithOptions(capacity, DefaultLogOptions()) +func NewLogCollector() *LogCollector { + return NewLogCollectorWithOptions(DefaultLogOptions()) } func DefaultLogOptions() LogOptions { @@ -48,31 +37,25 @@ type LogOptions struct { // NotifierOptions are options for notification about new logs NotifierOptions *NotifierOptions - // EventCollector is an optional event collector for collecting logs as grouped events - EventCollector *EventCollector + // EventAggregator is the aggregator for collecting logs as grouped events + EventAggregator *EventAggregator } -func NewLogCollectorWithOptions(capacity uint64, options LogOptions) *LogCollector { +func NewLogCollectorWithOptions(options LogOptions) *LogCollector { notifierOptions := DefaultNotifierOptions() if options.NotifierOptions != nil { notifierOptions = *options.NotifierOptions } - collector := &LogCollector{ - notifier: NewNotifierWithOptions[slog.Record](notifierOptions), - eventCollector: options.EventCollector, - } - if capacity > 0 { - collector.buffer = NewRingBuffer[slog.Record](capacity) + return &LogCollector{ + notifier: NewNotifierWithOptions[slog.Record](notifierOptions), + eventAggregator: options.EventAggregator, } - - return collector } // Close releases resources used by the collector func (c *LogCollector) Close() { c.notifier.Close() - c.buffer = nil } type CollectSlogLogsOptions struct { diff --git a/collector/notifier.go b/collector/notifier.go index 33bd23b..48cd5ef 100644 --- a/collector/notifier.go +++ b/collector/notifier.go @@ -14,6 +14,7 @@ type Notifier[T any] struct { notifyCh chan T closeOnce sync.Once closed bool + pending sync.WaitGroup // tracks items in notification pipeline } // NotifierOptions configures a notifier @@ -83,6 +84,9 @@ func (n *Notifier[T]) Subscribe(ctx context.Context) <-chan T { // Unsubscribe removes a subscription func (n *Notifier[T]) Unsubscribe(ch <-chan T) { + // Wait for any pending notifications to be distributed + n.pending.Wait() + n.mu.Lock() defer n.mu.Unlock() @@ -103,18 +107,23 @@ func (n *Notifier[T]) Notify(item T) { } n.mu.RUnlock() + n.pending.Add(1) // Non-blocking send to notification channel select { case n.notifyCh <- item: // Successfully sent default: // Channel full, drop notification + n.pending.Done() } } // Close closes the notifier and all subscriber channels func (n *Notifier[T]) Close() { n.closeOnce.Do(func() { + // Wait for any pending notifications to be distributed + n.pending.Wait() + n.mu.Lock() n.closed = true @@ -147,5 +156,6 @@ func (n *Notifier[T]) processNotifications() { } n.mu.RUnlock() + n.pending.Done() } } diff --git a/collector/session_context.go b/collector/session_context.go new file mode 100644 index 0000000..754e36a --- /dev/null +++ b/collector/session_context.go @@ -0,0 +1,26 @@ +package collector + +import ( + "context" + + "github.com/gofrs/uuid" +) + +type sessionIDsKeyType struct{} + +var sessionIDsKey = sessionIDsKeyType{} + +// WithSessionIDs returns a new context with the session IDs added. +// Multiple session IDs are used when multiple dashboard tabs have capture enabled. +func WithSessionIDs(ctx context.Context, sessionIDs []uuid.UUID) context.Context { + return context.WithValue(ctx, sessionIDsKey, sessionIDs) +} + +// SessionIDsFromContext retrieves the session IDs from the context. +// Returns the session IDs and true if found, or nil and false if not set. +func SessionIDsFromContext(ctx context.Context) ([]uuid.UUID, bool) { + if sessionIDs, ok := ctx.Value(sessionIDsKey).([]uuid.UUID); ok { + return sessionIDs, true + } + return nil, false +} diff --git a/collector/session_context_test.go b/collector/session_context_test.go new file mode 100644 index 0000000..7f70223 --- /dev/null +++ b/collector/session_context_test.go @@ -0,0 +1,55 @@ +package collector_test + +import ( + "context" + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + + "github.com/networkteam/devlog/collector" +) + +func TestWithSessionIDs_AddsToContext(t *testing.T) { + ctx := context.Background() + sessionID := uuid.Must(uuid.NewV4()) + + newCtx := collector.WithSessionIDs(ctx, []uuid.UUID{sessionID}) + + retrievedIDs, ok := collector.SessionIDsFromContext(newCtx) + assert.True(t, ok) + assert.Equal(t, []uuid.UUID{sessionID}, retrievedIDs) +} + +func TestWithSessionIDs_MultipleIDs(t *testing.T) { + ctx := context.Background() + sessionID1 := uuid.Must(uuid.NewV4()) + sessionID2 := uuid.Must(uuid.NewV4()) + + newCtx := collector.WithSessionIDs(ctx, []uuid.UUID{sessionID1, sessionID2}) + + retrievedIDs, ok := collector.SessionIDsFromContext(newCtx) + assert.True(t, ok) + assert.Len(t, retrievedIDs, 2) + assert.Contains(t, retrievedIDs, sessionID1) + assert.Contains(t, retrievedIDs, sessionID2) +} + +func TestSessionIDsFromContext_NotSet(t *testing.T) { + ctx := context.Background() + + retrievedIDs, ok := collector.SessionIDsFromContext(ctx) + + assert.False(t, ok) + assert.Nil(t, retrievedIDs) +} + +func TestSessionIDsFromContext_Set(t *testing.T) { + sessionID := uuid.Must(uuid.NewV4()) + ctx := collector.WithSessionIDs(context.Background(), []uuid.UUID{sessionID}) + + retrievedIDs, ok := collector.SessionIDsFromContext(ctx) + + assert.True(t, ok) + assert.Equal(t, []uuid.UUID{sessionID}, retrievedIDs) +} diff --git a/dashboard/handler.go b/dashboard/handler.go index 5dd913f..4069d3d 100644 --- a/dashboard/handler.go +++ b/dashboard/handler.go @@ -2,10 +2,11 @@ package dashboard import ( "context" + "encoding/json" "fmt" "net/http" - "net/url" "slices" + "time" "github.com/a-h/templ" "github.com/gofrs/uuid" @@ -15,8 +16,15 @@ import ( "github.com/networkteam/devlog/dashboard/views" ) +// DefaultStorageCapacity is the default number of events per storage +const DefaultStorageCapacity uint64 = 1000 + +// DefaultSessionIdleTimeout is the default time before an inactive session is cleaned up +const DefaultSessionIdleTimeout = 30 * time.Second + type Handler struct { - eventCollector *collector.EventCollector + sessions *SessionManager + eventAggregator *collector.EventAggregator pathPrefix string truncateAfter uint64 @@ -24,90 +32,224 @@ type Handler struct { mux http.Handler } -type HandlerOptions struct { - EventCollector *collector.EventCollector - - // PathPrefix where the Handler is mounted (e.g. "/_devlog"), can be left empty if the Handler is at the root ("/"). - PathPrefix string - // TruncateAfter is the maximum number of events to show in the event list and dashboard. If 0 or larger than the event collector capacity, it will use the collector's capacity. - TruncateAfter uint64 -} +// NewHandler creates a new dashboard handler. +// eventAggregator is the central event aggregator for collecting events. +// Use HandlerOption functions to customize behavior (path prefix, storage capacity, etc.). +func NewHandler(eventAggregator *collector.EventAggregator, opts ...HandlerOption) *Handler { + // Apply functional options + options := handlerOptions{} + for _, opt := range opts { + opt(&options) + } -func NewHandler(options HandlerOptions) *Handler { mux := http.NewServeMux() - if options.TruncateAfter == 0 || options.TruncateAfter > options.EventCollector.Capacity() { - options.TruncateAfter = options.EventCollector.Capacity() + + storageCapacity := options.StorageCapacity + if storageCapacity == 0 { + storageCapacity = DefaultStorageCapacity } - handler := &Handler{ - eventCollector: options.EventCollector, - truncateAfter: options.TruncateAfter, + truncateAfter := options.TruncateAfter + if truncateAfter == 0 || truncateAfter > storageCapacity { + truncateAfter = storageCapacity + } + + sessionIdleTimeout := options.SessionIdleTimeout + if sessionIdleTimeout == 0 { + sessionIdleTimeout = DefaultSessionIdleTimeout + } - pathPrefix: options.PathPrefix, + sessions := NewSessionManager(SessionManagerOptions{ + EventAggregator: eventAggregator, + StorageCapacity: storageCapacity, + IdleTimeout: sessionIdleTimeout, + MaxSessions: options.MaxSessions, + }) - mux: setHandlerOptions(options, mux), + handler := &Handler{ + sessions: sessions, + eventAggregator: eventAggregator, + truncateAfter: truncateAfter, + pathPrefix: options.PathPrefix, + mux: mux, } - mux.HandleFunc("GET /{$}", handler.root) - mux.HandleFunc("GET /event-list", handler.getEventList) - mux.HandleFunc("DELETE /event-list", handler.clearEventList) - mux.HandleFunc("GET /event/{eventId}", handler.getEventDetails) - mux.HandleFunc("GET /events-sse", handler.getEventsSSE) - mux.HandleFunc("GET /download/request-body/{eventId}", handler.downloadRequestBody) - mux.HandleFunc("GET /download/response-body/{eventId}", handler.downloadResponseBody) + // Static assets (no session required) + mux.Handle("GET /static/", http.StripPrefix("/static", http.FileServerFS(static.Assets))) + + // Global stats endpoint (no session required) + mux.HandleFunc("GET /stats", handler.getStats) + + // Root redirect - creates new session and redirects + mux.HandleFunc("GET /{$}", handler.rootRedirect) + + // Session-scoped routes under /s/{sid}/ (the /s/ prefix avoids conflicts with /static/) + mux.HandleFunc("GET /s/{sid}/{$}", handler.root) + mux.HandleFunc("GET /s/{sid}/event-list", handler.getEventList) + mux.HandleFunc("DELETE /s/{sid}/event-list", handler.clearEventList) + mux.HandleFunc("GET /s/{sid}/event/{eventId}", handler.getEventDetails) + mux.HandleFunc("GET /s/{sid}/events-sse", handler.getEventsSSE) + mux.HandleFunc("GET /s/{sid}/download/request-body/{eventId}", handler.downloadRequestBody) + mux.HandleFunc("GET /s/{sid}/download/response-body/{eventId}", handler.downloadResponseBody) - mux.Handle("/static/", http.StripPrefix("/static", http.FileServerFS(static.Assets))) + // Capture control endpoints + mux.HandleFunc("POST /s/{sid}/capture/start", handler.captureStart) + mux.HandleFunc("POST /s/{sid}/capture/stop", handler.captureStop) + mux.HandleFunc("POST /s/{sid}/capture/mode", handler.setCaptureMode) + mux.HandleFunc("GET /s/{sid}/capture/status", handler.captureStatus) + mux.HandleFunc("POST /s/{sid}/capture/cleanup", handler.captureCleanup) return handler } -func setHandlerOptions(options HandlerOptions, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - ctx = views.WithHandlerOptions(ctx, views.HandlerOptions{ - PathPrefix: options.PathPrefix, - TruncateAfter: options.TruncateAfter, - }) - r = r.WithContext(ctx) - - next.ServeHTTP(w, r) +// withHandlerOptions is a helper to set HandlerOptions in context before rendering +func (h *Handler) withHandlerOptions(r *http.Request, sessionID string, captureActive bool, captureMode string) *http.Request { + ctx := views.WithHandlerOptions(r.Context(), views.HandlerOptions{ + PathPrefix: h.pathPrefix, + TruncateAfter: h.truncateAfter, + SessionID: sessionID, + CaptureActive: captureActive, + CaptureMode: captureMode, }) + return r.WithContext(ctx) } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } +// Close shuts down the handler and releases resources +func (h *Handler) Close() { + h.sessions.Close() +} + +// getSessionID extracts the session ID from the URL path parameter +func (h *Handler) getSessionID(r *http.Request) (uuid.UUID, bool) { + sidStr := r.PathValue("sid") + if sidStr == "" { + return uuid.Nil, false + } + sessionID, err := uuid.FromString(sidStr) + if err != nil { + return uuid.Nil, false + } + return sessionID, true +} + +// setSessionCookie sets the session cookie for event filtering +func (h *Handler) setSessionCookie(w http.ResponseWriter, sessionID uuid.UUID) { + http.SetCookie(w, &http.Cookie{ + Name: collector.SessionCookiePrefix + sessionID.String(), + Value: "1", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// clearSessionCookie clears the session cookie +func (h *Handler) clearSessionCookie(w http.ResponseWriter, sessionID uuid.UUID) { + http.SetCookie(w, &http.Cookie{ + Name: collector.SessionCookiePrefix + sessionID.String(), + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) +} + +// rootRedirect redirects to a new session +func (h *Handler) rootRedirect(w http.ResponseWriter, r *http.Request) { + sessionID := uuid.Must(uuid.NewV4()) + http.Redirect(w, r, fmt.Sprintf("%s/s/%s/", h.pathPrefix, sessionID), http.StatusTemporaryRedirect) +} + func (h *Handler) root(w http.ResponseWriter, r *http.Request) { - idStr := r.URL.Query().Get("id") + sessionID, ok := h.getSessionID(r) + if !ok { + http.Error(w, "Invalid session ID", http.StatusBadRequest) + return + } + + storage := h.sessions.Get(sessionID) + + // Check URL params for capture state - allows recreating session from URL + captureParam := r.URL.Query().Get("capture") + modeParam := r.URL.Query().Get("mode") + if modeParam == "" { + modeParam = "session" // default + } + + // Recreate session from URL params if needed + if storage == nil && captureParam == "true" { + mode := collector.ParseCaptureModeOrDefault(modeParam) + var created bool + var err error + storage, created, err = h.sessions.GetOrCreate(sessionID, mode) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + if created && mode == collector.CaptureModeSession { + h.setSessionCookie(w, sessionID) + } + } + var selectedEvent *collector.Event - if idStr != "" { + idStr := r.URL.Query().Get("id") + if idStr != "" && storage != nil { eventID, err := uuid.FromString(idStr) if err != nil { http.Error(w, "Invalid event id", http.StatusBadRequest) return } - event, exists := h.eventCollector.GetEvent(eventID) + event, exists := storage.GetEvent(eventID) if !exists { - http.Redirect(w, r, fmt.Sprintf("%s/", h.pathPrefix), http.StatusTemporaryRedirect) // TODO Build correct URL + http.Redirect(w, r, fmt.Sprintf("%s/s/%s/", h.pathPrefix, sessionID), http.StatusTemporaryRedirect) return - } else { - selectedEvent = event } + selectedEvent = event } - recentEvents := h.loadRecentEvents() + var recentEvents []*collector.Event + captureActive := false + captureMode := modeParam + if storage != nil { + h.sessions.UpdateActivity(sessionID) + recentEvents = h.loadRecentEvents(storage) + captureActive = true + captureMode = storage.CaptureMode().String() + if storage.CaptureMode() == collector.CaptureModeSession { + // Re-set session cookie for session mode (cleared on beforeunload) + h.setSessionCookie(w, sessionID) + } + } + r = h.withHandlerOptions(r, sessionID.String(), captureActive, captureMode) templ.Handler( views.Dashboard(views.DashboardProps{ SelectedEvent: selectedEvent, Events: recentEvents, + CaptureActive: captureActive, + CaptureMode: captureMode, }), ).ServeHTTP(w, r) } func (h *Handler) getEventList(w http.ResponseWriter, r *http.Request) { - recentEvents := h.loadRecentEvents() + sessionID, _ := h.getSessionID(r) + storage := h.sessions.Get(sessionID) + + var recentEvents []*collector.Event + captureActive := false + captureMode := "session" + if storage != nil { + recentEvents = h.loadRecentEvents(storage) + captureActive = true + captureMode = storage.CaptureMode().String() + } + + r = h.withHandlerOptions(r, sessionID.String(), captureActive, captureMode) selectedStr := r.URL.Query().Get("selected") var selectedEventID *uuid.UUID @@ -122,35 +264,53 @@ func (h *Handler) getEventList(w http.ResponseWriter, r *http.Request) { views.EventList(views.EventListProps{ Events: recentEvents, SelectedEventID: selectedEventID, + CaptureActive: captureActive, + CaptureMode: captureMode, }), ).ServeHTTP(w, r) } func (h *Handler) clearEventList(w http.ResponseWriter, r *http.Request) { - h.eventCollector.Clear() - - // Check if there's an id parameter in the current URL that needs to be removed to unselect an event - currentURL, _ := url.Parse(r.Header.Get("HX-Current-URL")) - if currentURL != nil && currentURL.Query().Get("id") != "" { - // Build URL preserving all query parameters except 'id' - query := r.URL.Query() - query.Del("id") - - redirectURL := fmt.Sprintf("%s/", h.pathPrefix) - if len(query) > 0 { - redirectURL += "?" + query.Encode() - } + sessionID, _ := h.getSessionID(r) + storage := h.sessions.Get(sessionID) + if storage != nil { + storage.Clear() + } - // Use HTMX header to update the URL client-side without the id parameter - w.Header().Set("HX-Push-Url", redirectURL) + // Keep capture active if storage exists + captureActive := storage != nil + captureMode := "session" + if storage != nil { + captureMode = storage.CaptureMode().String() } + r = h.withHandlerOptions(r, sessionID.String(), captureActive, captureMode) + opts := views.HandlerOptions{ + PathPrefix: h.pathPrefix, + SessionID: sessionID.String(), + CaptureActive: captureActive, + CaptureMode: captureMode, + } + + // Update URL to remove id parameter but preserve capture state + w.Header().Set("HX-Push-Url", opts.BuildEventDetailURL("")) + templ.Handler( - views.SplitLayout(views.EventList(views.EventListProps{}), views.EventDetailContainer(nil)), + views.SplitLayout(views.EventList(views.EventListProps{CaptureActive: captureActive, CaptureMode: captureMode}), views.EventDetailContainer(nil)), ).ServeHTTP(w, r) } func (h *Handler) getEventDetails(w http.ResponseWriter, r *http.Request) { + sessionID, _ := h.getSessionID(r) + storage := h.sessions.Get(sessionID) + if storage == nil { + http.Error(w, "No capture session active", http.StatusNotFound) + return + } + + captureMode := storage.CaptureMode().String() + r = h.withHandlerOptions(r, sessionID.String(), true, captureMode) + idStr := r.PathValue("eventId") eventID, err := uuid.FromString(idStr) if err != nil { @@ -158,7 +318,7 @@ func (h *Handler) getEventDetails(w http.ResponseWriter, r *http.Request) { return } - event, exists := h.eventCollector.GetEvent(eventID) + event, exists := storage.GetEvent(eventID) if !exists { http.Error(w, "Event not found", http.StatusNotFound) return @@ -171,6 +331,39 @@ func (h *Handler) getEventDetails(w http.ResponseWriter, r *http.Request) { // getEventsSSE handles SSE connections for real-time log updates func (h *Handler) getEventsSSE(w http.ResponseWriter, r *http.Request) { + sessionID, hasSession := h.getSessionID(r) + if !hasSession { + http.Error(w, "Invalid session ID", http.StatusBadRequest) + return + } + + storage := h.sessions.Get(sessionID) + if storage == nil { + // Session was cleaned up - recreate it (fresh and empty) + // Use mode from query param, default to session mode + mode := collector.ParseCaptureModeOrDefault(r.URL.Query().Get("mode")) + + var created bool + var err error + storage, created, err = h.sessions.GetOrCreate(sessionID, mode) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + // Set cookie if session mode and newly created + if created && mode == collector.CaptureModeSession { + h.setSessionCookie(w, sessionID) + } + } + + // Set handler options in context for template rendering + captureMode := storage.CaptureMode().String() + r = h.withHandlerOptions(r, sessionID.String(), true, captureMode) + + // Update activity for this session + h.sessions.UpdateActivity(sessionID) + // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -181,34 +374,42 @@ func (h *Handler) getEventsSSE(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithCancel(r.Context()) defer cancel() - // Monitor for client disconnect - go func() { - <-ctx.Done() - // Context was canceled, connection is closed - }() - - // Create a notification channel for new logs - eventCh := h.eventCollector.Subscribe(ctx) + // Create a notification channel for new events from the user's storage + eventCh := storage.Subscribe(ctx) // Send a keep-alive message initially to ensure the connection is established fmt.Fprintf(w, "event: keepalive\ndata: connected\n\n") w.(http.Flusher).Flush() - // Listen for new logs and send them as SSE events + // Create a ticker to keep the session alive and send keepalive messages + // This prevents idle timeout while SSE connection is open + keepaliveTicker := time.NewTicker(h.sessions.IdleTimeout() / 2) + defer keepaliveTicker.Stop() + + // Listen for new events and send them as SSE events for { select { case <-ctx.Done(): return // Client disconnected + case <-keepaliveTicker.C: + // Keep session alive while SSE is connected + h.sessions.UpdateActivity(sessionID) + // Send keepalive to client + fmt.Fprintf(w, "event: keepalive\ndata: ping\n\n") + w.(http.Flusher).Flush() case event, ok := <-eventCh: if !ok { return // Channel closed } + // Update activity on each event + h.sessions.UpdateActivity(sessionID) + // Send as SSE event fmt.Fprintf(w, "event: new-event\n") fmt.Fprintf(w, "data: ") - views.EventListItem(&event, nil).Render(ctx, w) + views.EventListItem(event, nil).Render(ctx, w) fmt.Fprintf(w, "\n\n") @@ -217,8 +418,8 @@ func (h *Handler) getEventsSSE(w http.ResponseWriter, r *http.Request) { } } -func (h *Handler) loadRecentEvents() []*collector.Event { - recentEvents := h.eventCollector.GetEvents(h.truncateAfter) +func (h *Handler) loadRecentEvents(storage *collector.CaptureStorage) []*collector.Event { + recentEvents := storage.GetEvents(h.truncateAfter) slices.Reverse(recentEvents) return recentEvents @@ -226,6 +427,13 @@ func (h *Handler) loadRecentEvents() []*collector.Event { // downloadRequestBody handles downloading the request body for an event func (h *Handler) downloadRequestBody(w http.ResponseWriter, r *http.Request) { + sessionID, _ := h.getSessionID(r) + storage := h.sessions.Get(sessionID) + if storage == nil { + http.Error(w, "No capture session active", http.StatusNotFound) + return + } + idStr := r.PathValue("eventId") eventID, err := uuid.FromString(idStr) if err != nil { @@ -233,7 +441,7 @@ func (h *Handler) downloadRequestBody(w http.ResponseWriter, r *http.Request) { return } - event, exists := h.eventCollector.GetEvent(eventID) + event, exists := storage.GetEvent(eventID) if !exists { http.Error(w, "Event not found", http.StatusNotFound) return @@ -274,6 +482,13 @@ func (h *Handler) downloadRequestBody(w http.ResponseWriter, r *http.Request) { // downloadResponseBody handles downloading the response body for an event func (h *Handler) downloadResponseBody(w http.ResponseWriter, r *http.Request) { + sessionID, _ := h.getSessionID(r) + storage := h.sessions.Get(sessionID) + if storage == nil { + http.Error(w, "No capture session active", http.StatusNotFound) + return + } + idStr := r.PathValue("eventId") eventID, err := uuid.FromString(idStr) if err != nil { @@ -281,7 +496,7 @@ func (h *Handler) downloadResponseBody(w http.ResponseWriter, r *http.Request) { return } - event, exists := h.eventCollector.GetEvent(eventID) + event, exists := storage.GetEvent(eventID) if !exists { http.Error(w, "Event not found", http.StatusNotFound) return @@ -319,3 +534,210 @@ func (h *Handler) downloadResponseBody(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) w.Write(body) } + +// Capture control endpoints + +// CaptureStatusResponse is the response for GET /capture/status +type CaptureStatusResponse struct { + Active bool `json:"active"` + Mode string `json:"mode,omitempty"` // "session" or "global" +} + +// captureStart handles POST /capture/start - creates or resumes a capture session +func (h *Handler) captureStart(w http.ResponseWriter, r *http.Request) { + sessionID, ok := h.getSessionID(r) + if !ok { + http.Error(w, "Invalid session ID", http.StatusBadRequest) + return + } + + // Parse mode from request body (default to session mode) + mode := collector.ParseCaptureModeOrDefault(r.FormValue("mode")) + + // Get or create session + storage, created, err := h.sessions.GetOrCreate(sessionID, mode) + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + + if !created { + // Session exists, resume capturing with potentially new mode + oldMode := storage.CaptureMode() + storage.SetCapturing(true) + storage.SetCaptureMode(mode) + + // Handle cookie based on mode change + if mode == collector.CaptureModeSession && oldMode != collector.CaptureModeSession { + h.setSessionCookie(w, sessionID) + } else if mode == collector.CaptureModeGlobal && oldMode == collector.CaptureModeSession { + h.clearSessionCookie(w, sessionID) + } + } else { + // New session created - set cookie if session mode + if mode == collector.CaptureModeSession { + h.setSessionCookie(w, sessionID) + } + } + + h.respondWithCaptureState(w, r, sessionID, true, mode) +} + +// captureStop handles POST /capture/stop - pauses capture but keeps session and events +func (h *Handler) captureStop(w http.ResponseWriter, r *http.Request) { + sessionID, hasSession := h.getSessionID(r) + if !hasSession { + h.respondWithCaptureState(w, r, sessionID, false, collector.CaptureModeSession) + return + } + + storage := h.sessions.Get(sessionID) + if storage == nil { + h.respondWithCaptureState(w, r, sessionID, false, collector.CaptureModeSession) + return + } + + // Pause capturing - keep storage, session, and events intact + storage.SetCapturing(false) + + // Keep session cookie so user can resume + // Respond with active=false but preserve the mode + h.respondWithCaptureState(w, r, sessionID, false, storage.CaptureMode()) +} + +// setCaptureMode handles POST /capture/mode - changes capture mode +func (h *Handler) setCaptureMode(w http.ResponseWriter, r *http.Request) { + sessionID, hasSession := h.getSessionID(r) + if !hasSession { + http.Error(w, "Invalid session ID", http.StatusBadRequest) + return + } + + storage := h.sessions.Get(sessionID) + if storage == nil { + http.Error(w, "No capture session active", http.StatusNotFound) + return + } + + // Parse mode from request + mode, ok := collector.ParseCaptureMode(r.FormValue("mode")) + if !ok { + http.Error(w, "Invalid mode, must be 'session' or 'global'", http.StatusBadRequest) + return + } + + oldMode := storage.CaptureMode() + storage.SetCaptureMode(mode) + + // Handle cookie based on mode change + if mode == collector.CaptureModeSession && oldMode != collector.CaptureModeSession { + // Switching to session mode: set cookie + h.setSessionCookie(w, sessionID) + } else if mode == collector.CaptureModeGlobal && oldMode == collector.CaptureModeSession { + // Switching from session to global: clear cookie + h.clearSessionCookie(w, sessionID) + } + + h.respondWithCaptureState(w, r, sessionID, true, mode) +} + +// captureStatus handles GET /capture/status - returns current capture state +func (h *Handler) captureStatus(w http.ResponseWriter, r *http.Request) { + sessionID, hasSession := h.getSessionID(r) + if !hasSession { + h.respondWithCaptureState(w, r, sessionID, false, collector.CaptureModeSession) + return + } + + storage := h.sessions.Get(sessionID) + if storage == nil { + h.respondWithCaptureState(w, r, sessionID, false, collector.CaptureModeSession) + return + } + + h.respondWithCaptureState(w, r, sessionID, storage.IsCapturing(), storage.CaptureMode()) +} + +// respondWithCaptureState responds with capture state as HTML for HTMX or JSON for API +func (h *Handler) respondWithCaptureState(w http.ResponseWriter, r *http.Request, sessionID uuid.UUID, active bool, mode collector.CaptureMode) { + modeStr := mode.String() + + // Check if this is an HTMX request + if r.Header.Get("HX-Request") == "true" { + r = h.withHandlerOptions(r, sessionID.String(), active, modeStr) + opts := views.HandlerOptions{ + PathPrefix: h.pathPrefix, + SessionID: sessionID.String(), + CaptureActive: active, + CaptureMode: modeStr, + } + + // Trigger event list refresh via HTMX response header + w.Header().Set("HX-Trigger", "capture-state-changed") + + // Update browser URL to reflect capture state + w.Header().Set("HX-Push-Url", opts.BuildEventDetailURL("")) + + templ.Handler( + views.CaptureControls(views.CaptureState{ + Active: active, + Mode: modeStr, + }), + ).ServeHTTP(w, r) + return + } + + // Return JSON for API compatibility + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CaptureStatusResponse{Active: active, Mode: modeStr}) +} + +// captureCleanup handles POST /capture/cleanup - called via sendBeacon on tab close/reload +func (h *Handler) captureCleanup(w http.ResponseWriter, r *http.Request) { + sessionID, ok := h.getSessionID(r) + if !ok { + // Silent success for beacon - no session to clean up + w.WriteHeader(http.StatusOK) + return + } + + // Only clear session cookie - don't delete storage. + // Storage will be cleaned up by idle timeout or explicit stop. + // Cookie will be re-set on page load if session is still active. + h.clearSessionCookie(w, sessionID) + + w.WriteHeader(http.StatusOK) +} + +// StatsResponse is the response for GET /stats +type StatsResponse struct { + MemoryBytes uint64 `json:"memoryBytes"` + MemoryFormatted string `json:"memoryFormatted"` + SessionCount int `json:"sessionCount"` + MaxSessions int `json:"maxSessions,omitempty"` + EventCount int `json:"eventCount"` +} + +func (h *Handler) getStats(w http.ResponseWriter, r *http.Request) { + stats := h.eventAggregator.CalculateStats() + + response := StatsResponse{ + MemoryBytes: stats.TotalMemory, + MemoryFormatted: views.FormatBytes(stats.TotalMemory), + SessionCount: h.sessions.SessionCount(), + MaxSessions: h.sessions.MaxSessions(), + EventCount: stats.EventCount, + } + + // Check if HTMX request + if r.Header.Get("HX-Request") == "true" { + templ.Handler( + views.UsagePanelContent(response.MemoryFormatted, response.SessionCount, response.MaxSessions), + ).ServeHTTP(w, r) + return + } + + // JSON response for API + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/dashboard/options.go b/dashboard/options.go new file mode 100644 index 0000000..fcfc02e --- /dev/null +++ b/dashboard/options.go @@ -0,0 +1,62 @@ +package dashboard + +import "time" + +// handlerOptions holds configuration for a dashboard Handler. +// This is unexported; use HandlerOption functions to configure. +type handlerOptions struct { + // PathPrefix is where the handler is mounted (e.g. "/_devlog"). + PathPrefix string + // TruncateAfter limits the number of events shown in the event list. + TruncateAfter uint64 + // StorageCapacity is the number of events per user storage. + StorageCapacity uint64 + // SessionIdleTimeout is how long to wait after SSE disconnect before cleanup. + SessionIdleTimeout time.Duration + // MaxSessions is the maximum number of concurrent sessions (0 = unlimited). + MaxSessions int +} + +// HandlerOption configures a dashboard Handler. +type HandlerOption func(*handlerOptions) + +// WithPathPrefix sets the path prefix where the handler is mounted. +// For example, "/_devlog" if mounted at that path. +// This is used for generating correct URLs in the dashboard. +func WithPathPrefix(prefix string) HandlerOption { + return func(o *handlerOptions) { + o.PathPrefix = prefix + } +} + +// WithStorageCapacity sets the number of events per user storage. +// Default is 1000 if not specified. +func WithStorageCapacity(capacity uint64) HandlerOption { + return func(o *handlerOptions) { + o.StorageCapacity = capacity + } +} + +// WithSessionIdleTimeout sets how long to wait after SSE disconnect before cleanup. +// Default is 30 seconds if not specified. +func WithSessionIdleTimeout(timeout time.Duration) HandlerOption { + return func(o *handlerOptions) { + o.SessionIdleTimeout = timeout + } +} + +// WithTruncateAfter limits the number of events shown in the event list. +// Default uses StorageCapacity if not specified. +func WithTruncateAfter(limit uint64) HandlerOption { + return func(o *handlerOptions) { + o.TruncateAfter = limit + } +} + +// WithMaxSessions sets the maximum number of concurrent sessions. +// Default is 0 (unlimited). +func WithMaxSessions(limit int) HandlerOption { + return func(o *handlerOptions) { + o.MaxSessions = limit + } +} diff --git a/dashboard/session_manager.go b/dashboard/session_manager.go new file mode 100644 index 0000000..961c9e9 --- /dev/null +++ b/dashboard/session_manager.go @@ -0,0 +1,218 @@ +package dashboard + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" + + "github.com/gofrs/uuid" + + "github.com/networkteam/devlog/collector" +) + +// ErrMaxSessionsReached is returned when the maximum number of sessions has been reached +var ErrMaxSessionsReached = errors.New("maximum number of sessions reached") + +// sessionState tracks a user's capture session +type sessionState struct { + storageID uuid.UUID + lastActive time.Time +} + +// SessionManager manages capture sessions and their associated storages. +// It handles session lifecycle, activity tracking, and cleanup. +type SessionManager struct { + eventAggregator *collector.EventAggregator + + sessions map[uuid.UUID]*sessionState + sessionsMu sync.RWMutex + + storageCapacity uint64 + idleTimeout time.Duration + maxSessions int + + cleanupCtx context.Context + cleanupCtxCancel context.CancelFunc +} + +// SessionManagerOptions configures a SessionManager +type SessionManagerOptions struct { + EventAggregator *collector.EventAggregator + StorageCapacity uint64 + IdleTimeout time.Duration + MaxSessions int // 0 means unlimited +} + +// NewSessionManager creates a new SessionManager and starts the cleanup goroutine +func NewSessionManager(opts SessionManagerOptions) *SessionManager { + storageCapacity := opts.StorageCapacity + if storageCapacity == 0 { + storageCapacity = DefaultStorageCapacity + } + + idleTimeout := opts.IdleTimeout + if idleTimeout == 0 { + idleTimeout = DefaultSessionIdleTimeout + } + + cleanupCtx, cleanupCtxCancel := context.WithCancel(context.Background()) + + sm := &SessionManager{ + eventAggregator: opts.EventAggregator, + sessions: make(map[uuid.UUID]*sessionState), + storageCapacity: storageCapacity, + idleTimeout: idleTimeout, + maxSessions: opts.MaxSessions, + cleanupCtx: cleanupCtx, + cleanupCtxCancel: cleanupCtxCancel, + } + + go sm.cleanupLoop() + + return sm +} + +// Get returns the storage for a session, or nil if not found +func (sm *SessionManager) Get(sessionID uuid.UUID) *collector.CaptureStorage { + sm.sessionsMu.RLock() + state, exists := sm.sessions[sessionID] + sm.sessionsMu.RUnlock() + + if !exists { + return nil + } + + storage := sm.eventAggregator.GetStorage(state.storageID) + if storage == nil { + return nil + } + + return storage.(*collector.CaptureStorage) +} + +// GetOrCreate returns the storage for a session, creating it if it doesn't exist. +// Returns the storage, whether it was newly created, and an error if the session limit is reached. +func (sm *SessionManager) GetOrCreate(sessionID uuid.UUID, mode collector.CaptureMode) (*collector.CaptureStorage, bool, error) { + sm.sessionsMu.Lock() + defer sm.sessionsMu.Unlock() + + // Check if already exists + if state, exists := sm.sessions[sessionID]; exists { + if storage := sm.eventAggregator.GetStorage(state.storageID); storage != nil { + return storage.(*collector.CaptureStorage), false, nil + } + // Storage was removed but session state remains - clean it up + delete(sm.sessions, sessionID) + } + + // Check max sessions limit + if sm.maxSessions > 0 && len(sm.sessions) >= sm.maxSessions { + return nil, false, ErrMaxSessionsReached + } + + // Create new storage + storage := collector.NewCaptureStorage(sessionID, sm.storageCapacity, mode) + sm.eventAggregator.RegisterStorage(storage) + + sm.sessions[sessionID] = &sessionState{ + storageID: storage.ID(), + lastActive: time.Now(), + } + + return storage, true, nil +} + +// Delete removes a session and its storage +func (sm *SessionManager) Delete(sessionID uuid.UUID) { + sm.sessionsMu.Lock() + defer sm.sessionsMu.Unlock() + + state, exists := sm.sessions[sessionID] + if !exists { + return + } + + if storage := sm.eventAggregator.GetStorage(state.storageID); storage != nil { + storage.Close() + } + sm.eventAggregator.UnregisterStorage(state.storageID) + delete(sm.sessions, sessionID) +} + +// UpdateActivity updates the last active time for a session +func (sm *SessionManager) UpdateActivity(sessionID uuid.UUID) { + sm.sessionsMu.Lock() + if state, exists := sm.sessions[sessionID]; exists { + state.lastActive = time.Now() + } + sm.sessionsMu.Unlock() +} + +// IdleTimeout returns the configured idle timeout duration +func (sm *SessionManager) IdleTimeout() time.Duration { + return sm.idleTimeout +} + +// SessionCount returns the current number of active sessions +func (sm *SessionManager) SessionCount() int { + sm.sessionsMu.RLock() + defer sm.sessionsMu.RUnlock() + return len(sm.sessions) +} + +// MaxSessions returns the configured maximum number of sessions (0 means unlimited) +func (sm *SessionManager) MaxSessions() int { + return sm.maxSessions +} + +// Close shuts down the session manager and cleans up all sessions +func (sm *SessionManager) Close() { + sm.cleanupCtxCancel() + + sm.sessionsMu.Lock() + defer sm.sessionsMu.Unlock() + + for sessionID, state := range sm.sessions { + if storage := sm.eventAggregator.GetStorage(state.storageID); storage != nil { + storage.Close() + } + sm.eventAggregator.UnregisterStorage(state.storageID) + delete(sm.sessions, sessionID) + } +} + +// cleanupLoop periodically checks for idle sessions and cleans them up +func (sm *SessionManager) cleanupLoop() { + ticker := time.NewTicker(sm.idleTimeout / 2) + defer ticker.Stop() + + for { + select { + case <-sm.cleanupCtx.Done(): + return + case <-ticker.C: + sm.cleanupIdleSessions() + } + } +} + +func (sm *SessionManager) cleanupIdleSessions() { + now := time.Now() + + sm.sessionsMu.Lock() + defer sm.sessionsMu.Unlock() + + for sessionID, state := range sm.sessions { + if now.Sub(state.lastActive) > sm.idleTimeout { + fmt.Fprintf(os.Stderr, "[DEBUG] %s: cleanupIdleSessions: cleaning up session %s, idle for %v\n", time.Now().Format(time.DateTime), sessionID, now.Sub(state.lastActive)) + if storage := sm.eventAggregator.GetStorage(state.storageID); storage != nil { + storage.Close() + } + sm.eventAggregator.UnregisterStorage(state.storageID) + delete(sm.sessions, sessionID) + } + } +} diff --git a/dashboard/session_manager_test.go b/dashboard/session_manager_test.go new file mode 100644 index 0000000..5e09ace --- /dev/null +++ b/dashboard/session_manager_test.go @@ -0,0 +1,337 @@ +package dashboard + +import ( + "testing" + "time" + + "github.com/gofrs/uuid" + + "github.com/networkteam/devlog/collector" +) + +func TestSessionManager_Get_NonExistent(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage := sm.Get(sessionID) + + if storage != nil { + t.Errorf("expected nil storage for non-existent session, got %v", storage) + } +} + +func TestSessionManager_GetOrCreate_CreatesNew(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + storage, created, err := sm.GetOrCreate(sessionID, collector.CaptureModeSession) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !created { + t.Error("expected created to be true for new session") + } + if storage == nil { + t.Fatal("expected non-nil storage") + } + if storage.SessionID() != sessionID { + t.Errorf("expected session ID %s, got %s", sessionID, storage.SessionID()) + } + if storage.CaptureMode() != collector.CaptureModeSession { + t.Errorf("expected session mode, got %v", storage.CaptureMode()) + } +} + +func TestSessionManager_GetOrCreate_ReturnsExisting(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + + // Create first + storage1, created1, err := sm.GetOrCreate(sessionID, collector.CaptureModeSession) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !created1 { + t.Error("expected created to be true for first call") + } + + // Get existing + storage2, created2, err := sm.GetOrCreate(sessionID, collector.CaptureModeGlobal) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if created2 { + t.Error("expected created to be false for second call") + } + + if storage1.ID() != storage2.ID() { + t.Error("expected same storage instance") + } + + // Mode should not change on GetOrCreate of existing + if storage2.CaptureMode() != collector.CaptureModeSession { + t.Errorf("expected original mode to be preserved, got %v", storage2.CaptureMode()) + } +} + +func TestSessionManager_Get_AfterCreate(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + + // Create + storage1, _, _ := sm.GetOrCreate(sessionID, collector.CaptureModeGlobal) + + // Get + storage2 := sm.Get(sessionID) + + if storage2 == nil { + t.Fatal("expected non-nil storage after create") + } + if storage1.ID() != storage2.ID() { + t.Error("expected same storage instance") + } +} + +func TestSessionManager_Delete(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + + // Create + _, _, _ = sm.GetOrCreate(sessionID, collector.CaptureModeSession) + + // Delete + sm.Delete(sessionID) + + // Should be gone + storage := sm.Get(sessionID) + if storage != nil { + t.Error("expected nil storage after delete") + } + + // Storage should be unregistered from aggregator + if aggregator.GetStorage(sessionID) != nil { + t.Error("expected storage to be unregistered from aggregator") + } +} + +func TestSessionManager_Delete_NonExistent(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + + // Should not panic + sm.Delete(sessionID) +} + +func TestSessionManager_UpdateActivity(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + + // Create session + _, _, _ = sm.GetOrCreate(sessionID, collector.CaptureModeSession) + + // Record time before update + sm.sessionsMu.RLock() + timeBefore := sm.sessions[sessionID].lastActive + sm.sessionsMu.RUnlock() + + // Wait a bit + time.Sleep(10 * time.Millisecond) + + // Update activity + sm.UpdateActivity(sessionID) + + // Check time was updated + sm.sessionsMu.RLock() + timeAfter := sm.sessions[sessionID].lastActive + sm.sessionsMu.RUnlock() + + if !timeAfter.After(timeBefore) { + t.Error("expected lastActive to be updated") + } +} + +func TestSessionManager_UpdateActivity_NonExistent(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + + // Should not panic + sm.UpdateActivity(sessionID) +} + +func TestSessionManager_Close(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + + sessionID1 := uuid.Must(uuid.NewV4()) + sessionID2 := uuid.Must(uuid.NewV4()) + + _, _, _ = sm.GetOrCreate(sessionID1, collector.CaptureModeSession) + _, _, _ = sm.GetOrCreate(sessionID2, collector.CaptureModeGlobal) + + sm.Close() + + // All sessions should be gone + if sm.Get(sessionID1) != nil { + t.Error("expected session 1 to be cleaned up") + } + if sm.Get(sessionID2) != nil { + t.Error("expected session 2 to be cleaned up") + } +} + +func TestSessionManager_IdleCleanup(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: 50 * time.Millisecond, + }) + defer sm.Close() + + sessionID := uuid.Must(uuid.NewV4()) + + // Create session + _, _, _ = sm.GetOrCreate(sessionID, collector.CaptureModeSession) + + // Session should exist + if sm.Get(sessionID) == nil { + t.Fatal("expected session to exist") + } + + // Wait for idle timeout + cleanup interval + time.Sleep(100 * time.Millisecond) + + // Session should be cleaned up + if sm.Get(sessionID) != nil { + t.Error("expected session to be cleaned up after idle timeout") + } +} + +func TestSessionManager_IdleTimeout(t *testing.T) { + aggregator := collector.NewEventAggregator() + idleTimeout := 100 * time.Millisecond + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: idleTimeout, + }) + defer sm.Close() + + if sm.IdleTimeout() != idleTimeout { + t.Errorf("expected idle timeout %v, got %v", idleTimeout, sm.IdleTimeout()) + } +} + +func TestSessionManager_DefaultValues(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + // Leave StorageCapacity and IdleTimeout at 0 to test defaults + }) + defer sm.Close() + + if sm.storageCapacity != DefaultStorageCapacity { + t.Errorf("expected default storage capacity %d, got %d", DefaultStorageCapacity, sm.storageCapacity) + } + if sm.idleTimeout != DefaultSessionIdleTimeout { + t.Errorf("expected default idle timeout %v, got %v", DefaultSessionIdleTimeout, sm.idleTimeout) + } +} + +func TestSessionManager_MultipleSessions(t *testing.T) { + aggregator := collector.NewEventAggregator() + sm := NewSessionManager(SessionManagerOptions{ + EventAggregator: aggregator, + StorageCapacity: 100, + IdleTimeout: time.Minute, + }) + defer sm.Close() + + // Create multiple sessions + sessions := make([]uuid.UUID, 5) + for i := range sessions { + sessions[i] = uuid.Must(uuid.NewV4()) + _, _, _ = sm.GetOrCreate(sessions[i], collector.CaptureModeSession) + } + + // All should exist + for i, sessionID := range sessions { + storage := sm.Get(sessionID) + if storage == nil { + t.Errorf("expected session %d to exist", i) + } + } + + // Delete one + sm.Delete(sessions[2]) + + // Check counts + existing := 0 + for _, sessionID := range sessions { + if sm.Get(sessionID) != nil { + existing++ + } + } + if existing != 4 { + t.Errorf("expected 4 sessions, got %d", existing) + } +} diff --git a/dashboard/static/android-chrome-192x192.png b/dashboard/static/android-chrome-192x192.png new file mode 100644 index 0000000..e48afd0 Binary files /dev/null and b/dashboard/static/android-chrome-192x192.png differ diff --git a/dashboard/static/android-chrome-512x512.png b/dashboard/static/android-chrome-512x512.png new file mode 100644 index 0000000..65da874 Binary files /dev/null and b/dashboard/static/android-chrome-512x512.png differ diff --git a/dashboard/static/apple-touch-icon.png b/dashboard/static/apple-touch-icon.png new file mode 100644 index 0000000..72a289d Binary files /dev/null and b/dashboard/static/apple-touch-icon.png differ diff --git a/dashboard/static/assets.go b/dashboard/static/assets.go index bc8917b..9bb9d0c 100644 --- a/dashboard/static/assets.go +++ b/dashboard/static/assets.go @@ -2,5 +2,5 @@ package static import "embed" -//go:embed *.css *.ico +//go:embed *.css *.ico *.png *.webmanifest *.svg var Assets embed.FS diff --git a/dashboard/static/devlog-logo-dark.svg b/dashboard/static/devlog-logo-dark.svg new file mode 100644 index 0000000..9c232d2 --- /dev/null +++ b/dashboard/static/devlog-logo-dark.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/static/favicon-16x16.png b/dashboard/static/favicon-16x16.png new file mode 100644 index 0000000..8c60adc Binary files /dev/null and b/dashboard/static/favicon-16x16.png differ diff --git a/dashboard/static/favicon-32x32.png b/dashboard/static/favicon-32x32.png new file mode 100644 index 0000000..7ec1e91 Binary files /dev/null and b/dashboard/static/favicon-32x32.png differ diff --git a/dashboard/static/favicon.ico b/dashboard/static/favicon.ico index 58877a9..fd83500 100644 Binary files a/dashboard/static/favicon.ico and b/dashboard/static/favicon.ico differ diff --git a/dashboard/static/main.css b/dashboard/static/main.css index c80963c..3afa9c8 100644 --- a/dashboard/static/main.css +++ b/dashboard/static/main.css @@ -8,9 +8,13 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-400: oklch(70.4% 0.191 22.216); --color-red-500: oklch(63.7% 0.237 25.331); --color-red-600: oklch(57.7% 0.245 27.325); --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-red-900: oklch(39.6% 0.141 25.723); + --color-red-950: oklch(25.8% 0.092 26.042); --color-orange-400: oklch(75% 0.183 55.934); --color-orange-600: oklch(64.6% 0.222 41.116); --color-green-600: oklch(62.7% 0.194 149.214); @@ -25,28 +29,27 @@ --color-neutral-500: oklch(55.6% 0 0); --color-neutral-600: oklch(43.9% 0 0); --color-neutral-700: oklch(37.1% 0 0); + --color-neutral-800: oklch(26.9% 0 0); --color-black: #000; --color-white: #fff; --spacing: 0.25rem; - --container-sm: 24rem; --text-xs: 0.75rem; --text-xs--line-height: calc(1 / 0.75); --text-sm: 0.875rem; --text-sm--line-height: calc(1.25 / 0.875); - --text-base: 1rem; - --text-base--line-height: calc(1.5 / 1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); - --text-xl: 1.25rem; - --text-xl--line-height: calc(1.75 / 1.25); --font-weight-medium: 500; --font-weight-semibold: 600; - --font-weight-bold: 700; --radius-md: 0.375rem; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); + --color-devlog-cyan: #04F3F8; + --color-header-bg: #0D0A29; + --color-header-border: #1e2a4a; + --color-sidebar-bg: #5c5a67; } } @layer base { @@ -198,20 +201,6 @@ .visible { visibility: visible; } - .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; - } - .relative { - position: relative; - } .static { position: static; } @@ -236,6 +225,9 @@ .mx-auto { margin-inline: auto; } + .mt-0 { + margin-top: calc(var(--spacing) * 0); + } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -245,6 +237,12 @@ .mt-2 { margin-top: calc(var(--spacing) * 2); } + .mt-3 { + margin-top: calc(var(--spacing) * 3); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } .mt-6 { margin-top: calc(var(--spacing) * 6); } @@ -254,21 +252,24 @@ .mb-2 { margin-bottom: calc(var(--spacing) * 2); } - .mb-3 { - margin-bottom: calc(var(--spacing) * 3); - } .mb-4 { margin-bottom: calc(var(--spacing) * 4); } .mb-6 { margin-bottom: calc(var(--spacing) * 6); } + .ml-0 { + margin-left: calc(var(--spacing) * 0); + } .ml-0\.5 { margin-left: calc(var(--spacing) * 0.5); } .ml-2 { margin-left: calc(var(--spacing) * 2); } + .block { + display: block; + } .contents { display: contents; } @@ -299,6 +300,12 @@ .h-full { height: 100%; } + .h-screen { + height: 100vh; + } + .min-h-0 { + min-height: calc(var(--spacing) * 0); + } .min-h-screen { min-height: 100vh; } @@ -311,18 +318,24 @@ .w-full { width: 100%; } - .max-w-sm { - max-width: var(--container-sm); - } .flex-1 { flex: 1; } + .flex-grow { + flex-grow: 1; + } .grow { flex-grow: 1; } + .border-collapse { + border-collapse: collapse; + } .cursor-pointer { cursor: pointer; } + .resize { + resize: both; + } .grid-cols-\[24rem_1fr\] { grid-template-columns: 24rem 1fr; } @@ -347,9 +360,15 @@ .justify-center { justify-content: center; } + .justify-end { + justify-content: flex-end; + } .gap-1 { gap: calc(var(--spacing) * 1); } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } .gap-2 { gap: calc(var(--spacing) * 2); } @@ -359,6 +378,9 @@ .gap-4 { gap: calc(var(--spacing) * 4); } + .gap-5 { + gap: calc(var(--spacing) * 5); + } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } @@ -416,21 +438,54 @@ border-left-style: var(--tw-border-style); border-left-width: 1px; } + .border-header-border { + border-color: var(--color-header-border); + } .border-neutral-200 { border-color: var(--color-neutral-200); } .border-neutral-300 { border-color: var(--color-neutral-300); } + .border-neutral-500 { + border-color: var(--color-neutral-500); + } + .border-neutral-600 { + border-color: var(--color-neutral-600); + } + .border-red-600 { + border-color: var(--color-red-600); + } + .border-red-800 { + border-color: var(--color-red-800); + } .border-transparent { border-color: transparent; } .bg-black { background-color: var(--color-black); } + .bg-devlog-cyan { + background-color: var(--color-devlog-cyan); + } + .bg-devlog-cyan\/20 { + background-color: color-mix(in srgb, #04F3F8 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-devlog-cyan) 20%, transparent); + } + } .bg-green-600 { background-color: var(--color-green-600); } + .bg-header-bg { + background-color: var(--color-header-bg); + } + .bg-header-bg\/50 { + background-color: color-mix(in srgb, #0D0A29 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-header-bg) 50%, transparent); + } + } .bg-neutral-50 { background-color: var(--color-neutral-50); } @@ -440,6 +495,21 @@ .bg-neutral-200 { background-color: var(--color-neutral-200); } + .bg-neutral-300 { + background-color: var(--color-neutral-300); + } + .bg-neutral-700 { + background-color: var(--color-neutral-700); + } + .bg-neutral-800 { + background-color: var(--color-neutral-800); + } + .bg-neutral-800\/50 { + background-color: color-mix(in srgb, oklch(26.9% 0 0) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-neutral-800) 50%, transparent); + } + } .bg-orange-400 { background-color: var(--color-orange-400); } @@ -449,9 +519,36 @@ .bg-red-500 { background-color: var(--color-red-500); } + .bg-red-800 { + background-color: var(--color-red-800); + } + .bg-red-950 { + background-color: var(--color-red-950); + } + .bg-red-950\/50 { + background-color: color-mix(in srgb, oklch(25.8% 0.092 26.042) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-950) 50%, transparent); + } + } + .bg-sidebar-bg { + background-color: var(--color-sidebar-bg); + } .bg-white { background-color: var(--color-white); } + .bg-white\/10 { + background-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .bg-white\/50 { + background-color: color-mix(in srgb, #fff 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 50%, transparent); + } + } .p-2 { padding: calc(var(--spacing) * 2); } @@ -464,6 +561,9 @@ .p-8 { padding: calc(var(--spacing) * 8); } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } .px-2\.5 { padding-inline: calc(var(--spacing) * 2.5); } @@ -473,12 +573,21 @@ .px-4 { padding-inline: calc(var(--spacing) * 4); } + .py-0 { + padding-block: calc(var(--spacing) * 0); + } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } .py-2 { padding-block: calc(var(--spacing) * 2); } + .pl-1 { + padding-left: calc(var(--spacing) * 1); + } + .pl-2 { + padding-left: calc(var(--spacing) * 2); + } .text-center { text-align: center; } @@ -491,10 +600,6 @@ .font-mono { font-family: var(--font-mono); } - .text-base { - font-size: var(--text-base); - line-height: var(--tw-leading, var(--text-base--line-height)); - } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); @@ -503,18 +608,10 @@ font-size: var(--text-sm); line-height: var(--tw-leading, var(--text-sm--line-height)); } - .text-xl { - font-size: var(--text-xl); - line-height: var(--tw-leading, var(--text-xl--line-height)); - } .text-xs { font-size: var(--text-xs); line-height: var(--tw-leading, var(--text-xs--line-height)); } - .font-bold { - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); - } .font-medium { --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); @@ -541,9 +638,15 @@ .text-blue-600 { color: var(--color-blue-600); } + .text-devlog-cyan { + color: var(--color-devlog-cyan); + } .text-green-600 { color: var(--color-green-600); } + .text-neutral-300 { + color: var(--color-neutral-300); + } .text-neutral-400 { color: var(--color-neutral-400); } @@ -559,6 +662,9 @@ .text-orange-600 { color: var(--color-orange-600); } + .text-red-400 { + color: var(--color-red-400); + } .text-red-500 { color: var(--color-red-500); } @@ -571,6 +677,21 @@ .text-white { color: var(--color-white); } + .text-white\/50 { + color: color-mix(in srgb, #fff 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-white) 50%, transparent); + } + } + .text-white\/60 { + color: color-mix(in srgb, #fff 60%, transparent); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--color-white) 60%, transparent); + } + } + .underline { + text-decoration-line: underline; + } .ring { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -579,16 +700,14 @@ outline-style: var(--tw-outline-style); outline-width: 1px; } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } .transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } - .placeholder\:text-neutral-500 { - &::placeholder { - color: var(--color-neutral-500); - } - } .hover\:bg-black\/90 { &:hover { @media (hover: hover) { @@ -623,6 +742,46 @@ } } } + .hover\:bg-neutral-700\/50 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(37.1% 0 0) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-neutral-700) 50%, transparent); + } + } + } + } + .hover\:bg-red-900\/50 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(39.6% 0.141 25.723) 50%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-900) 50%, transparent); + } + } + } + } + .hover\:bg-white\/10 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + } + } + .hover\:bg-white\/20 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 20%, transparent); + } + } + } + } .hover\:text-blue-800 { &:hover { @media (hover: hover) { @@ -630,10 +789,25 @@ } } } - .focus-visible\:ring-1 { - &:focus-visible { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + .hover\:text-neutral-300 { + &:hover { + @media (hover: hover) { + color: var(--color-neutral-300); + } + } + } + .hover\:text-red-400 { + &:hover { + @media (hover: hover) { + color: var(--color-red-400); + } + } + } + .hover\:text-white { + &:hover { + @media (hover: hover) { + color: var(--color-white); + } } } .focus-visible\:ring-2 { @@ -642,11 +816,6 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } - .focus-visible\:ring-black { - &:focus-visible { - --tw-ring-color: var(--color-black); - } - } .focus-visible\:ring-offset-2 { &:focus-visible { --tw-ring-offset-width: 2px; @@ -664,24 +833,14 @@ pointer-events: none; } } - .disabled\:cursor-not-allowed { - &:disabled { - cursor: not-allowed; - } - } .disabled\:opacity-50 { &:disabled { opacity: 50%; } } - .sm\:flex-row { + .sm\:gap-6 { @media (width >= 40rem) { - flex-direction: row; - } - } - .sm\:items-center { - @media (width >= 40rem) { - align-items: center; + gap: calc(var(--spacing) * 6); } } .md\:p-4 { @@ -689,18 +848,27 @@ padding: calc(var(--spacing) * 4); } } - .md\:text-sm { - @media (width >= 48rem) { - font-size: var(--text-sm); - line-height: var(--tw-leading, var(--text-sm--line-height)); - } - } .\[\.selected\]\:bg-blue-50 { &:is(.selected) { background-color: var(--color-blue-50); } } } +.glow-red { + box-shadow: 0 0 10px 3px rgba(239, 68, 68, 0.5); +} +.glow-gray { + box-shadow: 0 0 8px 2px rgba(150, 150, 150, 0.3); +} +.btn-pressed { + box-shadow: inset 2px 4px 4px rgba(0, 0, 0, 0.5); +} +.btn-pressed.glow-gray { + background-color: color-mix(in srgb, #0D0A29 40%, rgb(144 144 144)); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in srgb, var(--color-header-bg) 40%, rgb(144 144 144)); + } +} @property --tw-divide-y-reverse { syntax: "*"; inherits: false; @@ -785,6 +953,59 @@ inherits: false; initial-value: solid; } +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -806,6 +1027,19 @@ --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; } } } diff --git a/dashboard/static/site.webmanifest b/dashboard/static/site.webmanifest new file mode 100644 index 0000000..403fb90 --- /dev/null +++ b/dashboard/static/site.webmanifest @@ -0,0 +1 @@ +{"name":"devlog","short_name":"devlog","icons":[{"src":"android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#0D0A29","background_color":"#0D0A29","display":"standalone"} diff --git a/dashboard/styles/main.css b/dashboard/styles/main.css index f1d8c73..848855a 100644 --- a/dashboard/styles/main.css +++ b/dashboard/styles/main.css @@ -1 +1,28 @@ @import "tailwindcss"; + +@theme { + --color-devlog-cyan: #04F3F8; + --color-devlog-cyan-glow: #63FCFF; + --color-header-bg: #0D0A29; + --color-header-border: #1e2a4a; + --color-sidebar-bg: #5c5a67; +} + +/* Custom glow utilities for capture buttons - enhanced for dark background */ +.glow-red { + box-shadow: 0 0 10px 3px rgba(239, 68, 68, 0.5); +} + +.glow-gray { + box-shadow: 0 0 8px 2px rgba(150, 150, 150, 0.3); +} + +/* Cassette player style pressed button - deeper shadow for dark theme */ +.btn-pressed { + box-shadow: inset 2px 4px 4px rgba(0, 0, 0, 0.5); +} + +/* Dark navy tint for pressed gray button */ +.btn-pressed.glow-gray { + background-color: color-mix(in srgb, var(--color-header-bg) 40%, rgb(144 144 144)); +} diff --git a/dashboard/views/button.go b/dashboard/views/button.go index 0704ab8..a22c9d0 100644 --- a/dashboard/views/button.go +++ b/dashboard/views/button.go @@ -6,29 +6,34 @@ type ButtonVariant string type ButtonSize string const ( - ButtonVariantOutline ButtonVariant = "outline" - ButtonVariantSecondary ButtonVariant = "secondary" + ButtonVariantDefault ButtonVariant = "" + ButtonVariantOutline ButtonVariant = "outline" + ButtonVariantOutlineDark ButtonVariant = "outline-dark" + ButtonVariantSecondary ButtonVariant = "secondary" ButtonSizeSm ButtonSize = "sm" ButtonSizeIcon ButtonSize = "icon" ) type ButtonProps struct { - Variant ButtonVariant - Size ButtonSize - Class string + Variant ButtonVariant + Size ButtonSize + Class string + Disabled bool } func buttonClasses(props ButtonProps) string { var classes []string // Base classes - classes = append(classes, "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50") + classes = append(classes, "cursor-pointer inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50") // Variant classes switch props.Variant { case ButtonVariantOutline: classes = append(classes, "border border-neutral-200 bg-white hover:bg-neutral-200 text-black") + case ButtonVariantOutlineDark: + classes = append(classes, "border border-header-border bg-white/10 hover:bg-white/20 text-white") case ButtonVariantSecondary: classes = append(classes, "bg-neutral-200 text-black hover:bg-neutral-200/80") default: // DefaultVariant diff --git a/dashboard/views/dashboard.templ b/dashboard/views/dashboard.templ index 9e7b927..ea76d5f 100644 --- a/dashboard/views/dashboard.templ +++ b/dashboard/views/dashboard.templ @@ -1,21 +1,36 @@ package views import ( - "github.com/networkteam/devlog/collector" + "fmt" + "github.com/networkteam/devlog/collector" ) type DashboardProps struct { - SelectedEvent *collector.Event - Events []*collector.Event + SelectedEvent *collector.Event + Events []*collector.Event + CaptureActive bool + CaptureMode string // "session" or "global" } templ Dashboard(props DashboardProps) { - {{ eventListProps := EventListProps{Events: props.Events} }} - if props.SelectedEvent != nil { - {{ eventListProps.SelectedEventID = &props.SelectedEvent.ID }} - } - - @Layout() { - @SplitLayout(EventList(eventListProps), EventDetailContainer(props.SelectedEvent)) + {{ eventListProps := EventListProps{Events: props.Events, CaptureActive: props.CaptureActive, CaptureMode: props.CaptureMode} }} + if props.SelectedEvent != nil { + {{ eventListProps.SelectedEventID = &props.SelectedEvent.ID }} + } + {{ capture := CaptureState{Active: props.CaptureActive, Mode: props.CaptureMode} }} + @Layout(capture) { + @SplitLayout(EventListContainer(eventListProps), EventDetailContainer(props.SelectedEvent)) } } + +templ EventListContainer(props EventListProps) { + {{ opts := MustGetHandlerOptions(ctx) }} +
+ @EventList(props) +
+} diff --git a/dashboard/views/dashboard_templ.go b/dashboard/views/dashboard_templ.go index f714c5c..fc83f25 100644 --- a/dashboard/views/dashboard_templ.go +++ b/dashboard/views/dashboard_templ.go @@ -9,12 +9,15 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import ( + "fmt" "github.com/networkteam/devlog/collector" ) type DashboardProps struct { SelectedEvent *collector.Event Events []*collector.Event + CaptureActive bool + CaptureMode string // "session" or "global" } func Dashboard(props DashboardProps) templ.Component { @@ -38,10 +41,11 @@ func Dashboard(props DashboardProps) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - eventListProps := EventListProps{Events: props.Events} + eventListProps := EventListProps{Events: props.Events, CaptureActive: props.CaptureActive, CaptureMode: props.CaptureMode} if props.SelectedEvent != nil { eventListProps.SelectedEventID = &props.SelectedEvent.ID } + capture := CaptureState{Active: props.CaptureActive, Mode: props.CaptureMode} templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) @@ -54,13 +58,64 @@ func Dashboard(props DashboardProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = SplitLayout(EventList(eventListProps), EventDetailContainer(props.SelectedEvent)).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = SplitLayout(EventListContainer(eventListProps), EventDetailContainer(props.SelectedEvent)).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout(capture).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func EventListContainer(props EventListProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + opts := MustGetHandlerOptions(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = EventList(props).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/dashboard/views/event-list.templ b/dashboard/views/event-list.templ index 16f9f04..39f1c8d 100644 --- a/dashboard/views/event-list.templ +++ b/dashboard/views/event-list.templ @@ -17,33 +17,42 @@ import ( type EventListProps struct { SelectedEventID *uuid.UUID Events []*collector.Event + CaptureActive bool + CaptureMode string // "session" or "global" } templ EventList(props EventListProps) { - + + } else { +
+

Click Record to start capturing events

+
+ } } @@ -66,21 +75,32 @@ func isEventSelected(event *collector.Event, selectedEventID *uuid.UUID) bool { templ linkListItem(event *collector.Event, selectedEventID *uuid.UUID) { {{ isSelected := isEventSelected(event, selectedEventID) }} + {{ opts := MustGetHandlerOptions(ctx) }}
{ children... }
} +templ childEventList(children []*collector.Event, selectedEventID *uuid.UUID) { + if len(children) > 0 { + + } +} + templ tagList(tags map[string]string) {
for _, key := range slices.Sorted(maps.Keys(tags)) { @@ -123,13 +143,7 @@ templ HTTPRequestListItem(event *collector.Event, selectedEventID *uuid.UUID) { @tagList(request.Tags) } } - if len(event.Children) > 0 { - - } + @childEventList(event.Children, selectedEventID) } @@ -163,13 +177,7 @@ templ HTTPServerRequestListItem(event *collector.Event, selectedEventID *uuid.UU @tagList(request.Tags) } } - if len(event.Children) > 0 { - - } + @childEventList(event.Children, selectedEventID) } diff --git a/dashboard/views/event-list_templ.go b/dashboard/views/event-list_templ.go index 18fb0c1..2276e73 100644 --- a/dashboard/views/event-list_templ.go +++ b/dashboard/views/event-list_templ.go @@ -25,6 +25,8 @@ import ( type EventListProps struct { SelectedEventID *uuid.UUID Events []*collector.Event + CaptureActive bool + CaptureMode string // "session" or "global" } func EventList(props EventListProps) templ.Component { @@ -48,45 +50,53 @@ func EventList(props EventListProps) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err } return nil }) @@ -165,28 +175,29 @@ func linkListItem(event *collector.Event, selectedEventID *uuid.UUID) templ.Comp } ctx = templ.ClearChildren(ctx) isSelected := isEventSelected(event, selectedEventID) + opts := MustGetHandlerOptions(ctx) var templ_7745c5c3_Var6 = []any{ - "p-3 hover:bg-neutral-100 cursor-pointer transition-colors [.selected]:bg-blue-50", + "p-3 bg-white hover:bg-neutral-100 cursor-pointer transition-colors [.selected]:bg-blue-50", templ.KV("selected", isSelected), } templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -233,7 +244,7 @@ func linkListItem(event *collector.Event, selectedEventID *uuid.UUID) templ.Comp if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -241,7 +252,7 @@ func linkListItem(event *collector.Event, selectedEventID *uuid.UUID) templ.Comp }) } -func tagList(tags map[string]string) templ.Component { +func childEventList(children []*collector.Event, selectedEventID *uuid.UUID) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -262,43 +273,84 @@ func tagList(tags map[string]string) templ.Component { templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if len(children) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func tagList(tags map[string]string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, key := range slices.Sorted(maps.Keys(tags)) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(key) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 88, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 108, Col: 60} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(tags[key]) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(tags[key]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 88, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 108, Col: 126} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -322,18 +374,18 @@ func HTTPRequestListItem(event *collector.Event, selectedEventID *uuid.UUID) tem }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent } ctx = templ.ClearChildren(ctx) request := event.Data.(collector.HTTPClientRequest) parsedURL, _ := url.Parse(request.URL) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var15 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var16 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -345,120 +397,120 @@ func HTTPRequestListItem(event *collector.Event, selectedEventID *uuid.UUID) tem }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 = []any{badgeClasses(BadgeProps{ + var templ_7745c5c3_Var17 = []any{badgeClasses(BadgeProps{ Variant: BadgeVariantOutline, })} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(request.Method) + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(request.Method) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 106, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 126, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 = []any{badgeClasses(BadgeProps{ + var templ_7745c5c3_Var20 = []any{badgeClasses(BadgeProps{ Variant: BadgeVariantSuccess, })} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var21 string - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(request.StatusCode)) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(request.StatusCode)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 113, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 133, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(parsedURL.Path) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(parsedURL.Path) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 120, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 140, Col: 72} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(parsedURL.Host) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(parsedURL.Host) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 121, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 141, Col: 73} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -470,27 +522,15 @@ func HTTPRequestListItem(event *collector.Event, selectedEventID *uuid.UUID) tem } return nil }) - templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var15), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var16), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if len(event.Children) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + templ_7745c5c3_Err = childEventList(event.Children, selectedEventID).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -514,17 +554,17 @@ func HTTPServerRequestListItem(event *collector.Event, selectedEventID *uuid.UUI }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) request := event.Data.(collector.HTTPServerRequest) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var27 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -536,107 +576,107 @@ func HTTPServerRequestListItem(event *collector.Event, selectedEventID *uuid.UUI }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 = []any{badgeClasses(BadgeProps{ + var templ_7745c5c3_Var28 = []any{badgeClasses(BadgeProps{ Variant: BadgeVariantOutline, })} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...) + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(request.Method) + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(request.Method) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 147, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 161, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var30 = []any{badgeClasses(BadgeProps{ + var templ_7745c5c3_Var31 = []any{badgeClasses(BadgeProps{ Variant: BadgeVariantSuccess, })} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...) + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(request.StatusCode)) + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(request.StatusCode)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 154, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 168, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(request.Path) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(request.Path) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 161, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 175, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -648,27 +688,15 @@ func HTTPServerRequestListItem(event *collector.Event, selectedEventID *uuid.UUI } return nil }) - templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var27), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if len(event.Children) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + templ_7745c5c3_Err = childEventList(event.Children, selectedEventID).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -692,17 +720,17 @@ func LogListItem(event *collector.Event, selectedEventID *uuid.UUID) templ.Compo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var35 := templ.GetChildren(ctx) - if templ_7745c5c3_Var35 == nil { - templ_7745c5c3_Var35 = templ.NopComponent + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) record := event.Data.(slog.Record) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Var36 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -714,116 +742,116 @@ func LogListItem(event *collector.Event, selectedEventID *uuid.UUID) templ.Compo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var37 = []any{badgeClasses(BadgeProps{ + var templ_7745c5c3_Var38 = []any{badgeClasses(BadgeProps{ Variant: logLevelToBadgeVariant(record.Level), })} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...) + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(record.Level) + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(record.Level) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 187, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 195, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(record.Message) + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(record.Message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 194, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 202, Col: 72} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for attr := range iterSlogAttrs(record) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(attr.Key) + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(attr.Key) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 198, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 206, Col: 73} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(attr.Value.String()) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(attr.Value.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 198, Col: 149} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 206, Col: 149} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -847,13 +875,13 @@ func DBQueryListItem(event *collector.Event, selectedEventID *uuid.UUID) templ.C }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var44 := templ.GetChildren(ctx) - if templ_7745c5c3_Var44 == nil { - templ_7745c5c3_Var44 = templ.NopComponent + templ_7745c5c3_Var45 := templ.GetChildren(ctx) + if templ_7745c5c3_Var45 == nil { + templ_7745c5c3_Var45 = templ.NopComponent } ctx = templ.ClearChildren(ctx) query := event.Data.(collector.DBQuery) - templ_7745c5c3_Var45 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var46 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -865,78 +893,78 @@ func DBQueryListItem(event *collector.Event, selectedEventID *uuid.UUID) templ.C }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(query.Query) > 100 { - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(query.Query[:100]) + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(query.Query[:100]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 211, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 219, Col: 35} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "...") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "...") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(query.Query) + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(query.Query) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 213, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 221, Col: 29} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
    Duration: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
    Duration: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2fms", float64(query.Duration.Microseconds())/1000)) + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2fms", float64(query.Duration.Microseconds())/1000)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 217, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 225, Col: 90} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if query.Error != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "Error: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "Error: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(query.Error.Error()) + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(query.Error.Error()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 219, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-list.templ`, Line: 227, Col: 71} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var45), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = linkListItem(event, selectedEventID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var46), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/dashboard/views/format.go b/dashboard/views/format.go new file mode 100644 index 0000000..380f69b --- /dev/null +++ b/dashboard/views/format.go @@ -0,0 +1,17 @@ +package views + +import "fmt" + +// FormatBytes returns a human-readable string with binary suffix (KiB, MiB, GiB) +func FormatBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} diff --git a/dashboard/views/header.templ b/dashboard/views/header.templ index d319dfe..b567b5b 100644 --- a/dashboard/views/header.templ +++ b/dashboard/views/header.templ @@ -1,29 +1,33 @@ package views -import "fmt" +import ( + "fmt" + "strings" +) -templ Header() { -
    -
    -

    devlog

    -
    -
    -
    - +type CaptureState struct { + Active bool + Mode string // "session" or "global" +} +templ Header(capture CaptureState) { + {{ opts := MustGetHandlerOptions(ctx) }} +
    +
    + @devlogLogo() + @CaptureControls(capture) +
    + @UsagePanel() @@ -32,16 +36,174 @@ templ Header() {
    } +templ CaptureControls(capture CaptureState) { + {{ opts := MustGetHandlerOptions(ctx) }} + {{ mode := capture.Mode }} + if mode == "" { + {{ mode = "session" }} + } +
    +
    + @TapeButton(TapeButtonProps{Pressed: capture.Active, Color: TapeButtonColorRed}, templ.Attributes{ + "title": "Start capture", + "hx-post": fmt.Sprintf("%s/s/%s/capture/start", opts.PathPrefix, opts.SessionID), + "hx-vals": "js:{mode: document.getElementById('capture-controls').dataset.mode}", + "hx-on::after-request": "if(event.detail.successful) htmx.trigger('#event-list-container', 'capture-state-changed')", + }) { + @iconRecord() + } + @TapeButton(TapeButtonProps{Pressed: !capture.Active, Color: TapeButtonColorGray}, templ.Attributes{ + "title": "Stop capture", + "hx-post": fmt.Sprintf("%s/s/%s/capture/stop", opts.PathPrefix, opts.SessionID), + "hx-on::after-request": "if(event.detail.successful) htmx.trigger('#event-list-container', 'capture-state-changed')", + }) { + @iconStop() + } +
    + @CaptureMode(mode, capture.Active) +
    +} + +// TapeButton renders a cassette-player style button (pressed/released states) +templ TapeButton(props TapeButtonProps, attrs templ.Attributes) { + +} + +type TapeButtonColor string + +const ( + TapeButtonColorRed TapeButtonColor = "red" + TapeButtonColorGray TapeButtonColor = "gray" +) + +type TapeButtonProps struct { + Pressed bool + Color TapeButtonColor +} + +func tapeButtonClasses(props TapeButtonProps) string { + var classes []string + + // Base classes + classes = append(classes, "cursor-pointer inline-flex items-center justify-center h-10 w-10 rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2") + + // Color and state classes - optimized for dark header background + switch props.Color { + case TapeButtonColorRed: + if props.Pressed { + classes = append(classes, "bg-red-800 border-red-600 text-red-400 glow-red btn-pressed") + } else { + classes = append(classes, "bg-red-950/50 border-red-800 text-red-500 glow-red hover:bg-red-900/50 hover:text-red-400") + } + default: // TapeButtonColorGray + if props.Pressed { + classes = append(classes, "bg-neutral-700 border-neutral-500 text-neutral-300 glow-gray btn-pressed") + } else { + classes = append(classes, "bg-neutral-800/50 border-neutral-600 text-neutral-400 glow-gray hover:bg-neutral-700/50 hover:text-neutral-300") + } + } + + return strings.Join(classes, " ") +} + +templ CaptureMode(mode string, capturing bool) { + {{ opts := MustGetHandlerOptions(ctx) }} +
    + if capturing { + + + } else { + + + } +
    +} + +templ iconRecord() { + + + +} + +templ iconStop() { + + + +} + templ iconDeleteRow() { - - - Delete Row Streamline Icon: https://streamlinehq.com - - - - - - - - + + + Delete Row Streamline Icon: https://streamlinehq.com + + + + + + + + +} + +templ devlogLogo() { + + + + + + + + + + + + + + + } \ No newline at end of file diff --git a/dashboard/views/header_templ.go b/dashboard/views/header_templ.go index ff7a21d..b773ad3 100644 --- a/dashboard/views/header_templ.go +++ b/dashboard/views/header_templ.go @@ -8,9 +8,17 @@ package views import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "fmt" +import ( + "fmt" + "strings" +) -func Header() templ.Component { +type CaptureState struct { + Active bool + Mode string // "session" or "global" +} + +func Header(capture CaptureState) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -31,20 +39,37 @@ func Header() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

    devlog

    ") + opts := MustGetHandlerOptions(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = devlogLogo().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CaptureControls(capture).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = UsagePanel().Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 = []any{buttonClasses( ButtonProps{ - Variant: ButtonVariantOutline, + Variant: ButtonVariantOutlineDark, Size: ButtonSizeIcon, })} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -86,7 +111,7 @@ func Header() templ.Component { }) } -func iconDeleteRow() templ.Component { +func CaptureControls(capture CaptureState) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -107,7 +132,468 @@ func iconDeleteRow() templ.Component { templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Delete Row Streamline Icon: https://streamlinehq.com ") + opts := MustGetHandlerOptions(ctx) + mode := capture.Mode + if mode == "" { + mode = "session" + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = iconRecord().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = TapeButton(TapeButtonProps{Pressed: capture.Active, Color: TapeButtonColorRed}, templ.Attributes{ + "title": "Start capture", + "hx-post": fmt.Sprintf("%s/s/%s/capture/start", opts.PathPrefix, opts.SessionID), + "hx-vals": "js:{mode: document.getElementById('capture-controls').dataset.mode}", + "hx-on::after-request": "if(event.detail.successful) htmx.trigger('#event-list-container', 'capture-state-changed')", + }).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var8 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = iconStop().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = TapeButton(TapeButtonProps{Pressed: !capture.Active, Color: TapeButtonColorGray}, templ.Attributes{ + "title": "Stop capture", + "hx-post": fmt.Sprintf("%s/s/%s/capture/stop", opts.PathPrefix, opts.SessionID), + "hx-on::after-request": "if(event.detail.successful) htmx.trigger('#event-list-container', 'capture-state-changed')", + }).Render(templ.WithChildren(ctx, templ_7745c5c3_Var8), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CaptureMode(mode, capture.Active).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// TapeButton renders a cassette-player style button (pressed/released states) +func TapeButton(props TapeButtonProps, attrs templ.Attributes) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var templ_7745c5c3_Var10 = []any{tapeButtonClasses(props)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +type TapeButtonColor string + +const ( + TapeButtonColorRed TapeButtonColor = "red" + TapeButtonColorGray TapeButtonColor = "gray" +) + +type TapeButtonProps struct { + Pressed bool + Color TapeButtonColor +} + +func tapeButtonClasses(props TapeButtonProps) string { + var classes []string + + // Base classes + classes = append(classes, "cursor-pointer inline-flex items-center justify-center h-10 w-10 rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2") + + // Color and state classes - optimized for dark header background + switch props.Color { + case TapeButtonColorRed: + if props.Pressed { + classes = append(classes, "bg-red-800 border-red-600 text-red-400 glow-red btn-pressed") + } else { + classes = append(classes, "bg-red-950/50 border-red-800 text-red-500 glow-red hover:bg-red-900/50 hover:text-red-400") + } + default: // TapeButtonColorGray + if props.Pressed { + classes = append(classes, "bg-neutral-700 border-neutral-500 text-neutral-300 glow-gray btn-pressed") + } else { + classes = append(classes, "bg-neutral-800/50 border-neutral-600 text-neutral-400 glow-gray hover:bg-neutral-700/50 hover:text-neutral-300") + } + } + + return strings.Join(classes, " ") +} + +func CaptureMode(mode string, capturing bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + opts := MustGetHandlerOptions(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if capturing { + var templ_7745c5c3_Var13 = []any{"px-3 py-2 cursor-pointer transition-colors", templ.KV("bg-devlog-cyan/20 text-devlog-cyan", mode == "session"), templ.KV("text-neutral-400 hover:bg-white/10 hover:text-white", mode != "session")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 = []any{"px-3 py-2 cursor-pointer transition-colors border-l border-header-border", templ.KV("bg-devlog-cyan/20 text-devlog-cyan", mode == "global"), templ.KV("text-neutral-400 hover:bg-white/10 hover:text-white", mode != "global")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var19 = []any{"px-3 py-2 cursor-pointer transition-colors", templ.KV("bg-devlog-cyan/20 text-devlog-cyan", mode == "session"), templ.KV("text-neutral-400 hover:bg-white/10 hover:text-white", mode != "session")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 = []any{"px-3 py-2 cursor-pointer transition-colors border-l border-header-border", templ.KV("bg-devlog-cyan/20 text-devlog-cyan", mode == "global"), templ.KV("text-neutral-400 hover:bg-white/10 hover:text-white", mode != "global")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func iconRecord() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func iconStop() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func iconDeleteRow() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Delete Row Streamline Icon: https://streamlinehq.com ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func devlogLogo() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/dashboard/views/helper.go b/dashboard/views/helper.go index b40158d..b9f835e 100644 --- a/dashboard/views/helper.go +++ b/dashboard/views/helper.go @@ -2,7 +2,9 @@ package views import ( "context" + "fmt" "io" + "net/url" "strings" "github.com/a-h/templ" @@ -65,6 +67,26 @@ func chromaStyles() templ.Component { type HandlerOptions struct { PathPrefix string TruncateAfter uint64 + SessionID string + CaptureActive bool + CaptureMode string // "session" or "global" +} + +// BuildEventDetailURL builds a URL for event detail view, preserving capture state +func (opts HandlerOptions) BuildEventDetailURL(eventID string) string { + base := fmt.Sprintf("%s/s/%s/", opts.PathPrefix, opts.SessionID) + params := url.Values{} + if eventID != "" { + params.Set("id", eventID) + } + if opts.CaptureActive { + params.Set("capture", "true") + params.Set("mode", opts.CaptureMode) + } + if len(params) > 0 { + return base + "?" + params.Encode() + } + return base } // Context key for HandlerOptions diff --git a/dashboard/views/layout.templ b/dashboard/views/layout.templ index 1a99d6c..3408422 100644 --- a/dashboard/views/layout.templ +++ b/dashboard/views/layout.templ @@ -13,24 +13,39 @@ func cachebuster(path string) string { return path } -templ Layout() { +templ Layout(capture CaptureState) { + {{ opts := MustGetHandlerOptions(ctx) }} devlog - - + + + + + + + - + @chromaStyles() - - @Header() -
    + + @Header(capture) +
    { children... }
    + if url := os.Getenv("REFRESH_LIVE_RELOAD_SCRIPT_URL"); url != "" { } diff --git a/dashboard/views/layout_templ.go b/dashboard/views/layout_templ.go index 9bbd854..801b77e 100644 --- a/dashboard/views/layout_templ.go +++ b/dashboard/views/layout_templ.go @@ -21,7 +21,7 @@ func cachebuster(path string) string { return path } -func Layout() templ.Component { +func Layout(capture CaptureState) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -42,33 +42,86 @@ func Layout() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) + opts := MustGetHandlerOptions(ctx) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "devlog") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -76,15 +129,28 @@ func Layout() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = Header().Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Header(capture).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -92,30 +158,30 @@ func Layout() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if url := os.Getenv("REFRESH_LIVE_RELOAD_SCRIPT_URL"); url != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/dashboard/views/split-layout.templ b/dashboard/views/split-layout.templ index f452f82..6f7a425 100644 --- a/dashboard/views/split-layout.templ +++ b/dashboard/views/split-layout.templ @@ -1,8 +1,8 @@ package views templ SplitLayout(leftContent, rightContent templ.Component) { -
    -
    +
    +
    @leftContent
    diff --git a/dashboard/views/split-layout_templ.go b/dashboard/views/split-layout_templ.go index cb32e1b..a347ab7 100644 --- a/dashboard/views/split-layout_templ.go +++ b/dashboard/views/split-layout_templ.go @@ -29,7 +29,7 @@ func SplitLayout(leftContent, rightContent templ.Component) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/dashboard/views/usage_panel.templ b/dashboard/views/usage_panel.templ new file mode 100644 index 0000000..7602710 --- /dev/null +++ b/dashboard/views/usage_panel.templ @@ -0,0 +1,47 @@ +package views + +import "fmt" + +templ UsagePanel() { + {{ opts := MustGetHandlerOptions(ctx) }} +
    + Loading... +
    +} + +templ UsagePanelContent(memory string, sessions int, maxSessions int) { +
    +
    + @iconDatabase() + { memory } +
    +
    + @iconUsers() + + if maxSessions > 0 { + { fmt.Sprintf("%d/%d", sessions, maxSessions) } + } else { + { fmt.Sprintf("%d", sessions) } + } + +
    +
    +} + +templ iconDatabase() { + + + +} + +templ iconUsers() { + + + +} diff --git a/dashboard/views/usage_panel_templ.go b/dashboard/views/usage_panel_templ.go new file mode 100644 index 0000000..aa68ed6 --- /dev/null +++ b/dashboard/views/usage_panel_templ.go @@ -0,0 +1,197 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.865 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "fmt" + +func UsagePanel() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + opts := MustGetHandlerOptions(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
    Loading...
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func UsagePanelContent(memory string, sessions int, maxSessions int) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = iconDatabase().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(memory) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/usage_panel.templ`, Line: 22, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = iconUsers().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if maxSessions > 0 { + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", sessions, maxSessions)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/usage_panel.templ`, Line: 28, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", sessions)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/usage_panel.templ`, Line: 30, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func iconDatabase() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func iconUsers() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/devlog.go b/devlog.go index a140ceb..bb7ec27 100644 --- a/devlog.go +++ b/devlog.go @@ -14,7 +14,9 @@ type Instance struct { httpClientCollector *collector.HTTPClientCollector httpServerCollector *collector.HTTPServerCollector dbQueryCollector *collector.DBQueryCollector - eventCollector *collector.EventCollector + eventAggregator *collector.EventAggregator + + dashboardHandler *dashboard.Handler } func (i *Instance) Close() { @@ -22,44 +24,28 @@ func (i *Instance) Close() { i.httpClientCollector.Close() i.httpServerCollector.Close() i.dbQueryCollector.Close() - i.eventCollector.Close() + if i.dashboardHandler != nil { + i.dashboardHandler.Close() + } + i.eventAggregator.Close() } type Options struct { - // LogCapacity is the maximum number of log entries to keep. - // Default: 0 - LogCapacity uint64 // LogOptions are the options for the log collector. // Default: nil, will use collector.DefaultLogOptions() LogOptions *collector.LogOptions - // HTTPClientCapacity is the maximum number of HTTP client requests (outgoing) to keep. - // Default: 0 - HTTPClientCapacity uint64 // HTTPClientOptions are the options for the HTTP client collector. // Default: nil, will use collector.DefaultHTTPClientOptions() HTTPClientOptions *collector.HTTPClientOptions - // HTTPServerCapacity is the maximum number of HTTP server requests (incoming) to keep. - // Default: 0 - HTTPServerCapacity uint64 // HTTPServerOptions are the options for the HTTP server collector. // Default: nil, will use collector.DefaultHTTPServerOptions() HTTPServerOptions *collector.HTTPServerOptions - // DBQueryCapacity is the maximum number of database queries to keep. - // Default: 0 - DBQueryCapacity uint64 // DBQueryOptions are the options for the database query collector. // Default: nil, will use collector.DefaultDBQueryOptions() DBQueryOptions *collector.DBQueryOptions - - // EventCapacity is the maximum number of events to keep. - // Default: 1000 - EventCapacity uint64 - // EventOptions are the options for the event collector. - // Default: nil, will use collector.DefaultEventOptions() - EventOptions *collector.EventOptions } // New creates a new devlog dashboard with default options. @@ -69,48 +55,43 @@ func New() *Instance { // NewWithOptions creates a new devlog dashboard with the specified options. // Default options are the zero value of Options. +// +// By default, no events are collected until a user starts a capture session +// through the dashboard. Events are collected per-user with isolation. func NewWithOptions(options Options) *Instance { - if options.EventCapacity == 0 { - options.EventCapacity = 1000 - } - - eventOptions := collector.DefaultEventOptions() - if options.EventOptions != nil { - eventOptions = *options.EventOptions - } - - eventCollector := collector.NewEventCollectorWithOptions(options.EventCapacity, eventOptions) + // Create the central EventAggregator (no storage by default) + eventAggregator := collector.NewEventAggregator() logOptions := collector.DefaultLogOptions() if options.LogOptions != nil { logOptions = *options.LogOptions } - logOptions.EventCollector = eventCollector + logOptions.EventAggregator = eventAggregator httpClientOptions := collector.DefaultHTTPClientOptions() if options.HTTPClientOptions != nil { httpClientOptions = *options.HTTPClientOptions } - httpClientOptions.EventCollector = eventCollector + httpClientOptions.EventAggregator = eventAggregator httpServerOptions := collector.DefaultHTTPServerOptions() if options.HTTPServerOptions != nil { httpServerOptions = *options.HTTPServerOptions } - httpServerOptions.EventCollector = eventCollector + httpServerOptions.EventAggregator = eventAggregator dbQueryOptions := collector.DefaultDBQueryOptions() if options.DBQueryOptions != nil { dbQueryOptions = *options.DBQueryOptions } - dbQueryOptions.EventCollector = eventCollector + dbQueryOptions.EventAggregator = eventAggregator instance := &Instance{ - logCollector: collector.NewLogCollectorWithOptions(options.LogCapacity, logOptions), - httpClientCollector: collector.NewHTTPClientCollectorWithOptions(options.HTTPClientCapacity, httpClientOptions), - httpServerCollector: collector.NewHTTPServerCollectorWithOptions(options.HTTPServerCapacity, httpServerOptions), - dbQueryCollector: collector.NewDBQueryCollectorWithOptions(options.DBQueryCapacity, dbQueryOptions), - eventCollector: eventCollector, + logCollector: collector.NewLogCollectorWithOptions(logOptions), + httpClientCollector: collector.NewHTTPClientCollectorWithOptions(httpClientOptions), + httpServerCollector: collector.NewHTTPServerCollectorWithOptions(httpServerOptions), + dbQueryCollector: collector.NewDBQueryCollectorWithOptions(dbQueryOptions), + eventAggregator: eventAggregator, } return instance } @@ -137,12 +118,17 @@ func (i *Instance) CollectDBQuery() func(ctx context.Context, dbQuery collector. return i.dbQueryCollector.Collect } -func (i *Instance) DashboardHandler(pathPrefix string) http.Handler { - return dashboard.NewHandler( - dashboard.HandlerOptions{ - EventCollector: i.eventCollector, - - PathPrefix: pathPrefix, - }, - ) +// DashboardHandler creates a dashboard handler mounted at the given path prefix. +// Use functional options from the dashboard package to customize behavior: +// +// dlog.DashboardHandler("/_devlog", +// dashboard.WithStorageCapacity(5000), +// dashboard.WithSessionIdleTimeout(time.Minute), +// ) +func (i *Instance) DashboardHandler(pathPrefix string, opts ...dashboard.HandlerOption) http.Handler { + // Prepend WithPathPrefix to user-provided options + allOpts := append([]dashboard.HandlerOption{dashboard.WithPathPrefix(pathPrefix)}, opts...) + handler := dashboard.NewHandler(i.eventAggregator, allOpts...) + i.dashboardHandler = handler + return handler } diff --git a/devlog_e2e_test.go b/devlog_e2e_test.go index bcc51b2..c34be4f 100644 --- a/devlog_e2e_test.go +++ b/devlog_e2e_test.go @@ -19,22 +19,16 @@ func TestE2E(t *testing.T) { runtime.ReadMemStats(&memBefore) dlog := devlog.NewWithOptions(devlog.Options{ - LogCapacity: 50, - LogOptions: nil, - HTTPClientCapacity: 10, HTTPClientOptions: &collector.HTTPClientOptions{ MaxBodySize: 1024 * 1024, CaptureRequestBody: true, CaptureResponseBody: true, }, - HTTPServerCapacity: 10, HTTPServerOptions: &collector.HTTPServerOptions{ MaxBodySize: 1024 * 1024, CaptureRequestBody: true, CaptureResponseBody: true, }, - DBQueryCapacity: 10, - EventCapacity: 10, }) mux := http.NewServeMux() diff --git a/docs/design/capture-session-system.md b/docs/design/capture-session-system.md new file mode 100644 index 0000000..b0b0a4d --- /dev/null +++ b/docs/design/capture-session-system.md @@ -0,0 +1,321 @@ +# Technical Design: Capture Session System + +## Goal + +Enable on-demand event collection in Devlog. By default, no events are collected. Users can enable capture through the +dashboard UI. Each user gets their own isolated storage with a configurable capture mode: + +1. **Session Mode**: Collects events only for HTTP requests with the user's session cookie +2. **Global Mode**: Collects all events (but stored per-user, so clearing doesn't affect others) + +## High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CAPTURE FLOW │ +│ (Request → Event → Storage) │ +└─────────────────────────────────────────────────────────────────────────────┘ + + HTTP Request + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ HTTPServerCollector.Middleware │ +│ │ +│ 1. Extract session cookie → ctx = WithSessionID(ctx, id) │ +│ 2. shouldCapture := aggregator.ShouldCapture(ctx) ◄───────────────────┐ │ +│ │ │ +│ if !shouldCapture { next.ServeHTTP(w,r); return } // early bailout │ │ +│ │ │ +│ 3. ctx = aggregator.StartEvent(ctx) // adds GroupID │ │ +│ 4. Capture request body, wrap response writer │ │ +│ 5. next.ServeHTTP(wrappedWriter, r.WithContext(ctx)) │ │ +│ 6. aggregator.EndEvent(ctx, httpServerRequest) │ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ │ + │ ctx carries: SessionID + GroupID │ + ▼ │ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Child Collectors │ +│ (DB Query, Log, HTTP Client) │ +│ │ +│ if !aggregator.ShouldCapture(ctx) { return } ◄────────────────────────┤ │ +│ │ │ +│ aggregator.CollectEvent(ctx, data) │ │ +│ // Uses GroupID from ctx to link as child event │ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ │ + ▼ │ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EventAggregator │ +│ │ +│ ShouldCapture(ctx) bool ◄─────────────────────────────────────────────┘ │ +│ - Iterates registered storages │ +│ - Returns true if any storage.ShouldCapture(ctx) returns true │ +│ │ +│ RegisterStorage(s) / UnregisterStorage(id) / GetStorage(id) │ +│ StartEvent(ctx) / EndEvent(ctx, data) / CollectEvent(ctx, data) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + │ Dispatch: for each storage that ShouldCapture(ctx) + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CaptureStorages (per user) │ +│ │ +│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │ +│ │ CaptureStorage │ │ CaptureStorage │ │ CaptureStorage │ │ +│ │ (User A) │ │ (User B) │ │ (User C) │ │ +│ ├───────────────────┤ ├───────────────────┤ ├───────────────────┤ │ +│ │ sessionID: uuid-A │ │ sessionID: uuid-B │ │ sessionID: uuid-C │ │ +│ │ mode: session │ │ mode: global │ │ mode: session │ │ +│ ├───────────────────┤ ├───────────────────┤ ├───────────────────┤ │ +│ │ ShouldCapture: │ │ ShouldCapture: │ │ ShouldCapture: │ │ +│ │ ctx.session == A │ │ return true │ │ ctx.session == C │ │ +│ ├───────────────────┤ ├───────────────────┤ ├───────────────────┤ │ +│ │ RingBuffer │ │ RingBuffer │ │ RingBuffer │ │ +│ │ Notifier ─────────│► │ Notifier ─────────│► │ Notifier ─────────│► │ +│ └───────────────────┘ └───────────────────┘ └───────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ │ │ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ NOTIFICATION FLOW │ +│ (Storage → SSE → Dashboard) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ │ │ + │ Subscribe(ctx) │ Subscribe(ctx) │ Subscribe(ctx) + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ SSE Handler │ │ SSE Handler │ │ SSE Handler │ +│ (User A) │ │ (User B) │ │ (User C) │ +│ │ │ │ │ │ +│ GET /events-sse│ │GET /events-sse │ │GET /events-sse │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Dashboard │ │ Dashboard │ │ Dashboard │ +│ (User A) │ │ (User B) │ │ (User C) │ +│ Session Mode │ │ Global Mode │ │ Session Mode │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +## Data Flow Summary + +### Capture Flow (Request → Storage) + +1. **Middleware** extracts session cookie, adds to context +2. **Middleware** asks `aggregator.ShouldCapture(ctx)` - O(1) check +3. If capturing: do expensive work (body capture, response wrap) +4. **Aggregator** creates Event, dispatches to matching storages +5. **Storage** adds event pointer to its ring buffer + +### Notification Flow (Storage → Dashboard) + +1. **Storage** notifies its subscribers when event is added +2. **SSE Handler** receives notification, sends to browser +3. **Dashboard** updates event list in real-time + +## EventAggregator + +Central coordinator (no storage, just dispatch). Treats all storages uniformly via the `EventStorage` interface: + +- `ShouldCapture(ctx)` - Iterates storages, returns true if any wants to capture +- `StartEvent(ctx)` / `EndEvent(ctx, data)` / `CollectEvent(ctx, data)` - Event lifecycle +- `RegisterStorage(s)` / `UnregisterStorage(id)` / `GetStorage(id)` - Uniform storage management + +## EventStorage Interface + +```go +type EventStorage interface { + ID() uuid.UUID + ShouldCapture(ctx context.Context) bool + Add(event *Event) + GetEvent(id uuid.UUID) (*Event, bool) + GetEvents(limit uint64) []*Event + Subscribe(ctx context.Context) <-chan *Event + Clear() + Close() +} +``` + +## CaptureStorage + +Single storage type with configurable capture mode: + +```go +type CaptureMode int +const ( + CaptureModeSession CaptureMode = iota // only matching session + CaptureModeGlobal // all requests +) +``` + +- `sessionID`: Identifies the storage owner (from cookie) +- `captureMode`: Session or Global +- `ShouldCapture(ctx)`: + - If mode == session: return `ctx.sessionID == s.sessionID` + - If mode == global: return `true` +- `SetCaptureMode(mode)` / `CaptureMode()`: Toggle capture mode +- Created when user clicks "Start Capture" +- Destroyed after idle timeout or explicit stop +- Each user gets their own storage (clearing is isolated) + +## Capture Lifecycle + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CAPTURE LIFECYCLE │ +└─────────────────────────────────────────────────────────────────────────────┘ + +START ACTIVE CLEANUP + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌─────────────┐ ┌──────────────┐ +│ POST │ ─────── │ SSE │ ─────────────── │ Idle Timeout │ +│ /capture │ cookie │ connected │ disconnect │ (30s) │ +│ /start │ │ │ │ │ +└──────────────┘ └─────────────┘ └──────────────┘ + │ │ │ + ▼ ▼ ▼ + Create storage Keep-alive via Unregister + Register with SSE connection storage from + aggregator (or explicit aggregator + Set cookie "Stop" button) → GC cleanup + │ + ▼ + ┌─────────────────────┐ + │ POST /capture/mode │ + │ Toggle: session ↔ │ + │ global │ + └─────────────────────┘ +``` + +## Key Design Decisions + +| Decision | Choice | Rationale | +|------------------------|---------------------------------------------|------------------------------------------------------| +| Breaking API change | Yes | v0.2.1 exists, users use high-level `devlog` package | +| Dashboard skip | Use SkipPaths | Add dashboard routes to HTTPServerOptions.SkipPaths | +| Storage model | Single CaptureStorage with mode per user | Each user isolated, clearing doesn't affect others | +| Storage-driven capture | Each storage decides via ShouldCapture(ctx) | Clean separation, extensible | +| Separate buffers | Each storage has own RingBuffer | Isolation, independent capacity | +| Shared events | Storages hold *Event pointers | Memory efficient, GC handles cleanup | +| Cookie scope | Application root (`/`) | Cookie sent with all app requests | +| Cookie name | `devlog_session` | Clear purpose, HttpOnly for security | +| UI placement | Header bar | Always visible, quick access | +| Idle timeout | 30s after SSE disconnect | Allows page reload without losing session | + +## API Endpoints (New) + +| Method | Path | Purpose | +|--------|-------------------|---------------------------------------------------------| +| POST | `/capture/start` | Create CaptureStorage for user, set cookie, register | +| POST | `/capture/stop` | Unregister storage, clear cookie | +| POST | `/capture/mode` | Set capture mode (session or global) for user's storage | +| GET | `/capture/status` | Get current capture state (active, mode) | + +## Configuration + +```go +type Options struct { + // ... existing fields ... + + // Capture configuration (if nil, no capture by default) + CaptureOptions *CaptureOptions +} + +type CaptureOptions struct { + StorageCapacity uint64 // Events per storage (default: 1000) + SessionIdleTimeout time.Duration // Cleanup delay after disconnect (default: 30s) +} +``` + +## Files to Modify + +| File | Changes | +|---------------------------------|---------------------------------------------------------------------------------| +| `collector/event_storage.go` | **NEW** - EventStorage interface and implementations | +| `collector/event_aggregator.go` | **RENAME** from event_collector.go - storage registry, dispatch to storages | +| `collector/session_context.go` | **NEW** - Context helpers for session ID | +| `collector/http_server.go` | Extract cookie, add session ID to context | +| `dashboard/handler.go` | New endpoints, storage-aware event fetching | +| `dashboard/views/header.templ` | Capture control buttons in header | +| `devlog.go` | CaptureOptions, storage initialization, rename EventCollector → EventAggregator | + +## TDD Test Cases + +### 1. CaptureStorage Tests (`collector/event_storage_test.go` - NEW) + +| Test | Fixture | Action | Expectation | +|------------------------------------------------------------|----------------------------------|------------------------------------|---------------------------| +| `TestCaptureStorage_ShouldCapture_SessionMode_NoSessionInCtx` | CaptureStorage(session mode) | ShouldCapture(ctx without session) | Returns false | +| `TestCaptureStorage_ShouldCapture_SessionMode_WrongSession` | CaptureStorage(uuid-A, session) | ShouldCapture(ctx with uuid-B) | Returns false | +| `TestCaptureStorage_ShouldCapture_SessionMode_MatchingSession` | CaptureStorage(uuid-A, session) | ShouldCapture(ctx with uuid-A) | Returns true | +| `TestCaptureStorage_ShouldCapture_GlobalMode_AlwaysTrue` | CaptureStorage(uuid-A, global) | ShouldCapture(any ctx) | Returns true | +| `TestCaptureStorage_SetCaptureMode` | CaptureStorage | SetCaptureMode(mode) | CaptureMode() returns mode| +| `TestCaptureStorage_Add_StoresEvent` | CaptureStorage | Add(event) | GetEvents() returns event | +| `TestCaptureStorage_RingBuffer_Capacity` | CaptureStorage capacity=5 | Add 10 events | Only last 5 kept | +| `TestCaptureStorage_Subscribe_ReceivesEvents` | CaptureStorage | Subscribe, Add event | Channel receives event | +| `TestCaptureStorage_GetEvent_ByID` | CaptureStorage with events | GetEvent(id) | Returns correct event | + +### 2. EventAggregator Tests (`collector/event_aggregator_test.go` - ADAPT existing) + +| Test | Fixture | Action | Expectation | +|-----------------------------------------------------------------|-------------------------------------------|-----------------------------------------|----------------------------| +| `TestEventAggregator_ShouldCapture_NoStorages` | Aggregator without storages | ShouldCapture(ctx) | Returns false | +| `TestEventAggregator_ShouldCapture_SessionModeMatch` | Aggregator + Storage(uuid-A, session) | ShouldCapture(ctx with uuid-A) | Returns true | +| `TestEventAggregator_ShouldCapture_SessionModeNoMatch` | Aggregator + Storage(uuid-A, session) | ShouldCapture(ctx with uuid-B) | Returns false | +| `TestEventAggregator_ShouldCapture_GlobalMode` | Aggregator + Storage(uuid-A, global) | ShouldCapture(any ctx) | Returns true | +| `TestEventAggregator_CollectEvent_DispatchesToMatchingStorages` | Aggregator + Storage(A, session) + Storage(B, global) | CollectEvent(ctx with uuid-A) | Event in both storages | +| `TestEventAggregator_CollectEvent_MultipleGlobalStorages` | Aggregator + Storage(A, global) + Storage(B, global) | CollectEvent(ctx) | Event in both storages | +| `TestEventAggregator_CollectEvent_NoCapture_NoDispatch` | Aggregator + Storage(A, session) | CollectEvent(ctx with uuid-B) | No events stored | +| `TestEventAggregator_RegisterUnregister_Storage` | Aggregator | Register, Unregister | Storage added/removed | +| `TestEventAggregator_StartEndEvent_WithCapture` | Aggregator + Storage(global) | StartEvent, EndEvent | Event with timing stored | +| `TestEventAggregator_StartEndEvent_NoCapture` | Aggregator (no storages) | StartEvent, EndEvent | No events stored | +| `TestEventAggregator_NestedEvents_WithCapture` | Aggregator + Storage(global) | Start parent, collect child, end parent | Parent has child | + +### 3. HTTPServerCollector Tests (`collector/http_server_test.go` - ADAPT) + +| Test | Fixture | Action | Expectation | +|----------------------------------------------------------------|------------------------------------------------|------------------------------------|-----------------------------------| +| `TestHTTPServer_Middleware_ExtractsCookie` | Middleware + Storage | Request with devlog_session cookie | Session ID in context | +| `TestHTTPServer_Middleware_NoCookie_NoSessionInCtx` | Middleware | Request without cookie | No session ID in context | +| `TestHTTPServer_Middleware_NoCapture_SkipsExpensiveWork` | Middleware + no storages | Request | No body capture, fast passthrough | +| `TestHTTPServer_Middleware_Capture_CapturesBody` | Middleware + Storage(global) | Request with body | Body captured in event | +| `TestHTTPServer_Middleware_SessionCapture_OnlyMatchingSession` | Middleware + Storage(A, session) + Storage(B, session) | Request with session A cookie | Event only in Storage A | + +### 4. Session Context Tests (`collector/session_context_test.go` - NEW) + +| Test | Fixture | Action | Expectation | +|-----------------------------------|----------------------|---------------------------|-----------------------------------| +| `TestWithSessionID_AddsToContext` | Empty context | WithSessionID(ctx, uuid) | SessionIDFromContext returns uuid | +| `TestSessionIDFromContext_NotSet` | Empty context | SessionIDFromContext(ctx) | Returns uuid.Nil, false | +| `TestSessionIDFromContext_Set` | Context with session | SessionIDFromContext(ctx) | Returns correct uuid, true | + +### 5. Dashboard Handler Tests (`dashboard/handler_test.go` - NEW or ADAPT) + +| Test | Fixture | Action | Expectation | +|----------------------------------------------|--------------------------------|-----------------------------|---------------------------------------| +| `TestHandler_CaptureStart_CreatesStorage` | Handler + Aggregator | POST /capture/start | CaptureStorage registered, cookie set | +| `TestHandler_CaptureStart_SetsCookie` | Handler | POST /capture/start | Response has devlog_session cookie | +| `TestHandler_CaptureStop_UnregistersStorage` | Handler + active capture | POST /capture/stop | CaptureStorage unregistered | +| `TestHandler_CaptureStop_ClearsCookie` | Handler + active capture | POST /capture/stop | Cookie cleared (MaxAge=-1) | +| `TestHandler_CaptureMode_SetsSessionMode` | Handler + active capture | POST /capture/mode (session)| Storage mode is session | +| `TestHandler_CaptureMode_SetsGlobalMode` | Handler + active capture | POST /capture/mode (global) | Storage mode is global | +| `TestHandler_CaptureStatus_ReturnsState` | Handler | GET /capture/status | JSON with active/mode state | +| `TestHandler_EventsSSE_FromUserStorage` | Handler + Storage with events | GET /events-sse with cookie | Receives events from user's storage | +| `TestHandler_EventList_FromUserStorage` | Handler + Storage with events | GET /event-list with cookie | Returns user's events | + +### 6. Integration / E2E Tests (`devlog_e2e_test.go` - ADAPT) + +| Test | Fixture | Action | Expectation | +|-----------------------------------------------|----------------------------------|---------------------------------------------|-----------------------------------| +| `TestE2E_NoCapture_ByDefault` | Devlog instance (default config) | Make HTTP request | No events captured | +| `TestE2E_SessionMode_OnlyMatchingRequests` | Devlog + capture (session mode) | Requests with/without cookie | Only cookie requests captured | +| `TestE2E_GlobalMode_CapturesAll` | Devlog + capture (global mode) | Make HTTP requests | All requests captured | +| `TestE2E_Capture_CapturesChildEvents` | Devlog + capture | Request with DB query + log | HTTP + DB + log in same storage | +| `TestE2E_Capture_Cleanup_AfterTimeout` | Devlog + capture | Start capture, disconnect SSE, wait timeout | Storage cleaned up | +| `TestE2E_MultipleUsers_IsolatedStorages` | Devlog | Two users, each captures | Each has own storage, clear isolated | +| `TestE2E_MultipleUsers_GlobalMode_EachGetsAllEvents` | Devlog | Two users in global mode, make request | Both get the event in their storage | diff --git a/go.mod b/go.mod index 61bc5f0..0233ce3 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/natefinch/atomic v1.0.1 // indirect @@ -29,6 +30,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/r3labs/sse/v2 v2.10.0 // indirect github.com/rjeczalik/notify v0.9.3 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect @@ -39,6 +41,7 @@ require ( golang.org/x/text v0.25.0 // indirect golang.org/x/tools v0.33.0 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 60ff1b1..3433fe5 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,7 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -50,6 +51,9 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -67,10 +71,9 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/networkteam/refresh v1.15.0 h1:8qobXuU29Ic08WkJC+lt4GHtoe9gDHTSEmgBqG5S0rg= github.com/networkteam/refresh v1.15.0/go.mod h1:J6iKbX8RO9eERGJ5yYm0gXQLy5MrsgtwiQJF7K80uoE= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -81,6 +84,9 @@ github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktE github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -141,8 +147,8 @@ gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/go.work b/go.work index 6302030..d38b1cd 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,7 @@ go 1.23.8 use ( . + ./acceptance ./dbadapter/sqllogger ./example ) diff --git a/go.work.sum b/go.work.sum index 8566aae..16a7b35 100644 --- a/go.work.sum +++ b/go.work.sum @@ -5,14 +5,17 @@ github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a h1:2KLQMJ8msqoPHIPDu github.com/aphistic/sweet v0.2.0 h1:I4z+fAUqvKfvZV/CHi5dV0QuwbmIvYYFDjG0Ss5QpAs= github.com/aws/aws-sdk-go v1.20.6 h1:kmy4Gvdlyez1fV4kw5RYxZzWKVyuHZHgPWeU/YvRsV4= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 h1:WWB576BN5zNSZc/M9d/10pqEx5VHNhaQ/yOVAkmj5Yo= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/go-logfmt/logfmt v0.4.0 h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA= github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7 h1:K//n/AqR5HjG3qxbrBCL4vJPW0MVFSs9CPK1OOJdRME= @@ -20,10 +23,11 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= -github.com/networkteam/go-sqllogger v0.4.0 h1:9hx7Zppj5KQ/l1vSEGLwe1s3viJbQKvyiN2+awPdvAs= -github.com/networkteam/go-sqllogger v0.4.0/go.mod h1:2ByE01zTVNuAlPdVhP1g+ARpr8kk3PesK/mCYgO4s1I= github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw= github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo= +github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785 h1:J1//5K/6QF10cZ59zLcVNFGmBfiSrH8Cho/lNrViK9s= +github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= github.com/rogpeppe/fastuuid v1.1.0 h1:INyGLmTCMGFr6OVIb977ghJvABML2CMVjPoRfNDdYDo= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= @@ -33,12 +37,17 @@ github.com/smartystreets/gunit v1.0.0 h1:RyPDUFcJbvtXlhJPk7v+wnxZRY2EUokhEYl2EJO github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tj/go-buffer v1.1.0 h1:Lo2OsPHlIxXF24zApe15AbK3bJLAOvkkxEA6Ux4c47M= github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2 h1:eGaGNxrtoZf/mBURsnNQKDR7u50Klgcf2eFDQEnc8Bc= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b h1:m74UWYy+HBs+jMFR9mdZU6shPewugMyH5+GV6LNgW8w= github.com/tj/go-spin v1.1.0 h1:lhdWZsvImxvZ3q1C5OIB7d72DuOwP4O2NdBg9PyzNds= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 h1:p/H982KKEjUnLJkM3tt/LemDnOc1GiZL5FCVlORJ5zo= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= @@ -61,5 +70,6 @@ golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=