Skip to content

Commit 8a98325

Browse files
Added logs filter state management saperate from the panel list filter, Implemented the stream filtering code to support both tty and non-tty logs with ASCII filter support, Refactor filtering code to support both list filter and logs filter, Added new views for log filter with its new keybindings
1 parent f4fc366 commit 8a98325

File tree

11 files changed

+525
-17
lines changed

11 files changed

+525
-17
lines changed

docs/Config.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ logs:
6464
timestamps: false
6565
since: '60m' # set to '' to show all logs
6666
tail: '' # set to 200 to show last 200 lines of logs
67+
68+
### Logs Filtering
69+
70+
When viewing container or service logs in the main panel, you can filter the log output in real-time:
71+
72+
- Press `/` while viewing logs to open the filter prompt
73+
- Type a search string to filter log lines (case-sensitive substring match)
74+
- Press `Enter` to commit the filter and return to viewing filtered logs
75+
- Press `Esc` to cancel the filter and return to unfiltered logs
76+
6777
commandTemplates:
6878
dockerCompose: docker compose # Determines the Docker Compose command to run, referred to as .DockerCompose in commandTemplates
6979
restartService: '{{ .DockerCompose }} restart {{ .Service.Name }}'

pkg/gui/arrangement.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
2929
sidePanelsDirection = boxlayout.ROW
3030
}
3131

32-
showInfoSection := gui.Config.UserConfig.Gui.ShowBottomLine || gui.State.Filter.active
32+
showInfoSection := gui.Config.UserConfig.Gui.ShowBottomLine || gui.State.Filter.active || gui.State.LogsFilter.active
3333
infoSectionSize := 0
3434
if showInfoSection {
3535
infoSectionSize = 1
@@ -112,6 +112,19 @@ func (gui *Gui) infoSectionChildren(informationStr string, appStatus string) []*
112112
}...)
113113
}
114114

115+
if gui.State.LogsFilter.active {
116+
return append(result, []*boxlayout.Box{
117+
{
118+
Window: "logsfilterPrefix",
119+
Size: runewidth.StringWidth(gui.logsFilterPrompt()),
120+
},
121+
{
122+
Window: "logsfilter",
123+
Weight: 1,
124+
},
125+
}...)
126+
}
127+
115128
result = append(result,
116129
[]*boxlayout.Box{
117130
{

pkg/gui/container_logs.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package gui
22

33
import (
4+
"bufio"
5+
"bytes"
46
"context"
57
"fmt"
68
"io"
79
"os"
810
"os/signal"
11+
"strings"
912
"time"
1013

1114
"github.com/docker/docker/api/types/container"
@@ -136,12 +139,26 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
136139
}
137140
}
138141

142+
var filterString string
143+
var hasFilter bool
144+
145+
if gui.State.LogsFilter.active {
146+
filterString = gui.State.LogsFilter.needle
147+
hasFilter = filterString != ""
148+
}
149+
139150
if ctr.Details.Config.Tty {
151+
if hasFilter {
152+
return gui.filterTTYLogs(readCloser, writer, filterString, ctx)
153+
}
140154
_, err = io.Copy(writer, readCloser)
141155
if err != nil {
142156
return err
143157
}
144158
} else {
159+
if hasFilter {
160+
return gui.filterNonTTYLogs(readCloser, writer, filterString, ctx)
161+
}
145162
_, err = stdcopy.StdCopy(writer, writer, readCloser)
146163
if err != nil {
147164
return err
@@ -150,3 +167,74 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
150167

151168
return nil
152169
}
170+
171+
// filterTTYLogs filters TTY logs line by line based on the filter string
172+
func (gui *Gui) filterTTYLogs(reader io.Reader, writer io.Writer, filter string, ctx context.Context) error {
173+
return streamFilterLines(reader, writer, filter, ctx)
174+
}
175+
176+
// filterNonTTYLogs filters non-TTY logs (stdout/stderr) line by line
177+
func (gui *Gui) filterNonTTYLogs(reader io.Reader, writer io.Writer, filter string, ctx context.Context) error {
178+
pipeReader, pipeWriter := io.Pipe()
179+
180+
go func() {
181+
defer pipeWriter.Close()
182+
183+
done := make(chan struct{})
184+
go func() {
185+
select {
186+
case <-ctx.Done():
187+
pipeWriter.CloseWithError(ctx.Err())
188+
case <-done:
189+
}
190+
}()
191+
192+
_, err := stdcopy.StdCopy(pipeWriter, pipeWriter, reader)
193+
close(done)
194+
195+
if err != nil {
196+
pipeWriter.CloseWithError(err)
197+
}
198+
}()
199+
200+
defer pipeReader.Close()
201+
return streamFilterLines(pipeReader, writer, filter, ctx)
202+
}
203+
204+
func streamFilterLines(reader io.Reader, writer io.Writer, filter string, ctx context.Context) error {
205+
scanner := bufio.NewScanner(reader)
206+
scanner.Buffer(make([]byte, 0, 4*1024), 10*1024*1024)
207+
208+
// Pre-convert filter to bytes for faster comparison when filter is ASCII
209+
filterBytes := []byte(filter)
210+
isASCIIFilter := len(filterBytes) == len(filter)
211+
212+
for scanner.Scan() {
213+
select {
214+
case <-ctx.Done():
215+
return nil
216+
default:
217+
}
218+
219+
line := scanner.Bytes()
220+
221+
var shouldWrite bool
222+
if isASCIIFilter {
223+
shouldWrite = bytes.Contains(line, filterBytes)
224+
} else {
225+
// For non-ASCII filters, we need string conversion for proper UTF-8 handling
226+
shouldWrite = strings.Contains(string(line), filter)
227+
}
228+
229+
if shouldWrite {
230+
if _, err := writer.Write(line); err != nil {
231+
return err
232+
}
233+
if _, err := writer.Write([]byte("\n")); err != nil {
234+
return err
235+
}
236+
}
237+
}
238+
239+
return scanner.Err()
240+
}

pkg/gui/container_logs_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package gui
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestStreamFilterLines(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
input string
16+
filter string
17+
expected string
18+
}{
19+
{
20+
name: "simple match - single line",
21+
input: "hello world\n",
22+
filter: "hello",
23+
expected: "hello world\n",
24+
},
25+
{
26+
name: "simple match - multiple lines",
27+
input: "hello world\nfoo bar\nbaz qux\n",
28+
filter: "foo",
29+
expected: "foo bar\n",
30+
},
31+
{
32+
name: "multiple matches",
33+
input: "hello world\nhello again\nfoo bar\n",
34+
filter: "hello",
35+
expected: "hello world\nhello again\n",
36+
},
37+
{
38+
name: "no matches",
39+
input: "hello world\nfoo bar\nbaz qux\n",
40+
filter: "xyz",
41+
expected: "",
42+
},
43+
{
44+
name: "empty filter - matches all",
45+
input: "hello world\nfoo bar\n",
46+
filter: "",
47+
expected: "hello world\nfoo bar\n",
48+
},
49+
{
50+
name: "case sensitive match",
51+
input: "Hello World\nhello world\nHELLO WORLD\n",
52+
filter: "hello",
53+
expected: "hello world\n",
54+
},
55+
{
56+
name: "partial word match",
57+
input: "hello world\nhelloworld\n",
58+
filter: "hello",
59+
expected: "hello world\nhelloworld\n",
60+
},
61+
{
62+
name: "filter in middle of line",
63+
input: "prefix hello suffix\n",
64+
filter: "hello",
65+
expected: "prefix hello suffix\n",
66+
},
67+
{
68+
name: "empty input",
69+
input: "",
70+
filter: "hello",
71+
expected: "",
72+
},
73+
{
74+
name: "newline only lines",
75+
input: "\n\n\n",
76+
filter: "",
77+
expected: "\n\n\n",
78+
},
79+
{
80+
name: "filter with special characters",
81+
input: "error: something went wrong\ninfo: all good\n",
82+
filter: "error:",
83+
expected: "error: something went wrong\n",
84+
},
85+
}
86+
87+
for _, tt := range tests {
88+
t.Run(tt.name, func(t *testing.T) {
89+
reader := strings.NewReader(tt.input)
90+
writer := &bytes.Buffer{}
91+
ctx := context.Background()
92+
93+
err := streamFilterLines(reader, writer, tt.filter, ctx)
94+
95+
assert.NoError(t, err)
96+
assert.Equal(t, tt.expected, writer.String())
97+
})
98+
}
99+
}
100+
101+
func TestStreamFilterLines_ContextCancellation(t *testing.T) {
102+
input := strings.Repeat("hello world\n", 1000)
103+
reader := strings.NewReader(input)
104+
writer := &bytes.Buffer{}
105+
ctx, cancel := context.WithCancel(context.Background())
106+
107+
cancel()
108+
109+
err := streamFilterLines(reader, writer, "hello", ctx)
110+
111+
assert.NoError(t, err)
112+
}
113+
114+
func TestStreamFilterLines_NonASCIIFilter(t *testing.T) {
115+
tests := []struct {
116+
name string
117+
input string
118+
filter string
119+
expected string
120+
}{
121+
{
122+
name: "UTF-8 characters",
123+
input: "hello 世界\nfoo bar\n",
124+
filter: "世界",
125+
expected: "hello 世界\n",
126+
},
127+
{
128+
name: "emoji filter",
129+
input: "error 🚨 occurred\ninfo: all good\n",
130+
filter: "🚨",
131+
expected: "error 🚨 occurred\n",
132+
},
133+
{
134+
name: "mixed ASCII and UTF-8",
135+
input: "hello 世界 world\nfoo bar\n",
136+
filter: "世界",
137+
expected: "hello 世界 world\n",
138+
},
139+
}
140+
141+
for _, tt := range tests {
142+
t.Run(tt.name, func(t *testing.T) {
143+
reader := strings.NewReader(tt.input)
144+
writer := &bytes.Buffer{}
145+
ctx := context.Background()
146+
147+
err := streamFilterLines(reader, writer, tt.filter, ctx)
148+
149+
assert.NoError(t, err)
150+
assert.Equal(t, tt.expected, writer.String())
151+
})
152+
}
153+
}
154+
155+
func TestStreamFilterLines_LongLines(t *testing.T) {
156+
longLine := strings.Repeat("a", 100000) + " target " + strings.Repeat("b", 100000) + "\n"
157+
shortLine := "target\n"
158+
159+
input := longLine + shortLine
160+
reader := strings.NewReader(input)
161+
writer := &bytes.Buffer{}
162+
ctx := context.Background()
163+
164+
err := streamFilterLines(reader, writer, "target", ctx)
165+
166+
assert.NoError(t, err)
167+
assert.Contains(t, writer.String(), "target")
168+
assert.Equal(t, 2, strings.Count(writer.String(), "target"))
169+
}
170+
171+
func TestStreamFilterLines_EmptyLines(t *testing.T) {
172+
input := "line with content\n\nanother line\n\n\n"
173+
reader := strings.NewReader(input)
174+
writer := &bytes.Buffer{}
175+
ctx := context.Background()
176+
177+
err := streamFilterLines(reader, writer, "", ctx)
178+
179+
assert.NoError(t, err)
180+
lines := strings.Split(writer.String(), "\n")
181+
assert.GreaterOrEqual(t, len(lines), 5)
182+
}

0 commit comments

Comments
 (0)