Skip to content

Commit 1345c4a

Browse files
committed
feat(tui): endpoint card scrolling
1 parent 73aeca7 commit 1345c4a

2 files changed

Lines changed: 111 additions & 13 deletions

File tree

tui/internal/ui/dashboard/cards/endpoints.go

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,11 @@ type endpointID struct {
2727
type EndpointsCard struct {
2828
endpoints []endpoint
2929
sorted []endpoint
30-
selectMode bool
30+
selectMode bool
3131
selectedIndex int
32+
scrollOffset int
3233
}
3334

34-
const maxEndpoints = 35 // Maximum number of endpoints to display
35-
3635
func NewEndpointsCard(logs []nginx.NGINXLog, period period.Period) *EndpointsCard {
3736
card := &EndpointsCard{}
3837
card.UpdateCalculated(logs, period)
@@ -67,6 +66,21 @@ func (p *EndpointsCard) RenderContent(width, height int) string {
6766
}
6867

6968
endpoints := p.sorted
69+
if p.selectMode {
70+
p.ensureSelectionVisible(height)
71+
} else {
72+
p.scrollOffset = 0
73+
}
74+
75+
start := p.scrollOffset
76+
if start < 0 {
77+
start = 0
78+
}
79+
if start > len(endpoints) {
80+
start = len(endpoints)
81+
}
82+
end := min(start+height, len(endpoints))
83+
visibleEndpoints := endpoints[start:end]
7084

7185
// Find max count for scaling bars
7286
maxCount := endpoints[0].count
@@ -89,16 +103,13 @@ func (p *EndpointsCard) RenderContent(width, height int) string {
89103
var buf strings.Builder
90104

91105
// Render each endpoint as a horizontal bar with overlaid text
92-
for i, ep := range endpoints {
93-
if i >= height {
94-
break // Don't exceed available height
95-
}
96-
106+
for i, ep := range visibleEndpoints {
97107
if i > 0 {
98108
buf.WriteByte('\n')
99109
}
100110

101-
isSelected := p.selectMode && i == p.selectedIndex
111+
actualIndex := start + i
112+
isSelected := p.selectMode && actualIndex == p.selectedIndex
102113

103114
// Calculate bar length proportional to count, using full width
104115
barLength := 0
@@ -192,7 +203,7 @@ func (p *EndpointsCard) RenderContent(width, height int) string {
192203
}
193204

194205
// Fill remaining height with empty lines
195-
for i := min(len(endpoints), height); i < height; i++ {
206+
for i := len(visibleEndpoints); i < height; i++ {
196207
buf.WriteByte('\n')
197208
}
198209

@@ -219,9 +230,6 @@ func (r *EndpointsCard) sortEndpoints() []endpoint {
219230
}
220231
return sorted[i].status < sorted[j].status
221232
})
222-
if len(sorted) > maxEndpoints {
223-
sorted = sorted[:maxEndpoints]
224-
}
225233
return sorted
226234
}
227235

@@ -262,10 +270,12 @@ func (r *EndpointsCard) GetRequiredHeight(width int) int {
262270
func (r *EndpointsCard) EnterSelectMode() {
263271
r.selectMode = true
264272
r.selectedIndex = 0
273+
r.scrollOffset = 0
265274
}
266275

267276
func (r *EndpointsCard) ExitSelectMode() {
268277
r.selectMode = false
278+
r.scrollOffset = 0
269279
}
270280

271281
func (r *EndpointsCard) IsInSelectMode() bool {
@@ -300,6 +310,7 @@ func (r *EndpointsCard) HasSelection() bool {
300310
func (r *EndpointsCard) ClearSelection() {
301311
r.selectedIndex = 0
302312
r.selectMode = false
313+
r.scrollOffset = 0
303314
}
304315

305316
// GetSelectedEndpoint returns the currently selected endpoint filter, or nil if none selected
@@ -314,3 +325,22 @@ func (r *EndpointsCard) GetSelectedEndpoint() *EndpointFilter {
314325
Status: ep.status,
315326
}
316327
}
328+
329+
func (r *EndpointsCard) ensureSelectionVisible(height int) {
330+
if height <= 0 || len(r.sorted) == 0 {
331+
r.scrollOffset = 0
332+
return
333+
}
334+
335+
maxOffset := max(len(r.sorted)-height, 0)
336+
r.scrollOffset = min(max(r.scrollOffset, 0), maxOffset)
337+
338+
if r.selectedIndex < r.scrollOffset {
339+
r.scrollOffset = r.selectedIndex
340+
}
341+
if r.selectedIndex >= r.scrollOffset+height {
342+
r.scrollOffset = r.selectedIndex - height + 1
343+
}
344+
345+
r.scrollOffset = min(max(r.scrollOffset, 0), maxOffset)
346+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package cards
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/tom-draper/nginx-analytics/tui/internal/logs/nginx"
9+
"github.com/tom-draper/nginx-analytics/tui/internal/logs/period"
10+
)
11+
12+
func TestEndpointsCardScrollsSelectionBeyondVisibleHeight(t *testing.T) {
13+
logs := make([]nginx.NGINXLog, 0, 12)
14+
for i := 0; i < 12; i++ {
15+
status := 200
16+
logs = append(logs, nginx.NGINXLog{
17+
Method: "GET",
18+
Path: fmt.Sprintf("/endpoint-%02d", i),
19+
Status: &status,
20+
})
21+
}
22+
23+
card := NewEndpointsCard(logs, period.Period30Days)
24+
card.EnterSelectMode()
25+
26+
for range 7 {
27+
card.SelectDown()
28+
}
29+
30+
rendered := card.RenderContent(40, 5)
31+
32+
if !strings.Contains(rendered, "/endpoint-07") {
33+
t.Fatalf("expected selected endpoint to be visible after scrolling, got:\n%s", rendered)
34+
}
35+
if strings.Contains(rendered, "/endpoint-00") {
36+
t.Fatalf("expected initial endpoint to scroll out of view, got:\n%s", rendered)
37+
}
38+
if card.scrollOffset == 0 {
39+
t.Fatalf("expected scroll offset to advance, got %d", card.scrollOffset)
40+
}
41+
}
42+
43+
func TestEndpointsCardDoesNotTruncateAllEndpointsForSelection(t *testing.T) {
44+
logs := make([]nginx.NGINXLog, 0, 60)
45+
for i := 0; i < 60; i++ {
46+
status := 200
47+
logs = append(logs, nginx.NGINXLog{
48+
Method: "GET",
49+
Path: fmt.Sprintf("/endpoint-%02d", i),
50+
Status: &status,
51+
})
52+
}
53+
54+
card := NewEndpointsCard(logs, period.Period30Days)
55+
card.EnterSelectMode()
56+
57+
for range 59 {
58+
card.SelectDown()
59+
}
60+
61+
selected := card.GetSelectedEndpoint()
62+
if selected == nil {
63+
t.Fatal("expected a selected endpoint at the end of the list")
64+
}
65+
if selected.Path != "/endpoint-59" {
66+
t.Fatalf("expected last endpoint to be selectable, got %q", selected.Path)
67+
}
68+
}

0 commit comments

Comments
 (0)