diff --git a/internal/funnel/funnel.go b/internal/funnel/funnel.go index 1c2ad4d..3dabdd6 100644 --- a/internal/funnel/funnel.go +++ b/internal/funnel/funnel.go @@ -15,7 +15,6 @@ type Funnel struct { Requests *RequestList } - // ID returns the unique identifier for the funnel. func (f *Funnel) ID() string { if f.HTTPFunnel == nil { @@ -87,4 +86,3 @@ func (f *Funnel) Destroy() error { return nil } - diff --git a/internal/funnel/http.go b/internal/funnel/http.go index e21e9ed..284a299 100644 --- a/internal/funnel/http.go +++ b/internal/funnel/http.go @@ -147,7 +147,7 @@ func (s *HttpServer) handleRequest(w http.ResponseWriter, r *http.Request) { proxy.Director = func(req *http.Request) { originalDirector(req) - // read the request body, the plan is to expose this in the UI somehow, but that comes + // read the request body, the plan is to expose this in the UI somehow, but that comes // at the expense of increased memory usage... make this better var reqBodyBytes []byte var err error diff --git a/internal/funnel/types.go b/internal/funnel/types.go index a245685..23882ad 100644 --- a/internal/funnel/types.go +++ b/internal/funnel/types.go @@ -1,6 +1,7 @@ package funnel import ( + "fmt" "net/url" "strings" "sync" @@ -101,3 +102,45 @@ func (r *CaptureRequestResponse) Path() string { func (r *CaptureRequestResponse) StatusCode() int { return r.Response.StatusCode } + +func (r *CaptureRequestResponse) Type() string { + // use the response content-type header to determine the type of the request + contentType := r.Response.Headers["Content-Type"] + if contentType == "" { + return "" + } + + // handle some explicit cases, json, html, xml, css, js, etc. + if strings.HasPrefix(contentType, "application/json") { + return "json" + } + if strings.HasPrefix(contentType, "text/html") { + return "html" + } + if strings.HasPrefix(contentType, "text/xml") { + return "xml" + } + if strings.HasPrefix(contentType, "text/css") { + return "css" + } + if strings.HasPrefix(contentType, "text/javascript") { + return "js" + } + if strings.HasPrefix(contentType, "text/plain") { + return "txt" + } + // split the content type by "/" and take the first part + parts := strings.Split(contentType, "/") + if len(parts) > 0 { + return parts[0] + } + return "" +} + +func (r *CaptureRequestResponse) RoundedDuration() string { + if r.Duration.Seconds() >= 1 { + // we want one decimal place + return fmt.Sprintf("%.1fs", r.Duration.Seconds()) + } + return fmt.Sprintf("%dms", r.Duration.Round(time.Millisecond).Milliseconds()) +} diff --git a/internal/funnel/types_test.go b/internal/funnel/types_test.go index 3446fed..694b309 100644 --- a/internal/funnel/types_test.go +++ b/internal/funnel/types_test.go @@ -200,3 +200,67 @@ func TestRequestList_Add(t *testing.T) { }) } + +func TestCaptureRequestResponse_RoundedDuration(t *testing.T) { + testCases := []struct { + name string + duration time.Duration + expected string + }{ + {"Zero duration", 0, "0ms"}, + {"Milliseconds", 123 * time.Millisecond, "123ms"}, + {"Half second", 500 * time.Millisecond, "500ms"}, + {"Just under 1 second", 999 * time.Millisecond, "999ms"}, + {"Exactly 1 second", 1 * time.Second, "1.0s"}, + {"1.2 seconds", 1200 * time.Millisecond, "1.2s"}, + {"1.23 seconds (rounds)", 1234 * time.Millisecond, "1.2s"}, // Should round based on fmt.Sprintf + {"Long duration", 5*time.Minute + 30*time.Second + 456*time.Millisecond, "330.5s"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + crr := CaptureRequestResponse{Duration: tc.duration} + if got := crr.RoundedDuration(); got != tc.expected { + t.Errorf("Expected RoundedDuration() to be %q, got %q", tc.expected, got) + } + }) + } +} + +func TestCaptureRequestResponse_Type(t *testing.T) { + testCases := []struct { + name string + contentType string + expected string + }{ + {"No Content-Type", "", ""}, + {"JSON", "application/json", "json"}, + {"JSON with charset", "application/json; charset=utf-8", "json"}, + {"HTML", "text/html", "html"}, + {"XML", "text/xml", "xml"}, + {"CSS", "text/css", "css"}, + {"JavaScript", "text/javascript", "js"}, + {"Plain Text", "text/plain", "txt"}, + {"Image PNG", "image/png", "image"}, + {"Video MP4", "video/mp4", "video"}, + {"Application Octet Stream", "application/octet-stream", "application"}, + {"Weird format", "foo/bar", "foo"}, + {"Only main type", "audio", "audio"}, + {"Empty string after split", "/", ""}, // Invalid Content-Type + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + headers := make(map[string]string) + if tc.contentType != "" { + headers["Content-Type"] = tc.contentType + } + crr := CaptureRequestResponse{ + Response: CaptureResponse{Headers: headers}, + } + if got := crr.Type(); got != tc.expected { + t.Errorf("Expected Type() to be %q for Content-Type %q, got %q", tc.expected, tc.contentType, got) + } + }) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index bcaf9a2..80be0f1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -240,9 +240,11 @@ func createInitialTable() table.Model { func createRequestTable() table.Model { columns := []table.Column{ {Title: "Timestamp"}, - {Title: "Method"}, {Title: "Status"}, - {Title: "URL"}, + {Title: "Method"}, + {Title: "Path"}, + {Title: "Type"}, + {Title: "Duration"}, {Title: "ID"}, // Hidden column for request ID } @@ -433,14 +435,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.table.SetColumns(newColumns) - urlWidth := tableWidth - 12 - 8 - 8 - 20 + timestampWidth := 12 + statusWidth := 7 + methodWidth := 7 + typeWidth := 8 + durationWidth := 10 + + bufferWidth := 10 + + urlWidth := tableWidth - timestampWidth - statusWidth - methodWidth - typeWidth - durationWidth - bufferWidth requestColumns := []table.Column{ - {Title: "Timestamp", Width: 12}, - {Title: "Method", Width: 8}, - {Title: "Status", Width: 8}, - {Title: "URL", Width: urlWidth}, - {}, // Hidden column, no title or width needed + {Title: "Timestamp", Width: timestampWidth}, + {Title: "Status", Width: statusWidth}, + {Title: "Method", Width: methodWidth}, + {Title: "Path", Width: urlWidth}, + {Title: "Type", Width: typeWidth}, + {Title: "Duration", Width: durationWidth}, + {}, // hiddend column that will store the id of the request } m.requestTable.SetColumns(requestColumns) @@ -721,10 +733,10 @@ func (m model) updateDetailView(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if m.detailTabIndex == 1 { selectedRow := m.requestTable.SelectedRow() - if len(selectedRow) < 5 { // Ensure row and ID exist (index 4) + if len(selectedRow) < 6 { // Ensure row and ID exist (index 5) return m, nil // Or handle error } - selectedRequestID := selectedRow[4] // Get ID from the hidden column + selectedRequestID := selectedRow[len(selectedRow)-1] // id is always the last column funnel, err := m.funnelRegistry.GetFunnel(m.detailedFunnelID) if err == nil { @@ -981,9 +993,11 @@ func (m *model) populateRequestTable() { for node != nil { rows = append(rows, table.Row{ node.Request.Timestamp.Format("15:04:05"), - node.Request.Method(), strconv.Itoa(node.Request.StatusCode()), + node.Request.Method(), node.Request.Path(), + node.Request.Type(), + node.Request.RoundedDuration(), node.Request.ID, }) node = node.Next @@ -1133,10 +1147,11 @@ func (m model) viewRequestDetailView(contentHeight int) string { } requestInfo := fmt.Sprintf( - "URL: %s\nMethod: %s\nStatus: %d", + "URL: %s\nMethod: %s\nStatus: %d\nDuration: %s", m.selectedRequest.Path(), m.selectedRequest.Method(), m.selectedRequest.StatusCode(), + m.selectedRequest.RoundedDuration(), ) formatHeaders := func(headers map[string]string) string {