From 87805f59e44f5dc1d3a1f7deea5b1f2f130d1e80 Mon Sep 17 00:00:00 2001 From: Nitish Tiwari Date: Sun, 22 Oct 2023 09:45:06 +0530 Subject: [PATCH] Update the query command to have the -i flag Also clean up the query command so it works without any inputs provided. In such cases it uses sensible defaults. --- README.md | 6 +- cmd/query.go | 160 ++++++++++++++++++++++++++++------------- cmd/stream.go | 4 +- main.go | 5 +- pkg/model/query.go | 25 +++---- pkg/model/status.go | 30 ++++---- pkg/model/timeinput.go | 12 +--- 7 files changed, 148 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 9ad043e..7658258 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ ## pb -pb is the command line interface for [Parseable Server](https://github.com/parseablehq/parseable). pb allows you to manage Streams, Users, and Data on Parseable Server. You can use pb to manage multiple Parseable Server instances using Profiles. +Dashboard fatigue is one of key reasons for poor adoption of logging tools among developers. With pb, we intend to bring the familiar command line interface for querying and analyzing log data at scale. -We believe dashboard fatigue is one of key reasons for poor adoption of logging tools among developers. With pb, we intend to bring the familiar command line interface for querying and analyzing log data at scale. +pb is the command line interface for [Parseable Server](https://github.com/parseablehq/parseable). pb allows you to manage Streams, Users, and Data on Parseable Server. You can use pb to manage multiple Parseable Server instances using Profiles. ![pb banner](https://github.com/parseablehq/.github/blob/main/images/pb/pb.png?raw=true) @@ -22,7 +22,7 @@ chmod +x pb && mv pb /usr/local/bin pb comes configured with `demo` profile as the default. This means you can directly start using pb against the [demo Parseable Server](https://demo.parseable.io). For example, to query the stream `backend` on demo server, run: ```bash -pb query backend +pb query backend ``` #### Profiles diff --git a/cmd/query.go b/cmd/query.go index 79f9c5a..69ec5fc 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -17,21 +17,20 @@ package cmd import ( "bytes" + "encoding/json" + "errors" "fmt" "io" "os" + "time" + "pb/pkg/model" - "strconv" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) var ( - durationFlag = "duration" - durationFlagShort = "d" - defaultDuration = "10" - startFlag = "from" startFlagShort = "f" defaultStart = "1m" @@ -39,71 +38,78 @@ var ( endFlag = "to" endFlagShort = "t" defaultEnd = "now" + + interactiveFlag = "interactive" + interactiveFlagShort = "i" ) -var queryInteractive = &cobra.Command{ - Use: "i [stream-name] --duration 10", - Example: " pb query frontend --duration 10", - Short: "Interactive query table view", - Long: "\n command is used to open a prompt to query a stream.", - Args: cobra.ExactArgs(1), +var query = &cobra.Command{ + Use: "query [query] [flags]", + Example: " pb query \"select * from frontend\" --from=10m --to=now", + Short: "Run SQL query on a log stream", + Long: "\nqRun SQL query on a log stream. Default output format is json. Use -i flag to open interactive table view.", + Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, RunE: func(command *cobra.Command, args []string) error { - stream := args[0] - duration, _ := command.Flags().GetString(durationFlag) - - if duration == "" { - duration = defaultDuration + var query string + + // if no query is provided set it to default "select * from " + // here is the first stream that server returns + if len(args) == 0 || args[0] == "" || args[0] == " " { + stream, err := fetchFirstStream() + if err != nil { + return err + } + query = fmt.Sprintf("select * from %s", stream) + } else { + query = args[0] } - durationInt, err := strconv.Atoi(duration) + + start, err := command.Flags().GetString(startFlag) if err != nil { return err } - - p := tea.NewProgram(model.NewQueryModel(DefaultProfile, stream, uint(durationInt)), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - fmt.Printf("there's been an error: %v", err) - os.Exit(1) + if start == "" { + start = defaultStart } - return nil - }, -} - -var queryJSON = &cobra.Command{ - Use: "query [query] --from=10m --to=now", - Example: " pb query \"select * from frontend\" --from=10m --to=now", - Short: "Run SQL query", - Long: "\nquery command is used to run query. Output format is json string", - Args: cobra.ExactArgs(1), - PreRunE: PreRunDefaultProfile, - RunE: func(command *cobra.Command, args []string) error { - query := args[0] - start, _ := command.Flags().GetString(startFlag) end, _ := command.Flags().GetString(endFlag) - - if start == "" { - start = defaultStart + if err != nil { + return err } if end == "" { end = defaultEnd } + interactive, _ := command.Flags().GetBool(interactiveFlag) + if err != nil { + return err + } + + startTime, endTime, err := parseTime(start, end) + if err != nil { + return err + } + + if interactive { + p := tea.NewProgram(model.NewQueryModel(DefaultProfile, query, startTime, endTime), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Printf("there's been an error: %v", err) + os.Exit(1) + } + return nil + } + client := DefaultClient() return fetchData(&client, query, start, end) }, } -var QueryInteractiveCmd = func() *cobra.Command { - queryInteractive.Flags().StringP(durationFlag, durationFlagShort, defaultDuration, "specify the duration in minutes for which queries should be executed. Defaults to 10 minutes") - return queryInteractive -}() - var QueryCmd = func() *cobra.Command { - queryJSON.Flags().StringP(startFlag, startFlagShort, defaultStart, "Specify start datetime of query. Supports RFC3999 time format and durations (ex. 10m, 1hr ..) ") - queryJSON.Flags().StringP(endFlag, endFlagShort, defaultEnd, "Specify end datetime of query. Supports RFC3999 time format and literal - now ") - queryJSON.AddCommand(queryInteractive) - return queryJSON + query.Flags().BoolP(interactiveFlag, interactiveFlagShort, false, "open the query result in interactive mode") + query.Flags().StringP(startFlag, startFlagShort, defaultStart, "Start time for query. Takes date as '2023-10-12T07:20:50.52Z' or string like '10m', '1hr'") + query.Flags().StringP(endFlag, endFlagShort, defaultEnd, "End time for query. Takes date as '2023-10-12T07:20:50.52Z' or 'now'") + return query }() func fetchData(client *HTTPClient, query string, startTime string, endTime string) (err error) { @@ -134,3 +140,61 @@ func fetchData(client *HTTPClient, query string, startTime string, endTime strin } return } + +func fetchFirstStream() (string, error) { + client := DefaultClient() + req, err := client.NewRequest("GET", "logstream", nil) + if err != nil { + return "", err + } + + resp, err := client.client.Do(req) + if err != nil { + return "", err + } + + if resp.StatusCode == 200 { + items := []map[string]string{} + if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { + return "", err + } + defer resp.Body.Close() + + if len(items) == 0 { + return "", errors.New("no stream found on the server, please create a stream to proceed") + } + // return with the first stream that is present in the list + for _, v := range items { + return v["name"], nil + } + } + return "", fmt.Errorf("received error status code %d from server", resp.StatusCode) +} + +// Returns start and end time for query in RFC3339 format +func parseTime(start, end string) (time.Time, time.Time, error) { + if start == defaultStart && end == defaultEnd { + return time.Now().Add(-1 * time.Minute), time.Now(), nil + } + + startTime, err := time.Parse(time.RFC3339, start) + if err != nil { + // try parsing as duration + duration, err := time.ParseDuration(start) + if err != nil { + return time.Time{}, time.Time{}, err + } + startTime = time.Now().Add(-1 * duration) + } + + endTime, err := time.Parse(time.RFC3339, end) + if err != nil { + if end == "now" { + endTime = time.Now() + } else { + return time.Time{}, time.Time{}, err + } + } + + return startTime, endTime, nil +} diff --git a/cmd/stream.go b/cmd/stream.go index 1dbaaae..f19ecc2 100644 --- a/cmd/stream.go +++ b/cmd/stream.go @@ -150,7 +150,7 @@ var StatStreamCmd = &cobra.Command{ return err } - isRententionSet := len(retention) > 0 + isRetentionSet := len(retention) > 0 fmt.Println(styleBold.Render("\nInfo:")) fmt.Printf(" Event Count: %d\n", ingestionCount) @@ -161,7 +161,7 @@ var StatStreamCmd = &cobra.Command{ 100-(float64(storageSize)/float64(ingestionSize))*100, "%") fmt.Println() - if isRententionSet { + if isRetentionSet { fmt.Println(styleBold.Render("Retention:")) for _, item := range retention { fmt.Printf(" Action: %s\n", styleBold.Render(item.Action)) diff --git a/main.go b/main.go index 672dbaa..e58c8ca 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ package main import ( "errors" "os" + "pb/cmd" "pb/pkg/config" @@ -60,8 +61,8 @@ var cli = &cobra.Command{ var profile = &cobra.Command{ Use: "profile", - Short: "Manage profiles", - Long: "\nuse profile command to configure (multiple) Parseable instances. Each profile takes a URL and credentials.", + Short: "Manage different Parseable targets", + Long: "\nuse profile command to configure different Parseable instances. Each profile takes a URL and credentials.", } var user = &cobra.Command{ diff --git a/pkg/model/query.go b/pkg/model/query.go index 91cca66..38d530e 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -23,13 +23,14 @@ import ( "math" "net/http" "os" - "pb/pkg/config" - "pb/pkg/iterator" "regexp" "strings" "sync" "time" + "pb/pkg/config" + "pb/pkg/iterator" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" @@ -49,8 +50,8 @@ const ( // Style for this widget var ( - FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} - FocusSecondry = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} + FocusPrimary = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} + FocusSecondary = lipgloss.AdaptiveColor{Light: "18", Dark: "220"} StandardPrimary = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} StandardSecondary = lipgloss.AdaptiveColor{Light: "238", Dark: "254"} @@ -67,7 +68,7 @@ var ( baseStyle = lipgloss.NewStyle().BorderForeground(StandardPrimary) baseBoldUnderlinedStyle = lipgloss.NewStyle().BorderForeground(StandardPrimary).Bold(true) - headerStyle = lipgloss.NewStyle().Inherit(baseStyle).Foreground(FocusSecondry).Bold(true) + headerStyle = lipgloss.NewStyle().Inherit(baseStyle).Foreground(FocusSecondary).Bold(true) tableStyle = lipgloss.NewStyle().Inherit(baseStyle).Align(lipgloss.Left) ) @@ -96,7 +97,7 @@ var ( key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl r", "(re) run query")), } - pagiatorKeyBinds = []key.Binding{ + paginatorKeyBinds = []key.Binding{ key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl r", "Fetch Next Minute")), key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl b", "Fetch Prev Minute")), } @@ -196,10 +197,10 @@ func createIteratorFromModel(m *QueryModel) *iterator.QueryIterator[QueryData, F return &iter } -func NewQueryModel(profile config.Profile, stream string, duration uint) QueryModel { +func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime time.Time) QueryModel { w, h, _ := term.GetSize(int(os.Stdout.Fd())) - inputs := NewTimeInputModel(duration) + inputs := NewTimeInputModel(startTime, endTime) columns := []table.Column{ table.NewColumn("Id", "Id", 5), @@ -228,12 +229,12 @@ func NewQueryModel(profile config.Profile, stream string, duration uint) QueryMo query.SetHeight(2) query.SetWidth(70) query.ShowLineNumbers = true - query.SetValue(fmt.Sprintf("select * from %s", stream)) + query.SetValue(queryStr) query.KeyMap = textAreaKeyMap query.Focus() help := help.New() - help.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondry) + help.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondary) model := QueryModel{ width: w, @@ -245,7 +246,7 @@ func NewQueryModel(profile config.Profile, stream string, duration uint) QueryMo profile: profile, help: help, queryIterator: nil, - status: NewStatusBar(profile.URL, stream, w), + status: NewStatusBar(profile.URL, w), } model.queryIterator = createIteratorFromModel(&model) return model @@ -442,7 +443,7 @@ func (m QueryModel) View() string { } if m.queryIterator != nil { - helpKeys = append(helpKeys, pagiatorKeyBinds) + helpKeys = append(helpKeys, paginatorKeyBinds) } else { helpKeys = append(helpKeys, additionalKeyBinds) } diff --git a/pkg/model/status.go b/pkg/model/status.go index ec37e40..d9a8e7b 100644 --- a/pkg/model/status.go +++ b/pkg/model/status.go @@ -32,10 +32,6 @@ var ( Background(lipgloss.AdaptiveColor{Light: "#13315C", Dark: "#FFD6A5"}). Padding(0, 1) - streamStyle = commonStyle.Copy(). - Background(lipgloss.AdaptiveColor{Light: "#0B2545", Dark: "#FDFFB6"}). - Padding(0, 1) - infoStyle = commonStyle.Copy(). Background(lipgloss.AdaptiveColor{Light: "#212529", Dark: "#CAFFBF"}). AlignHorizontal(lipgloss.Right) @@ -46,22 +42,20 @@ var ( ) type StatusBar struct { - title string - host string - stream string - Info string - Error string - width int + title string + host string + Info string + Error string + width int } -func NewStatusBar(host string, stream string, width int) StatusBar { +func NewStatusBar(host string, width int) StatusBar { return StatusBar{ - title: "Parseable", - host: host, - stream: stream, - Info: "", - Error: "", - width: width, + title: "Parseable", + host: host, + Info: "", + Error: "", + width: width, } } @@ -85,7 +79,7 @@ func (m StatusBar) View() string { rightStyle = infoStyle } - left := lipgloss.JoinHorizontal(lipgloss.Bottom, titleStyle.Render(m.title), hostStyle.Render(m.host), streamStyle.Render(m.stream)) + left := lipgloss.JoinHorizontal(lipgloss.Bottom, titleStyle.Render(m.title), hostStyle.Render(m.host)) leftWidth := lipgloss.Width(left) rightWidth := m.width - leftWidth diff --git a/pkg/model/timeinput.go b/pkg/model/timeinput.go index e68629b..f3ef6bd 100644 --- a/pkg/model/timeinput.go +++ b/pkg/model/timeinput.go @@ -18,9 +18,10 @@ package model import ( "fmt" - "pb/pkg/model/datetime" "time" + "pb/pkg/model/datetime" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -115,14 +116,7 @@ func (m *TimeInputModel) currentFocus() string { } // NewTimeInputModel creates a new model -func NewTimeInputModel(duration uint) TimeInputModel { - endTime := time.Now() - startTime := endTime.Add(TenMinute) - - if duration != 0 { - startTime = endTime.Add(-(time.Duration(duration) * time.Minute)) - } - +func NewTimeInputModel(startTime, endTime time.Time) TimeInputModel { list := NewTimeRangeModel() inputStyle := lipgloss.NewStyle().Inherit(baseStyle).Bold(true).Width(6).Align(lipgloss.Center)