From a32effc42de8b5ca295a12b5f201b8115e323586 Mon Sep 17 00:00:00 2001 From: Bryce Palmer Date: Sat, 18 Nov 2023 11:33:33 -0500 Subject: [PATCH] (feature): log panel fuzzy search (#35) * (feature): log panel fuzzy search Signed-off-by: Bryce Palmer * add search highlighting, strict/fuzzy match toggle, and help Signed-off-by: Bryce Palmer --------- Signed-off-by: Bryce Palmer --- go.mod | 2 + go.sum | 6 + pkg/charm/models/dashboard.go | 8 +- pkg/charm/models/panels/logs.go | 215 ++++++++++++++++++++++++++++++-- pkg/charm/styles/styles.go | 8 ++ pkg/paneler/logs.go | 43 ++++--- 6 files changed, 246 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index a6fd072..50557a0 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 github.com/charmbracelet/lipgloss v0.7.1 + github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v1.7.0 github.com/tidwall/gjson v1.17.0 k8s.io/api v0.28.1 @@ -17,6 +18,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect diff --git a/go.sum b/go.sum index 6304a98..74ef5fd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/calyptia/go-bubble-table v0.2.1 h1:NWcVRyGCLuP7QIA29uUFSY+IjmWcmUWHjy5J/CPb0Rk= @@ -73,6 +75,8 @@ 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= @@ -118,6 +122,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/pkg/charm/models/dashboard.go b/pkg/charm/models/dashboard.go index 04fbcfc..3c29185 100644 --- a/pkg/charm/models/dashboard.go +++ b/pkg/charm/models/dashboard.go @@ -32,12 +32,12 @@ func (k DashboardKeyMap) FullHelp() [][]key.Binding { var DefaultDashboardKeys = DashboardKeyMap{ Help: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), + key.WithKeys("ctrl+h"), + key.WithHelp("ctrl+h", "toggle help"), ), Quit: key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c"), - key.WithHelp("q, esc, ctrl+c", "quit"), + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q, ctrl+c", "quit"), ), } diff --git a/pkg/charm/models/panels/logs.go b/pkg/charm/models/panels/logs.go index 865a19f..b752387 100644 --- a/pkg/charm/models/panels/logs.go +++ b/pkg/charm/models/panels/logs.go @@ -1,59 +1,254 @@ package panels import ( + "fmt" "strings" "sync" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/sahilm/fuzzy" ) +type LogsKeyMap struct { + Search key.Binding + SubmitSearch key.Binding + QuitSearch key.Binding + ToggleStrict key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k LogsKeyMap) ShortHelp() []key.Binding { + return []key.Binding{} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k LogsKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Search, k.SubmitSearch, k.QuitSearch, k.ToggleStrict}, + } +} + +var DefaultLogsKeys = LogsKeyMap{ + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "open a prompt to search logs"), + ), + SubmitSearch: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "submit search prompt"), + ), + QuitSearch: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "exit search mode"), + ), + ToggleStrict: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "toggle strict search mode"), + ), +} + +const modeLogs = "logs" +const modeSearching = "searching" +const modeSearched = "searched" + // Logs is a tea.Model implementation // that represents an item panel type Logs struct { - viewport viewport.Model - name string - mutex *sync.Mutex - content string + viewport viewport.Model + searchbar textinput.Model + name string + mutex *sync.Mutex + content string + contentUpdated bool + mode string + keys LogsKeyMap + strictSearch bool } -func NewLogs(name string, viewport viewport.Model) *Logs { +func NewLogs(keys LogsKeyMap, name string) *Logs { + searchbar := textinput.New() + searchbar.Prompt = "> " + searchbar.Placeholder = "search term" return &Logs{ - viewport: viewport, - name: name, - mutex: &sync.Mutex{}, - content: "", + viewport: viewport.New(10, 10), + searchbar: searchbar, + name: name, + mutex: &sync.Mutex{}, + content: "", + mode: modeLogs, + keys: keys, } } func (m *Logs) Init() tea.Cmd { return nil } func (m *Logs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.mutex.Lock() + defer m.mutex.Unlock() var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.viewport.Width = msg.Width m.viewport.Height = msg.Height / 2 + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Search): + m.mode = modeSearching + if !m.searchbar.Focused() { + m.searchbar.Focus() + } + m.searchbar.SetValue("") + return m, nil + case key.Matches(msg, m.keys.QuitSearch): + m.mode = modeLogs + if m.searchbar.Focused() { + m.searchbar.Blur() + } + m.viewport.SetContent(wrapLogs(m.content, m.viewport.Width)) + m.contentUpdated = false + case key.Matches(msg, m.keys.SubmitSearch): + if m.mode == modeSearching { + m.mode = modeSearched + if m.searchbar.Focused() { + m.searchbar.Blur() + } + } + case key.Matches(msg, m.keys.ToggleStrict): + m.strictSearch = !m.strictSearch + } + } + + if m.contentUpdated && m.mode == modeLogs { + m.viewport.SetContent(wrapLogs(m.content, m.viewport.Width)) + m.contentUpdated = false } + + if m.mode == modeSearching { + m.searchbar, cmd = m.searchbar.Update(msg) + return m, cmd + } + + if m.mode == modeSearched { + m.viewport.SetContent(searchLogs(m.content, m.searchbar.Value(), m.viewport.Width, m.strictSearch)) + } + m.viewport, cmd = m.viewport.Update(msg) return m, cmd } func (m *Logs) View() string { + searchMode := "fuzzy" + if m.strictSearch { + searchMode = "strict" + } + searchModeOutput := styles.LogSearchModeStyle().Render(fmt.Sprintf("search mode: %s", searchMode)) + + if m.mode == modeSearching { + return lipgloss.JoinVertical(lipgloss.Top, + m.searchbar.View(), + searchModeOutput, + ) + } + + if m.mode == modeSearched { + return lipgloss.JoinVertical(lipgloss.Top, + m.viewport.View(), + searchModeOutput, + ) + } + return m.viewport.View() } +func (m *Logs) Help() help.KeyMap { + return m.keys +} + func (m *Logs) AddContent(content string) { m.mutex.Lock() defer m.mutex.Unlock() m.content = strings.Join([]string{m.content, content}, "\n") - m.viewport.SetContent(wrapLogs(m.content, m.viewport.Width)) + m.contentUpdated = true } func (m *Logs) Name() string { return m.name } +// searchLogs searches the logs for the given term +// and returns a string with the matching log lines +// and the matched term highlighted. Uses fuzzy search +// if strict is false. Wraps logs to the given width if wrap > 0. +func searchLogs(logs, term string, wrap int, strict bool) string { + splitLogs := strings.Split(logs, "\n") + if strict { + return strictMatchLogs(term, splitLogs, wrap) + } + return fuzzyMatchLogs(term, splitLogs, wrap) +} + +func strictMatchLogs(searchTerm string, logLines []string, wrap int) string { + var results strings.Builder + for _, log := range logLines { + if wrap > 0 { + log = wrapLogs(log, wrap) + } + if strings.Contains(log, searchTerm) { + highlighted := strings.Replace( + log, + searchTerm, + styles.LogSearchHighlightStyle().Render(searchTerm), -1, + ) + results.WriteString(highlighted + "\n") + } + } + return results.String() +} + +func fuzzyMatchLogs(searchTerm string, logLines []string, wrap int) string { + var matches []fuzzy.Match + if wrap > 0 { + wrappedLogs := []string{} + for _, log := range logLines { + wrappedLogs = append(wrappedLogs, wrapLogs(log, wrap)) + } + matches = fuzzy.Find(searchTerm, wrappedLogs) + } else { + matches = fuzzy.Find(searchTerm, logLines) + } + + var results strings.Builder + for _, match := range matches { + for i := 0; i < len(match.Str); i++ { + if matched(i, match.MatchedIndexes) { + results.WriteString(styles.LogSearchHighlightStyle().Render(string(match.Str[i]))) + } else { + results.WriteString(string(match.Str[i])) + } + } + results.WriteString("\n") + } + + return results.String() +} + +func matched(index int, matches []int) bool { + for _, i := range matches { + if index == i { + return true + } + } + return false +} + func wrapLogs(logs string, maxWidth int) string { splitLogs := strings.Split(logs, "\n") var logsBuilder strings.Builder diff --git a/pkg/charm/styles/styles.go b/pkg/charm/styles/styles.go index 99b494d..50acd95 100644 --- a/pkg/charm/styles/styles.go +++ b/pkg/charm/styles/styles.go @@ -31,3 +31,11 @@ func ContentStyle() lipgloss.Style { func TableSelectedRowStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(adaptColor) } + +func LogSearchHighlightStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(adaptColor) +} + +func LogSearchModeStyle() lipgloss.Style { + return lipgloss.NewStyle().Italic(true).Faint(true) +} diff --git a/pkg/paneler/logs.go b/pkg/paneler/logs.go index a2534ac..bab017c 100644 --- a/pkg/paneler/logs.go +++ b/pkg/paneler/logs.go @@ -5,8 +5,8 @@ import ( "context" "encoding/json" "fmt" + "io" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/everettraven/buoy/pkg/charm/models/panels" "github.com/everettraven/buoy/pkg/types" @@ -40,27 +40,25 @@ func NewLog(typedClient *kubernetes.Clientset, dynamicClient dynamic.Interface, } func (t *Log) Model(panel types.Panel) (tea.Model, error) { - log := types.Logs{} - err := json.Unmarshal(panel.Blob, &log) + log := &types.Logs{} + err := json.Unmarshal(panel.Blob, log) if err != nil { return nil, fmt.Errorf("unmarshalling panel to table type: %s", err) } - logItem := modelWrapperForLogPanel(log) + logPanel := panels.NewLogs(panels.DefaultLogsKeys, log.Name) pod, err := t.getPodForObject(log) if err != nil { return nil, fmt.Errorf("error getting pod for object: %w", err) } - go streamLogs(t.typedClient, pod, logItem, log.Container) //nolint: errcheck - return logItem, nil -} - -func modelWrapperForLogPanel(logsPanel types.Logs) *panels.Logs { - vp := viewport.New(100, 20) - vpw := panels.NewLogs(logsPanel.Name, vp) - return vpw + rc, err := logsForPod(t.typedClient, pod, log.Container) + if err != nil { + return nil, fmt.Errorf("error getting logs for pod: %w", err) + } + go streamLogs(rc, logPanel) + return logPanel, nil } -func (t *Log) getPodForObject(logsPanel types.Logs) (*v1.Pod, error) { +func (t *Log) getPodForObject(logsPanel *types.Logs) (*v1.Pod, error) { gvk := schema.GroupVersionKind{ Group: logsPanel.Group, Version: logsPanel.Version, @@ -114,7 +112,14 @@ func getPodSelectorForUnstructured(u *unstructured.Unstructured) (labels.Selecto return metav1.LabelSelectorAsSelector(sel) } -func streamLogs(kc *kubernetes.Clientset, pod *v1.Pod, logItem *panels.Logs, container string) { +func streamLogs(rc io.ReadCloser, logPanel *panels.Logs) { + scanner := bufio.NewScanner(rc) + for scanner.Scan() { + logPanel.AddContent(scanner.Text()) + } +} + +func logsForPod(kc *kubernetes.Clientset, pod *v1.Pod, container string) (io.ReadCloser, error) { req := kc.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &v1.PodLogOptions{ Container: container, Follow: true, @@ -122,13 +127,7 @@ func streamLogs(kc *kubernetes.Clientset, pod *v1.Pod, logItem *panels.Logs, con rc, err := req.Stream(context.Background()) if err != nil { - logItem.AddContent(fmt.Errorf("fetching logs for %s/%s: %w", pod.Namespace, pod.Name, err).Error()) - return - } - defer rc.Close() - - scanner := bufio.NewScanner(rc) - for scanner.Scan() { - logItem.AddContent(scanner.Text()) + return nil, fmt.Errorf("fetching logs for %s/%s: %w", pod.Namespace, pod.Name, err) } + return rc, nil }