From fe03860cc29965ff72d730533db21c51bf5efe05 Mon Sep 17 00:00:00 2001 From: Pratik Date: Wed, 6 May 2026 20:37:58 +0530 Subject: [PATCH 1/7] refactor and fix: fixed query apis and refactor interactive TUI mode for pb query run --- cmd/query.go | 81 +++++++++- pkg/model/query.go | 306 +++++++++++++++++++++--------------- pkg/model/tablekeymap.go | 18 ++- pkg/model/textareakeymap.go | 35 +++-- 4 files changed, 284 insertions(+), 156 deletions(-) diff --git a/cmd/query.go b/cmd/query.go index 2e46468..6d5a3ab 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -21,13 +21,14 @@ import ( "fmt" "io" "os" + "regexp" + "strconv" "strings" "time" - // "pb/pkg/model" + "pb/pkg/model" - //! This dependency is required by the interactive flag Do not remove - // tea "github.com/charmbracelet/bubbletea" + tea "github.com/charmbracelet/bubbletea" internalHTTP "pb/pkg/http" "github.com/spf13/cobra" @@ -47,9 +48,9 @@ var ( var query = &cobra.Command{ Use: "run [query] [flags]", - Example: " pb query run \"select * from frontend\" --from=10m --to=now", + Example: " pb query run \"select * from frontend\" --from=10m --to=now\n pb query run \"select * from frontend\" -i", Short: "Run SQL query on a dataset", - Long: "\nRun SQL query on a dataset. Default output format is text. Use --output flag to set output format to json.", + Long: "\nRun SQL query on a dataset. Default output format is text.\nUse --output json for JSON output, or -i for interactive table view.", Args: cobra.MaximumNArgs(1), PreRunE: PreRunDefaultProfile, RunE: func(command *cobra.Command, args []string) error { @@ -69,7 +70,7 @@ var query = &cobra.Command{ return nil } - query := args[0] + sqlQuery := args[0] start, err := command.Flags().GetString(startFlag) if err != nil { command.Annotations["error"] = err.Error() @@ -88,14 +89,40 @@ var query = &cobra.Command{ end = defaultEnd } - outputFormat, err := command.Flags().GetString("output") + interactive, err := command.Flags().GetBool("interactive") + if err != nil { + command.Annotations["error"] = err.Error() + return err + } + + sqlQuery = quoteStreamNames(sqlQuery) + + if interactive { + startT, err := parseTimeStr(start) + if err != nil { + return fmt.Errorf("invalid --from value: %w", err) + } + endT, err := parseTimeStr(end) + if err != nil { + return fmt.Errorf("invalid --to value: %w", err) + } + m := model.NewQueryModel(DefaultProfile, sqlQuery, startT, endT) + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + if err != nil { + command.Annotations["error"] = err.Error() + } + return err + } + + outputFmt, err := command.Flags().GetString("output") if err != nil { command.Annotations["error"] = err.Error() return fmt.Errorf("failed to get 'output' flag: %w", err) } client := internalHTTP.DefaultClient(&DefaultProfile) - err = fetchData(&client, query, start, end, outputFormat) + err = fetchData(&client, sqlQuery, start, end, outputFmt) if err != nil { command.Annotations["error"] = err.Error() } @@ -107,6 +134,44 @@ func init() { query.Flags().StringP(startFlag, startFlagShort, defaultStart, "Start time for query.") query.Flags().StringP(endFlag, endFlagShort, defaultEnd, "End time for query.") query.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") + query.Flags().BoolP("interactive", "i", false, "Open interactive table view") +} + +// parseTimeStr converts a CLI time string to time.Time. +// Accepts: "now", RFC3339 ("2024-01-01T00:00:00Z"), Go durations ("10m", "2h"), or day suffix ("1d", "7d"). +func parseTimeStr(s string) (time.Time, error) { + if s == "now" { + return time.Now(), nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, nil + } + if strings.HasSuffix(s, "d") { + n, err := strconv.Atoi(strings.TrimSuffix(s, "d")) + if err == nil { + return time.Now().Add(-time.Duration(n) * 24 * time.Hour), nil + } + } + if d, err := time.ParseDuration(s); err == nil { + return time.Now().Add(-d), nil + } + return time.Time{}, fmt.Errorf("unrecognized time format %q (use: now, 10m, 2h, 1d, or RFC3339)", s) +} + +// fromClauseRe matches an unquoted identifier after FROM or JOIN. +var fromClauseRe = regexp.MustCompile(`(?i)(\b(?:from|join)\s+)([a-zA-Z_][a-zA-Z0-9_-]*)`) + +// quoteStreamNames wraps stream names containing hyphens in double quotes so +// DataFusion does not treat them as subtraction (nginx-logs → "nginx-logs"). +// Already-quoted identifiers are left untouched. +func quoteStreamNames(query string) string { + return fromClauseRe.ReplaceAllStringFunc(query, func(match string) string { + m := fromClauseRe.FindStringSubmatch(match) + if len(m) < 3 || !strings.Contains(m[2], "-") { + return match + } + return m[1] + `"` + m[2] + `"` + }) } var QueryCmd = query diff --git a/pkg/model/query.go b/pkg/model/query.go index 5a25444..7753060 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -26,7 +26,6 @@ import ( "pb/pkg/config" "pb/pkg/iterator" "strings" - "sync" "time" "github.com/charmbracelet/bubbles/help" @@ -91,9 +90,7 @@ var ( InnerDivider: "║", } - additionalKeyBinds = []key.Binding{ - key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl r", "(re) run query")), - } + additionalKeyBinds = []key.Binding{runQueryKey} paginatorKeyBinds = []key.Binding{ key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl r", "Fetch Next Minute")), @@ -136,6 +133,7 @@ type QueryModel struct { queryIterator *iterator.QueryIterator[QueryData, FetchResult] overlay uint focused int + dataRows []table.Row // actual data rows (without padding) } func (m *QueryModel) focusSelected() { @@ -182,11 +180,11 @@ func createIteratorFromModel(m *QueryModel) *iterator.QueryIterator[QueryData, F Timeout: time.Second * 50, } res, err := fetchData(client, &m.profile, "select count(*) as count from "+table, m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) - if err == fetchErr { + if err == fetchErr || len(res.Records) == 0 { return false } - count := res.Records[0]["count"].(float64) - return count > 0 + count, ok := res.Records[0]["count"].(float64) + return ok && count > 0 }) return &iter } @@ -204,6 +202,11 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t rows := make([]table.Row, 0) + pageSize := h - 14 // header(4) + help(4) + status(1) + table-overhead(6) = 15; -1 buffer + if pageSize < 5 { + pageSize = 5 + } + table := table.New(columns). WithRows(rows). Filtered(true). @@ -212,7 +215,7 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t Border(customBorder). Focused(true). WithKeyMap(tableKeyBinds). - WithPageSize(30). + WithPageSize(pageSize). WithBaseStyle(tableStyle). WithMissingDataIndicatorStyled(table.StyledCell{ Style: lipgloss.NewStyle().Foreground(StandardSecondary), @@ -232,6 +235,9 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t help := help.New() help.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondary) + status := NewStatusBar(profile.URL, w) + status.Info = "fetching..." + model := QueryModel{ width: w, height: h, @@ -242,30 +248,13 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t profile: profile, help: help, queryIterator: nil, - status: NewStatusBar(profile.URL, w), + status: status, } - model.queryIterator = createIteratorFromModel(&model) return model } func (m QueryModel) Init() tea.Cmd { - return func() tea.Msg { - var ready sync.WaitGroup - ready.Add(1) - go func() { - m.initIterator() - for !m.queryIterator.Ready() { - time.Sleep(time.Millisecond * 100) - } - ready.Done() - }() - ready.Wait() - if m.queryIterator.Finished() { - return nil - } - - return IteratorNext(m.queryIterator)() - } + return NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) } func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -275,19 +264,22 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width, m.height, _ = term.GetSize(int(os.Stdout.Fd())) + m.width = msg.Width + m.height = msg.Height m.help.Width = m.width m.status.width = m.width m.table = m.table.WithMaxTotalWidth(m.width) - // width adjustment for time widget m.query.SetWidth(int(m.width - 41)) return m, nil case FetchData: + m.status.Info = "" if msg.status == fetchOk { m.UpdateTable(msg) + m.status.Error = "" + m.status.Info = fmt.Sprintf("%d rows", len(m.dataRows)) } else { - m.status.Error = "failed to query" + m.status.Error = "query failed" } return m, nil @@ -315,25 +307,23 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEnter { m.overlay = overlayNone m.focusSelected() - return m, nil + m.status.Error = "" + m.status.Info = "fetching..." + return m, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) } } // common keybind if msg.Type == tea.KeyCtrlR { m.overlay = overlayNone - if m.queryIterator == nil { - return m, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) - } - if m.queryIterator.Ready() && !m.queryIterator.Finished() { - return m, IteratorNext(m.queryIterator) - } - return m, nil + m.status.Error = "" + m.status.Info = "fetching..." + return m, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) } if msg.Type == tea.KeyCtrlB { m.overlay = overlayNone - if m.queryIterator.CanFetchPrev() { + if m.queryIterator != nil && m.queryIterator.CanFetchPrev() { return m, IteratorPrev(m.queryIterator) } return m, nil @@ -349,14 +339,12 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.currentFocus() { case "query": m.query, cmd = m.query.Update(msg) - m.initIterator() case "table": m.table, cmd = m.table.Update(msg) } cmds = append(cmds, cmd) case overlayInputs: m.timeRange, cmd = m.timeRange.Update(msg) - m.initIterator() cmds = append(cmds, cmd) } } @@ -365,19 +353,12 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m QueryModel) View() string { - outer := lipgloss.NewStyle().Inherit(baseStyle). - UnsetMaxHeight().Width(m.width).Height(m.height) - - m.table = m.table.WithMaxTotalWidth(m.width - 2) - - var mainView string - var helpKeys [][]key.Binding - var helpView string - - statusView := lipgloss.PlaceVertical(2, lipgloss.Bottom, m.status.View()) - statusHeight := lipgloss.Height(statusView) + if m.width == 0 || m.height == 0 { + return "" + } - time := lipgloss.JoinVertical( + // Step 1: build the fixed-height components and measure them. + timePane := lipgloss.JoinVertical( lipgloss.Left, fmt.Sprintf("%s %s ", baseBoldUnderlinedStyle.Render(" start "), m.timeRange.start.Value()), fmt.Sprintf("%s %s ", baseBoldUnderlinedStyle.Render(" end "), m.timeRange.end.Value()), @@ -385,7 +366,6 @@ func (m QueryModel) View() string { queryOuter, timeOuter := &borderedStyle, &borderedStyle tableOuter := lipgloss.NewStyle() - switch m.currentFocus() { case "query": queryOuter = &borderedFocusStyle @@ -396,33 +376,19 @@ func (m QueryModel) View() string { BorderForeground(FocusPrimary) } - mainViewRenderElements := []string{lipgloss.JoinHorizontal(lipgloss.Top, queryOuter.Render(m.query.View()), timeOuter.Render(time)), tableOuter.Render(m.table.View())} - - if m.queryIterator != nil { - inactiveStyle := lipgloss.NewStyle().Foreground(StandardPrimary) - activeStyle := lipgloss.NewStyle().Foreground(FocusPrimary) - var line strings.Builder - - if m.queryIterator.CanFetchPrev() { - line.WriteString(activeStyle.Render("<<")) - } else { - line.WriteString(inactiveStyle.Render("<<")) - } - - fmt.Fprintf(&line, " %d of many ", m.table.TotalRows()) - - if m.queryIterator.Ready() && !m.queryIterator.Finished() { - line.WriteString(activeStyle.Render(">>")) - } else { - line.WriteString(inactiveStyle.Render(">>")) - } + header := lipgloss.JoinHorizontal(lipgloss.Top, + queryOuter.Render(m.query.View()), + timeOuter.Render(timePane), + ) + headerHeight := lipgloss.Height(header) - mainViewRenderElements = append(mainViewRenderElements, line.String()) - } + statusView := m.status.View() + statusHeight := lipgloss.Height(statusView) + // Step 2: build help view and measure it. + var helpKeys [][]key.Binding switch m.overlay { case overlayNone: - mainView = lipgloss.JoinVertical(lipgloss.Left, mainViewRenderElements...) switch m.currentFocus() { case "query": helpKeys = TextAreaHelpKeys{}.FullHelp() @@ -430,31 +396,43 @@ func (m QueryModel) View() string { helpKeys = [][]key.Binding{ {key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select timeRange"))}, } + helpKeys = append(helpKeys, additionalKeyBinds) case "table": helpKeys = tableHelpBinds.FullHelp() + helpKeys = append(helpKeys, additionalKeyBinds) } case overlayInputs: - mainView = m.timeRange.View() helpKeys = m.timeRange.FullHelp() + helpKeys = append(helpKeys, additionalKeyBinds) } + helpView := m.help.FullHelpView(helpKeys) + helpHeight := lipgloss.Height(helpView) - if m.queryIterator != nil { - helpKeys = append(helpKeys, paginatorKeyBinds) - } else { - helpKeys = append(helpKeys, additionalKeyBinds) + // Step 3: calculate exact table page size so everything fits. + tableAvail := m.height - headerHeight - helpHeight - statusHeight + pageSize := tableAvail - 6 + if pageSize < 1 { + pageSize = 1 } - helpView = m.help.FullHelpView(helpKeys) + // Pad rows to pageSize so the table always fills its allocated height. + // Empty rows render as blank lines inside the table border. + displayRows := make([]table.Row, pageSize) + copy(displayRows, m.dataRows) - helpHeight := lipgloss.Height(helpView) - tableBoxHeight := m.height - statusHeight - helpHeight - render := fmt.Sprintf( - "%s\n%s\n%s", - lipgloss.PlaceVertical(tableBoxHeight, lipgloss.Top, mainView), - helpView, - statusView) - - return outer.Render(render) + m.table = m.table.WithPageSize(pageSize).WithRows(displayRows) + + // Step 4: compose main view. + var mainView string + switch m.overlay { + case overlayNone: + mainView = lipgloss.JoinVertical(lipgloss.Left, header, tableOuter.Render(m.table.View())) + case overlayInputs: + mainView = m.timeRange.View() + } + + render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) + return lipgloss.NewStyle().Width(m.width).Render(render) } type QueryData struct { @@ -462,13 +440,18 @@ type QueryData struct { Records []map[string]interface{} `json:"records"` } -func NewFetchTask(profile config.Profile, query string, startTime string, endTime string) func() tea.Msg { - return func() tea.Msg { +func NewFetchTask(profile config.Profile, query string, startTime string, endTime string) tea.Cmd { + return func() (msg tea.Msg) { res := FetchData{ status: fetchErr, schema: []string{}, data: []map[string]interface{}{}, } + defer func() { + if r := recover(); r != nil { + msg = res + } + }() client := &http.Client{ Timeout: time.Second * 50, @@ -486,7 +469,7 @@ func NewFetchTask(profile config.Profile, query string, startTime string, endTim } } -func IteratorNext(iter *iterator.QueryIterator[QueryData, FetchResult]) func() tea.Msg { +func IteratorNext(iter *iterator.QueryIterator[QueryData, FetchResult]) tea.Cmd { return func() tea.Msg { res := FetchData{ status: fetchErr, @@ -506,7 +489,7 @@ func IteratorNext(iter *iterator.QueryIterator[QueryData, FetchResult]) func() t } } -func IteratorPrev(iter *iterator.QueryIterator[QueryData, FetchResult]) func() tea.Msg { +func IteratorPrev(iter *iterator.QueryIterator[QueryData, FetchResult]) tea.Cmd { return func() tea.Msg { res := FetchData{ status: fetchErr, @@ -530,29 +513,37 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start data = QueryData{} res = fetchErr - queryTemplate := `{ - "query": "%s", - "startTime": "%s", - "endTime": "%s" + body, err := json.Marshal(map[string]string{ + "query": query, + "startTime": startTime, + "endTime": endTime, + }) + if err != nil { + return } - ` - - finalQuery := fmt.Sprintf(queryTemplate, query, startTime, endTime) endpoint := fmt.Sprintf("%s/%s", profile.URL, "api/v1/query?fields=true") - req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer([]byte(finalQuery))) + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) if err != nil { return } - req.SetBasicAuth(profile.Username, profile.Password) + if profile.Token != "" { + req.Header.Set("Authorization", "Bearer "+profile.Token) + } else { + req.SetBasicAuth(profile.Username, profile.Password) + } req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return + } err = json.NewDecoder(resp.Body).Decode(&data) - defer resp.Body.Close() if err != nil { return } @@ -561,25 +552,24 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start return } -func (m *QueryModel) UpdateTable(data FetchData) { - // pin p_timestamp to left if available - 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 +type colSpec struct { + key string + title string + width int + filterable bool + fixed bool // fixed-width columns are not scaled down +} - if containsTimestamp { - columns[0] = table.NewColumn(dateTimeKey, dateTimeKey, dateTimeWidth) - columnIndex++ +func (m *QueryModel) UpdateTable(data FetchData) { + if len(data.schema) == 0 { + return } - if containsTags { - columns[len(columns)-2] = table.NewColumn(tagKey, tagKey, inferWidthForColumns(tagKey, &data.data, 100, 80)).WithFiltered(true) - } + // Build column specs: timestamp pinned left, p_tags/p_metadata pinned right. + var specs []colSpec - if containsMetadata { - columns[len(columns)-1] = table.NewColumn(metadataKey, metadataKey, inferWidthForColumns(metadataKey, &data.data, 100, 80)).WithFiltered(true) + if slices.Contains(data.schema, dateTimeKey) { + specs = append(specs, colSpec{key: dateTimeKey, title: dateTimeKey, width: dateTimeWidth, fixed: true}) } for _, title := range data.schema { @@ -587,20 +577,78 @@ func (m *QueryModel) UpdateTable(data FetchData) { case dateTimeKey, tagKey, metadataKey: continue default: - width := inferWidthForColumns(title, &data.data, 100, 100) + 1 - columns[columnIndex] = table.NewColumn(title, title, width).WithFiltered(true) - columnIndex++ + w := inferWidthForColumns(title, &data.data, 100, 100) + 1 + specs = append(specs, colSpec{key: title, title: title, width: w, filterable: true}) + } + } + + if slices.Contains(data.schema, tagKey) { + specs = append(specs, colSpec{key: tagKey, title: tagKey, width: inferWidthForColumns(tagKey, &data.data, 100, 80), filterable: true}) + } + + if slices.Contains(data.schema, metadataKey) { + specs = append(specs, colSpec{key: metadataKey, title: metadataKey, width: inferWidthForColumns(metadataKey, &data.data, 100, 80), filterable: true}) + } + + // Scale scalable column widths so the total table fits within the terminal. + // Only scale when each column would still be at least minReadableWidth wide — + // when there are too many columns (e.g. 50+), skip scaling so the first N + // columns stay readable and > handles the rest via horizontal scroll. + if m.width > 0 && len(specs) > 0 { + const minReadableWidth = 8 + + numBorders := len(specs) + 1 + available := m.width - numBorders + + totalWidth, fixedWidth := 0, 0 + for _, s := range specs { + totalWidth += s.width + if s.fixed { + fixedWidth += s.width + } + } + + if totalWidth > available { + scalableAvail := available - fixedWidth + scalableTotal := totalWidth - fixedWidth + numScalable := 0 + for _, s := range specs { + if !s.fixed { + numScalable++ + } + } + if scalableTotal > 0 && scalableAvail > 0 && numScalable > 0 && + scalableAvail/numScalable >= minReadableWidth { + for i := range specs { + if !specs[i].fixed { + newW := specs[i].width * scalableAvail / scalableTotal + if newW < minReadableWidth { + newW = minReadableWidth + } + specs[i].width = newW + } + } + } + } + } + + // Build table.Columns from scaled specs. + columns := make([]table.Column, 0, len(specs)) + for _, s := range specs { + col := table.NewColumn(s.key, s.title, s.width) + if s.filterable { + col = col.WithFiltered(true) } + columns = append(columns, col) } - rows := make([]table.Row, len(data.data)) - for i := 0; i < len(data.data); i++ { - rowJSON := data.data[i] - rows[i] = table.NewRow(rowJSON) + m.dataRows = make([]table.Row, len(data.data)) + for i, rowJSON := range data.data { + m.dataRows[i] = table.NewRow(rowJSON) } m.table = m.table.WithColumns(columns) - m.table = m.table.WithRows(rows) + m.table = m.table.WithRows(m.dataRows) } func inferWidthForColumns(column string, data *[]map[string]interface{}, maxRecords int, maxWidth int) (width int) { diff --git a/pkg/model/tablekeymap.go b/pkg/model/tablekeymap.go index 665aa0b..e637701 100644 --- a/pkg/model/tablekeymap.go +++ b/pkg/model/tablekeymap.go @@ -24,7 +24,7 @@ type TableKeyMap struct { RowUp key.Binding RowDown key.Binding PageUp key.Binding - PageDown key.Binding + PageDown key.Binding PageFirst key.Binding PageLast key.Binding ScrollRight key.Binding @@ -44,9 +44,11 @@ func (k TableKeyMap) ShortHelp() []key.Binding { // key.Map interface. func (k TableKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.RowUp, k.RowDown, k.PageUp, k.PageDown}, // first column - {k.ScrollLeft, k.ScrollRight, k.PageFirst, k.PageLast}, - {k.FilterClear, k.Filter, k.FilterBlur}, // second column + {k.RowUp, k.RowDown}, // first column + {k.ScrollLeft, k.ScrollRight}, // second column + { k.PageUp, k.PageDown}, // third column + {k.PageFirst, k.PageLast}, // fourth column + {k.FilterClear, k.Filter}, // fifth column } } @@ -91,10 +93,10 @@ var tableHelpBinds = TableKeyMap{ key.WithKeys("esc"), key.WithHelp("esc", "remove filter"), ), - FilterBlur: key.NewBinding( - key.WithKeys("esc", "enter"), - key.WithHelp("enter/esc", "blur filter"), - ), + // FilterBlur: key.NewBinding( + // key.WithKeys("esc", "enter"), + // key.WithHelp("enter/esc", "blur filter"), + // ), } var tableKeyBinds = table.KeyMap{ diff --git a/pkg/model/textareakeymap.go b/pkg/model/textareakeymap.go index 7ad4df4..edf04c9 100644 --- a/pkg/model/textareakeymap.go +++ b/pkg/model/textareakeymap.go @@ -34,12 +34,25 @@ func (k TextAreaHelpKeys) ShortHelp() []key.Binding { func (k TextAreaHelpKeys) FullHelp() [][]key.Binding { t := textAreaKeyMap return [][]key.Binding{ - {t.CharacterForward, t.CharacterBackward, t.WordForward, t.WordBackward}, // first column - {t.DeleteWordForward, t.DeleteWordBackward, t.DeleteCharacterForward, t.DeleteCharacterBackward}, - {t.LineStart, t.LineEnd, t.InputBegin, t.InputEnd}, // second column + {t.CharacterForward, t.CharacterBackward}, // first column + {t.WordForward, t.WordBackward}, + {t.DeleteWordForward, t.DeleteWordBackward}, + {t.DeleteCharacterForward, t.DeleteCharacterBackward}, + {t.LineStart, t.LineEnd}, // second column + {runQueryKey, exit}, } } +var runQueryKey = key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "run query"), +) + +var exit = key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "exit"), +) + var textAreaKeyMap = textarea.KeyMap{ CharacterForward: key.NewBinding( key.WithKeys("right", "ctrl+f"), @@ -47,7 +60,7 @@ var textAreaKeyMap = textarea.KeyMap{ ), CharacterBackward: key.NewBinding( key.WithKeys("left", "ctrl+b"), - key.WithHelp("←", "right"), + key.WithHelp("←", "left"), ), WordForward: key.NewBinding( key.WithKeys("ctrl+right", "alt+f"), @@ -63,10 +76,10 @@ var textAreaKeyMap = textarea.KeyMap{ key.WithHelp("↑", "up")), DeleteWordBackward: key.NewBinding( key.WithKeys("ctrl+backspace", "ctrl+w"), - key.WithHelp("ctrl bkspc", "delete word behind")), + key.WithHelp("ctrl bkspc", "del word behind")), DeleteWordForward: key.NewBinding( key.WithKeys("ctrl+delete", "alt+d"), - key.WithHelp("ctrl del", "delete word forward")), + key.WithHelp("ctrl del", "del word forward")), DeleteAfterCursor: key.NewBinding( key.WithKeys("ctrl+k"), ), @@ -78,7 +91,7 @@ var textAreaKeyMap = textarea.KeyMap{ ), DeleteCharacterBackward: key.NewBinding( key.WithKeys("backspace", "ctrl+h"), - key.WithHelp("bkspc", "delete backward"), + key.WithHelp("bkspc", "del backward"), ), DeleteCharacterForward: key.NewBinding( key.WithKeys("delete", "ctrl+d"), @@ -93,9 +106,9 @@ var textAreaKeyMap = textarea.KeyMap{ Paste: key.NewBinding( key.WithKeys("ctrl+v"), key.WithHelp("ctrl v", "paste")), - InputBegin: key.NewBinding( - key.WithKeys("ctrl+home"), - key.WithHelp("ctrl home", "home")), + // InputBegin: key.NewBinding( + // key.WithKeys("ctrl+home"), + // key.WithHelp("ctrl home", "home")), InputEnd: key.NewBinding( key.WithKeys("ctrl+end"), key.WithHelp("ctrl end", "end")), @@ -105,4 +118,4 @@ var textAreaKeyMap = textarea.KeyMap{ UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u")), TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t")), -} +} \ No newline at end of file From d7623b95ac24883ff916b043ddb32ca635f3bc72 Mon Sep 17 00:00:00 2001 From: Pratik Date: Fri, 8 May 2026 17:53:09 +0530 Subject: [PATCH 2/7] feat: add PromQL query support via pb query promql --- cmd/promql.go | 778 ++++++++++++++++++++++++++++++++++++ cmd/query.go | 3 +- main.go | 1 + pkg/model/query.go | 59 +-- pkg/model/tablekeymap.go | 10 +- pkg/model/textareakeymap.go | 2 +- 6 files changed, 788 insertions(+), 65 deletions(-) create mode 100644 cmd/promql.go diff --git a/cmd/promql.go b/cmd/promql.go new file mode 100644 index 0000000..cdf23ce --- /dev/null +++ b/cmd/promql.go @@ -0,0 +1,778 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + internalHTTP "pb/pkg/http" + + "github.com/spf13/cobra" +) + +const defaultMetricsStream = "otel_metrics" + +// PromqlCmd is the parent command for all PromQL operations. +var PromqlCmd = &cobra.Command{ + Use: "promql", + Short: "PromQL queries and metrics exploration", + Long: "\nRun PromQL queries and explore metrics stored in a Parseable metrics stream.", +} + +func init() { + // query execution + PromqlCmd.AddCommand(promqlRunCmd) + + // metadata / exploration + PromqlCmd.AddCommand(promqlLabelsCmd) + PromqlCmd.AddCommand(promqlLabelValuesCmd) + PromqlCmd.AddCommand(promqlSeriesCmd) + + // cardinality group + PromqlCmd.AddCommand(promqlCardinalityCmd) + promqlCardinalityCmd.AddCommand(promqlCardinalityLabelNamesCmd) + promqlCardinalityCmd.AddCommand(promqlCardinalityLabelValuesCmd) + promqlCardinalityCmd.AddCommand(promqlCardinalityActiveSeriesCmd) + + // ops / debug + PromqlCmd.AddCommand(promqlActiveQueriesCmd) + PromqlCmd.AddCommand(promqlTSDBCmd) + + // flags: run + promqlRunCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset to query") + promqlRunCmd.Flags().StringP("from", "f", "5m", "Start time (e.g. 5m, 1h, 2024-01-01T00:00:00Z)") + promqlRunCmd.Flags().StringP("to", "t", "now", "End time") + promqlRunCmd.Flags().String("step", "1m", "Resolution step for range queries (e.g. 15s, 1m, 1h)") + promqlRunCmd.Flags().StringP("output", "o", "text", "Output format: text or json") + promqlRunCmd.Flags().Bool("instant", false, "Instant query — evaluate at --to time only") + + // flags: labels + promqlLabelsCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") + promqlLabelsCmd.Flags().StringP("from", "f", "", "Start time filter (optional)") + promqlLabelsCmd.Flags().StringP("to", "t", "", "End time filter (optional)") + promqlLabelsCmd.Flags().StringP("output", "o", "text", "Output format: text or json") + + // flags: label-values + promqlLabelValuesCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") + promqlLabelValuesCmd.Flags().StringP("from", "f", "", "Start time filter (optional)") + promqlLabelValuesCmd.Flags().StringP("to", "t", "", "End time filter (optional)") + promqlLabelValuesCmd.Flags().StringP("output", "o", "text", "Output format: text or json") + + // flags: series + promqlSeriesCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") + promqlSeriesCmd.Flags().StringArrayP("match", "m", nil, "Series selector (repeatable, e.g. '{job=\"api\"}')") + promqlSeriesCmd.Flags().StringP("from", "f", "", "Start time filter (optional)") + promqlSeriesCmd.Flags().StringP("to", "t", "", "End time filter (optional)") + promqlSeriesCmd.Flags().StringP("output", "o", "text", "Output format: text or json") + + // flags: cardinality label-names + promqlCardinalityLabelNamesCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") + promqlCardinalityLabelNamesCmd.Flags().Int("lookback", 3600, "Seconds to look back from now") + promqlCardinalityLabelNamesCmd.Flags().Int("limit", 20, "Maximum number of labels to return") + promqlCardinalityLabelNamesCmd.Flags().String("selector", "", "Label selector to filter series") + promqlCardinalityLabelNamesCmd.Flags().StringP("output", "o", "text", "Output format: text or json") + + // flags: cardinality label-values + promqlCardinalityLabelValuesCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") + promqlCardinalityLabelValuesCmd.Flags().StringP("label", "l", "", "Label name to analyze") + promqlCardinalityLabelValuesCmd.Flags().Int("lookback", 3600, "Seconds to look back from now") + promqlCardinalityLabelValuesCmd.Flags().Int("limit", 20, "Maximum number of values to return") + promqlCardinalityLabelValuesCmd.Flags().StringP("output", "o", "text", "Output format: text or json") + + // flags: cardinality active-series + promqlCardinalityActiveSeriesCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") + promqlCardinalityActiveSeriesCmd.Flags().Int("lookback", 3600, "Seconds to look back from now") + promqlCardinalityActiveSeriesCmd.Flags().Int("limit", 20, "Maximum number of series to return") + promqlCardinalityActiveSeriesCmd.Flags().String("selector", "", "Label selector to filter series") + promqlCardinalityActiveSeriesCmd.Flags().StringP("output", "o", "text", "Output format: text or json") + + // flags: tsdb + promqlTSDBCmd.Flags().StringP("dataset", "d", defaultMetricsStream, "Metrics dataset") + promqlTSDBCmd.Flags().Int("top", 10, "Max entries per category") + promqlTSDBCmd.Flags().String("date", "", "Date to analyze (YYYY-MM-DD, defaults to today)") + promqlTSDBCmd.Flags().String("focus-label", "", "Label to break down series counts by") + promqlTSDBCmd.Flags().StringP("output", "o", "text", "Output format: text or json") +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +func promqlGet(path string, params url.Values) ([]byte, error) { + client := internalHTTP.DefaultClient(&DefaultProfile) + client.Client.Timeout = 120 * time.Second + client.Client.Transport = &http.Transport{ + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + } + reqURL, err := url.JoinPath(DefaultProfile.URL, path) + if err != nil { + return nil, err + } + if len(params) > 0 { + reqURL += "?" + params.Encode() + } + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + if DefaultProfile.Token != "" { + req.Header.Set("Authorization", "Bearer "+DefaultProfile.Token) + } else { + req.SetBasicAuth(DefaultProfile.Username, DefaultProfile.Password) + } + resp, err := client.Client.Do(req) + if err != nil { + if strings.Contains(err.Error(), "connection reset") { + return nil, fmt.Errorf("server reset the connection — query timed out") + } + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +func printRawJSON(body []byte) { + var v interface{} + if json.Unmarshal(body, &v) == nil { + b, _ := json.MarshalIndent(v, "", " ") + fmt.Println(string(b)) + } else { + fmt.Println(string(body)) + } +} + +func optionalTimeParam(params url.Values, cmd *cobra.Command, flagName, paramName string) { + val, _ := cmd.Flags().GetString(flagName) + if val == "" { + return + } + t, err := parseTimeStr(val) + if err == nil { + params.Set(paramName, t.UTC().Format(time.RFC3339)) + } +} + +// --------------------------------------------------------------------------- +// 1. run — range or instant PromQL query +// --------------------------------------------------------------------------- + +var promqlRunCmd = &cobra.Command{ + Use: "run [expr]", + Short: "Run a PromQL query (range or instant)", + Long: "\nEvaluate a PromQL expression against a Parseable metrics stream.\nDefaults to range query. Use --instant for point-in-time evaluation.", + Example: " pb query promql run \"http_requests_total\" --dataset otel_metrics --from 1h\n" + + " pb query promql run \"rate(http_requests_total[5m])\" --dataset otel_metrics --from 1h --step 1m\n" + + " pb query promql run \"up\" --dataset otel_metrics --instant -o json", + Args: cobra.ExactArgs(1), + PreRunE: PreRunDefaultProfile, + RunE: runPromqlQuery, +} + +type promqlResponse struct { + Status string `json:"status"` + Data promqlData `json:"data"` + Error string `json:"error,omitempty"` + ErrorType string `json:"errorType,omitempty"` +} + +type promqlData struct { + ResultType string `json:"resultType"` + Result []promqlSeries `json:"result"` +} + +type promqlSeries struct { + Metric map[string]string `json:"metric"` + Value []any `json:"value,omitempty"` // instant: [ts, "val"] + Values [][]any `json:"values,omitempty"` // range: [[ts, "val"], ...] +} + +func runPromqlQuery(cmd *cobra.Command, args []string) error { + expr := args[0] + stream, _ := cmd.Flags().GetString("dataset") + fromStr, _ := cmd.Flags().GetString("from") + toStr, _ := cmd.Flags().GetString("to") + step, _ := cmd.Flags().GetString("step") + outputFmt, _ := cmd.Flags().GetString("output") + instant, _ := cmd.Flags().GetBool("instant") + + toTime, err := parseTimeStr(toStr) + if err != nil { + return fmt.Errorf("invalid --to: %w", err) + } + + params := url.Values{} + params.Set("query", expr) + params.Set("stream", stream) + + var apiPath string + if instant { + apiPath = "prometheus/api/v1/query" + params.Set("time", toTime.UTC().Format(time.RFC3339)) + } else { + startTime, err := parseTimeStr(fromStr) + if err != nil { + return fmt.Errorf("invalid --from: %w", err) + } + apiPath = "prometheus/api/v1/query_range" + params.Set("start", startTime.UTC().Format(time.RFC3339)) + params.Set("end", toTime.UTC().Format(time.RFC3339)) + params.Set("step", step) + } + + body, err := promqlGet(apiPath, params) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var result promqlResponse + if err := json.Unmarshal(body, &result); err != nil { + fmt.Println(string(body)) + return nil + } + if result.Status == "error" { + return fmt.Errorf("query error (%s): %s", result.ErrorType, result.Error) + } + if len(result.Data.Result) == 0 { + fmt.Println("No data returned.") + return nil + } + + for _, series := range result.Data.Result { + fmt.Printf("%s\n", formatPromqlLabels(series.Metric)) + switch result.Data.ResultType { + case "vector": + if len(series.Value) == 2 { + fmt.Printf(" %s %v\n", promqlTS(series.Value[0]), series.Value[1]) + } + case "matrix": + for _, pt := range series.Values { + if len(pt) == 2 { + fmt.Printf(" %s %v\n", promqlTS(pt[0]), pt[1]) + } + } + } + fmt.Println() + } + fmt.Printf("result_type=%s series=%d\n", result.Data.ResultType, len(result.Data.Result)) + return nil +} + +// --------------------------------------------------------------------------- +// 2. labels — list all label names +// --------------------------------------------------------------------------- + +var promqlLabelsCmd = &cobra.Command{ + Use: "labels", + Short: "List all label names in a metrics stream", + Example: " pb query promql labels --stream otel_metrics", + Args: cobra.NoArgs, + PreRunE: PreRunDefaultProfile, + RunE: func(cmd *cobra.Command, _ []string) error { + stream, _ := cmd.Flags().GetString("dataset") + outputFmt, _ := cmd.Flags().GetString("output") + + params := url.Values{} + params.Set("stream", stream) + optionalTimeParam(params, cmd, "from", "start") + optionalTimeParam(params, cmd, "to", "end") + + body, err := promqlGet("prometheus/api/v1/labels", params) + if err != nil { + return err + } + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var resp struct { + Status string `json:"status"` + Data []string `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Println(string(body)) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + for _, l := range resp.Data { + fmt.Println(l) + } + fmt.Printf("\ntotal=%d\n", len(resp.Data)) + return nil + }, +} + +// --------------------------------------------------------------------------- +// 3. label-values — distinct values for a label +// --------------------------------------------------------------------------- + +var promqlLabelValuesCmd = &cobra.Command{ + Use: "label-values [label_name]", + Short: "List distinct values for a label", + Example: " pb query promql label-values job --stream otel_metrics\n pb query promql label-values __name__ --stream otel_metrics", + Args: cobra.ExactArgs(1), + PreRunE: PreRunDefaultProfile, + RunE: func(cmd *cobra.Command, args []string) error { + label := args[0] + stream, _ := cmd.Flags().GetString("dataset") + outputFmt, _ := cmd.Flags().GetString("output") + + params := url.Values{} + params.Set("stream", stream) + optionalTimeParam(params, cmd, "from", "start") + optionalTimeParam(params, cmd, "to", "end") + + body, err := promqlGet("prometheus/api/v1/label/"+label+"/values", params) + if err != nil { + return err + } + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var resp struct { + Status string `json:"status"` + Data []string `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Println(string(body)) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + for _, v := range resp.Data { + fmt.Println(v) + } + fmt.Printf("\nlabel=%s total=%d\n", label, len(resp.Data)) + return nil + }, +} + +// --------------------------------------------------------------------------- +// 4. series — find time series matching a selector +// --------------------------------------------------------------------------- + +var promqlSeriesCmd = &cobra.Command{ + Use: "series", + Short: "Find time series matching a label selector", + Example: " pb query promql series --match 'http_requests_total' --stream otel_metrics\n pb query promql series --match '{job=\"api\"}' --stream otel_metrics", + Args: cobra.NoArgs, + PreRunE: PreRunDefaultProfile, + RunE: func(cmd *cobra.Command, _ []string) error { + stream, _ := cmd.Flags().GetString("dataset") + matchers, _ := cmd.Flags().GetStringArray("match") + outputFmt, _ := cmd.Flags().GetString("output") + + if len(matchers) == 0 { + return fmt.Errorf("at least one --match selector is required") + } + + params := url.Values{} + params.Set("stream", stream) + for _, m := range matchers { + params.Add("match[]", m) + } + optionalTimeParam(params, cmd, "from", "start") + optionalTimeParam(params, cmd, "to", "end") + + body, err := promqlGet("prometheus/api/v1/series", params) + if err != nil { + return err + } + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var resp struct { + Status string `json:"status"` + Data []map[string]string `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Println(string(body)) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + for _, series := range resp.Data { + fmt.Println(formatPromqlLabels(series)) + } + fmt.Printf("\ntotal=%d\n", len(resp.Data)) + return nil + }, +} + +// --------------------------------------------------------------------------- +// 5. cardinality (parent) + subcommands +// --------------------------------------------------------------------------- + +var promqlCardinalityCmd = &cobra.Command{ + Use: "cardinality", + Short: "Cardinality analysis for a metrics stream", + Long: "\nAnalyze label cardinality and active series in a Parseable metrics stream.", +} + +type cardinalityEntry struct { + Name string `json:"name"` + Value int `json:"value"` +} + +// cardinality label-names +var promqlCardinalityLabelNamesCmd = &cobra.Command{ + Use: "label-names", + Short: "Labels with the highest number of distinct values", + Example: " pb query promql cardinality label-names --stream otel_metrics --limit 20", + Args: cobra.NoArgs, + PreRunE: PreRunDefaultProfile, + RunE: func(cmd *cobra.Command, _ []string) error { + stream, _ := cmd.Flags().GetString("dataset") + lookback, _ := cmd.Flags().GetInt("lookback") + limit, _ := cmd.Flags().GetInt("limit") + selector, _ := cmd.Flags().GetString("selector") + outputFmt, _ := cmd.Flags().GetString("output") + + params := url.Values{} + params.Set("stream", stream) + params.Set("lookback", fmt.Sprintf("%d", lookback)) + params.Set("limit", fmt.Sprintf("%d", limit)) + if selector != "" { + params.Set("selector", selector) + } + + body, err := promqlGet("prometheus/api/v1/cardinality/label_names", params) + if err != nil { + return err + } + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var resp struct { + Status string `json:"status"` + Data []cardinalityEntry `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Println(string(body)) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + fmt.Printf("%-40s %s\n", "LABEL", "DISTINCT VALUES") + fmt.Println(strings.Repeat("-", 55)) + for _, e := range resp.Data { + fmt.Printf("%-40s %d\n", e.Name, e.Value) + } + return nil + }, +} + +// cardinality label-values +var promqlCardinalityLabelValuesCmd = &cobra.Command{ + Use: "label-values", + Short: "Series count per value for a specific label", + Example: " pb query promql cardinality label-values --label job --stream otel_metrics", + Args: cobra.NoArgs, + PreRunE: PreRunDefaultProfile, + RunE: func(cmd *cobra.Command, _ []string) error { + stream, _ := cmd.Flags().GetString("dataset") + labelName, _ := cmd.Flags().GetString("label") + lookback, _ := cmd.Flags().GetInt("lookback") + limit, _ := cmd.Flags().GetInt("limit") + outputFmt, _ := cmd.Flags().GetString("output") + + params := url.Values{} + params.Set("stream", stream) + params.Set("lookback", fmt.Sprintf("%d", lookback)) + params.Set("limit", fmt.Sprintf("%d", limit)) + if labelName != "" { + params.Set("label_name", labelName) + } + + body, err := promqlGet("prometheus/api/v1/cardinality/label_values", params) + if err != nil { + return err + } + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var resp struct { + Status string `json:"status"` + Data []cardinalityEntry `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Println(string(body)) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + fmt.Printf("%-40s %s\n", "VALUE", "SERIES COUNT") + fmt.Println(strings.Repeat("-", 55)) + for _, e := range resp.Data { + fmt.Printf("%-40s %d\n", e.Name, e.Value) + } + return nil + }, +} + +// cardinality active-series +var promqlCardinalityActiveSeriesCmd = &cobra.Command{ + Use: "active-series", + Short: "List currently active series", + Example: " pb query promql cardinality active-series --stream otel_metrics --selector '{job=\"api\"}'", + Args: cobra.NoArgs, + PreRunE: PreRunDefaultProfile, + RunE: func(cmd *cobra.Command, _ []string) error { + stream, _ := cmd.Flags().GetString("dataset") + lookback, _ := cmd.Flags().GetInt("lookback") + limit, _ := cmd.Flags().GetInt("limit") + selector, _ := cmd.Flags().GetString("selector") + outputFmt, _ := cmd.Flags().GetString("output") + + params := url.Values{} + params.Set("stream", stream) + params.Set("lookback", fmt.Sprintf("%d", lookback)) + params.Set("limit", fmt.Sprintf("%d", limit)) + if selector != "" { + params.Set("selector", selector) + } + + body, err := promqlGet("prometheus/api/v1/cardinality/active_series", params) + if err != nil { + return err + } + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var resp struct { + Status string `json:"status"` + Data struct { + TotalActiveSeries int `json:"total_active_series"` + Series []map[string]string `json:"series"` + } `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Println(string(body)) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + fmt.Printf("total_active_series=%d\n\n", resp.Data.TotalActiveSeries) + for _, s := range resp.Data.Series { + fmt.Println(formatPromqlLabels(s)) + } + return nil + }, +} + +// --------------------------------------------------------------------------- +// 6. active-queries — currently executing queries +// --------------------------------------------------------------------------- + +var promqlActiveQueriesCmd = &cobra.Command{ + Use: "active-queries", + Short: "Show currently executing PromQL queries", + Example: " pb query promql active-queries", + Args: cobra.NoArgs, + PreRunE: PreRunDefaultProfile, + RunE: func(_ *cobra.Command, _ []string) error { + body, err := promqlGet("prometheus/api/v1/status/active_queries", nil) + if err != nil { + return err + } + + var resp struct { + Status string `json:"status"` + Data []struct { + Query string `json:"query"` + Stream string `json:"stream"` + StartedAt string `json:"started_at"` + ElapsedMs int `json:"elapsed_ms"` + } `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + printRawJSON(body) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + if len(resp.Data) == 0 { + fmt.Println("No active queries.") + return nil + } + fmt.Printf("%-50s %-15s %-22s %s\n", "QUERY", "STREAM", "STARTED", "ELAPSED") + fmt.Println(strings.Repeat("-", 100)) + for _, q := range resp.Data { + query := q.Query + if len(query) > 48 { + query = query[:45] + "..." + } + fmt.Printf("%-50s %-15s %-22s %dms\n", query, q.Stream, q.StartedAt, q.ElapsedMs) + } + return nil + }, +} + +// --------------------------------------------------------------------------- +// 7. tsdb — TSDB statistics +// --------------------------------------------------------------------------- + +var promqlTSDBCmd = &cobra.Command{ + Use: "tsdb", + Short: "Show TSDB statistics for a metrics stream", + Example: " pb query promql tsdb --stream otel_metrics\n pb query promql tsdb --stream otel_metrics --top 20 --focus-label job", + Args: cobra.NoArgs, + PreRunE: PreRunDefaultProfile, + RunE: func(cmd *cobra.Command, _ []string) error { + stream, _ := cmd.Flags().GetString("dataset") + topN, _ := cmd.Flags().GetInt("top") + date, _ := cmd.Flags().GetString("date") + focusLabel, _ := cmd.Flags().GetString("focus-label") + outputFmt, _ := cmd.Flags().GetString("output") + + params := url.Values{} + params.Set("stream", stream) + params.Set("topN", fmt.Sprintf("%d", topN)) + if date != "" { + params.Set("date", date) + } + if focusLabel != "" { + params.Set("focusLabel", focusLabel) + } + + body, err := promqlGet("prometheus/api/v1/status/tsdb", params) + if err != nil { + return err + } + if outputFmt == "json" { + printRawJSON(body) + return nil + } + + var resp struct { + Status string `json:"status"` + Data struct { + TotalSeries int `json:"totalSeries"` + TotalLabelValuePairs int `json:"totalLabelValuePairs"` + SeriesByMetric []cardinalityEntry `json:"seriesCountByMetricName"` + SeriesByLabel []cardinalityEntry `json:"seriesCountByLabelName"` + SeriesByFocusLabel []cardinalityEntry `json:"seriesCountByFocusLabelValue"` + LabelValueCount []cardinalityEntry `json:"labelValueCountByLabelName"` + } `json:"data"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Println(string(body)) + return nil + } + if resp.Status == "error" { + return fmt.Errorf("%s", resp.Error) + } + + d := resp.Data + fmt.Printf("Total Series: %d\n", d.TotalSeries) + fmt.Printf("Total Label Pairs: %d\n\n", d.TotalLabelValuePairs) + + if len(d.SeriesByMetric) > 0 { + fmt.Println("Top metrics by series count:") + for _, e := range d.SeriesByMetric { + fmt.Printf(" %-50s %d\n", e.Name, e.Value) + } + fmt.Println() + } + if len(d.SeriesByLabel) > 0 { + fmt.Println("Top labels by series count:") + for _, e := range d.SeriesByLabel { + fmt.Printf(" %-40s %d\n", e.Name, e.Value) + } + fmt.Println() + } + if len(d.SeriesByFocusLabel) > 0 { + fmt.Printf("Series by %s value:\n", focusLabel) + for _, e := range d.SeriesByFocusLabel { + fmt.Printf(" %-40s %d\n", e.Name, e.Value) + } + fmt.Println() + } + if len(d.LabelValueCount) > 0 { + fmt.Println("Distinct values per label:") + for _, e := range d.LabelValueCount { + fmt.Printf(" %-40s %d\n", e.Name, e.Value) + } + } + return nil + }, +} + +// --------------------------------------------------------------------------- +// Shared formatting helpers +// --------------------------------------------------------------------------- + +func formatPromqlLabels(m map[string]string) string { + name := m["__name__"] + var labels []string + for k, v := range m { + if k != "__name__" { + labels = append(labels, k+"=\""+v+"\"") + } + } + if len(labels) == 0 { + return name + } + if name == "" { + return "{" + strings.Join(labels, ", ") + "}" + } + return fmt.Sprintf("%s{%s}", name, strings.Join(labels, ", ")) +} + +func promqlTS(v any) string { + if f, ok := v.(float64); ok { + return time.Unix(int64(f), 0).UTC().Format("2006-01-02T15:04:05Z") + } + return fmt.Sprintf("%v", v) +} diff --git a/cmd/query.go b/cmd/query.go index 6d5a3ab..e39414c 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -28,9 +28,10 @@ import ( "pb/pkg/model" - tea "github.com/charmbracelet/bubbletea" internalHTTP "pb/pkg/http" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" ) diff --git a/main.go b/main.go index 5cd61a6..2d0fe7e 100644 --- a/main.go +++ b/main.go @@ -266,6 +266,7 @@ func main() { dataset.AddCommand(pb.StatDatasetCmd) query.AddCommand(pb.QueryCmd) + query.AddCommand(pb.PromqlCmd) query.AddCommand(pb.SavedQueryList) schema.AddCommand(pb.GenerateSchemaCmd) diff --git a/pkg/model/query.go b/pkg/model/query.go index 7753060..1f5bf69 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -25,7 +25,6 @@ import ( "os" "pb/pkg/config" "pb/pkg/iterator" - "strings" "time" "github.com/charmbracelet/bubbles/help" @@ -92,11 +91,6 @@ var ( additionalKeyBinds = []key.Binding{runQueryKey} - 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")), - } - QueryNavigationMap = []string{"query", "time", "table"} ) @@ -152,45 +146,6 @@ func (m *QueryModel) currentFocus() string { return QueryNavigationMap[m.focused] } -func (m *QueryModel) initIterator() { - iter := createIteratorFromModel(m) - m.queryIterator = iter -} - -func createIteratorFromModel(m *QueryModel) *iterator.QueryIterator[QueryData, FetchResult] { - startTime := m.timeRange.start.Time() - endTime := m.timeRange.end.Time() - - startTime = startTime.Truncate(time.Minute) - endTime = endTime.Truncate(time.Minute).Add(time.Minute) - - table := streamNameFromQuery(m.query.Value()) - if table != "" { - iter := iterator.NewQueryIterator( - startTime, endTime, - false, - func(t1, t2 time.Time) (QueryData, FetchResult) { - client := &http.Client{ - Timeout: time.Second * 50, - } - return fetchData(client, &m.profile, m.query.Value(), t1.UTC().Format(time.RFC3339), t2.UTC().Format(time.RFC3339)) - }, - func(_, _ time.Time) bool { - client := &http.Client{ - Timeout: time.Second * 50, - } - res, err := fetchData(client, &m.profile, "select count(*) as count from "+table, m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) - if err == fetchErr || len(res.Records) == 0 { - return false - } - count, ok := res.Records[0]["count"].(float64) - return ok && count > 0 - }) - return &iter - } - return nil -} - func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime time.Time) QueryModel { w, h, _ := term.GetSize(int(os.Stdout.Fd())) @@ -449,7 +404,7 @@ func NewFetchTask(profile config.Profile, query string, startTime string, endTim } defer func() { if r := recover(); r != nil { - msg = res + msg = res } }() @@ -698,15 +653,3 @@ func countDigits(num int) int { numDigits := int(math.Log10(math.Abs(float64(num)))) + 1 return numDigits } - -func streamNameFromQuery(query string) string { - stream := "" - tokens := strings.Split(query, " ") - for i, token := range tokens { - if token == "from" { - stream = tokens[i+1] - break - } - } - return stream -} diff --git a/pkg/model/tablekeymap.go b/pkg/model/tablekeymap.go index e637701..75bc3a5 100644 --- a/pkg/model/tablekeymap.go +++ b/pkg/model/tablekeymap.go @@ -24,7 +24,7 @@ type TableKeyMap struct { RowUp key.Binding RowDown key.Binding PageUp key.Binding - PageDown key.Binding + PageDown key.Binding PageFirst key.Binding PageLast key.Binding ScrollRight key.Binding @@ -44,11 +44,11 @@ func (k TableKeyMap) ShortHelp() []key.Binding { // key.Map interface. func (k TableKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.RowUp, k.RowDown}, // first column + {k.RowUp, k.RowDown}, // first column {k.ScrollLeft, k.ScrollRight}, // second column - { k.PageUp, k.PageDown}, // third column - {k.PageFirst, k.PageLast}, // fourth column - {k.FilterClear, k.Filter}, // fifth column + {k.PageUp, k.PageDown}, // third column + {k.PageFirst, k.PageLast}, // fourth column + {k.FilterClear, k.Filter}, // fifth column } } diff --git a/pkg/model/textareakeymap.go b/pkg/model/textareakeymap.go index edf04c9..eb7076c 100644 --- a/pkg/model/textareakeymap.go +++ b/pkg/model/textareakeymap.go @@ -118,4 +118,4 @@ var textAreaKeyMap = textarea.KeyMap{ UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u")), TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t")), -} \ No newline at end of file +} From c93c62179997439d437df863d8642553c61ede0b Mon Sep 17 00:00:00 2001 From: Pratik Date: Tue, 12 May 2026 18:54:07 +0530 Subject: [PATCH 3/7] fix: minor query bugs and ui feature --- cmd/promql.go | 2 ++ cmd/query.go | 24 ++++++++++++++++++++++ pkg/model/query.go | 50 +++++++++++++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/cmd/promql.go b/cmd/promql.go index cdf23ce..4e7c879 100644 --- a/cmd/promql.go +++ b/cmd/promql.go @@ -140,7 +140,9 @@ func promqlGet(path string, params url.Values) ([]byte, error) { } else { req.SetBasicAuth(DefaultProfile.Username, DefaultProfile.Password) } + stopSpinner := startSpinner() resp, err := client.Client.Do(req) + stopSpinner() if err != nil { if strings.Contains(err.Error(), "connection reset") { return nil, fmt.Errorf("server reset the connection — query timed out") diff --git a/cmd/query.go b/cmd/query.go index e39414c..a69716a 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -123,7 +123,9 @@ var query = &cobra.Command{ } client := internalHTTP.DefaultClient(&DefaultProfile) + stopSpinner := startSpinner() err = fetchData(&client, sqlQuery, start, end, outputFmt) + stopSpinner() if err != nil { command.Annotations["error"] = err.Error() } @@ -159,6 +161,28 @@ func parseTimeStr(s string) (time.Time, error) { return time.Time{}, fmt.Errorf("unrecognized time format %q (use: now, 10m, 2h, 1d, or RFC3339)", s) } +// startSpinner prints an animated spinner to stderr while a fetch is in progress. +// Call the returned function to stop it and clear the line. +func startSpinner() func() { + frames := []string{"|", "/", "-", "\\"} + done := make(chan struct{}) + go func() { + i := 0 + for { + select { + case <-done: + fmt.Fprint(os.Stderr, "\r\033[K") // clear the line + return + default: + fmt.Fprintf(os.Stderr, "\r%s fetching...", frames[i%len(frames)]) + i++ + time.Sleep(100 * time.Millisecond) + } + } + }() + return func() { close(done) } +} + // fromClauseRe matches an unquoted identifier after FROM or JOIN. var fromClauseRe = regexp.MustCompile(`(?i)(\b(?:from|join)\s+)([a-zA-Z_][a-zA-Z0-9_-]*)`) diff --git a/pkg/model/query.go b/pkg/model/query.go index 1f5bf69..4f892aa 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -22,13 +22,16 @@ import ( "fmt" "math" "net/http" + "net/url" "os" + "strings" "pb/pkg/config" "pb/pkg/iterator" "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -124,6 +127,8 @@ type QueryModel struct { profile config.Profile help help.Model status StatusBar + spinner spinner.Model + loading bool queryIterator *iterator.QueryIterator[QueryData, FetchResult] overlay uint focused int @@ -191,7 +196,10 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t help.Styles.FullDesc = lipgloss.NewStyle().Foreground(FocusSecondary) status := NewStatusBar(profile.URL, w) - status.Info = "fetching..." + + sp := spinner.New() + sp.Spinner = spinner.Line + sp.Style = lipgloss.NewStyle().Foreground(FocusPrimary) model := QueryModel{ width: w, @@ -202,6 +210,8 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t overlay: overlayNone, profile: profile, help: help, + spinner: sp, + loading: true, queryIterator: nil, status: status, } @@ -209,7 +219,10 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t } func (m QueryModel) Init() tea.Cmd { - return NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) + return tea.Batch( + m.spinner.Tick, + NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()), + ) } func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -218,6 +231,13 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case spinner.TickMsg: + if m.loading { + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -228,6 +248,7 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case FetchData: + m.loading = false m.status.Info = "" if msg.status == fetchOk { m.UpdateTable(msg) @@ -263,8 +284,9 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlay = overlayNone m.focusSelected() m.status.Error = "" - m.status.Info = "fetching..." - return m, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) + m.status.Info = "" + m.loading = true + return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) } } @@ -272,8 +294,9 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyCtrlR { m.overlay = overlayNone m.status.Error = "" - m.status.Info = "fetching..." - return m, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()) + m.status.Info = "" + m.loading = true + return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) } if msg.Type == tea.KeyCtrlB { @@ -337,6 +360,10 @@ func (m QueryModel) View() string { ) headerHeight := lipgloss.Height(header) + if m.loading { + m.status.Info = m.spinner.View() + " fetching..." + m.status.Error = "" + } statusView := m.status.View() statusHeight := lipgloss.Height(statusView) @@ -386,6 +413,14 @@ func (m QueryModel) View() string { mainView = m.timeRange.View() } + // Pin help+status to the bottom by padding the main view to fill remaining height. + mainHeight := lipgloss.Height(mainView) + bottomHeight := helpHeight + statusHeight + padLines := m.height - mainHeight - bottomHeight + if padLines > 0 { + mainView = mainView + strings.Repeat("\n", padLines) + } + render := lipgloss.JoinVertical(lipgloss.Left, mainView, helpView, statusView) return lipgloss.NewStyle().Width(m.width).Render(render) } @@ -477,7 +512,8 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start return } - endpoint := fmt.Sprintf("%s/%s", profile.URL, "api/v1/query?fields=true") + endpoint, _ := url.JoinPath(profile.URL, "api/v1/query") + endpoint += "?fields=true" req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) if err != nil { return From f22ce43af28ecb12b133fe30fa6e6f603d42bb20 Mon Sep 17 00:00:00 2001 From: Pratik Date: Wed, 13 May 2026 21:23:23 +0530 Subject: [PATCH 4/7] feat: interactive login wizard, query field auto-quoting, tail spinner, and query UX improvements --- CODE_OF_CONDUCT.md | 128 +++++++++ CONTRIBUTING.md | 78 ++++++ README.md | 250 +++++++++++++---- cmd/login.go | 143 +--------- cmd/profile.go | 74 ++++- cmd/query.go | 174 +++++++++++- cmd/tail.go | 39 ++- main.go | 1 + pkg/config/config.go | 27 +- pkg/model/login/login.go | 529 ++++++++++++++++++++++++++++++++++++ pkg/model/query.go | 58 +++- pkg/model/textareakeymap.go | 18 +- 12 files changed, 1324 insertions(+), 195 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 pkg/model/login/login.go diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..469e563 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +hi@parseable.io. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5b75eac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to pb + +Thank you for your interest in contributing to `pb`! This document covers everything you need to get started. + +## Prerequisites + +- [Go 1.25+](https://go.dev/dl/) +- `make` +- A running [Parseable Server](https://github.com/parseablehq/parseable) for integration testing + +## Development Setup + +```bash +# Clone the repo +git clone https://github.com/parseablehq/pb.git +cd pb + +# Install lint tooling +make getdeps + +# Build the binary +make build # produces ./pb + +# Or install to $GOPATH/bin +make install +``` + +## Running Tests + +```bash +go test ./... +``` + +## Running the Linter + +```bash +make lint # golangci-lint +make vet # go vet +make verifiers # vet + lint (full check, same as CI) +``` + +All checks must pass before raising a PR. + +## Making Changes + +### Branch Naming + +| Type | Pattern | Example | +|------|---------|---------| +| Feature | `feat/` | `feat/promql-instant-query` | +| Bug fix | `fix/` | `fix/double-slash-url` | +| Docs | `docs/` | `docs/update-readme` | +| Chore | `chore/` | `chore/upgrade-go-version` | + +### CLA + +All contributors must sign the [Contributor License Agreement](https://github.com/parseablehq/.github) before a PR can be merged. The CLA bot will prompt you automatically on your first PR. + +## Pull Request Checklist + +Before marking a PR as ready for review: + +- [ ] `make verifiers` passes locally +- [ ] New behavior is covered by tests where applicable +- [ ] README or docs updated if a user-visible change was made +- [ ] CLA signed (bot will comment if not) + +## Code Style + +- `gofmt` / `gofumpt` formatting (enforced by CI) +- `goimports` for import ordering +- Prefer short, focused functions + +## Getting Help + +Open a [GitHub Discussion](https://github.com/parseablehq/pb/discussions) for questions, or join the [Parseable Slack](https://launchpass.com/parseable). + +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating. diff --git a/README.md b/README.md index 4f28464..6c847fa 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,294 @@ # pb -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. +[![Build](https://github.com/parseablehq/pb/actions/workflows/build.yaml/badge.svg)](https://github.com/parseablehq/pb/actions/workflows/build.yaml) +[![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE) +[![Latest Release](https://img.shields.io/github/v/release/parseablehq/pb)](https://github.com/parseablehq/pb/releases/latest) +[![Go Version](https://img.shields.io/github/go-mod/go-version/parseablehq/pb)](go.mod) -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](https://github.com/parseablehq/.github/blob/main/images/pb/pb.gif?raw=true) +`pb` is the command line interface for [Parseable](https://github.com/parseablehq/parseable) — a fast, lightweight log and metrics storage server. Use `pb` to run SQL and PromQL queries, tail live data, manage datasets, users, and profiles, all from your terminal. ## Installation -pb is available as a single, self contained binary for Mac, Linux, and Windows. You can download the latest version from the [releases page](https://github.com/parseablehq/pb/releases/latest). +Download the latest binary for your platform from the [releases page](https://github.com/parseablehq/pb/releases/latest). + +**macOS / Linux** + +```bash +tar -xzf pb___.tar.gz +mv pb /usr/local/bin/pb +pb --version +``` + +**Windows** + +1. Download `pb__windows_amd64.tar.gz` from the releases page +2. Open PowerShell and extract: + +```powershell +tar -xzf pb__windows_amd64.tar.gz +``` + +3. Move `pb.exe` to a folder in your `PATH` (e.g. `C:\Users\\bin\`) and verify: + +```powershell +pb --version +``` + +**Install with Go (all platforms)** + +```bash +go install github.com/parseablehq/pb@latest +``` + +## Quick Start + +### Step 1 — Connect to a server + +Run `pb login` to launch the interactive setup wizard: + +```bash +pb login +``` -To install pb, download the binary for your platform, un-tar the binary and place it in your `$PATH`. +The wizard walks you through: +- **Choose type** — Self-hosted or Parseable Cloud +- **Enter server URL** — e.g. `http://localhost:8000` +- **Choose auth** — Username & Password, or Token +- **Enter credentials** +- **Name the profile** — e.g. `local`, `staging`, `prod` -## Usage +Use `↑ ↓` to navigate lists, `Enter` to confirm, `Esc` to go back one step. If a profile name already exists, the wizard asks whether to replace it or pick a new name. -pb is configured with `demo` profile as the default. This means you can directly start using pb against the [demo Parseable Server](https://demo.parseable.com). +> **Prefer a one-liner?** Use `pb profile add` instead — see [Profiles](#profiles). + +### Step 2 — Run your first query + +```bash +pb query run "SELECT * FROM backend" --from=10m --to=now +``` + +That's it. See the sections below for every available command. + +--- + +## Commands ### Profiles -To start using pb against your Parseable server, create a profile (a profile is a set of credentials for a Parseable Server instance). You can create a profile using the `pb profile add` command. For example: +Manage multiple Parseable server connections. All commands use the active default profile automatically. + +Profiles are stored in `~/.config/pb/config.toml` (macOS/Linux) or `%AppData%\pb\config.toml` (Windows). ```bash -pb profile add local http://localhost:8000 admin admin +pb login # interactive setup wizard (recommended for new users) +pb profile add staging https://staging.example.com admin secret # add a profile non-interactively +pb profile list # list all profiles +pb profile default staging # switch default profile +pb profile update staging https://new-host.example.com:8000 # update URL for a profile +pb profile remove staging # remove a profile +pb logout # remove the active profile ``` -This will create a profile named `local` that points to the Parseable Server at `http://localhost:8000` and uses the username `admin` and password `admin`. +When you remove the default profile: +- 1 profile remaining → it becomes the new default automatically +- 2+ remaining → an interactive picker lets you choose the new default +- 0 remaining → default is cleared -You can create as many profiles as you like. To avoid having to specify the profile name every time you run a command, pb allows setting a default profile. To set the default profile, use the `pb profile default` command. For example: +### SQL Query + +Query a dataset and print results to stdout. + +```bash +pb query run "SELECT * FROM backend" --from=10m --to=now +``` + +**Time range** — supports relative durations, day shorthand, and RFC3339: ```bash -pb profile default local +pb query run "SELECT * FROM backend" --from=1h # last 1 hour +pb query run "SELECT * FROM backend" --from=7d # last 7 days +pb query run "SELECT * FROM backend" \ + --from=2024-01-01T00:00:00Z --to=2024-01-01T01:00:00Z # exact window ``` -### Query +**JSON output:** + +```bash +pb query run "SELECT * FROM backend" --from=1h --output json | jq . +``` -By default `pb` sends json data to stdout. +**Interactive table view** — navigate, filter, and paginate results in the terminal: ```bash -pb query run "select * from backend" --from=1m --to=now +pb query run "SELECT * FROM backend" --from=1h -i ``` -or specifying time range in rfc3999 +**Save a query for later:** ```bash -pb query run "select * from backend" --from=2024-01-00T01:40:00.000Z --to=2024-01-00T01:55:00.000Z +pb query run "SELECT * FROM backend WHERE status = 500" --from=1h --save-as=server-errors +pb query list # list and apply saved queries ``` -You can use tools like `jq` and `grep` to further process and filter the output. Some examples: +> **Note on field names with dots** — OTel fields like `service.name`, `http.status_code`, and `code.file.path` can be used directly in queries without manual quoting. `pb` handles the quoting transparently: +> ```bash +> pb query run "SELECT * FROM otel-logs WHERE service.name = 'frontend'" --from=1h +> ``` + +#### Interactive Mode Keys + +| Key | Action | +|-----|--------| +| `Tab` | Next panel (Query → Time → Table) | +| `Shift+Tab` | Previous panel | +| `Enter` (Time panel) | Open time range picker | +| `Ctrl+R` | Run query | +| `Ctrl+B` | Fetch previous page | +| `Ctrl+C` | Exit | + +**Table panel keys:** + +| Key | Action | +|-----|--------| +| `↑` / `w` | Scroll up | +| `↓` / `s` | Scroll down | +| `Shift+↑` / `PgUp` | Previous page | +| `Shift+↓` / `PgDn` | Next page | +| `←` / `a` | Scroll columns left | +| `→` / `d` | Scroll columns right | +| `/` | Filter rows | +| `Esc` | Clear filter | + +### PromQL Query + +Query metrics datasets using PromQL expressions. ```bash -pb query run "select * from backend" --from=1m --to=now | jq . -pb query run "select host, id, method, status from backend where status = 500" --from=1m --to=now | jq . > 500.json -pb query run "select host, id, method, status from backend where status = 500" | jq '. | select(.method == "PATCH")' -pb query run "select host, id, method, status from backend where status = 500" --from=1m --to=now | grep "POST" | jq . | less +# Range query — returns a time series over the given window +pb query promql run "rate(http_requests_total[5m])" \ + --dataset otel_metrics --from=1h --step=1m + +# Instant query — evaluate at a single point in time +pb query promql run "up" --dataset otel_metrics --instant + +# JSON output +pb query promql run "http_requests_total" --dataset otel_metrics -o json ``` -#### Save Filter +**Explore metrics:** -To save a query as a filter use the `--save-as` flag followed by a name for the filter. For example: +```bash +pb query promql labels --dataset otel_metrics # all label names +pb query promql label-values job --dataset otel_metrics # values for a label +pb query promql series --match 'http_requests_total{job="api"}' --dataset otel_metrics # matching series +``` + +**Cardinality analysis** — find high-cardinality labels before they cause memory issues: ```bash -pb query run "select * from backend" --from=1m --to=now --save-as=FilterName +pb query promql cardinality label-names --dataset otel_metrics # labels by distinct value count +pb query promql cardinality label-values --label service.name \ + --dataset otel_metrics # series count per label value +pb query promql cardinality active-series --dataset otel_metrics # total active series ``` -### List Filter +**TSDB statistics:** + +```bash +pb query promql tsdb --dataset otel_metrics +``` -To list all filter for the active user run: +**Currently running queries:** ```bash -pb query list +pb query promql active-queries ``` ### Live Tail -`pb` can be used to tail live data from Parseable Server. To tail live data, use the `pb tail` command. For example: +Stream live log events from a dataset as they arrive: ```bash pb tail backend ``` -You can also use the terminal tools like `jq` and `grep` to filter and process the tail output. Some examples: +Filter in real time with standard tools: ```bash -pb tail backend | jq '. | select(.method == "PATCH")' +pb tail backend | jq '.[] | select(.method == "PATCH")' pb tail backend | grep "POST" | jq . ``` -To stop tailing, press `Ctrl+C`. +Press `Ctrl+C` to stop. -### Stream Management +### Dataset Management + +```bash +pb dataset list # list all datasets on the server +pb dataset info my_logs # show stats (size, event count) for a dataset +pb dataset add my_logs # create a new dataset +pb dataset remove my_logs # delete a dataset +``` -Once a profile is configured, you can use pb to query and manage _that_ Parseable Server instance. For example, to list all the streams on the server, run: +### Users and Roles ```bash -pb stream list +pb user list # list all users +pb user add alice # create a user (prompts for password) +pb user set-role alice admin,editor # assign roles +pb user remove alice # delete a user + +pb role list # list available roles +pb role add ingestors # create a role (interactive privilege picker) +pb role remove ingestors # delete a role ``` -### Users +### Status -To list all the users with their privileges, run: +Check connectivity and version info for the active server: ```bash -pb user list +pb status ``` -You can also use the `pb users` command to manage users. - ### Version -Version command prints the version of pb and the Parseable Server it is configured to use. - ```bash pb version +pb --version ``` -### Add Autocomplete +### Autocomplete -To enable autocomplete for pb, run the following command according to your shell: +Enable shell completion for `pb` commands and flags. -For bash: +**Bash:** ```bash pb autocomplete bash > /etc/bash_completion.d/pb source /etc/bash_completion.d/pb ``` -For zsh: +**Zsh:** ```zsh pb autocomplete zsh > /usr/local/share/zsh/site-functions/_pb autoload -U compinit && compinit ``` -For powershell +**PowerShell:** ```powershell pb autocomplete powershell > $env:USERPROFILE\Documents\PowerShell\pb_complete.ps1 . $PROFILE ``` + +--- + +## Contributing + +Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for how to set up your dev environment, branch naming conventions, and the PR checklist. All contributors must sign the CLA — the bot will prompt you automatically on your first PR. + +## License + +`pb` is released under the [GNU Affero General Public License v3.0](LICENSE). diff --git a/cmd/login.go b/cmd/login.go index c0382cb..4084120 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -16,137 +16,37 @@ package cmd import ( - "bufio" "fmt" - "os" - "os/exec" "pb/pkg/config" - "runtime" - "strings" + "pb/pkg/model/login" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) -const cloudURL = "https://app.parseable.com" - -var ( - loginToken string - loginURL string - loginUsername string - loginPassword string - loginProfileName string -) - -func init() { - LoginCmd.Flags().StringVar(&loginToken, "token", "", "Auth token for cloud login") - LoginCmd.Flags().StringVar(&loginURL, "url", "", "Server URL for self-hosted Parseable") - LoginCmd.Flags().StringVar(&loginUsername, "username", "", "Username for self-hosted login") - LoginCmd.Flags().StringVar(&loginPassword, "password", "", "Password for self-hosted login") - LoginCmd.Flags().StringVar(&loginProfileName, "profile", "default", "Profile name to save as") -} - var LoginCmd = &cobra.Command{ Use: "login", Short: "Login to Parseable", - Long: `Login to Parseable cloud or a self-hosted instance. + Long: `Interactive login wizard for Parseable. -Cloud login (opens browser): - pb login - -Cloud login with token: - pb login --token - -Self-hosted login: - pb login --url http://localhost:8000 --username admin --password admin`, +Select self-hosted and enter your server URL, credentials, and a +profile name. All settings are saved to ~/.config/pb/config.toml.`, RunE: func(_ *cobra.Command, _ []string) error { - // --- Self-hosted path --- - if loginURL != "" { - return selfHostedLogin() - } - - // --- Cloud path --- - return cloudLogin() - }, -} - -func selfHostedLogin() error { - username := loginUsername - password := loginPassword - - if username == "" { - fmt.Print("Username: ") - reader := bufio.NewReader(os.Stdin) - line, err := reader.ReadString('\n') + _m, err := tea.NewProgram(login.New()).Run() if err != nil { - return fmt.Errorf("failed to read username: %w", err) + return err } - username = strings.TrimSpace(line) - } - - if password == "" { - fmt.Print("Password: ") - reader := bufio.NewReader(os.Stdin) - line, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read password: %w", err) - } - password = strings.TrimSpace(line) - } - - if username == "" || password == "" { - return fmt.Errorf("username and password are required for self-hosted login") - } - - profile := config.Profile{ - URL: loginURL, - Username: username, - Password: password, - } - if err := writeProfile(profile, loginProfileName); err != nil { - return fmt.Errorf("failed to save profile: %w", err) - } - - fmt.Printf("✓ Logged in. Profile '%s' saved.\n", loginProfileName) - fmt.Printf(" URL: %s\n", loginURL) - return nil -} - -func cloudLogin() error { - token := loginToken - - if token == "" { - loginPageURL := cloudURL + "/login" - fmt.Printf("Opening login page: %s\n\n", loginPageURL) - if err := openBrowser(loginPageURL); err != nil { - fmt.Println("Could not open browser automatically. Please visit the URL above and copy your token.") - } else { - fmt.Println("Browser opened. After logging in, copy your token from the dashboard.") + m, ok := _m.(login.Model) + if !ok || !m.Done { + return nil } - fmt.Print("\nPaste your token here: ") - reader := bufio.NewReader(os.Stdin) - line, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read token: %w", err) - } - token = strings.TrimSpace(line) - if token == "" { - return fmt.Errorf("no token provided, login canceled") + if err := writeProfile(m.Profile, m.Name); err != nil { + return fmt.Errorf("failed to save profile: %w", err) } - } - - profile := config.Profile{ - URL: cloudURL, - Token: token, - } - if err := writeProfile(profile, loginProfileName); err != nil { - return fmt.Errorf("failed to save profile: %w", err) - } - - fmt.Printf("✓ Logged in. Profile '%s' saved.\n", loginProfileName) - fmt.Printf(" URL: %s\n", cloudURL) - return nil + return nil + }, } func writeProfile(profile config.Profile, profileName string) error { @@ -168,18 +68,3 @@ func writeProfile(profile config.Profile, profileName string) error { } return config.WriteConfigToFile(fileConfig) } - -func openBrowser(url string) error { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "linux": - cmd = exec.Command("xdg-open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - default: - return fmt.Errorf("unsupported platform: %s", runtime.GOOS) - } - return cmd.Start() -} diff --git a/cmd/profile.go b/cmd/profile.go index 3efc30f..274332f 100644 --- a/cmd/profile.go +++ b/cmd/profile.go @@ -180,9 +180,32 @@ var RemoveProfileCmd = &cobra.Command{ return nil } + wasDefault := fileConfig.DefaultProfile == name delete(fileConfig.Profiles, name) - if len(fileConfig.Profiles) == 0 { - fileConfig.DefaultProfile = "" + + if wasDefault { + switch len(fileConfig.Profiles) { + case 0: + fileConfig.DefaultProfile = "" + case 1: + for k := range fileConfig.Profiles { + fileConfig.DefaultProfile = k + fmt.Printf("'%s' is now set as the default profile\n", k) + } + default: + fmt.Println("Select a new default profile:") + _m, err := tea.NewProgram(defaultprofile.New(fileConfig.Profiles)).Run() + if err != nil { + return fmt.Errorf("error selecting new default profile: %w", err) + } + m := _m.(defaultprofile.Model) + if m.Success { + fileConfig.DefaultProfile = m.Choice + fmt.Printf("'%s' is now set as the default profile\n", m.Choice) + } else { + fileConfig.DefaultProfile = "" + } + } } commandError := config.WriteConfigToFile(fileConfig) @@ -257,6 +280,53 @@ var DefaultProfileCmd = &cobra.Command{ }, } +var UpdateProfileCmd = &cobra.Command{ + Use: "update profile-name new-url", + Aliases: []string{"set-url"}, + Example: " pb profile update local http://localhost:9000", + Short: "Update the URL of an existing profile", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + startTime := time.Now() + + name := args[0] + rawURL := args[1] + + if _, err := url.Parse(rawURL); err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + fileConfig, err := config.ReadConfigFromFile() + if err != nil { + return fmt.Errorf("error reading config: %w", err) + } + + profile, exists := fileConfig.Profiles[name] + if !exists { + return fmt.Errorf("no profile found with the name: %s", name) + } + + profile.URL = rawURL + fileConfig.Profiles[name] = profile + + commandError := config.WriteConfigToFile(fileConfig) + cmd.Annotations["executionTime"] = time.Since(startTime).String() + if commandError != nil { + cmd.Annotations["error"] = commandError.Error() + return commandError + } + + if outputFormat == "json" { + return outputResult(profile) + } + fmt.Printf("Profile '%s' URL updated to %s\n", name, rawURL) + return nil + }, +} + var ListProfileCmd = &cobra.Command{ Use: "list profiles", Short: "List all added profiles", diff --git a/cmd/query.go b/cmd/query.go index a69716a..2993131 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -45,6 +45,7 @@ var ( defaultEnd = "now" outputFlag = "output" + saveAsName string ) var query = &cobra.Command{ @@ -97,6 +98,7 @@ var query = &cobra.Command{ } sqlQuery = quoteStreamNames(sqlQuery) + sqlQuery = quoteFieldsWithDots(sqlQuery) if interactive { startT, err := parseTimeStr(start) @@ -128,8 +130,17 @@ var query = &cobra.Command{ stopSpinner() if err != nil { command.Annotations["error"] = err.Error() + return err } - return err + + if saveAsName != "" { + if saveErr := saveFilter(&client, sqlQuery, saveAsName, start, end); saveErr != nil { + fmt.Fprintf(os.Stderr, "warning: could not save query: %v\n", saveErr) + } else { + fmt.Fprintf(os.Stderr, "Query saved as '%s'\n", saveAsName) + } + } + return nil }, } @@ -138,6 +149,7 @@ func init() { query.Flags().StringP(endFlag, endFlagShort, defaultEnd, "End time for query.") query.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format (text|json)") query.Flags().BoolP("interactive", "i", false, "Open interactive table view") + query.Flags().StringVar(&saveAsName, "save-as", "", "Save this query with a name for later use") } // parseTimeStr converts a CLI time string to time.Time. @@ -166,12 +178,14 @@ func parseTimeStr(s string) (time.Time, error) { func startSpinner() func() { frames := []string{"|", "/", "-", "\\"} done := make(chan struct{}) + stopped := make(chan struct{}) go func() { + defer close(stopped) i := 0 for { select { case <-done: - fmt.Fprint(os.Stderr, "\r\033[K") // clear the line + fmt.Fprint(os.Stderr, "\r\033[K") return default: fmt.Fprintf(os.Stderr, "\r%s fetching...", frames[i%len(frames)]) @@ -180,7 +194,10 @@ func startSpinner() func() { } } }() - return func() { close(done) } + return func() { + close(done) + <-stopped // wait for goroutine to clear the line before caller prints output + } } // fromClauseRe matches an unquoted identifier after FROM or JOIN. @@ -199,6 +216,84 @@ func quoteStreamNames(query string) string { }) } +// quoteFieldsWithDots wraps unquoted dotted identifiers in double quotes so +// DataFusion treats them as field names instead of table.column references. +// e.g. service.name → "service.name", http.status_code → "http.status_code" +// Already-quoted identifiers and string literals are left untouched. +func quoteFieldsWithDots(query string) string { + var result strings.Builder + i, n := 0, len(query) + for i < n { + ch := query[i] + switch ch { + case '\'': + result.WriteByte(ch) + i++ + for i < n { + c := query[i] + result.WriteByte(c) + i++ + if c == '\'' { + if i < n && query[i] == '\'' { // escaped '' inside string + result.WriteByte(query[i]) + i++ + } else { + break + } + } + } + case '"': + result.WriteByte(ch) + i++ + for i < n { + c := query[i] + result.WriteByte(c) + i++ + if c == '"' { + break + } + } + default: + if identStart(ch) { + j := i + 1 + for j < n && identChar(query[j]) { + j++ + } + // walk dot-separated segments: a.b.c + k, hasDot := j, false + for k < n && query[k] == '.' && k+1 < n && identChar(query[k+1]) { + hasDot = true + k++ + for k < n && identChar(query[k]) { + k++ + } + } + if hasDot { + result.WriteByte('"') + result.WriteString(query[i:k]) + result.WriteByte('"') + i = k + } else { + result.WriteString(query[i:j]) + i = j + } + } else { + result.WriteByte(ch) + i++ + } + } + } + return result.String() +} + +func identStart(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' +} + +func identChar(c byte) bool { + return identStart(c) || (c >= '0' && c <= '9') +} + var QueryCmd = query func fetchData(client *internalHTTP.HTTPClient, query string, startTime, endTime, outputFormat string) error { @@ -241,6 +336,79 @@ func fetchData(client *internalHTTP.HTTPClient, query string, startTime, endTime return nil } +func extractStreamName(query string) string { + re := regexp.MustCompile(`(?i)\bfrom\s+(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_-]*))`) + m := re.FindStringSubmatch(query) + if len(m) >= 3 { + if m[1] != "" { + return m[1] + } + return m[2] + } + return "" +} + +func saveFilter(client *internalHTTP.HTTPClient, sqlQuery, name, startTime, endTime string) error { + startT, err := parseTimeStr(startTime) + if err != nil { + return fmt.Errorf("invalid start time: %w", err) + } + endT, err := parseTimeStr(endTime) + if err != nil { + return fmt.Errorf("invalid end time: %w", err) + } + + q := sqlQuery + body, err := json.Marshal(struct { + StreamName string `json:"stream_name"` + FilterName string `json:"filter_name"` + UserID string `json:"user_id"` + Query struct { + FilterType string `json:"filter_type"` + FilterQuery *string `json:"filter_query"` + } `json:"query"` + TimeFilter struct { + From string `json:"from"` + To string `json:"to"` + } `json:"time_filter"` + }{ + StreamName: extractStreamName(sqlQuery), + FilterName: name, + UserID: DefaultProfile.Username, + Query: struct { + FilterType string `json:"filter_type"` + FilterQuery *string `json:"filter_query"` + }{FilterType: "sql", FilterQuery: &q}, + TimeFilter: struct { + From string `json:"from"` + To string `json:"to"` + }{ + From: startT.UTC().Format(time.RFC3339), + To: endT.UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return err + } + + req, err := client.NewRequest("POST", "filters", bytes.NewBuffer(body)) + if err != nil { + return err + } + + resp, err := client.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned %s: %s", resp.Status, strings.TrimSpace(string(b))) + } + return nil +} + // 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 { diff --git a/cmd/tail.go b/cmd/tail.go index 6889786..c973f3a 100644 --- a/cmd/tail.go +++ b/cmd/tail.go @@ -23,6 +23,7 @@ import ( "encoding/json" "fmt" "io" + "os" "pb/pkg/analytics" "pb/pkg/config" internalHTTP "pb/pkg/http" @@ -58,9 +59,10 @@ func tail(profile config.Profile, stream string) error { Stream: stream, }) - // get grpc url for this request + stopConnect := tailSpinner("connecting...") httpClient := internalHTTP.DefaultClient(&DefaultProfile) about, err := analytics.FetchAbout(&httpClient) + stopConnect() if err != nil { return err } @@ -73,6 +75,11 @@ func tail(profile config.Profile, stream string) error { authHeader := basicAuth(profile.Username, profile.Password) + watching := func() { + fmt.Fprintf(os.Stderr, "\r\033[K● watching %s... (ctrl+c to stop)", stream) + } + watching() + for { resp, err := flightClient.DoGet( metadata.NewOutgoingContext(context.Background(), metadata.New(map[string]string{ @@ -81,11 +88,13 @@ func tail(profile config.Profile, stream string) error { &flight.Ticket{Ticket: payload}, ) if err != nil { + fmt.Fprint(os.Stderr, "\r\033[K") return err } records, err := flight.NewRecordReader(resp) if err != nil { + fmt.Fprint(os.Stderr, "\r\033[K") return err } @@ -96,13 +105,16 @@ func tail(profile config.Profile, stream string) error { if isStreamEnd(err) { break } + fmt.Fprint(os.Stderr, "\r\033[K") return err } + fmt.Fprint(os.Stderr, "\r\033[K") // clear watching line before printing record var buf bytes.Buffer array.RecordToJSON(record, &buf) fmt.Println(buf.String()) } + watching() time.Sleep(500 * time.Millisecond) } } @@ -121,6 +133,31 @@ func isStreamEnd(err error) bool { return false } +func tailSpinner(msg string) func() { + frames := []string{"|", "/", "-", "\\"} + done := make(chan struct{}) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + i := 0 + for { + select { + case <-done: + fmt.Fprint(os.Stderr, "\r\033[K") + return + default: + fmt.Fprintf(os.Stderr, "\r%s %s", frames[i%len(frames)], msg) + i++ + time.Sleep(100 * time.Millisecond) + } + } + }() + return func() { + close(done) + <-stopped + } +} + func basicAuth(username, password string) string { auth := username + ":" + password return base64.StdEncoding.EncodeToString([]byte(auth)) diff --git a/main.go b/main.go index 2d0fe7e..2cef4cc 100644 --- a/main.go +++ b/main.go @@ -248,6 +248,7 @@ var uninstall = &cobra.Command{ func main() { profile.AddCommand(pb.AddProfileCmd) profile.AddCommand(pb.RemoveProfileCmd) + profile.AddCommand(pb.UpdateProfileCmd) profile.AddCommand(pb.ListProfileCmd) profile.AddCommand(pb.DefaultProfileCmd) diff --git a/pkg/config/config.go b/pkg/config/config.go index cf39cb5..be2fbc4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,6 +22,7 @@ import ( "net" "net/url" "os" + "runtime" path "path/filepath" toml "github.com/pelletier/go-toml/v2" @@ -29,14 +30,30 @@ import ( var ( configFilename = "config.toml" - configAppName = "parseable" + configAppName = "pb" ) -// Path returns user directory that can be used for the config file +// Path returns the config file path. +// On Windows: %AppData%\pb\config.toml +// On macOS/Linux: ~/.config/pb/config.toml (XDG style) func Path() (string, error) { - dir, err := os.UserConfigDir() - if err != nil { - return "", err + var dir string + if runtime.GOOS == "windows" { + appData, err := os.UserConfigDir() + if err != nil { + return "", err + } + dir = appData + } else { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + dir = xdg + } else { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir = path.Join(home, ".config") + } } return path.Join(dir, configAppName, configFilename), nil } diff --git a/pkg/model/login/login.go b/pkg/model/login/login.go new file mode 100644 index 0000000..0b26ef4 --- /dev/null +++ b/pkg/model/login/login.go @@ -0,0 +1,529 @@ +// Copyright (c) 2024 Parseable, Inc +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package login + +import ( + "strings" + + "pb/pkg/config" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type step int + +const ( + stepChooseType step = iota + stepCloudSoon + stepEnterURL + stepChooseAuth + stepEnterUsername + stepEnterPassword + stepEnterToken + stepEnterProfileName + stepConfirmReplace + stepDone +) + +var ( + primaryColor = lipgloss.AdaptiveColor{Light: "16", Dark: "226"} + normalColor = lipgloss.AdaptiveColor{Light: "235", Dark: "255"} + dimColor = lipgloss.AdaptiveColor{Light: "244", Dark: "240"} + successColor = lipgloss.AdaptiveColor{Light: "28", Dark: "82"} + errorColor = lipgloss.AdaptiveColor{Light: "196", Dark: "196"} + subtitleColor = lipgloss.AdaptiveColor{Light: "238", Dark: "248"} + + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) + selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(primaryColor) + normalStyle = lipgloss.NewStyle().Foreground(normalColor) + dimStyle = lipgloss.NewStyle().Foreground(dimColor) + successStyle = lipgloss.NewStyle().Bold(true).Foreground(successColor) + hintStyle = lipgloss.NewStyle().Foreground(dimColor) + errorStyle = lipgloss.NewStyle().Foreground(errorColor) + labelStyle = lipgloss.NewStyle().Foreground(subtitleColor) +) + +// Model is the BubbleTea model for the interactive login wizard. +type Model struct { + step step + typeIndex int // 0 = self-hosted, 1 = cloud + authIndex int // 0 = username+password, 1 = token + replaceIndex int // 0 = Replace, 1 = Change name + + urlInput textinput.Model + usernameInput textinput.Model + passwordInput textinput.Model + tokenInput textinput.Model + profileNameInput textinput.Model + + serverURL string + errMsg string + + // Result fields — set when Done is true. + Done bool + Profile config.Profile + Name string +} + +func newInput(placeholder string, charLimit int) textinput.Model { + t := textinput.New() + t.Placeholder = placeholder + t.CharLimit = charLimit + t.PromptStyle = lipgloss.NewStyle().Foreground(primaryColor) + t.TextStyle = lipgloss.NewStyle().Foreground(normalColor) + t.Cursor.Style = lipgloss.NewStyle().Foreground(primaryColor) + return t +} + +// New returns a fresh login wizard model. +func New() Model { + urlInput := newInput("http://localhost:8000", 256) + + usernameInput := newInput("admin", 64) + + passwordInput := newInput("password", 64) + passwordInput.EchoMode = textinput.EchoPassword + passwordInput.EchoCharacter = '•' + + tokenInput := newInput("paste token here", 512) + + profileInput := newInput("e.g. local, staging, prod", 64) + profileInput.SetValue("default") + + return Model{ + step: stepChooseType, + urlInput: urlInput, + usernameInput: usernameInput, + passwordInput: passwordInput, + tokenInput: tokenInput, + profileNameInput: profileInput, + } +} + +// Init starts the cursor blink. +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +// Update handles keyboard events and routes to the active text input. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if key.String() == "ctrl+c" { + return m, tea.Quit + } + + switch m.step { + + // ── Step 1: choose deployment type ────────────────────────────────── + case stepChooseType: + switch key.Type { + case tea.KeyUp: + if m.typeIndex > 0 { + m.typeIndex-- + } + case tea.KeyDown: + if m.typeIndex < 1 { + m.typeIndex++ + } + case tea.KeyEnter: + if m.typeIndex == 0 { + m.errMsg = "" + m.step = stepEnterURL + m.urlInput.Focus() + return m, textinput.Blink + } + m.step = stepCloudSoon + } + return m, nil + + // ── Coming-soon screen ─────────────────────────────────────────────── + case stepCloudSoon: + m.step = stepChooseType + return m, nil + + // ── Step 2: server URL ─────────────────────────────────────────────── + case stepEnterURL: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.step = stepChooseType + m.urlInput.Blur() + return m, nil + case tea.KeyEnter: + val := strings.TrimSpace(m.urlInput.Value()) + if val == "" { + m.errMsg = "Server URL is required" + return m, nil + } + m.serverURL = val + m.errMsg = "" + m.step = stepChooseAuth + m.urlInput.Blur() + return m, nil + } + + // ── Step 3: auth method ────────────────────────────────────────────── + case stepChooseAuth: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.step = stepEnterURL + m.urlInput.Focus() + return m, textinput.Blink + case tea.KeyUp: + if m.authIndex > 0 { + m.authIndex-- + } + return m, nil + case tea.KeyDown: + if m.authIndex < 1 { + m.authIndex++ + } + return m, nil + case tea.KeyEnter: + m.errMsg = "" + if m.authIndex == 0 { + m.step = stepEnterUsername + m.usernameInput.Focus() + } else { + m.step = stepEnterToken + m.tokenInput.Focus() + } + return m, textinput.Blink + } + return m, nil + + // ── Step 4a: username ──────────────────────────────────────────────── + case stepEnterUsername: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.step = stepChooseAuth + m.usernameInput.Blur() + return m, nil + case tea.KeyEnter: + if strings.TrimSpace(m.usernameInput.Value()) == "" { + m.errMsg = "Username is required" + return m, nil + } + m.errMsg = "" + m.step = stepEnterPassword + m.usernameInput.Blur() + m.passwordInput.Focus() + return m, textinput.Blink + } + + // ── Step 4b: password ──────────────────────────────────────────────── + case stepEnterPassword: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.step = stepEnterUsername + m.passwordInput.Blur() + m.usernameInput.Focus() + return m, textinput.Blink + case tea.KeyEnter: + if m.passwordInput.Value() == "" { + m.errMsg = "Password is required" + return m, nil + } + m.errMsg = "" + m.step = stepEnterProfileName + m.passwordInput.Blur() + m.profileNameInput.Focus() + return m, textinput.Blink + } + + // ── Step 4c: token ─────────────────────────────────────────────────── + case stepEnterToken: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.step = stepChooseAuth + m.tokenInput.Blur() + return m, nil + case tea.KeyEnter: + if strings.TrimSpace(m.tokenInput.Value()) == "" { + m.errMsg = "Token is required" + return m, nil + } + m.errMsg = "" + m.step = stepEnterProfileName + m.tokenInput.Blur() + m.profileNameInput.Focus() + return m, textinput.Blink + } + + // ── Step 5: profile name ───────────────────────────────────────────── + case stepEnterProfileName: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.profileNameInput.Blur() + if m.authIndex == 0 { + m.step = stepEnterPassword + m.passwordInput.Focus() + } else { + m.step = stepEnterToken + m.tokenInput.Focus() + } + return m, textinput.Blink + case tea.KeyEnter: + val := strings.TrimSpace(m.profileNameInput.Value()) + if val == "" { + m.errMsg = "Profile name is required" + return m, nil + } + m.errMsg = "" + // Check if the profile name already exists. + if existing, err := config.ReadConfigFromFile(); err == nil { + if _, exists := existing.Profiles[val]; exists { + m.Name = val + m.replaceIndex = 0 + m.step = stepConfirmReplace + m.profileNameInput.Blur() + return m, nil + } + } + return m.finalize(val) + } + + case stepConfirmReplace: + switch key.Type { + case tea.KeyEsc: + m.errMsg = "" + m.step = stepEnterProfileName + m.profileNameInput.Focus() + return m, textinput.Blink + case tea.KeyUp: + if m.replaceIndex > 0 { + m.replaceIndex-- + } + return m, nil + case tea.KeyDown: + if m.replaceIndex < 1 { + m.replaceIndex++ + } + return m, nil + case tea.KeyEnter: + if m.replaceIndex == 1 { + // Change name — go back to profile name input. + m.errMsg = "" + m.step = stepEnterProfileName + m.profileNameInput.Focus() + return m, textinput.Blink + } + // Replace — proceed with save. + return m.finalize(m.Name) + } + return m, nil + } + } + + // Forward all other messages (character input, blink ticks) to active input. + var cmd tea.Cmd + switch m.step { + case stepEnterURL: + m.urlInput, cmd = m.urlInput.Update(msg) + case stepEnterUsername: + m.usernameInput, cmd = m.usernameInput.Update(msg) + case stepEnterPassword: + m.passwordInput, cmd = m.passwordInput.Update(msg) + case stepEnterToken: + m.tokenInput, cmd = m.tokenInput.Update(msg) + case stepEnterProfileName: + m.profileNameInput, cmd = m.profileNameInput.Update(msg) + } + return m, cmd +} + +func (m Model) finalize(name string) (tea.Model, tea.Cmd) { + m.Name = name + if m.authIndex == 0 { + m.Profile = config.Profile{ + URL: m.serverURL, + Username: strings.TrimSpace(m.usernameInput.Value()), + Password: m.passwordInput.Value(), + } + } else { + m.Profile = config.Profile{ + URL: m.serverURL, + Token: strings.TrimSpace(m.tokenInput.Value()), + } + } + m.Done = true + m.step = stepDone + return m, tea.Quit +} + +func sep() string { + return dimStyle.Render(strings.Repeat("─", 44)) +} + +func breadcrumb(trail string) string { + return dimStyle.Render(" "+trail+" ›") + " " +} + +// View renders the current wizard step. +func (m Model) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(titleStyle.Render(" Parseable Login")) + b.WriteString("\n") + b.WriteString(sep()) + b.WriteString("\n\n") + + switch m.step { + + case stepChooseType: + b.WriteString(dimStyle.Render(" How would you like to connect?")) + b.WriteString("\n\n") + entries := []struct{ label, badge string }{ + {"Self-hosted", ""}, + {"Parseable Cloud", " (coming soon)"}, + } + for i, e := range entries { + if i == m.typeIndex { + b.WriteString(selectedStyle.Render(" ❯ " + e.label)) + b.WriteString(dimStyle.Render(e.badge)) + } else { + b.WriteString(normalStyle.Render(" " + e.label)) + b.WriteString(dimStyle.Render(e.badge)) + } + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(hintStyle.Render(" ↑↓ navigate · Enter select · Ctrl+C quit")) + + case stepCloudSoon: + b.WriteString(selectedStyle.Render(" Parseable Cloud")) + b.WriteString("\n\n") + b.WriteString(normalStyle.Render(" We're working on it!")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Cloud login is coming soon. Stay tuned for updates.")) + b.WriteString("\n\n") + b.WriteString(hintStyle.Render(" Press any key to go back")) + + case stepEnterURL: + b.WriteString(breadcrumb("Self-hosted")) + b.WriteString(labelStyle.Render("Server URL")) + b.WriteString("\n\n ") + b.WriteString(m.urlInput.View()) + b.WriteString("\n\n") + b.WriteString(renderErr(m.errMsg)) + b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + + case stepChooseAuth: + b.WriteString(breadcrumb("Self-hosted")) + b.WriteString(labelStyle.Render("Authentication")) + b.WriteString("\n\n") + authEntries := []string{"Username & Password", "Token"} + for i, entry := range authEntries { + if i == m.authIndex { + b.WriteString(selectedStyle.Render(" ❯ " + entry)) + } else { + b.WriteString(normalStyle.Render(" " + entry)) + } + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(hintStyle.Render(" Esc back · ↑↓ navigate · Enter select")) + + case stepEnterUsername: + b.WriteString(breadcrumb("Self-hosted")) + b.WriteString(labelStyle.Render("Username")) + b.WriteString("\n\n ") + b.WriteString(m.usernameInput.View()) + b.WriteString("\n\n") + b.WriteString(renderErr(m.errMsg)) + b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + + case stepEnterPassword: + b.WriteString(breadcrumb("Self-hosted")) + b.WriteString(labelStyle.Render("Password")) + b.WriteString("\n\n ") + b.WriteString(m.passwordInput.View()) + b.WriteString("\n\n") + b.WriteString(renderErr(m.errMsg)) + b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + + case stepEnterToken: + b.WriteString(breadcrumb("Self-hosted")) + b.WriteString(labelStyle.Render("Token")) + b.WriteString("\n\n ") + b.WriteString(m.tokenInput.View()) + b.WriteString("\n\n") + b.WriteString(renderErr(m.errMsg)) + b.WriteString(hintStyle.Render(" Esc back · Enter continue")) + + case stepEnterProfileName: + b.WriteString(labelStyle.Render(" Profile name")) + b.WriteString("\n\n ") + b.WriteString(m.profileNameInput.View()) + b.WriteString("\n\n") + b.WriteString(renderErr(m.errMsg)) + b.WriteString(hintStyle.Render(" Esc back · Enter save")) + + case stepConfirmReplace: + b.WriteString(errorStyle.Render(" Profile '" + m.Name + "' already exists")) + b.WriteString("\n\n") + entries := []string{"Replace it", "Change name"} + for i, e := range entries { + if i == m.replaceIndex { + b.WriteString(selectedStyle.Render(" ❯ " + e)) + } else { + b.WriteString(normalStyle.Render(" " + e)) + } + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(hintStyle.Render(" Esc back · ↑↓ navigate · Enter select")) + + case stepDone: + b.WriteString(successStyle.Render(" ✓ Profile '" + m.Name + "' saved")) + b.WriteString("\n\n") + b.WriteString(labelStyle.Render(" URL: ")) + b.WriteString(normalStyle.Render(m.Profile.URL)) + b.WriteString("\n") + if m.Profile.Username != "" { + b.WriteString(labelStyle.Render(" User: ")) + b.WriteString(normalStyle.Render(m.Profile.Username)) + b.WriteString("\n") + } + if m.Profile.Token != "" { + b.WriteString(labelStyle.Render(" Auth: ")) + b.WriteString(normalStyle.Render("token (stored)")) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(" To add more profiles:")) + b.WriteString("\n") + b.WriteString(hintStyle.Render(" pb profile add [user] [pass]")) + } + + b.WriteString("\n\n") + return b.String() +} + +func renderErr(msg string) string { + if msg == "" { + return "" + } + return errorStyle.Render(" ✗ "+msg) + "\n\n" +} diff --git a/pkg/model/query.go b/pkg/model/query.go index 4f892aa..de55add 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -20,6 +20,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "math" "net/http" "net/url" @@ -106,6 +107,7 @@ type FetchData struct { status FetchResult schema []string data []map[string]interface{} + errMsg string } const ( @@ -133,6 +135,7 @@ type QueryModel struct { overlay uint focused int dataRows []table.Row // actual data rows (without padding) + fetchErrMsg string // last fetch error, shown in the result area } func (m *QueryModel) focusSelected() { @@ -251,10 +254,17 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false m.status.Info = "" if msg.status == fetchOk { + m.fetchErrMsg = "" m.UpdateTable(msg) m.status.Error = "" m.status.Info = fmt.Sprintf("%d rows", len(m.dataRows)) } else { + m.dataRows = []table.Row{} + m.table = m.table.WithRows([]table.Row{}) + m.fetchErrMsg = msg.errMsg + if m.fetchErrMsg == "" { + m.fetchErrMsg = "query failed" + } m.status.Error = "query failed" } return m, nil @@ -276,6 +286,15 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusSelected() return m, nil } + + if msg.Type == tea.KeyShiftTab { + m.focused-- + if m.focused < 0 { + m.focused = len(QueryNavigationMap) - 1 + } + m.focusSelected() + return m, nil + } } // special behavior on time input page @@ -405,10 +424,32 @@ func (m QueryModel) View() string { m.table = m.table.WithPageSize(pageSize).WithRows(displayRows) // Step 4: compose main view. + var resultPane string + if m.fetchErrMsg != "" && !m.loading { + // Render with width constraint so the long error string wraps, + // then clip to tableAvail lines so the header stays in place. + errStyle := lipgloss.NewStyle(). + Padding(1, 2). + Foreground(lipgloss.AdaptiveColor{Light: "#9B2226", Dark: "#FF6B6B"}). + Width(m.width - 6) + rendered := errStyle.Render(m.fetchErrMsg) + lines := strings.Split(rendered, "\n") + maxLines := tableAvail - 2 + if maxLines < 1 { + maxLines = 1 + } + if len(lines) > maxLines { + lines = lines[:maxLines] + } + resultPane = tableOuter.Render(strings.Join(lines, "\n")) + } else { + resultPane = tableOuter.Render(m.table.View()) + } + var mainView string switch m.overlay { case overlayNone: - mainView = lipgloss.JoinVertical(lipgloss.Left, header, tableOuter.Render(m.table.View())) + mainView = lipgloss.JoinVertical(lipgloss.Left, header, resultPane) case overlayInputs: mainView = m.timeRange.View() } @@ -447,12 +488,14 @@ func NewFetchTask(profile config.Profile, query string, startTime string, endTim Timeout: time.Second * 50, } - data, status := fetchData(client, &profile, query, startTime, endTime) + data, status, errMsg := fetchData(client, &profile, query, startTime, endTime) if status == fetchOk { res.data = data.Records res.schema = data.Fields res.status = fetchOk + } else { + res.errMsg = errMsg } return res @@ -499,7 +542,7 @@ func IteratorPrev(iter *iterator.QueryIterator[QueryData, FetchResult]) tea.Cmd } } -func fetchData(client *http.Client, profile *config.Profile, query string, startTime string, endTime string) (data QueryData, res FetchResult) { +func fetchData(client *http.Client, profile *config.Profile, query string, startTime string, endTime string) (data QueryData, res FetchResult, errMsg string) { data = QueryData{} res = fetchErr @@ -509,6 +552,7 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start "endTime": endTime, }) if err != nil { + errMsg = err.Error() return } @@ -516,6 +560,7 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start endpoint += "?fields=true" req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) if err != nil { + errMsg = err.Error() return } if profile.Token != "" { @@ -526,16 +571,23 @@ func fetchData(client *http.Client, profile *config.Profile, query string, start req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { + errMsg = err.Error() return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + errMsg = strings.TrimSpace(string(b)) + if errMsg == "" { + errMsg = resp.Status + } return } err = json.NewDecoder(resp.Body).Decode(&data) if err != nil { + errMsg = err.Error() return } diff --git a/pkg/model/textareakeymap.go b/pkg/model/textareakeymap.go index eb7076c..55652be 100644 --- a/pkg/model/textareakeymap.go +++ b/pkg/model/textareakeymap.go @@ -34,11 +34,11 @@ func (k TextAreaHelpKeys) ShortHelp() []key.Binding { func (k TextAreaHelpKeys) FullHelp() [][]key.Binding { t := textAreaKeyMap return [][]key.Binding{ - {t.CharacterForward, t.CharacterBackward}, // first column + {t.CharacterForward, t.CharacterBackward}, {t.WordForward, t.WordBackward}, - {t.DeleteWordForward, t.DeleteWordBackward}, - {t.DeleteCharacterForward, t.DeleteCharacterBackward}, - {t.LineStart, t.LineEnd}, // second column + {nextPanel, prevPanel}, +// {t.DeleteCharacterForward, t.DeleteCharacterBackward}, + {t.LineStart, t.LineEnd}, {runQueryKey, exit}, } } @@ -53,6 +53,16 @@ var exit = key.NewBinding( key.WithHelp("ctrl+c", "exit"), ) +var nextPanel = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next panel"), +) + +var prevPanel = key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev panel"), +) + var textAreaKeyMap = textarea.KeyMap{ CharacterForward: key.NewBinding( key.WithKeys("right", "ctrl+f"), From 868ab256d7b9361d994dd7a79ead5eafa98f5000 Mon Sep 17 00:00:00 2001 From: Pratik Date: Wed, 13 May 2026 21:25:45 +0530 Subject: [PATCH 5/7] fix: lint check pass --- pkg/config/config.go | 2 +- pkg/model/query.go | 2 +- pkg/model/textareakeymap.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index be2fbc4..168d5e9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,8 +22,8 @@ import ( "net" "net/url" "os" - "runtime" path "path/filepath" + "runtime" toml "github.com/pelletier/go-toml/v2" ) diff --git a/pkg/model/query.go b/pkg/model/query.go index de55add..56855a2 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -25,9 +25,9 @@ import ( "net/http" "net/url" "os" - "strings" "pb/pkg/config" "pb/pkg/iterator" + "strings" "time" "github.com/charmbracelet/bubbles/help" diff --git a/pkg/model/textareakeymap.go b/pkg/model/textareakeymap.go index 55652be..dfa7c5d 100644 --- a/pkg/model/textareakeymap.go +++ b/pkg/model/textareakeymap.go @@ -37,7 +37,7 @@ func (k TextAreaHelpKeys) FullHelp() [][]key.Binding { {t.CharacterForward, t.CharacterBackward}, {t.WordForward, t.WordBackward}, {nextPanel, prevPanel}, -// {t.DeleteCharacterForward, t.DeleteCharacterBackward}, + // {t.DeleteCharacterForward, t.DeleteCharacterBackward}, {t.LineStart, t.LineEnd}, {runQueryKey, exit}, } From bea89bde90063282fbab2ae29d19096ae9881d38 Mon Sep 17 00:00:00 2001 From: Pratik Date: Thu, 14 May 2026 12:02:29 +0530 Subject: [PATCH 6/7] feat: added single -i cmd and modified time range --- cmd/query.go | 20 ++++++++++------- pkg/model/query.go | 51 +++++++++++++++++++++++++++++++++++++++--- pkg/model/timerange.go | 20 +++++++---------- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/cmd/query.go b/cmd/query.go index 2993131..133716f 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -66,13 +66,23 @@ var query = &cobra.Command{ command.Annotations["executionTime"] = duration.String() }() - if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + interactive, err := command.Flags().GetBool("interactive") + if err != nil { + command.Annotations["error"] = err.Error() + return err + } + + if (len(args) == 0 || strings.TrimSpace(args[0]) == "") && !interactive { fmt.Println("Please enter your query") fmt.Printf("Example:\n pb query run \"select * from frontend\" --from=10m --to=now\n") return nil } - sqlQuery := args[0] + var sqlQuery string + if len(args) > 0 { + sqlQuery = args[0] + } + start, err := command.Flags().GetString(startFlag) if err != nil { command.Annotations["error"] = err.Error() @@ -91,12 +101,6 @@ var query = &cobra.Command{ end = defaultEnd } - interactive, err := command.Flags().GetBool("interactive") - if err != nil { - command.Annotations["error"] = err.Error() - return err - } - sqlQuery = quoteStreamNames(sqlQuery) sqlQuery = quoteFieldsWithDots(sqlQuery) diff --git a/pkg/model/query.go b/pkg/model/query.go index 56855a2..0375a61 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -131,6 +131,7 @@ type QueryModel struct { status StatusBar spinner spinner.Model loading bool + hasQueried bool // true once the first query has been dispatched queryIterator *iterator.QueryIterator[QueryData, FetchResult] overlay uint focused int @@ -192,6 +193,7 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t query.SetWidth(70) query.ShowLineNumbers = true query.SetValue(queryStr) + query.Placeholder = "write your SQL query here..." query.KeyMap = textAreaKeyMap query.Focus() @@ -204,6 +206,7 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t sp.Spinner = spinner.Line sp.Style = lipgloss.NewStyle().Foreground(FocusPrimary) + hasQuery := strings.TrimSpace(queryStr) != "" model := QueryModel{ width: w, height: h, @@ -214,7 +217,8 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t profile: profile, help: help, spinner: sp, - loading: true, + loading: hasQuery, + hasQueried: hasQuery, queryIterator: nil, status: status, } @@ -222,6 +226,9 @@ func NewQueryModel(profile config.Profile, queryStr string, startTime, endTime t } func (m QueryModel) Init() tea.Cmd { + if strings.TrimSpace(m.query.Value()) == "" { + return m.spinner.Tick + } return tea.Batch( m.spinner.Tick, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc()), @@ -305,6 +312,7 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status.Error = "" m.status.Info = "" m.loading = true + m.hasQueried = true return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) } } @@ -315,6 +323,7 @@ func (m QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status.Error = "" m.status.Info = "" m.loading = true + m.hasQueried = true return m, tea.Batch(m.spinner.Tick, NewFetchTask(m.profile, m.query.Value(), m.timeRange.StartValueUtc(), m.timeRange.EndValueUtc())) } @@ -380,7 +389,7 @@ func (m QueryModel) View() string { headerHeight := lipgloss.Height(header) if m.loading { - m.status.Info = m.spinner.View() + " fetching..." + m.status.Info = "" m.status.Error = "" } statusView := m.status.View() @@ -424,8 +433,44 @@ func (m QueryModel) View() string { m.table = m.table.WithPageSize(pageSize).WithRows(displayRows) // Step 4: compose main view. + availW := m.width - 6 + if availW < 0 { + availW = 0 + } + availH := tableAvail - 2 + if availH < 0 { + availH = 0 + } + var resultPane string - if m.fetchErrMsg != "" && !m.loading { + if !m.hasQueried { + // Welcome / empty state — no query has been run yet. + logoStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(FocusPrimary). + Border(lipgloss.DoubleBorder()). + BorderForeground(FocusSecondary). + Padding(0, 2) + hintStyle := lipgloss.NewStyle(). + Foreground(StandardSecondary). + MarginTop(1) + keyStyle := lipgloss.NewStyle(). + Foreground(FocusPrimary). + Bold(true) + + logo := logoStyle.Render("P A R S E A B L E") + hint := hintStyle.Render("write your SQL query above and press " + keyStyle.Render("ctrl+r") + " to run") + content := lipgloss.JoinVertical(lipgloss.Center, logo, hint) + placed := lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) + resultPane = tableOuter.Render(placed) + } else if m.loading { + // Query dispatched — show spinner centred in the result area. + spinStyle := lipgloss.NewStyle(). + Foreground(FocusPrimary) + content := spinStyle.Render(m.spinner.View() + " fetching...") + placed := lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) + resultPane = tableOuter.Render(placed) + } else if m.fetchErrMsg != "" { // Render with width constraint so the long error string wraps, // then clip to tableAvail lines so the header stays in place. errStyle := lipgloss.NewStyle(). diff --git a/pkg/model/timerange.go b/pkg/model/timerange.go index 2a394fb..86e386c 100644 --- a/pkg/model/timerange.go +++ b/pkg/model/timerange.go @@ -29,26 +29,22 @@ import ( // Items for time range const ( - TenMinute = -10 * time.Minute - TwentyMinute = -20 * time.Minute - ThirtyMinute = -30 * time.Minute - OneHour = -1 * time.Hour - ThreeHour = -3 * time.Hour - OneDay = -24 * time.Hour - ThreeDay = -72 * time.Hour - OneWeek = -168 * time.Hour + TenMinute = -10 * time.Minute + OneHour = -1 * time.Hour + FiveHour = -5 * time.Hour + OneDay = -24 * time.Hour + ThreeDay = -72 * time.Hour + OneWeek = -168 * time.Hour ) var ( timeDurations = []list.Item{ timeDurationItem{duration: TenMinute, repr: "10 Minutes"}, - timeDurationItem{duration: TwentyMinute, repr: "20 Minutes"}, - timeDurationItem{duration: ThirtyMinute, repr: "30 Minutes"}, timeDurationItem{duration: OneHour, repr: "1 Hour"}, - timeDurationItem{duration: ThreeHour, repr: "3 Hours"}, + timeDurationItem{duration: FiveHour, repr: "5 Hours"}, timeDurationItem{duration: OneDay, repr: "1 Day"}, timeDurationItem{duration: ThreeDay, repr: "3 Days"}, - timeDurationItem{duration: OneWeek, repr: "1 Week"}, + timeDurationItem{duration: OneWeek, repr: "7 Days"}, } listItemRender = lipgloss.NewStyle().Foreground(StandardSecondary) From cda04d31c40dd1c3dd4e3295f63ea4d61f181beb Mon Sep 17 00:00:00 2001 From: Pratik Date: Thu, 14 May 2026 12:06:00 +0530 Subject: [PATCH 7/7] lint ci fix --- pkg/model/query.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/model/query.go b/pkg/model/query.go index 0375a61..cea57f4 100644 --- a/pkg/model/query.go +++ b/pkg/model/query.go @@ -464,7 +464,7 @@ func (m QueryModel) View() string { placed := lipgloss.Place(availW, availH, lipgloss.Center, lipgloss.Center, content) resultPane = tableOuter.Render(placed) } else if m.loading { - // Query dispatched — show spinner centred in the result area. + // Query dispatched — show spinner centered in the result area. spinStyle := lipgloss.NewStyle(). Foreground(FocusPrimary) content := spinStyle.Render(m.spinner.View() + " fetching...")