diff --git a/cmd/pre.go b/cmd/pre.go index f4c7e9b..19ce3d8 100644 --- a/cmd/pre.go +++ b/cmd/pre.go @@ -33,7 +33,7 @@ func PreRunDefaultProfile(cmd *cobra.Command, args []string) error { conf, err := config.ReadConfigFromFile() if err != nil { if os.IsNotExist(err) { - return errors.New("No config found to run this command. Add a profile using pb profile command") + return errors.New("no config found to run this command. add a profile using pb profile command") } else { return err } diff --git a/cmd/profile.go b/cmd/profile.go index 3c43f21..db97f6e 100644 --- a/cmd/profile.go +++ b/cmd/profile.go @@ -25,12 +25,35 @@ import ( "pb/pkg/config" "pb/pkg/model" - "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) +type ProfileListItem struct { + title, url, user string +} + +func (item *ProfileListItem) Render(highlight bool) string { + if highlight { + render := fmt.Sprintf( + "%s\n%s\n%s", + selectedStyle.Render(item.title), + selectedStyleAlt.Render(fmt.Sprintf("url: %s", item.url)), + selectedStyleAlt.Render(fmt.Sprintf("user: %s", item.user)), + ) + return selectedItemOuter.Render(render) + } else { + render := fmt.Sprintf( + "%s\n%s\n%s", + standardStyle.Render(item.title), + standardStyleAlt.Render(fmt.Sprintf("url: %s", item.url)), + standardStyleAlt.Render(fmt.Sprintf("user: %s", item.user)), + ) + return itemOuter.Render(render) + } +} + var AddProfileCmd = &cobra.Command{ Use: "add name url ", Example: "add local_logs http://0.0.0.0:8000 admin admin", @@ -163,40 +186,17 @@ var ListProfileCmd = &cobra.Command{ return nil } - cols := []table.Column{ - {Title: "PROFILE", Width: 7}, - {Title: "URL", Width: 5}, - {Title: "USER", Width: 8}, + if len(file_config.Profiles) != 0 { + println() } - rows := make([]table.Row, len(file_config.Profiles)) - row_idx := 0 - selected_row := 0 + row := 0 for key, value := range file_config.Profiles { - if file_config.Default_profile == key { - selected_row = row_idx - } - - rows[row_idx] = table.Row{key, value.Url, value.Username} - row_idx += 1 - - // update max width for table - cols[0].Width = Max(cols[0].Width, len(key)) - cols[1].Width = Max(cols[1].Width, len(value.Url)) - cols[2].Width = Max(cols[2].Width, len(value.Password)) + item := ProfileListItem{key, value.Url, value.Username} + fmt.Println(item.Render(file_config.Default_profile == key)) + row += 1 + fmt.Println() } - - tbl := table.New( - table.WithColumns(cols), - table.WithRows(rows), - table.WithHeight(len(rows)), - table.WithStyles(listingTableStyle()), - ) - - tbl.SetCursor(selected_row) - - fmt.Println(tbl.View()) - return nil }, } diff --git a/cmd/query.go b/cmd/query.go index 7f1408d..4595c34 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -21,19 +21,25 @@ import ( "fmt" "os" "pb/pkg/model" + "strconv" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) var QueryProfileCmd = &cobra.Command{ - Use: "query name", + Use: "query name minutes", + Example: "query local_logs 20", Short: "Open Query TUI", - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(2), PreRunE: PreRunDefaultProfile, RunE: func(cmd *cobra.Command, args []string) error { stream := args[0] - p := tea.NewProgram(model.NewQueryModel(DefaultProfile, stream), tea.WithAltScreen()) + duration, err := strconv.Atoi(args[1]) + if err != nil { + return err + } + p := tea.NewProgram(model.NewQueryModel(DefaultProfile, stream, uint(duration)), tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) diff --git a/cmd/style.go b/cmd/style.go index 434577f..f02430a 100644 --- a/cmd/style.go +++ b/cmd/style.go @@ -18,20 +18,26 @@ package cmd import ( - "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/lipgloss" ) // styling for cli outputs var ( - selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "4", Dark: "11"}).Bold(true) + FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} + FocusSecondry = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} + + standardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} + standardSecondry = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} + + standardStyle = lipgloss.NewStyle().Foreground(standardPrimary) + standardStyleBold = lipgloss.NewStyle().Foreground(standardPrimary).Bold(true) + standardStyleAlt = lipgloss.NewStyle().Foreground(standardSecondry) + + selectedStyle = lipgloss.NewStyle().Foreground(FocusPrimary).Bold(true) + selectedStyleAlt = lipgloss.NewStyle().Foreground(FocusSecondry) + + selectedItemOuter = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderLeft(true).PaddingLeft(1).BorderForeground(FocusPrimary) + itemOuter = lipgloss.NewStyle().PaddingLeft(1) + styleBold = lipgloss.NewStyle().Bold(true) ) - -func listingTableStyle() table.Styles { - s := table.DefaultStyles() - s.Header = s.Header.Border(lipgloss.NormalBorder(), false, false, true, false) - s.Selected = selectedStyle - return s -} diff --git a/cmd/user.go b/cmd/user.go index 283fa22..95cb4e6 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -21,10 +21,44 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "pb/pkg/config" + "strings" + "sync" "github.com/spf13/cobra" ) +type UserRoleData struct { + Privilege string `json:"privilege"` + Resource struct { + Stream string `json:"stream"` + Tag string `json:"tag"` + } `json:"resource"` +} + +func (user *UserRoleData) Render() string { + var s strings.Builder + s.WriteString(standardStyle.Render(user.Privilege)) + + if user.Resource.Stream != "" { + s.WriteString(" - ") + s.WriteString(standardStyleAlt.Render(user.Resource.Stream)) + } + if user.Resource.Tag != "" { + s.WriteString(" ( ") + s.WriteString(standardStyleAlt.Render(user.Resource.Tag)) + s.WriteString(" )") + } + + return s.String() +} + +type FetchUserRoleRes struct { + data []UserRoleData + err error +} + var AddUserCmd = &cobra.Command{ Use: "add name", Example: "add bob", @@ -117,14 +151,38 @@ var ListUserCmd = &cobra.Command{ defer resp.Body.Close() if resp.StatusCode == 200 { - items := []string{} - err = json.Unmarshal(bytes, &items) + users := []string{} + err = json.Unmarshal(bytes, &users) if err != nil { return err } - for _, item := range items { - fmt.Println(item) + + client = DefaultClient() + role_responses := make([]FetchUserRoleRes, len(users)) + + wsg := sync.WaitGroup{} + wsg.Add(len(users)) + for idx, user := range users { + idx := idx + user := user + go func() { + role_responses[idx] = fetchUserRoles(&client.client, &DefaultProfile, user) + wsg.Done() + }() + } + wsg.Wait() + fmt.Println() + for idx, user := range users { + roles := role_responses[idx] + fmt.Println(standardStyleBold.Bold(true).Render(user)) + if roles.err == nil { + for _, role := range roles.data { + fmt.Printf(" %s\n", role.Render()) + } + } + println() } + } else { body := string(bytes) fmt.Printf("Request Failed\nStatus Code: %s\nResponse: %s\n", resp.Status, body) @@ -133,3 +191,24 @@ var ListUserCmd = &cobra.Command{ return nil }, } + +func fetchUserRoles(client *http.Client, profile *config.Profile, user string) (res FetchUserRoleRes) { + endpoint := fmt.Sprintf("%s/%s", profile.Url, fmt.Sprintf("api/v1/user/%s/role", user)) + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return + } + req.SetBasicAuth(profile.Username, profile.Password) + resp, err := client.Do(req) + if err != nil { + return + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return + } + defer resp.Body.Close() + + res.err = json.Unmarshal(body, &res.data) + return +} diff --git a/pkg/model/help.go b/pkg/model/help.go new file mode 100644 index 0000000..d2db1db --- /dev/null +++ b/pkg/model/help.go @@ -0,0 +1,67 @@ +package model + +import ( + "github.com/charmbracelet/bubbles/key" +) + +type TableKeyMap struct { + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding + ScrollRight key.Binding + ScrollLeft key.Binding + Filter key.Binding + ClearFilter key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. It's part +// of the key.Map interface. +func (k TableKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.ScrollRight, k.ScrollRight, k.Filter, k.ClearFilter} +} + +// FullHelp returns keybindings for the expanded help view. It's part of the +// key.Map interface. +func (k TableKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.PageUp, k.PageDown}, // first column + {k.ScrollLeft, k.ScrollRight}, + {k.ClearFilter, k.Filter}, // second column + } +} + +var tableKeys = TableKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "move down"), + ), + PageUp: key.NewBinding( + key.WithKeys("right", "l", "pgdown"), + key.WithHelp("→/l", "prev page"), + ), + PageDown: key.NewBinding( + key.WithKeys("left", "h", "pgup"), + key.WithHelp("←/h", "next page"), + ), + ScrollLeft: key.NewBinding( + key.WithKeys("shift+left", "shift+h"), + key.WithHelp("shift ←/h", "scroll left"), + ), + ScrollRight: key.NewBinding( + key.WithKeys("shift+right", "shift+l"), + key.WithHelp("shift →/l", "scroll right"), + ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/ .. ", "Filter"), + ), + ClearFilter: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "remove filter"), + ), +} diff --git a/pkg/model/query.go b/pkg/model/query.go index a82bde7..3847dc6 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -29,7 +29,7 @@ import ( "pb/pkg/config" - "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/help" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" table "github.com/evertras/bubble-table/table" @@ -42,7 +42,6 @@ var ( baseStyle = lipgloss.NewStyle().BorderForeground(lipgloss.AdaptiveColor{Light: "236", Dark: "248"}) headerStyle = lipgloss.NewStyle().Inherit(baseStyle).Foreground(lipgloss.AdaptiveColor{Light: "#023047", Dark: "#90E0EF"}).Bold(true) tableStyle = lipgloss.NewStyle().Inherit(baseStyle).Align(lipgloss.Left) - focusColor = lipgloss.AdaptiveColor{Light: "#5a189a", Dark: "#e0aaff"} customBorder = table.Border{ Top: "─", @@ -63,17 +62,8 @@ var ( InnerDivider: "║", } - textarea_style = textarea.Style{ - Base: baseStyle, - Text: baseStyle, - } ) -var navigation_map [][]string = [][]string{ - {"query", "time", "execute"}, - {"table"}, -} - type Mode int type FetchResult int @@ -83,12 +73,6 @@ type FetchData struct { data []map[string]interface{} } -const ( - navigation Mode = iota - active - inactive -) - const ( FetchOk FetchResult = iota FetchErr @@ -97,31 +81,17 @@ const ( type QueryModel struct { width int height int - query textarea.Model + query string time_range timeRangeModel table table.Model - mode Mode profile config.Profile stream string + help help.Model status StatusBar - focus struct { - x uint - y uint - } } -func NewQueryModel(profile config.Profile, stream string) QueryModel { - query := textarea.New() - query.ShowLineNumbers = false - query.SetHeight(2) - query.SetWidth(50) - query.FocusedStyle = textarea_style - query.BlurredStyle = textarea_style - default_text := fmt.Sprintf("select * from %s", stream) - query.Placeholder = default_text - query.InsertString(default_text) - query.Focus() - +func NewQueryModel(profile config.Profile, stream string, duration uint) QueryModel { + query := fmt.Sprintf("select * from %s", stream) var w, h, _ = term.GetSize(int(os.Stdout.Fd())) columns := []table.Column{ @@ -131,11 +101,10 @@ func NewQueryModel(profile config.Profile, stream string) QueryModel { rows := make([]table.Row, 0) keys := table.DefaultKeyMap() - keys.RowDown.SetKeys("j", "down", "s") - keys.RowUp.SetKeys("k", "up", "w") table := table.New(columns). WithRows(rows). + Filtered(true). HeaderStyle(headerStyle). SelectableRows(false). Border(customBorder). @@ -152,106 +121,18 @@ func NewQueryModel(profile config.Profile, stream string) QueryModel { width: w, height: h, query: query, - time_range: NewTimeRangeModel(), + time_range: NewTimeRangeModel(duration), table: table, - mode: navigation, profile: profile, stream: stream, + help: help.New(), status: NewStatusBar(profile.Url, stream, w), - focus: struct { - x uint - y uint - }{0, 0}, } } -func (m *QueryModel) currentFocus() string { - return navigation_map[m.focus.y][m.focus.x] -} - -func (m *QueryModel) Blur() { - switch m.currentFocus() { - case "query": - m.query.Blur() - case "table": - m.table.Focused(false) - default: - return - } -} - -func (m *QueryModel) Focus(id string) { - switch id { - case "query": - m.query.Focus() - case "table": - m.table.Focused(true) - } -} - -func (m *QueryModel) Navigate(key tea.KeyMsg) { - switch key.String() { - case "enter": - m.mode = active - m.Focus(m.currentFocus()) - - case "up", "w": - if m.focus.y > 0 { - m.focus.y -= 1 - m.focus.x = 0 - } - case "down", "s": - if m.focus.y < uint(len(navigation_map))-1 { - m.focus.y += 1 - m.focus.x = 0 - } - case "left", "a": - if m.focus.x > 0 { - m.focus.x -= 1 - } - case "right", "d": - if m.focus.x < uint(len(navigation_map[m.focus.y]))-1 { - m.focus.x += 1 - } - default: - return - } -} - -func (m QueryModel) HandleKeyPress(key tea.KeyMsg) (QueryModel, tea.Cmd) { - var cmd tea.Cmd - - if key.Type == tea.KeyEsc { - m.mode = navigation - m.Blur() - return m, nil - } - - if m.mode == navigation { - if key.Type == tea.KeyEnter && m.currentFocus() == "execute" { - m.mode = inactive - cmd = NewFetchTask(m.profile, m.stream, m.query.Value(), m.time_range.StartValueUtc(), m.time_range.EndValueUtc()) - } else { - m.Navigate(key) - } - } else { - focused := navigation_map[m.focus.y][m.focus.x] - switch focused { - case "query": - m.query, cmd = m.query.Update(key) - case "time": - m.time_range, cmd = m.time_range.Update(key) - case "table": - m.table, cmd = m.table.Update(key) - } - } - - return m, cmd -} - func (m QueryModel) Init() tea.Cmd { // Just return `nil`, which means "no I/O right now, please." - return nil + return NewFetchTask(m.profile, m.stream, m.query, m.time_range.StartValueUtc(), m.time_range.EndValueUtc()) } func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -262,6 +143,9 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height, _ = term.GetSize(int(os.Stdout.Fd())) + m.help.Width = m.width + m.status.width = m.width + m.table = m.table.WithMaxTotalWidth(m.width) return m, nil case FetchData: @@ -271,10 +155,9 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status.Error = "failed to query" } - m.mode = navigation return m, nil - // Is it a key press? + // Is it a key press? case tea.KeyMsg: switch msg.Type { // These keys should exit the program. @@ -282,10 +165,8 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit default: - if m.mode != inactive { - m, cmd = m.HandleKeyPress(msg) - cmds = append(cmds, cmd) - } + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) } } @@ -296,72 +177,29 @@ func (m QueryModel) View() string { var outer = lipgloss.NewStyle().Inherit(baseStyle). UnsetMaxHeight().Width(m.width).Height(m.height) - var input_style = lipgloss.NewStyle(). - Inherit(baseStyle). - Border(lipgloss.RoundedBorder(), true). - Margin(0) - - var query_style = input_style.Copy() - var time_style = input_style.Copy() - var execute_style = input_style.Copy().Height(2).Align(lipgloss.Center) - var table_style = input_style.Copy().Border(lipgloss.RoundedBorder(), false) - - var patchStyleFocus = func(style *lipgloss.Style) { - border := lipgloss.RoundedBorder() - border.TopLeft = "╓" - border.Left = "║" - border.BottomLeft = "╙ " - - style.BorderStyle(border).BorderForeground(focusColor) - } - - focused := navigation_map[m.focus.y][m.focus.x] - - switch focused { - case "query": - patchStyleFocus(&query_style) - case "time": - patchStyleFocus(&time_style) - case "execute": - patchStyleFocus(&execute_style) - case "table": - table_style.Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(focusColor) - } - m.table.WithMaxTotalWidth(m.width - 10) - button := "execute" - - if m.mode == inactive { - button = "loading" - } - - var inputs = lipgloss.JoinHorizontal( - lipgloss.Bottom, - query_style.Render(m.query.View()), - time_style.Render(fmt.Sprintf("%s\n%s", m.time_range.StartValue(), m.time_range.EndValue())), - execute_style.Render(button), - ) - - inputHeight := lipgloss.Height(inputs) statusHeight := 1 - tableHeight := m.height - inputHeight - statusHeight + HelpHeight := 5 - if focused == "table" { - m.table = m.table.WithMaxTotalWidth(m.width - 2) + tableView := m.table.View() + tableHeight := lipgloss.Height(tableView) + + if (tableHeight + HelpHeight + statusHeight) > m.height { + m.help.ShowAll = false + HelpHeight = 2 } else { - m.table = m.table.WithMaxTotalWidth(m.width) + m.help.ShowAll = true } - m.status.width = m.width + tableBoxHeight := m.height - statusHeight - HelpHeight - render := fmt.Sprintf("%s\n%s\n%s", inputs, lipgloss.PlaceVertical(tableHeight, lipgloss.Top, table_style.Render(m.table.View())), m.status.View()) + m.help.Styles.FullDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + help := m.help.View(tableKeys) - if m.mode == active && focused == "time" { - return outer.Render(lipgloss.Place(m.width-4, m.height-4, lipgloss.Center, lipgloss.Center, m.time_range.View())) - } else { - return outer.Render(render) - } + render := fmt.Sprintf("%s\n%s\n\n%s", lipgloss.PlaceVertical(tableBoxHeight, lipgloss.Top, tableView), help, m.status.View()) + + return outer.Render(render) } @@ -473,7 +311,7 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start } func (m *QueryModel) UpdateTable(data FetchData) { - columns := make([]table.Column, len(data.schema)) + columns := make([]table.Column, len(data.schema)-2) columns[0] = table.NewColumn("p_timestamp", "p_timestamp", 24) columnIndex := 1 @@ -483,7 +321,7 @@ func (m *QueryModel) UpdateTable(data FetchData) { continue default: width := inferWidthForColumns(title, &data.data, 100, 80) + 3 - columns[columnIndex] = table.NewColumn(title, title, width) + columns[columnIndex] = table.NewColumn(title, title, width).WithFiltered(true) columnIndex += 1 } } diff --git a/pkg/model/timerange.go b/pkg/model/timerange.go index f7c53a5..0320113 100644 --- a/pkg/model/timerange.go +++ b/pkg/model/timerange.go @@ -166,10 +166,14 @@ func (m *timeRangeModel) currentFocus() string { return rangeNavigationMap[m.focus] } -func NewTimeRangeModel() timeRangeModel { +func NewTimeRangeModel(duration uint) timeRangeModel { end_time := time.Now() start_time := end_time.Add(TenMinute) + if duration != 0 { + start_time = end_time.Add(-(time.Duration(duration) * time.Minute)) + } + list := list.New(timeDurations, timeDurationItemDelegate{}, 20, 10) list.SetShowPagination(false) list.SetShowHelp(false)