Skip to content

Commit

Permalink
Refactor log filtering and add tabs (#167)
Browse files Browse the repository at this point in the history
* Add a new DimensionModel interface

We'll implement this interface with other models to a model to handle its own sizing.

* Add a new sidebar layout

* Add a box layout

* Add tabs component

* Add wrapper to huh.Form

This wrapper implements the dimension model

* Prepare logs to live inside layout

Instead of having the logs view respond to the stack size, we will put it in a layout in a future commit. This change makes it implement the DimensionModel interface

* Return form fields when parsing cobra command

We return fields instead of a complete form so that we can define tabs with subsets of the fields.

This commit doesn't compile. The commit will fix up the logview to properly handle the tabs

* Move searching and footer to logview

* Fix up log test

* Add log view test

* Address comments

* Use space instead of x

* Fix form test
  • Loading branch information
mdbenjam authored Dec 6, 2024
1 parent 3e267e0 commit 7892634
Show file tree
Hide file tree
Showing 19 changed files with 937 additions and 206 deletions.
46 changes: 23 additions & 23 deletions cmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"github.com/renderinc/cli/pkg/tui/views"
)

var logsCmd = &cobra.Command{
var LogsCmd = &cobra.Command{
Use: "logs",
Short: "View logs for services and datastores",
Long: `View logs for services and datastores.
Expand All @@ -33,7 +33,7 @@ In interactive mode you can update the filters and view logs in real time.`,
}

func filterLogs(ctx context.Context, in views.LogInput, breadcrumb string) tea.Cmd {
return command.AddToStackFunc(ctx, logsCmd, breadcrumb, &in, views.NewLogsView(ctx, logsCmd, filterLogs, in))
return command.AddToStackFunc(ctx, LogsCmd, breadcrumb, &in, views.NewLogsView(ctx, LogsCmd, filterLogs, in, views.LoadLogData))
}

func writeLog(format command.Output, out io.Writer, log *lclient.Log) error {
Expand Down Expand Up @@ -97,10 +97,10 @@ func TailResourceLogs(ctx context.Context, resourceID string) tea.Cmd {
func InteractiveLogs(ctx context.Context, input views.LogInput, breadcrumb string) tea.Cmd {
return command.AddToStackFunc(
ctx,
logsCmd,
LogsCmd,
breadcrumb,
&input,
views.NewLogsView(ctx, logsCmd, filterLogs, input, tui.WithCustomOptions[resource.Resource](getLogsOptions(ctx, breadcrumb))),
views.NewLogsView(ctx, LogsCmd, filterLogs, input, views.LoadLogData, tui.WithCustomOptions[resource.Resource](getLogsOptions(ctx, breadcrumb))),
)
}

Expand Down Expand Up @@ -128,7 +128,7 @@ func init() {
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "CONNECT", "TRACE",
}, true)

logsCmd.RunE = func(cmd *cobra.Command, args []string) error {
LogsCmd.RunE = func(cmd *cobra.Command, args []string) error {
var input views.LogInput
err := command.ParseCommand(cmd, args, &input)
if err != nil {
Expand All @@ -144,29 +144,29 @@ func init() {
return nil
}

logsCmd.PreRunE = func(cmd *cobra.Command, args []string) error {
LogsCmd.PreRunE = func(cmd *cobra.Command, args []string) error {
// Resources flag is required in non-interactive mode
format := command.GetFormatFromContext(cmd.Context())
if format != nil && *format != command.Interactive {
return logsCmd.MarkFlagRequired("resources")
return LogsCmd.MarkFlagRequired("resources")
}
return nil
}

rootCmd.AddCommand(logsCmd)

logsCmd.Flags().StringSliceP("resources", "r", []string{}, "A list of comma separated resource IDs to query. Required in non-interactive mode.")
logsCmd.Flags().String("start", "", "The start time of the logs to query")
logsCmd.Flags().String("end", "", "The end time of the logs to query")
logsCmd.Flags().StringSlice("text", []string{}, "A list of comma separated strings to search for in the logs")
logsCmd.Flags().Var(levelFlag, "level", "A list of comma separated log levels to query")
logsCmd.Flags().Var(logTypeFlag, "type", "A list of comma separated log types to query")
logsCmd.Flags().StringSlice("instance", []string{}, "A list of comma separated instance IDs to query")
logsCmd.Flags().StringSlice("host", []string{}, "A list of comma separated hosts to query")
logsCmd.Flags().StringSlice("status-code", []string{}, "A list of comma separated status codes to query")
logsCmd.Flags().Var(methodTypeFlag, "method", "A list of comma separated HTTP methods to query")
logsCmd.Flags().StringSlice("path", []string{}, "A list of comma separated paths to query")
logsCmd.Flags().Int("limit", 100, "The maximum number of logs to return")
logsCmd.Flags().Var(directionFlag, "direction", "The direction to query the logs. Can be 'forward' or 'backward'")
logsCmd.Flags().Bool("tail", false, "Stream new logs")
rootCmd.AddCommand(LogsCmd)

LogsCmd.Flags().StringSliceP("resources", "r", []string{}, "A list of comma separated resource IDs to query. Required in non-interactive mode.")
LogsCmd.Flags().String("start", "", "The start time of the logs to query")
LogsCmd.Flags().String("end", "", "The end time of the logs to query")
LogsCmd.Flags().StringSlice("text", []string{}, "A list of comma separated strings to search for in the logs")
LogsCmd.Flags().Var(levelFlag, "level", "A list of comma separated log levels to query")
LogsCmd.Flags().Var(logTypeFlag, "type", "A list of comma separated log types to query")
LogsCmd.Flags().StringSlice("instance", []string{}, "A list of comma separated instance IDs to query")
LogsCmd.Flags().StringSlice("host", []string{}, "A list of comma separated hosts to query")
LogsCmd.Flags().StringSlice("status-code", []string{}, "A list of comma separated status codes to query")
LogsCmd.Flags().Var(methodTypeFlag, "method", "A list of comma separated HTTP methods to query")
LogsCmd.Flags().StringSlice("path", []string{}, "A list of comma separated paths to query")
LogsCmd.Flags().Int("limit", 100, "The maximum number of logs to return")
LogsCmd.Flags().Var(directionFlag, "direction", "The direction to query the logs. Can be 'forward' or 'backward'")
LogsCmd.Flags().Bool("tail", false, "Stream new logs")
}
23 changes: 6 additions & 17 deletions pkg/command/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"strconv"
"strings"

"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/x/ansi"
"github.com/renderinc/cli/pkg/pointers"
Expand Down Expand Up @@ -298,7 +297,7 @@ func StructFromFormValues(formValues FormValues, v any) error {
return nil
}

func HuhForm(cmd *cobra.Command, v any) (*huh.Form, FormValues) {
func HuhFormFields(cmd *cobra.Command, v any) ([]huh.Field, FormValues) {
huhFieldMap := make(map[string]huh.Field)
formValues := FormValuesFromStruct(v)

Expand All @@ -315,9 +314,9 @@ func HuhForm(cmd *cobra.Command, v any) (*huh.Form, FormValues) {
}

// We have to wrap the description because of this bug in lipgloss: https://github.com/charmbracelet/lipgloss/issues/85
// It's unfortunate to set a default width of 55, but this should work with our current
// It's unfortunate to set a default width of 53, but this should work with our current
// filter component. We can adjust if needed.
wrappedDescription := ansi.Wrap(flag.Usage, 55, "-")
wrappedDescription := ansi.Wrap(flag.Usage, 53, "-")

if flag.Value.Type() == EnumType {
enumFlag := flag.Value.(*CobraEnum)
Expand Down Expand Up @@ -347,7 +346,7 @@ func HuhForm(cmd *cobra.Command, v any) (*huh.Form, FormValues) {
})

// Order the fields in the form by the order they have in the struct
var huhFields []huh.Field
var fields []huh.Field
vtype := reflect.TypeOf(v).Elem()
for i := 0; i < vtype.NumField(); i++ {
// Get the field
Expand All @@ -357,19 +356,9 @@ func HuhForm(cmd *cobra.Command, v any) (*huh.Form, FormValues) {
cliTag := field.Tag.Get("cli")

if huhField, ok := huhFieldMap[cliTag]; ok {
huhFields = append(huhFields, huhField)
fields = append(fields, huhField)
}
}

// If no fields were created, return an empty form
if len(huhFields) == 0 {
return huh.NewForm(), formValues
}

keyMap := huh.NewDefaultKeyMap()
keyMap.Input.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
keyMap.Select.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
keyMap.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))

return huh.NewForm(huh.NewGroup(huhFields...)).WithKeyMap(keyMap), formValues
return fields, formValues
}
7 changes: 5 additions & 2 deletions pkg/command/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package command_test
import (
"testing"

"github.com/charmbracelet/huh"
"github.com/renderinc/cli/pkg/command"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -84,7 +85,8 @@ func TestHuhForm(t *testing.T) {
cmd.Flags().String("foo", "", "")
cmd.Flags().Int("bar", 0, "")

form, _ := command.HuhForm(&cmd, &v)
fields, _ := command.HuhFormFields(&cmd, &v)
form := huh.NewForm(huh.NewGroup(fields...))
form.Init()()

require.Contains(t, form.View(), "foo")
Expand All @@ -107,7 +109,8 @@ func TestHuhForm(t *testing.T) {
barInput := command.NewEnumInput([]string{"single choice 1", "single choice 2", "single choice 3"}, false)
cmd.Flags().Var(barInput, "bar", "")

form, _ := command.HuhForm(&cmd, &v)
fields, _ := command.HuhFormFields(&cmd, &v)
form := huh.NewForm(huh.NewGroup(fields...))
form.Init()

require.Contains(t, form.View(), "multi choice 3")
Expand Down
18 changes: 18 additions & 0 deletions pkg/tui/dimensionmodel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package tui

import "github.com/charmbracelet/bubbletea"

// DimensionModel is an extension of tea.Model that implements a
// SetWidth and SetHeight method. This allows for models to handle their
// own sizing. For models that contain child models, their implementation
// to SetWidth and SetHeight should also call SetWidth and SetHeight on
// the child models and subtract out any padding or margins that the parent
// model may have.
//
// This allows for a more flexible and composable layout system, where each
// model is in charge of its own size and layout.
type DimensionModel interface {
tea.Model
SetWidth(int)
SetHeight(int)
}
59 changes: 0 additions & 59 deletions pkg/tui/filters.go

This file was deleted.

44 changes: 44 additions & 0 deletions pkg/tui/form.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package tui

import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)

const (
minHeight = 10
)

// Form is a wrapper around a huh form that implements the layout.DimensionModel interface
type Form struct {
*huh.Form
}

func NewForm(huhForm *huh.Form) *Form {
return &Form{Form: huhForm}
}

func (f *Form) Init() tea.Cmd {
return f.Form.Init()
}

func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_, cmd := f.Form.Update(msg)
return f, cmd
}

func (f *Form) View() string {
return f.Form.View()
}

func (f *Form) SetWidth(width int) {
f.Form = f.Form.WithWidth(width)
}

func (f *Form) SetHeight(height int) {
// Ensure the form is at least minHeight high
// otherwise it may collapse some fields (like options)
// and not expand even if the height eventually exceeds
// minHeight
f.Form = f.Form.WithHeight(max(height, minHeight))
}
5 changes: 2 additions & 3 deletions pkg/tui/formwithaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package tui

import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)

type FormAction[T any] struct {
Expand Down Expand Up @@ -42,10 +41,10 @@ func (fa *FormAction[T]) View() string {
type FormWithAction[T any] struct {
done bool
formAction FormAction[T]
huhForm *huh.Form
huhForm tea.Model
}

func NewFormWithAction[T any](action FormAction[T], form *huh.Form) *FormWithAction[T] {
func NewFormWithAction[T any](action FormAction[T], form tea.Model) *FormWithAction[T] {
return &FormWithAction[T]{
formAction: action,
huhForm: form,
Expand Down
40 changes: 40 additions & 0 deletions pkg/tui/layouts/box.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package layouts

import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/renderinc/cli/pkg/tui"
)

// BoxLayout is a simple layout that renders a single child model with a
// specified style. It implements the DimensionModel interface so it can
// be a convenient way to wrap a model with a style.
type BoxLayout struct {
style lipgloss.Style
content tui.DimensionModel
}

func NewBoxLayout(style lipgloss.Style, content tui.DimensionModel) *BoxLayout {
return &BoxLayout{style: style, content: content}
}

func (l *BoxLayout) Init() tea.Cmd {
return l.content.Init()
}

func (l *BoxLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_, cmd := l.content.Update(msg)
return l, cmd
}

func (l *BoxLayout) View() string {
return l.style.Render(l.content.View())
}

func (l *BoxLayout) SetWidth(width int) {
l.content.SetWidth(width - l.style.GetHorizontalFrameSize())
}

func (l *BoxLayout) SetHeight(height int) {
l.content.SetHeight(height - l.style.GetVerticalFrameSize())
}
29 changes: 29 additions & 0 deletions pkg/tui/layouts/box_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package layouts_test

import (
"testing"

"github.com/charmbracelet/lipgloss"
"github.com/renderinc/cli/pkg/tui/layouts"
"github.com/renderinc/cli/pkg/tui/testhelper"
"github.com/stretchr/testify/require"
)

func TestBoxLayout(t *testing.T) {
t.Run("properly calculates interior width and height", func(t *testing.T) {
child := testhelper.FakeDimensionModel{Value: "foo"}

style := lipgloss.NewStyle().Padding(1, 2, 3, 4).Margin(1, 2, 3, 4)

box := layouts.NewBoxLayout(style, &child)

box.SetWidth(20)
box.SetHeight(20)

// The box should have right padding and margin of 2 and left padding and margin of 4 for a total of 12
require.Equal(t, 20-12, child.Width)

// The box should have top padding and margin of 1 and bottom padding and margin of 3 for a total of 8
require.Equal(t, 20-8, child.Height)
})
}
Loading

0 comments on commit 7892634

Please sign in to comment.