Skip to content

Commit 9cb1f81

Browse files
sutr90Vojtěch FričVojtěch Frič
authored
feat: support filtering by field (#156)
Co-authored-by: Vojtěch Frič <vojtechfric@ibm.com> Co-authored-by: Vojtěch Frič <you@example.com>
1 parent 22c2dce commit 9cb1f81

File tree

8 files changed

+476
-25
lines changed

8 files changed

+476
-25
lines changed

internal/app/statefiltered.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,22 @@ type StateFilteredModel struct {
1818
table logsTableModel
1919
logEntries source.LazyLogEntries
2020

21-
filterText string
21+
filterText string
22+
filterField string
2223
}
2324

2425
func newStateFiltered(
2526
previousState StateLoadedModel,
2627
filterText string,
28+
filterField string,
2729
) StateFilteredModel {
2830
return StateFilteredModel{
2931
Application: previousState.Application,
3032

3133
previousState: previousState,
3234

33-
filterText: filterText,
35+
filterText: filterText,
36+
filterField: filterField,
3437
}
3538
}
3639

@@ -43,9 +46,14 @@ func (s StateFilteredModel) Init() tea.Cmd {
4346

4447
// View renders component. It implements tea.Model.
4548
func (s StateFilteredModel) View() string {
46-
footer := s.FooterStyle.Render(
47-
fmt.Sprintf("filtered %d by: %s", s.logEntries.Len(), s.filterText),
48-
)
49+
var msg string
50+
if s.filterField == "" {
51+
msg = fmt.Sprintf("filtered %d by: %s", s.logEntries.Len(), s.filterText)
52+
} else {
53+
msg = fmt.Sprintf("filtered %d by field: %s, query: %s", s.logEntries.Len(), s.filterField, s.filterText)
54+
}
55+
56+
footer := s.FooterStyle.Render(msg)
4957

5058
return s.BaseStyle.Render(s.table.View()) + "\n" + footer
5159
}
@@ -94,7 +102,7 @@ func (s StateFilteredModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
94102
}
95103

96104
func (s StateFilteredModel) handleStateFilteredModel() (StateFilteredModel, tea.Msg) {
97-
entries, err := s.Application.Entries().Filter(s.filterText)
105+
entries, err := s.Application.Entries().Filter(s.filterText, s.filterField, s.Config)
98106
if err != nil {
99107
return s, events.ShowError(err)()
100108
}

internal/app/statefiltered_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func TestStateFiltered(t *testing.T) {
3939
})
4040

4141
lines := strings.Split(model.View(), "\n")
42-
assert.Contains(t, lines[len(lines)-1], ">")
42+
assert.Contains(t, lines[len(lines)-2], ">")
4343

4444
_, ok := model.(app.StateFilteringModel)
4545
assert.Truef(t, ok, "%s", model)

internal/app/statefiltering.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ package app
22

33
import (
44
"github.com/charmbracelet/bubbles/key"
5-
"github.com/charmbracelet/bubbles/textinput"
65
tea "github.com/charmbracelet/bubbletea"
76

87
"github.com/hedhyw/json-log-viewer/internal/keymap"
98
"github.com/hedhyw/json-log-viewer/internal/pkg/events"
9+
"github.com/hedhyw/json-log-viewer/internal/pkg/widgets"
1010
)
1111

1212
// StateFilteringModel is a state to prompt for filter term.
@@ -16,14 +16,19 @@ type StateFilteringModel struct {
1616
previousState StateLoadedModel
1717
table logsTableModel
1818

19-
textInput textinput.Model
19+
textInput widgets.PillInputModel
2020
keys keymap.KeyMap
2121
}
2222

2323
func newStateFiltering(
2424
previousState StateLoadedModel,
2525
) StateFilteringModel {
26-
textInput := textinput.New()
26+
fieldTitles := make([]string, len(previousState.Config.Fields))
27+
for i, f := range previousState.Config.Fields {
28+
fieldTitles[i] = f.Title
29+
}
30+
31+
textInput := widgets.NewPillInputModel(fieldTitles)
2732
textInput.Focus()
2833

2934
return StateFilteringModel{
@@ -39,7 +44,7 @@ func newStateFiltering(
3944

4045
// Init initializes component. It implements tea.Model.
4146
func (s StateFilteringModel) Init() tea.Cmd {
42-
return nil
47+
return s.textInput.Init()
4348
}
4449

4550
// View renders component. It implements tea.Model.
@@ -64,7 +69,11 @@ func (s StateFilteringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6469
s.table, cmdBatch = batched(s.table.Update(msg))(cmdBatch)
6570
}
6671

67-
s.textInput, cmdBatch = batched(s.textInput.Update(msg))(cmdBatch)
72+
var cmd tea.Cmd
73+
s.textInput, cmd = s.textInput.Update(msg)
74+
if cmd != nil {
75+
cmdBatch = append(cmdBatch, cmd)
76+
}
6877

6978
return s, tea.Batch(cmdBatch...)
7079
}
@@ -81,13 +90,15 @@ func (s StateFilteringModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
8190
}
8291

8392
func (s StateFilteringModel) handleEnterKeyClickedMsg() (tea.Model, tea.Cmd) {
84-
if s.textInput.Value() == "" {
93+
filterField, input := s.textInput.Value()
94+
if input == "" {
8595
return s, events.EscKeyClicked
8696
}
8797

8898
return initializeModel(newStateFiltered(
8999
s.previousState,
90-
s.textInput.Value(),
100+
input,
101+
filterField,
91102
))
92103
}
93104

internal/pkg/source/entry.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,13 @@ func (entries LazyLogEntries) Len() int {
9191
}
9292

9393
// Filter filters entries by ignore case exact match.
94-
func (entries LazyLogEntries) Filter(term string) (LazyLogEntries, error) {
94+
func (entries LazyLogEntries) Filter(term string, fieldName string, c *config.Config) (LazyLogEntries, error) {
9595
if term == "" {
9696
return entries, nil
9797
}
9898

99+
fieldIndex := getFilterFieldNameIndex(fieldName, c)
100+
99101
termLower := bytes.ToLower([]byte(term))
100102

101103
filtered := make([]LazyLogEntry, 0, len(entries.Entries))
@@ -106,8 +108,21 @@ func (entries LazyLogEntries) Filter(term string) (LazyLogEntries, error) {
106108
return LazyLogEntries{}, err
107109
}
108110

109-
if bytes.Contains(bytes.ToLower(line), termLower) {
110-
filtered = append(filtered, f)
111+
if len(fieldName) == 0 {
112+
// fulltext mode
113+
if bytes.Contains(bytes.ToLower(line), termLower) {
114+
filtered = append(filtered, f)
115+
}
116+
} else {
117+
// field mode
118+
entry := f.LogEntry(entries.Seeker, c)
119+
if entry.Error != nil {
120+
return LazyLogEntries{}, entry.Error
121+
}
122+
123+
if bytes.Contains(bytes.ToLower([]byte(entry.Fields[fieldIndex])), termLower) {
124+
filtered = append(filtered, f)
125+
}
111126
}
112127
}
113128

@@ -117,6 +132,20 @@ func (entries LazyLogEntries) Filter(term string) (LazyLogEntries, error) {
117132
}, nil
118133
}
119134

135+
func getFilterFieldNameIndex(fieldName string, c *config.Config) int {
136+
if c == nil || fieldName == "" {
137+
return -1
138+
}
139+
140+
for i, field := range c.Fields {
141+
if strings.EqualFold(field.Title, fieldName) {
142+
return i
143+
}
144+
}
145+
146+
return -1
147+
}
148+
120149
func parseField(
121150
parsedLine any,
122151
field config.Field,

internal/pkg/source/entry_test.go

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ func TestLazyLogEntriesFilter(t *testing.T) {
276276

277277
logEntries, logEntry := createEntries(t)
278278

279-
filtered, err := logEntries.Filter(term)
279+
filtered, err := logEntries.Filter(term, "", nil)
280280
require.NoError(t, err)
281281

282282
if assert.Len(t, filtered.Entries, 1) {
@@ -289,7 +289,7 @@ func TestLazyLogEntriesFilter(t *testing.T) {
289289

290290
logEntries, logEntry := createEntries(t)
291291

292-
filtered, err := logEntries.Filter(strings.ToUpper(term))
292+
filtered, err := logEntries.Filter(strings.ToUpper(term), "", nil)
293293
require.NoError(t, err)
294294

295295
if assert.Len(t, filtered.Entries, 1) {
@@ -302,7 +302,7 @@ func TestLazyLogEntriesFilter(t *testing.T) {
302302

303303
logEntries, _ := createEntries(t)
304304

305-
filtered, err := logEntries.Filter("")
305+
filtered, err := logEntries.Filter("", "", nil)
306306
require.NoError(t, err)
307307
assert.Len(t, filtered.Entries, logEntries.Len())
308308
})
@@ -312,7 +312,7 @@ func TestLazyLogEntriesFilter(t *testing.T) {
312312

313313
logEntries, _ := createEntries(t)
314314

315-
filtered, err := logEntries.Filter(term + " - not found!")
315+
filtered, err := logEntries.Filter(term+" - not found!", "", nil)
316316
require.NoError(t, err)
317317

318318
assert.Empty(t, filtered.Entries)
@@ -331,11 +331,53 @@ func TestLazyLogEntriesFilter(t *testing.T) {
331331

332332
logEntries.Seeker = f
333333

334-
_, err = logEntries.Filter(term + " - not found!")
334+
_, err = logEntries.Filter(term+" - not found!", "", nil)
335335
require.Error(t, err)
336336
})
337337
}
338338

339+
func TestLazyLogEntriesFieldFilter(t *testing.T) {
340+
t.Parallel()
341+
342+
const term = "info"
343+
344+
const logs = `
345+
{"level":"info","time":"2025-12-16T13:20:00-05:00","message":"2025-12-16 13:20:00.049 Day1| Ana went home!"}
346+
{"level":"debug","time":"2025-12-16T13:20:00-05:00","message":"2025-12-16 13:21:00.049 Day2| Tom was daydreaming"}
347+
{"level":"error","time":"2025-12-16T13:20:00-05:00","message":"2025-12-16 13:22:00.049 Day3| Can't wait to be weekend!"}
348+
`
349+
350+
defaultConfig := config.GetDefaultConfig()
351+
352+
createEntries := func(tb testing.TB) (source.LazyLogEntries, source.LazyLogEntry) {
353+
tb.Helper()
354+
source, err := source.Reader(bytes.NewReader([]byte(logs)), defaultConfig)
355+
require.NoError(tb, err)
356+
357+
tb.Cleanup(func() { assert.NoError(tb, source.Close()) })
358+
359+
logEntries, err := source.ParseLogEntries()
360+
require.NoError(tb, err)
361+
362+
logEntry := logEntries.Entries[0]
363+
364+
return logEntries, logEntry
365+
}
366+
367+
t.Run("found_exact", func(t *testing.T) {
368+
t.Parallel()
369+
370+
logEntries, logEntry := createEntries(t)
371+
372+
filtered, err := logEntries.Filter(term, "level", defaultConfig)
373+
require.NoError(t, err)
374+
375+
if assert.Len(t, filtered.Entries, 1) {
376+
assert.Equal(t, logEntry, filtered.Entries[0])
377+
}
378+
})
379+
}
380+
339381
func TestSecondTimeFormatting(t *testing.T) {
340382
t.Parallel()
341383

0 commit comments

Comments
 (0)