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/client.go b/cmd/client.go index 1696c1e..9219299 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -20,8 +20,9 @@ import ( "io" "net/http" "net/url" - "pb/pkg/config" "time" + + "pb/pkg/config" ) type HTTPClient struct { diff --git a/cmd/pre.go b/cmd/pre.go index 713d86c..e5efc90 100644 --- a/cmd/pre.go +++ b/cmd/pre.go @@ -19,6 +19,7 @@ package cmd import ( "errors" "os" + "pb/pkg/config" "github.com/spf13/cobra" diff --git a/cmd/profile.go b/cmd/profile.go index f259b03..c260f8e 100644 --- a/cmd/profile.go +++ b/cmd/profile.go @@ -20,6 +20,7 @@ import ( "fmt" "net/url" "os" + "pb/pkg/config" "pb/pkg/model/credential" "pb/pkg/model/defaultprofile" 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/role.go b/cmd/role.go index 107701b..6ca884a 100644 --- a/cmd/role.go +++ b/cmd/role.go @@ -21,10 +21,11 @@ import ( "fmt" "io" "os" - "pb/pkg/model/role" "strings" "sync" + "pb/pkg/model/role" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" 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/cmd/tail.go b/cmd/tail.go index cc5e387..7fa333e 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -22,6 +22,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "pb/pkg/config" "github.com/apache/arrow/go/v13/arrow/array" diff --git a/cmd/user.go b/cmd/user.go index 73fd4b4..d738ebb 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -21,9 +21,10 @@ import ( "fmt" "io" "os" - "pb/pkg/model/role" "sync" + "pb/pkg/model/role" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" 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/credential/credential.go b/pkg/model/credential/credential.go index a601a2e..1fe4691 100644 --- a/pkg/model/credential/credential.go +++ b/pkg/model/credential/credential.go @@ -17,9 +17,10 @@ package credential import ( - "pb/pkg/model/button" "strings" + "pb/pkg/model/button" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" diff --git a/pkg/model/defaultprofile/profile.go b/pkg/model/defaultprofile/profile.go index 0106806..f238dea 100644 --- a/pkg/model/defaultprofile/profile.go +++ b/pkg/model/defaultprofile/profile.go @@ -18,6 +18,7 @@ package defaultprofile import ( "fmt" "io" + "pb/pkg/config" "github.com/charmbracelet/bubbles/list" diff --git a/pkg/model/query.go b/pkg/model/query.go index 91cca66..1f62598 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" @@ -41,16 +42,16 @@ import ( ) const ( - datetimeWidth = 26 - datetimeKey = "p_timestamp" + dateTimeWidth = 26 + dateTimeKey = "p_timestamp" tagKey = "p_tags" metadataKey = "p_metadata" ) // 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) } @@ -566,14 +567,14 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start func (m *QueryModel) UpdateTable(data FetchData) { // pin p_timestamp to left if available - containsTimestamp := slices.Contains(data.schema, datetimeKey) + containsTimestamp := slices.Contains(data.schema, dateTimeKey) containsTags := slices.Contains(data.schema, tagKey) containsMetadata := slices.Contains(data.schema, metadataKey) columns := make([]table.Column, len(data.schema)) columnIndex := 0 if containsTimestamp { - columns[0] = table.NewColumn(datetimeKey, datetimeKey, datetimeWidth) + columns[0] = table.NewColumn(dateTimeKey, dateTimeKey, dateTimeWidth) columnIndex++ } @@ -587,7 +588,7 @@ func (m *QueryModel) UpdateTable(data FetchData) { for _, title := range data.schema { switch title { - case datetimeKey, tagKey, metadataKey: + case dateTimeKey, tagKey, metadataKey: continue default: width := inferWidthForColumns(title, &data.data, 100, 100) + 1 diff --git a/pkg/model/role/role.go b/pkg/model/role/role.go index 332e4ff..bcc7b77 100644 --- a/pkg/model/role/role.go +++ b/pkg/model/role/role.go @@ -18,9 +18,10 @@ package role import ( "fmt" + "strings" + "pb/pkg/model/button" "pb/pkg/model/selection" - "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" 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)