Skip to content

Commit

Permalink
add search highlighting, strict/fuzzy match toggle, and help
Browse files Browse the repository at this point in the history
Signed-off-by: Bryce Palmer <everettraven@gmail.com>
  • Loading branch information
everettraven committed Nov 18, 2023
1 parent 57857eb commit d673f5d
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 42 deletions.
4 changes: 2 additions & 2 deletions pkg/charm/models/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ 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", "ctrl+c"),
Expand Down
124 changes: 106 additions & 18 deletions pkg/charm/models/panels/logs.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
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
Expand All @@ -27,14 +32,14 @@ func (k LogsKeyMap) ShortHelp() []key.Binding {
// key.Map interface.
func (k LogsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Search},
{k.Search, k.SubmitSearch, k.QuitSearch, k.ToggleStrict},
}
}

var DefaultLogsKeys = LogsKeyMap{
Search: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "open a prompt to enter a term to fuzzy search logs"),
key.WithHelp("/", "open a prompt to search logs"),
),
SubmitSearch: key.NewBinding(
key.WithKeys("enter"),
Expand All @@ -44,6 +49,10 @@ var DefaultLogsKeys = LogsKeyMap{
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"
Expand All @@ -60,19 +69,22 @@ type Logs struct {
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,
viewport: viewport.New(10, 10),
searchbar: searchbar,
name: name,
mutex: &sync.Mutex{},
content: "",
mode: modeLogs,
keys: keys,
}
}

Expand All @@ -88,27 +100,29 @@ func (m *Logs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.Height = msg.Height / 2
case tea.KeyMsg:
switch {
case key.Matches(msg, DefaultLogsKeys.Search):
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, DefaultLogsKeys.QuitSearch):
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, DefaultLogsKeys.SubmitSearch):
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
}
}

Expand All @@ -123,20 +137,41 @@ func (m *Logs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

if m.mode == modeSearched {
m.viewport.SetContent(m.searchLogs())
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 m.searchbar.View()
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()
Expand All @@ -148,17 +183,70 @@ func (m *Logs) Name() string {
return m.name
}

func (m *Logs) searchLogs() string {
searchTerm := m.searchbar.Value()
splitLogs := strings.Split(m.content, "\n")
matches := fuzzy.Find(searchTerm, splitLogs)
matchedLogs := []string{}
// 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 {
matchedLog := splitLogs[match.Index]
// TODO: highlight matched term
matchedLogs = append(matchedLogs, matchedLog)
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 wrapLogs(strings.Join(matchedLogs, "\n"), m.viewport.Width)
return false
}

func wrapLogs(logs string, maxWidth int) string {
Expand Down
8 changes: 8 additions & 0 deletions pkg/charm/styles/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
43 changes: 21 additions & 22 deletions pkg/paneler/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -114,21 +112,22 @@ 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,
})

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
}

0 comments on commit d673f5d

Please sign in to comment.